Files
kandev/apps/backend/internal/github/service_cleanup_policy_test.go
T

821 lines
27 KiB
Go

package github
import (
"context"
"fmt"
"testing"
"github.com/kandev/kandev/internal/common/logger"
)
func testLogger(t *testing.T) *logger.Logger {
t.Helper()
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"})
if err != nil {
t.Fatalf("create test logger: %v", err)
}
return log
}
// recordingSessionChecker satisfies TaskSessionChecker for tests and lets the
// caller dial in "did the user type something" without spinning up a real
// task repository.
type recordingSessionChecker struct {
hasUserMsg bool
err error
calls int
}
func (s *recordingSessionChecker) HasUserAuthoredMessage(_ context.Context, _ string) (bool, error) {
s.calls++
return s.hasUserMsg, s.err
}
// prFeedbackStub returns a canned PRFeedback for shouldDeleteReviewTask tests.
// Unrelated client methods come from NoopClient.
type prFeedbackStub struct {
NoopClient
state string
err error
}
func (c *prFeedbackStub) GetPRFeedback(_ context.Context, _, _ string, _ int) (*PRFeedback, error) {
if c.err != nil {
return nil, c.err
}
return &PRFeedback{PR: &PR{State: c.state}}, nil
}
func reviewTaskFixture() *ReviewPRTask {
return &ReviewPRTask{
ID: "rpt-1",
ReviewWatchID: "rw-1",
RepoOwner: "acme",
RepoName: "widget",
PRNumber: 99,
TaskID: "task-99",
}
}
func TestShouldDeleteReviewTask_AutoPolicy_NoUserMessages_Deletes(t *testing.T) {
log := testLogger(t)
checker := &recordingSessionChecker{hasUserMsg: false}
svc := newCleanupTestService(&prFeedbackStub{state: prStateMerged}, log, checker)
del, reason := svc.shouldDeleteReviewTask(context.Background(), reviewTaskFixture(), CleanupPolicyAuto)
if !del {
t.Fatalf("expected delete=true under auto with no user messages")
}
if reason != "pr_merged_or_closed" {
t.Fatalf("reason=%q, want pr_merged_or_closed", reason)
}
if checker.calls != 1 {
t.Fatalf("HasUserAuthoredMessage calls=%d, want 1", checker.calls)
}
}
func TestShouldDeleteReviewTask_AutoPolicy_UserMessage_Preserves(t *testing.T) {
log := testLogger(t)
checker := &recordingSessionChecker{hasUserMsg: true}
svc := newCleanupTestService(&prFeedbackStub{state: prStateClosed}, log, checker)
del, _ := svc.shouldDeleteReviewTask(context.Background(), reviewTaskFixture(), CleanupPolicyAuto)
if del {
t.Fatalf("expected delete=false when user authored a message under auto")
}
}
func TestShouldDeleteReviewTask_AlwaysPolicy_IgnoresUserMessages(t *testing.T) {
log := testLogger(t)
checker := &recordingSessionChecker{hasUserMsg: true}
svc := newCleanupTestService(&prFeedbackStub{state: prStateMerged}, log, checker)
del, reason := svc.shouldDeleteReviewTask(context.Background(), reviewTaskFixture(), CleanupPolicyAlways)
if !del {
t.Fatalf("expected delete=true under always-policy even with user messages")
}
if reason != "pr_merged_or_closed" {
t.Fatalf("reason=%q, want pr_merged_or_closed", reason)
}
if checker.calls != 0 {
t.Fatalf("HasUserAuthoredMessage calls=%d, want 0 (always-policy short-circuits)", checker.calls)
}
}
func TestShouldDeleteReviewTask_NeverPolicy_KeepsEverything(t *testing.T) {
log := testLogger(t)
checker := &recordingSessionChecker{hasUserMsg: false}
svc := newCleanupTestService(&prFeedbackStub{state: prStateMerged}, log, checker)
del, _ := svc.shouldDeleteReviewTask(context.Background(), reviewTaskFixture(), CleanupPolicyNever)
if del {
t.Fatalf("expected delete=false under never-policy")
}
// Never-policy must skip the upstream call too — no point burning quota.
if checker.calls != 0 {
t.Fatalf("HasUserAuthoredMessage calls=%d, want 0 (never-policy short-circuits)", checker.calls)
}
}
func TestShouldDeleteReviewTask_OpenPR_KeepsTask(t *testing.T) {
log := testLogger(t)
checker := &recordingSessionChecker{hasUserMsg: false}
svc := newCleanupTestService(&prFeedbackStub{state: "open"}, log, checker)
del, _ := svc.shouldDeleteReviewTask(context.Background(), reviewTaskFixture(), CleanupPolicyAuto)
if del {
t.Fatalf("expected delete=false for open PR")
}
}
func TestShouldDeleteReviewTask_GitHubError_TracksFailureCount(t *testing.T) {
log := testLogger(t)
svc := newCleanupTestService(&prFeedbackStub{err: fmt.Errorf("rate limit hit")}, log, &recordingSessionChecker{})
rpt := reviewTaskFixture()
for i := 0; i < cleanupFetchFailureThreshold+1; i++ {
if del, _ := svc.shouldDeleteReviewTask(context.Background(), rpt, CleanupPolicyAuto); del {
t.Fatalf("iteration %d: expected delete=false under fetch error", i)
}
}
// The counter is keyed per-row; one error → one increment.
key := reviewFailureKey(rpt)
svc.cleanupFailureMu.Lock()
got := svc.cleanupFailureCounts[key]
svc.cleanupFailureMu.Unlock()
if got != cleanupFetchFailureThreshold+1 {
t.Fatalf("failure count = %d, want %d", got, cleanupFetchFailureThreshold+1)
}
}
func TestShouldDeleteReviewTask_GitHubErrorThenSuccess_ResetsCounter(t *testing.T) {
log := testLogger(t)
client := &switchingFeedbackClient{err: fmt.Errorf("transient")}
svc := newCleanupTestService(client, log, &recordingSessionChecker{})
rpt := reviewTaskFixture()
// First call fails — only the side effect (counter inc) matters here.
_, _ = svc.shouldDeleteReviewTask(context.Background(), rpt, CleanupPolicyAuto)
// Now switch the client to a healthy response — the next call must reset.
client.err = nil
client.state = prStateMerged
_, _ = svc.shouldDeleteReviewTask(context.Background(), rpt, CleanupPolicyAuto)
key := reviewFailureKey(rpt)
svc.cleanupFailureMu.Lock()
_, present := svc.cleanupFailureCounts[key]
svc.cleanupFailureMu.Unlock()
if present {
t.Fatalf("failure counter should be cleared after a successful fetch")
}
}
func TestCleanupAllOrphanedReviewTasks_DeletedWatchOrphans(t *testing.T) {
_, svc, mockClient, store := setupPollerTest(t)
ctx := context.Background()
// Watch + dedup row + merged PR.
watch := &ReviewWatch{WorkspaceID: "ws-1", Enabled: true}
if err := store.CreateReviewWatch(ctx, watch); err != nil {
t.Fatalf("CreateReviewWatch: %v", err)
}
rpt := &ReviewPRTask{
ReviewWatchID: watch.ID,
RepoOwner: "acme",
RepoName: "widget",
PRNumber: 77,
PRURL: "https://github.com/acme/widget/pull/77",
TaskID: "task-77",
}
if err := store.CreateReviewPRTask(ctx, rpt); err != nil {
t.Fatalf("CreateReviewPRTask: %v", err)
}
mockClient.AddPR(&PR{Number: 77, State: prStateMerged, RepoOwner: "acme", RepoName: "widget"})
// Simulate the watch having been deleted out from under the dedup row by
// removing only the watch (bypassing the cascade so the orphan survives).
if _, err := store.db.Exec(`DELETE FROM github_review_watches WHERE id = ?`, watch.ID); err != nil {
t.Fatalf("manual watch delete: %v", err)
}
rec := &recordingTaskDeleter{}
svc.SetTaskDeleter(rec)
deleted, err := svc.CleanupAllOrphanedReviewTasks(ctx)
if err != nil {
t.Fatalf("CleanupAllOrphanedReviewTasks: %v", err)
}
if deleted != 1 {
t.Fatalf("deleted=%d, want 1 (orphan with missing watch should fall back to auto policy and reap)", deleted)
}
if len(rec.calls) != 1 || rec.calls[0] != "task-77" {
t.Fatalf("DeleteTask calls=%v, want [task-77]", rec.calls)
}
}
func TestCleanupAllOrphanedReviewTasks_DisabledWatch_StillCleansUp(t *testing.T) {
_, svc, mockClient, store := setupPollerTest(t)
ctx := context.Background()
watch := &ReviewWatch{WorkspaceID: "ws-1", Enabled: false}
if err := store.CreateReviewWatch(ctx, watch); err != nil {
t.Fatalf("CreateReviewWatch: %v", err)
}
rpt := &ReviewPRTask{
ReviewWatchID: watch.ID,
RepoOwner: "acme",
RepoName: "widget",
PRNumber: 88,
TaskID: "task-88",
}
if err := store.CreateReviewPRTask(ctx, rpt); err != nil {
t.Fatalf("CreateReviewPRTask: %v", err)
}
mockClient.AddPR(&PR{Number: 88, State: prStateMerged, RepoOwner: "acme", RepoName: "widget"})
rec := &recordingTaskDeleter{}
svc.SetTaskDeleter(rec)
deleted, err := svc.CleanupAllOrphanedReviewTasks(ctx)
if err != nil {
t.Fatalf("CleanupAllOrphanedReviewTasks: %v", err)
}
if deleted != 1 {
t.Fatalf("deleted=%d, want 1 (disabled watch's PR task should still be reaped by global sweep)", deleted)
}
}
// Regression for cubic P1: when GetReviewWatch fails for a watch, the row
// MUST be skipped this cycle. Treating it as orphan would fail-open under
// the auto policy, reaping enabled-watch rows (potentially even rows whose
// real watch is configured as `never`) on a transient DB hiccup.
func TestCleanupAllOrphanedReviewTasks_UnknownWatchSkipped(t *testing.T) {
_, svc, mockClient, store := setupPollerTest(t)
ctx := context.Background()
watch := &ReviewWatch{WorkspaceID: "ws-1", Enabled: true, CleanupPolicy: CleanupPolicyNever}
if err := store.CreateReviewWatch(ctx, watch); err != nil {
t.Fatalf("CreateReviewWatch: %v", err)
}
rpt := &ReviewPRTask{
ReviewWatchID: watch.ID,
RepoOwner: "acme",
RepoName: "widget",
PRNumber: 222,
TaskID: "task-222",
}
if err := store.CreateReviewPRTask(ctx, rpt); err != nil {
t.Fatalf("CreateReviewPRTask: %v", err)
}
mockClient.AddPR(&PR{Number: 222, State: prStateMerged, RepoOwner: "acme", RepoName: "widget"})
// Wedge the watch row so GetReviewWatch errors. Setting a bad enum value
// would still parse; instead, drop the table briefly. We simulate the
// store error by closing the read DB connection.
if _, err := store.db.Exec(`ALTER TABLE github_review_watches RENAME TO github_review_watches_x`); err != nil {
t.Fatalf("rename watches table: %v", err)
}
t.Cleanup(func() {
_, _ = store.db.Exec(`ALTER TABLE github_review_watches_x RENAME TO github_review_watches`)
})
rec := &recordingTaskDeleter{}
svc.SetTaskDeleter(rec)
deleted, err := svc.CleanupAllOrphanedReviewTasks(ctx)
if err != nil {
t.Fatalf("CleanupAllOrphanedReviewTasks: %v", err)
}
if deleted != 0 {
t.Fatalf("deleted=%d, want 0 (row whose watch fetch errored must be skipped, not fail-open as orphan)", deleted)
}
if len(rec.calls) != 0 {
t.Fatalf("DeleteTask called %d times, want 0", len(rec.calls))
}
}
// Regression for greptile P2: the global sweep must not re-hit GitHub for
// dedup rows the per-watch loop already processed in the same cycle.
// CleanupAllOrphanedReviewTasks skips rows whose watch is currently
// enabled — the per-watch loop is responsible for those.
func TestCleanupAllOrphanedReviewTasks_SkipsEnabledWatchRows(t *testing.T) {
_, svc, mockClient, store := setupPollerTest(t)
ctx := context.Background()
watch := &ReviewWatch{WorkspaceID: "ws-1", Enabled: true}
if err := store.CreateReviewWatch(ctx, watch); err != nil {
t.Fatalf("CreateReviewWatch: %v", err)
}
rpt := &ReviewPRTask{
ReviewWatchID: watch.ID,
RepoOwner: "acme",
RepoName: "widget",
PRNumber: 111,
TaskID: "task-111",
}
if err := store.CreateReviewPRTask(ctx, rpt); err != nil {
t.Fatalf("CreateReviewPRTask: %v", err)
}
// PR is merged — without the skip, the orphan sweep would happily
// delete it on top of the per-watch loop.
mockClient.AddPR(&PR{Number: 111, State: prStateMerged, RepoOwner: "acme", RepoName: "widget"})
rec := &recordingTaskDeleter{}
svc.SetTaskDeleter(rec)
deleted, err := svc.CleanupAllOrphanedReviewTasks(ctx)
if err != nil {
t.Fatalf("CleanupAllOrphanedReviewTasks: %v", err)
}
if deleted != 0 {
t.Fatalf("deleted=%d, want 0 (enabled-watch rows must be skipped by the orphan sweep)", deleted)
}
if len(rec.calls) != 0 {
t.Fatalf("DeleteTask called %d times, want 0", len(rec.calls))
}
// And CleanupAllReviewTasks (manual button path) reaps the same row.
deleted, err = svc.CleanupAllReviewTasks(ctx)
if err != nil {
t.Fatalf("CleanupAllReviewTasks: %v", err)
}
if deleted != 1 {
t.Fatalf("manual sweep deleted=%d, want 1", deleted)
}
}
func TestCleanupAllOrphanedReviewTasks_RespectsNeverPolicy(t *testing.T) {
_, svc, mockClient, store := setupPollerTest(t)
ctx := context.Background()
watch := &ReviewWatch{WorkspaceID: "ws-1", Enabled: true, CleanupPolicy: CleanupPolicyNever}
if err := store.CreateReviewWatch(ctx, watch); err != nil {
t.Fatalf("CreateReviewWatch: %v", err)
}
rpt := &ReviewPRTask{
ReviewWatchID: watch.ID,
RepoOwner: "acme",
RepoName: "widget",
PRNumber: 55,
TaskID: "task-55",
}
if err := store.CreateReviewPRTask(ctx, rpt); err != nil {
t.Fatalf("CreateReviewPRTask: %v", err)
}
mockClient.AddPR(&PR{Number: 55, State: prStateMerged, RepoOwner: "acme", RepoName: "widget"})
rec := &recordingTaskDeleter{}
svc.SetTaskDeleter(rec)
deleted, err := svc.CleanupAllOrphanedReviewTasks(ctx)
if err != nil {
t.Fatalf("CleanupAllOrphanedReviewTasks: %v", err)
}
if deleted != 0 {
t.Fatalf("deleted=%d, want 0 (never-policy must keep tasks even when PR merges)", deleted)
}
if len(rec.calls) != 0 {
t.Fatalf("DeleteTask calls=%v, want none", rec.calls)
}
}
// Regression for claude blocker on c4d0a678: when ListEnabledReviewWatches
// returns an empty slice (user disabled or deleted every watch), the
// poller used to early-return BEFORE running the orphan sweep — so the
// sweep that exists precisely for this scenario never fired. Verify the
// poller's checkReviewWatches now runs the sweep through to completion
// and reaps the orphan task.
func TestCheckReviewWatches_RunsOrphanSweepWhenNoEnabledWatches(t *testing.T) {
poller, svc, mockClient, store := setupPollerTest(t)
ctx := context.Background()
// Create a watch + dedup row, then disable the watch so
// ListEnabledReviewWatches returns empty. The dedup row + task survive.
watch := &ReviewWatch{WorkspaceID: "ws-1", Enabled: false}
if err := store.CreateReviewWatch(ctx, watch); err != nil {
t.Fatalf("CreateReviewWatch: %v", err)
}
rpt := &ReviewPRTask{
ReviewWatchID: watch.ID,
RepoOwner: "acme",
RepoName: "widget",
PRNumber: 9001,
TaskID: "task-9001",
}
if err := store.CreateReviewPRTask(ctx, rpt); err != nil {
t.Fatalf("CreateReviewPRTask: %v", err)
}
mockClient.AddPR(&PR{Number: 9001, State: prStateMerged, RepoOwner: "acme", RepoName: "widget"})
rec := &recordingTaskDeleter{}
svc.SetTaskDeleter(rec)
poller.checkReviewWatches(ctx)
if len(rec.calls) != 1 || rec.calls[0] != "task-9001" {
t.Fatalf("DeleteTask calls=%v, want [task-9001] (orphan sweep must run even with zero enabled watches)", rec.calls)
}
}
// Same regression on the issue side.
func TestCheckIssueWatches_RunsOrphanSweepWhenNoEnabledWatches(t *testing.T) {
poller, svc, _, store := setupPollerTest(t)
ctx := context.Background()
svc.client = newIssueStateMockClient(map[int]string{9002: "closed"})
watch := &IssueWatch{WorkspaceID: "ws-1", Enabled: false}
if err := store.CreateIssueWatch(ctx, watch); err != nil {
t.Fatalf("CreateIssueWatch: %v", err)
}
_, _ = store.ReserveIssueWatchTask(ctx, watch.ID, "acme", "widget", 9002, "https://example/9002")
if err := store.AssignIssueWatchTaskID(ctx, watch.ID, "acme", "widget", 9002, "task-9002"); err != nil {
t.Fatalf("AssignIssueWatchTaskID: %v", err)
}
rec := &recordingTaskDeleter{}
svc.SetTaskDeleter(rec)
poller.checkIssueWatches(ctx)
if len(rec.calls) != 1 || rec.calls[0] != "task-9002" {
t.Fatalf("DeleteTask calls=%v, want [task-9002] (orphan sweep must run even with zero enabled watches)", rec.calls)
}
}
// --- Issue-side mirrors ---
// These tests mirror the review-side coverage above for the symmetric
// issue path. Without them a copy-paste divergence between
// cleanupAllReviewTasks and cleanupAllIssueTasks — wrong cache key,
// inverted predicate, forgotten unknown-watch guard — would only surface
// in a production incident.
// issueStateMockClient extends the default MockClient with per-issue state
// lookup. MockClient.GetIssueState always returns "open"; cleanup tests
// need to flip individual issues to "closed".
type issueStateMockClient struct {
*MockClient
states map[int]string // issue number -> state
}
func newIssueStateMockClient(states map[int]string) *issueStateMockClient {
return &issueStateMockClient{MockClient: NewMockClient(), states: states}
}
func (c *issueStateMockClient) GetIssueState(_ context.Context, _, _ string, number int) (string, error) {
if s, ok := c.states[number]; ok {
return s, nil
}
return "open", nil
}
func TestCleanupAllOrphanedIssueTasks_DeletedWatchOrphans(t *testing.T) {
_, svc, _, store := setupPollerTest(t)
ctx := context.Background()
svc.client = newIssueStateMockClient(map[int]string{77: "closed"})
watch := &IssueWatch{WorkspaceID: "ws-1", Enabled: true}
if err := store.CreateIssueWatch(ctx, watch); err != nil {
t.Fatalf("CreateIssueWatch: %v", err)
}
reserved, err := store.ReserveIssueWatchTask(ctx, watch.ID, "acme", "widget", 77, "https://example/77")
if err != nil || !reserved {
t.Fatalf("ReserveIssueWatchTask: ok=%v err=%v", reserved, err)
}
if err := store.AssignIssueWatchTaskID(ctx, watch.ID, "acme", "widget", 77, "task-77"); err != nil {
t.Fatalf("AssignIssueWatchTaskID: %v", err)
}
if _, err := store.db.Exec(`DELETE FROM github_issue_watches WHERE id = ?`, watch.ID); err != nil {
t.Fatalf("manual watch delete: %v", err)
}
rec := &recordingTaskDeleter{}
svc.SetTaskDeleter(rec)
deleted, err := svc.CleanupAllOrphanedIssueTasks(ctx)
if err != nil {
t.Fatalf("CleanupAllOrphanedIssueTasks: %v", err)
}
if deleted != 1 {
t.Fatalf("deleted=%d, want 1 (orphan with missing watch should fall back to auto policy and reap)", deleted)
}
if len(rec.calls) != 1 || rec.calls[0] != "task-77" {
t.Fatalf("DeleteTask calls=%v, want [task-77]", rec.calls)
}
}
func TestCleanupAllOrphanedIssueTasks_DisabledWatch_StillCleansUp(t *testing.T) {
_, svc, _, store := setupPollerTest(t)
ctx := context.Background()
svc.client = newIssueStateMockClient(map[int]string{88: "closed"})
watch := &IssueWatch{WorkspaceID: "ws-1", Enabled: false}
if err := store.CreateIssueWatch(ctx, watch); err != nil {
t.Fatalf("CreateIssueWatch: %v", err)
}
_, _ = store.ReserveIssueWatchTask(ctx, watch.ID, "acme", "widget", 88, "https://example/88")
if err := store.AssignIssueWatchTaskID(ctx, watch.ID, "acme", "widget", 88, "task-88"); err != nil {
t.Fatalf("AssignIssueWatchTaskID: %v", err)
}
rec := &recordingTaskDeleter{}
svc.SetTaskDeleter(rec)
deleted, err := svc.CleanupAllOrphanedIssueTasks(ctx)
if err != nil {
t.Fatalf("CleanupAllOrphanedIssueTasks: %v", err)
}
if deleted != 1 {
t.Fatalf("deleted=%d, want 1 (disabled watch's issue task should still be reaped by global sweep)", deleted)
}
}
func TestCleanupAllOrphanedIssueTasks_UnknownWatchSkipped(t *testing.T) {
_, svc, _, store := setupPollerTest(t)
ctx := context.Background()
svc.client = newIssueStateMockClient(map[int]string{222: "closed"})
watch := &IssueWatch{WorkspaceID: "ws-1", Enabled: true, CleanupPolicy: CleanupPolicyNever}
if err := store.CreateIssueWatch(ctx, watch); err != nil {
t.Fatalf("CreateIssueWatch: %v", err)
}
_, _ = store.ReserveIssueWatchTask(ctx, watch.ID, "acme", "widget", 222, "https://example/222")
if err := store.AssignIssueWatchTaskID(ctx, watch.ID, "acme", "widget", 222, "task-222"); err != nil {
t.Fatalf("AssignIssueWatchTaskID: %v", err)
}
if _, err := store.db.Exec(`ALTER TABLE github_issue_watches RENAME TO github_issue_watches_x`); err != nil {
t.Fatalf("rename watches table: %v", err)
}
t.Cleanup(func() {
_, _ = store.db.Exec(`ALTER TABLE github_issue_watches_x RENAME TO github_issue_watches`)
})
rec := &recordingTaskDeleter{}
svc.SetTaskDeleter(rec)
deleted, err := svc.CleanupAllOrphanedIssueTasks(ctx)
if err != nil {
t.Fatalf("CleanupAllOrphanedIssueTasks: %v", err)
}
if deleted != 0 {
t.Fatalf("deleted=%d, want 0 (row whose watch fetch errored must be skipped, not fail-open as orphan)", deleted)
}
if len(rec.calls) != 0 {
t.Fatalf("DeleteTask called %d times, want 0", len(rec.calls))
}
}
func TestCleanupAllOrphanedIssueTasks_SkipsEnabledWatchRows(t *testing.T) {
_, svc, _, store := setupPollerTest(t)
ctx := context.Background()
svc.client = newIssueStateMockClient(map[int]string{111: "closed"})
watch := &IssueWatch{WorkspaceID: "ws-1", Enabled: true}
if err := store.CreateIssueWatch(ctx, watch); err != nil {
t.Fatalf("CreateIssueWatch: %v", err)
}
_, _ = store.ReserveIssueWatchTask(ctx, watch.ID, "acme", "widget", 111, "https://example/111")
if err := store.AssignIssueWatchTaskID(ctx, watch.ID, "acme", "widget", 111, "task-111"); err != nil {
t.Fatalf("AssignIssueWatchTaskID: %v", err)
}
rec := &recordingTaskDeleter{}
svc.SetTaskDeleter(rec)
deleted, err := svc.CleanupAllOrphanedIssueTasks(ctx)
if err != nil {
t.Fatalf("CleanupAllOrphanedIssueTasks: %v", err)
}
if deleted != 0 {
t.Fatalf("deleted=%d, want 0 (enabled-watch rows must be skipped by the orphan sweep)", deleted)
}
if len(rec.calls) != 0 {
t.Fatalf("DeleteTask called %d times, want 0", len(rec.calls))
}
// And CleanupAllIssueTasks (manual button path) reaps the same row.
deleted, err = svc.CleanupAllIssueTasks(ctx)
if err != nil {
t.Fatalf("CleanupAllIssueTasks: %v", err)
}
if deleted != 1 {
t.Fatalf("manual sweep deleted=%d, want 1", deleted)
}
}
func TestCleanupAllOrphanedIssueTasks_RespectsNeverPolicy(t *testing.T) {
_, svc, _, store := setupPollerTest(t)
ctx := context.Background()
svc.client = newIssueStateMockClient(map[int]string{55: "closed"})
watch := &IssueWatch{WorkspaceID: "ws-1", Enabled: true, CleanupPolicy: CleanupPolicyNever}
if err := store.CreateIssueWatch(ctx, watch); err != nil {
t.Fatalf("CreateIssueWatch: %v", err)
}
_, _ = store.ReserveIssueWatchTask(ctx, watch.ID, "acme", "widget", 55, "https://example/55")
if err := store.AssignIssueWatchTaskID(ctx, watch.ID, "acme", "widget", 55, "task-55"); err != nil {
t.Fatalf("AssignIssueWatchTaskID: %v", err)
}
rec := &recordingTaskDeleter{}
svc.SetTaskDeleter(rec)
deleted, err := svc.CleanupAllOrphanedIssueTasks(ctx)
if err != nil {
t.Fatalf("CleanupAllOrphanedIssueTasks: %v", err)
}
if deleted != 0 {
t.Fatalf("deleted=%d, want 0 (never-policy must keep tasks even when issue closes)", deleted)
}
if len(rec.calls) != 0 {
t.Fatalf("DeleteTask calls=%v, want none", rec.calls)
}
}
func TestDeleteIssueWatch_CascadesDedupRows(t *testing.T) {
_, _, _, store := setupPollerTest(t)
ctx := context.Background()
watch := &IssueWatch{WorkspaceID: "ws-1", Enabled: true}
if err := store.CreateIssueWatch(ctx, watch); err != nil {
t.Fatalf("CreateIssueWatch: %v", err)
}
for _, n := range []int{1, 2, 3} {
_, _ = store.ReserveIssueWatchTask(ctx, watch.ID, "acme", "widget", n, fmt.Sprintf("https://example/%d", n))
if err := store.AssignIssueWatchTaskID(ctx, watch.ID, "acme", "widget", n, fmt.Sprintf("task-%d", n)); err != nil {
t.Fatalf("AssignIssueWatchTaskID: %v", err)
}
}
if err := store.DeleteIssueWatch(ctx, watch.ID); err != nil {
t.Fatalf("DeleteIssueWatch: %v", err)
}
remaining, err := store.ListIssueWatchTasksByWatch(ctx, watch.ID)
if err != nil {
t.Fatalf("ListIssueWatchTasksByWatch: %v", err)
}
if len(remaining) != 0 {
t.Fatalf("dedup rows leaked after watch delete: %d remaining", len(remaining))
}
}
func TestDeleteReviewWatch_CascadesDedupRows(t *testing.T) {
_, _, _, store := setupPollerTest(t)
ctx := context.Background()
watch := &ReviewWatch{WorkspaceID: "ws-1", Enabled: true}
if err := store.CreateReviewWatch(ctx, watch); err != nil {
t.Fatalf("CreateReviewWatch: %v", err)
}
for _, n := range []int{1, 2, 3} {
rpt := &ReviewPRTask{
ReviewWatchID: watch.ID,
RepoOwner: "acme",
RepoName: "widget",
PRNumber: n,
TaskID: fmt.Sprintf("task-%d", n),
}
if err := store.CreateReviewPRTask(ctx, rpt); err != nil {
t.Fatalf("CreateReviewPRTask: %v", err)
}
}
if err := store.DeleteReviewWatch(ctx, watch.ID); err != nil {
t.Fatalf("DeleteReviewWatch: %v", err)
}
remaining, err := store.ListReviewPRTasksByWatch(ctx, watch.ID)
if err != nil {
t.Fatalf("ListReviewPRTasksByWatch: %v", err)
}
if len(remaining) != 0 {
t.Fatalf("dedup rows leaked after watch delete: %d remaining", len(remaining))
}
}
func TestDeleteReviewWatchesByWorkspace(t *testing.T) {
_, svc, _, store := setupPollerTest(t)
ctx := context.Background()
// Wire a deleter so the watch-delete task-reaping branch runs: the
// workspace sweep must reap each cleared watch's owned task and leave the
// survivor's task untouched.
rec := &recordingTaskDeleter{}
svc.SetTaskDeleter(rec)
mk := func(ws string) *ReviewWatch {
w := &ReviewWatch{WorkspaceID: ws, Enabled: true}
if err := store.CreateReviewWatch(ctx, w); err != nil {
t.Fatalf("CreateReviewWatch: %v", err)
}
rpt := &ReviewPRTask{
ReviewWatchID: w.ID,
RepoOwner: "acme",
RepoName: "widget",
PRNumber: 1,
TaskID: "task-" + w.ID,
}
if err := store.CreateReviewPRTask(ctx, rpt); err != nil {
t.Fatalf("CreateReviewPRTask: %v", err)
}
return w
}
// Two watches in ws-1 (to be cleared) and one in ws-2 that must survive.
w1 := mk("ws-1")
w2 := mk("ws-1")
wOther := mk("ws-2")
deleted, err := svc.DeleteReviewWatchesByWorkspace(ctx, "ws-1")
if err != nil {
t.Fatalf("DeleteReviewWatchesByWorkspace: %v", err)
}
if deleted != 2 {
t.Fatalf("deleted = %d, want 2", deleted)
}
ws1, err := store.ListReviewWatches(ctx, "ws-1")
if err != nil {
t.Fatalf("ListReviewWatches ws-1: %v", err)
}
if len(ws1) != 0 {
t.Fatalf("ws-1 watches remain: %d", len(ws1))
}
ws2, err := store.ListReviewWatches(ctx, "ws-2")
if err != nil {
t.Fatalf("ListReviewWatches ws-2: %v", err)
}
if len(ws2) != 1 {
t.Fatalf("ws-2 watches = %d, want 1 (unaffected)", len(ws2))
}
// Dedup rows for the deleted watches are gone; the survivor's remain.
for _, w := range []*ReviewWatch{w1, w2} {
rows, err := store.ListReviewPRTasksByWatch(ctx, w.ID)
if err != nil {
t.Fatalf("ListReviewPRTasksByWatch: %v", err)
}
if len(rows) != 0 {
t.Fatalf("dedup rows leaked for deleted watch %s: %d", w.ID, len(rows))
}
}
survivor, err := store.ListReviewPRTasksByWatch(ctx, wOther.ID)
if err != nil {
t.Fatalf("ListReviewPRTasksByWatch survivor: %v", err)
}
if len(survivor) != 1 {
t.Fatalf("survivor dedup rows = %d, want 1", len(survivor))
}
// Only the cleared workspace's tasks are reaped; the survivor's is not.
reaped := map[string]bool{}
for _, id := range rec.calls {
reaped[id] = true
}
for _, w := range []*ReviewWatch{w1, w2} {
if !reaped["task-"+w.ID] {
t.Fatalf("task for cleared watch %s was not reaped; calls=%v", w.ID, rec.calls)
}
}
if reaped["task-"+wOther.ID] {
t.Fatalf("survivor task was reaped; calls=%v", rec.calls)
}
}
func TestNormalizeCleanupPolicy(t *testing.T) {
if got := NormalizeCleanupPolicy(""); got != CleanupPolicyAuto {
t.Fatalf("empty → %q, want auto", got)
}
if got := NormalizeCleanupPolicy(CleanupPolicyAlways); got != CleanupPolicyAlways {
t.Fatalf("always → %q, want always", got)
}
}
func TestIsValidCleanupPolicy(t *testing.T) {
for _, ok := range []string{"", CleanupPolicyAuto, CleanupPolicyAlways, CleanupPolicyNever} {
if !IsValidCleanupPolicy(ok) {
t.Errorf("expected %q valid", ok)
}
}
for _, bad := range []string{"sometimes", "AUTO", "1"} {
if IsValidCleanupPolicy(bad) {
t.Errorf("expected %q invalid", bad)
}
}
}
// switchingFeedbackClient flips between failure and success on each call so
// tests can simulate "transient outage clears".
type switchingFeedbackClient struct {
NoopClient
state string
err error
}
func (c *switchingFeedbackClient) GetPRFeedback(_ context.Context, _, _ string, _ int) (*PRFeedback, error) {
if c.err != nil {
return nil, c.err
}
return &PRFeedback{PR: &PR{State: c.state}}, nil
}