Files
openmaic/OpenMAIC/tests/media/asset-pool.test.ts
2026-08-16 14:58:47 +08:00

193 lines
7.9 KiB
TypeScript

import { IDBFactory } from 'fake-indexeddb';
import { HttpAssetStore } from '@openmaic/storage';
import { afterEach, describe, expect, it, vi } from 'vitest';
describe('getAssetPool', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.resetModules();
});
it('binds the replacement channel on load so a passive realm receives peers', async () => {
// Pins the production wiring: a tab that only renders never calls
// notifyAssetReplaced, so loading this module must be what starts listening.
vi.stubGlobal('indexedDB', new IDBFactory());
vi.stubGlobal('window', globalThis as unknown as Window);
vi.resetModules();
await import('@/lib/media/asset-pool');
const { observeAssetReplacements, __resetAssetReplacementChannelForTesting } =
await import('@/lib/media/asset-replacement-events');
const received: string[] = [];
const stop = observeAssetReplacements((ref) => {
received.push(ref);
});
const peer = new BroadcastChannel('maic-asset-replacements');
peer.postMessage('ast_peer_replacement');
await new Promise((resolve) => setTimeout(resolve, 10));
try {
expect(received).toEqual(['ast_peer_replacement']);
} finally {
peer.close();
stop();
__resetAssetReplacementChannelForTesting();
}
});
it('is lazy, singleton-scoped, and does not construct during SSR', async () => {
vi.stubGlobal('indexedDB', undefined);
vi.resetModules();
const { getAssetPool } = await import('@/lib/media/asset-pool');
expect(() => getAssetPool()).toThrow(/requires IndexedDB/);
const indexedDB = new IDBFactory();
vi.stubGlobal('indexedDB', indexedDB);
const first = getAssetPool();
const second = getAssetPool();
const { BrowserAssetStore } = await import('@openmaic/storage');
expect(second).toBe(first);
expect(first).toBeInstanceOf(BrowserAssetStore);
expect(await indexedDB.databases()).toEqual([]);
await first.put(new Blob(['asset'], { type: 'text/plain' }));
expect((await indexedDB.databases()).map((entry) => entry.name)).toContain('maic-asset-pool');
await first.close();
});
it('fails loudly while deletion is deferred, then reopens after a successful retry', async () => {
const indexedDB = new IDBFactory();
vi.stubGlobal('indexedDB', indexedDB);
vi.resetModules();
const { AssetPoolDeletionDeferredError, clearAssetPool, getAssetPool, putAsset } =
await import('@/lib/media/asset-pool');
const first = getAssetPool();
await first.put(new Blob(['old'], { type: 'text/plain' }));
const blocker = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open('maic-asset-pool');
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
await expect(clearAssetPool()).rejects.toBeInstanceOf(AssetPoolDeletionDeferredError);
const blockedPut = Promise.race([
putAsset(new Blob(['blocked'], { type: 'text/plain' })),
new Promise<string>((_resolve, reject) => {
setTimeout(() => reject(new Error('putAsset hung behind the pending delete.')), 100);
}),
]);
await expect(blockedPut).rejects.toThrow('BrowserAssetStore is closed.');
blocker.close();
await expect(clearAssetPool()).resolves.toBeUndefined();
const fresh = getAssetPool();
expect(fresh).not.toBe(first);
await expect(putAsset(new Blob(['new'], { type: 'text/plain' }))).resolves.toMatch(/^ast_/);
await fresh.close();
});
it('uses a configured instance and seals the seam after resolution', async () => {
const injected = {
put: vi.fn(),
resolve: vi.fn(),
invalidate: vi.fn(),
remove: vi.fn(),
replace: vi.fn(),
release: vi.fn(),
close: vi.fn(),
} as never;
const config = await import('@/lib/media/asset-pool-config');
config.configureAssetPoolStorage({ store: injected, serverBacked: true });
const { getAssetPool } = await import('@/lib/media/asset-pool');
expect(getAssetPool()).toBe(injected);
expect(() => config.configureAssetPoolStorage({ store: injected })).toThrow(
'configureAssetPoolStorage must be called at module-level bootstrap, before any asset consumer runs — a component effect is too late.',
);
});
it('closes server clients and revokes local URLs without deleting remote assets', async () => {
const fetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
if (init?.method === 'DELETE') throw new Error('remote asset deletion was attempted');
return new Response(new Blob(['server-bytes'], { type: 'image/png' }), {
status: 200,
headers: {
'content-type': 'image/png',
'x-asset-revision': '1',
},
});
});
const client = new HttpAssetStore({ baseUrl: '/api/persistence', fetch });
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:server-asset');
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
const remove = vi
.spyOn(client, 'remove')
.mockRejectedValue(new Error('remote asset deletion was attempted'));
const close = vi.spyOn(client, 'close');
const config = await import('@/lib/media/asset-pool-config');
config.configureAssetPoolStorage({ store: client, serverBacked: true });
const { clearAssetPool, getAssetPool } = await import('@/lib/media/asset-pool');
await expect(getAssetPool().resolve('ast_server')).resolves.toBe('blob:server-asset');
await expect(clearAssetPool()).resolves.toBeUndefined();
expect(close).toHaveBeenCalledOnce();
expect(revokeObjectURL).toHaveBeenCalledWith('blob:server-asset');
expect(remove).not.toHaveBeenCalled();
expect(fetch.mock.calls.every(([, init]) => init?.method !== 'DELETE')).toBe(true);
});
it('rebuilds a fresh usable server client after clear', async () => {
const makeClient = (url: string) => ({
put: vi.fn(),
resolve: vi.fn().mockResolvedValue(url),
invalidate: vi.fn().mockResolvedValue(undefined),
remove: vi.fn(),
replace: vi.fn(),
release: vi.fn(),
close: vi.fn().mockResolvedValue(undefined),
});
const firstClient = makeClient('blob:first-client');
const secondClient = makeClient('blob:second-client');
const factory = vi.fn().mockReturnValueOnce(firstClient).mockReturnValueOnce(secondClient);
const config = await import('@/lib/media/asset-pool-config');
config.configureAssetPoolStorage({ store: factory, serverBacked: true });
const { clearAssetPool, getAssetPool } = await import('@/lib/media/asset-pool');
const first = getAssetPool();
await expect(clearAssetPool()).resolves.toBeUndefined();
const reopened = getAssetPool();
expect(first).toBe(firstClient);
expect(firstClient.close).toHaveBeenCalledOnce();
expect(reopened).toBe(secondClient);
expect(reopened).not.toBe(first);
await expect(reopened.resolve('ast_reopened')).resolves.toBe('blob:second-client');
expect(factory).toHaveBeenCalledTimes(2);
});
it('refuses to reinstall a concrete store after clear instead of reviving the closed one', async () => {
// The factory form above reopens cleanly; a concrete instance cannot, and
// silently reinstalling it would put a closed store back in service.
const injected = {
put: vi.fn(),
resolve: vi.fn(),
invalidate: vi.fn(),
remove: vi.fn(),
replace: vi.fn(),
release: vi.fn(),
close: vi.fn().mockResolvedValue(undefined),
} as never;
const config = await import('@/lib/media/asset-pool-config');
config.configureAssetPoolStorage({ store: injected, serverBacked: true });
const { clearAssetPool, getAssetPool } = await import('@/lib/media/asset-pool');
expect(getAssetPool()).toBe(injected);
await expect(clearAssetPool()).resolves.toBeUndefined();
expect(() => getAssetPool()).toThrow(/cannot be reopened[\s\S]*factory/);
});
});