Files
Genarrative/src/services/gameDistributionClient.test.ts
T
kdletters 2f0e2a48a3 把游戏分发客户端纳入 ESLint 边界
- `.eslintrc.cjs`:`src/services/gameDistributionClient.ts` 及其测试加入活跃服务白名单,不再被遗留忽略列表吞掉。
- 客户端:按仓库规则修掉排序与未使用类型导入(顺带移除两个只在 re-export 中出现的多余导入)。
- 修复 `scripts/vite-retired-css-plugin.test.ts` 的边界门禁:活跃模块不得停留在 ESLint 忽略名单里。
2026-09-20 22:56:43 +08:00

251 lines
7.8 KiB
TypeScript

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<string, string>)['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);
});
});