2026-08-17 20:26:44 +02:00
|
|
|
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).
|
2026-08-18 16:37:44 +02:00
|
|
|
import { fileCacheValidator, isYdocValidator } from "./project-file-cache";
|
2026-08-17 20:26:44 +02:00
|
|
|
|
|
|
|
|
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,
|
2026-08-18 16:37:44 +02:00
|
|
|
isYdocValidator,
|
2026-08-17 20:26:44 +02:00
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-18 16:37:44 +02:00
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 20:26:44 +02:00
|
|
|
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)!]]),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|