package quiesce import ( "context" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/backupwindow" ) // v0.271.0 — `09` §3 decision 20: on an update night the full-system backup WAITS for the automatic // update leg, inside its own window, until W+5h; after W+5h only for a step already in flight, and never // at or past W+5h30m. Window 02:30 → gate [04:30, 08:30), leg stop 07:30, step grace until 08:00. // TestD20_GateWaitsForTheLeg — the CONSEQUENCE, through runOnce: while the leg runs at 04:45 the due // backup is not started and no app is stopped; the same poll at 07:30 (W+5h) runs it. // // COMPANION RED-PROOF (REPORT.md): drop the updateLegDefers block from runOnce — this test fails at // "the backup started while the update leg was running (04:45, before W+5h)". func TestD20_GateWaitsForTheLeg(t *testing.T) { legRunning := func() (bool, bool) { return true, false } be := &fakeBackend{due: true, dueAge: i64(20 * 3600), phases: []string{"done"}} st := &fakeStacks{running: []string{"nextcloud"}} l := windowLoop(t, be, st, "02:30", atBudapest(4, 45)) l.updateLegFn = legRunning if err := l.runOnce(context.Background()); err != nil { t.Fatalf("runOnce: %v", err) } if be.startCalls != 0 || len(st.stoppedNames()) != 0 { t.Fatalf("the backup started while the update leg was running (04:45, before W+5h): start=%d stopped=%v", be.startCalls, st.stoppedNames()) } be2 := &fakeBackend{due: true, dueAge: i64(20 * 3600), phases: []string{"done"}} st2 := &fakeStacks{running: []string{"nextcloud"}} l2 := windowLoop(t, be2, st2, "02:30", atBudapest(7, 30)) l2.updateLegFn = legRunning if err := l2.runOnce(context.Background()); err != nil { t.Fatalf("runOnce: %v", err) } if be2.startCalls != 1 { t.Fatalf("at W+5h (07:30) the backup must run even though the leg still says active; start=%d", be2.startCalls) } } // TestD20_UpdateLegDefers — the truth table of the pure predicate. func TestD20_UpdateLegDefers(t *testing.T) { cases := []struct { name string active, step bool h, m int window string want bool }{ {"no leg", false, false, 5, 0, "02:30", false}, {"leg running inside the gate", true, false, 4, 45, "02:30", true}, {"leg running one minute before W+5h", true, false, 7, 29, "02:30", true}, {"leg running AT W+5h", true, false, 7, 30, "02:30", false}, {"step in flight at W+5h+10m", true, true, 7, 40, "02:30", true}, {"step in flight at W+5h+30m (cap)", true, true, 8, 0, "02:30", false}, {"leg running across midnight (W 23:00, now 03:00)", true, false, 3, 0, "23:00", true}, {"leg running across midnight at W+5h (04:00)", true, false, 4, 0, "23:00", false}, {"unreadable window never blocks", true, true, 5, 0, "nonsense", false}, } for _, c := range cases { fn := func() (bool, bool) { return c.active, c.step } if got, _ := updateLegDefers(fn, atBudapest(c.h, c.m), c.window); got != c.want { t.Errorf("%s: updateLegDefers = %v, want %v", c.name, got, c.want) } } if got, _ := updateLegDefers(nil, atBudapest(5, 0), "02:30"); got { t.Error("an unwired leg must never defer the backup") } } // TestLegDeadlineAndGateShareW5h — the gate and the leg read the SAME offset (stacks.LegDeadline uses // backupwindow.UpdateLegStopOffsetMin too). If either moves alone, the backup would wait for a leg that // has stopped starting steps, or the leg would start steps the backup no longer waits for. func TestLegDeadlineAndGateShareW5h(t *testing.T) { if backupwindow.UpdateLegStopOffsetMin != 300 { t.Fatalf("decision 20 says W+5h; the shared constant is %d min", backupwindow.UpdateLegStopOffsetMin) } if backupwindow.UpdateLegStopOffsetMin >= 360 || backupwindow.UpdateLegStopOffsetMin+legStepGraceMin >= 360 { t.Fatal("the leg's stop (plus the in-flight grace) must leave the full-system backup part of its [W+2h, W+6h) window") } }