Files
Genarrative/src/services/gameDistributionClient.test.ts
T
lhk229 b60aea4e71
Project CI / AI game creator shell Rust crates (push) Successful in 1m43s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 2m7s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m55s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 3m23s
Project CI / Frontend tests (push) Successful in 3m3s
Project CI / Repository checks (push) Successful in 3m42s
Project CI / AI game creator shell web tests (push) Successful in 2m3s
Project CI / Backend tests (push) Successful in 6m19s
Project CI / Native shell tests (push) Successful in 8m7s
实现网站游戏评分与评价 (#561)
公开游戏详情页新增评分与评价:登录用户可提交并修改自己对每款游戏的唯一一条 1–10 分评价,评论可空且最多 4000 个 Unicode 码点;游客可分页浏览评价并查看平均分与评分人数。

- 新增私有评价表及共享 DTO/API,事务内校验游戏公开可见性、保证唯一性,并计算分页与全量评分统计;下架或封禁后关闭读写,重新公开及更新版本保留评价。
- 详情页支持编辑预填、取消、提交期间冻结输入、失败保留草稿,以及保存、翻页和账号/游戏切换时的旧响应隔离;复用现有认证与公共组件。
- 同步主规范、里程碑、实施计划、生成绑定、表目录和验收脚本。不扩展 AGC、外部 API、评论审核或评分缓存。

验证:
- 本轮审查复跑前端定向测试 40 项、评价领域测试 2 项,均通过。
- 网站 typecheck、Rust/TypeScript DTO 对齐、SpacetimeDB schema、文档索引、编码与 diff 检查通过。
- 实施文档另记录本地隔离数据库/API 49 项及桌面/移动视口浏览器 smoke 通过;本轮审查未重跑这些运行时验收,也未运行完整测试套件。

发布说明:新增 SpacetimeDB 表,部署时需同步 module 与 API。尚未部署 dev/production,待用户验收。

Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/561
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-10-01 19:19:58 +08:00

289 lines
9.1 KiB
TypeScript

/* @vitest-environment jsdom */
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { clearStoredAccessToken, setStoredAccessToken } from './apiClient';
import {
cancelGameVersion,
createGameVersion,
getGame,
getGameVersion,
getMyGameReview,
listGameReviews,
listGames,
listMyGames,
saveMyGameReview,
unpublishGame,
uploadGamePackage,
} from './gameDistributionClient';
describe('gameDistributionClient', () => {
beforeEach(() => {
clearStoredAccessToken({ emit: false });
});
it('评价读取公开分页和私有鉴权、PUT 使用 envelope 且不缓存', async () => {
setStoredAccessToken('reviewer-token', { emit: false });
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(
JSON.stringify({
ok: true,
data: { review: null },
error: null,
meta: { apiVersion: 'v1' },
}),
{ status: 200 },
),
),
);
vi.stubGlobal('fetch', fetchMock);
await listGameReviews('game-1', 2);
expect(fetchMock.mock.calls[0][0]).toBe(
'/api/game-distribution/games/game-1/reviews?page=2&pageSize=20',
);
expect(fetchMock.mock.calls[0][1].headers.Authorization).toBeUndefined();
expect(fetchMock.mock.calls[0][1].cache).toBe('no-store');
expect(await getMyGameReview('game-1')).toEqual({ review: null });
expect(fetchMock.mock.calls[1][1].headers.Authorization).toBe(
'Bearer reviewer-token',
);
await saveMyGameReview('game-1', { score: 10, comment: '' });
expect(fetchMock.mock.calls[2][1]).toMatchObject({
method: 'PUT',
cache: 'no-store',
body: JSON.stringify({ score: 10, comment: '' }),
});
});
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);
});
});