package main import ( "encoding/json" "strings" "testing" "time" ) // clocked gives a harness a clock the test moves. func clocked(h *harness) *time.Time { now := time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC) h.Tracker.Now = func() time.Time { return now } return &now } // A move to diagnosing or fixing by someone else while the worker is current is refused, naming the worker, and // changes nothing (ADR 0284 §3). func TestAnotherWorkerIsRefusedNamingTheWorker(t *testing.T) { for _, to := range []string{StatusDiagnosing, StatusFixing} { t.Run(to, func(t *testing.T) { h := newHarness(t) now := clocked(h) n := h.open(t, "the counter skips a number", "hq") if _, err := h.Move(MoveAsk{Number: n, Status: StatusDiagnosing, By: "fix-a@g14"}); err != nil { t.Fatal(err) } *now = now.Add(23 * time.Hour) before, _ := h.mem.Get(IssuesState, keyOf(n)) events := len(h.said()) _, err := h.Move(MoveAsk{Number: n, Status: to, By: "fix-b@ace"}) if err == nil || !strings.Contains(err.Error(), "fix-a@g14") || !strings.Contains(err.Error(), "take-over") { t.Fatalf("want refused naming the worker, got %v", err) } after, _ := h.mem.Get(IssuesState, keyOf(n)) if after.Revision != before.Revision || len(h.said()) != events { t.Fatal("a refused move changed the issue or said an event") } }) } } // The worker moves on from diagnosing to fixing as the same worker; its since stands. func TestTheWorkerMovesOn(t *testing.T) { h := newHarness(t) now := clocked(h) n := h.open(t, "x", "hq") if _, err := h.Move(MoveAsk{Number: n, Status: StatusDiagnosing, By: "fix-a@g14"}); err != nil { t.Fatal(err) } since := *now *now = now.Add(time.Hour) m, err := h.Move(MoveAsk{Number: n, Status: StatusFixing, By: "FIX-A@g14"}) if err != nil { t.Fatal(err) } is, _ := h.Get(n) if is.Worker.By != "fix-a@g14" || !is.Worker.Since.Equal(since) || !is.Worker.Active.Equal(*now) || m.By != "fix-a@g14" || m.Since == nil || !m.Since.Equal(since) || m.TookOverFrom != "" { t.Fatalf("worker %+v, answer %+v", is.Worker, m) } // Naming the fix while it stays fixing is the worker's too. if _, err := h.Move(MoveAsk{Number: n, Status: StatusFixing, FixedBy: []string{"hq#1"}}); err == nil || !strings.Contains(err.Error(), "needs by") { t.Fatalf("a move to fixing without by: %v", err) } if _, err := h.Move(MoveAsk{Number: n, Status: StatusFixing, By: "fix-b@ace", FixedBy: []string{"hq#1"}}); err == nil { t.Fatal("another named the fix while the worker was current") } if _, err := h.Move(MoveAsk{Number: n, Status: StatusFixing, By: "fix-a@g14", FixedBy: []string{"hq#1"}}); err != nil { t.Fatal(err) } if _, err := h.Move(MoveAsk{Number: n, Status: StatusFixing, By: "fix-a@g14", TakeOver: "mine"}); err == nil || !strings.Contains(err.Error(), "already") { t.Fatalf("a take-over of one's own work: %v", err) } } // A take-over gives a reason, the reason is noted, and the moved event says from whom (ADR 0284 §3, §6). func TestATakeOverNeedsAReasonAndIsNoted(t *testing.T) { h := newHarness(t) clocked(h) n := h.open(t, "x", "hq") if _, err := h.Move(MoveAsk{Number: n, Status: StatusFixing, By: "fix-a@g14"}); err != nil { t.Fatal(err) } m, err := h.Move(MoveAsk{Number: n, Status: StatusFixing, By: "jochen@shanks", TakeOver: "the operator finishes it by hand"}) if err != nil { t.Fatal(err) } is, _ := h.Get(n) if is.Worker.By != "jochen@shanks" || m.TookOverFrom != "fix-a@g14" || len(is.Notes) != 1 || !strings.Contains(is.Notes[0].Text, "from fix-a@g14") || !strings.Contains(is.Notes[0].Text, "the operator finishes it by hand") || is.Notes[0].By != Unproven { t.Fatalf("worker %+v, notes %+v, answer %+v", is.Worker, is.Notes, m) } ev := h.said() last := ev[len(ev)-1] if last.Event != EventMoved || last.Body["by"] != "jochen@shanks" || last.Body["took-over-from"] != "fix-a@g14" || last.Body["take-over"] != "the operator finishes it by hand" || last.Body["caller"] != Unproven { t.Fatalf("event %+v", last) } } // A worker with nothing done on it past worker-stale-after is stale: list and get say so, and it may be taken over // without a reason, noted (ADR 0284 §4). A caller's note keeps it live; the tracker's own does not. func TestAStaleWorkerIsShownAndMayBeTakenOverWithoutAReason(t *testing.T) { h := newHarness(t) now := clocked(h) n := h.open(t, "x", "hq") if _, err := h.Move(MoveAsk{Number: n, Status: StatusFixing, By: "fix-a@g14"}); err != nil { t.Fatal(err) } since := *now *now = now.Add(20 * time.Hour) if _, err := h.Note(n, "still on it: the counter's revision is read twice"); err != nil { t.Fatal(err) } *now = now.Add(10 * time.Hour) // 30 h after the start, 10 h after the worker's note if _, err := h.note(GroomBy, n, "Triage: a note of the tracker's own"); err != nil { t.Fatal(err) } l, _ := h.List(ListAsk{}) if r := l.Rows[0]; r.Worker != "fix-a@g14" || r.Since == nil || !r.Since.Equal(since) || r.Stale { t.Fatalf("row %+v: the worker's note keeps the worker current", r) } if _, err := h.Move(MoveAsk{Number: n, Status: StatusFixing, By: "fix-b@ace"}); err == nil { t.Fatal("a current worker was taken over without a reason") } *now = now.Add(15 * time.Hour) // 25 h after the worker's note; the tracker's note 15 h ago does not count l, _ = h.List(ListAsk{}) if r := l.Rows[0]; !r.Stale || r.Worker != "fix-a@g14" { t.Fatalf("row %+v: want stale", r) } v, _ := h.View(n) if !v.WorkerStale { t.Fatalf("get %+v: want worker-stale", v) } m, err := h.Move(MoveAsk{Number: n, Status: StatusFixing, By: "fix-b@ace"}) if err != nil { t.Fatalf("a stale worker taken over from without a reason: %v", err) } is, _ := h.Get(n) last := is.Notes[len(is.Notes)-1] if m.TookOverFrom != "fix-a@g14" || is.Worker.By != "fix-b@ace" || !strings.Contains(last.Text, "stale") { t.Fatalf("answer %+v, notes %+v", m, is.Notes) } l, _ = h.List(ListAsk{}) if r := l.Rows[0]; r.Stale || r.Worker != "fix-b@ace" { t.Fatalf("row %+v after the take-over", r) } } // The bound is the setting worker-stale-after. func TestTheStaleBoundIsTheSetting(t *testing.T) { h := newHarness(t) now := clocked(h) h.settings.WorkerStaleAfter = "1h" n := h.open(t, "x", "hq") if _, err := h.Move(MoveAsk{Number: n, Status: StatusDiagnosing, By: "fix-a@g14"}); err != nil { t.Fatal(err) } *now = now.Add(59 * time.Minute) if _, err := h.Move(MoveAsk{Number: n, Status: StatusDiagnosing, By: "fix-b@ace"}); err == nil { t.Fatal("taken under the bound") } *now = now.Add(2 * time.Minute) if _, err := h.Move(MoveAsk{Number: n, Status: StatusDiagnosing, By: "fix-b@ace"}); err != nil { t.Fatalf("past the bound: %v", err) } } // A move to a status that has no worker ends the work, and the event says whose ended. func TestAMoveOnEndsTheWork(t *testing.T) { h := newHarness(t) clocked(h) n := h.open(t, "x", "hq") if _, err := h.Move(MoveAsk{Number: n, Status: StatusDiagnosing, By: "fix-a@g14"}); err != nil { t.Fatal(err) } m, err := h.Move(MoveAsk{Number: n, Status: StatusLocated}) if err != nil { t.Fatal(err) } is, _ := h.Get(n) ev := h.said() if is.Worker != nil || m.Released != "fix-a@g14" || ev[len(ev)-1].Body["released"] != "fix-a@g14" || m.By != Unproven { t.Fatalf("worker %+v, answer %+v, event %+v", is.Worker, m, ev[len(ev)-1]) } // Anyone may start on it now, without a take-over. if _, err := h.Move(MoveAsk{Number: n, Status: StatusFixing, By: "fix-b@ace"}); err != nil { t.Fatal(err) } } // The duplicate check and the triage treat fixing as not closed, like diagnosing and located (ADR 0284 §7). func TestFixingIsNotClosed(t *testing.T) { for _, s := range Statuses { want := s != StatusResolved && s != StatusWontfix if got := unresolved(&Issue{Status: s}); got != want { t.Errorf("unresolved at %s: %v, want %v", s, got, want) } } h := newHarness(t) n := h.open(t, "x", "hq") h.fixing(t, n) l, _ := h.List(ListAsk{Status: StatusFixing}) if len(l.Rows) != 1 || l.Rows[0].Number != n || l.Rows[0].Worker != "fix@g14" { t.Fatalf("%+v", l) } } // Over the verb as the tool runner calls it: by and take-over are arguments, and fixing is a status the schema // offers. func TestTheMoveVerbTakesByAndTakeOver(t *testing.T) { h := newHarness(t) clocked(h) n := h.open(t, "x", "hq") if _, err := h.call(t, "move", map[string]any{"number": n, "status": "fixing", "by": "fix-a@g14"}); err != nil { t.Fatal(err) } if _, err := h.call(t, "move", map[string]any{"number": n, "status": "fixing", "by": "fix-b@ace"}); err == nil { t.Fatal("taken without a reason over the verb") } if _, err := h.call(t, "move", map[string]any{"number": n, "status": "fixing", "by": "fix-b@ace", "take-over": "fix-a's session ended"}); err != nil { t.Fatal(err) } got, err := h.call(t, "get", map[string]any{"number": n}) if err != nil { t.Fatal(err) } raw, _ := json.Marshal(got) if !strings.Contains(string(raw), `"worker":{"by":"fix-b@ace"`) { t.Fatalf("get %s", raw) } for _, tool := range Tools(h.Tracker) { if tool.Name != Seat+".move" { continue } raw, _ := json.Marshal(tool.Input) if !strings.Contains(string(raw), `"fixing"`) || !strings.Contains(string(raw), `"take-over"`) || !strings.Contains(tool.Description, "only from fixing") { t.Fatalf("move's schema %s", raw) } } } // A resolved issue may still have what it holds corrected: resolved to resolved is not refused as "not from fixing". func TestAResolvedIssueMayBeCorrected(t *testing.T) { h := newHarness(t) n := h.open(t, "x", "hq") h.fixing(t, n) if _, err := h.Move(MoveAsk{Number: n, Status: StatusResolved, FixedBy: []string{"hq#1"}}); err != nil { t.Fatal(err) } if _, err := h.Move(MoveAsk{Number: n, Status: StatusResolved, FixedBy: []string{"hq#1", "hq#2"}}); err != nil { t.Fatalf("a correction of a resolved issue: %v", err) } if is, _ := h.Get(n); len(is.FixedBy) != 2 { t.Fatalf("%+v", is.FixedBy) } } // A caller's link and set keep the worker current, as a note does (ADR 0284 §4). func TestALinkAndASetKeepTheWorkerCurrent(t *testing.T) { for _, act := range []string{"link", "unlink", "set"} { t.Run(act, func(t *testing.T) { h := newHarness(t) now := clocked(h) n := h.open(t, "x", "hq") other := h.open(t, "y") if act == "unlink" { if _, err := h.Link(LinkAsk{Number: n, Kind: LinkRelatesTo, Target: keyOf(other)}); err != nil { t.Fatal(err) } } if _, err := h.Move(MoveAsk{Number: n, Status: StatusFixing, By: "fix-a@g14"}); err != nil { t.Fatal(err) } *now = now.Add(20 * time.Hour) var err error switch act { case "link": _, err = h.Link(LinkAsk{Number: n, Kind: LinkRelatesTo, Target: keyOf(other)}) case "unlink": _, err = h.Link(LinkAsk{Number: n, Kind: LinkRelatesTo, Target: keyOf(other), Remove: true}) case "set": _, err = h.Set(SetAsk{Number: n, Severity: "minor"}) } if err != nil { t.Fatal(err) } *now = now.Add(10 * time.Hour) // 30 h after the start, 10 h after the act if _, err := h.Move(MoveAsk{Number: n, Status: StatusFixing, By: "fix-b@ace"}); err == nil { t.Fatalf("the %s did not keep the worker current", act) } }) } }