Files
kandev/apps/backend/internal/linear/service_issue_watch_test.go
T

374 lines
12 KiB
Go

package linear
import (
"context"
"errors"
"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)
}
}
}
// 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 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.SetConfig(ctx, &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_StampsLastPolledOnError(t *testing.T) {
f := newSvcFixture(t)
ctx := context.Background()
if _, err := f.svc.SetConfig(ctx, &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)")
}
}