Files
felhom-controller/controller/internal/backup/tier2_anchor_test.go
T
admin 3f048e042b R-101 + F-DIAG: the restore dialog names the last SUCCESSFUL copy (v0.182.0)
Tier2LastRun is the attempt clock and was rendered as 'Legutóbbi másolat' in the restore
confirm dialog. New LastSuccess + SuccessTracked anchor; tier2Update makes the three
rebuild sites safe by construction. F-DIAG: six distinct causes, target-aware redaction.
2026-07-28 16:36:47 +02:00

175 lines
7.4 KiB
Go

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)
}
}