import { Asset } from "@noya-app/noya-schemas"; import { Base64 } from "@noya-app/noya-utils"; import { describe, expect, it } from "bun:test"; import { indexedDB } from "fake-indexeddb"; import { AssetManager, AssetStoreIndexedDB, AssetStoreMemory, AssetStoreRemote, } from "../AssetManager"; import { RPCManager } from "../rpcManager"; const getRandomId = () => { let i = 0; return () => { return (i++).toString(); }; }; describe("AssetStoreIndexedDB", () => { it("should set, get, and delete asset", async () => { const assetStore = new AssetStoreIndexedDB({ indexedDB, randomId: getRandomId(), }); const asset = await assetStore.create({ data: new Uint8Array([42]) }); expect(asset).toEqual({ id: "0", stableId: expect.any(String), url: expect.stringMatching(/^blob:.*$/), createdAt: expect.any(String), size: 1, width: null, height: null, userId: null, }); await assetStore.delete(asset.id); const deletedAsset = await assetStore.read(asset.id); expect(deletedAsset).toEqual(null); }); it("should get all assets", async () => { const assetStore = new AssetStoreIndexedDB({ indexedDB, randomId: getRandomId(), }); const asset1 = await assetStore.create({ data: new Uint8Array([42]) }); const asset2 = await assetStore.create({ data: new Uint8Array([43]) }); const assets = await assetStore.list(); expect(assets).toEqual([asset1, asset2]); }); it("should update asset data in-place", async () => { const assetStore = new AssetStoreIndexedDB({ indexedDB, randomId: getRandomId(), }); const asset = await assetStore.create({ data: new Uint8Array([42]), contentType: "application/octet-stream", }); expect(asset.size).toBe(1); expect(asset.version).toBeUndefined(); const updated = await assetStore.update(asset.id, { data: new Uint8Array([1, 2, 3, 4]), contentType: "image/png", }); expect(updated.id).toBe(asset.id); expect(updated.stableId).toBe(asset.stableId); expect(updated.size).toBe(4); expect(updated.contentType).toBe("image/png"); expect(updated.version).toBe(1); expect(updated.url).not.toBe(asset.url); const read = await assetStore.read(asset.id); expect(read?.size).toBe(4); expect(read?.version).toBe(1); }); it("should throw when updating non-existent asset", async () => { const assetStore = new AssetStoreIndexedDB({ indexedDB, randomId: getRandomId(), }); await expect( assetStore.update("non-existent", { data: new Uint8Array([1]) }) ).rejects.toThrow("Asset non-existent not found"); }); }); describe("AssetStoreMemory", () => { it("should update asset data in-place", async () => { const assetStore = new AssetStoreMemory({ randomId: getRandomId(), }); const asset = await assetStore.create({ data: new Uint8Array([42]), contentType: "application/octet-stream", }); expect(asset.size).toBe(1); expect(asset.version).toBeUndefined(); const updated = await assetStore.update(asset.id, { data: new Uint8Array([1, 2, 3, 4]), contentType: "image/png", }); expect(updated.id).toBe(asset.id); expect(updated.stableId).toBe(asset.stableId); expect(updated.size).toBe(4); expect(updated.contentType).toBe("image/png"); expect(updated.version).toBe(1); expect(updated.url).not.toBe(asset.url); const read = await assetStore.read(asset.id); expect(read?.size).toBe(4); expect(read?.version).toBe(1); }); it("should throw when updating non-existent asset", async () => { const assetStore = new AssetStoreMemory({ randomId: getRandomId(), }); await expect( assetStore.update("non-existent", { data: new Uint8Array([1]) }) ).rejects.toThrow("Asset non-existent not found"); }); }); describe("AssetManager", () => { async function testSuite(assetManager: AssetManager) { let assets: Asset[] = []; assetManager.assets$.subscribe((newAssets) => { assets = newAssets; }); const created1 = await assetManager.create(new Uint8Array([42])); const a1 = assets.find((a) => a.stableId === created1.id)!; expect(a1).toEqual({ id: "0", stableId: expect.any(String), url: expect.stringMatching(/^blob:.*$/), createdAt: expect.any(String), size: 1, userId: null, contentType: a1.contentType, width: null, height: null, }); const created2 = await assetManager.create(new Uint8Array([43])); const a2 = assets.find((a) => a.stableId === created2.id)!; expect(assetManager.assets$.get().map((a) => a.stableId)).toEqual([ a1.stableId, a2.stableId, ]); expect(assets.map((a) => a.stableId)).toEqual([a1.stableId, a2.stableId]); await assetManager.delete(a1.stableId); expect(assetManager.assets$.get().map((a) => a.stableId)).toEqual([ a2.stableId, ]); expect(assets.map((a) => a.stableId)).toEqual([a2.stableId]); const blob = new Blob([new Uint8Array([44])], { type: "image/png" }); const createdBlob = await assetManager.create(blob); const blobAsset = assets.find((a) => a.stableId === createdBlob.id)!; expect(blobAsset).toEqual({ id: expect.any(String), stableId: expect.any(String), url: expect.stringMatching(/^blob:.*$/), createdAt: expect.any(String), size: 1, contentType: "image/png", width: null, height: null, userId: null, }); const file = new File([new Uint8Array([45])], "test.png", { type: "image/png", }); const createdFile = await assetManager.create(file); const fileAsset = assets.find((a) => a.stableId === createdFile.id)!; expect(fileAsset).toEqual({ id: expect.any(String), stableId: expect.any(String), url: expect.stringMatching(/^blob:.*$/), createdAt: expect.any(String), size: 1, contentType: "image/png", width: null, height: null, userId: null, }); } it("IndexedDB", async () => { const assetStore = new AssetStoreIndexedDB({ indexedDB, randomId: getRandomId(), }); await testSuite(new AssetManager({ assetStore })); }); it("Memory", async () => { const assetStore = new AssetStoreMemory({ randomId: getRandomId(), }); await testSuite(new AssetManager({ assetStore })); }); it("Remote", async () => { await testSuite( new AssetManager({ assetStore: new AssetStoreRemote(createMockRPCManager()), }) ); }); it("should update asset via AssetManager", async () => { const assetStore = new AssetStoreMemory({ randomId: getRandomId(), }); const assetManager = new AssetManager({ assetStore }); const created = await assetManager.create({ data: new Uint8Array([42]), contentType: "application/octet-stream", }); expect(created.size).toBe(1); const updated = await assetManager.update(created.id, { data: new Uint8Array([1, 2, 3, 4, 5]), contentType: "image/png", }); expect(updated.id).toBe(created.id); expect(updated.size).toBe(5); expect(updated.contentType).toBe("image/png"); expect(updated.version).toBe(1); const read = assetManager.read(created.id); expect(read?.size).toBe(5); expect(read?.version).toBe(1); }); it("should update asset using stableId", async () => { const assetStore = new AssetStoreMemory({ randomId: getRandomId(), }); const assetManager = new AssetManager({ assetStore }); const created = await assetManager.create({ data: new Uint8Array([42]), }); // Update using stableId (which is what NoyaAsset.id returns) const updated = await assetManager.update(created.id, { data: new Uint8Array([1, 2, 3]), }); expect(updated.id).toBe(created.id); expect(updated.size).toBe(3); }); it(`perserves asset urls when id hasn't changed`, async () => { const assetStore = new AssetStoreMemory({ randomId: getRandomId(), }); const assetManager = new AssetManager({ assetStore }); const blob1 = new Blob([new Uint8Array([42])]); const blob2 = new Blob([new Uint8Array([43])]); const blob3 = new Blob([new Uint8Array([44])]); const c1 = await assetManager.create(blob1); const c2 = await assetManager.create(blob2); const c3 = await assetManager.create(blob3); const asset1 = assetManager.assets$.get().find((a) => a.stableId === c1.id)!; const asset2 = assetManager.assets$.get().find((a) => a.stableId === c2.id)!; const asset3 = assetManager.assets$.get().find((a) => a.stableId === c3.id)!; const a1 = { ...asset1, url: "http://a?token=123" }; const a2 = { ...asset2, url: "http://b?token=456" }; const a3 = { ...asset3, url: "http://c" }; assetManager.set([a1, a2, a3]); const assets = assetManager.assets$.get(); const newAssetsToSet = [ a1, { ...a2, url: "http://unused" }, { ...a3, url: "http://used?token=789" }, ]; assetManager.set(newAssetsToSet); const updatedAssets = assetManager.assets$.get(); expect(updatedAssets[0].url).toEqual(assets[0].url); expect(updatedAssets[1].url).toEqual(assets[1].url); expect(updatedAssets[2].url).toEqual(newAssetsToSet[2].url); }); }); function createMockRPCManager() { const memoryAssetStore = new AssetStoreMemory({ randomId: getRandomId() }); const rpcManager = new RPCManager({ isConnected: true, }); rpcManager.addListener(async (request) => { if (request.url === "/api/assets") { if (request.options.method === "GET") { const assets = await memoryAssetStore.list(); rpcManager.handleMessage({ id: request.id!, type: "end", response: { status: 200, statusText: "OK", headers: {}, body: JSON.stringify(assets), }, }); } else if (request.options.method === "POST") { const asset = await memoryAssetStore.create({ data: Base64.decode(request.options.body!), contentType: request.options.headers?.["Content-Type"], }); rpcManager.handleMessage({ id: request.id!, type: "end", response: { status: 200, statusText: "OK", headers: {}, body: JSON.stringify(asset), }, }); } } else if (request.url.startsWith("/api/assets/")) { if (request.options.method === "DELETE") { const id = request.url.split("/").pop()!; await memoryAssetStore.delete(id); rpcManager.handleMessage({ id: request.id!, type: "end", response: { status: 200, statusText: "OK", headers: {}, body: JSON.stringify({ success: true }), }, }); } } else { // Handle unknown requests rpcManager.handleMessage({ id: request.id!, type: "error", error: "Unknown request", }); } }); return rpcManager; }