import { beforeEach, describe, expect, it, vi } from 'vitest'; import { clearStoredAccessToken, setStoredAccessToken } from './apiClient'; import { cancelGameVersion, createGameVersion, getGame, getGameVersion, listGames, listMyGames, unpublishGame, uploadGamePackage, } from './gameDistributionClient'; describe('gameDistributionClient', () => { beforeEach(() => { clearStoredAccessToken({ emit: false }); }); it('按关键词和分类读取后端目录', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue( new Response( JSON.stringify({ games: [ { id: 'game-1', title: '星轨防线', summary: '测试游戏', currentVersion: { id: 'v1' }, }, ], }), { status: 200, headers: { 'Content-Type': 'application/json' } }, ), ), ); const games = await listGames({ keyword: '星轨', category: '动作' }); expect(games).toHaveLength(1); expect(games[0]?.id).toBe('game-1'); }); it('读取详情时拒绝空 id', async () => { expect(await getGame('')).toBeNull(); }); it('版本创建携带幂等键并保留后端失败状态', async () => { setStoredAccessToken('test-access-token', { emit: false }); vi.stubGlobal( 'fetch', vi.fn().mockImplementation( () => new Response(JSON.stringify({ message: '包校验失败' }), { status: 422, headers: { 'Content-Type': 'application/json' }, }), ), ); await expect( createGameVersion( 'game-1', { packageSha256: 'x', packageBytes: 1, packageFileCount: 1, packageEntryPath: 'index.html', gameMetadata: { title: '测试游戏', summary: '测试', category: '益智', deviceSupport: { desktop: true, mobile: false, touch: false }, inputModes: ['keyboard'], orientation: 'responsive', }, }, 'idem-1', ), ).rejects.toMatchObject({ status: 422 }); }); it('发行包上传使用独立版本资源和幂等键', async () => { const fetchMock = vi.fn().mockImplementation( () => new Response( JSON.stringify({ versionId: 'version-1', status: 'uploaded' }), { status: 200, headers: { 'Content-Type': 'application/json' }, }, ), ); vi.stubGlobal('fetch', fetchMock); setStoredAccessToken('test-access-token', { emit: false }); await uploadGamePackage('version-1', new ArrayBuffer(2), 'idem-upload'); expect(fetchMock).toHaveBeenCalledWith( '/api/game-distribution/versions/version-1/package', expect.objectContaining({ method: 'PUT', body: expect.any(ArrayBuffer), headers: expect.objectContaining({ 'Content-Type': 'application/zip', 'Idempotency-Key': 'idem-upload', }), }), ); }); }); describe('gameDistributionClient 作者接口', () => { beforeEach(() => { clearStoredAccessToken({ emit: false }); }); it('读取我的游戏只返回带 id 的条目', async () => { setStoredAccessToken('author-token', { emit: false }); vi.stubGlobal( 'fetch', vi.fn().mockImplementation(() => Promise.resolve( new Response( JSON.stringify({ ok: true, data: { games: [{ id: 'game-1' }, {}, null] }, error: null, meta: { apiVersion: 'v1' }, }), { status: 200, headers: { 'Content-Type': 'application/json' } }, ), ), ), ); const games = await listMyGames(); expect(games.map((game) => game.id)).toEqual(['game-1']); const myGamesCall = vi .mocked(fetch) .mock.calls.find(([url]) => String(url).includes('/my-games')); expect(myGamesCall?.[0]).toBe('/api/game-distribution/my-games'); }); it('下架请求携带 CAS 版本与幂等键,空入参在本地失败关闭', async () => { setStoredAccessToken('author-token', { emit: false }); const fetchMock = vi.fn().mockImplementation(() => Promise.resolve( new Response( JSON.stringify({ ok: true, data: { game: { id: 'game-1', status: 'unpublished' } }, error: null, meta: { apiVersion: 'v1' }, }), { status: 200, headers: { 'Content-Type': 'application/json' } }, ), ), ); vi.stubGlobal('fetch', fetchMock); await unpublishGame('game-1', 4, 'unpublish-key-1'); const unpublishCalls = () => fetchMock.mock.calls.filter(([url]) => String(url).includes('/unpublish'), ); expect(unpublishCalls()).toHaveLength(1); expect(unpublishCalls()[0]?.[0]).toBe( '/api/game-distribution/games/game-1/unpublish', ); expect(unpublishCalls()[0]?.[1]).toEqual( expect.objectContaining({ method: 'POST', body: JSON.stringify({ expectedPublicationRevision: 4 }), headers: expect.objectContaining({ 'Idempotency-Key': 'unpublish-key-1', }), }), ); await expect(unpublishGame(' ', 1, 'k')).rejects.toThrow('缺少游戏编号'); await expect(unpublishGame('game-1', 1, ' ')).rejects.toThrow( '缺少幂等键', ); // 本地校验失败不得发出任何下架请求。 expect(unpublishCalls()).toHaveLength(1); }); it('回读单个版本状态与服务端恢复动作', async () => { setStoredAccessToken('test-access-token', { emit: false }); const fetchMock = vi.fn().mockImplementation(() => Promise.resolve( new Response( JSON.stringify({ game: { id: 'game-1' }, version: { versionId: 'version-1', status: 'upload_failed', recoveryAction: 'reupload', }, }), { status: 200, headers: { 'Content-Type': 'application/json' } }, ), ), ); vi.stubGlobal('fetch', fetchMock); const detail = await getGameVersion('version-1'); expect(detail.version.recoveryAction).toBe('reupload'); const readCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/api/game-distribution/versions/version-1'), ); expect(readCall?.[0]).toBe('/api/game-distribution/versions/version-1'); expect(readCall?.[1]?.method).toBe('GET'); }); it('撤回版本带幂等键与公开修订号,并拒绝空标识', async () => { setStoredAccessToken('test-access-token', { emit: false }); const fetchMock = vi.fn().mockImplementation(() => Promise.resolve( new Response(JSON.stringify({ replayed: false }), { status: 200, headers: { 'Content-Type': 'application/json' }, }), ), ); vi.stubGlobal('fetch', fetchMock); await cancelGameVersion('version-1', 3, 'idem-1', '看错了'); const cancelCalls = () => fetchMock.mock.calls.filter(([url]) => String(url).includes('/versions/version-1/cancel'), ); expect(cancelCalls()).toHaveLength(1); const cancelCall = cancelCalls()[0]; expect(cancelCall?.[1]?.method).toBe('POST'); expect( (cancelCall?.[1]?.headers as Record)['Idempotency-Key'], ).toBe('idem-1'); expect(JSON.parse(String(cancelCall?.[1]?.body))).toEqual({ expectedPublicationRevision: 3, reason: '看错了', }); await expect(cancelGameVersion('', 1, 'idem-2')).rejects.toThrow( '撤回版本缺少版本编号', ); await expect(cancelGameVersion('version-1', 1, ' ')).rejects.toThrow( '撤回版本缺少幂等键', ); // 本地校验失败不得发出任何撤回请求。 expect(cancelCalls()).toHaveLength(1); }); });