package moves import ( "reflect" "strconv" "testing" "time" "github.com/carlosframework/platform/internal/provision" ) // The fixture journals of Plan 6 Task 4 (spec ยง13): one move per outcome, // with every stamp a known distance from the cut. const ( tmID = "mv_abcdefghijklmnopqrstuvwxyz" tmInc = "inc-1" tmRB = "rb_abcdefghijklmnopqrstuvwxyz" ) var tmT0 = time.Date(2026, 10, 11, 9, 0, 0, 0, time.UTC) func tmAt(d time.Duration) *time.Time { t := tmT0.Add(d); return &t } // tmCut is 10 minutes after the request; every later stamp is from it. func tmCut(d time.Duration) *time.Time { return tmAt(10*time.Minute + d) } // doneJournal: requested, ready 5 minutes later, cut 5 minutes after that // with a 95-second budget (cut_deadline 90 s after the cut, the open budget // off it), flipped 40 s in, a landing_deadline later than the cut's deadline // (so the cut's binds: 50 s after the flip), opened 70.5 s after the cut. func doneJournal() *provision.MoveState { return &provision.MoveState{ID: tmID, Incarnation: tmInc, From: "ie", To: "uk", State: provision.MoveCommitted, RequestedAt: tmT0, ReadyAt: tmAt(5 * time.Minute), CutAt: tmCut(0), CutBudgetS: 95, CutDeadline: tmCut(90 * time.Second), FlippedAt: tmCut(40 * time.Second), LandingDeadline: tmCut(160 * time.Second), CommittedAt: tmCut(65 * time.Second), OpenedAt: tmCut(70500 * time.Millisecond), SourceParkedAt: tmCut(5 * time.Minute)} } // rolledBackJournal: the same cut and flip, a landing_deadline 30 s after // the flip (earlier than the cut's, so it binds), the rollback decided at 75 // s with its 15-minute release deadline, the source recovered at 200 s and // reopened at 210 s, the target parked at 300 s. func rolledBackJournal() *provision.MoveState { m := doneJournal() m.State, m.CommittedAt, m.OpenedAt, m.SourceParkedAt = provision.MoveRollingBack, nil, nil, nil m.LandingDeadline = tmCut(70 * time.Second) m.Rollback = &provision.MoveRollback{ID: tmRB, Reason: provision.RollbackLandingDeadline, RequestedAt: tmCut(75 * time.Second), ReleaseDeadline: tmCut(75*time.Second + 15*time.Minute), TargetPhase: provision.RbTargetCleaned, SourcePhase: provision.RbSourceOpened, RecoveredAt: tmCut(200 * time.Second), OpenedAt: tmCut(210 * time.Second), TargetParkedAt: tmCut(300 * time.Second)} return m } // revertJournal: cut, never flipped, the source reopened 50 s after the cut. func revertJournal() *provision.MoveState { m := doneJournal() m.State, m.FlippedAt, m.LandingDeadline, m.CommittedAt, m.OpenedAt, m.SourceParkedAt = provision.MoveReverting, nil, nil, nil, nil, nil m.ReopenedAt = tmCut(50 * time.Second) return m } // Plan 6 Task 4: a fixture journal yields the expected seconds, each beside // its budget: prepare (requested to ready), cut (cut_at to the flip), land // (the flip to the open) against the landing deadline (the earlier of the // cut's absolute deadline and landing_deadline, provision.LandingDeadlineOf), // offline (cut_at to the open, or to the source's reopen) against the cut's // admitted budget, and a rollback (its decision to the source's reopen) // against its release deadline. func TestTimingsOfAFixtureJournal(t *testing.T) { for name, c := range map[string]struct { m *provision.MoveState want map[string]float64 }{ "done": {doneJournal(), map[string]float64{ "prepare_s": 300, "cut_s": 40, "cut_budget_s": 95, "land_s": 30.5, "land_budget_s": 50, "offline_s": 70.5}}, "rolled back after the flip": {rolledBackJournal(), map[string]float64{ "prepare_s": 300, "cut_s": 40, "cut_budget_s": 95, "land_budget_s": 30, "offline_s": 210, "rollback_s": 135, "release_budget_s": 900}}, "reverted before the flip": {revertJournal(), map[string]float64{ "prepare_s": 300, "cut_budget_s": 95, "offline_s": 50}}, "nothing stamped but the request": {&provision.MoveState{ID: tmID, State: provision.MovePreparing, RequestedAt: tmT0}, map[string]float64{}}, "no move": {nil, map[string]float64{}}, } { if got := Timings(c.m); !reflect.DeepEqual(got, c.want) { t.Errorf("%s: Timings = %v, want %v", name, got, c.want) } } // The landing deadline is provision's one rule (preflight L20): the // earlier of the two, named. for name, c := range map[string]struct { m *provision.MoveState want *time.Time which string }{ "the cut's binds": {doneJournal(), doneJournal().CutDeadline, "cut_deadline"}, "the landing's binds": {rolledBackJournal(), rolledBackJournal().LandingDeadline, "landing_deadline"}, "neither": {&provision.MoveState{}, nil, ""}, } { d, which := provision.LandingDeadlineOf(c.m) if which != c.which || (d == nil) != (c.want == nil) || (d != nil && !d.Equal(*c.want)) { t.Errorf("%s: LandingDeadlineOf = %v %q, want %v %q", name, d, which, c.want, c.which) } } } // Preflight L20: Timings' offline is the closes' own (provision.MoveOfflineS), // so it equals the LastMove.OfflineS each of the three closes records. func TestTimingsOfflineIsTheClosesOffline(t *testing.T) { g := provision.MoveGuard{ID: tmID, Incarnation: tmInc} now := *tmCut(time.Hour) for name, c := range map[string]struct { m *provision.MoveState region string close func(*provision.Record) error }{ "MoveClose": {doneJournal(), "uk", provision.MoveClose(g, now)}, "MoveRolledBackClose": {rolledBackJournal(), "ie", provision.MoveRolledBackClose(g, now)}, "MoveRevertClose": {revertJournal(), "ie", provision.MoveRevertClose(g, now)}, } { want := Timings(c.m)["offline_s"] rec := provision.Record{Host: "a.go.example.com", Region: c.region, Move: c.m} if err := c.close(&rec); err != nil { t.Fatalf("%s: %v", name, err) } if rec.LastMove == nil || rec.LastMove.OfflineS != want || want == 0 { t.Errorf("%s: the close recorded %+v, Timings says offline %v", name, rec.LastMove, want) } } } // Plan 6 Task 4: the done and rolled_back log events carry the timings in // their Fields, from the record's own journal times (the close's before), // whoever writes them; an abort carries none. func TestTerminalTransitionsCarryTheTimings(t *testing.T) { g := provision.MoveGuard{ID: tmID, Incarnation: tmInc} now := *tmCut(time.Hour) str := func(v float64) string { return strconv.FormatFloat(v, 'f', -1, 64) } for name, c := range map[string]struct { m *provision.MoveState region string close func(*provision.Record) error want map[string]string }{ "done": {doneJournal(), "uk", provision.MoveClose(g, now), map[string]string{ "prepare_s": "300", "cut_s": "40", "cut_budget_s": "95", "land_s": str(30.5), "land_budget_s": "50", "offline_s": str(70.5)}}, "rolled back": {rolledBackJournal(), "ie", provision.MoveRolledBackClose(g, now), map[string]string{ "prepare_s": "300", "cut_s": "40", "cut_budget_s": "95", "land_budget_s": "30", "offline_s": "210", "rollback_s": "135", "release_budget_s": "900"}}, "reverted": {revertJournal(), "ie", provision.MoveRevertClose(g, now), map[string]string{ "prepare_s": "300", "cut_budget_s": "95", "offline_s": "50"}}, } { before := provision.Record{Host: "a.go.example.com", Region: c.region, Move: c.m} after := before after.Move = c.m if err := c.close(&after); err != nil { t.Fatalf("%s: %v", name, err) } e, ok := TransitionOf(before, after) if !ok || e.Identity != after.LastMove.Outcome { t.Fatalf("%s: TransitionOf = %+v %v", name, e, ok) } for k, v := range c.want { if e.Fields[k] != v { t.Errorf("%s: Fields[%s] = %q, want %q (fields %v)", name, k, e.Fields[k], v, e.Fields) } } if got := TimingsOf(e.Fields); !reflect.DeepEqual(got, Timings(c.m)) { t.Errorf("%s: TimingsOf(Fields) = %v, want Timings %v", name, got, Timings(c.m)) } } // An abort's close names no timing, only the offline_s every close // carries (Task 3: LastMove's). m := &provision.MoveState{ID: tmID, Incarnation: tmInc, From: "ie", To: "uk", State: provision.MoveAborting, RequestedAt: tmT0, ReadyAt: tmAt(5 * time.Minute)} before := provision.Record{Host: "a.go.example.com", Region: "ie", Move: m} after := provision.Record{Host: "a.go.example.com", Region: "ie", LastMove: &provision.MoveOutcome{ID: tmID, Outcome: "aborted", At: now}} e, ok := TransitionOf(before, after) if !ok || e.Event != "aborted" { t.Fatalf("the abort: %+v %v", e, ok) } if got := TimingsOf(e.Fields); !reflect.DeepEqual(got, map[string]float64{"offline_s": 0}) { t.Errorf("an abort carries timings %v", got) } } // Preflight L29: a preparing move has one start, PreparingSince, which both // the six-hour abort (AbortDue) and the console's 60-minute page measure from. func TestPreparingSinceIsTheLaterOfRequestAndReady(t *testing.T) { later := tmT0.Add(2 * time.Hour) earlier := tmT0.Add(-time.Hour) for name, c := range map[string]struct { ready *time.Time want time.Time }{ "no ready": {nil, tmT0}, "a later ready": {&later, later}, "an earlier one": {&earlier, tmT0}, } { m := &provision.MoveState{ID: tmID, State: provision.MovePreparing, RequestedAt: tmT0, ReadyAt: c.ready} if got := PreparingSince(m); !got.Equal(c.want) { t.Errorf("%s: PreparingSince = %v, want %v", name, got, c.want) } rec := provision.Record{Move: m} if AbortDue(rec, c.want.Add(AbortAfter-time.Second)) || !AbortDue(rec, c.want.Add(AbortAfter)) { t.Errorf("%s: AbortDue does not run from PreparingSince", name) } } }