Files
kandev/apps/web/hooks/domains/github/task-pr-sync-resource.test.ts
2026-09-23 13:31:12 +01:00

385 lines
13 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createAppStore } from "@/lib/state/store";
import type { TaskPR } from "@/lib/types/github";
import {
createTaskPRSyncResource,
type TaskPRSyncRequester,
type TaskPRSyncScope,
} from "./task-pr-sync-resource";
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
function createStore(workspaceId: string | null = "workspace-1", generation = 0) {
return createAppStore({
workspaces: { items: [], activeId: workspaceId },
workspaceContextGeneration: generation,
});
}
function makePR(overrides: Partial<TaskPR> = {}): TaskPR {
return {
id: "association-1",
workspace_id: "workspace-1",
task_id: "task-1",
owner: "acme",
repo: "app",
pr_number: 1,
pr_url: "https://github.com/acme/app/pull/1",
pr_title: "Task PR",
head_branch: "feature/task-1",
base_branch: "main",
author_login: "octocat",
state: "open",
review_state: "",
checks_state: "",
mergeable_state: "clean",
review_count: 0,
pending_review_count: 0,
comment_count: 0,
unresolved_review_threads: 0,
checks_total: 0,
checks_passing: 0,
additions: 0,
deletions: 0,
created_at: "2026-08-27T00:00:00Z",
merged_at: null,
closed_at: null,
last_synced_at: null,
updated_at: "2026-08-27T00:00:00Z",
...overrides,
};
}
const scope: TaskPRSyncScope = {
workspaceId: "workspace-1",
workspaceContextGeneration: 0,
taskId: "task-1",
};
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe("task PR sync resource request ownership", () => {
// @covers AC-INTEGRATIONS-GITHUB-TASK-PR-SYNC-COORDINATION-001.1
// @covers AC-INTEGRATIONS-GITHUB-TASK-PR-SYNC-COORDINATION-001.2
it("joins active requests and publishes one loaded snapshot to every lease", async () => {
const store = createStore();
const requester = vi.fn<TaskPRSyncRequester>();
const pending = deferred<{ prs: TaskPR[] }>();
requester.mockReturnValue(pending.promise);
const resource = createTaskPRSyncResource(store, requester);
const firstListener = vi.fn();
const secondListener = vi.fn();
const firstRefresh = resource.refresh(scope);
const releaseFirst = resource.subscribe(scope, firstListener);
const releaseSecond = resource.subscribe(scope, secondListener);
const secondRefresh = resource.refresh(scope);
expect(requester).toHaveBeenCalledTimes(1);
expect(firstRefresh).toBe(secondRefresh);
pending.resolve({ prs: [] });
await firstRefresh;
expect(resource.getSnapshot(scope)).toBe(true);
expect(firstListener).toHaveBeenCalled();
expect(secondListener).toHaveBeenCalled();
releaseFirst();
releaseSecond();
});
it("marks only a user refresh as explicit", async () => {
const store = createStore();
const requester = vi.fn<TaskPRSyncRequester>().mockResolvedValue({ prs: [] });
const resource = createTaskPRSyncResource(store, requester, { retryDelayMs: 100 });
const release = resource.subscribe(scope, vi.fn());
await Promise.resolve();
expect(requester.mock.calls[0]?.[1]).toEqual({ explicitRefresh: false });
await resource.refresh(scope);
expect(requester.mock.calls[1]?.[1]).toEqual({ explicitRefresh: true });
release();
});
// @covers AC-INTEGRATIONS-GITHUB-TASK-PR-SYNC-COORDINATION-001.4
it("keeps retries after one release and stops them after the final release", async () => {
const store = createStore();
const requester = vi.fn<TaskPRSyncRequester>().mockResolvedValue({ prs: [] });
const resource = createTaskPRSyncResource(store, requester, {
retryDelayMs: 10,
maxRetries: 3,
});
const releaseFirst = resource.subscribe(scope, vi.fn());
const releaseSecond = resource.subscribe(scope, vi.fn());
await Promise.resolve();
await vi.advanceTimersByTimeAsync(10);
expect(requester).toHaveBeenCalledTimes(2);
releaseFirst();
await vi.advanceTimersByTimeAsync(10);
expect(requester).toHaveBeenCalledTimes(3);
releaseSecond();
await vi.advanceTimersByTimeAsync(100);
expect(requester).toHaveBeenCalledTimes(3);
});
// @covers AC-INTEGRATIONS-GITHUB-TASK-PR-SYNC-COORDINATION-001.3
it("keeps task, workspace, generation, and store scopes independent", async () => {
const store = createStore();
const requester = vi.fn<TaskPRSyncRequester>().mockResolvedValue({ prs: [] });
const resource = createTaskPRSyncResource(store, requester, { retryDelayMs: 100 });
const scopes = [
scope,
{ ...scope, taskId: "task-2" },
{ ...scope, workspaceId: "workspace-2" },
{ ...scope, workspaceContextGeneration: 1 },
];
const releases = scopes.map((item) => resource.subscribe(item, vi.fn()));
await Promise.resolve();
expect(requester).toHaveBeenCalledTimes(4);
releases.forEach((release) => release());
const secondStore = createStore();
const secondResource = createTaskPRSyncResource(secondStore, requester, {
retryDelayMs: 100,
});
const release = secondResource.subscribe(scope, vi.fn());
await Promise.resolve();
expect(requester).toHaveBeenCalledTimes(5);
release();
});
it("allows a Strict Mode-style release and reacquisition to rejoin the request", async () => {
const store = createStore();
const requester = vi.fn<TaskPRSyncRequester>();
const pending = deferred<{ prs: TaskPR[] }>();
requester.mockReturnValue(pending.promise);
const resource = createTaskPRSyncResource(store, requester);
const releaseFirst = resource.subscribe(scope, vi.fn());
releaseFirst();
const releaseSecond = resource.subscribe(scope, vi.fn());
expect(requester).toHaveBeenCalledTimes(1);
pending.resolve({ prs: [] });
await resource.refresh(scope);
releaseSecond();
});
});
describe("task PR sync resource lifecycle", () => {
// @covers AC-INTEGRATIONS-GITHUB-TASK-PR-SYNC-COORDINATION-001.5
it("stops retrying after a permanent response", async () => {
const store = createStore();
const requester = vi.fn<TaskPRSyncRequester>().mockResolvedValue({
prs: [],
permanent: true,
});
const resource = createTaskPRSyncResource(store, requester, { retryDelayMs: 10 });
const release = resource.subscribe(scope, vi.fn());
await Promise.resolve();
await vi.advanceTimersByTimeAsync(100);
expect(requester).toHaveBeenCalledTimes(1);
expect(resource.getSnapshot(scope)).toBe(true);
release();
});
it("publishes discovered pull requests to the scoped store", async () => {
const store = createStore();
const requester = vi.fn<TaskPRSyncRequester>().mockResolvedValue({ prs: [makePR()] });
const resource = createTaskPRSyncResource(store, requester, { retryDelayMs: 10 });
const release = resource.subscribe(scope, vi.fn());
await Promise.resolve();
expect(store.getState().taskPRs.byTaskId[scope.taskId]).toEqual([makePR()]);
expect(resource.getSnapshot(scope)).toBe(true);
await vi.advanceTimersByTimeAsync(100);
expect(requester).toHaveBeenCalledTimes(1);
release();
});
it("marks a task loaded only after the final transient failure", async () => {
const store = createStore();
const requester = vi.fn<TaskPRSyncRequester>().mockRejectedValue(new Error("sync unavailable"));
const resource = createTaskPRSyncResource(store, requester, {
retryDelayMs: 10,
maxRetries: 2,
});
const release = resource.subscribe(scope, vi.fn());
await Promise.resolve();
expect(resource.getSnapshot(scope)).toBe(false);
await vi.advanceTimersByTimeAsync(10);
expect(resource.getSnapshot(scope)).toBe(false);
await vi.advanceTimersByTimeAsync(10);
expect(resource.getSnapshot(scope)).toBe(true);
expect(requester).toHaveBeenCalledTimes(3);
await vi.advanceTimersByTimeAsync(100);
expect(requester).toHaveBeenCalledTimes(3);
release();
});
it("anchors the retry delay to the start of the previous request", async () => {
const store = createStore();
const requester = vi.fn<TaskPRSyncRequester>();
const first = deferred<{ prs: TaskPR[] }>();
const second = deferred<{ prs: TaskPR[] }>();
requester.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
const resource = createTaskPRSyncResource(store, requester, { retryDelayMs: 10 });
const release = resource.subscribe(scope, vi.fn());
expect(requester).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(8);
first.resolve({ prs: [] });
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(1);
expect(requester).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
expect(requester).toHaveBeenCalledTimes(2);
second.resolve({ prs: [] });
await vi.advanceTimersByTimeAsync(0);
release();
});
it("stops the retry schedule when another path discovers a pull request", async () => {
const store = createStore();
const requester = vi.fn<TaskPRSyncRequester>().mockResolvedValue({ prs: [] });
const resource = createTaskPRSyncResource(store, requester, { retryDelayMs: 10 });
const release = resource.subscribe(scope, vi.fn());
await Promise.resolve();
store.getState().setTaskPR("task-1", makePR(), {
workspaceId: scope.workspaceId,
workspaceContextGeneration: scope.workspaceContextGeneration,
});
await vi.advanceTimersByTimeAsync(100);
expect(requester).toHaveBeenCalledTimes(1);
release();
});
});
describe("task PR sync resource recovery", () => {
// @covers AC-INTEGRATIONS-GITHUB-TASK-PR-SYNC-COORDINATION-001.5
it("starts one fresh retry window on the connected transition", async () => {
const store = createStore();
const requester = vi.fn<TaskPRSyncRequester>().mockResolvedValue({ prs: [] });
const resource = createTaskPRSyncResource(store, requester, {
retryDelayMs: 10,
maxRetries: 2,
});
const release = resource.subscribe(scope, vi.fn());
await Promise.resolve();
await vi.advanceTimersByTimeAsync(30);
const callsAfterExhaustion = requester.mock.calls.length;
store.getState().setConnectionStatus("connected", null);
await Promise.resolve();
expect(requester.mock.calls.length).toBe(callsAfterExhaustion + 1);
store.getState().setConnectionStatus("connected", null);
await Promise.resolve();
expect(requester.mock.calls.length).toBe(callsAfterExhaustion + 1);
await vi.advanceTimersByTimeAsync(10);
expect(requester.mock.calls.length).toBe(callsAfterExhaustion + 2);
release();
});
it("abandons a pending pre-reconnect request and refreshes immediately", async () => {
const store = createStore();
const requester = vi.fn<TaskPRSyncRequester>();
const first = deferred<{ prs: TaskPR[] }>();
const second = deferred<{ prs: TaskPR[] }>();
requester.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
const resource = createTaskPRSyncResource(store, requester);
const release = resource.subscribe(scope, vi.fn());
expect(requester).toHaveBeenCalledTimes(1);
store.getState().setConnectionStatus("connected", null);
expect(requester).toHaveBeenCalledTimes(2);
second.resolve({ prs: [] });
first.resolve({ prs: [makePR()] });
await vi.advanceTimersByTimeAsync(0);
expect(store.getState().taskPRs.byTaskId[scope.taskId]).toBeUndefined();
release();
});
// @covers AC-INTEGRATIONS-GITHUB-TASK-PR-SYNC-COORDINATION-001.6
it("rejects a response from an inactive workspace context", async () => {
const store = createStore();
const requester = vi.fn<TaskPRSyncRequester>();
const pending = deferred<{ prs: TaskPR[] }>();
requester.mockReturnValue(pending.promise);
const resource = createTaskPRSyncResource(store, requester);
const release = resource.subscribe(scope, vi.fn());
store.setState((state) => ({
...state,
workspaces: { ...state.workspaces, activeId: "workspace-2" },
workspaceContextGeneration: 1,
}));
pending.resolve({ prs: [makePR()] });
await resource.refresh(scope);
expect(store.getState().taskPRs.byTaskId[scope.taskId]).toBeUndefined();
expect(resource.getSnapshot(scope)).toBe(false);
release();
});
it("does not publish an unresolved response after the final release", async () => {
const store = createStore();
const requester = vi.fn<TaskPRSyncRequester>();
const pending = deferred<{ prs: TaskPR[] }>();
requester.mockReturnValueOnce(pending.promise).mockResolvedValue({ prs: [] });
const resource = createTaskPRSyncResource(store, requester);
const release = resource.subscribe(scope, vi.fn());
release();
pending.resolve({ prs: [makePR()] });
await resource.refresh(scope);
expect(store.getState().taskPRs.byTaskId[scope.taskId]).toBeUndefined();
expect(resource.getSnapshot(scope)).toBe(false);
const reacquired = resource.subscribe(scope, vi.fn());
await Promise.resolve();
// The explicit refresh starts a replacement request even though the
// original passive request was released while it was in flight.
expect(requester).toHaveBeenCalledTimes(3);
reacquired();
});
});