Files
fusion/packages/dashboard/src/__tests__/github-tracking-state.test.ts
Fusion 44c26e341e feat(FN-3876): add GitHub tracking test coverage across settings, issues, s
Adds comprehensive test coverage for GitHub tracking across four areas: settings inheritance, issue creation, status sync transitions, and unlink sync regression — spanning 280 lines of new tests across four test files with minimal store.ts adjustment.

Fusion-Task-Id: FN-3876
2026-05-10 06:06:26 -07:00

266 lines
8.2 KiB
TypeScript

import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import type { TaskStore } from "@fusion/core";
import { decideIssueAction, GitHubTrackingStateService } from "../github-tracking-state.js";
const { mockSetIssueState } = vi.hoisted(() => ({
mockSetIssueState: vi.fn(),
}));
const { mockResolveGithubTrackingAuth } = vi.hoisted(() => ({
mockResolveGithubTrackingAuth: vi.fn(),
}));
vi.mock("../github.js", () => ({
GitHubClient: vi.fn().mockImplementation(() => ({
setIssueState: (...args: unknown[]) => mockSetIssueState(...args),
})),
}));
vi.mock("../github-auth.js", () => ({
resolveGithubTrackingAuth: (...args: unknown[]) => mockResolveGithubTrackingAuth(...args),
}));
class MockStore extends EventEmitter {
logEntry: Mock;
getSettings: Mock;
getGlobalSettingsStore: Mock;
constructor() {
super();
this.logEntry = vi.fn().mockResolvedValue(undefined);
this.getSettings = vi.fn().mockResolvedValue({ githubAuthMode: "token", githubAuthToken: "ghp_test" });
this.getGlobalSettingsStore = vi.fn(() => ({ getSettings: vi.fn().mockResolvedValue({}) }));
}
}
function createTask(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: "FN-1",
githubTracking: {
enabled: true,
issue: {
owner: "owner",
repo: "repo",
number: 42,
url: "https://github.com/owner/repo/issues/42",
createdAt: "2026-01-01T00:00:00.000Z",
},
},
...overrides,
};
}
async function flushAsync(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0));
}
describe("decideIssueAction", () => {
const columns = ["triage", "todo", "in-progress", "in-review", "done", "archived"] as const;
const activeColumns = ["triage", "todo", "in-progress", "in-review"] as const;
it.each(columns.filter((from) => from !== "done"))("returns close for %s -> done", (from) => {
expect(decideIssueAction(from, "done")).toEqual({ action: "close", stateReason: "completed" });
});
it.each(activeColumns)("returns reopen for done -> %s", (to) => {
expect(decideIssueAction("done", to)).toEqual({ action: "reopen", stateReason: "reopened" });
});
it("returns null for done -> archived", () => {
expect(decideIssueAction("done", "archived")).toBeNull();
});
it.each([
["triage", "todo"],
["todo", "in-progress"],
["in-progress", "in-review"],
["in-review", "archived"],
["done", "done"],
["archived", "archived"],
] as const)("returns null for %s -> %s", (from, to) => {
expect(decideIssueAction(from, to)).toBeNull();
});
});
describe("GitHubTrackingStateService", () => {
let store: MockStore;
let service: GitHubTrackingStateService;
beforeEach(() => {
vi.clearAllMocks();
store = new MockStore();
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
service = new GitHubTrackingStateService(store as unknown as TaskStore);
});
it("start/stop are idempotent", async () => {
service.start();
service.start();
store.emit("task:moved", { task: createTask(), from: "triage", to: "done" });
await flushAsync();
expect(mockSetIssueState).toHaveBeenCalledTimes(1);
service.stop();
service.stop();
store.emit("task:moved", { task: createTask(), from: "done", to: "todo" });
await flushAsync();
expect(mockSetIssueState).toHaveBeenCalledTimes(1);
});
it("closes on triage -> done and logs success", async () => {
service.start();
store.emit("task:moved", { task: createTask(), from: "triage", to: "done" });
await flushAsync();
expect(mockSetIssueState).toHaveBeenCalledWith("owner", "repo", 42, "closed", "completed");
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Closed linked GitHub tracking issue", "owner/repo#42");
});
it("closes on archived -> done", async () => {
service.start();
store.emit("task:moved", { task: createTask(), from: "archived", to: "done" });
await flushAsync();
expect(mockSetIssueState).toHaveBeenCalledWith("owner", "repo", 42, "closed", "completed");
});
it.each(["todo", "triage", "in-progress", "in-review"] as const)("reopens on done -> %s", async (to) => {
service.start();
store.emit("task:moved", { task: createTask(), from: "done", to });
await flushAsync();
expect(mockSetIssueState).toHaveBeenCalledWith("owner", "repo", 42, "open", "reopened");
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Reopened linked GitHub tracking issue", "owner/repo#42");
});
it("does nothing for done -> archived", async () => {
service.start();
store.emit("task:moved", { task: createTask(), from: "done", to: "archived" });
await flushAsync();
expect(mockSetIssueState).not.toHaveBeenCalled();
expect(store.logEntry).not.toHaveBeenCalled();
});
it("does nothing for non-done transitions", async () => {
service.start();
for (const [from, to] of [["triage", "todo"], ["todo", "in-progress"], ["in-review", "in-review"]] as const) {
store.emit("task:moved", { task: createTask(), from, to });
}
await flushAsync();
expect(mockSetIssueState).not.toHaveBeenCalled();
});
it("ignores disabled tracking", async () => {
service.start();
store.emit("task:moved", {
task: createTask({ githubTracking: { enabled: false } }),
from: "todo",
to: "done",
});
await flushAsync();
expect(mockSetIssueState).not.toHaveBeenCalled();
});
it("ignores missing linked issue", async () => {
service.start();
store.emit("task:moved", {
task: createTask({ githubTracking: { enabled: true } }),
from: "todo",
to: "done",
});
await flushAsync();
expect(mockSetIssueState).not.toHaveBeenCalled();
});
it("logs incomplete metadata", async () => {
service.start();
store.emit("task:moved", {
task: createTask({
githubTracking: {
enabled: true,
issue: {
owner: "",
repo: "repo",
number: 42,
},
},
}),
from: "todo",
to: "done",
});
await flushAsync();
expect(mockSetIssueState).not.toHaveBeenCalled();
expect(store.logEntry).toHaveBeenCalledWith(
"FN-1",
"Failed to update GitHub tracking issue state",
"Linked issue metadata is incomplete",
);
});
it("swallows close failures and keeps listener alive", async () => {
service.start();
mockSetIssueState.mockRejectedValueOnce(new Error("close failed"));
expect(() => {
store.emit("task:moved", { task: createTask(), from: "todo", to: "done" });
}).not.toThrow();
await flushAsync();
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Failed to close GitHub tracking issue", "close failed");
mockSetIssueState.mockResolvedValueOnce(undefined);
store.emit("task:moved", { task: createTask(), from: "done", to: "todo" });
await flushAsync();
expect(mockSetIssueState).toHaveBeenCalledTimes(2);
});
it("swallows reopen failures", async () => {
service.start();
mockSetIssueState.mockRejectedValueOnce(new Error("reopen failed"));
store.emit("task:moved", { task: createTask(), from: "done", to: "todo" });
await flushAsync();
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Failed to reopen GitHub tracking issue", "reopen failed");
});
it("resolves auth per call", async () => {
service.start();
store.emit("task:moved", { task: createTask(), from: "todo", to: "done" });
store.emit("task:moved", { task: createTask(), from: "done", to: "todo" });
await flushAsync();
expect(mockSetIssueState).toHaveBeenCalledTimes(2);
expect(mockResolveGithubTrackingAuth).toHaveBeenCalledTimes(2);
});
it("emits close then reopen in order", async () => {
service.start();
store.emit("task:moved", { task: createTask(), from: "triage", to: "done" });
store.emit("task:moved", { task: createTask(), from: "done", to: "todo" });
await flushAsync();
expect(mockSetIssueState).toHaveBeenCalledTimes(2);
expect(mockSetIssueState).toHaveBeenNthCalledWith(1, "owner", "repo", 42, "closed", "completed");
expect(mockSetIssueState).toHaveBeenNthCalledWith(2, "owner", "repo", 42, "open", "reopened");
});
});