package sentry import ( "context" "testing" "time" ) func newTestIssueWatch(workspaceID string) *IssueWatch { return &IssueWatch{ WorkspaceID: workspaceID, WorkflowID: "wf-1", WorkflowStepID: "step-1", Filter: SearchFilter{ OrgSlug: "acme", ProjectSlug: "frontend", Levels: []string{"error"}, }, Prompt: "Investigate {{issue.short_id}}", Enabled: true, } } func TestStore_IssueWatch_CreateGet(t *testing.T) { store := newTestStore(t) ctx := context.Background() w := newTestIssueWatch("ws-1") if err := store.CreateIssueWatch(ctx, w); err != nil { t.Fatalf("create: %v", err) } if w.ID == "" { t.Fatal("expected ID assigned") } if w.PollIntervalSeconds != DefaultIssueWatchPollInterval { t.Errorf("expected default poll interval %d, got %d", DefaultIssueWatchPollInterval, w.PollIntervalSeconds) } if w.CreatedAt.IsZero() || w.UpdatedAt.IsZero() { t.Error("expected timestamps assigned") } got, err := store.GetIssueWatch(ctx, w.ID) if err != nil { t.Fatalf("get: %v", err) } if got == nil { t.Fatal("expected watch, got nil") } if got.WorkspaceID != w.WorkspaceID || got.Prompt != w.Prompt { t.Errorf("round-trip mismatch: %+v vs %+v", got, w) } // Filter survives the JSON round-trip. if got.Filter.OrgSlug != "acme" || got.Filter.ProjectSlug != "frontend" || len(got.Filter.Levels) != 1 || got.Filter.Levels[0] != "error" { t.Errorf("filter round-trip failed: %+v", got.Filter) } } func TestStore_IssueWatch_ListByWorkspace(t *testing.T) { store := newTestStore(t) ctx := context.Background() w1 := newTestIssueWatch("ws-1") w2 := newTestIssueWatch("ws-1") w3 := newTestIssueWatch("ws-2") for _, w := range []*IssueWatch{w1, w2, w3} { if err := store.CreateIssueWatch(ctx, w); err != nil { t.Fatalf("create: %v", err) } } got, err := store.ListIssueWatches(ctx, "ws-1") if err != nil { t.Fatalf("list: %v", err) } if len(got) != 2 { t.Fatalf("expected 2 watches for ws-1, got %d", len(got)) } for _, w := range got { if w.WorkspaceID != "ws-1" { t.Errorf("workspace leaked into list: %s", w.WorkspaceID) } } } func TestStore_IssueWatch_ListEnabled(t *testing.T) { store := newTestStore(t) ctx := context.Background() enabled := newTestIssueWatch("ws-1") disabled := newTestIssueWatch("ws-2") disabled.Enabled = false for _, w := range []*IssueWatch{enabled, disabled} { if err := store.CreateIssueWatch(ctx, w); err != nil { t.Fatalf("create: %v", err) } } got, err := store.ListEnabledIssueWatches(ctx) if err != nil { t.Fatalf("list enabled: %v", err) } if len(got) != 1 { t.Fatalf("expected only the enabled watch, got %d", len(got)) } if got[0].ID != enabled.ID { t.Errorf("expected enabled watch returned, got %s", got[0].ID) } } func TestStore_IssueWatch_Update(t *testing.T) { store := newTestStore(t) ctx := context.Background() w := newTestIssueWatch("ws-1") if err := store.CreateIssueWatch(ctx, w); err != nil { t.Fatalf("create: %v", err) } originalCreated := w.CreatedAt w.Filter = SearchFilter{OrgSlug: "acme", ProjectSlug: "backend", Query: "deadlock"} w.Enabled = false w.PollIntervalSeconds = 60 if err := store.UpdateIssueWatch(ctx, w); err != nil { t.Fatalf("update: %v", err) } got, _ := store.GetIssueWatch(ctx, w.ID) if got.Filter.Query != "deadlock" || got.Enabled || got.PollIntervalSeconds != 60 { t.Errorf("update did not persist: %+v", got) } if !got.CreatedAt.Equal(originalCreated) { t.Errorf("update must not change created_at: %v vs %v", got.CreatedAt, originalCreated) } } func TestStore_IssueWatch_LastPolledStamp(t *testing.T) { store := newTestStore(t) ctx := context.Background() w := newTestIssueWatch("ws-1") if err := store.CreateIssueWatch(ctx, w); err != nil { t.Fatalf("create: %v", err) } t1 := time.Now().UTC().Truncate(time.Second) if err := store.UpdateIssueWatchLastPolled(ctx, w.ID, t1); err != nil { t.Fatalf("stamp: %v", err) } got, _ := store.GetIssueWatch(ctx, w.ID) if got.LastPolledAt == nil || !got.LastPolledAt.Equal(t1) { t.Errorf("expected last_polled_at=%v, got %v", t1, got.LastPolledAt) } } func TestStore_IssueWatch_DeleteCascadesDedupRows(t *testing.T) { store := newTestStore(t) ctx := context.Background() w := newTestIssueWatch("ws-1") if err := store.CreateIssueWatch(ctx, w); err != nil { t.Fatalf("create: %v", err) } if _, err := store.ReserveIssueWatchTask(ctx, w.ID, "PROJ-1", "https://sentry.io/issues/PROJ-1"); err != nil { t.Fatalf("reserve: %v", err) } if err := store.DeleteIssueWatch(ctx, w.ID); err != nil { t.Fatalf("delete: %v", err) } gone, _ := store.GetIssueWatch(ctx, w.ID) if gone != nil { t.Errorf("expected watch deleted, got %+v", gone) } // Re-creating with the same ID and reserving the same short_id should // succeed without UNIQUE collision — proves cascade ran. w2 := newTestIssueWatch("ws-1") w2.ID = w.ID if err := store.CreateIssueWatch(ctx, w2); err != nil { t.Fatalf("recreate watch: %v", err) } ok, err := store.ReserveIssueWatchTask(ctx, w2.ID, "PROJ-1", "https://sentry.io/issues/PROJ-1") if err != nil { t.Fatalf("re-reserve: %v", err) } if !ok { t.Error("expected re-reserve to succeed after cascade delete") } } func TestStore_IssueWatchTask_ReserveDedup(t *testing.T) { store := newTestStore(t) ctx := context.Background() w := newTestIssueWatch("ws-1") if err := store.CreateIssueWatch(ctx, w); err != nil { t.Fatalf("create: %v", err) } first, err := store.ReserveIssueWatchTask(ctx, w.ID, "PROJ-7", "https://sentry.io/issues/PROJ-7") if err != nil { t.Fatalf("first reserve: %v", err) } if !first { t.Error("expected first reservation to win") } second, err := store.ReserveIssueWatchTask(ctx, w.ID, "PROJ-7", "https://sentry.io/issues/PROJ-7") if err != nil { t.Fatalf("second reserve: %v", err) } if second { t.Error("expected second reservation to lose due to UNIQUE constraint") } seen, err := store.ListSeenIssueShortIDs(ctx, w.ID) if err != nil { t.Fatalf("list seen: %v", err) } if _, ok := seen["PROJ-7"]; !ok { t.Error("expected PROJ-7 in seen set after reservation") } } func TestStore_IssueWatchTask_AssignTaskID(t *testing.T) { store := newTestStore(t) ctx := context.Background() w := newTestIssueWatch("ws-1") if err := store.CreateIssueWatch(ctx, w); err != nil { t.Fatalf("create: %v", err) } if _, err := store.ReserveIssueWatchTask(ctx, w.ID, "PROJ-1", "https://sentry.io/issues/PROJ-1"); err != nil { t.Fatalf("reserve: %v", err) } if err := store.AssignIssueWatchTaskID(ctx, w.ID, "PROJ-1", "task-abc"); err != nil { t.Fatalf("assign: %v", err) } if err := store.AssignIssueWatchTaskID(ctx, w.ID, "PROJ-NOPE", "task-zzz"); err == nil { t.Error("expected error for missing reservation, got nil") } } func TestStore_IssueWatchTask_Release(t *testing.T) { store := newTestStore(t) ctx := context.Background() w := newTestIssueWatch("ws-1") if err := store.CreateIssueWatch(ctx, w); err != nil { t.Fatalf("create: %v", err) } if _, err := store.ReserveIssueWatchTask(ctx, w.ID, "PROJ-1", "https://sentry.io/issues/PROJ-1"); err != nil { t.Fatalf("reserve: %v", err) } if err := store.ReleaseIssueWatchTask(ctx, w.ID, "PROJ-1"); err != nil { t.Fatalf("release: %v", err) } again, err := store.ReserveIssueWatchTask(ctx, w.ID, "PROJ-1", "https://sentry.io/issues/PROJ-1") if err != nil { t.Fatalf("re-reserve: %v", err) } if !again { t.Error("expected reservation to succeed after release") } } func TestStore_IssueWatchTask_ListTaskIDsAndReset(t *testing.T) { store := newTestStore(t) ctx := context.Background() w := newTestIssueWatch("ws-1") if err := store.CreateIssueWatch(ctx, w); err != nil { t.Fatalf("create: %v", err) } if err := store.UpdateIssueWatchLastPolled(ctx, w.ID, time.Now().UTC()); err != nil { t.Fatalf("stamp last polled: %v", err) } // Three reservations; only two get task IDs assigned so the third row // exercises the empty-ID inclusion the reset flow depends on. for _, k := range []string{"PROJ-1", "PROJ-2", "PROJ-3"} { if _, err := store.ReserveIssueWatchTask(ctx, w.ID, k, "https://sentry.io/issues/"+k); err != nil { t.Fatalf("reserve %s: %v", k, err) } } if err := store.AssignIssueWatchTaskID(ctx, w.ID, "PROJ-1", "task-a"); err != nil { t.Fatalf("assign 1: %v", err) } if err := store.AssignIssueWatchTaskID(ctx, w.ID, "PROJ-2", "task-b"); err != nil { t.Fatalf("assign 2: %v", err) } ids, err := store.ListIssueWatchTaskIDs(ctx, w.ID) if err != nil { t.Fatalf("list ids: %v", err) } if len(ids) != 3 { t.Fatalf("ListIssueWatchTaskIDs returned %d rows, want 3 (including empty reservation)", len(ids)) } nonEmpty := 0 for _, id := range ids { if id != "" { nonEmpty++ } } if nonEmpty != 2 { t.Errorf("expected 2 non-empty task IDs, got %d", nonEmpty) } if err := store.ResetIssueWatchState(ctx, w.ID); err != nil { t.Fatalf("reset: %v", err) } idsAfter, err := store.ListIssueWatchTaskIDs(ctx, w.ID) if err != nil { t.Fatalf("list ids after reset: %v", err) } if len(idsAfter) != 0 { t.Errorf("expected 0 dedup rows after reset, got %d", len(idsAfter)) } got, err := store.GetIssueWatch(ctx, w.ID) if err != nil { t.Fatalf("get watch: %v", err) } if got.LastPolledAt != nil { t.Errorf("expected LastPolledAt to be nil after reset, got %v", got.LastPolledAt) } }