import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { clearStoredAccessToken, getStoredAccessToken, setStoredAccessToken, } from './apiClient'; import { clearSignedAssetReadUrlCache, getSignedAssetReadUrl, resolveAssetReadUrl, } from './assetReadUrlService'; function createLocalStorageMock() { const store = new Map(); return { getItem(key: string) { return store.has(key) ? store.get(key)! : null; }, setItem(key: string, value: string) { store.set(key, String(value)); }, removeItem(key: string) { store.delete(key); }, clear() { store.clear(); }, }; } describe('assetReadUrlService', () => { beforeEach(() => { vi.stubGlobal('window', { localStorage: createLocalStorageMock(), dispatchEvent: vi.fn(), }); clearSignedAssetReadUrlCache(); clearStoredAccessToken({ emit: false }); setStoredAccessToken('test-access-token', { emit: false }); vi.restoreAllMocks(); }); afterEach(() => { clearStoredAccessToken({ emit: false }); vi.useRealTimers(); }); test('resolveAssetReadUrl returns passthrough for absolute url', async () => { await expect(resolveAssetReadUrl('https://example.com/demo.png')).resolves.toBe( 'https://example.com/demo.png', ); }); test('resolveAssetReadUrl returns passthrough for data url', async () => { await expect(resolveAssetReadUrl('data:image/png;base64,abc')).resolves.toBe( 'data:image/png;base64,abc', ); }); test('resolveAssetReadUrl exchanges legacy generated path for signed url', async () => { vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response( JSON.stringify({ ok: true, data: { read: { objectKey: 'generated-characters/hero/visual/asset-01/master.png', signedUrl: 'https://signed.example.com/master.png', expiresAt: '2099-01-01T00:10:00Z', }, }, error: null, meta: { apiVersion: '2026-04-08', routeVersion: '2026-04-08', latencyMs: 1, timestamp: '2099-01-01T00:00:00Z', }, }), { status: 200, headers: { 'Content-Type': 'application/json', }, }, ), ); await expect( resolveAssetReadUrl('/generated-characters/hero/visual/asset-01/master.png'), ).resolves.toBe('https://signed.example.com/master.png'); expect(globalThis.fetch).toHaveBeenCalledTimes(1); expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain( '/api/assets/read-url?', ); }); test('resolveAssetReadUrl normalizes generated object key without leading slash', async () => { vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response( JSON.stringify({ ok: true, data: { read: { objectKey: 'generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png', signedUrl: 'https://signed.example.com/puzzle.png', expiresAt: '2099-01-01T00:10:00Z', }, }, error: null, meta: { apiVersion: '2026-04-08', routeVersion: '2026-04-08', latencyMs: 1, timestamp: '2099-01-01T00:00:00Z', }, }), { status: 200, headers: { 'Content-Type': 'application/json', }, }, ), ); await expect( resolveAssetReadUrl( 'generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png', ), ).resolves.toBe('https://signed.example.com/puzzle.png'); expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain( 'legacyPublicPath=%2Fgenerated-puzzle-assets%2Fpuzzle-session-1%2Fcandidate-1%2Fasset-1%2Fimage.png', ); }); test('resolveAssetReadUrl does not append cache busting query to OSS signed url', async () => { vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response( JSON.stringify({ ok: true, data: { read: { objectKey: 'generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png', signedUrl: 'https://bucket.oss-cn-beijing.aliyuncs.com/generated-puzzle-assets/puzzle.png?x-oss-signature=abc&x-oss-expires=600', expiresAt: '2099-01-01T00:10:00Z', }, }, error: null, meta: { apiVersion: '2026-04-08', routeVersion: '2026-04-08', latencyMs: 1, timestamp: '2099-01-01T00:00:00Z', }, }), { status: 200, headers: { 'Content-Type': 'application/json', }, }, ), ); await expect( resolveAssetReadUrl( '/generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png', { refreshKey: 'latest-result' }, ), ).resolves.toBe( 'https://bucket.oss-cn-beijing.aliyuncs.com/generated-puzzle-assets/puzzle.png?x-oss-signature=abc&x-oss-expires=600', ); }); test('getSignedAssetReadUrl reuses cached signed url before expiry', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2099-01-01T00:00:00Z')); vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response( JSON.stringify({ ok: true, data: { read: { objectKey: 'generated-custom-world-scenes/profile-1/landmark-1/scene.png', signedUrl: 'https://signed.example.com/scene.png', expiresAt: '2099-01-01T00:10:00Z', }, }, error: null, meta: { apiVersion: '2026-04-08', routeVersion: '2026-04-08', latencyMs: 1, timestamp: '2099-01-01T00:00:00Z', }, }), { status: 200, headers: { 'Content-Type': 'application/json', }, }, ), ); const first = await getSignedAssetReadUrl({ legacyPublicPath: '/generated-custom-world-scenes/profile-1/landmark-1/scene.png', }); const second = await getSignedAssetReadUrl({ legacyPublicPath: '/generated-custom-world-scenes/profile-1/landmark-1/scene.png', }); expect(first).toBe('https://signed.example.com/scene.png'); expect(second).toBe(first); expect(globalThis.fetch).toHaveBeenCalledTimes(1); }); test('getSignedAssetReadUrl caches not-found failures for the same legacy path', async () => { vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response( JSON.stringify({ ok: false, data: null, error: { code: 'NOT_FOUND', message: '对象不存在', }, meta: { apiVersion: '2026-04-08', routeVersion: '2026-04-08', latencyMs: 1, timestamp: '2099-01-01T00:00:00Z', }, }), { status: 404, headers: { 'Content-Type': 'application/json', }, }, ), ); await expect( getSignedAssetReadUrl({ legacyPublicPath: '/generated-characters/hero/missing/master.png', }), ).rejects.toThrow(); await expect( getSignedAssetReadUrl({ legacyPublicPath: '/generated-characters/hero/missing/master.png', }), ).rejects.toThrow('资源不存在或暂时不可读取'); expect(globalThis.fetch).toHaveBeenCalledTimes(1); }); test('getSignedAssetReadUrl 401 不会清空全局登录态', async () => { vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response( JSON.stringify({ ok: false, data: null, error: { code: 'UNAUTHORIZED', message: '登录状态已失效', }, meta: { apiVersion: '2026-04-08', routeVersion: '2026-04-08', latencyMs: 1, timestamp: '2099-01-01T00:00:00Z', }, }), { status: 401, headers: { 'Content-Type': 'application/json', }, }, ), ); await expect( getSignedAssetReadUrl({ legacyPublicPath: '/generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png', }), ).rejects.toThrow('登录状态已失效'); expect(globalThis.fetch).toHaveBeenCalledTimes(1); expect(getStoredAccessToken()).toBe('test-access-token'); expect(window.dispatchEvent).not.toHaveBeenCalled(); }); });