Files
kandev/apps/backend/internal/linear/service_issue_watch_test.go
T
2026-07-06 21:43:13 +01:00

858 lines
29 KiB
Go

package linear
import (
"context"
"errors"
"fmt"
"math"
"testing"
"github.com/kandev/kandev/internal/integrations/optional"
)
func TestService_CreateIssueWatch_AcceptsRichFilters(t *testing.T) {
f := newSvcFixture(t)
ctx := context.Background()
min := 1.0
cases := map[string]SearchFilter{
"priority": {Priorities: []int{1}},
"labels": {LabelIDs: []string{"l1"}},
"creator": {CreatorID: "u1"},
"estimateMin": {EstimateMin: &min},
}
for name, filter := range cases {
w, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{
WorkspaceID: "ws-1",
WorkflowID: "wf",
WorkflowStepID: "step",
Filter: filter,
})
if err != nil {
t.Errorf("%s: create rejected: %v", name, err)
continue
}
if w.ID == "" {
t.Errorf("%s: expected ID assigned", name)
}
}
}
// fakeRepoLookup is a static RepositoryLookup for repository-binding tests.
type fakeRepoLookup struct {
workspaceID string
defaultBranch string
ok bool
}
func (f fakeRepoLookup) GetRepository(_ context.Context, _ string) (string, string, bool) {
return f.workspaceID, f.defaultBranch, f.ok
}
func TestService_CreateIssueWatch_RepositoryBinding(t *testing.T) {
ctx := context.Background()
baseReq := func() *CreateIssueWatchRequest {
return &CreateIssueWatchRequest{
WorkspaceID: "ws-1",
WorkflowID: "wf",
WorkflowStepID: "step",
Filter: SearchFilter{TeamKey: "ENG"},
}
}
t.Run("default branch filled from repo", func(t *testing.T) {
f := newSvcFixture(t)
f.svc.SetRepositoryLookup(fakeRepoLookup{workspaceID: "ws-1", defaultBranch: "main", ok: true})
req := baseReq()
req.RepositoryID = "repo-1"
w, err := f.svc.CreateIssueWatch(ctx, req)
if err != nil {
t.Fatalf("create: %v", err)
}
if w.RepositoryID != "repo-1" || w.BaseBranch != "main" {
t.Fatalf("expected repo-1@main, got repo=%q branch=%q", w.RepositoryID, w.BaseBranch)
}
})
t.Run("explicit branch preserved", func(t *testing.T) {
f := newSvcFixture(t)
f.svc.SetRepositoryLookup(fakeRepoLookup{workspaceID: "ws-1", defaultBranch: "main", ok: true})
req := baseReq()
req.RepositoryID = "repo-1"
req.BaseBranch = "release/v2"
w, err := f.svc.CreateIssueWatch(ctx, req)
if err != nil {
t.Fatalf("create: %v", err)
}
if w.BaseBranch != "release/v2" {
t.Fatalf("explicit branch overwritten: %q", w.BaseBranch)
}
})
t.Run("cross-workspace repo rejected", func(t *testing.T) {
f := newSvcFixture(t)
f.svc.SetRepositoryLookup(fakeRepoLookup{workspaceID: "ws-other", defaultBranch: "main", ok: true})
req := baseReq()
req.RepositoryID = "repo-1"
if _, err := f.svc.CreateIssueWatch(ctx, req); !errors.Is(err, ErrInvalidConfig) {
t.Fatalf("expected ErrInvalidConfig for cross-workspace repo, got %v", err)
}
})
t.Run("missing repo rejected", func(t *testing.T) {
f := newSvcFixture(t)
f.svc.SetRepositoryLookup(fakeRepoLookup{ok: false})
req := baseReq()
req.RepositoryID = "ghost"
if _, err := f.svc.CreateIssueWatch(ctx, req); !errors.Is(err, ErrInvalidConfig) {
t.Fatalf("expected ErrInvalidConfig for missing repo, got %v", err)
}
})
t.Run("unbound skips lookup and clears branch", func(t *testing.T) {
f := newSvcFixture(t) // no RepositoryLookup wired
req := baseReq()
req.BaseBranch = "ignored-without-repo"
w, err := f.svc.CreateIssueWatch(ctx, req)
if err != nil {
t.Fatalf("create: %v", err)
}
if w.RepositoryID != "" || w.BaseBranch != "" {
t.Fatalf("unbound watch must stay empty, got repo=%q branch=%q", w.RepositoryID, w.BaseBranch)
}
})
}
// withSearchResults returns a fakeClient that always returns the given issues
// for SearchIssues, ignoring the filter.
func (c *fakeClient) withSearchResults(issues []LinearIssue) *fakeClient {
c.searchIssuesFn = func(_ SearchFilter, _ string, _ int) (*SearchResult, error) {
return &SearchResult{Issues: issues, IsLast: true}, nil
}
return c
}
func TestService_CreateIssueWatch_DefaultsAndValidation(t *testing.T) {
f := newSvcFixture(t)
ctx := context.Background()
// Empty filter is rejected.
if _, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{
WorkspaceID: "ws-1",
WorkflowID: "wf",
WorkflowStepID: "step",
}); !errors.Is(err, ErrInvalidConfig) {
t.Errorf("expected ErrInvalidConfig for empty filter, got %v", err)
}
// Whitespace-only fields are also rejected (normalize then check).
if _, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{
WorkspaceID: "ws-1",
WorkflowID: "wf",
WorkflowStepID: "step",
Filter: SearchFilter{Query: " ", TeamKey: " ", StateIDs: []string{" ", ""}},
}); !errors.Is(err, ErrInvalidConfig) {
t.Errorf("expected ErrInvalidConfig for whitespace-only filter, got %v", err)
}
// Happy path assigns ID + defaults Enabled=true.
w, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{
WorkspaceID: "ws-1",
WorkflowID: "wf",
WorkflowStepID: "step",
Filter: SearchFilter{TeamKey: "ENG"},
})
if err != nil {
t.Fatalf("create: %v", err)
}
if w.ID == "" {
t.Fatal("expected ID assigned")
}
if !w.Enabled {
t.Error("expected Enabled defaulted to true")
}
if w.Filter.TeamKey != "ENG" {
t.Errorf("filter not persisted: %+v", w.Filter)
}
}
func TestService_UpdateIssueWatch_PartialPatch(t *testing.T) {
f := newSvcFixture(t)
ctx := context.Background()
created, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{
WorkspaceID: "ws-1",
WorkflowID: "wf",
WorkflowStepID: "step",
Filter: SearchFilter{TeamKey: "ENG"},
Prompt: "original",
})
if err != nil {
t.Fatalf("create: %v", err)
}
// Patch only the prompt; everything else must remain.
newPrompt := "updated"
updated, err := f.svc.UpdateIssueWatch(ctx, created.ID, &UpdateIssueWatchRequest{
Prompt: &newPrompt,
})
if err != nil {
t.Fatalf("update: %v", err)
}
if updated.Prompt != "updated" {
t.Errorf("prompt not patched: %q", updated.Prompt)
}
if updated.Filter.TeamKey != "ENG" || updated.WorkspaceID != created.WorkspaceID {
t.Errorf("unexpected mutation of unset fields: %+v", updated)
}
// Patching filter to something empty is rejected to keep watch rows valid.
empty := SearchFilter{}
if _, err := f.svc.UpdateIssueWatch(ctx, created.ID, &UpdateIssueWatchRequest{Filter: &empty}); !errors.Is(err, ErrInvalidConfig) {
t.Errorf("expected ErrInvalidConfig for empty filter patch, got %v", err)
}
}
func TestService_IssueWatch_MaxInflightTasks(t *testing.T) {
f := newSvcFixture(t)
ctx := context.Background()
// Default create (no cap supplied) persists NULL → reads back as nil
// (uncapped). The store column default of 5 must NOT leak through the
// INSERT path, which names the column explicitly with a NULL bind.
uncapped, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{
WorkspaceID: "ws-1", WorkflowID: "wf", WorkflowStepID: "step",
Filter: SearchFilter{TeamKey: "ENG"},
})
if err != nil {
t.Fatalf("create uncapped: %v", err)
}
if uncapped.MaxInflightTasks != nil {
t.Fatalf("expected nil (uncapped), got %v", *uncapped.MaxInflightTasks)
}
got, err := f.svc.GetIssueWatch(ctx, uncapped.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.MaxInflightTasks != nil {
t.Fatalf("uncapped did not round-trip as nil: %v", *got.MaxInflightTasks)
}
// Create with a positive cap round-trips through the nullable column.
cap5 := 5
capped, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{
WorkspaceID: "ws-1", WorkflowID: "wf", WorkflowStepID: "step",
Filter: SearchFilter{TeamKey: "ENG"}, MaxInflightTasks: &cap5,
})
if err != nil {
t.Fatalf("create capped: %v", err)
}
if capped.MaxInflightTasks == nil || *capped.MaxInflightTasks != 5 {
t.Fatalf("cap not persisted: %v", capped.MaxInflightTasks)
}
// Non-positive caps are rejected on create.
for _, bad := range []int{0, -1} {
b := bad
if _, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{
WorkspaceID: "ws-1", WorkflowID: "wf", WorkflowStepID: "step",
Filter: SearchFilter{TeamKey: "ENG"}, MaxInflightTasks: &b,
}); !errors.Is(err, ErrInvalidConfig) {
t.Errorf("expected ErrInvalidConfig for cap=%d, got %v", bad, err)
}
}
// PATCH is tri-state: present+int sets the cap, present+null clears it,
// absent leaves it unchanged. The absent case is the footgun greptile/the
// local reviewer flagged — a partial update must NOT silently drop the cap.
cap3 := 3
updated, err := f.svc.UpdateIssueWatch(ctx, capped.ID, &UpdateIssueWatchRequest{
MaxInflightTasks: optional.Int{Present: true, Value: &cap3},
})
if err != nil {
t.Fatalf("update set cap: %v", err)
}
if updated.MaxInflightTasks == nil || *updated.MaxInflightTasks != 3 {
t.Fatalf("cap not updated: %v", updated.MaxInflightTasks)
}
// Partial PATCH that omits MaxInflightTasks (Present=false) must preserve
// the existing cap of 3 — not reset it to uncapped.
newPrompt := "changed"
preserved, err := f.svc.UpdateIssueWatch(ctx, capped.ID, &UpdateIssueWatchRequest{
Prompt: &newPrompt,
})
if err != nil {
t.Fatalf("partial update: %v", err)
}
if preserved.MaxInflightTasks == nil || *preserved.MaxInflightTasks != 3 {
t.Fatalf("partial PATCH wrongly cleared the cap: %v", preserved.MaxInflightTasks)
}
// Explicit null clears the cap back to uncapped.
cleared, err := f.svc.UpdateIssueWatch(ctx, capped.ID, &UpdateIssueWatchRequest{
MaxInflightTasks: optional.Int{Present: true, Value: nil},
})
if err != nil {
t.Fatalf("update clear cap: %v", err)
}
if cleared.MaxInflightTasks != nil {
t.Fatalf("expected cap cleared to nil, got %v", *cleared.MaxInflightTasks)
}
// Non-positive cap is rejected on update too.
zero := 0
if _, err := f.svc.UpdateIssueWatch(ctx, capped.ID, &UpdateIssueWatchRequest{
MaxInflightTasks: optional.Int{Present: true, Value: &zero},
}); !errors.Is(err, ErrInvalidConfig) {
t.Errorf("expected ErrInvalidConfig for update cap=0, got %v", err)
}
}
func TestValidateSortBy(t *testing.T) {
for _, ok := range []IssueSortBy{
SortByDefault, SortByPriorityDesc, SortByPriorityAsc,
SortByCreatedDesc, SortByCreatedAsc, SortByUpdatedDesc, SortByUpdatedAsc,
} {
if err := validateSortBy(ok); err != nil {
t.Errorf("validateSortBy(%q) rejected a known value: %v", ok, err)
}
}
if err := validateSortBy(IssueSortBy("bogus")); !errors.Is(err, ErrInvalidConfig) {
t.Errorf("expected ErrInvalidConfig for unknown sortBy, got %v", err)
}
}
func TestService_IssueWatch_SortByRoundTrips(t *testing.T) {
f := newSvcFixture(t)
ctx := context.Background()
created, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{
WorkspaceID: "ws-1", WorkflowID: "wf", WorkflowStepID: "step",
Filter: SearchFilter{TeamKey: "ENG"}, SortBy: SortByPriorityDesc,
})
if err != nil {
t.Fatalf("create with sortBy: %v", err)
}
if created.SortBy != SortByPriorityDesc {
t.Fatalf("sortBy not set on create: %q", created.SortBy)
}
got, err := f.svc.GetIssueWatch(ctx, created.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.SortBy != SortByPriorityDesc {
t.Fatalf("sortBy did not round-trip: %q", got.SortBy)
}
// PATCH from one allowed value to another persists through the edit path
// (UpdateIssueWatchRequest.SortBy → applyIssueWatchPatch → validate → store).
newSort := SortByCreatedAsc
updated, err := f.svc.UpdateIssueWatch(ctx, created.ID, &UpdateIssueWatchRequest{SortBy: &newSort})
if err != nil {
t.Fatalf("update sortBy: %v", err)
}
if updated.SortBy != SortByCreatedAsc {
t.Fatalf("sortBy not applied on update: %q", updated.SortBy)
}
if got, err := f.svc.GetIssueWatch(ctx, created.ID); err != nil {
t.Fatalf("get after update: %v", err)
} else if got.SortBy != SortByCreatedAsc {
t.Fatalf("updated sortBy did not persist: %q", got.SortBy)
}
// PATCH back to "" pins the "Linear default order" case.
defaultSort := SortByDefault
if _, err := f.svc.UpdateIssueWatch(ctx, created.ID, &UpdateIssueWatchRequest{SortBy: &defaultSort}); err != nil {
t.Fatalf("update sortBy to default: %v", err)
}
if got, err := f.svc.GetIssueWatch(ctx, created.ID); err != nil {
t.Fatalf("get after reset: %v", err)
} else if got.SortBy != SortByDefault {
t.Fatalf("reset sortBy did not persist: %q", got.SortBy)
}
// PATCH with an unknown sortBy is rejected.
bogus := IssueSortBy("bogus")
if _, err := f.svc.UpdateIssueWatch(ctx, created.ID, &UpdateIssueWatchRequest{SortBy: &bogus}); !errors.Is(err, ErrInvalidConfig) {
t.Errorf("expected ErrInvalidConfig for unknown sortBy on update, got %v", err)
}
// Create with an unknown sortBy is rejected.
if _, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{
WorkspaceID: "ws-1", WorkflowID: "wf", WorkflowStepID: "step",
Filter: SearchFilter{TeamKey: "ENG"}, SortBy: IssueSortBy("bogus"),
}); !errors.Is(err, ErrInvalidConfig) {
t.Errorf("expected ErrInvalidConfig for unknown sortBy on create, got %v", err)
}
}
func TestValidateFilterBounds_RejectsNonFiniteAndNegativeEstimates(t *testing.T) {
nan := math.NaN()
posInf := math.Inf(1)
negInf := math.Inf(-1)
neg := -1.0
cases := map[string]SearchFilter{
"estimateMin NaN": {EstimateMin: &nan},
"estimateMax NaN": {EstimateMax: &nan},
"estimateMin +Inf": {EstimateMin: &posInf},
"estimateMax -Inf": {EstimateMax: &negInf},
"estimateMin < 0": {EstimateMin: &neg},
"estimateMax < 0": {EstimateMax: &neg},
}
for name, f := range cases {
t.Run(name, func(t *testing.T) {
if err := validateFilterBounds(f); !errors.Is(err, ErrInvalidConfig) {
t.Errorf("expected ErrInvalidConfig, got %v", err)
}
})
}
}
func TestService_CreateIssueWatch_RejectsOutOfRangeInterval(t *testing.T) {
f := newSvcFixture(t)
ctx := context.Background()
for _, n := range []int{1, 30, 59, 3601, 86400} {
_, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{
WorkspaceID: "ws-1",
WorkflowID: "wf",
WorkflowStepID: "step",
Filter: SearchFilter{TeamKey: "ENG"},
PollIntervalSeconds: n,
})
if !errors.Is(err, ErrInvalidConfig) {
t.Errorf("expected ErrInvalidConfig for pollIntervalSeconds=%d, got %v", n, err)
}
}
// Zero is allowed (the store coerces to default).
if _, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{
WorkspaceID: "ws-1",
WorkflowID: "wf",
WorkflowStepID: "step",
Filter: SearchFilter{TeamKey: "ENG"},
}); err != nil {
t.Errorf("expected zero pollIntervalSeconds to be accepted, got %v", err)
}
}
func TestService_UpdateIssueWatch_RejectsEmptyWorkflowFields(t *testing.T) {
f := newSvcFixture(t)
ctx := context.Background()
created, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{
WorkspaceID: "ws-1", WorkflowID: "wf", WorkflowStepID: "step",
Filter: SearchFilter{TeamKey: "ENG"},
})
if err != nil {
t.Fatalf("create: %v", err)
}
empty := ""
for _, req := range []*UpdateIssueWatchRequest{
{WorkflowID: &empty},
{WorkflowStepID: &empty},
} {
if _, err := f.svc.UpdateIssueWatch(ctx, created.ID, req); !errors.Is(err, ErrInvalidConfig) {
t.Errorf("expected ErrInvalidConfig for %+v, got %v", req, err)
}
}
}
func TestService_UpdateIssueWatch_NotFound(t *testing.T) {
f := newSvcFixture(t)
prompt := "x"
_, err := f.svc.UpdateIssueWatch(context.Background(), "ghost", &UpdateIssueWatchRequest{Prompt: &prompt})
if !errors.Is(err, ErrIssueWatchNotFound) {
t.Errorf("expected ErrIssueWatchNotFound, got %v", err)
}
}
func TestService_CheckIssueWatch_FiltersAlreadySeen(t *testing.T) {
f := newSvcFixture(t)
ctx := context.Background()
if _, err := f.svc.SetConfigForWorkspace(ctx, "ws-1", &SetConfigRequest{
AuthMethod: AuthMethodAPIKey, Secret: "lin_api",
}); err != nil {
t.Fatalf("set config: %v", err)
}
f.client.withSearchResults([]LinearIssue{
{Identifier: "ENG-1", Title: "one", URL: "https://linear.app/x/issue/ENG-1"},
{Identifier: "ENG-2", Title: "two", URL: "https://linear.app/x/issue/ENG-2"},
{Identifier: "ENG-3", Title: "three", URL: "https://linear.app/x/issue/ENG-3"},
})
w, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{
WorkspaceID: "ws-1", WorkflowID: "wf", WorkflowStepID: "step",
Filter: SearchFilter{TeamKey: "ENG"},
})
if err != nil {
t.Fatalf("create watch: %v", err)
}
// Pre-seed ENG-2 as already turned into a task.
if _, err := f.store.ReserveIssueWatchTask(ctx, w.ID, "ENG-2", "https://linear.app/x/issue/ENG-2"); err != nil {
t.Fatalf("seed reservation: %v", err)
}
got, err := f.svc.CheckIssueWatch(ctx, w)
if err != nil {
t.Fatalf("check: %v", err)
}
if len(got) != 2 {
t.Fatalf("expected 2 unseen issues, got %d", len(got))
}
for _, i := range got {
if i.Identifier == "ENG-2" {
t.Error("ENG-2 should have been filtered as already seen")
}
}
refreshed, _ := f.store.GetIssueWatch(ctx, w.ID)
if refreshed.LastPolledAt == nil {
t.Error("expected last_polled_at stamped after check")
}
}
func TestService_CheckIssueWatch_AppliesSort(t *testing.T) {
f := newSvcFixture(t)
ctx := context.Background()
if _, err := f.svc.SetConfigForWorkspace(ctx, "ws-1", &SetConfigRequest{
AuthMethod: AuthMethodAPIKey, Secret: "lin_api",
}); err != nil {
t.Fatalf("set config: %v", err)
}
// Mixed priorities in scrambled order; the watch's SortByPriorityDesc must
// reorder the returned slice urgent→high→med→low→none. Pins the
// sortIssues(out, w.SortBy) call in CheckIssueWatch.
f.client.withSearchResults([]LinearIssue{
{Identifier: "LOW", Priority: 4, URL: "https://linear.app/x/issue/LOW"},
{Identifier: "URGENT", Priority: 1, URL: "https://linear.app/x/issue/URGENT"},
{Identifier: "NONE", Priority: 0, URL: "https://linear.app/x/issue/NONE"},
{Identifier: "HIGH", Priority: 2, URL: "https://linear.app/x/issue/HIGH"},
})
w, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{
WorkspaceID: "ws-1", WorkflowID: "wf", WorkflowStepID: "step",
Filter: SearchFilter{TeamKey: "ENG"}, SortBy: SortByPriorityDesc,
})
if err != nil {
t.Fatalf("create watch: %v", err)
}
got, err := f.svc.CheckIssueWatch(ctx, w)
if err != nil {
t.Fatalf("check: %v", err)
}
want := []string{"URGENT", "HIGH", "LOW", "NONE"}
if len(got) != len(want) {
t.Fatalf("expected %d issues, got %d", len(want), len(got))
}
for i, id := range want {
if got[i].Identifier != id {
t.Errorf("position %d: got %s, want %s (full: %v)", i, got[i].Identifier, id, identifiers(got))
}
}
}
func TestService_CheckIssueWatch_PaginatesBacklog(t *testing.T) {
f := newSvcFixture(t)
ctx := context.Background()
if _, err := f.svc.SetConfigForWorkspace(ctx, "ws-1", &SetConfigRequest{
AuthMethod: AuthMethodAPIKey, Secret: "lin_api",
}); err != nil {
t.Fatalf("set config: %v", err)
}
// Two pages: the URGENT issue is on page 2. Sorting only page 1 would never
// surface it first — this pins cross-page accumulation + sort (CodeRabbit).
f.client.searchIssuesFn = func(_ SearchFilter, pageToken string, _ int) (*SearchResult, error) {
if pageToken == "" {
return &SearchResult{Issues: []LinearIssue{
{Identifier: "LOW", Priority: 4, URL: "https://linear.app/x/issue/LOW"},
{Identifier: "MED", Priority: 3, URL: "https://linear.app/x/issue/MED"},
}, IsLast: false, NextPageToken: "p2"}, nil
}
return &SearchResult{Issues: []LinearIssue{
{Identifier: "URGENT", Priority: 1, URL: "https://linear.app/x/issue/URGENT"},
{Identifier: "HIGH", Priority: 2, URL: "https://linear.app/x/issue/HIGH"},
}, IsLast: true}, nil
}
w, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{
WorkspaceID: "ws-1", WorkflowID: "wf", WorkflowStepID: "step",
Filter: SearchFilter{TeamKey: "ENG"}, SortBy: SortByPriorityDesc,
})
if err != nil {
t.Fatalf("create watch: %v", err)
}
got, err := f.svc.CheckIssueWatch(ctx, w)
if err != nil {
t.Fatalf("check: %v", err)
}
want := []string{"URGENT", "HIGH", "MED", "LOW"}
if len(got) != len(want) {
t.Fatalf("expected issues from both pages (%d), got %d: %v", len(want), len(got), identifiers(got))
}
for i, id := range want {
if got[i].Identifier != id {
t.Errorf("position %d: got %s, want %s (full: %v)", i, got[i].Identifier, id, identifiers(got))
}
}
}
func TestService_CheckIssueWatch_BoundsPages(t *testing.T) {
f := newSvcFixture(t)
ctx := context.Background()
if _, err := f.svc.SetConfigForWorkspace(ctx, "ws-1", &SetConfigRequest{
AuthMethod: AuthMethodAPIKey, Secret: "lin_api",
}); err != nil {
t.Fatalf("set config: %v", err)
}
// Mock never reports IsLast and always hands back a fresh cursor + a fresh
// identifier — without the issueWatchMaxPages cap this loops forever.
calls := 0
f.client.searchIssuesFn = func(_ SearchFilter, _ string, _ int) (*SearchResult, error) {
calls++
return &SearchResult{Issues: []LinearIssue{
{Identifier: fmt.Sprintf("ENG-%d", calls), URL: "https://linear.app/x/issue"},
}, IsLast: false, NextPageToken: "next"}, nil
}
// A non-default sort is required for the watch to paginate at all.
w, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{
WorkspaceID: "ws-1", WorkflowID: "wf", WorkflowStepID: "step",
Filter: SearchFilter{TeamKey: "ENG"}, SortBy: SortByPriorityDesc,
})
if err != nil {
t.Fatalf("create watch: %v", err)
}
got, err := f.svc.CheckIssueWatch(ctx, w)
if err != nil {
t.Fatalf("check: %v", err)
}
if calls != issueWatchMaxPages {
t.Errorf("expected SearchIssues called %d times (page cap), got %d", issueWatchMaxPages, calls)
}
if len(got) != issueWatchMaxPages {
t.Errorf("expected %d accumulated issues, got %d", issueWatchMaxPages, len(got))
}
}
// TestService_CheckIssueWatch_DefaultSortFetchesSinglePage pins that a
// default-order watch keeps the legacy single-page fetch: paginating it would
// burn Linear rate limit and enlarge dispatch bursts for no ordering benefit.
func TestService_CheckIssueWatch_DefaultSortFetchesSinglePage(t *testing.T) {
f := newSvcFixture(t)
ctx := context.Background()
if _, err := f.svc.SetConfigForWorkspace(ctx, "ws-1", &SetConfigRequest{
AuthMethod: AuthMethodAPIKey, Secret: "lin_api",
}); err != nil {
t.Fatalf("set config: %v", err)
}
// Mock always offers another page; a paginating watch would loop to the cap.
calls := 0
f.client.searchIssuesFn = func(_ SearchFilter, _ string, _ int) (*SearchResult, error) {
calls++
return &SearchResult{Issues: []LinearIssue{
{Identifier: fmt.Sprintf("ENG-%d", calls), URL: "https://linear.app/x/issue"},
}, IsLast: false, NextPageToken: "next"}, nil
}
// No SortBy => SortByDefault.
w, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{
WorkspaceID: "ws-1", WorkflowID: "wf", WorkflowStepID: "step",
Filter: SearchFilter{TeamKey: "ENG"},
})
if err != nil {
t.Fatalf("create watch: %v", err)
}
got, err := f.svc.CheckIssueWatch(ctx, w)
if err != nil {
t.Fatalf("check: %v", err)
}
if calls != 1 {
t.Errorf("default watch must fetch a single page, got %d SearchIssues calls", calls)
}
if len(got) != 1 {
t.Errorf("expected 1 issue from the single page, got %d", len(got))
}
}
// TestService_CheckIssueWatch_CancelStopsPagination pins that a context
// cancellation on a later page aborts with the error rather than publishing the
// partial set — dispatch detaches the context, so partial results would still
// create tasks during shutdown.
func TestService_CheckIssueWatch_CancelStopsPagination(t *testing.T) {
f := newSvcFixture(t)
ctx := context.Background()
if _, err := f.svc.SetConfigForWorkspace(ctx, "ws-1", &SetConfigRequest{
AuthMethod: AuthMethodAPIKey, Secret: "lin_api",
}); err != nil {
t.Fatalf("set config: %v", err)
}
// Page 1 succeeds; page 2 reports a canceled context.
f.client.searchIssuesFn = func(_ SearchFilter, pageToken string, _ int) (*SearchResult, error) {
if pageToken == "" {
return &SearchResult{Issues: []LinearIssue{
{Identifier: "ENG-1", Priority: 4, URL: "https://linear.app/x/issue/ENG-1"},
}, IsLast: false, NextPageToken: "p2"}, nil
}
return nil, context.Canceled
}
w, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{
WorkspaceID: "ws-1", WorkflowID: "wf", WorkflowStepID: "step",
Filter: SearchFilter{TeamKey: "ENG"}, SortBy: SortByPriorityDesc,
})
if err != nil {
t.Fatalf("create watch: %v", err)
}
got, err := f.svc.CheckIssueWatch(ctx, w)
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context.Canceled, got err=%v got=%v", err, identifiers(got))
}
if got != nil {
t.Errorf("expected no issues on cancellation, got %v", identifiers(got))
}
}
func TestService_CheckIssueWatch_StampsLastPolledOnError(t *testing.T) {
f := newSvcFixture(t)
ctx := context.Background()
if _, err := f.svc.SetConfigForWorkspace(ctx, "ws-1", &SetConfigRequest{
AuthMethod: AuthMethodAPIKey, Secret: "lin_api",
}); err != nil {
t.Fatalf("set config: %v", err)
}
f.client.searchIssuesFn = func(_ SearchFilter, _ string, _ int) (*SearchResult, error) {
return nil, errors.New("upstream 500")
}
w, _ := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{
WorkspaceID: "ws-1", WorkflowID: "wf", WorkflowStepID: "step",
Filter: SearchFilter{TeamKey: "ENG"},
})
if _, err := f.svc.CheckIssueWatch(ctx, w); err == nil {
t.Error("expected error from search to surface to caller")
}
refreshed, _ := f.store.GetIssueWatch(ctx, w.ID)
if refreshed.LastPolledAt == nil {
t.Error("expected last_polled_at stamped even on search failure (liveness signal)")
}
}
func TestService_UpdateIssueWatch_RepositoryBinding(t *testing.T) {
ctx := context.Background()
f := newSvcFixture(t)
f.svc.SetRepositoryLookup(fakeRepoLookup{workspaceID: "ws-1", defaultBranch: "main", ok: true})
w, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{
WorkspaceID: "ws-1",
WorkflowID: "wf",
WorkflowStepID: "step",
Filter: SearchFilter{TeamKey: "ENG"},
})
if err != nil {
t.Fatalf("create: %v", err)
}
if w.RepositoryID != "" {
t.Fatalf("expected unbound on create, got %q", w.RepositoryID)
}
// Bind an existing watch via update — the edit path must persist it.
repoID := "repo-1"
updated, err := f.svc.UpdateIssueWatch(ctx, w.ID, &UpdateIssueWatchRequest{RepositoryID: &repoID})
if err != nil {
t.Fatalf("update bind: %v", err)
}
if updated.RepositoryID != "repo-1" || updated.BaseBranch != "main" {
t.Fatalf("expected repo-1@main after update, got repo=%q branch=%q", updated.RepositoryID, updated.BaseBranch)
}
// Unbind via update (empty string).
empty := ""
cleared, err := f.svc.UpdateIssueWatch(ctx, w.ID, &UpdateIssueWatchRequest{RepositoryID: &empty})
if err != nil {
t.Fatalf("update unbind: %v", err)
}
if cleared.RepositoryID != "" || cleared.BaseBranch != "" {
t.Fatalf("expected cleared after update, got repo=%q branch=%q", cleared.RepositoryID, cleared.BaseBranch)
}
}
func TestService_UpdateIssueWatch_RebindAndDeletedRepo(t *testing.T) {
ctx := context.Background()
f := newSvcFixture(t)
f.svc.SetRepositoryLookup(fakeRepoLookup{workspaceID: "ws-1", defaultBranch: "main", ok: true})
w, err := f.svc.CreateIssueWatch(ctx, &CreateIssueWatchRequest{
WorkspaceID: "ws-1", WorkflowID: "wf", WorkflowStepID: "step",
Filter: SearchFilter{TeamKey: "ENG"},
})
if err != nil {
t.Fatalf("create: %v", err)
}
repo1 := "repo-1"
if _, err := f.svc.UpdateIssueWatch(ctx, w.ID, &UpdateIssueWatchRequest{RepositoryID: &repo1}); err != nil {
t.Fatalf("bind: %v", err)
}
// Rebind to a different repo with no explicit branch → branch resets to the
// new repo's default ("develop"), not the previous repo's "main".
f.svc.SetRepositoryLookup(fakeRepoLookup{workspaceID: "ws-1", defaultBranch: "develop", ok: true})
repo2 := "repo-2"
rebound, err := f.svc.UpdateIssueWatch(ctx, w.ID, &UpdateIssueWatchRequest{RepositoryID: &repo2})
if err != nil {
t.Fatalf("rebind: %v", err)
}
if rebound.RepositoryID != "repo-2" || rebound.BaseBranch != "develop" {
t.Fatalf("rebind should reset branch to new default, got repo=%q branch=%q", rebound.RepositoryID, rebound.BaseBranch)
}
// Bound repo is now soft-deleted (lookup fails). Editing an unrelated field
// must still succeed — the binding isn't part of this PATCH.
f.svc.SetRepositoryLookup(fakeRepoLookup{ok: false})
prompt := "updated prompt"
edited, err := f.svc.UpdateIssueWatch(ctx, w.ID, &UpdateIssueWatchRequest{Prompt: &prompt})
if err != nil {
t.Fatalf("unrelated edit blocked by deleted bound repo: %v", err)
}
if edited.Prompt != "updated prompt" || edited.RepositoryID != "repo-2" || edited.BaseBranch != "develop" {
t.Fatalf("expected prompt updated + binding preserved, got prompt=%q repo=%q branch=%q", edited.Prompt, edited.RepositoryID, edited.BaseBranch)
}
}
func TestService_IssueWatch_RejectsInvalidBaseBranch(t *testing.T) {
ctx := context.Background()
f := newSvcFixture(t)
f.svc.SetRepositoryLookup(fakeRepoLookup{workspaceID: "ws-1", defaultBranch: "main", ok: true})
base := func() *CreateIssueWatchRequest {
return &CreateIssueWatchRequest{
WorkspaceID: "ws-1", WorkflowID: "wf", WorkflowStepID: "step",
Filter: SearchFilter{TeamKey: "ENG"}, RepositoryID: "repo-1",
}
}
bad := base()
bad.BaseBranch = "bad..ref"
if _, err := f.svc.CreateIssueWatch(ctx, bad); !errors.Is(err, ErrInvalidConfig) {
t.Fatalf("expected ErrInvalidConfig for invalid base branch on create, got %v", err)
}
w, err := f.svc.CreateIssueWatch(ctx, base()) // valid: empty branch -> default
if err != nil {
t.Fatalf("create: %v", err)
}
badRef := "bad..ref"
if _, err := f.svc.UpdateIssueWatch(ctx, w.ID, &UpdateIssueWatchRequest{BaseBranch: &badRef}); !errors.Is(err, ErrInvalidConfig) {
t.Fatalf("expected ErrInvalidConfig for invalid base branch on update, got %v", err)
}
}