package backup import ( "errors" "io" "log" "path/filepath" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // R-101 Part 2 — THE COPY-SITE HAZARD, exercised through the REAL record* helpers. // // The three record* helpers used to build a WHOLE CrossDriveBackup literal, with a helper re-applying // exactly two fields. Anything not named in the literal was zeroed on every status write. Adding // LastSuccess to that shape would have had `recordTier2Failure` CLEAR the anchor — the mirror image of // the defect being fixed, and firing on the FIRST failure rather than lying dormant. // // The R-100 lesson applies: these call the production functions. A test that modelled the copy in a // closure would stay green through any mutation of the real code. func anchorMgr(t *testing.T) *Manager { t.Helper() sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0)) if err != nil { t.Fatalf("settings.Load: %v", err) } return &Manager{settings: sett, logger: log.New(io.Discard, "", 0)} } func tgt() *Tier2Target { return &Tier2Target{NamespaceRoot: "/mnt/hdd_1/felhom-data/calibre-web"} } // SCENARIO F — a FAILURE must not wipe the anchor. This is the Part 2 proof and it runs the real // recordTier2Success → recordTier2Failure sequence. // // RED-PROOF: make recordTier2Failure build a whole literal again (or add `c.LastSuccess = ""` to its // mutation) → this fails with "a FAILED run wiped the success anchor". func TestTier2_FailureDoesNotWipeTheAnchor(t *testing.T) { m := anchorMgr(t) // Seed a row with a DISTINCT, older anchor (a real success on the 25th) already tracked, so the // anchor and the attempt clock are distinguishable — a success recorded in this same test would // land in the same second as the failures and prove nothing about which field moved. const anchor = "2026-07-25T01:30:00Z" if err := m.settings.SetCrossDriveConfig("calibre-web", &settings.CrossDriveBackup{ Enabled: true, Method: "rsync", Schedule: "daily", LastRun: anchor, LastStatus: "ok", LastSuccess: anchor, SuccessTracked: true, }); err != nil { t.Fatal(err) } // Five consecutive failing nights, through the real helper. for i := 0; i < 5; i++ { m.recordTier2Failure("calibre-web", tgt(), errors.New("rsync: connection reset")) got := m.settings.GetCrossDriveConfig("calibre-web") if got.LastSuccess == "" { t.Fatalf("a FAILED run wiped the success anchor (round %d) — one bad night would read as 'no copy has ever succeeded'", i+1) } if got.LastSuccess != anchor { t.Fatalf("a FAILED run MOVED the anchor to %q (was %q) — that is the R-101 defect in mirror image", got.LastSuccess, anchor) } if got.LastStatus != "error" { t.Errorf("the failure was not recorded (status=%q)", got.LastStatus) } // the ATTEMPT clock DOES move — that is what made the old rendering wrong if got.LastRun == anchor { t.Errorf("LastRun did not advance on the attempt — it still reads the old success time %q", got.LastRun) } } } // A successful run advances the anchor, or a tier would look permanently stale after one good night. func TestTier2_SuccessAdvancesTheAnchor(t *testing.T) { m := anchorMgr(t) if err := m.settings.SetCrossDriveConfig("calibre-web", &settings.CrossDriveBackup{ Enabled: true, Method: "rsync", Schedule: "daily", LastRun: "2026-07-25T01:30:00Z", LastStatus: "error", LastSuccess: "2026-07-25T01:30:00Z", SuccessTracked: true, }); err != nil { t.Fatal(err) } m.recordTier2Success("calibre-web", tgt(), 1<<30, "", 0) got := m.settings.GetCrossDriveConfig("calibre-web") if got.LastSuccess == "2026-07-25T01:30:00Z" { t.Error("a successful run did not advance the anchor") } if got.LastSuccess != got.LastRun { t.Errorf("the anchor and the attempt clock disagree after a success (%q vs %q)", got.LastSuccess, got.LastRun) } if got.LastError != "" { t.Errorf("a success left the previous error in place (%q)", got.LastError) } } // A customer preference must still survive a status write — the behaviour the old helper existed to // provide. The new copy-the-row form should give this for free, and this pins that it does. // // RED-PROOF: make tier2Update start from a zero-value struct instead of copying the existing row → // this fails with "a status write wiped the customer's Tier-2 preference". func TestTier2_StatusWritePreservesCustomerPreference(t *testing.T) { m := anchorMgr(t) if err := m.settings.SetTier2Preference("calibre-web", true, "/mnt/hdd_2"); err != nil { t.Fatal(err) } m.recordTier2Failure("calibre-web", tgt(), errors.New("boom")) got := m.settings.GetCrossDriveConfig("calibre-web") if !got.UserDisabled || got.PreferredTarget != "/mnt/hdd_2" { t.Errorf("a status write wiped the customer's Tier-2 preference (UserDisabled=%v PreferredTarget=%q)", got.UserDisabled, got.PreferredTarget) } } // A no-target write must not destroy the anchor either: "there is no destination drive right now" is // not evidence that the last successful copy never happened. func TestTier2_NoTargetKeepsTheAnchor(t *testing.T) { m := anchorMgr(t) m.recordTier2Success("calibre-web", tgt(), 1<<30, "", 0) anchor := m.settings.GetCrossDriveConfig("calibre-web").LastSuccess m.recordTier2NoTarget("calibre-web", "nincs elérhető második meghajtó") got := m.settings.GetCrossDriveConfig("calibre-web") if got.LastSuccess != anchor { t.Errorf("a no_target write lost the anchor (%q, was %q)", got.LastSuccess, anchor) } if got.LastRun != "" { t.Errorf("no_target should clear the attempt clock as before, got %q", got.LastRun) } } // SCENARIO E (producer half) — a LEGACY row is migrated truthfully on first touch: a row whose last // known state was a SUCCESS adopts that time as its anchor, so the deploy does not flip every existing // customer to "never succeeded". // // RED-PROOF: delete the `if !cfg.SuccessTracked` seeding block in tier2Update → this fails with // "a legacy OK row was not migrated". func TestTier2_LegacyOkRowSeedsItsAnchor(t *testing.T) { m := anchorMgr(t) // A pre-R-101 row: status ok, a last_run, no anchor, not tracked. if err := m.settings.SetCrossDriveConfig("calibre-web", &settings.CrossDriveBackup{ Enabled: true, Method: "rsync", Schedule: "daily", LastRun: "2026-07-28T01:30:00Z", LastStatus: "ok", }); err != nil { t.Fatal(err) } // The next run fails — the first new-code touch of this row. m.recordTier2Failure("calibre-web", tgt(), errors.New("boom")) got := m.settings.GetCrossDriveConfig("calibre-web") if !got.SuccessTracked { t.Fatal("the row was not marked as tracked") } if got.LastSuccess != "2026-07-28T01:30:00Z" { t.Errorf("a legacy OK row was not migrated — its known-good run should have become the anchor (got %q)", got.LastSuccess) } } // ...but a legacy row whose last known state was an ERROR seeds NOTHING: the old data contains no // evidence of a success, and inventing one would be the original defect. func TestTier2_LegacyErrorRowSeedsNothing(t *testing.T) { m := anchorMgr(t) if err := m.settings.SetCrossDriveConfig("calibre-web", &settings.CrossDriveBackup{ Enabled: true, Method: "rsync", Schedule: "daily", LastRun: "2026-07-28T01:30:00Z", LastStatus: "error", }); err != nil { t.Fatal(err) } m.recordTier2Failure("calibre-web", tgt(), errors.New("boom")) if got := m.settings.GetCrossDriveConfig("calibre-web"); got.LastSuccess != "" { t.Errorf("a legacy ERROR row invented an anchor (%q) — that is the defect, not the fix", got.LastSuccess) } }