package issue_test import ( "context" "os" "path/filepath" "strconv" "testing" "superwork-tui/internal/gitea" "superwork-tui/internal/issue" ) // fakeClient implements issue.GiteaClient using canned data. type fakeClient struct { user *gitea.User issuesByFilter map[string][]gitea.Issue // key: filterKey+":"+filterVal repoComments []gitea.Comment issueComments map[int][]gitea.Comment pullRequests map[int]*gitea.PullRequest dependencies map[int][]gitea.Issue posted []string // bodies posted via PostIssueComment } func (f *fakeClient) GetCurrentUser(ctx context.Context) (*gitea.User, error) { return f.user, nil } func (f *fakeClient) ListIssuesByFilter(ctx context.Context, owner, repo, filterKey, filterVal string) ([]gitea.Issue, error) { key := filterKey + ":" + filterVal return f.issuesByFilter[key], nil } func (f *fakeClient) ListAllRepoComments(ctx context.Context, owner, repo string) ([]gitea.Comment, error) { return f.repoComments, nil } func (f *fakeClient) GetIssue(ctx context.Context, owner, repo string, number int) (*gitea.Issue, error) { for _, bucket := range f.issuesByFilter { for i := range bucket { if bucket[i].Number == number { iss := bucket[i] return &iss, nil } } } return nil, nil } func (f *fakeClient) GetPullRequest(ctx context.Context, owner, repo string, number int) (*gitea.PullRequest, error) { pr, ok := f.pullRequests[number] if !ok { return nil, &gitea.APIError{Status: 404} } return pr, nil } func (f *fakeClient) ListIssueComments(ctx context.Context, owner, repo string, number int) ([]gitea.Comment, error) { return f.issueComments[number], nil } func (f *fakeClient) PostIssueComment(ctx context.Context, owner, repo string, number int, body string) (*gitea.Comment, error) { f.posted = append(f.posted, body) return &gitea.Comment{ID: 999, Body: body}, nil } func (f *fakeClient) GetDependencies(ctx context.Context, owner, repo string, number int) ([]gitea.Issue, error) { return f.dependencies[number], nil } // --- extractStateJSON tests --- func TestExtractStateJSON_TailScan(t *testing.T) { // The last comment is a state blob; an intermediate ordinary comment must not shadow it. comments := []gitea.Comment{ {Body: `{"column":"todo","branch":"feat/old"}`}, {Body: "just some text"}, {Body: `{"column":"in-progress","branch":"feat/new","pr":"42"}`}, {Body: "PR auto-link comment or review note"}, } state := issue.ExtractStateJSON(comments) // tail-first: last comment is plain text, skip; second-to-last is the state blob if state["column"] != "in-progress" { t.Errorf("column: got %v, want in-progress", state["column"]) } if state["branch"] != "feat/new" { t.Errorf("branch: got %v, want feat/new", state["branch"]) } if state["pr"] != "42" { t.Errorf("pr: got %v, want 42", state["pr"]) } } func TestExtractStateJSON_OrdinaryJSONSkipped(t *testing.T) { // A JSON object without any known state fields must not be treated as a state blob. comments := []gitea.Comment{ {Body: `{"column":"done","pr":"7"}`}, {Body: `{"foo":"bar","baz":123}`}, // unknown fields only } state := issue.ExtractStateJSON(comments) // The unknown-fields JSON at the end must be skipped; pick the earlier state blob. if state["column"] != "done" { t.Errorf("column: got %v, want done", state["column"]) } } func TestExtractStateJSON_NoStateBlob(t *testing.T) { comments := []gitea.Comment{ {Body: "just text"}, {Body: `{"foo":"bar"}`}, } state := issue.ExtractStateJSON(comments) if len(state) != 0 { t.Errorf("expected empty map, got %v", state) } } func TestExtractStateJSON_Empty(t *testing.T) { state := issue.ExtractStateJSON(nil) if len(state) != 0 { t.Errorf("expected empty map, got %v", state) } } func TestExtractStateJSON_Array_Skipped(t *testing.T) { // JSON arrays are not state blobs. comments := []gitea.Comment{ {Body: `{"column":"review"}`}, {Body: `[1,2,3]`}, } state := issue.ExtractStateJSON(comments) if state["column"] != "review" { t.Errorf("column: got %v, want review", state["column"]) } } // --- isValidSpxFilePath tests --- func TestIsValidSpxFilePath(t *testing.T) { valid := []string{ "docs/superpowers/specs/my-feature.md", "some/path/plan.md", } for _, v := range valid { if !issue.IsValidSpxFilePath(v) { t.Errorf("expected %q to be valid", v) } } invalid := []string{ "", "...", "…", "noSlash.md", "has/slash/but.txt", } for _, s := range invalid { if issue.IsValidSpxFilePath(s) { t.Errorf("expected %q to be invalid", s) } } } // --- loader tests --- func makeIssue(number int, state, htmlURL string) gitea.Issue { return gitea.Issue{ ID: number, Number: number, Title: "Issue " + string(rune('0'+number)), State: state, HtmlURL: htmlURL, } } func issueURL(number int) string { return "https://gitea.example.com/owner/repo/issues/" + itoa(number) } func itoa(n int) string { return strconv.Itoa(n) } func TestLoadIssues_TailScanChoosesLastStateBlob(t *testing.T) { iss := makeIssue(1, "open", "https://gitea.example.com/owner/repo/issues/1") client := &fakeClient{ user: &gitea.User{Login: "alice"}, issuesByFilter: map[string][]gitea.Issue{ "assigned_by:alice": {iss}, "created_by:alice": {}, }, repoComments: []gitea.Comment{ {Body: `{"column":"todo"}`, IssueURL: issueURL(1), CreatedAt: "2024-01-01T00:00:00Z"}, {Body: "ordinary text", IssueURL: issueURL(1), CreatedAt: "2024-01-02T00:00:00Z"}, {Body: `{"column":"in-progress","branch":"feat/1"}`, IssueURL: issueURL(1), CreatedAt: "2024-01-03T00:00:00Z"}, {Body: "PR auto-link", IssueURL: issueURL(1), CreatedAt: "2024-01-04T00:00:00Z"}, }, dependencies: map[int][]gitea.Issue{}, pullRequests: map[int]*gitea.PullRequest{}, } issues, err := issue.LoadIssues(context.Background(), client, "owner", "repo", "") if err != nil { t.Fatalf("LoadIssues: %v", err) } if len(issues) != 1 { t.Fatalf("expected 1 issue, got %d", len(issues)) } got := issues[0] if got.Column != issue.ColumnInProgress { t.Errorf("column: got %v, want in-progress", got.Column) } if got.Branch != "feat/1" { t.Errorf("branch: got %v, want feat/1", got.Branch) } } func TestLoadIssues_MissingStateSeeded(t *testing.T) { iss := makeIssue(2, "open", "https://gitea.example.com/owner/repo/issues/2") client := &fakeClient{ user: &gitea.User{Login: "bob"}, issuesByFilter: map[string][]gitea.Issue{ "assigned_by:bob": {iss}, "created_by:bob": {}, }, repoComments: []gitea.Comment{}, // no comments → no state blob dependencies: map[int][]gitea.Issue{}, pullRequests: map[int]*gitea.PullRequest{}, } issues, err := issue.LoadIssues(context.Background(), client, "owner", "repo", "") if err != nil { t.Fatalf("LoadIssues: %v", err) } if len(issues) != 1 { t.Fatalf("expected 1 issue, got %d", len(issues)) } got := issues[0] // open issue defaults to todo if got.Column != issue.ColumnTodo { t.Errorf("column: got %v, want todo", got.Column) } // A seed comment must have been posted if len(client.posted) != 1 { t.Errorf("expected 1 posted comment, got %d", len(client.posted)) } } func TestLoadIssues_DoneSkipsPRAndDeps(t *testing.T) { iss := makeIssue(3, "closed", "https://gitea.example.com/owner/repo/issues/3") client := &fakeClient{ user: &gitea.User{Login: "carol"}, issuesByFilter: map[string][]gitea.Issue{ "assigned_by:carol": {iss}, "created_by:carol": {}, }, repoComments: []gitea.Comment{ {Body: `{"column":"done","pr":"10"}`, IssueURL: issueURL(3), CreatedAt: "2024-01-01T00:00:00Z"}, }, dependencies: map[int][]gitea.Issue{ 3: {{Number: 1}}, }, pullRequests: map[int]*gitea.PullRequest{ 10: {Number: 10, Merged: true, MergedAt: "2024-02-01T00:00:00Z"}, }, } prCallCount := 0 depsCallCount := 0 _ = prCallCount _ = depsCallCount issues, err := issue.LoadIssues(context.Background(), client, "owner", "repo", "") if err != nil { t.Fatalf("LoadIssues: %v", err) } if len(issues) != 1 { t.Fatalf("expected 1 issue, got %d", len(issues)) } got := issues[0] if got.Column != issue.ColumnDone { t.Errorf("column: got %v, want done", got.Column) } // done column: prerequisite must be 0 (skipped), prMerged is from state JSON if got.Prerequisite != 0 { t.Errorf("prerequisite: got %d, want 0 (done skips deps)", got.Prerequisite) } // PrMerged not live-fetched but state JSON had pr:"10"; prMerged not in state JSON → false // The point: no live PR fetch happened (we can't easily assert that without a sentinel, // but we assert the column and prerequisite are correct) } func TestLoadIssues_Prerequisite(t *testing.T) { iss := makeIssue(5, "open", "https://gitea.example.com/owner/repo/issues/5") client := &fakeClient{ user: &gitea.User{Login: "dave"}, issuesByFilter: map[string][]gitea.Issue{ "assigned_by:dave": {iss}, "created_by:dave": {}, }, repoComments: []gitea.Comment{ {Body: `{"column":"todo"}`, IssueURL: issueURL(5), CreatedAt: "2024-01-01T00:00:00Z"}, }, dependencies: map[int][]gitea.Issue{ 5: {{Number: 3}, {Number: 4}}, // first dep is prerequisite }, pullRequests: map[int]*gitea.PullRequest{}, } issues, err := issue.LoadIssues(context.Background(), client, "owner", "repo", "") if err != nil { t.Fatalf("LoadIssues: %v", err) } if len(issues) != 1 { t.Fatalf("expected 1 issue, got %d", len(issues)) } if issues[0].Prerequisite != 3 { t.Errorf("prerequisite: got %d, want 3", issues[0].Prerequisite) } } func TestLoadIssues_WorktreeExists(t *testing.T) { tmp := t.TempDir() worktree := filepath.Join(tmp, "trees", "feat-5") if err := os.MkdirAll(worktree, 0o755); err != nil { t.Fatal(err) } relWorktree := "trees/feat-5" iss := makeIssue(6, "open", "https://gitea.example.com/owner/repo/issues/6") client := &fakeClient{ user: &gitea.User{Login: "eve"}, issuesByFilter: map[string][]gitea.Issue{ "assigned_by:eve": {iss}, "created_by:eve": {}, }, repoComments: []gitea.Comment{ { Body: `{"column":"in-progress","worktreePath":"trees/feat-5"}`, IssueURL: issueURL(6), CreatedAt: "2024-01-01T00:00:00Z", }, }, dependencies: map[int][]gitea.Issue{}, pullRequests: map[int]*gitea.PullRequest{}, } issues, err := issue.LoadIssues(context.Background(), client, "owner", "repo", tmp) if err != nil { t.Fatalf("LoadIssues: %v", err) } if len(issues) != 1 { t.Fatalf("expected 1 issue, got %d", len(issues)) } got := issues[0] if got.WorktreePath != relWorktree { t.Errorf("worktreePath: got %q, want %q", got.WorktreePath, relWorktree) } if !got.WorktreeExists { t.Errorf("worktreeExists: got false, want true") } } func TestLoadSingleIssue_NotFound(t *testing.T) { client := &fakeClient{ user: &gitea.User{Login: "frank"}, issuesByFilter: map[string][]gitea.Issue{}, repoComments: nil, issueComments: map[int][]gitea.Comment{}, dependencies: map[int][]gitea.Issue{}, pullRequests: map[int]*gitea.PullRequest{}, } got, err := issue.LoadSingleIssue(context.Background(), client, "owner", "repo", 999, "") if err != nil { t.Fatalf("LoadSingleIssue: %v", err) } if got != nil { t.Errorf("expected nil for missing issue, got %+v", got) } }