Files
buzz-sheet/lib/editor/autosave.test.ts
T
gunshiz c1b5ed867e
CI / Verify (push) Successful in 1m6s
CI / Build immutable images and deploy (push) Successful in 1m49s
fix(autosave) : retry constellation version conflicts
2026-09-02 12:59:14 +07:00

139 lines
5.0 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import {
AutosaveCoordinator,
saveWithConflictRetry,
type AutosaveResponse,
type AutosaveStatus,
} from "./autosave";
afterEach(() => {
vi.useRealTimers();
});
async function advance(milliseconds: number) {
vi.advanceTimersByTime(milliseconds);
await Promise.resolve();
await Promise.resolve();
}
function harness(
responses: AutosaveResponse[] = [
{ status: "saved", version: 2 },
],
) {
const saves: Array<{ value: string; expectedVersion: number }> = [];
const statuses: AutosaveStatus[] = [];
const save = vi.fn(async (payload: { value: string; expectedVersion: number }) => {
saves.push(payload);
return responses.shift() ?? { status: "saved" as const, version: payload.expectedVersion + 1 };
});
const coordinator = new AutosaveCoordinator({
initialVersion: 1,
createPayload: (value: string, expectedVersion) => ({ value, expectedVersion }),
save,
onStatus: (status) => statuses.push(status),
debounceMs: 750,
retryBaseMs: 100,
retryCapMs: 400,
maxRetries: 2,
});
return { coordinator, save, saves, statuses };
}
describe("AutosaveCoordinator", () => {
it("debounces edits and sends only the latest draft", async () => {
vi.useFakeTimers();
const { coordinator, save, saves } = harness();
coordinator.schedule("หนึ่ง");
await advance(500);
coordinator.schedule("สอง");
await advance(749);
expect(save).not.toHaveBeenCalled();
await advance(1);
expect(saves).toEqual([{ value: "สอง", expectedVersion: 1 }]);
});
it("serializes a new edit behind an in-flight save", async () => {
vi.useFakeTimers();
let resolveFirst: ((value: AutosaveResponse) => void) | undefined;
const saves: string[] = [];
const coordinator = new AutosaveCoordinator({
initialVersion: 4,
debounceMs: 10,
createPayload: (value: string, expectedVersion) => ({ value, expectedVersion }),
save: async ({ value, expectedVersion }) => {
saves.push(`${value}:${expectedVersion}`);
if (saves.length === 1) {
return new Promise<AutosaveResponse>((resolve) => {
resolveFirst = resolve;
});
}
return { status: "saved", version: expectedVersion + 1 };
},
});
coordinator.schedule("แรก");
await advance(10);
coordinator.schedule("ล่าสุด");
await advance(10);
expect(saves).toEqual(["แรก:4"]);
resolveFirst?.({ status: "saved", version: 5 });
await advance(0);
expect(saves).toEqual(["แรก:4", "ล่าสุด:5"]);
});
it("retries only transient failures with capped exponential delays", async () => {
vi.useFakeTimers();
const { coordinator, save } = harness([
{ status: "transient-error", message: "network" },
{ status: "transient-error", message: "network" },
{ status: "saved", version: 2 },
]);
coordinator.schedule("ร่าง");
await advance(750);
expect(save).toHaveBeenCalledTimes(1);
await advance(99);
expect(save).toHaveBeenCalledTimes(1);
await advance(1);
expect(save).toHaveBeenCalledTimes(2);
await advance(200);
expect(save).toHaveBeenCalledTimes(3);
});
it("stops on conflicts and validation errors", async () => {
vi.useFakeTimers();
const conflict = harness([{ status: "conflict", currentVersion: 8 }]);
conflict.coordinator.schedule("ชนกัน");
await advance(750);
expect(conflict.save).toHaveBeenCalledTimes(1);
expect(conflict.statuses.at(-1)).toEqual({ state: "conflict", currentVersion: 8 });
const invalid = harness([{ status: "validation-error", issues: ["title"] }]);
invalid.coordinator.schedule("ผิด");
await advance(750);
expect(invalid.save).toHaveBeenCalledTimes(1);
expect(invalid.statuses.at(-1)).toMatchObject({ state: "error", issues: ["title"] });
});
});
describe("saveWithConflictRetry", () => {
it("retries one version conflict against the current server version", async () => {
const calls: number[] = [];
const save = vi.fn(async (_value: string, expectedVersion: number) => {
calls.push(expectedVersion);
if (calls.length === 1) return { status: "conflict" as const, currentVersion: 8 };
return { status: "saved" as const, version: 9 };
});
const result = await saveWithConflictRetry(save, "constellation draft", 7);
expect(calls).toEqual([7, 8]);
expect(result).toEqual({ response: { status: "saved", version: 9 }, version: 8, retried: true });
});
it("stops after one retry when another concurrent save wins", async () => {
const save = vi.fn(async (_value: string, expectedVersion: number) => ({ status: "conflict" as const, currentVersion: expectedVersion + 1 }));
const result = await saveWithConflictRetry(save, "constellation draft", 3);
expect(save).toHaveBeenCalledTimes(2);
expect(result.response).toEqual({ status: "conflict", currentVersion: 5 });
});
});