package quiesce import ( "context" "testing" "time" ) func i64(v int64) *int64 { return &v } // atBudapest builds a time whose Budapest wall-clock hour/minute are h:m (the predicate reads only // those; the caller in the loop passes now.In(Budapest)). func atBudapest(h, m int) time.Time { return time.Date(2026, 7, 24, h, m, 0, 0, budapestLocation()) } const cadence24 = 24 * time.Hour // Group D — scheduledRunAllowed truth table: inside gate / outside / outside+valve / wrap / nil age. // Window 02:30 → gate [04:30, 08:30). Valve threshold = cadence+24h = 48h. // Red-proof: invert the valve comparison (`<` instead of `>`) and the starving-box case (age 49h, // outside window) flips to false. func TestScheduledRunAllowed(t *testing.T) { h := func(hours int64) *int64 { return i64(hours * 3600) } cases := []struct { name string now time.Time window string age *int64 want bool }{ {"inside gate, recent backup", atBudapest(5, 0), "02:30", h(20), true}, {"gate open boundary (inclusive)", atBudapest(4, 30), "02:30", h(20), true}, {"gate close boundary (exclusive)", atBudapest(8, 30), "02:30", h(20), false}, {"outside gate, no valve", atBudapest(12, 0), "02:30", h(20), false}, {"outside gate, valve (age > 48h)", atBudapest(12, 0), "02:30", h(49), true}, {"outside gate, nil age (no backup yet)", atBudapest(12, 0), "02:30", nil, true}, {"wrap: inside gate across midnight", atBudapest(2, 0), "23:00", h(20), true}, {"wrap: outside gate across midnight", atBudapest(12, 0), "23:00", h(20), false}, {"unparseable window fails open", atBudapest(12, 0), "nonsense", h(20), true}, } for _, c := range cases { if got := scheduledRunAllowed(c.now, c.window, c.age, cadence24); got != c.want { t.Errorf("%s: scheduledRunAllowed(%s, %q, age, cadence) = %v, want %v", c.name, c.now.Format("15:04"), c.window, got, c.want) } } } // windowLoop builds a Loop with the gate wired and now/window overridden for deterministic tests. func windowLoop(t *testing.T, be Backend, st Stacks, window string, now time.Time) *Loop { t.Helper() l := testLoop(t, be, st) l.windowStartFn = func() string { return window } l.cadence = cadence24 l.now = func() time.Time { return now } return l } // Group D (integration) — a DUE scheduled cycle outside the window with a recent backup is deferred: // no StartBackup, no stacks stopped. func TestRunOnce_GateDefersOutsideWindow(t *testing.T) { be := &fakeBackend{due: true, dueAge: i64(20 * 3600)} st := &fakeStacks{running: []string{"nextcloud"}} l := windowLoop(t, be, st, "02:30", atBudapest(12, 0)) // gate [04:30,08:30), 12:00 is outside if err := l.runOnce(context.Background()); err != nil { t.Fatalf("runOnce: %v", err) } if be.startCalls != 0 { t.Errorf("gate should have deferred, but StartBackup was called %d time(s)", be.startCalls) } if len(st.stoppedNames()) != 0 { t.Errorf("gate should have deferred, but stacks were stopped: %v", st.stoppedNames()) } } // Group D (integration) — inside the window the cycle runs normally. func TestRunOnce_GateRunsInsideWindow(t *testing.T) { be := &fakeBackend{due: true, dueAge: i64(20 * 3600), phases: []string{"done"}} st := &fakeStacks{running: []string{"nextcloud"}} l := windowLoop(t, be, st, "02:30", atBudapest(5, 0)) // 05:00 is inside [04:30,08:30) if err := l.runOnce(context.Background()); err != nil { t.Fatalf("runOnce: %v", err) } if be.startCalls != 1 { t.Errorf("inside the window the cycle should run; StartBackup calls = %d", be.startCalls) } } // Group D (integration) — outside the window but the safety valve holds (box was off during its // window; last backup older than cadence+24h): the cycle runs regardless of the window. func TestRunOnce_ValveRunsOutsideWindow(t *testing.T) { be := &fakeBackend{due: true, dueAge: i64(49 * 3600), phases: []string{"done"}} st := &fakeStacks{running: []string{"nextcloud"}} l := windowLoop(t, be, st, "02:30", atBudapest(12, 0)) // outside, but age 49h > 48h valve if err := l.runOnce(context.Background()); err != nil { t.Fatalf("runOnce: %v", err) } if be.startCalls != 1 { t.Errorf("safety valve should have run the cycle; StartBackup calls = %d", be.startCalls) } } // Group D (integration) — the MANUAL trigger path is never gated: TriggerNow runs quiesceAndPoll // directly (bypassing runOnce), so an outside-window manual backup still fires. func TestTriggerNow_NotGated(t *testing.T) { be := &fakeBackend{due: false, phases: []string{"done"}} // not due — only the manual path can run it st := &fakeStacks{running: []string{"nextcloud"}} l := windowLoop(t, be, st, "02:30", atBudapest(12, 0)) // outside the window if err := l.TriggerNow(); err != nil { t.Fatalf("TriggerNow: %v", err) } // TriggerNow runs asynchronously — wait for the backup to be started. deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { be.mu.Lock() n := be.startCalls be.mu.Unlock() if n == 1 { return } time.Sleep(10 * time.Millisecond) } t.Fatal("manual TriggerNow did not start a backup — it must never be gated by the window") }