package loop import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" "time" "ukrrs.com/mopac/harness/internal/config" ) // fakeRedmine is a mutable in-memory Redmine: /issues.json scope listing, // PUT /issues/{id}.json (note + status), /issue_statuses.json, and // GET /issues/{id}.json (updated_on). Notes bump updated_on like the real // SoR, so the loop's refresh logic is exercised for real. type fakeRedmine struct { mu sync.Mutex issues map[string]struct { subject string status string updatedOn string notes []string } statusIDs map[string]int noted []string // issue ids that got a note, in order srv *httptest.Server } func newFakeRedmine(t *testing.T) *fakeRedmine { t.Helper() f := &fakeRedmine{ issues: map[string]struct { subject string status string updatedOn string notes []string }{}, statusIDs: map[string]int{"New": 1, "In Progress": 2, "Done": 3}, } mux := http.NewServeMux() mux.HandleFunc("GET /issues.json", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() var b strings.Builder b.WriteString(`{"issues":[`) first := true for id, is := range f.issues { if !first { b.WriteString(",") } first = false fmt.Fprintf(&b, `{"id":%s,"subject":%q,"description":"do the thing","updated_on":%q,"status":{"name":%q},"custom_fields":[{"name":"Class","value":"primary"}]}`, id, is.subject, is.updatedOn, is.status) } b.WriteString(`]}`) w.Header().Set("Content-Type", "application/json") w.Write([]byte(b.String())) }) mux.HandleFunc("PUT /issues/", func(w http.ResponseWriter, r *http.Request) { id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/issues/"), ".json") var body struct { Issue struct { Notes string `json:"notes"` StatusID int `json:"status_id"` } `json:"issue"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } f.mu.Lock() defer f.mu.Unlock() is, ok := f.issues[id] if !ok { http.Error(w, "not found", http.StatusNotFound) return } if body.Issue.Notes != "" { is.notes = append(is.notes, body.Issue.Notes) f.noted = append(f.noted, id) } if body.Issue.StatusID != 0 { for name, sid := range f.statusIDs { if sid == body.Issue.StatusID { is.status = name } } } is.updatedOn = time.Now().UTC().Format(time.RFC3339) f.issues[id] = is w.WriteHeader(http.StatusNoContent) }) mux.HandleFunc("GET /issue_statuses.json", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() var b strings.Builder b.WriteString(`{"issue_statuses":[`) first := true for name, id := range f.statusIDs { if !first { b.WriteString(",") } first = false fmt.Fprintf(&b, `{"id":%d,"name":%q}`, id, name) } b.WriteString(`]}`) w.Header().Set("Content-Type", "application/json") w.Write([]byte(b.String())) }) mux.HandleFunc("GET /issues/", func(w http.ResponseWriter, r *http.Request) { id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/issues/"), ".json") f.mu.Lock() defer f.mu.Unlock() is, ok := f.issues[id] if !ok { http.Error(w, "not found", http.StatusNotFound) return } fmt.Fprintf(w, `{"issue":{"id":%s,"updated_on":%q}}`, id, is.updatedOn) }) f.srv = httptest.NewServer(mux) t.Cleanup(f.srv.Close) return f } func (f *fakeRedmine) addIssue(id, subject, status, updatedOn string) { f.mu.Lock() defer f.mu.Unlock() f.issues[id] = struct { subject string status string updatedOn string notes []string }{subject: subject, status: status, updatedOn: updatedOn} } func (f *fakeRedmine) setUpdatedOn(id, updatedOn string) { f.mu.Lock() defer f.mu.Unlock() if is, ok := f.issues[id]; ok { is.updatedOn = updatedOn f.issues[id] = is } } func (f *fakeRedmine) status(id string) string { f.mu.Lock() defer f.mu.Unlock() return f.issues[id].status } func (f *fakeRedmine) noteCount(id string) int { f.mu.Lock() defer f.mu.Unlock() return len(f.issues[id].notes) } func loopTestConfig(t *testing.T, redmineURL, llmURL string) *config.Config { t.Helper() cfg := testConfig(t, llmURL) cfg.Redmine.URL = redmineURL cfg.Redmine.KeyRef = "literal:rm-key" cfg.Redmine.ScopeQuery = "project=mopac" cfg.Redmine.StatusMap = map[string]string{"In Progress": "Done"} cfg.Loop.StateDir = filepath.Join(t.TempDir(), "state-loop") cfg.Loop.PollIntervalSecs = 3600 // between-scan sleep; tests use --once scans return cfg } // runOneScan runs the daemon in --once mode against the given servers. func runOneScan(t *testing.T, cfg *config.Config, dryRun bool) string { t.Helper() var out strings.Builder cond, err := New(cfg, &out) if err != nil { t.Fatal(err) } if err := cond.RunLoop(context.Background(), LoopOpts{Once: true, DryRun: dryRun}); err != nil { t.Fatalf("RunLoop: %v (output:\n%s)", err, out.String()) } return out.String() } func TestLoopDispatchNoteStatusDedup(t *testing.T) { rm := newFakeRedmine(t) rm.addIssue("421", "Self-host: write the note", "In Progress", "2026-08-28T21:30:00Z") f := newFakeLLM(t, textMsg("Report body: the turn ran.")) cfg := loopTestConfig(t, rm.srv.URL, f.srv.URL) out := runOneScan(t, cfg, false) // One LLM turn, routed through the class map. if f.requestCount() != 1 { t.Fatalf("LLM calls = %d, want 1", f.requestCount()) } for _, want := range []string{ "dispatch 421", "noted REPORT on #421", "status In Progress -> Done on #421", } { if !strings.Contains(out, want) { t.Errorf("output missing %q:\n%s", want, out) } } if rm.status("421") != "Done" { t.Errorf("issue status = %q, want Done", rm.status("421")) } if rm.noteCount("421") != 1 { t.Fatalf("notes on #421 = %d, want 1", rm.noteCount("421")) } if body := rm.issues["421"].notes[0]; !strings.Contains(body, "Report body: the turn ran.") || !strings.Contains(body, "# REPORT - teststack - 421") { t.Errorf("note body wrong:\n%s", body) } // REPORT file landed too. reports, err := filepath.Glob(filepath.Join(cfg.ReportDir, "REPORT-teststack-421-*.md")) if err != nil || len(reports) != 1 { t.Fatalf("REPORT files = %v err=%v", reports, err) } // Second scan: our own note bumped updated_on, but the refresh must // have advanced the dedup marker -> no re-dispatch. f2 := newFakeLLM(t, textMsg("must not run")) cfg.LiteLLM.BaseURL = f2.srv.URL out2 := runOneScan(t, cfg, false) if f2.requestCount() != 0 { t.Fatalf("second scan re-dispatched (LLM calls = %d)", f2.requestCount()) } if !strings.Contains(out2, "0 new/updated") { t.Errorf("second scan should find nothing new:\n%s", out2) } if rm.noteCount("421") != 1 { t.Errorf("second scan added notes: %d", rm.noteCount("421")) } } func TestLoopRedispatchOnIssueUpdate(t *testing.T) { rm := newFakeRedmine(t) rm.addIssue("421", "First edit", "In Progress", "2026-08-28T21:30:00Z") f := newFakeLLM(t, textMsg("first reply")) cfg := loopTestConfig(t, rm.srv.URL, f.srv.URL) runOneScan(t, cfg, false) if f.requestCount() != 1 { t.Fatalf("first scan LLM calls = %d", f.requestCount()) } // PMO updates the issue (bumps updated_on): the loop re-releases it. rm.setUpdatedOn("421", "2026-08-28T22:30:00Z") out := runOneScan(t, cfg, false) if !strings.Contains(out, "1 new/updated") { t.Errorf("updated issue should be new:\n%s", out) } if f.requestCount() != 2 { t.Errorf("LLM calls after update = %d, want 2", f.requestCount()) } if rm.noteCount("421") != 2 { t.Errorf("notes after update = %d, want 2", rm.noteCount("421")) } } func TestLoopTurnFailureRecordedNoHotLoop(t *testing.T) { rm := newFakeRedmine(t) rm.addIssue("421", "Broken task", "In Progress", "2026-08-28T21:30:00Z") srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "proxy down", http.StatusBadGateway) })) defer srv.Close() cfg := loopTestConfig(t, rm.srv.URL, srv.URL) out := runOneScan(t, cfg, false) if !strings.Contains(out, "turn failed") { t.Errorf("output should record the failed turn:\n%s", out) } // No note, no status change on a failed turn... if rm.noteCount("421") != 0 || rm.status("421") != "In Progress" { t.Errorf("failed turn must not note/status: notes=%d status=%s", rm.noteCount("421"), rm.status("421")) } // ...and the next scan must NOT retry it (updated_on marker consumed). out2 := runOneScan(t, cfg, false) if !strings.Contains(out2, "0 new/updated") { t.Errorf("failed turn hot-looped:\n%s", out2) } } func TestLoopDryRunDispatchesNothing(t *testing.T) { rm := newFakeRedmine(t) rm.addIssue("421", "Would-be task", "In Progress", "2026-08-28T21:30:00Z") f := newFakeLLM(t, textMsg("unused")) cfg := loopTestConfig(t, rm.srv.URL, f.srv.URL) out := runOneScan(t, cfg, true) if f.requestCount() != 0 { t.Errorf("dry-run made %d LLM calls", f.requestCount()) } if !strings.Contains(out, "would dispatch 421") { t.Errorf("dry-run should print the would-be dispatch:\n%s", out) } if _, err := os.Stat(filepath.Join(cfg.Loop.StateDir, "loop.jsonl")); !os.IsNotExist(err) { t.Errorf("dry-run must not write loop state") } // A dry-run must not consume the task: a real scan still dispatches. runOneScan(t, cfg, false) if f.requestCount() != 1 { t.Errorf("LLM calls after real scan = %d, want 1", f.requestCount()) } } func TestLoopStateSurvivesRestart(t *testing.T) { rm := newFakeRedmine(t) rm.addIssue("421", "Persisted", "In Progress", "2026-08-28T21:30:00Z") f := newFakeLLM(t, textMsg("reply")) cfg := loopTestConfig(t, rm.srv.URL, f.srv.URL) runOneScan(t, cfg, false) // New conductor = process restart: state reloads from loop.jsonl, and // the refresh line (post-note updated_on) is what dedups. f2 := newFakeLLM(t, textMsg("must not run")) cfg.LiteLLM.BaseURL = f2.srv.URL runOneScan(t, cfg, false) if f2.requestCount() != 0 { t.Errorf("restart re-dispatched a processed issue") } } func TestLoopStatusMapMissLeavesStatus(t *testing.T) { rm := newFakeRedmine(t) rm.addIssue("421", "Unmapped status", "Review", "2026-08-28T21:30:00Z") f := newFakeLLM(t, textMsg("reply")) cfg := loopTestConfig(t, rm.srv.URL, f.srv.URL) out := runOneScan(t, cfg, false) if strings.Contains(out, "status Review ->") { t.Errorf("unmapped status must not transition:\n%s", out) } if rm.status("421") != "Review" { t.Errorf("status changed: %q", rm.status("421")) } if rm.noteCount("421") != 1 { t.Errorf("note should still land, got %d", rm.noteCount("421")) } } func TestLoopRequiresRedmineConfig(t *testing.T) { cfg := testConfig(t, "http://unused") cond, err := New(cfg, os.Stdout) if err != nil { t.Fatal(err) } err = cond.RunLoop(context.Background(), LoopOpts{Once: true}) if err == nil || !strings.Contains(err.Error(), "[redmine] url is required") { t.Fatalf("err = %v, want missing-redmine error", err) } } func TestLoopGiteaCommitStep(t *testing.T) { rm := newFakeRedmine(t) rm.addIssue("421", "Commit me", "In Progress", "2026-08-28T21:30:00Z") f := newFakeLLM(t, textMsg("reply with body")) var mu sync.Mutex var commits []map[string]any var authHeaders []string gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() defer mu.Unlock() authHeaders = append(authHeaders, r.Header.Get("Authorization")) if r.Method == http.MethodGet { http.Error(w, `{"message":"Not Found"}`, http.StatusNotFound) return } var body map[string]any json.NewDecoder(r.Body).Decode(&body) commits = append(commits, body) w.Write([]byte(`{"content":{"path":"x"}}`)) })) defer gitea.Close() cfg := loopTestConfig(t, rm.srv.URL, f.srv.URL) cfg.Gitea = config.GiteaConfig{ URL: gitea.URL, KeyRef: "literal:gt-key", Owner: "ukrrs", Repo: "reports", Branch: "main", CommitReports: true, } out := runOneScan(t, cfg, false) mu.Lock() defer mu.Unlock() if len(commits) != 1 { t.Fatalf("gitea commits = %d, want 1", len(commits)) } if authHeaders[0] != "token gt-key" { t.Errorf("gitea auth = %q", authHeaders[0]) } path, _ := commits[0]["message"].(string) if !strings.Contains(out, "gitea: committed") { t.Errorf("output missing gitea commit line:\n%s", out) } if msg, _ := commits[0]["message"].(string); !strings.Contains(msg, "task 421") { t.Errorf("commit message = %q", msg) } _ = path if commits[0]["branch"] != "main" { t.Errorf("commit branch = %v", commits[0]["branch"]) } }