package github import ( "context" "testing" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" taskmodels "github.com/kandev/kandev/internal/task/models" ) func TestListActivePRWatches_ExcludesArchivedTasks(t *testing.T) { _, svc, _, store := setupPollerTest(t) ctx := context.Background() seedTask(t, store, "t-active", false) seedTask(t, store, "t-archived", true) mustCreateWatch(t, store, "s-active", "t-active") mustCreateWatch(t, store, "s-archived", "t-archived") watches, err := svc.ListActivePRWatches(ctx) if err != nil { t.Fatalf("list watches: %v", err) } if len(watches) != 1 { t.Fatalf("expected 1 watch (active only), got %d", len(watches)) } if watches[0].TaskID != "t-active" { t.Errorf("expected watch for t-active, got %q", watches[0].TaskID) } } func TestListActivePRWatches_ExcludesOrphanedWatches(t *testing.T) { _, svc, _, store := setupPollerTest(t) ctx := context.Background() // Orphan: watch exists but no matching task row (task was hard-deleted). mustCreateWatch(t, store, "s-orphan", "t-gone") watches, err := svc.ListActivePRWatches(ctx) if err != nil { t.Fatalf("list watches: %v", err) } if len(watches) != 0 { t.Fatalf("expected orphaned watch to be excluded, got %d watches", len(watches)) } } func TestHandleTaskUpdated_ArchiveDeletesWatches(t *testing.T) { _, svc, _, store := setupPollerTest(t) ctx := context.Background() seedTask(t, store, "t1", false) mustCreateWatch(t, store, "s1", "t1") // Simulate task-service publishing task.updated with archived_at set. event := bus.NewEvent(events.TaskUpdated, "task-service", map[string]interface{}{ "task_id": "t1", "archived_at": "2026-04-19T12:00:00Z", }) if err := svc.handleTaskUpdated(ctx, event); err != nil { t.Fatalf("handleTaskUpdated: %v", err) } if got, _ := store.GetPRWatchBySession(ctx, "s1"); got != nil { t.Errorf("expected watch to be deleted after archive event, got %+v", got) } } func TestHandleTaskUpdated_NonArchiveUpdateLeavesWatches(t *testing.T) { _, svc, _, store := setupPollerTest(t) ctx := context.Background() seedTask(t, store, "t1", false) mustCreateWatch(t, store, "s1", "t1") // Regular edit: no archived_at in payload. event := bus.NewEvent(events.TaskUpdated, "task-service", map[string]interface{}{ "task_id": "t1", "title": "Edited title", }) if err := svc.handleTaskUpdated(ctx, event); err != nil { t.Fatalf("handleTaskUpdated: %v", err) } if got, _ := store.GetPRWatchBySession(ctx, "s1"); got == nil { t.Error("expected watch to persist after non-archive update") } } func TestHandleTaskDeleted_DeletesWatches(t *testing.T) { _, svc, _, store := setupPollerTest(t) ctx := context.Background() seedTask(t, store, "t1", false) mustCreateWatch(t, store, "s1", "t1") event := bus.NewEvent(events.TaskDeleted, "task-service", map[string]interface{}{ "task_id": "t1", }) if err := svc.handleTaskDeleted(ctx, event); err != nil { t.Fatalf("handleTaskDeleted: %v", err) } if got, _ := store.GetPRWatchBySession(ctx, "s1"); got != nil { t.Errorf("expected watch to be deleted after delete event, got %+v", got) } } func TestHandleTaskDeletedRevokesCredentialLeases(t *testing.T) { _, svc, _, _ := setupPollerTest(t) broker := revocationTestBroker("workspace-1", "t1", "s1") svc.SetCredentialBroker(broker) event := bus.NewEvent(events.TaskDeleted, "task-service", map[string]interface{}{ "task_id": "t1", }) if err := svc.handleTaskDeleted(context.Background(), event); err != nil { t.Fatalf("handleTaskDeleted: %v", err) } if got := broker.ActiveLeaseCount(); got != 0 { t.Fatalf("lease records = %d, want 0", got) } } func TestHandleTaskEvents_MalformedPayloadIsNoop(t *testing.T) { _, svc, _, store := setupPollerTest(t) ctx := context.Background() seedTask(t, store, "t1", false) mustCreateWatch(t, store, "s1", "t1") // Wrong payload type — should be ignored, not crash. bad := bus.NewEvent(events.TaskUpdated, "task-service", "not-a-map") if err := svc.handleTaskUpdated(ctx, bad); err != nil { t.Fatalf("handleTaskUpdated: %v", err) } // Missing task_id — should be ignored. empty := bus.NewEvent(events.TaskDeleted, "task-service", map[string]interface{}{}) if err := svc.handleTaskDeleted(ctx, empty); err != nil { t.Fatalf("handleTaskDeleted: %v", err) } if got, _ := store.GetPRWatchBySession(ctx, "s1"); got == nil { t.Error("expected watch to persist when payload is malformed") } } func TestHandleWorkspaceDeletedRemovesOnlyOwnedConnectionSecrets(t *testing.T) { svc, secrets := newWorkspaceConnectionService(t, "octocat") ctx := context.Background() if err := svc.store.UpsertWorkspaceSettings(ctx, &WorkspaceSettings{ WorkspaceID: "ws-1", TaskGitCredentialsMode: TaskGitCredentialsModeExecutor, }); err != nil { t.Fatalf("seed workspace settings: %v", err) } secrets.values[WorkspacePATSecretKey("ws-1")] = "pat" secrets.values[UserAccessTokenSecretKey("ws-1", "user-1")] = "access" secrets.values[UserRefreshTokenSecretKey("ws-1", "user-1")] = "refresh" secrets.values[WorkspacePATSecretKey("ws-2")] = "other" event := bus.NewEvent(events.WorkspaceDeleted, "task-service", map[string]interface{}{"id": "ws-1"}) if err := svc.handleWorkspaceDeleted(ctx, event); err != nil { t.Fatalf("handleWorkspaceDeleted: %v", err) } var settingsCount int if err := svc.store.db.GetContext(ctx, &settingsCount, `SELECT COUNT(1) FROM github_workspace_settings WHERE workspace_id = ?`, "ws-1"); err != nil { t.Fatalf("count workspace settings: %v", err) } if settingsCount != 0 { t.Fatalf("workspace settings rows = %d, want 0", settingsCount) } for _, id := range []string{ WorkspacePATSecretKey("ws-1"), UserAccessTokenSecretKey("ws-1", "user-1"), UserRefreshTokenSecretKey("ws-1", "user-1"), } { if _, ok := secrets.values[id]; ok { t.Fatalf("workspace-owned secret %q was not deleted", id) } } if got := secrets.values[WorkspacePATSecretKey("ws-2")]; got != "other" { t.Fatalf("unrelated workspace secret changed: %q", got) } } func TestHandleWorkspaceDeletedRevokesCredentialLeasesWithoutSecretStore(t *testing.T) { _, svc, _, _ := setupPollerTest(t) broker := revocationTestBroker("workspace-1", "t1", "s1") svc.SetCredentialBroker(broker) svc.connectionSecrets = nil event := bus.NewEvent(events.WorkspaceDeleted, "task-service", map[string]interface{}{"id": "workspace-1"}) if err := svc.handleWorkspaceDeleted(context.Background(), event); err != nil { t.Fatalf("handleWorkspaceDeleted: %v", err) } if got := broker.ActiveLeaseCount(); got != 0 { t.Fatalf("lease records = %d, want 0", got) } } func TestSubscribeTaskEventsTerminalSessionRevokesCredentialLease(t *testing.T) { _, svc, _, _ := setupPollerTest(t) broker := revocationTestBroker("workspace-1", "t1", "s1") svc.SetCredentialBroker(broker) svc.subscribeTaskEvents() t.Cleanup(svc.unsubscribeTaskEvents) event := bus.NewEvent(events.TaskSessionStateChanged, "orchestrator", map[string]interface{}{ "task_id": "t1", "session_id": "s1", "new_state": string(taskmodels.TaskSessionStateCompleted), }) if err := svc.eventBus.Publish(context.Background(), events.TaskSessionStateChanged, event); err != nil { t.Fatalf("publish: %v", err) } if got := broker.ActiveLeaseCount(); got != 0 { t.Fatalf("lease records = %d, want 0", got) } } func TestSubscribeTaskEvents_EndToEnd(t *testing.T) { _, svc, _, store := setupPollerTest(t) ctx := context.Background() svc.subscribeTaskEvents() seedTask(t, store, "t1", false) mustCreateWatch(t, store, "s1", "t1") event := bus.NewEvent(events.TaskUpdated, "task-service", map[string]interface{}{ "task_id": "t1", "archived_at": "2026-04-19T12:00:00Z", }) if err := svc.eventBus.Publish(ctx, events.TaskUpdated, event); err != nil { t.Fatalf("publish: %v", err) } // MemoryEventBus delivers synchronously, so the watch should already be gone. if got, _ := store.GetPRWatchBySession(ctx, "s1"); got != nil { t.Errorf("expected watch to be deleted by subscribed handler, got %+v", got) } } // mustCreateWatch is a test helper for creating a PR watch with minimal boilerplate. func mustCreateWatch(t *testing.T, store *Store, sessionID, taskID string) { t.Helper() w := &PRWatch{ SessionID: sessionID, TaskID: taskID, Owner: "owner", Repo: "repo", PRNumber: 0, Branch: "main", } if err := store.CreatePRWatch(context.Background(), w); err != nil { t.Fatalf("create PR watch: %v", err) } } func revocationTestBroker(workspaceID, taskID, sessionID string) *CredentialBroker { connection := &WorkspaceConnection{ WorkspaceID: workspaceID, Source: ConnectionSourcePAT, Status: ConnectionStatusActive, } connections := &fakeConnectionReader{workspaces: map[string]*WorkspaceConnection{workspaceID: connection}} broker := NewCredentialBroker(connections, NewCredentialResolver(connections, fakeAuthSecrets{ WorkspacePATSecretKey(workspaceID): "transient", }), &fakeBrokerAuthorizer{}) if _, err := broker.Issue(context.Background(), CredentialLeaseRequest{ WorkspaceID: workspaceID, TaskID: taskID, SessionID: sessionID, RepositoryID: "repository-1", Owner: "owner", Repo: "repo", Host: defaultGitHubHost, }); err != nil { panic(err) } return broker }