import { afterEach, describe, expect, it, vi } from 'vitest'; import { ApiError, NETWORK_ERROR_CODE, 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(); }); 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( '/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'); }); });