Stabilize dashboard tests against constructor and request-harness edge cases. - update dashboard route and service tests to use constructor-safe mock implementations and reset targeted spies between cases - make the test request helper resume its mock socket, always report successful writes, and preserve end callbacks - tighten board workflow route assertions around explicit workflow-column flag disabling and cache-bounded slim task snapshots Files changed: .../__tests__/auth-middleware-integration.test.ts | 4 ++-- .../src/__tests__/browse-directory-routes.test.ts | 4 ++-- packages/dashboard/src/__tests__/chat.test.ts | 3 ++- .../src/__tests__/discovery-routes.test.ts | 4 ++-- .../src/__tests__/docker-node-routes.test.ts | 4 ++-- .../__tests__/experiment-routes.finalize.test.ts | 2 +- .../src/__tests__/github-issue-comment.test.ts | 4 ++-- .../dashboard/src/__tests__/github-poll.test.ts | 4 ++-- .../__tests__/github-source-issue-close.test.ts | 4 ++-- .../github-source-issue-reconciler.test.ts | 4 ++-- .../src/__tests__/github-tracking-comments.test.ts | 4 ++-- .../src/__tests__/github-tracking-delete.test.ts | 4 ++-- .../src/__tests__/github-tracking-hook.test.ts | 4 ++-- ...ithub-tracking-periodic-reconcile-sweep.test.ts | 12 +++++----- .../__tests__/github-tracking-reconciler.test.ts | 4 ++-- .../src/__tests__/github-tracking-state.test.ts | 4 ++-- .../src/__tests__/github-tracking-unlink.test.ts | 4 ++-- .../src/__tests__/github-tracking.test.ts | 4 ++-- .../dashboard/src/__tests__/mesh-routes.test.ts | 4 ++-- .../dashboard/src/__tests__/node-routes.test.ts | 4 ++-- .../src/__tests__/pi-extensions-routes.test.ts | 10 ++++----- .../src/__tests__/plugin-routes.routes.test.ts | 4 ++-- .../dashboard/src/__tests__/plugin-routes.test.ts | 4 ++-- .../__tests__/project-pause-resume-routes.test.ts | 4 ++-- .../dashboard/src/__tests__/project-routes.test.ts | 4 ++-- .../dashboard/src/__tests__/routes-agents.test.ts | 4 ++-- .../dashboard/src/__tests__/routes-auth.test.ts | 6 +++-- .../src/__tests__/routes-automation.test.ts | 4 ++-- .../dashboard/src/__tests__/routes-git.test.ts | 4 ++-- .../dashboard/src/__tests__/routes-github.test.ts | 4 ++-- .../src/__tests__/routes-planning-tracking.test.ts | 1 + .../src/__tests__/routes-planning.test.ts | 4 ++-- .../dashboard/src/__tests__/routes-proxy.test.ts | 8 +++---- .../src/__tests__/routes-settings.test.ts | 4 ++-- .../dashboard/src/__tests__/routes-system.test.ts | 7 ++++-- .../src/__tests__/routes-tasks-ops.test.ts | 4 ++-- .../dashboard/src/__tests__/routes-tasks.test.ts | 4 ++-- .../src/__tests__/session-resume-history.test.ts | 1 + .../dashboard/src/__tests__/setup-routes.test.ts | 8 +++---- .../shared-branch-group-entry-points.test.ts | 4 ++-- .../routes/__tests__/board-workflows-route.test.ts | 26 +++++++++++++--------- .../src/routes/__tests__/custom-providers.test.ts | 4 ++-- .../routes/__tests__/docker-node-routes.test.ts | 4 ++-- .../__tests__/register-docker-node-routes.test.ts | 6 ++--- .../register-docker-provisioning-routes.test.ts | 10 ++++----- packages/dashboard/src/test-request.ts | 7 ++++-- 46 files changed, 126 insertions(+), 109 deletions(-) Fusion-Task-Id: FN-6146 Fusion-Task-Lineage: 989e9da3-92ed-43bd-a505-6114de4f1120
635 lines
22 KiB
TypeScript
635 lines
22 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, mockDeleteIssue, mockGetIssue } = vi.hoisted(() => ({
|
|
mockSetIssueState: vi.fn(),
|
|
mockDeleteIssue: vi.fn(),
|
|
mockGetIssue: vi.fn(),
|
|
}));
|
|
|
|
const { mockResolveGithubTrackingAuth } = vi.hoisted(() => ({
|
|
mockResolveGithubTrackingAuth: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../github.js", () => ({
|
|
GitHubClient: vi.fn().mockImplementation(function () { return {
|
|
setIssueState: (...args: unknown[]) => mockSetIssueState(...args),
|
|
deleteIssue: (...args: unknown[]) => mockDeleteIssue(...args),
|
|
getIssue: (...args: unknown[]) => mockGetIssue(...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,
|
|
};
|
|
}
|
|
|
|
function createSourceTask(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
return {
|
|
id: "FN-source",
|
|
sourceIssue: {
|
|
provider: "github",
|
|
repository: "acme/widgets",
|
|
issueNumber: 42,
|
|
externalIssueId: "42",
|
|
},
|
|
...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("closes on done -> archived", () => {
|
|
expect(decideIssueAction("done", "archived")).toEqual({ action: "close", stateReason: "completed" });
|
|
});
|
|
|
|
it("closes on in-review -> archived", () => {
|
|
expect(decideIssueAction("in-review", "archived")).toEqual({ action: "close", stateReason: "not_planned" });
|
|
});
|
|
|
|
it.each(["todo", "triage", "in-progress"] as const)("returns close not_planned for %s -> archived", (from) => {
|
|
expect(decideIssueAction(from, "archived")).toEqual({ action: "close", stateReason: "not_planned" });
|
|
});
|
|
|
|
it.each([
|
|
["triage", "todo"],
|
|
["todo", "in-progress"],
|
|
["in-progress", "in-review"],
|
|
["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" } });
|
|
mockGetIssue.mockResolvedValue({ state: "open" });
|
|
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" });
|
|
store.emit("task:deleted", createTask({ id: "FN-2" }));
|
|
await flushAsync();
|
|
expect(mockSetIssueState).toHaveBeenCalledTimes(2);
|
|
|
|
service.stop();
|
|
service.stop();
|
|
|
|
store.emit("task:moved", { task: createTask(), from: "done", to: "todo" });
|
|
store.emit("task:deleted", createTask({ id: "FN-3" }));
|
|
await flushAsync();
|
|
expect(mockSetIssueState).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
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("closes on done -> archived", async () => {
|
|
service.start();
|
|
|
|
store.emit("task:moved", { task: createTask(), from: "done", to: "archived" });
|
|
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 triage -> archived with not_planned", async () => {
|
|
service.start();
|
|
|
|
store.emit("task:moved", { task: createTask(), from: "triage", to: "archived" });
|
|
await flushAsync();
|
|
|
|
expect(mockSetIssueState).toHaveBeenCalledWith("owner", "repo", 42, "closed", "not_planned");
|
|
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Closed linked GitHub tracking issue", "owner/repo#42");
|
|
});
|
|
|
|
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("retries once for transient close failures", async () => {
|
|
service.start();
|
|
mockSetIssueState.mockRejectedValueOnce(new Error("ECONNRESET"));
|
|
mockSetIssueState.mockResolvedValueOnce(undefined);
|
|
|
|
store.emit("task:moved", { task: createTask(), from: "todo", to: "done" });
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
|
|
expect(mockSetIssueState).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("treats already-closed issue as success", async () => {
|
|
service.start();
|
|
mockGetIssue.mockResolvedValueOnce({ state: "closed" });
|
|
|
|
store.emit("task:moved", { task: createTask(), from: "todo", to: "done" });
|
|
await flushAsync();
|
|
|
|
expect(mockSetIssueState).not.toHaveBeenCalled();
|
|
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Linked GitHub tracking issue already closed", "owner/repo#42");
|
|
});
|
|
|
|
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("closes issue for late-registered project stores after attach", async () => {
|
|
const lateStore = new MockStore();
|
|
service.start();
|
|
service.attach(lateStore as unknown as TaskStore);
|
|
|
|
lateStore.emit("task:moved", { task: createTask({ id: "FN-late" }), from: "todo", to: "done" });
|
|
await flushAsync();
|
|
|
|
expect(mockSetIssueState).toHaveBeenCalledWith("owner", "repo", 42, "closed", "completed");
|
|
});
|
|
|
|
it("emits close and reopen updates", 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).toHaveBeenCalledWith("owner", "repo", 42, "closed", "completed");
|
|
expect(mockSetIssueState).toHaveBeenCalledWith("owner", "repo", 42, "open", "reopened");
|
|
});
|
|
|
|
describe("on task:deleted", () => {
|
|
it.each([undefined, "auto", "close"] as const)("closes the linked issue with not_planned when action is %s", async (action) => {
|
|
service.start();
|
|
|
|
if (action === undefined) {
|
|
store.emit("task:deleted", createTask());
|
|
} else {
|
|
store.emit("task:deleted", createTask(), { githubIssueAction: action });
|
|
}
|
|
await flushAsync();
|
|
|
|
expect(mockSetIssueState).toHaveBeenCalledWith("owner", "repo", 42, "closed", "not_planned");
|
|
expect(mockDeleteIssue).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("deletes linked issue when githubIssueAction is delete", async () => {
|
|
service.start();
|
|
|
|
store.emit("task:deleted", createTask(), { githubIssueAction: "delete" });
|
|
await flushAsync();
|
|
|
|
expect(mockDeleteIssue).toHaveBeenCalledWith("owner", "repo", 42);
|
|
expect(mockSetIssueState).not.toHaveBeenCalled();
|
|
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Deleted linked GitHub tracking issue", "owner/repo#42");
|
|
});
|
|
|
|
it("leaves linked issue untouched when githubIssueAction is leave", async () => {
|
|
service.start();
|
|
|
|
store.emit("task:deleted", createTask(), { githubIssueAction: "leave" });
|
|
await flushAsync();
|
|
|
|
expect(mockDeleteIssue).not.toHaveBeenCalled();
|
|
expect(mockSetIssueState).not.toHaveBeenCalled();
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-1",
|
|
"Left linked GitHub tracking issue unchanged on task delete",
|
|
"owner/repo#42",
|
|
);
|
|
});
|
|
|
|
describe("source-imported issue delete", () => {
|
|
it("leaves source issue untouched when githubIssueAction is leave", async () => {
|
|
service.start();
|
|
|
|
store.emit("task:deleted", createSourceTask(), { githubIssueAction: "leave" });
|
|
await flushAsync();
|
|
|
|
expect(mockDeleteIssue).not.toHaveBeenCalled();
|
|
expect(mockSetIssueState).not.toHaveBeenCalled();
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-source",
|
|
"Left linked source GitHub issue unchanged on task delete",
|
|
"acme/widgets#42",
|
|
);
|
|
});
|
|
|
|
it("closes source issue with completed reason", async () => {
|
|
service.start();
|
|
|
|
store.emit("task:deleted", createSourceTask(), { githubIssueAction: "close" });
|
|
await flushAsync();
|
|
|
|
expect(mockSetIssueState).toHaveBeenCalledWith("acme", "widgets", 42, "closed", "completed");
|
|
});
|
|
|
|
it("short-circuits when source issue is already closed", async () => {
|
|
service.start();
|
|
mockGetIssue.mockResolvedValueOnce({ state: "closed" });
|
|
|
|
store.emit("task:deleted", createSourceTask(), { githubIssueAction: "close" });
|
|
await flushAsync();
|
|
|
|
expect(mockSetIssueState).not.toHaveBeenCalled();
|
|
expect(store.logEntry).toHaveBeenCalledWith("FN-source", "Linked source GitHub issue already closed", "acme/widgets#42");
|
|
});
|
|
|
|
it("retries transient source close errors once", async () => {
|
|
service.start();
|
|
mockSetIssueState.mockRejectedValueOnce(new Error("ECONNRESET"));
|
|
mockSetIssueState.mockResolvedValueOnce(undefined);
|
|
|
|
store.emit("task:deleted", createSourceTask(), { githubIssueAction: "close" });
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
|
|
expect(mockSetIssueState).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("emits failure for non-transient source close errors", async () => {
|
|
service.start();
|
|
mockSetIssueState.mockRejectedValueOnce(new Error("close failed"));
|
|
const emitSpy = vi.spyOn(store, "emit");
|
|
|
|
store.emit("task:deleted", createSourceTask(), { githubIssueAction: "close" });
|
|
await flushAsync();
|
|
|
|
expect(emitSpy).toHaveBeenCalledWith(
|
|
"github-issue:action",
|
|
expect.objectContaining({ taskId: "FN-source", action: "close", outcome: "failed", error: "close failed" }),
|
|
);
|
|
});
|
|
|
|
it("deletes source issue when githubIssueAction is delete", async () => {
|
|
service.start();
|
|
|
|
store.emit("task:deleted", createSourceTask(), { githubIssueAction: "delete" });
|
|
await flushAsync();
|
|
|
|
expect(mockDeleteIssue).toHaveBeenCalledWith("acme", "widgets", 42);
|
|
});
|
|
|
|
it("retries transient source delete errors once", async () => {
|
|
service.start();
|
|
mockDeleteIssue.mockRejectedValueOnce(new Error("timed out"));
|
|
mockDeleteIssue.mockResolvedValueOnce(undefined);
|
|
|
|
store.emit("task:deleted", createSourceTask(), { githubIssueAction: "delete" });
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
|
|
expect(mockDeleteIssue).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it.each([undefined, "auto"] as const)("defaults source delete action %s to close", async (action) => {
|
|
service.start();
|
|
|
|
if (action === undefined) {
|
|
store.emit("task:deleted", createSourceTask());
|
|
} else {
|
|
store.emit("task:deleted", createSourceTask(), { githubIssueAction: action });
|
|
}
|
|
await flushAsync();
|
|
|
|
expect(mockSetIssueState).toHaveBeenCalledWith("acme", "widgets", 42, "closed", "completed");
|
|
});
|
|
|
|
it("ignores non-github source providers", async () => {
|
|
service.start();
|
|
|
|
store.emit("task:deleted", createSourceTask({ sourceIssue: { provider: "gitlab", repository: "group/proj", issueNumber: 42 } }));
|
|
await flushAsync();
|
|
|
|
expect(mockSetIssueState).not.toHaveBeenCalled();
|
|
expect(mockDeleteIssue).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("logs malformed source repository", async () => {
|
|
service.start();
|
|
|
|
store.emit("task:deleted", createSourceTask({ sourceIssue: { provider: "github", repository: "no-slash", issueNumber: 42 } }));
|
|
await flushAsync();
|
|
|
|
expect(mockSetIssueState).not.toHaveBeenCalled();
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-source",
|
|
"Failed to close linked source GitHub issue",
|
|
"Invalid source issue repository: no-slash",
|
|
);
|
|
});
|
|
|
|
it.each([0, -1, 1.5] as const)("ignores invalid source issue numbers (%s)", async (issueNumber) => {
|
|
service.start();
|
|
|
|
store.emit("task:deleted", createSourceTask({ sourceIssue: { provider: "github", repository: "acme/widgets", issueNumber } }));
|
|
await flushAsync();
|
|
|
|
expect(mockSetIssueState).not.toHaveBeenCalled();
|
|
expect(mockDeleteIssue).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("prefers tracking branch when both tracking and source issue exist", async () => {
|
|
service.start();
|
|
|
|
store.emit("task:deleted", createTask({ sourceIssue: { provider: "github", repository: "acme/widgets", issueNumber: 42 } }), { githubIssueAction: "close" });
|
|
await flushAsync();
|
|
|
|
expect(mockSetIssueState).toHaveBeenCalledWith("owner", "repo", 42, "closed", "not_planned");
|
|
expect(mockSetIssueState).not.toHaveBeenCalledWith("acme", "widgets", 42, "closed", "completed");
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
label: "tracking disabled",
|
|
task: createTask({ githubTracking: { enabled: false } }),
|
|
},
|
|
{
|
|
label: "missing issue",
|
|
task: createTask({ githubTracking: { enabled: true } }),
|
|
},
|
|
{
|
|
label: "missing owner",
|
|
task: createTask({ githubTracking: { enabled: true, issue: { owner: "", repo: "repo", number: 42 } } }),
|
|
},
|
|
{
|
|
label: "missing repo",
|
|
task: createTask({ githubTracking: { enabled: true, issue: { owner: "owner", repo: "", number: 42 } } }),
|
|
},
|
|
{
|
|
label: "missing number",
|
|
task: createTask({ githubTracking: { enabled: true, issue: { owner: "owner", repo: "repo" } } }),
|
|
},
|
|
])("does nothing when $label", async ({ task }) => {
|
|
service.start();
|
|
|
|
store.emit("task:deleted", task);
|
|
await flushAsync();
|
|
|
|
expect(mockSetIssueState).not.toHaveBeenCalled();
|
|
expect(mockDeleteIssue).not.toHaveBeenCalled();
|
|
expect(store.logEntry).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("logs close failures without throwing", async () => {
|
|
service.start();
|
|
mockSetIssueState.mockRejectedValueOnce(new Error("delete close failed"));
|
|
|
|
expect(() => {
|
|
store.emit("task:deleted", createTask(), { githubIssueAction: "close" });
|
|
}).not.toThrow();
|
|
await flushAsync();
|
|
|
|
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Failed to close linked GitHub tracking issue", "delete close failed");
|
|
});
|
|
|
|
it("still attempts close and emits failure event when logEntry rejects for deleted task", async () => {
|
|
service.start();
|
|
store.logEntry = vi.fn().mockRejectedValue(new Error("Task FN-1 not found"));
|
|
mockSetIssueState.mockRejectedValueOnce(new Error("delete close failed"));
|
|
const emitSpy = vi.spyOn(store, "emit");
|
|
const unhandledRejections: unknown[] = [];
|
|
const onUnhandledRejection = (reason: unknown) => {
|
|
unhandledRejections.push(reason);
|
|
};
|
|
process.on("unhandledRejection", onUnhandledRejection);
|
|
|
|
try {
|
|
store.emit("task:deleted", createTask(), { githubIssueAction: "close" });
|
|
await flushAsync();
|
|
|
|
expect(mockSetIssueState).toHaveBeenCalledTimes(1);
|
|
expect(unhandledRejections).toHaveLength(0);
|
|
expect(emitSpy).toHaveBeenCalledWith(
|
|
"github-issue:action",
|
|
expect.objectContaining({
|
|
taskId: "FN-1",
|
|
action: "close",
|
|
outcome: "failed",
|
|
error: "delete close failed",
|
|
}),
|
|
);
|
|
} finally {
|
|
process.off("unhandledRejection", onUnhandledRejection);
|
|
}
|
|
});
|
|
|
|
it("logs delete failures without throwing", async () => {
|
|
service.start();
|
|
mockDeleteIssue.mockRejectedValueOnce(new Error("delete failed"));
|
|
|
|
expect(() => {
|
|
store.emit("task:deleted", createTask(), { githubIssueAction: "delete" });
|
|
}).not.toThrow();
|
|
await flushAsync();
|
|
|
|
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Failed to delete linked GitHub tracking issue", "delete failed");
|
|
});
|
|
|
|
it("still attempts delete and emits failure event when logEntry rejects for deleted task", async () => {
|
|
service.start();
|
|
store.logEntry = vi.fn().mockRejectedValue(new Error("Task FN-1 not found"));
|
|
mockDeleteIssue.mockRejectedValueOnce(new Error("delete failed"));
|
|
const emitSpy = vi.spyOn(store, "emit");
|
|
const unhandledRejections: unknown[] = [];
|
|
const onUnhandledRejection = (reason: unknown) => {
|
|
unhandledRejections.push(reason);
|
|
};
|
|
process.on("unhandledRejection", onUnhandledRejection);
|
|
|
|
try {
|
|
store.emit("task:deleted", createTask(), { githubIssueAction: "delete" });
|
|
await flushAsync();
|
|
|
|
expect(mockDeleteIssue).toHaveBeenCalledTimes(1);
|
|
expect(unhandledRejections).toHaveLength(0);
|
|
expect(emitSpy).toHaveBeenCalledWith(
|
|
"github-issue:action",
|
|
expect.objectContaining({
|
|
taskId: "FN-1",
|
|
action: "delete",
|
|
outcome: "failed",
|
|
error: "delete failed",
|
|
}),
|
|
);
|
|
} finally {
|
|
process.off("unhandledRejection", onUnhandledRejection);
|
|
}
|
|
});
|
|
});
|
|
});
|