346 lines
12 KiB
TypeScript
346 lines
12 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
createSnapshotEventGuard,
|
|
type GranularEventEntry,
|
|
type SnapshotEventGuard,
|
|
} from "@renderer/lib/snapshot-events";
|
|
|
|
async function handleSnapshotEntityEvent(
|
|
entry: GranularEventEntry,
|
|
deps: {
|
|
companyId: string | null;
|
|
getTask: (input: { companyId: string; id: string }) => Promise<{ ok: boolean; data?: unknown }>;
|
|
getRun: (input: { companyId: string; id: string }) => Promise<{ ok: boolean; data?: unknown }>;
|
|
getApproval: (input: { companyId: string; id: string }) => Promise<{ ok: boolean; data?: unknown }>;
|
|
getAgent: (input: { companyId: string; id: string }) => Promise<{ ok: boolean; data?: unknown }>;
|
|
patchTask: (task: unknown) => void;
|
|
removeTask: (id: string) => void;
|
|
patchRun: (run: unknown) => void;
|
|
removeRun: (id: string) => void;
|
|
patchApproval: (approval: unknown) => void;
|
|
removeApproval: (id: string) => void;
|
|
patchAgent: (agent: unknown) => void;
|
|
fullRefresh: () => void;
|
|
},
|
|
eventGuard: SnapshotEventGuard = createSnapshotEventGuard(),
|
|
) {
|
|
function requestFullRefresh() {
|
|
eventGuard.invalidateSnapshot();
|
|
deps.fullRefresh();
|
|
}
|
|
|
|
if (!deps.companyId) {
|
|
requestFullRefresh();
|
|
return;
|
|
}
|
|
|
|
let needsFullRefresh = false;
|
|
eventGuard.invalidateSnapshot();
|
|
eventGuard.invalidateEntity(entry);
|
|
const token = eventGuard.captureEntity(entry);
|
|
|
|
if (entry.type === "run-updated" && entry.action === "completed") {
|
|
needsFullRefresh = true;
|
|
} else {
|
|
try {
|
|
switch (entry.type) {
|
|
case "task-updated": {
|
|
if (entry.action === "deleted") {
|
|
deps.removeTask(entry.entityId);
|
|
} else {
|
|
const result = await deps.getTask({ companyId: deps.companyId, id: entry.entityId });
|
|
if (!eventGuard.canApplyEntity(entry, token)) return;
|
|
if (result.ok) {
|
|
deps.patchTask(result.data);
|
|
} else {
|
|
deps.removeTask(entry.entityId);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case "run-updated": {
|
|
if (entry.action === "deleted") {
|
|
deps.removeRun(entry.entityId);
|
|
} else {
|
|
const result = await deps.getRun({ companyId: deps.companyId, id: entry.entityId });
|
|
if (!eventGuard.canApplyEntity(entry, token)) return;
|
|
if (result.ok) {
|
|
deps.patchRun(result.data);
|
|
} else {
|
|
deps.removeRun(entry.entityId);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case "approval-updated": {
|
|
if (entry.action === "deleted") {
|
|
deps.removeApproval(entry.entityId);
|
|
} else {
|
|
const result = await deps.getApproval({ companyId: deps.companyId, id: entry.entityId });
|
|
if (!eventGuard.canApplyEntity(entry, token)) return;
|
|
if (result.ok) {
|
|
deps.patchApproval(result.data);
|
|
} else {
|
|
deps.removeApproval(entry.entityId);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case "agent-updated": {
|
|
const result = await deps.getAgent({ companyId: deps.companyId, id: entry.entityId });
|
|
if (!eventGuard.canApplyEntity(entry, token)) return;
|
|
if (result.ok) {
|
|
deps.patchAgent(result.data);
|
|
} else {
|
|
needsFullRefresh = true;
|
|
}
|
|
break;
|
|
}
|
|
case "budget-changed": {
|
|
needsFullRefresh = true;
|
|
break;
|
|
}
|
|
default: {
|
|
needsFullRefresh = true;
|
|
break;
|
|
}
|
|
}
|
|
} catch {
|
|
needsFullRefresh = true;
|
|
}
|
|
}
|
|
|
|
if (needsFullRefresh) {
|
|
requestFullRefresh();
|
|
}
|
|
}
|
|
|
|
function createMockDeps() {
|
|
return {
|
|
companyId: "company-1" as string | null,
|
|
getTask: vi.fn().mockResolvedValue({ ok: true, data: { id: "t-1", title: "Test task" } }),
|
|
getRun: vi.fn().mockResolvedValue({ ok: true, data: { id: "r-1", status: "running" } }),
|
|
getApproval: vi.fn().mockResolvedValue({ ok: true, data: { id: "a-1", state: "pending" } }),
|
|
getAgent: vi.fn().mockResolvedValue({ ok: true, data: { id: "ag-1", name: "Agent" } }),
|
|
patchTask: vi.fn(),
|
|
removeTask: vi.fn(),
|
|
patchRun: vi.fn(),
|
|
removeRun: vi.fn(),
|
|
patchApproval: vi.fn(),
|
|
removeApproval: vi.fn(),
|
|
patchAgent: vi.fn(),
|
|
fullRefresh: vi.fn(),
|
|
};
|
|
}
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
const promise = new Promise<T>((innerResolve) => {
|
|
resolve = innerResolve;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
describe("snapshot entity guard", () => {
|
|
it("task-updated event calls getTask and patchTask", async () => {
|
|
const deps = createMockDeps();
|
|
await handleSnapshotEntityEvent(
|
|
{ type: "task-updated", entityId: "t-1", action: "updated" },
|
|
deps,
|
|
);
|
|
expect(deps.getTask).toHaveBeenCalledWith({ companyId: "company-1", id: "t-1" });
|
|
expect(deps.patchTask).toHaveBeenCalledWith({ id: "t-1", title: "Test task" });
|
|
expect(deps.fullRefresh).toHaveBeenCalledTimes(0);
|
|
});
|
|
|
|
it("deleted task calls removeTask without fetching", async () => {
|
|
const deps = createMockDeps();
|
|
await handleSnapshotEntityEvent(
|
|
{ type: "task-updated", entityId: "t-1", action: "deleted" },
|
|
deps,
|
|
);
|
|
expect(deps.removeTask).toHaveBeenCalledWith("t-1");
|
|
expect(deps.getTask).toHaveBeenCalledTimes(0);
|
|
expect(deps.fullRefresh).toHaveBeenCalledTimes(0);
|
|
});
|
|
|
|
it("task delete invalidates a pending full snapshot refresh token", async () => {
|
|
const deps = createMockDeps();
|
|
const eventGuard = createSnapshotEventGuard();
|
|
eventGuard.invalidateSnapshot();
|
|
const pendingRefresh = eventGuard.captureSnapshot();
|
|
|
|
await handleSnapshotEntityEvent(
|
|
{ type: "task-updated", entityId: "t-1", action: "deleted" },
|
|
deps,
|
|
eventGuard,
|
|
);
|
|
|
|
expect(eventGuard.canApplySnapshot(pendingRefresh)).toBe(false);
|
|
expect(deps.removeTask).toHaveBeenCalledWith("t-1");
|
|
});
|
|
|
|
it("delayed task update cannot resurrect a task deleted by a later event", async () => {
|
|
const deps = createMockDeps();
|
|
const eventGuard = createSnapshotEventGuard();
|
|
const taskFetch = deferred<{ ok: boolean; data: { id: string; title: string } }>();
|
|
deps.getTask.mockReturnValue(taskFetch.promise);
|
|
|
|
const update = handleSnapshotEntityEvent(
|
|
{ type: "task-updated", entityId: "t-1", action: "updated" },
|
|
deps,
|
|
eventGuard,
|
|
);
|
|
expect(deps.getTask).toHaveBeenCalledWith({ companyId: "company-1", id: "t-1" });
|
|
|
|
await handleSnapshotEntityEvent(
|
|
{ type: "task-updated", entityId: "t-1", action: "deleted" },
|
|
deps,
|
|
eventGuard,
|
|
);
|
|
taskFetch.resolve({ ok: true, data: { id: "t-1", title: "Stale task" } });
|
|
await update;
|
|
|
|
expect(deps.removeTask).toHaveBeenCalledWith("t-1");
|
|
expect(deps.patchTask).toHaveBeenCalledTimes(0);
|
|
});
|
|
|
|
it("newer full refresh invalidates a delayed task update", async () => {
|
|
const deps = createMockDeps();
|
|
const eventGuard = createSnapshotEventGuard();
|
|
const taskFetch = deferred<{ ok: boolean; data: { id: string; title: string } }>();
|
|
deps.getTask.mockReturnValue(taskFetch.promise);
|
|
|
|
const update = handleSnapshotEntityEvent(
|
|
{ type: "task-updated", entityId: "t-1", action: "updated" },
|
|
deps,
|
|
eventGuard,
|
|
);
|
|
eventGuard.invalidateSnapshot();
|
|
taskFetch.resolve({ ok: true, data: { id: "t-1", title: "Stale task" } });
|
|
await update;
|
|
|
|
expect(deps.patchTask).toHaveBeenCalledTimes(0);
|
|
});
|
|
|
|
it("later task update wins over an earlier delayed task update", async () => {
|
|
const deps = createMockDeps();
|
|
const eventGuard = createSnapshotEventGuard();
|
|
const firstFetch = deferred<{ ok: boolean; data: { id: string; title: string } }>();
|
|
const secondFetch = deferred<{ ok: boolean; data: { id: string; title: string } }>();
|
|
deps.getTask
|
|
.mockReturnValueOnce(firstFetch.promise)
|
|
.mockReturnValueOnce(secondFetch.promise);
|
|
|
|
const firstUpdate = handleSnapshotEntityEvent(
|
|
{ type: "task-updated", entityId: "t-1", action: "updated" },
|
|
deps,
|
|
eventGuard,
|
|
);
|
|
const secondUpdate = handleSnapshotEntityEvent(
|
|
{ type: "task-updated", entityId: "t-1", action: "updated" },
|
|
deps,
|
|
eventGuard,
|
|
);
|
|
|
|
secondFetch.resolve({ ok: true, data: { id: "t-1", title: "Fresh task" } });
|
|
await secondUpdate;
|
|
firstFetch.resolve({ ok: true, data: { id: "t-1", title: "Stale task" } });
|
|
await firstUpdate;
|
|
|
|
expect(deps.patchTask).toHaveBeenCalledTimes(1);
|
|
expect(deps.patchTask).toHaveBeenCalledWith({ id: "t-1", title: "Fresh task" });
|
|
});
|
|
|
|
it("run-updated completed triggers full refresh", async () => {
|
|
const deps = createMockDeps();
|
|
await handleSnapshotEntityEvent(
|
|
{ type: "run-updated", entityId: "r-1", action: "completed" },
|
|
deps,
|
|
);
|
|
expect(deps.fullRefresh).toHaveBeenCalled();
|
|
expect(deps.getRun).toHaveBeenCalledTimes(0);
|
|
});
|
|
|
|
it("failed getTask fetch removes the missing task", async () => {
|
|
const deps = createMockDeps();
|
|
deps.getTask.mockResolvedValue({ ok: false, error: { code: "NOT_FOUND", message: "missing" } });
|
|
await handleSnapshotEntityEvent(
|
|
{ type: "task-updated", entityId: "t-1", action: "updated" },
|
|
deps,
|
|
);
|
|
expect(deps.removeTask).toHaveBeenCalledWith("t-1");
|
|
expect(deps.patchTask).toHaveBeenCalledTimes(0);
|
|
});
|
|
|
|
it("approval-updated event calls getApproval and patchApproval", async () => {
|
|
const deps = createMockDeps();
|
|
await handleSnapshotEntityEvent(
|
|
{ type: "approval-updated", entityId: "a-1", action: "decided" },
|
|
deps,
|
|
);
|
|
expect(deps.getApproval).toHaveBeenCalledWith({ companyId: "company-1", id: "a-1" });
|
|
expect(deps.patchApproval).toHaveBeenCalledWith({ id: "a-1", state: "pending" });
|
|
});
|
|
|
|
it("agent-updated event calls getAgent and patchAgent", async () => {
|
|
const deps = createMockDeps();
|
|
await handleSnapshotEntityEvent(
|
|
{ type: "agent-updated", entityId: "ag-1", action: "paused" },
|
|
deps,
|
|
);
|
|
expect(deps.getAgent).toHaveBeenCalledWith({ companyId: "company-1", id: "ag-1" });
|
|
expect(deps.patchAgent).toHaveBeenCalledWith({ id: "ag-1", name: "Agent" });
|
|
});
|
|
|
|
it("budget-changed triggers full refresh", async () => {
|
|
const deps = createMockDeps();
|
|
await handleSnapshotEntityEvent(
|
|
{ type: "budget-changed", entityId: "c-1", action: "changed" },
|
|
deps,
|
|
);
|
|
expect(deps.fullRefresh).toHaveBeenCalled();
|
|
});
|
|
|
|
it("missing companyId triggers full refresh", async () => {
|
|
const deps = createMockDeps();
|
|
deps.companyId = null;
|
|
await handleSnapshotEntityEvent(
|
|
{ type: "task-updated", entityId: "t-1", action: "updated" },
|
|
deps,
|
|
);
|
|
expect(deps.fullRefresh).toHaveBeenCalled();
|
|
expect(deps.getTask).toHaveBeenCalledTimes(0);
|
|
});
|
|
|
|
it("IPC error triggers full refresh", async () => {
|
|
const deps = createMockDeps();
|
|
deps.getTask.mockRejectedValue(new Error("IPC failed"));
|
|
await handleSnapshotEntityEvent(
|
|
{ type: "task-updated", entityId: "t-1", action: "updated" },
|
|
deps,
|
|
);
|
|
expect(deps.fullRefresh).toHaveBeenCalled();
|
|
});
|
|
|
|
it("unknown entity event triggers full refresh", async () => {
|
|
const deps = createMockDeps();
|
|
await handleSnapshotEntityEvent(
|
|
{ type: "future-entity", entityId: "x-1", action: "created" } as unknown as GranularEventEntry,
|
|
deps,
|
|
);
|
|
expect(deps.fullRefresh).toHaveBeenCalled();
|
|
});
|
|
|
|
it("run-updated status change fetches and patches run", async () => {
|
|
const deps = createMockDeps();
|
|
await handleSnapshotEntityEvent(
|
|
{ type: "run-updated", entityId: "r-1", action: "status_changed" },
|
|
deps,
|
|
);
|
|
expect(deps.getRun).toHaveBeenCalledWith({ companyId: "company-1", id: "r-1" });
|
|
expect(deps.patchRun).toHaveBeenCalledWith({ id: "r-1", status: "running" });
|
|
expect(deps.fullRefresh).toHaveBeenCalledTimes(0);
|
|
});
|
|
});
|