import { afterEach, describe, expect, it, vi } from 'vitest'; import { ApiError, DEFAULT_API_BASE, NETWORK_ERROR_CODE, apiBase, getPublicAlbum, getPublicPhotos, } from '../api/client'; import { errorMessageKey } from '../api/errors'; function jsonResponse(status: number, body: unknown): Response { return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); } afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); }); describe('api base url', () => { it('defaults to the ov entry (8081) when VITE_API_BASE is unset', () => { expect(apiBase()).toBe(DEFAULT_API_BASE); expect(DEFAULT_API_BASE).toBe('http://127.0.0.1:8081'); }); it('uses VITE_API_BASE when the build provides one', () => { vi.stubEnv('VITE_API_BASE', 'https://api.example.com'); expect(apiBase()).toBe('https://api.example.com'); }); it('treats empty string as same-origin (production gateway deploy)', () => { // 空串非 nullish:?? 不回退缺省,fetch 得到相对 /v1 路径(同源网关)。 vi.stubEnv('VITE_API_BASE', ''); expect(apiBase()).toBe(''); }); it('requests go to the configured base + /v1 path', async () => { const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, { items: [], nextCursor: null })); vi.stubGlobal('fetch', fetchMock); vi.stubEnv('VITE_API_BASE', 'https://api.example.com'); await getPublicPhotos('tok-1'); expect(fetchMock).toHaveBeenCalledWith( 'https://api.example.com/v1/public/albums/tok-1/photos', expect.objectContaining({}), ); }); }); describe('api client', () => { it('returns parsed payload and targets the /v1 public album endpoint', async () => { const fetchMock = vi.fn().mockResolvedValue( jsonResponse(200, { publicToken: 'tok-1', resolvedLocale: 'zh-CN', defaultLocale: 'zh-CN' }), ); vi.stubGlobal('fetch', fetchMock); const album = await getPublicAlbum('tok-1', 'zh-CN'); expect(album.publicToken).toBe('tok-1'); expect(fetchMock).toHaveBeenCalledWith( `${DEFAULT_API_BASE}/v1/public/albums/tok-1?locale=zh-CN`, expect.objectContaining({ headers: expect.objectContaining({ accept: 'application/json' }) }), ); }); it('builds cursor query for the photo list', async () => { const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, { items: [], nextCursor: null })); vi.stubGlobal('fetch', fetchMock); await getPublicPhotos('tok-1', { cursor: 'c-9', limit: 30, category: 'cat-2' }); const calledUrl = new URL(String(fetchMock.mock.calls[0]?.[0]), 'http://viewer.local'); expect(calledUrl.pathname).toBe('/v1/public/albums/tok-1/photos'); expect(calledUrl.searchParams.get('cursor')).toBe('c-9'); expect(calledUrl.searchParams.get('limit')).toBe('30'); expect(calledUrl.searchParams.get('category')).toBe('cat-2'); }); it('throws ApiError carrying code/status/requestId from the error envelope', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue(jsonResponse(404, { code: 'ALBUM_NOT_FOUND', message: 'album not found', requestId: 'req-1' })), ); const thrown = await getPublicAlbum('nope').then( () => null, (error: unknown) => error, ); expect(thrown).toBeInstanceOf(ApiError); const apiError = thrown as ApiError; expect(apiError.code).toBe('ALBUM_NOT_FOUND'); expect(apiError.status).toBe(404); expect(apiError.requestId).toBe('req-1'); expect(apiError.message).toBe('album not found'); }); it('degrades non-envelope error bodies to INTERNAL_ERROR with fallback message', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('boom', { status: 502 }))); const thrown = (await getPublicAlbum('tok').then( () => null, (error: unknown) => error, )) as ApiError; expect(thrown).toBeInstanceOf(ApiError); expect(thrown.code).toBe('INTERNAL_ERROR'); expect(thrown.status).toBe(502); expect(thrown.requestId).toBeNull(); }); it('maps fetch transport failures to the NETWORK_ERROR code', async () => { vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))); const thrown = (await getPublicAlbum('tok').then( () => null, (error: unknown) => error, )) as ApiError; expect(thrown).toBeInstanceOf(ApiError); expect(thrown.code).toBe(NETWORK_ERROR_CODE); expect(errorMessageKey(thrown.code)).toBe('errors.network'); }); }); describe('error code to i18n key mapping', () => { it('maps known codes to dedicated message keys', () => { expect(errorMessageKey('ALBUM_NOT_FOUND')).toBe('errors.albumNotFound'); expect(errorMessageKey('ALBUM_NOT_PUBLIC')).toBe('errors.albumNotAvailable'); expect(errorMessageKey('PHOTO_NOT_PUBLIC')).toBe('errors.photoNotFound'); expect(errorMessageKey('NOT_FOUND')).toBe('errors.notFound'); expect(errorMessageKey('AUTH_TOKEN_EXPIRED')).toBe('errors.authExpired'); }); it('falls back to errors.unknown for unregistered codes', () => { expect(errorMessageKey('SOMETHING_ELSE')).toBe('errors.unknown'); }); });