package api import ( "encoding/json" "testing" "time" ) func TestEnvelopeRoundTrip(t *testing.T) { t.Parallel() hello := HelloPayload{ ProtocolVersion: CurrentProtocolVersion, AgentVersion: "0.1.0", ResticVersion: "0.17.1", Hostname: "test-host", OS: OSLinux, Arch: ArchAmd64, } env, err := Marshal(MsgHello, "", hello) if err != nil { t.Fatalf("marshal: %v", err) } wire, err := json.Marshal(env) if err != nil { t.Fatalf("marshal envelope: %v", err) } var decoded Envelope if err := json.Unmarshal(wire, &decoded); err != nil { t.Fatalf("unmarshal envelope: %v", err) } if decoded.Type != MsgHello { t.Errorf("type: got %q want %q", decoded.Type, MsgHello) } var got HelloPayload if err := decoded.UnmarshalPayload(&got); err != nil { t.Fatalf("unmarshal payload: %v", err) } if got != hello { t.Errorf("round-trip mismatch: %+v != %+v", got, hello) } } func TestEnvelopeNilPayload(t *testing.T) { t.Parallel() env, err := Marshal(MsgHeartbeat, "", nil) if err != nil { t.Fatalf("marshal: %v", err) } if len(env.Payload) != 0 { t.Errorf("nil payload should encode as empty, got %q", env.Payload) } // Unmarshalling nothing into anything must not error. var hb HeartbeatPayload if err := env.UnmarshalPayload(&hb); err != nil { t.Errorf("unmarshal empty payload: %v", err) } } func TestEnvelopeRPCCorrelation(t *testing.T) { t.Parallel() cmd := CommandRunPayload{JobID: "01HJ8K7", Kind: JobBackup} env, err := Marshal(MsgCommandRun, "req-1", cmd) if err != nil { t.Fatalf("marshal: %v", err) } if env.ID != "req-1" { t.Errorf("id not preserved: %q", env.ID) } res := CommandResultPayload{JobID: "01HJ8K7", Accepted: true} resEnv, err := Marshal(MsgCommandResult, env.ID, res) if err != nil { t.Fatalf("marshal result: %v", err) } if resEnv.ID != env.ID { t.Errorf("rpc id mismatch: req=%q res=%q", env.ID, resEnv.ID) } } func TestErrorPayload(t *testing.T) { t.Parallel() ep := ErrorPayload{ Code: ErrProtocolTooOld, Message: "agent protocol_version 0 below minimum 1", HelpURL: "https://example.com/upgrade", } env, err := Marshal(MsgError, "", ep) if err != nil { t.Fatalf("marshal: %v", err) } wire, _ := json.Marshal(env) var decoded Envelope if err := json.Unmarshal(wire, &decoded); err != nil { t.Fatalf("unmarshal: %v", err) } var got ErrorPayload if err := decoded.UnmarshalPayload(&got); err != nil { t.Fatalf("unmarshal payload: %v", err) } if got.Code != ErrProtocolTooOld { t.Errorf("code: got %q want %q", got.Code, ErrProtocolTooOld) } } func TestProtocolVersionConstants(t *testing.T) { t.Parallel() if CurrentProtocolVersion < 1 { t.Errorf("CurrentProtocolVersion must be >= 1, got %d", CurrentProtocolVersion) } if MinAgentProtocolVersion > CurrentProtocolVersion { t.Errorf("min %d > current %d — server would refuse all agents", MinAgentProtocolVersion, CurrentProtocolVersion) } } func TestJobProgressShapeStable(t *testing.T) { t.Parallel() // Locks the JSON field names from spec.md §6.2 so a rename here // breaks tests instead of silently breaking the agent. p := JobProgressPayload{ JobID: "j", PercentDone: 0.5, FilesDone: 1, TotalFiles: 2, BytesDone: 100, TotalBytes: 200, ETASeconds: 30, ThroughputBps: 1000, } raw, _ := json.Marshal(p) want := `{"job_id":"j","percent_done":0.5,"files_done":1,"total_files":2,"bytes_done":100,"total_bytes":200,"eta_seconds":30,"throughput_bps":1000}` if string(raw) != want { t.Errorf("wire shape drifted:\n got %s\n want %s", raw, want) } } func TestRepoStatsPayloadRoundTrip(t *testing.T) { t.Parallel() // Nil pointer fields must be omitted from JSON output. empty := RepoStatsPayload{} raw, err := json.Marshal(empty) if err != nil { t.Fatalf("marshal empty: %v", err) } if string(raw) != "{}" { t.Errorf("empty payload should marshal to {}, got %s", raw) } // Populated fields must survive a round trip. total := int64(123456) rawSize := int64(200000) files := int64(42) snaps := int64(7) lockPresent := true now := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) pruneAt := time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC) freed := int64(8192) p := RepoStatsPayload{ TotalSizeBytes: &total, RawSizeBytes: &rawSize, UniqueFiles: &files, SnapshotCount: &snaps, LastCheckAt: &now, LastCheckStatus: "ok", LockPresent: &lockPresent, LastPruneAt: &pruneAt, LastPruneFreedBytes: &freed, } raw2, err := json.Marshal(p) if err != nil { t.Fatalf("marshal full: %v", err) } var got RepoStatsPayload if err := json.Unmarshal(raw2, &got); err != nil { t.Fatalf("unmarshal: %v", err) } if got.TotalSizeBytes == nil || *got.TotalSizeBytes != total { t.Errorf("TotalSizeBytes: got %v, want %d", got.TotalSizeBytes, total) } if got.RawSizeBytes == nil || *got.RawSizeBytes != rawSize { t.Errorf("RawSizeBytes: got %v, want %d", got.RawSizeBytes, rawSize) } if got.UniqueFiles == nil || *got.UniqueFiles != files { t.Errorf("UniqueFiles: got %v, want %d", got.UniqueFiles, files) } if got.SnapshotCount == nil || *got.SnapshotCount != snaps { t.Errorf("SnapshotCount: got %v, want %d", got.SnapshotCount, snaps) } if got.LastCheckAt == nil || !got.LastCheckAt.Equal(now) { t.Errorf("LastCheckAt: got %v, want %v", got.LastCheckAt, now) } if got.LastCheckStatus != "ok" { t.Errorf("LastCheckStatus: got %q, want %q", got.LastCheckStatus, "ok") } if got.LockPresent == nil || *got.LockPresent != lockPresent { t.Errorf("LockPresent: got %v, want %v", got.LockPresent, lockPresent) } if got.LastPruneAt == nil || !got.LastPruneAt.Equal(pruneAt) { t.Errorf("LastPruneAt: got %v, want %v", got.LastPruneAt, pruneAt) } if got.LastPruneFreedBytes == nil || *got.LastPruneFreedBytes != freed { t.Errorf("LastPruneFreedBytes: got %v, want %d", got.LastPruneFreedBytes, freed) } // Partial update: only set LockPresent. lockFalse := false partial := RepoStatsPayload{LockPresent: &lockFalse} rawPartial, _ := json.Marshal(partial) if string(rawPartial) != `{"lock_present":false}` { t.Errorf("partial marshal: got %s, want {\"lock_present\":false}", rawPartial) } } // touch time so the import is used by other tests in this file when // they grow over time. var _ = time.Now