import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; import type { Resource } from "@noya-app/noya-schemas"; import { Base64 } from "@noya-app/noya-utils"; import { indexedDB as fakeIndexedDB } from "fake-indexeddb"; import { NoyaManager } from "../../NoyaManager"; import type { ExtractResponseBody, RouteKey } from "../../rpc/routes"; import type { SerializableRequest } from "../../rpc/types"; import { localStorageSync } from "../localStorageSync"; import { buildLocalAssetRoutes, buildLocalResourceRoutes, createJsonResponse, createRoutePattern, ensureLocalResourceStore, registerRoute, type Registration, sendLocalInitializationMessages, } from "../localRpcHelpers"; import { bridgeRpcRequestToLocalAssets } from "../parentLocalRpcBridge"; const flushAsync = () => new Promise((resolve) => setTimeout(resolve, 0)); function createManager() { return new NoyaManager(null); } function createRouteRequest({ url, method, body, headers, }: { url: string; method: string; body?: string; headers?: Record; }): SerializableRequest { return { id: `${method}-${url}`, url, options: { method, ...(body !== undefined ? { body } : {}), ...(headers !== undefined ? { headers } : {}), }, }; } function getRoute( routes: Registration[], key: K ): Registration { const route = routes.find((item) => item.key === key); if (!route) { throw new Error(`Route ${key} not found`); } return route as Registration; } function invokeRoute({ routes, key, request, }: { routes: Registration[]; key: K; request: SerializableRequest; }): Promise> { const route = getRoute(routes, key); const pathname = request.url.split("?")[0]; const match = pathname.match(route.pattern); return Promise.resolve( route.handler(request, match) as ExtractResponseBody ); } function createDirectoryResource(id: string, path: string): Resource { return { id, stableId: `${id}-stable`, type: "directory", path, createdAt: "2025-01-01T00:00:00.000Z", updatedAt: "2025-01-01T00:00:00.000Z", accessibleByFileId: null, accessibleByFileVersionId: null, }; } const originalIndexedDB = globalThis.indexedDB; const originalWarn = console.warn; let warnMock: ReturnType; describe("localRpcHelpers", () => { beforeEach(() => { warnMock = mock(() => {}); console.warn = warnMock as any; if (typeof localStorage !== "undefined") { localStorage.clear(); } // Default to memory-store fallback to keep tests deterministic. (globalThis as any).indexedDB = undefined; }); afterEach(() => { if (typeof localStorage !== "undefined") { localStorage.clear(); } console.warn = originalWarn; (globalThis as any).indexedDB = originalIndexedDB; }); describe("utility helpers", () => { it("creates route patterns and registers routes correctly", () => { const pattern = createRoutePattern("DELETE /api/assets/:id"); const match = "/api/assets/asset-123".match(pattern); expect(match?.[1]).toBe("asset-123"); expect(pattern.test("/api/assets/asset-123/extra")).toBe(false); const handler = async () => ({ success: true as const }); const route = registerRoute("DELETE /api/assets/:id", handler); expect(route.method).toBe("DELETE"); expect(route.pattern.test("/api/assets/asset-999")).toBe(true); expect(route.handler).toBe(handler); }); it("creates JSON RPC responses", () => { const response = createJsonResponse({ ok: true, value: 42 }); expect(response).toEqual({ status: 200, statusText: "OK", headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true, value: 42 }), }); }); }); describe("ensureLocalResourceStore", () => { it("falls back to an in-memory store when IndexedDB is unavailable", async () => { const manager = createManager(); const store = ensureLocalResourceStore(manager, "fallback-key"); const resource = createDirectoryResource("resource-memory", "/memory"); await store.create(resource); expect(await store.read(resource.id)).toEqual(resource); const updated = { ...resource, path: "/memory-updated" }; expect(await store.update(resource.id, updated)).toEqual(updated); expect((await store.list()).map((item) => item.path)).toEqual([ "/memory-updated", ]); expect(await store.delete(resource.id)).toEqual(updated); expect(await store.read(resource.id)).toBeNull(); expect(warnMock).toHaveBeenCalled(); }); it("caches IndexedDB stores per manager and storage key", async () => { (globalThis as any).indexedDB = fakeIndexedDB; const key = `resource-idb-${Date.now()}`; const manager = createManager(); const first = ensureLocalResourceStore(manager, key); const second = ensureLocalResourceStore(manager, key); const different = ensureLocalResourceStore(manager, `${key}-different`); const resource = createDirectoryResource("resource-idb", "/idb"); expect(first).toBe(second); expect(first).not.toBe(different); await first.create(resource); const resources = await second.list(); expect(resources).toHaveLength(1); expect(resources[0]).toEqual(resource); expect(await different.list()).toEqual([]); }); }); describe("buildLocalAssetRoutes", () => { it("handles file name route and persists name locally", async () => { const manager = createManager(); const routes = buildLocalAssetRoutes(manager, { offlineStorageKey: "asset-route-test", }); const result = await invokeRoute({ routes, key: "PUT /api/file", request: createRouteRequest({ url: "/api/file", method: "PUT", body: JSON.stringify({ name: "Offline File", changeId: "change-1" }), }), }); expect(result).toEqual({ name: "Offline File", changeId: "change-1" }); expect(manager.filePropertyManager.name$.get()).toBe("Offline File"); expect(localStorage.getItem("asset-route-test:name")).toBe( "Offline File" ); }); it("throws for invalid JSON in PUT /api/file", async () => { const manager = createManager(); const routes = buildLocalAssetRoutes(manager, { offlineStorageKey: "asset-route-invalid-json", }); await expect( invokeRoute({ routes, key: "PUT /api/file", request: createRouteRequest({ url: "/api/file", method: "PUT", body: "{invalid-json", }), }) ).rejects.toThrow("Invalid request body"); }); it("returns data URLs from GET /api/assets when _listWithBytes is available", async () => { const manager = createManager(); const bytes = new Uint8Array([104, 105]); // "hi" manager.assetManager.assetStore = { read: async () => null, list: async () => [], create: async () => { throw new Error("not used"); }, delete: async () => {}, _listWithBytes: async () => [ { id: "asset-1", stableId: "asset-1", createdAt: "2025-01-01T00:00:00.000Z", size: bytes.byteLength, contentType: "text/plain", width: null, height: null, userId: null, data: bytes, }, ], } as any; const routes = buildLocalAssetRoutes(manager, { offlineStorageKey: "asset-route-bytes", }); const result = await invokeRoute({ routes, key: "GET /api/assets", request: createRouteRequest({ url: "/api/assets", method: "GET", }), }); expect(result).toEqual([ { id: "asset-1", stableId: "asset-1", createdAt: "2025-01-01T00:00:00.000Z", size: 2, contentType: "text/plain", width: null, height: null, userId: null, url: `data:text/plain;base64,${Base64.encode(bytes)}`, }, ]); }); it("creates and deletes local assets through routes", async () => { const manager = createManager(); const createdPayloads: Array<{ data: Uint8Array; contentType?: string }> = []; const assets = new Map(); let nextId = 0; manager.assetManager.assetStore = { read: async (id: string) => assets.get(id) ?? null, list: async () => [...assets.values()], create: async ({ data, contentType, }: { data: Uint8Array; contentType?: string; }) => { createdPayloads.push({ data, contentType }); const id = `asset-${nextId++}`; const asset = { id, stableId: id, url: `/assets/${id}`, createdAt: "2025-01-01T00:00:00.000Z", size: data.byteLength, contentType, width: null, height: null, userId: null, }; assets.set(id, asset); return asset; }, delete: async (id: string) => { assets.delete(id); }, } as any; const routes = buildLocalAssetRoutes(manager, { offlineStorageKey: "asset-route-create-delete", }); const deleteRoute = getRoute(routes, "DELETE /api/assets/:id"); const content = "hello"; const created = await invokeRoute({ routes, key: "POST /api/assets", request: createRouteRequest({ url: "/api/assets", method: "POST", body: Base64.encode(new TextEncoder().encode(content)), headers: { "content-type": "text/plain" }, }), }); expect(createdPayloads).toHaveLength(1); expect(createdPayloads[0]).toEqual({ data: new TextEncoder().encode(content), contentType: "text/plain", }); expect(created.url).toBe( `data:text/plain;base64,${Base64.encode(new TextEncoder().encode(content))}` ); expect(manager.assetManager.assets$.get()).toHaveLength(1); const path = `/api/assets/${created.id}`; const match = path.match(deleteRoute.pattern); const deleted = await deleteRoute.handler( createRouteRequest({ url: path, method: "DELETE", }), match ); expect(deleted).toEqual({ success: true }); expect(manager.assetManager.assets$.get()).toHaveLength(0); }); }); describe("buildLocalResourceRoutes", () => { it("reads local resources and initializes the resource manager", async () => { const manager = createManager(); const key = `resource-get-${Date.now()}`; const store = ensureLocalResourceStore(manager, key); const resource = createDirectoryResource("resource-get", "/folder-get"); await store.create(resource); const routes = buildLocalResourceRoutes(manager, { offlineStorageKey: key, }); const result = await invokeRoute({ routes, key: "GET /api/resources", request: createRouteRequest({ url: "/api/resources", method: "GET", }), }); expect(result).toEqual([resource]); expect(manager.resourceManager.isInitialized$.get()).toBe(true); expect(manager.resourceManager.resources$.get()[0]?.id).toBe( "resource-get" ); }); it("validates PATCH /api/resources payloads", async () => { const manager = createManager(); const routes = buildLocalResourceRoutes(manager, { offlineStorageKey: "resource-validation", }); await expect( invokeRoute({ routes, key: "PATCH /api/resources", request: createRouteRequest({ url: "/api/resources", method: "PATCH", body: "{not-json", }), }) ).rejects.toThrow("Invalid request body"); await expect( invokeRoute({ routes, key: "PATCH /api/resources", request: createRouteRequest({ url: "/api/resources", method: "PATCH", body: JSON.stringify({}), }), }) ).rejects.toThrow( "PATCH body must include create, update, or delete arrays" ); await expect( invokeRoute({ routes, key: "PATCH /api/resources", request: createRouteRequest({ url: "/api/resources", method: "PATCH", body: JSON.stringify({ create: {} }), }), }) ).rejects.toThrow("Invalid PATCH request: create must be an array"); await expect( invokeRoute({ routes, key: "PATCH /api/resources", request: createRouteRequest({ url: "/api/resources", method: "PATCH", body: JSON.stringify({ create: [{ type: "resource", path: "/nested", fileId: "file" }], }), }), }) ).rejects.toThrow( "Invalid PATCH request: create[0].resourceId must be a string" ); }); it("rejects missing resource IDs instead of silently succeeding", async () => { const manager = createManager(); const routes = buildLocalResourceRoutes(manager, { offlineStorageKey: "resource-missing-id", }); for (const body of [ { update: [{ id: "missing", path: "/updated" }] }, { delete: [{ id: "missing" }] }, ]) { await expect( invokeRoute({ routes, key: "PATCH /api/resources", request: createRouteRequest({ url: "/api/resources", method: "PATCH", body: JSON.stringify(body), }), }) ).rejects.toThrow("Resource missing not found"); } }); it("does not persist earlier operations when a PATCH fails validation", async () => { const manager = createManager(); const routes = buildLocalResourceRoutes(manager, { offlineStorageKey: "resource-atomic-validation", }); await expect( invokeRoute({ routes, key: "PATCH /api/resources", request: createRouteRequest({ url: "/api/resources", method: "PATCH", body: JSON.stringify({ create: [ { type: "directory", path: "/must-not-persist" }, { type: "asset", path: "/invalid-asset" }, ], }), }), }) ).rejects.toThrow( "Invalid PATCH request: create[1] requires assetId or asset" ); const resources = await invokeRoute({ routes, key: "GET /api/resources", request: createRouteRequest({ url: "/api/resources", method: "GET", }), }); expect(resources).toEqual([]); }); it("rolls back uploaded assets and resources when a PATCH fails", async () => { const manager = createManager(); const routes = buildLocalResourceRoutes(manager, { offlineStorageKey: "resource-atomic-upload", }); const createAsset = mock(async () => { if (createAsset.mock.calls.length === 2) { throw new Error("Second upload failed"); } return { id: "stable-upload-1", url: "/assets/stable-upload-1", createdAt: "2025-01-01T00:00:00.000Z", size: 1, width: null, height: null, userId: null, }; }); const deleteAsset = mock(async () => undefined); manager.assetManager.create = createAsset as any; manager.assetManager.delete = deleteAsset as any; manager.assetManager._getServerAssetId = (() => "server-upload-1") as any; await expect( invokeRoute({ routes, key: "PATCH /api/resources", request: createRouteRequest({ url: "/api/resources", method: "PATCH", body: JSON.stringify({ create: ["/first", "/second"].map((path) => ({ type: "asset", path, asset: { encoding: "utf-8", content: path, contentType: "text/plain", }, })), }), }), }) ).rejects.toThrow("Second upload failed"); expect(deleteAsset).toHaveBeenCalledWith("server-upload-1"); expect( await ensureLocalResourceStore( manager, "resource-atomic-upload" ).list() ).toEqual([]); }); it("serializes concurrent PATCH requests without losing resources", async () => { const manager = createManager(); const routes = buildLocalResourceRoutes(manager, { offlineStorageKey: "resource-concurrent-patches", }); const create = (path: string) => invokeRoute({ routes, key: "PATCH /api/resources", request: createRouteRequest({ url: "/api/resources", method: "PATCH", body: JSON.stringify({ create: [{ type: "directory", path }], }), }), }); await Promise.all([create("/first"), create("/second")]); const resources = await ensureLocalResourceStore( manager, "resource-concurrent-patches" ).list(); expect(resources.map((resource) => resource.path).sort()).toEqual([ "/first", "/second", ]); }); it("serializes reads that publish manager state with PATCH requests", async () => { const manager = createManager(); const key = "resource-read-patch-race"; const store = ensureLocalResourceStore(manager, key); await store.create(createDirectoryResource("existing", "/existing")); const originalList = store.list; let releaseList!: () => void; const listGate = new Promise((resolve) => { releaseList = resolve; }); let listCalls = 0; store.list = async () => { listCalls += 1; const snapshot = await originalList(); if (listCalls === 1) { await listGate; } return snapshot; }; const routes = buildLocalResourceRoutes(manager, { offlineStorageKey: key, }); const readPromise = invokeRoute({ routes, key: "GET /api/resources", request: createRouteRequest({ url: "/api/resources", method: "GET", }), }); await flushAsync(); let patchResolved = false; const patchPromise = invokeRoute({ routes, key: "PATCH /api/resources", request: createRouteRequest({ url: "/api/resources", method: "PATCH", body: JSON.stringify({ create: [{ type: "directory", path: "/new" }], }), }), }).then((result) => { patchResolved = true; return result; }); await flushAsync(); expect(patchResolved).toBe(false); releaseList(); await Promise.all([readPromise, patchPromise]); expect( manager.resourceManager.resources$.get().map((resource) => resource.path) ).toEqual(["/existing", "/new"]); }); it("creates, updates, and deletes resources locally", async () => { const manager = createManager(); const key = `resource-crud-${Date.now()}`; const routes = buildLocalResourceRoutes(manager, { offlineStorageKey: key, }); const createAsset = mock(async (options: any) => ({ id: "stable-asset-id", url: "/assets/stable-asset-id", createdAt: "2025-01-01T00:00:00.000Z", size: options.data.byteLength, contentType: options.contentType, width: null, height: null, userId: null, })); manager.assetManager.create = createAsset as any; manager.assetManager._getServerAssetId = (() => "server-asset-id") as any; const assetText = "upload-asset"; const createResult = await invokeRoute({ routes, key: "PATCH /api/resources", request: createRouteRequest({ url: "/api/resources", method: "PATCH", body: JSON.stringify({ create: [ { type: "directory", path: "/dir" }, { type: "asset", path: "/asset", asset: { encoding: "base64", content: Base64.encode(new TextEncoder().encode(assetText)), contentType: "text/plain", }, }, { type: "file", path: "/file", fileId: "file-v1" }, { type: "resource", path: "/resource", fileId: "file-v-resource-1", resourceId: "resource-1", }, ], }), }), }); expect(createResult.created).toHaveLength(4); expect(createResult.updated).toHaveLength(0); expect(createResult.deleted).toHaveLength(0); expect(createAsset).toHaveBeenCalledTimes(1); expect(createAsset.mock.calls[0]?.[0]).toEqual({ data: new TextEncoder().encode(assetText), contentType: "text/plain", }); const createdDirectory = createResult.created.find( (item) => item.type === "directory" ); const createdAsset = createResult.created.find( (item) => item.type === "asset" ); const createdFile = createResult.created.find( (item) => item.type === "file" ); const createdResource = createResult.created.find( (item) => item.type === "resource" ); if ( !createdDirectory || createdDirectory.type !== "directory" || !createdAsset || createdAsset.type !== "asset" || !createdFile || createdFile.type !== "file" || !createdResource || createdResource.type !== "resource" ) { throw new Error("Expected all resource variants to be created"); } expect(createdAsset.assetId).toBe("server-asset-id"); expect(createdFile.fileId).toBe("file-v1"); expect(createdResource.fileId).toBe("file-v-resource-1"); expect(createdResource.resourceId).toBe("resource-1"); const updateResult = await invokeRoute({ routes, key: "PATCH /api/resources", request: createRouteRequest({ url: "/api/resources", method: "PATCH", body: JSON.stringify({ update: [ { id: createdAsset.id, path: "/asset-updated", assetId: "asset-id-2", }, { id: createdFile.id, path: "/file-updated", }, { id: createdResource.id, type: "resource", fileVersionId: "file-v-resource-2", resourceId: "resource-2", }, ], delete: [{ id: createdDirectory.id }], }), }), }); expect(updateResult.created).toHaveLength(0); expect(updateResult.updated).toHaveLength(3); expect(updateResult.deleted).toHaveLength(1); expect(updateResult.deleted[0].id).toBe(createdDirectory.id); const updatedAsset = updateResult.updated.find( (item) => item.id === createdAsset.id ); const updatedFile = updateResult.updated.find( (item) => item.id === createdFile.id ); const updatedResource = updateResult.updated.find( (item) => item.id === createdResource.id ); if ( !updatedAsset || updatedAsset.type !== "asset" || !updatedFile || updatedFile.type !== "file" || !updatedResource || updatedResource.type !== "resource" ) { throw new Error("Expected created resources to be updated"); } expect(updatedAsset.path).toBe("/asset-updated"); expect(updatedAsset.assetId).toBe("asset-id-2"); expect(updatedFile.path).toBe("/file-updated"); expect(updatedFile.fileId).toBe("file-v1"); expect(updatedResource.fileId).toBe("file-v-resource-2"); expect(updatedResource.resourceId).toBe("resource-2"); const allResources = await invokeRoute({ routes, key: "GET /api/resources", request: createRouteRequest({ url: "/api/resources", method: "GET", }), }); expect(allResources).toHaveLength(3); expect(allResources.map((item) => item.path).sort()).toEqual([ "/asset-updated", "/file-updated", "/resource", ]); }); }); describe("local resource synchronization", () => { it("keeps resources uninitialized until local hydration completes", async () => { const manager = createManager(); const cleanup = localStorageSync({ key: "resource-hydration-readiness", })({ noyaManager: manager }); expect(manager.resourceManager.isInitialized$.get()).toBe(false); await flushAsync(); expect(manager.resourceManager.isInitialized$.get()).toBe(true); cleanup(); }); it("sends refreshed embedded resources before completing the RPC", async () => { const manager = createManager(); const sent: any[] = []; const handled = await bridgeRpcRequestToLocalAssets( manager, createRouteRequest({ url: "/api/resources", method: "PATCH", body: JSON.stringify({ create: [{ type: "directory", path: "/embedded" }], }), }), (message) => sent.push(message), { offlineStorageKey: "resource-embedded-order" } ); expect(handled).toBe(true); expect(sent.map((message) => message.type)).toEqual([ "resources", "end", ]); expect(sent[0].resources).toHaveLength(1); expect(sent[0].resources[0].path).toBe("/embedded"); }); }); describe("sendLocalInitializationMessages", () => { it("sends base initialization messages and local resources", async () => { const manager = createManager(); const key = `resource-init-${Date.now()}`; const localResource = createDirectoryResource("resource-init", "/init"); const store = ensureLocalResourceStore(manager, key); await store.create(localResource); localStorage.setItem(`${key}:name`, "Stored Name"); const sent: any[] = []; sendLocalInitializationMessages( manager, (message) => { sent.push(message); }, { offlineStorageKey: key } ); await flushAsync(); expect(sent).toContainEqual({ type: "fileName", name: "Stored Name" }); expect(sent).toContainEqual({ type: "secrets", secrets: [] }); expect(sent).toContainEqual({ type: "inputs", inputs: [] }); expect(sent).toContainEqual({ type: "outputTransforms", outputTransforms: [], }); expect(sent).toContainEqual({ type: "resources", resources: [localResource], }); expect(manager.filePropertyManager.name$.get()).toBe("Stored Name"); expect(manager.resourceManager.isInitialized$.get()).toBe(true); }); it("completes resource initialization when local hydration fails", async () => { const manager = createManager(); const key = "resource-init-failure"; const store = ensureLocalResourceStore(manager, key); store.list = async () => { throw new Error("Hydration failed"); }; const sent: any[] = []; sendLocalInitializationMessages( manager, (message) => sent.push(message), { offlineStorageKey: key } ); await flushAsync(); expect(manager.resourceManager.isInitialized$.get()).toBe(true); expect(sent).toContainEqual({ type: "resources", resources: [] }); }); }); });