Files
Genarrative/src/services/rpg-entry/rpgProfileClient.test.ts
T
kdletters 95d6bc2ae7 接入外部 OpenAPI 与 API Key 管理
新增外部编辑器 OpenAPI 路由与 openapi.json 导出

新增账号级 API Key 表、鉴权、创建、列表和撤销链路

新增个人中心开发者 API Key 管理弹窗

补充前端契约、客户端方法、测试与项目文档
2026-06-19 15:31:53 +08:00

401 lines
11 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
const { fetchWithApiAuthMock, requestJsonMock } = vi.hoisted(() => ({
fetchWithApiAuthMock: vi.fn(),
requestJsonMock: vi.fn(),
}));
import {
clearRpgProfileBrowseHistory,
createRpgProfileExternalApiKey,
listRpgProfileBrowseHistory,
listRpgProfileExternalApiKeys,
listRpgProfileSaveArchives,
revokeRpgProfileExternalApiKey,
resumeRpgProfileSaveArchive,
submitRpgProfileFeedback,
syncRpgProfileBrowseHistory,
upsertRpgProfileBrowseHistory,
watchWechatRpgProfileRechargeOrder,
} from './rpgProfileClient';
vi.mock('../apiClient', () => ({
BACKGROUND_AUTH_REQUEST_OPTIONS: {
authImpact: 'local',
skipRefresh: true,
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
},
fetchWithApiAuth: fetchWithApiAuthMock,
requestJson: requestJsonMock,
}));
function createSseResponse(bodyText: string) {
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(bodyText));
controller.close();
},
}),
{
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
},
},
);
}
beforeEach(() => {
fetchWithApiAuthMock.mockReset();
});
describe('rpgProfileClient browse history routes', () => {
beforeEach(() => {
requestJsonMock.mockReset();
requestJsonMock.mockResolvedValue({ entries: [] });
});
it('reads browse history from the profile route', async () => {
await listRpgProfileBrowseHistory();
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/profile/browse-history',
expect.objectContaining({ method: 'GET' }),
'读取浏览历史失败',
expect.objectContaining({
authImpact: 'local',
retry: expect.objectContaining({ maxRetries: 1 }),
skipRefresh: true,
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
}),
);
});
it('writes browse history through the profile route', async () => {
await upsertRpgProfileBrowseHistory({
ownerUserId: 'user-1',
profileId: 'profile-1',
worldName: '测试世界',
subtitle: '测试副标题',
summaryText: '测试摘要',
coverImageSrc: null,
themeMode: 'mythic',
authorDisplayName: '测试作者',
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/profile/browse-history',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}),
'写入浏览历史失败',
expect.objectContaining({
authImpact: 'local',
retry: expect.objectContaining({
maxRetries: 1,
retryUnsafeMethods: true,
}),
skipRefresh: true,
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
}),
);
});
it('syncs browse history through the profile route', async () => {
await syncRpgProfileBrowseHistory([
{
ownerUserId: 'user-1',
profileId: 'profile-1',
worldName: '测试世界',
subtitle: '测试副标题',
summaryText: '测试摘要',
coverImageSrc: null,
themeMode: 'mythic',
authorDisplayName: '测试作者',
},
]);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/profile/browse-history',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}),
'同步浏览历史失败',
expect.any(Object),
);
});
it('clears browse history through the profile route', async () => {
await clearRpgProfileBrowseHistory();
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/profile/browse-history',
expect.objectContaining({ method: 'DELETE' }),
'清空浏览历史失败',
expect.objectContaining({
retry: expect.objectContaining({
maxRetries: 1,
retryUnsafeMethods: true,
}),
}),
);
});
});
describe('rpgProfileClient save archive routes', () => {
beforeEach(() => {
requestJsonMock.mockReset();
requestJsonMock.mockResolvedValue({ entries: [] });
});
it('reads save archives from the profile route', async () => {
await listRpgProfileSaveArchives();
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/profile/save-archives',
expect.objectContaining({ method: 'GET' }),
'读取存档列表失败',
expect.objectContaining({
authImpact: 'local',
retry: expect.objectContaining({ maxRetries: 1 }),
skipRefresh: true,
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
}),
);
});
it('resumes a save archive through the profile route', async () => {
requestJsonMock.mockResolvedValueOnce({
entry: {
worldKey: 'custom:world-1',
},
snapshot: {
version: 2,
savedAt: '2026-04-19T10:15:00.000Z',
bottomTab: 'adventure',
currentStory: null,
gameState: {
worldType: 'CUSTOM',
},
},
});
await resumeRpgProfileSaveArchive('custom:world-1');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/profile/save-archives/custom%3Aworld-1',
expect.objectContaining({ method: 'POST' }),
'恢复存档失败',
expect.objectContaining({
retry: expect.objectContaining({
maxRetries: 1,
retryUnsafeMethods: true,
}),
}),
);
});
});
describe('rpgProfileClient external api key routes', () => {
beforeEach(() => {
requestJsonMock.mockReset();
requestJsonMock.mockResolvedValue({ keys: [] });
});
it('lists profile api keys from the profile route', async () => {
await listRpgProfileExternalApiKeys();
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/profile/api-keys',
expect.objectContaining({ method: 'GET' }),
'读取 API Key 失败',
expect.objectContaining({
retry: expect.objectContaining({ maxRetries: 1 }),
}),
);
});
it('creates profile api keys through the profile route', async () => {
await createRpgProfileExternalApiKey('测试 Key');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/profile/api-keys',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}),
'创建 API Key 失败',
expect.objectContaining({
retry: expect.objectContaining({
maxRetries: 1,
retryUnsafeMethods: true,
}),
}),
);
expect(JSON.parse(requestJsonMock.mock.calls[0][1].body)).toEqual({
name: '测试 Key',
});
});
it('revokes profile api keys through the profile route', async () => {
await revokeRpgProfileExternalApiKey('key:1');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/profile/api-keys/key%3A1',
expect.objectContaining({ method: 'DELETE' }),
'撤销 API Key 失败',
expect.objectContaining({
retry: expect.objectContaining({
maxRetries: 1,
retryUnsafeMethods: true,
}),
}),
);
});
});
describe('rpgProfileClient feedback routes', () => {
beforeEach(() => {
requestJsonMock.mockReset();
requestJsonMock.mockResolvedValue({
feedback: {
feedbackId: 'feedback:user-1:1',
status: 'open',
createdAt: '2026-05-08T10:00:00Z',
evidenceItems: [],
},
});
});
it('submits profile feedback through the profile route', async () => {
await submitRpgProfileFeedback({
description: '图片上传后没有展示预览',
contactPhone: null,
evidenceItems: [
{
fileName: 'preview.png',
contentType: 'image/png',
sizeBytes: 128,
dataUrl: 'data:image/png;base64,ZmVlZGJhY2s=',
},
],
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/profile/feedback',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}),
'提交反馈失败',
expect.objectContaining({
retry: expect.objectContaining({
maxRetries: 1,
retryUnsafeMethods: true,
}),
}),
);
expect(JSON.parse(requestJsonMock.mock.calls[0][1].body)).toEqual({
description: '图片上传后没有展示预览',
contactPhone: null,
evidenceItems: [
{
fileName: 'preview.png',
contentType: 'image/png',
sizeBytes: 128,
dataUrl: 'data:image/png;base64,ZmVlZGJhY2s=',
},
],
});
});
});
describe('rpgProfileClient recharge order events', () => {
beforeEach(() => {
fetchWithApiAuthMock.mockReset();
});
it('waits for a non-pending order event before completing the SSE watch', async () => {
const pendingOrder = {
orderId: 'order-wechat-sse-1',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'pending',
paymentChannel: 'wechat_mp_virtual',
paidAt: null,
providerTransactionId: null,
createdAt: '2026-04-25T10:00:00Z',
pointsDelta: 0,
membershipExpiresAt: null,
};
const center = {
walletBalance: 0,
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
},
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
};
const paidOrder = {
...pendingOrder,
status: 'paid',
paidAt: '2026-04-25T10:01:00Z',
providerTransactionId: 'wx-sse-1',
pointsDelta: 120,
};
fetchWithApiAuthMock.mockResolvedValueOnce(
createSseResponse(
[
'event: order',
`data: ${JSON.stringify({ order: pendingOrder, center })}`,
'',
'event: order',
`data: ${JSON.stringify({
order: paidOrder,
center: {
...center,
walletBalance: 120,
hasPointsRecharged: true,
},
})}`,
'',
'event: done',
'data: {"orderId":"order-wechat-sse-1","status":"paid"}',
'',
'',
].join('\n'),
),
);
const result = await watchWechatRpgProfileRechargeOrder(
'order-wechat-sse-1',
);
expect(fetchWithApiAuthMock).toHaveBeenCalledWith(
'/api/profile/recharge/orders/order-wechat-sse-1/wechat/events',
expect.objectContaining({
method: 'GET',
headers: { Accept: 'text/event-stream' },
}),
expect.any(Object),
);
expect(result.order.status).toBe('paid');
expect(result.center.walletBalance).toBe(120);
});
});