From 12d8d1557229b8acbd4e56cd8cbb56cd1db06c79 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 23 Nov 2025 11:03:40 +0000 Subject: [PATCH] Add fetch controller tests --- .../src/services/fetch-controller.test.ts | 186 ++++++++++++++++++ .../src/services/fetch-controller.ts | 4 + .../src/sync-operations/cursor-tracker.ts | 4 +- 3 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 frontend/sync-client/src/services/fetch-controller.test.ts diff --git a/frontend/sync-client/src/services/fetch-controller.test.ts b/frontend/sync-client/src/services/fetch-controller.test.ts new file mode 100644 index 00000000..e5562dcd --- /dev/null +++ b/frontend/sync-client/src/services/fetch-controller.test.ts @@ -0,0 +1,186 @@ +import { describe, it, mock, beforeEach, afterEach } from "node:test"; +import assert from "node:assert"; +import { FetchController } from "./fetch-controller"; +import { Logger } from "../tracing/logger"; +import { SyncResetError } from "./sync-reset-error"; +import { sleep } from "../utils/sleep"; + +describe("FetchController", () => { + const createMockFetch = (shouldSleep: boolean) => + mock.fn(async () => { + if (shouldSleep) { + await sleep(50); + } + return Promise.resolve(new Response("OK", { status: 200 })); + }); + + beforeEach(() => { + mock.timers.enable({ apis: ["setTimeout"] }); + }); + + afterEach(() => { + mock.timers.reset(); + }); + + it("should allow fetch when canFetch is true", async () => { + const logger = new Logger(); + const controller = new FetchController(true, logger); + const mockFetch = createMockFetch(true); + const controlledFetch = controller.getControlledFetchImplementation( + logger, + mockFetch + ); + + mock.timers.tick(50); + const response = await controlledFetch("http://example.com"); + + assert.strictEqual(await response.text(), "OK"); + assert.strictEqual(mockFetch.mock.calls.length, 1); + }); + + it("should block fetch until canFetch becomes true", async () => { + const logger = new Logger(); + const controller = new FetchController(false, logger); + const mockFetch = createMockFetch(true); + const controlledFetch = controller.getControlledFetchImplementation( + logger, + mockFetch + ); + + const fetchPromise = controlledFetch("http://example.com"); + mock.timers.tick(10); + assert.strictEqual(mockFetch.mock.calls.length, 0); + + controller.canFetch = true; + + mock.timers.tick(50); + const response = await fetchPromise; + assert.strictEqual(await response.text(), "OK"); + assert.strictEqual(mockFetch.mock.calls.length, 1); + }); + + it("should reject during reset", async () => { + const logger = new Logger(); + const controller = new FetchController(true, logger); + const mockFetch = createMockFetch(true); + const controlledFetch = controller.getControlledFetchImplementation( + logger, + mockFetch + ); + + const firstRequest = controlledFetch("http://example.com"); + assert.strictEqual(mockFetch.mock.calls.length, 1); // because firstRequest started before reset + controller.startReset(); + const secondRequest = controlledFetch("http://example.com"); + + mock.timers.tick(50); + + await assert.rejects( + firstRequest, + (error: unknown) => error instanceof SyncResetError + ); + await assert.rejects( + secondRequest, + (error: unknown) => error instanceof SyncResetError + ); + assert.strictEqual(mockFetch.mock.calls.length, 1); // because firstRequest started before reset + }); + + it("should allow fetch after reset finishes", async () => { + const logger = new Logger(); + const controller = new FetchController(true, logger); + const mockFetch = createMockFetch(true); + const controlledFetch = controller.getControlledFetchImplementation( + logger, + mockFetch + ); + + controller.startReset(); + controller.finishReset(); + + mock.timers.tick(50); + const response = await controlledFetch("http://example.com"); + assert.strictEqual(await response.text(), "OK"); + }); + + it("should throw when finishing reset without starting", () => { + const logger = new Logger(); + const controller = new FetchController(true, logger); + + assert.throws( + () => controller.finishReset(), + (error: unknown) => + error instanceof Error && + error.message === "Cannot finish reset when not resetting" + ); + }); + + it("should defer canFetch changes during reset", async () => { + const logger = new Logger(); + const controller = new FetchController(false, logger); + const mockFetch = createMockFetch(true); + const controlledFetch = controller.getControlledFetchImplementation( + logger, + mockFetch + ); + + controller.startReset(); + controller.canFetch = true; + + await assert.rejects( + async () => controlledFetch("http://example.com"), + (error: unknown) => error instanceof SyncResetError + ); + + controller.finishReset(); + + mock.timers.tick(50); + const response = await controlledFetch("http://example.com"); + assert.strictEqual(await response.text(), "OK"); + }); + + it("should handle different input types", async () => { + const logger = new Logger(); + const controller = new FetchController(true, logger); + const mockFetch = createMockFetch(false); + const controlledFetch = controller.getControlledFetchImplementation( + logger, + mockFetch + ); + + await controlledFetch("http://example.com"); + await controlledFetch(new URL("http://example.com")); + await controlledFetch( + new Request("http://example.com", { method: "POST" }) + ); + + assert.strictEqual(mockFetch.mock.calls.length, 3); + }); + + it("should handle fetch errors", async () => { + const logger = new Logger(); + const controller = new FetchController(true, logger); + const mockFetch = mock.fn(async () => { + throw new Error("Network error"); + }); + const controlledFetch = controller.getControlledFetchImplementation( + logger, + mockFetch + ); + + await assert.rejects( + async () => controlledFetch("http://example.com"), + (error: unknown) => + error instanceof Error && error.message === "Network error" + ); + }); + + it("should not create unhandled rejection on reset with no waiting fetches", async () => { + const logger = new Logger(); + const controller = new FetchController(true, logger); + + controller.startReset(); + mock.timers.tick(10); + controller.finishReset(); + }); +}); diff --git a/frontend/sync-client/src/services/fetch-controller.ts b/frontend/sync-client/src/services/fetch-controller.ts index fbfac59e..38dfcb48 100644 --- a/frontend/sync-client/src/services/fetch-controller.ts +++ b/frontend/sync-client/src/services/fetch-controller.ts @@ -65,6 +65,10 @@ export class FetchController { public startReset(): void { this.isResetting = true; this.rejectUntil(new SyncResetError()); + // Catch unhandled rejection if no fetches are waiting + this.until.catch(() => { + // Intentionally ignore - this rejection is handled by waiting fetches + }); } /** diff --git a/frontend/sync-client/src/sync-operations/cursor-tracker.ts b/frontend/sync-client/src/sync-operations/cursor-tracker.ts index 32048ba5..dc5e4cd7 100644 --- a/frontend/sync-client/src/sync-operations/cursor-tracker.ts +++ b/frontend/sync-client/src/sync-operations/cursor-tracker.ts @@ -167,14 +167,14 @@ export class CursorTracker { continue; } - if (clientCursors.upToDateness == DocumentUpToDateness.Later) { + if (clientCursors.upToDateness === DocumentUpToDateness.Later) { continue; } result.push({ ...clientCursors, isOutdated: - clientCursors.upToDateness == DocumentUpToDateness.Prior + clientCursors.upToDateness === DocumentUpToDateness.Prior }); included.add(clientCursors.deviceId);