import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { mkdtempSync } from "node:fs"; import { rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { TaskStore } from "@fusion/core"; import { GitHubTrackingCommentService } from "../github-tracking-comments.js"; import { GitHubTrackingStateService } from "../github-tracking-state.js"; const { mockCommentOnIssue, mockSetIssueState, mockResolveGithubTrackingAuth } = vi.hoisted(() => ({ mockCommentOnIssue: vi.fn(), mockSetIssueState: vi.fn(), mockResolveGithubTrackingAuth: vi.fn(), })); vi.mock("../github.js", () => ({ GitHubClient: vi.fn().mockImplementation(() => ({ commentOnIssue: (...args: unknown[]) => mockCommentOnIssue(...args), setIssueState: (...args: unknown[]) => mockSetIssueState(...args), })), })); vi.mock("../github-auth.js", () => ({ resolveGithubTrackingAuth: (...args: unknown[]) => mockResolveGithubTrackingAuth(...args), })); function makeTmpDir(): string { return mkdtempSync(join(tmpdir(), "kb-dashboard-github-tracking-unlink-test-")); } async function flushAsync(): Promise { await new Promise((resolve) => setTimeout(resolve, 0)); } describe("github tracking unlink flow", () => { let rootDir: string; let globalDir: string; let store: TaskStore; let commentService: GitHubTrackingCommentService; let stateService: GitHubTrackingStateService; beforeEach(async () => { vi.clearAllMocks(); mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "token" } }); rootDir = makeTmpDir(); globalDir = makeTmpDir(); store = new TaskStore(rootDir, globalDir, { inMemoryDb: true }); await store.init(); commentService = new GitHubTrackingCommentService(store); stateService = new GitHubTrackingStateService(store); commentService.start(); stateService.start(); }); afterEach(async () => { commentService.stop(); stateService.stop(); await flushAsync(); store.close(); await rm(rootDir, { recursive: true, force: true }); await rm(globalDir, { recursive: true, force: true }); }); it("clears linked issue metadata on unlink while preserving toggle semantics", async () => { const task = await store.createTask({ description: "unlink", githubTracking: { enabled: true, repoOverride: "octocat/hello-world", }, }); await store.linkGithubIssue(task.id, { owner: "octocat", repo: "hello-world", number: 7, url: "https://github.com/octocat/hello-world/issues/7", createdAt: new Date().toISOString(), }); const unlinked = await store.unlinkGithubIssue(task.id); expect(unlinked.githubTracking?.issue).toBeUndefined(); expect(unlinked.githubTracking?.unlinkedAt).toBeTruthy(); expect(unlinked.githubTracking?.enabled).toBe(true); }); it("stops all status-sync calls after unlink and does not mutate remote issue during unlink", async () => { const task = await store.createTask({ description: "unlink sync", githubTracking: { enabled: true }, }); await store.linkGithubIssue(task.id, { owner: "octocat", repo: "hello-world", number: 9, url: "https://github.com/octocat/hello-world/issues/9", createdAt: new Date().toISOString(), }); await store.moveTask(task.id, "todo"); await store.moveTask(task.id, "in-progress"); await store.moveTask(task.id, "done"); await flushAsync(); expect(mockCommentOnIssue).toHaveBeenCalled(); expect(mockSetIssueState).toHaveBeenCalled(); mockCommentOnIssue.mockClear(); mockSetIssueState.mockClear(); await store.unlinkGithubIssue(task.id); await flushAsync(); // unlink is local-only: should not close/reopen/comment as a side-effect expect(mockCommentOnIssue).not.toHaveBeenCalled(); expect(mockSetIssueState).not.toHaveBeenCalled(); await store.moveTask(task.id, "todo"); await store.moveTask(task.id, "in-progress"); await store.moveTask(task.id, "done"); await flushAsync(); expect(mockCommentOnIssue).not.toHaveBeenCalled(); expect(mockSetIssueState).not.toHaveBeenCalled(); }); });