pcbjam/web/standalone/src/lib/project-source-cache.test.ts

251 lines
8.4 KiB
TypeScript
Raw Normal View History

import { afterEach, describe, expect, it, vi } from "vitest";
import type { ProjectFile } from "@pcbjam/shared";
// Real validator logic inside the mocked cache module — only the IDB-touching
// functions are replaced (the node test env has no IndexedDB anyway).
import { fileCacheValidator, isYdocValidator } from "./project-file-cache";
const PROJECT_ID = "0b7a4bfa-0000-5000-8000-0000000000aa";
const file = (over: Partial<ProjectFile> = {}): ProjectFile => ({
id: "0b7a4bfa-0000-5000-8000-0000000000ab",
projectId: PROJECT_ID,
path: "boards/main.kicad_pcb",
size: 3,
contentType: "text/plain",
revision: 2,
createdAt: "2026-08-01T00:00:00.000Z",
updatedAt: "2026-08-15T12:00:00.000Z",
...over,
});
function cacheMock() {
return {
fileCacheValidator,
isYdocValidator,
readCachedFileBytes: vi.fn(async () => null as Uint8Array | null),
writeCachedFileBytes: vi.fn(async () => {}),
pruneProjectFileCache: vi.fn(async () => {}),
};
}
// project-source reads config at import time; mock it fresh then dynamic-import
// (same pattern as project-source.test.ts, but selecting the REMOTE source).
async function loadRemote(cache: ReturnType<typeof cacheMock>, client?: unknown) {
vi.resetModules();
vi.doMock("@/lib/config", () => ({
API_BASE_URL: "http://localhost:3050",
PROJECT_SOURCE_KIND: "remote",
PROJECT_MANIFEST_URL: undefined,
LOCAL_PROJECTS_ENABLED: false,
userSlug: () => "test-user",
currentScope: () => "team-a",
}));
vi.doMock("./project-file-cache", () => cache);
if (client) vi.doMock("./contract-client", () => ({ client }));
return (await import("./project-source")).projectSource;
}
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
vi.resetModules();
});
const plainResponse = (bytes: Uint8Array) => ({
ok: true,
headers: { get: (h: string) => (h === "content-type" ? "text/plain" : null) },
arrayBuffer: async () => bytes.buffer,
});
describe("remote source file-body cache", () => {
it("serves a cache hit without touching the network", async () => {
const cache = cacheMock();
const cached = new Uint8Array([9, 9, 9]);
cache.readCachedFileBytes.mockResolvedValue(cached);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const src = (await loadRemote(cache))();
const meta = file();
const got = await src.fetchFileBytes("proj", meta.path, meta);
expect(got).toBe(cached);
expect(fetchMock).not.toHaveBeenCalled();
expect(cache.readCachedFileBytes).toHaveBeenCalledWith(
PROJECT_ID,
meta.path,
fileCacheValidator(meta),
);
});
it("on a miss, fetches and stores under the listing's validator", async () => {
const cache = cacheMock();
const bytes = new Uint8Array([1, 2, 3]);
vi.stubGlobal("fetch", vi.fn(async () => plainResponse(bytes)));
const src = (await loadRemote(cache))();
const meta = file();
const got = await src.fetchFileBytes("proj", meta.path, meta);
expect(Array.from(got)).toEqual([1, 2, 3]);
expect(cache.writeCachedFileBytes).toHaveBeenCalledWith(
PROJECT_ID,
meta.path,
fileCacheValidator(meta),
got,
);
});
it("never caches a ydoc-materialized response, even with cacheable meta", async () => {
// Listing said no ydoc, but a room appeared between listing and fetch: the
// server answers as a ydoc. The garbage update fails conversion → the
// plain-retry fallback serves the bytes, and NOTHING is written to the
// cache (those bytes move without a file-row change).
const cache = cacheMock();
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: true,
headers: {
get: (h: string) =>
h === "content-type" ? "application/x-pcbjam-ydoc" : null,
},
arrayBuffer: async () => new Uint8Array([0xde, 0xad]).buffer,
})
.mockResolvedValueOnce(plainResponse(new Uint8Array([7])));
vi.stubGlobal("fetch", fetchMock);
const src = (await loadRemote(cache))();
const got = await src.fetchFileBytes("proj", "a.kicad_sch", file());
expect(Array.from(got)).toEqual([7]);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(cache.writeCachedFileBytes).not.toHaveBeenCalled();
});
it("skips the cache entirely when no listing meta is passed", async () => {
const cache = cacheMock();
vi.stubGlobal(
"fetch",
vi.fn(async () => plainResponse(new Uint8Array([5]))),
);
const src = (await loadRemote(cache))();
await src.fetchFileBytes("proj", "x.txt");
expect(cache.readCachedFileBytes).not.toHaveBeenCalled();
expect(cache.writeCachedFileBytes).not.toHaveBeenCalled();
});
it("ydoc + cold: caches the CONVERTED text under the blob-tag validator", async () => {
const { fileToDoc, docToY, docToFile } = await import("@pcbjam/shared");
const Y = await import("yjs");
const kdoc = fileToDoc("(kicad_sch (version 20230121))");
const ydoc = new Y.Doc();
docToY(kdoc, ydoc);
const update = Y.encodeStateAsUpdate(ydoc);
ydoc.destroy();
const cache = cacheMock();
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
ok: true,
headers: {
get: (h: string) =>
h === "content-type" ? "application/x-pcbjam-ydoc" : null,
},
arrayBuffer: async () => update.buffer,
})),
);
const src = (await loadRemote(cache))();
const meta = file({ hasYdoc: true, ydocTag: "etag-77" });
const got = await src.fetchFileBytes("proj", meta.path, meta);
// The returned bytes are the client-side conversion of the update…
expect(new TextDecoder().decode(got)).toBe(docToFile(kdoc));
// …and exactly those bytes are cached under the y-form validator, so the
// next warm load skips the download AND the conversion.
const validator = fileCacheValidator(meta)!;
expect(isYdocValidator(validator)).toBe(true);
expect(cache.writeCachedFileBytes).toHaveBeenCalledWith(
PROJECT_ID,
meta.path,
validator,
got,
);
});
it("ydoc + unconvertible: the plain fallback is cached once per blob tag", async () => {
// The stale-v1-ydoc double-fetch: negotiation returns garbage, the client
// re-fetches server-materialized text. Caching THAT under the blob tag
// turns two fetches per load into two fetches per blob generation.
const cache = cacheMock();
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: true,
headers: {
get: (h: string) =>
h === "content-type" ? "application/x-pcbjam-ydoc" : null,
},
arrayBuffer: async () => new Uint8Array([0xde, 0xad]).buffer,
})
.mockResolvedValueOnce(plainResponse(new Uint8Array([40, 41])));
vi.stubGlobal("fetch", fetchMock);
const src = (await loadRemote(cache))();
const meta = file({ hasYdoc: true, ydocTag: "etag-stale" });
const got = await src.fetchFileBytes("proj", meta.path, meta);
expect(Array.from(got)).toEqual([40, 41]);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(cache.writeCachedFileBytes).toHaveBeenCalledWith(
PROJECT_ID,
meta.path,
fileCacheValidator(meta)!,
got,
);
});
it("ydoc + LIVE: no cache read or write — bytes are moving", async () => {
const cache = cacheMock();
vi.stubGlobal(
"fetch",
vi.fn(async () => plainResponse(new Uint8Array([1]))),
);
const src = (await loadRemote(cache))();
const meta = file({ hasYdoc: true, ydocTag: "e", isLive: true });
await src.fetchFileBytes("proj", meta.path, meta);
expect(cache.readCachedFileBytes).not.toHaveBeenCalled();
expect(cache.writeCachedFileBytes).not.toHaveBeenCalled();
});
it("prunes to the fresh listing on getProject (cacheable rows only)", async () => {
const cache = cacheMock();
const cacheable = file();
const collabOnly = file({
path: "sheets/child.kicad_sch",
revision: 0,
hasYdoc: true,
});
const client = {
getProject: vi.fn(async () => ({
status: 200,
body: {
project: { id: PROJECT_ID, scopeId: "s", slug: "proj" },
files: [cacheable, collabOnly],
},
})),
};
const src = (await loadRemote(cache, client))();
await src.getProject("proj");
expect(cache.pruneProjectFileCache).toHaveBeenCalledWith(
PROJECT_ID,
new Map([[cacheable.path, fileCacheValidator(cacheable)!]]),
);
});
});