package backup import ( "encoding/json" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // ── R-331 — the hub must be able to tell "empty repository" from "never measured" ──────────────── // // THE DEFECT THESE PIN. The hub's operator Backup card read `Snapshots 0 · Repo Size 0 MB` for EVERY // customer. Measured on `demo-hp` 2026-08-30: the card said 0 while the box's own settings held // `snapshot_count: 67, repo_size_bytes: 140829678, stats_known: true` and that night's log said // `[offbox] backup OK: 8 app(s) backed up, 67 snapshot(s)`. // // The card rendered the report's `backup` object, whose `snapshot_count` / `repo_size_mb` / // `integrity_ok` fields have had NO producer since disk-tier restic moved to the host agent (slice // 8C) — `buildBackupReport` says so in a comment and leaves them zero. The live numbers were in the // report all along, in the `offsite` object, which the hub's own OffsiteChecker already reads. // // So the hub fix is to render `offsite`. That makes ONE field mandatory that was not being forwarded, // and these tests are why: **zero is what an unread repository and a genuinely empty one both look // like, and they are opposite news.** R-225 already measured that confusion inside the controller — // a rebuilt box rendered „Tároló méret · 0 pillanatkép" over a store that really held snapshot // `f3d9cd67` — and `StatsKnown` is what fixed it there. Not forwarding it left the hub free to make // the identical mistake one layer up. // RED-PROOF (run 2026-08-30, recorded in REPORT.md): drop `StatsKnown: t.StatsKnown` from // OffboxReportStatus() → this fails with "OffboxReportStatus dropped StatsKnown". func TestOffboxReportStatus_CarriesStatsKnown(t *testing.T) { m := &Manager{settings: newTestSettings(t)} if err := m.settings.SetOffboxTarget(&settings.OffboxTarget{ Enabled: true, Host: "nas.example", User: "u1", RepoPath: "/vol/repo", EscrowState: "escrowed", LastStatus: "ok", LastSuccess: "2026-08-30T02:17:19Z", SnapshotCount: 67, RepoSizeBytes: 140829678, StatsKnown: true, }); err != nil { t.Fatalf("seed: %v", err) } got := m.OffboxReportStatus() if got == nil { t.Fatal("OffboxReportStatus returned nil for an enabled target") } if !got.StatsKnown { t.Error("OffboxReportStatus dropped StatsKnown — the hub cannot then tell an empty " + "repository from an unmeasured one, and will render a real backup as `Snapshots 0`") } // The counts must ride along with it, or "known" is a claim about nothing. if got.SnapshotCount != 67 || got.RepoSizeBytes != 140829678 { t.Errorf("counts = %d snapshots / %d bytes, want 67 / 140829678 (demo-hp's real values)", got.SnapshotCount, got.RepoSizeBytes) } } // A repository that was MEASURED and really is empty must be distinguishable on the wire from one // nobody has measured. This is the whole point of the field, so it is asserted on the JSON rather // than on the struct: the hub sees bytes, not Go values. func TestOffboxReportStatus_MeasuredEmptyIsNotUnmeasured(t *testing.T) { encode := func(t *testing.T, tgt *settings.OffboxTarget) map[string]any { t.Helper() m := &Manager{settings: newTestSettings(t)} if err := m.settings.SetOffboxTarget(tgt); err != nil { t.Fatalf("seed: %v", err) } b, err := json.Marshal(m.OffboxReportStatus()) if err != nil { t.Fatalf("marshal: %v", err) } var out map[string]any if err := json.Unmarshal(b, &out); err != nil { t.Fatalf("unmarshal: %v", err) } return out } base := func() *settings.OffboxTarget { return &settings.OffboxTarget{ Enabled: true, Host: "nas.example", User: "u1", RepoPath: "/vol/repo", EscrowState: "escrowed", LastStatus: "ok", } } measuredEmpty := base() measuredEmpty.StatsKnown = true // read the repo, it really holds nothing unmeasured := base() // never read me, um := encode(t, measuredEmpty), encode(t, unmeasured) if me["snapshot_count"] != float64(0) || um["snapshot_count"] != float64(0) { t.Fatalf("both cases must carry snapshot_count 0 — that is the premise of the whole field "+ "(measured=%v unmeasured=%v)", me["snapshot_count"], um["snapshot_count"]) } if me["stats_known"] != true { t.Errorf("a MEASURED empty repository reported stats_known=%v — the hub will render it as "+ "unknown and the operator never learns the repository really is empty", me["stats_known"]) } if _, present := um["stats_known"]; present { t.Errorf("an UNMEASURED repository emitted stats_known=%v; it must be ABSENT so a hub reading "+ "it as false degrades to \"unknown\"", um["stats_known"]) } } // Fail-safe direction, stated as a test because a comment claiming it is a wish. A hub parsing a // pre-v0.225.0 report sees no `stats_known` key at all, which unmarshals to false — and false MUST // mean "cannot answer", never "the answer is zero". Absence is ignorance, not emptiness. func TestOffboxReportStatus_AbsentStatsKnownParsesAsUnknown(t *testing.T) { const oldControllerReport = `{"enabled":true,"escrow_state":"escrowed",` + `"last_status":"ok","snapshot_count":0,"repo_size_bytes":0,"quota_gb":50}` var got OffboxReportStatus if err := json.Unmarshal([]byte(oldControllerReport), &got); err != nil { t.Fatalf("unmarshal: %v", err) } if got.StatsKnown { t.Fatal("a pre-v0.225.0 report parsed as stats_known=true — the hub would present an " + "unmeasured repository as a confirmed-empty one") } if !got.Enabled { t.Fatal("the rest of the old report stopped parsing — adding the field broke compatibility") } }