package backup import ( "context" "path/filepath" "testing" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // ── Due-ness, NOT a weekday ────────────────────────────────────────────────────────────────────── // // A job that fires only on Sundays silently skips a week every time the box is off on a Sunday. // **R-341 is exactly that failure** — a dated check quietly missed, five days overdue, and nothing // asked again. Asking "is the last successful check older than the max age?" catches up on the next // day the box is running, whatever day that is. func seedLastCheck(t *testing.T, m *Manager, at time.Time, ok bool) { t.Helper() m.RecordIntegrityOutcome(at, ok) } func TestR359_NotDueRunsNothing(t *testing.T) { m, _ := newIntegrityManager(t, okRepo(nil)) seedLastCheck(t, m, time.Now().Add(-2*24*time.Hour), true) due, last := m.IntegrityDue(time.Now()) if due { t.Fatalf("a check 2 days old was reported due against a 7-day max age (last=%s)", last) } } func TestR359_OverdueRunsOnAnyWeekday(t *testing.T) { // 21 days: the box was off. It must run on whatever day it next comes up — the assertion is made // for EVERY weekday so a Sunday-gated implementation cannot pass by luck. m, _ := newIntegrityManager(t, okRepo(nil)) seedLastCheck(t, m, time.Date(2026, 8, 1, 6, 0, 0, 0, time.UTC), true) for d := 0; d < 7; d++ { now := time.Date(2026, 8, 22, 6, 0, 0, 0, time.UTC).AddDate(0, 0, d) due, _ := m.IntegrityDue(now) if !due { t.Fatalf("a 21-day-old check was NOT due on %s — a check that waits for one weekday is a "+ "check that skips a whole period every time the box is off that day (R-341)", now.Weekday()) } } } func TestR359_FailureStillAdvancesDueness(t *testing.T) { // A broken store must not be re-checked every night: that is load with no new information, and the // hourly operator cooldown already governs the mail. m, _ := newIntegrityManager(t, okRepo(nil)) m.RecordIntegrityOutcome(time.Now(), false) due, _ := m.IntegrityDue(time.Now()) if due { t.Fatal("a FAILED check left the store due immediately — it would be re-checked every night, " + "telling nobody anything new") } if tgt := m.settings.GetOffboxTarget(); tgt.LastIntegrityOK { t.Fatal("a failed check recorded a passing verdict") } } func TestR359_SkipDoesNotAdvanceDueness(t *testing.T) { // The complement of the failure rule, and the reason the two are separate calls: a skip looked at // NOTHING, so it may not reset the clock. m, cap := newIntegrityManager(t, okRepo(nil)) if err := m.AcquireRunningForTest(); err != nil { t.Fatal(err) } res := m.CheckOffboxIntegrity(context.Background()) m.ReleaseRunningForTest() if !res.Skipped || len(cap.argvs) != 0 { t.Fatalf("fixture: expected a skip with no restic, got %+v / %v", res, cap.argvs) } due, _ := m.IntegrityDue(time.Now()) if !due { t.Fatal("a SKIPPED check advanced due-ness — the store would wait a full period before " + "anything looked at it again") } } func TestR359_FirstEverRunIsDue(t *testing.T) { // An unchecked store must never read as a fresh one. This is the fail-safe direction. m, _ := newIntegrityManager(t, okRepo(nil)) due, last := m.IntegrityDue(time.Now()) if !due { t.Fatal("a store that has NEVER been checked was reported as not due") } if !last.IsZero() { t.Errorf("a never-checked store reported a last-check time of %s", last) } } func TestR359_UnparseableStampIsTreatedAsNeverChecked(t *testing.T) { // Fail-safe again: a corrupt stamp must not certify a store as recently verified. m, _ := newIntegrityManager(t, okRepo(nil)) if err := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.LastIntegrityCheck = "not-a-time" }); err != nil { t.Fatal(err) } due, _ := m.IntegrityDue(time.Now()) if !due { t.Fatal("an unparseable last-check stamp was read as a recent check — a corrupt field must " + "never buy the store a free period") } } func TestR359_DuenessSurvivesRestart(t *testing.T) { // Written and read back through the REAL store, not an in-memory field: a due-ness anchor that // does not survive a controller restart would re-check on every boot, or never. m, _ := newIntegrityManager(t, okRepo(nil)) at := time.Now().Add(-3 * 24 * time.Hour).UTC().Truncate(time.Second) m.RecordIntegrityOutcome(at, true) reloaded, err := settings.Load(filepath.Join(m.cfg.Paths.DataDir, "settings.json"), m.logger) if err != nil { t.Fatalf("reload: %v", err) } tgt := reloaded.GetOffboxTarget() if tgt == nil || tgt.LastIntegrityCheck == "" { t.Fatal("the last-check stamp did not survive a reload from disk") } got, perr := time.Parse(time.RFC3339, tgt.LastIntegrityCheck) if perr != nil { t.Fatalf("the persisted stamp does not parse: %v", perr) } if !got.Equal(at) { t.Errorf("persisted stamp = %s, want %s", got, at) } if !tgt.LastIntegrityOK { t.Error("the persisted verdict did not survive") } }