# Conflicts: # docs/technical/README.md # server-node/src/modules/assets/qwenSpriteRoutes.ts # src/components/CustomWorldResultView.test.tsx # src/components/CustomWorldResultView.tsx # src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx # src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx # src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx # src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx # src/components/rpg-entry/RpgEntryCharacterSelectView.tsx # src/components/rpg-entry/RpgEntryHomeView.tsx # src/services/apiClient.ts # src/tools/QwenSpriteSheetTool.tsx
4471 lines
138 KiB
TypeScript
4471 lines
138 KiB
TypeScript
import assert from 'node:assert/strict';
|
||
import fs from 'node:fs';
|
||
import type { AddressInfo } from 'node:net';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
import test from 'node:test';
|
||
|
||
import express from 'express';
|
||
|
||
import { createApp } from './app.ts';
|
||
import type { AppConfig } from './config.ts';
|
||
import { prepareEventStreamResponse } from './http.ts';
|
||
import { requestIdMiddleware } from './middleware/requestId.ts';
|
||
import { createAppContext } from './server.ts';
|
||
import { CustomWorldAgentOrchestrator } from './services/customWorldAgentOrchestrator.js';
|
||
import { createTestCustomWorldAgentSingleTurnLlmClient } from './services/customWorldAgentTestHelpers.js';
|
||
import { httpRequest, type TestRequestInit } from './testHttp.ts';
|
||
|
||
type TestConfigOverrides = Partial<
|
||
Omit<
|
||
AppConfig,
|
||
'llm' | 'dashScope' | 'smsAuth' | 'wechatAuth' | 'authSession'
|
||
>
|
||
> & {
|
||
llm?: Partial<AppConfig['llm']>;
|
||
dashScope?: Partial<AppConfig['dashScope']>;
|
||
smsAuth?: Partial<AppConfig['smsAuth']>;
|
||
wechatAuth?: Partial<AppConfig['wechatAuth']>;
|
||
authSession?: Partial<AppConfig['authSession']>;
|
||
};
|
||
|
||
type TestAppContext = Awaited<ReturnType<typeof createAppContext>>;
|
||
|
||
function installTestCustomWorldAgentSingleTurnLlm(context: TestAppContext) {
|
||
context.customWorldAgentOrchestrator = new CustomWorldAgentOrchestrator(
|
||
context.customWorldAgentSessions,
|
||
null,
|
||
{
|
||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||
},
|
||
);
|
||
}
|
||
|
||
function createTestConfig(
|
||
testName: string,
|
||
overrides: TestConfigOverrides = {},
|
||
): AppConfig {
|
||
const tempRoot = fs.mkdtempSync(
|
||
path.join(os.tmpdir(), `genarrative-server-node-${testName}-`),
|
||
);
|
||
|
||
const baseConfig: AppConfig = {
|
||
nodeEnv: 'test',
|
||
projectRoot: tempRoot,
|
||
publicDir: path.join(tempRoot, 'public'),
|
||
logsDir: path.join(tempRoot, 'logs'),
|
||
dataDir: path.join(tempRoot, 'data'),
|
||
rawEnv: {},
|
||
databaseUrl: `pg-mem://genarrative-${testName}`,
|
||
serverAddr: ':0',
|
||
logLevel: 'silent',
|
||
editorApiEnabled: true,
|
||
assetsApiEnabled: true,
|
||
jwtSecret: 'test-secret',
|
||
jwtExpiresIn: '7d',
|
||
jwtIssuer: 'genarrative-server-node-test',
|
||
llm: {
|
||
baseUrl: 'https://example.invalid',
|
||
apiKey: '',
|
||
model: 'test-model',
|
||
},
|
||
dashScope: {
|
||
baseUrl: 'https://example.invalid',
|
||
apiKey: '',
|
||
imageModel: 'test-image-model',
|
||
requestTimeoutMs: 1000,
|
||
},
|
||
smsAuth: {
|
||
enabled: true,
|
||
provider: 'mock',
|
||
endpoint: 'dypnsapi.aliyuncs.com',
|
||
accessKeyId: '',
|
||
accessKeySecret: '',
|
||
signName: 'Test Sign',
|
||
templateCode: '100001',
|
||
templateParamKey: 'code',
|
||
countryCode: '86',
|
||
schemeName: '',
|
||
codeLength: 6,
|
||
codeType: 1,
|
||
validTimeSeconds: 300,
|
||
intervalSeconds: 60,
|
||
duplicatePolicy: 1,
|
||
caseAuthPolicy: 1,
|
||
returnVerifyCode: false,
|
||
mockVerifyCode: '123456',
|
||
maxSendPerPhonePerDay: 20,
|
||
maxSendPerIpPerHour: 30,
|
||
maxVerifyFailuresPerPhonePerHour: 12,
|
||
maxVerifyFailuresPerIpPerHour: 24,
|
||
captchaTtlSeconds: 180,
|
||
captchaTriggerVerifyFailuresPerPhone: 3,
|
||
captchaTriggerVerifyFailuresPerIp: 5,
|
||
blockPhoneFailureThreshold: 6,
|
||
blockIpFailureThreshold: 10,
|
||
blockPhoneDurationMinutes: 30,
|
||
blockIpDurationMinutes: 30,
|
||
},
|
||
wechatAuth: {
|
||
enabled: true,
|
||
provider: 'mock',
|
||
appId: '',
|
||
appSecret: '',
|
||
authorizeEndpoint: 'https://open.weixin.qq.com/connect/qrconnect',
|
||
accessTokenEndpoint: 'https://api.weixin.qq.com/sns/oauth2/access_token',
|
||
userInfoEndpoint: 'https://api.weixin.qq.com/sns/userinfo',
|
||
callbackPath: '/api/auth/wechat/callback',
|
||
defaultRedirectPath: '/',
|
||
mockUserId: 'mock_wechat_user',
|
||
mockUnionId: 'mock_wechat_union',
|
||
mockDisplayName: '微信旅人',
|
||
mockAvatarUrl: '',
|
||
},
|
||
authSession: {
|
||
accessCookieName: 'genarrative_access_session',
|
||
accessCookieTtlSeconds: 7200,
|
||
accessCookieSecure: false,
|
||
accessCookieSameSite: 'Lax',
|
||
accessCookiePath: '/',
|
||
refreshCookieName: 'genarrative_refresh_session',
|
||
refreshSessionTtlDays: 30,
|
||
refreshCookieSecure: false,
|
||
refreshCookieSameSite: 'Lax',
|
||
refreshCookiePath: '/api/auth',
|
||
},
|
||
};
|
||
|
||
return {
|
||
...baseConfig,
|
||
...overrides,
|
||
llm: {
|
||
...baseConfig.llm,
|
||
...overrides.llm,
|
||
},
|
||
dashScope: {
|
||
...baseConfig.dashScope,
|
||
...overrides.dashScope,
|
||
},
|
||
smsAuth: {
|
||
...baseConfig.smsAuth,
|
||
...overrides.smsAuth,
|
||
},
|
||
wechatAuth: {
|
||
...baseConfig.wechatAuth,
|
||
...overrides.wechatAuth,
|
||
},
|
||
authSession: {
|
||
...baseConfig.authSession,
|
||
...overrides.authSession,
|
||
},
|
||
};
|
||
}
|
||
|
||
async function withTestServer<T>(
|
||
testName: string,
|
||
run: (options: { baseUrl: string; context: TestAppContext }) => Promise<T>,
|
||
overrides: TestConfigOverrides = {},
|
||
) {
|
||
const context = await createAppContext(createTestConfig(testName, overrides));
|
||
const app = createApp(context);
|
||
const server = await new Promise<import('node:http').Server>((resolve) => {
|
||
const nextServer = app.listen(0, '127.0.0.1', () => resolve(nextServer));
|
||
});
|
||
|
||
try {
|
||
const address = server.address() as AddressInfo;
|
||
return await run({
|
||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||
context,
|
||
});
|
||
} finally {
|
||
await new Promise<void>((resolve, reject) => {
|
||
server.close((error) => {
|
||
if (error) {
|
||
reject(error);
|
||
return;
|
||
}
|
||
resolve();
|
||
});
|
||
});
|
||
await context.db.close();
|
||
}
|
||
}
|
||
|
||
async function authEntry(baseUrl: string, username: string, password: string) {
|
||
const response = await httpRequest(`${baseUrl}/api/auth/entry`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
username,
|
||
password,
|
||
}),
|
||
});
|
||
const payload = (await response.json()) as {
|
||
token: string;
|
||
user: {
|
||
id: string;
|
||
username: string;
|
||
};
|
||
};
|
||
const refreshCookie = buildCookieHeader(
|
||
response.headers.get('set-cookie'),
|
||
'genarrative_refresh_session',
|
||
);
|
||
|
||
assert.equal(response.status, 200);
|
||
assert.ok(payload.token);
|
||
return {
|
||
...payload,
|
||
refreshCookie,
|
||
};
|
||
}
|
||
|
||
async function sendPhoneCode(
|
||
baseUrl: string,
|
||
phone: string,
|
||
scene: 'login' | 'bind_phone' | 'change_phone' = 'login',
|
||
) {
|
||
const response = await httpRequest(`${baseUrl}/api/auth/phone/send-code`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
phone,
|
||
scene,
|
||
}),
|
||
});
|
||
const payload = (await response.json()) as {
|
||
ok: true;
|
||
cooldownSeconds: number;
|
||
expiresInSeconds: number;
|
||
};
|
||
|
||
assert.equal(response.status, 200);
|
||
assert.equal(payload.ok, true);
|
||
return payload;
|
||
}
|
||
|
||
async function phoneLogin(baseUrl: string, phone: string, code = '123456') {
|
||
const response = await httpRequest(`${baseUrl}/api/auth/phone/login`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
phone,
|
||
code,
|
||
}),
|
||
});
|
||
const payload = (await response.json()) as {
|
||
token: string;
|
||
user: {
|
||
id: string;
|
||
username: string;
|
||
displayName: string;
|
||
phoneNumberMasked: string | null;
|
||
loginMethod: 'phone' | 'password' | 'wechat';
|
||
bindingStatus: 'active' | 'pending_bind_phone';
|
||
wechatBound: boolean;
|
||
};
|
||
};
|
||
const refreshCookie = buildCookieHeader(
|
||
response.headers.get('set-cookie'),
|
||
'genarrative_refresh_session',
|
||
);
|
||
|
||
assert.equal(response.status, 200);
|
||
assert.ok(payload.token);
|
||
return {
|
||
...payload,
|
||
refreshCookie,
|
||
};
|
||
}
|
||
|
||
async function waitForCustomWorldAgentOperation(params: {
|
||
baseUrl: string;
|
||
token: string;
|
||
sessionId: string;
|
||
operationId: string;
|
||
expectedStatus: 'completed' | 'failed';
|
||
}) {
|
||
let operationText = '';
|
||
|
||
for (let attempt = 0; attempt < 24; attempt += 1) {
|
||
const operationResponse = await httpRequest(
|
||
`${params.baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(params.sessionId)}/operations/${encodeURIComponent(params.operationId)}`,
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${params.token}`,
|
||
},
|
||
},
|
||
);
|
||
assert.equal(operationResponse.status, 200);
|
||
operationText = await operationResponse.text();
|
||
|
||
if (
|
||
new RegExp(`"status":"${params.expectedStatus}"`, 'u').test(operationText)
|
||
) {
|
||
return operationText;
|
||
}
|
||
|
||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||
}
|
||
|
||
throw new Error(`operation did not reach ${params.expectedStatus}`);
|
||
}
|
||
|
||
async function createReadyCustomWorldAgentSession(params: {
|
||
baseUrl: string;
|
||
token: string;
|
||
}) {
|
||
const createResponse = await httpRequest(
|
||
`${params.baseUrl}/api/runtime/custom-world/agent/sessions`,
|
||
withBearer(params.token, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
seedText: '一个被潮雾切开的列岛世界。',
|
||
}),
|
||
}),
|
||
);
|
||
const created = (await createResponse.json()) as {
|
||
session: {
|
||
sessionId: string;
|
||
};
|
||
};
|
||
|
||
assert.equal(createResponse.status, 200);
|
||
|
||
const messagePayloads = [
|
||
{
|
||
clientMessageId: 'phase3-app-1',
|
||
text: '玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。',
|
||
},
|
||
{
|
||
clientMessageId: 'phase3-app-2',
|
||
text: '整体主题是海岛悬疑,气质冷峻克制。核心冲突是守灯会与沉船商盟争夺航道解释权。关键人物叫沈砺,是玩家的旧友兼宿敌,其实暗地里在为沉船商盟引路。标志性元素是潮雾钟声、盐火灯塔。',
|
||
},
|
||
];
|
||
|
||
for (const payload of messagePayloads) {
|
||
const messageResponse = await httpRequest(
|
||
`${params.baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}/messages`,
|
||
withBearer(params.token, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
...payload,
|
||
focusCardId: null,
|
||
selectedCardIds: [],
|
||
}),
|
||
}),
|
||
);
|
||
const messageData = (await messageResponse.json()) as {
|
||
operation: {
|
||
operationId: string;
|
||
};
|
||
};
|
||
|
||
assert.equal(messageResponse.status, 200);
|
||
await waitForCustomWorldAgentOperation({
|
||
baseUrl: params.baseUrl,
|
||
token: params.token,
|
||
sessionId: created.session.sessionId,
|
||
operationId: messageData.operation.operationId,
|
||
expectedStatus: 'completed',
|
||
});
|
||
}
|
||
|
||
const sessionResponse = await httpRequest(
|
||
`${params.baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}`,
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${params.token}`,
|
||
},
|
||
},
|
||
);
|
||
const session = (await sessionResponse.json()) as {
|
||
sessionId: string;
|
||
stage: string;
|
||
creatorIntentReadiness: {
|
||
isReady: boolean;
|
||
};
|
||
};
|
||
|
||
assert.equal(sessionResponse.status, 200);
|
||
assert.equal(session.stage, 'foundation_review');
|
||
assert.equal(session.creatorIntentReadiness.isReady, true);
|
||
|
||
return session;
|
||
}
|
||
|
||
async function createObjectRefiningCustomWorldAgentSession(params: {
|
||
baseUrl: string;
|
||
token: string;
|
||
}) {
|
||
const readySession = await createReadyCustomWorldAgentSession(params);
|
||
|
||
const actionResponse = await httpRequest(
|
||
`${params.baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(readySession.sessionId)}/actions`,
|
||
withBearer(params.token, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
action: 'draft_foundation',
|
||
}),
|
||
}),
|
||
);
|
||
const actionPayload = (await actionResponse.json()) as {
|
||
operation: {
|
||
operationId: string;
|
||
status: string;
|
||
};
|
||
};
|
||
|
||
assert.equal(actionResponse.status, 200);
|
||
assert.equal(actionPayload.operation.status, 'queued');
|
||
|
||
await waitForCustomWorldAgentOperation({
|
||
baseUrl: params.baseUrl,
|
||
token: params.token,
|
||
sessionId: readySession.sessionId,
|
||
operationId: actionPayload.operation.operationId,
|
||
expectedStatus: 'completed',
|
||
});
|
||
|
||
const sessionResponse = await httpRequest(
|
||
`${params.baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(readySession.sessionId)}`,
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${params.token}`,
|
||
},
|
||
},
|
||
);
|
||
const session = (await sessionResponse.json()) as {
|
||
sessionId: string;
|
||
stage: string;
|
||
draftCards: Array<{
|
||
id: string;
|
||
kind: string;
|
||
title: string;
|
||
summary: string;
|
||
}>;
|
||
draftProfile: Record<string, unknown> | null;
|
||
messages: Array<{ kind: string; text: string }>;
|
||
};
|
||
|
||
assert.equal(sessionResponse.status, 200);
|
||
assert.equal(session.stage, 'object_refining');
|
||
|
||
return session;
|
||
}
|
||
|
||
async function markAgentSessionPublishReady(params: {
|
||
context: TestAppContext;
|
||
userId: string;
|
||
sessionId: string;
|
||
}) {
|
||
const snapshot = await params.context.customWorldAgentOrchestrator.getSessionSnapshot(
|
||
params.userId,
|
||
params.sessionId,
|
||
);
|
||
const draftProfile = snapshot?.draftProfile as Record<string, unknown> | null;
|
||
const playableNpcs = Array.isArray(draftProfile?.playableNpcs)
|
||
? (draftProfile?.playableNpcs as Array<Record<string, unknown>>)
|
||
: [];
|
||
const storyNpcs = Array.isArray(draftProfile?.storyNpcs)
|
||
? (draftProfile?.storyNpcs as Array<Record<string, unknown>>)
|
||
: [];
|
||
const landmarks = Array.isArray(draftProfile?.landmarks)
|
||
? (draftProfile?.landmarks as Array<Record<string, unknown>>)
|
||
: [];
|
||
const sceneChapters = Array.isArray(draftProfile?.sceneChapters)
|
||
? (draftProfile?.sceneChapters as Array<Record<string, unknown>>)
|
||
: [];
|
||
const camp =
|
||
draftProfile?.camp && typeof draftProfile.camp === 'object'
|
||
? (draftProfile.camp as Record<string, unknown>)
|
||
: null;
|
||
const firstPlayableRoleId =
|
||
typeof playableNpcs[0]?.id === 'string' && playableNpcs[0]?.id.trim()
|
||
? playableNpcs[0].id.trim()
|
||
: null;
|
||
const firstStoryRoleId =
|
||
typeof storyNpcs[0]?.id === 'string' && storyNpcs[0]?.id.trim()
|
||
? storyNpcs[0].id.trim()
|
||
: firstPlayableRoleId;
|
||
|
||
assert.ok(snapshot);
|
||
assert.ok(draftProfile);
|
||
assert.ok(playableNpcs.length > 0);
|
||
assert.ok(storyNpcs.length > 0);
|
||
assert.ok(landmarks.length > 0);
|
||
assert.ok(sceneChapters.length > 0);
|
||
assert.ok(firstStoryRoleId);
|
||
|
||
await params.context.customWorldAgentSessions.replaceDerivedState(
|
||
params.userId,
|
||
params.sessionId,
|
||
{
|
||
stage: 'ready_to_publish',
|
||
qualityFindings: [],
|
||
draftProfile: {
|
||
...draftProfile,
|
||
chapters:
|
||
Array.isArray(draftProfile.chapters) && draftProfile.chapters.length > 0
|
||
? draftProfile.chapters
|
||
: [{ id: 'chapter-main-1', title: '主线第一章' }],
|
||
camp: {
|
||
...(camp ?? {}),
|
||
id:
|
||
typeof camp?.id === 'string' && camp.id.trim()
|
||
? camp.id.trim()
|
||
: 'camp-home',
|
||
name:
|
||
typeof camp?.name === 'string' && camp.name.trim()
|
||
? camp.name.trim()
|
||
: '归潮营地',
|
||
description:
|
||
typeof camp?.description === 'string' && camp.description.trim()
|
||
? camp.description.trim()
|
||
: '可供玩家整理线索的临时据点。',
|
||
imageSrc:
|
||
typeof camp?.imageSrc === 'string' && camp.imageSrc.trim()
|
||
? camp.imageSrc.trim()
|
||
: '/generated/camp/publish-ready.png',
|
||
generatedSceneAssetId:
|
||
typeof camp?.generatedSceneAssetId === 'string' &&
|
||
camp.generatedSceneAssetId.trim()
|
||
? camp.generatedSceneAssetId.trim()
|
||
: 'scene-camp-publish-ready',
|
||
generatedScenePrompt:
|
||
typeof camp?.generatedScenePrompt === 'string' &&
|
||
camp.generatedScenePrompt.trim()
|
||
? camp.generatedScenePrompt.trim()
|
||
: '潮雾营地发布正式图',
|
||
generatedSceneModel:
|
||
typeof camp?.generatedSceneModel === 'string' &&
|
||
camp.generatedSceneModel.trim()
|
||
? camp.generatedSceneModel.trim()
|
||
: 'test-scene-model',
|
||
},
|
||
playableNpcs: playableNpcs.map((entry, index) => ({
|
||
...entry,
|
||
imageSrc:
|
||
typeof entry.imageSrc === 'string' && entry.imageSrc.trim()
|
||
? entry.imageSrc.trim()
|
||
: `/generated/playable/publish-ready-${index + 1}.png`,
|
||
generatedVisualAssetId:
|
||
typeof entry.generatedVisualAssetId === 'string' &&
|
||
entry.generatedVisualAssetId.trim()
|
||
? entry.generatedVisualAssetId.trim()
|
||
: `visual-playable-publish-${index + 1}`,
|
||
generatedAnimationSetId:
|
||
typeof entry.generatedAnimationSetId === 'string' &&
|
||
entry.generatedAnimationSetId.trim()
|
||
? entry.generatedAnimationSetId.trim()
|
||
: `anim-playable-publish-${index + 1}`,
|
||
})),
|
||
storyNpcs: storyNpcs.map((entry, index) => ({
|
||
...entry,
|
||
imageSrc:
|
||
typeof entry.imageSrc === 'string' && entry.imageSrc.trim()
|
||
? entry.imageSrc.trim()
|
||
: `/generated/story/publish-ready-${index + 1}.png`,
|
||
generatedVisualAssetId:
|
||
typeof entry.generatedVisualAssetId === 'string' &&
|
||
entry.generatedVisualAssetId.trim()
|
||
? entry.generatedVisualAssetId.trim()
|
||
: `visual-story-publish-${index + 1}`,
|
||
generatedAnimationSetId:
|
||
typeof entry.generatedAnimationSetId === 'string' &&
|
||
entry.generatedAnimationSetId.trim()
|
||
? entry.generatedAnimationSetId.trim()
|
||
: `anim-story-publish-${index + 1}`,
|
||
})),
|
||
landmarks: landmarks.map((entry, index) => ({
|
||
...entry,
|
||
imageSrc:
|
||
typeof entry.imageSrc === 'string' && entry.imageSrc.trim()
|
||
? entry.imageSrc.trim()
|
||
: `/generated/landmark/publish-ready-${index + 1}.png`,
|
||
generatedSceneAssetId:
|
||
typeof entry.generatedSceneAssetId === 'string' &&
|
||
entry.generatedSceneAssetId.trim()
|
||
? entry.generatedSceneAssetId.trim()
|
||
: `scene-landmark-publish-${index + 1}`,
|
||
generatedScenePrompt:
|
||
typeof entry.generatedScenePrompt === 'string' &&
|
||
entry.generatedScenePrompt.trim()
|
||
? entry.generatedScenePrompt.trim()
|
||
: `地点 ${typeof entry.name === 'string' ? entry.name : index + 1} 的正式场景图`,
|
||
generatedSceneModel:
|
||
typeof entry.generatedSceneModel === 'string' &&
|
||
entry.generatedSceneModel.trim()
|
||
? entry.generatedSceneModel.trim()
|
||
: 'test-scene-model',
|
||
})),
|
||
sceneChapters: sceneChapters.map((chapter, chapterIndex) => {
|
||
const acts = Array.isArray(chapter.acts)
|
||
? (chapter.acts as Array<Record<string, unknown>>)
|
||
: [];
|
||
|
||
return {
|
||
...chapter,
|
||
linkedThreadIds:
|
||
Array.isArray(chapter.linkedThreadIds) &&
|
||
chapter.linkedThreadIds.length > 0
|
||
? chapter.linkedThreadIds
|
||
: ['thread-publish-ready'],
|
||
acts: acts.map((act, actIndex) => ({
|
||
...act,
|
||
encounterNpcIds:
|
||
Array.isArray(act.encounterNpcIds) && act.encounterNpcIds.length > 0
|
||
? act.encounterNpcIds
|
||
: [firstStoryRoleId],
|
||
primaryNpcId:
|
||
typeof act.primaryNpcId === 'string' && act.primaryNpcId.trim()
|
||
? act.primaryNpcId.trim()
|
||
: firstStoryRoleId,
|
||
backgroundImageSrc:
|
||
typeof act.backgroundImageSrc === 'string' &&
|
||
act.backgroundImageSrc.trim()
|
||
? act.backgroundImageSrc.trim()
|
||
: `/generated/scene/publish-ready-${chapterIndex + 1}-${actIndex + 1}.png`,
|
||
backgroundAssetId:
|
||
typeof act.backgroundAssetId === 'string' &&
|
||
act.backgroundAssetId.trim()
|
||
? act.backgroundAssetId.trim()
|
||
: `scene-act-publish-${chapterIndex + 1}-${actIndex + 1}`,
|
||
})),
|
||
};
|
||
}),
|
||
},
|
||
},
|
||
);
|
||
}
|
||
|
||
function parseRedirectHash(location: string) {
|
||
const url = new URL(location, 'http://127.0.0.1');
|
||
return new URLSearchParams(
|
||
url.hash.startsWith('#') ? url.hash.slice(1) : url.hash,
|
||
);
|
||
}
|
||
|
||
function readCookieValue(cookieHeader: string, cookieName: string) {
|
||
const match = cookieHeader.match(
|
||
new RegExp(`${cookieName}=([^;,\r\n]+)`, 'u'),
|
||
);
|
||
return match?.[1] ? decodeURIComponent(match[1]) : '';
|
||
}
|
||
|
||
function buildCookieHeader(cookieHeader: string | null | undefined, cookieName: string) {
|
||
const value = readCookieValue(cookieHeader || '', cookieName);
|
||
return value ? `${cookieName}=${encodeURIComponent(value)}` : '';
|
||
}
|
||
|
||
async function startWechatMockFlow(baseUrl: string, redirectPath = '/') {
|
||
const startResponse = await httpRequest(
|
||
`${baseUrl}/api/auth/wechat/start?redirectPath=${encodeURIComponent(redirectPath)}`,
|
||
);
|
||
const startPayload = (await startResponse.json()) as {
|
||
authorizationUrl: string;
|
||
};
|
||
|
||
assert.equal(startResponse.status, 200);
|
||
assert.ok(startPayload.authorizationUrl);
|
||
|
||
const callbackResponse = await httpRequest(startPayload.authorizationUrl);
|
||
assert.equal(callbackResponse.status, 302);
|
||
const location = callbackResponse.headers.get('location') || '';
|
||
assert.ok(location);
|
||
const hash = parseRedirectHash(location);
|
||
const token = hash.get('auth_token')?.trim() || '';
|
||
assert.ok(token);
|
||
|
||
return {
|
||
location,
|
||
hash,
|
||
token,
|
||
};
|
||
}
|
||
|
||
async function withListeningApp<T>(
|
||
app: express.Express,
|
||
run: (options: { baseUrl: string }) => Promise<T>,
|
||
) {
|
||
const server = await new Promise<import('node:http').Server>((resolve) => {
|
||
const nextServer = app.listen(0, '127.0.0.1', () => resolve(nextServer));
|
||
});
|
||
|
||
try {
|
||
const address = server.address() as AddressInfo;
|
||
return await run({
|
||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||
});
|
||
} finally {
|
||
await new Promise<void>((resolve, reject) => {
|
||
server.close((error) => {
|
||
if (error) {
|
||
reject(error);
|
||
return;
|
||
}
|
||
resolve();
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
function withBearer(token: string, init: TestRequestInit = {}) {
|
||
return {
|
||
...init,
|
||
headers: {
|
||
...(init.headers ?? {}),
|
||
Authorization: `Bearer ${token}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
} satisfies TestRequestInit;
|
||
}
|
||
|
||
test('legacy json responses remain compatible and include response metadata headers', async () => {
|
||
await withTestServer('legacy-http', async ({ baseUrl }) => {
|
||
const requestId = 'req-legacy-http';
|
||
const response = await httpRequest(`${baseUrl}/api/auth/entry`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-Request-Id': requestId,
|
||
},
|
||
body: JSON.stringify({
|
||
username: 'header_user',
|
||
password: 'secret123',
|
||
}),
|
||
});
|
||
const payload = await response.json<{
|
||
token: string;
|
||
user: {
|
||
username: string;
|
||
};
|
||
}>();
|
||
|
||
assert.equal(response.status, 200);
|
||
assert.ok(payload.token);
|
||
assert.equal(payload.user.username, 'header_user');
|
||
assert.equal(response.headers.get('x-request-id'), requestId);
|
||
assert.equal(response.headers.get('x-api-version'), '2026-04-08');
|
||
assert.equal(response.headers.get('x-route-version'), '2026-04-08');
|
||
assert.ok(Number(response.headers.get('x-response-time-ms')) >= 0);
|
||
});
|
||
});
|
||
|
||
test('auth entry auto-registers, me works, logout invalidates old token', async () => {
|
||
await withTestServer('auth', async ({ baseUrl }) => {
|
||
const entry = await authEntry(baseUrl, 'hero_test', 'secret123');
|
||
assert.ok(entry.refreshCookie);
|
||
|
||
const meResponse = await httpRequest(`${baseUrl}/api/auth/me`, {
|
||
headers: {
|
||
Authorization: `Bearer ${entry.token}`,
|
||
},
|
||
});
|
||
const mePayload = (await meResponse.json()) as {
|
||
user: {
|
||
username: string;
|
||
};
|
||
};
|
||
|
||
assert.equal(meResponse.status, 200);
|
||
assert.equal(mePayload.user.username, 'hero_test');
|
||
|
||
const logoutResponse = await httpRequest(
|
||
`${baseUrl}/api/auth/logout`,
|
||
withBearer(entry.token, { method: 'POST' }),
|
||
);
|
||
assert.equal(logoutResponse.status, 200);
|
||
|
||
const expiredResponse = await httpRequest(`${baseUrl}/api/auth/me`, {
|
||
headers: {
|
||
Authorization: `Bearer ${entry.token}`,
|
||
},
|
||
});
|
||
assert.equal(expiredResponse.status, 401);
|
||
});
|
||
});
|
||
|
||
test('auth entry tolerates concurrent creation of the same local account', async () => {
|
||
await withTestServer('auth-entry-race', async ({ baseUrl }) => {
|
||
const body = JSON.stringify({
|
||
username: 'guest_race_local',
|
||
password: 'secret123',
|
||
});
|
||
const responses = await Promise.all([
|
||
httpRequest(`${baseUrl}/api/auth/entry`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body,
|
||
}),
|
||
httpRequest(`${baseUrl}/api/auth/entry`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body,
|
||
}),
|
||
]);
|
||
const payloads = await Promise.all(
|
||
responses.map((response) =>
|
||
response.json<{
|
||
token: string;
|
||
user: {
|
||
id: string;
|
||
username: string;
|
||
};
|
||
}>(),
|
||
),
|
||
);
|
||
|
||
assert.deepEqual(
|
||
responses.map((response) => response.status),
|
||
[200, 200],
|
||
);
|
||
assert.ok(payloads[0].token);
|
||
assert.ok(payloads[1].token);
|
||
assert.equal(payloads[0].user.username, 'guest_race_local');
|
||
assert.equal(payloads[1].user.id, payloads[0].user.id);
|
||
});
|
||
});
|
||
|
||
test('login options expose enabled methods without authentication', async () => {
|
||
await withTestServer('auth-login-options', async ({ baseUrl }) => {
|
||
const response = await httpRequest(`${baseUrl}/api/auth/login-options`);
|
||
const payload = (await response.json()) as {
|
||
availableLoginMethods: string[];
|
||
};
|
||
|
||
assert.equal(response.status, 200);
|
||
assert.deepEqual(payload.availableLoginMethods, ['phone', 'wechat']);
|
||
});
|
||
});
|
||
|
||
test('wechat start uses qrconnect for desktop browsers', async () => {
|
||
await withTestServer(
|
||
'wechat-start-desktop',
|
||
async ({ baseUrl }) => {
|
||
const response = await httpRequest(
|
||
`${baseUrl}/api/auth/wechat/start?redirectPath=${encodeURIComponent('/')}`,
|
||
{
|
||
headers: {
|
||
'User-Agent':
|
||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/135.0.0.0 Safari/537.36',
|
||
},
|
||
},
|
||
);
|
||
const payload = (await response.json()) as {
|
||
authorizationUrl: string;
|
||
};
|
||
const authorizationUrl = new URL(payload.authorizationUrl);
|
||
|
||
assert.equal(response.status, 200);
|
||
assert.equal(
|
||
`${authorizationUrl.origin}${authorizationUrl.pathname}`,
|
||
'https://open.weixin.qq.com/connect/qrconnect',
|
||
);
|
||
assert.equal(authorizationUrl.searchParams.get('scope'), 'snsapi_login');
|
||
assert.equal(authorizationUrl.hash, '#wechat_redirect');
|
||
},
|
||
{
|
||
wechatAuth: {
|
||
enabled: true,
|
||
provider: 'wechat',
|
||
appId: 'wx-test-app-id',
|
||
appSecret: 'wx-test-app-secret',
|
||
},
|
||
},
|
||
);
|
||
});
|
||
|
||
test('wechat start uses oauth authorize inside wechat browser', async () => {
|
||
await withTestServer(
|
||
'wechat-start-in-app',
|
||
async ({ baseUrl }) => {
|
||
const response = await httpRequest(
|
||
`${baseUrl}/api/auth/wechat/start?redirectPath=${encodeURIComponent('/')}`,
|
||
{
|
||
headers: {
|
||
'User-Agent':
|
||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 MicroMessenger/8.0.54',
|
||
},
|
||
},
|
||
);
|
||
const payload = (await response.json()) as {
|
||
authorizationUrl: string;
|
||
};
|
||
const authorizationUrl = new URL(payload.authorizationUrl);
|
||
|
||
assert.equal(response.status, 200);
|
||
assert.equal(
|
||
`${authorizationUrl.origin}${authorizationUrl.pathname}`,
|
||
'https://open.weixin.qq.com/connect/oauth2/authorize',
|
||
);
|
||
assert.equal(
|
||
authorizationUrl.searchParams.get('scope'),
|
||
'snsapi_userinfo',
|
||
);
|
||
assert.equal(authorizationUrl.hash, '#wechat_redirect');
|
||
},
|
||
{
|
||
wechatAuth: {
|
||
enabled: true,
|
||
provider: 'wechat',
|
||
appId: 'wx-test-app-id',
|
||
appSecret: 'wx-test-app-secret',
|
||
},
|
||
},
|
||
);
|
||
});
|
||
|
||
test('wechat start rejects unsupported mobile browsers for real provider', async () => {
|
||
await withTestServer(
|
||
'wechat-start-mobile-browser',
|
||
async ({ baseUrl }) => {
|
||
const response = await httpRequest(
|
||
`${baseUrl}/api/auth/wechat/start?redirectPath=${encodeURIComponent('/')}`,
|
||
{
|
||
headers: {
|
||
'User-Agent':
|
||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1',
|
||
},
|
||
},
|
||
);
|
||
const payload = (await response.json()) as {
|
||
error: {
|
||
code: string;
|
||
message: string;
|
||
};
|
||
};
|
||
|
||
assert.equal(response.status, 400);
|
||
assert.equal(payload.error.code, 'BAD_REQUEST');
|
||
assert.equal(
|
||
payload.error.message,
|
||
'当前浏览器请使用手机号登录,或在微信内打开后再使用微信登录',
|
||
);
|
||
},
|
||
{
|
||
wechatAuth: {
|
||
enabled: true,
|
||
provider: 'wechat',
|
||
appId: 'wx-test-app-id',
|
||
appSecret: 'wx-test-app-secret',
|
||
},
|
||
},
|
||
);
|
||
});
|
||
|
||
test('phone login sends code, creates a user and returns masked profile info', async () => {
|
||
await withTestServer('phone-login', async ({ baseUrl }) => {
|
||
const sendResult = await sendPhoneCode(baseUrl, '13800138000');
|
||
assert.equal(sendResult.cooldownSeconds, 60);
|
||
assert.equal(sendResult.expiresInSeconds, 300);
|
||
|
||
const entry = await phoneLogin(baseUrl, '13800138000');
|
||
assert.equal(entry.user.username, '138****8000');
|
||
assert.equal(entry.user.displayName, '138****8000');
|
||
assert.equal(entry.user.phoneNumberMasked, '138****8000');
|
||
assert.equal(entry.user.loginMethod, 'phone');
|
||
|
||
const meResponse = await httpRequest(`${baseUrl}/api/auth/me`, {
|
||
headers: {
|
||
Authorization: `Bearer ${entry.token}`,
|
||
},
|
||
});
|
||
const mePayload = (await meResponse.json()) as {
|
||
user: {
|
||
username: string;
|
||
phoneNumberMasked: string | null;
|
||
loginMethod: string;
|
||
};
|
||
};
|
||
|
||
assert.equal(meResponse.status, 200);
|
||
assert.equal(mePayload.user.username, '138****8000');
|
||
assert.equal(mePayload.user.phoneNumberMasked, '138****8000');
|
||
assert.equal(mePayload.user.loginMethod, 'phone');
|
||
});
|
||
});
|
||
|
||
test('phone send-code accepts change_phone scene', async () => {
|
||
await withTestServer('phone-change-code', async ({ baseUrl }) => {
|
||
const sendResult = await sendPhoneCode(
|
||
baseUrl,
|
||
'13800138001',
|
||
'change_phone',
|
||
);
|
||
|
||
assert.equal(sendResult.cooldownSeconds, 60);
|
||
assert.equal(sendResult.expiresInSeconds, 300);
|
||
});
|
||
});
|
||
|
||
test('phone login reuses the same account for repeated verification', async () => {
|
||
await withTestServer('phone-login-reuse', async ({ baseUrl }) => {
|
||
await sendPhoneCode(baseUrl, '13900139000');
|
||
const firstEntry = await phoneLogin(baseUrl, '13900139000');
|
||
|
||
await sendPhoneCode(baseUrl, '13900139000');
|
||
const secondEntry = await phoneLogin(baseUrl, '13900139000');
|
||
|
||
assert.equal(firstEntry.user.id, secondEntry.user.id);
|
||
});
|
||
});
|
||
|
||
test('phone login rejects incorrect verification codes', async () => {
|
||
await withTestServer('phone-login-invalid-code', async ({ baseUrl }) => {
|
||
await sendPhoneCode(baseUrl, '13700137000');
|
||
|
||
const response = await httpRequest(`${baseUrl}/api/auth/phone/login`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
phone: '13700137000',
|
||
code: '000000',
|
||
}),
|
||
});
|
||
const payload = (await response.json()) as {
|
||
error: {
|
||
code: string;
|
||
message: string;
|
||
};
|
||
};
|
||
|
||
assert.equal(response.status, 401);
|
||
assert.equal(payload.error.code, 'UNAUTHORIZED');
|
||
assert.equal(payload.error.message, '验证码错误或已失效');
|
||
});
|
||
});
|
||
|
||
test('captcha challenge is required after repeated verification failures', async () => {
|
||
await withTestServer('phone-login-captcha', async ({ baseUrl }) => {
|
||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||
const response = await httpRequest(`${baseUrl}/api/auth/phone/login`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
phone: '13600136000',
|
||
code: '000000',
|
||
}),
|
||
});
|
||
assert.equal(response.status, 401);
|
||
}
|
||
|
||
const sendCodeResponse = await httpRequest(
|
||
`${baseUrl}/api/auth/phone/send-code`,
|
||
{
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
phone: '13600136000',
|
||
scene: 'login',
|
||
}),
|
||
},
|
||
);
|
||
const sendCodePayload = (await sendCodeResponse.json()) as {
|
||
error: {
|
||
code: string;
|
||
message: string;
|
||
details?: {
|
||
captchaChallenge?: {
|
||
challengeId: string;
|
||
imageDataUrl: string;
|
||
};
|
||
};
|
||
};
|
||
};
|
||
|
||
assert.equal(sendCodeResponse.status, 403);
|
||
assert.equal(sendCodePayload.error.code, 'CAPTCHA_REQUIRED');
|
||
assert.ok(sendCodePayload.error.details?.captchaChallenge?.challengeId);
|
||
assert.match(
|
||
sendCodePayload.error.details?.captchaChallenge?.imageDataUrl ?? '',
|
||
/^data:image\/svg\+xml;base64,/u,
|
||
);
|
||
});
|
||
});
|
||
|
||
test('phone number enters temporary protection after repeated failed verifications', async () => {
|
||
await withTestServer('phone-risk-block', async ({ baseUrl }) => {
|
||
await sendPhoneCode(baseUrl, '13800138000');
|
||
const entry = await phoneLogin(baseUrl, '13800138000');
|
||
|
||
for (let attempt = 0; attempt < 6; attempt += 1) {
|
||
const response = await httpRequest(`${baseUrl}/api/auth/phone/login`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
phone: '13800138000',
|
||
code: '000000',
|
||
}),
|
||
});
|
||
assert.equal(response.status, 401);
|
||
}
|
||
|
||
const blockedResponse = await httpRequest(
|
||
`${baseUrl}/api/auth/phone/send-code`,
|
||
{
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
phone: '13800138000',
|
||
scene: 'login',
|
||
}),
|
||
},
|
||
);
|
||
const blockedPayload = (await blockedResponse.json()) as {
|
||
error: {
|
||
code: string;
|
||
message: string;
|
||
};
|
||
};
|
||
|
||
assert.equal(blockedResponse.status, 429);
|
||
assert.equal(blockedPayload.error.code, 'TOO_MANY_REQUESTS');
|
||
|
||
const auditResponse = await httpRequest(`${baseUrl}/api/auth/audit-logs`, {
|
||
headers: {
|
||
Authorization: `Bearer ${entry.token}`,
|
||
Cookie: entry.refreshCookie || '',
|
||
},
|
||
});
|
||
const auditPayload = (await auditResponse.json()) as {
|
||
logs: Array<{
|
||
eventType: string;
|
||
}>;
|
||
};
|
||
|
||
assert.ok(
|
||
auditPayload.logs.some((log) => log.eventType === 'risk_block_phone'),
|
||
);
|
||
});
|
||
});
|
||
|
||
test('ip enters temporary protection after repeated failed verifications across phones', async () => {
|
||
await withTestServer('ip-risk-block', async ({ baseUrl }) => {
|
||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||
const phone = `13900139${String(attempt).padStart(3, '0')}`;
|
||
const response = await httpRequest(`${baseUrl}/api/auth/phone/login`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
phone,
|
||
code: '000000',
|
||
}),
|
||
});
|
||
assert.equal(response.status, 401);
|
||
}
|
||
|
||
const blockedResponse = await httpRequest(
|
||
`${baseUrl}/api/auth/phone/send-code`,
|
||
{
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
phone: '13700137000',
|
||
scene: 'login',
|
||
}),
|
||
},
|
||
);
|
||
const blockedPayload = (await blockedResponse.json()) as {
|
||
error: {
|
||
code: string;
|
||
};
|
||
};
|
||
|
||
assert.equal(blockedResponse.status, 429);
|
||
assert.equal(blockedPayload.error.code, 'TOO_MANY_REQUESTS');
|
||
});
|
||
});
|
||
|
||
test('risk block endpoint returns active phone protection for the signed-in account', async () => {
|
||
await withTestServer('risk-blocks-endpoint', async ({ baseUrl }) => {
|
||
await sendPhoneCode(baseUrl, '13800138000');
|
||
const entry = await phoneLogin(baseUrl, '13800138000');
|
||
|
||
for (let attempt = 0; attempt < 6; attempt += 1) {
|
||
const response = await httpRequest(`${baseUrl}/api/auth/phone/login`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
phone: '13800138000',
|
||
code: '000000',
|
||
}),
|
||
});
|
||
assert.equal(response.status, 401);
|
||
}
|
||
|
||
const blocksResponse = await httpRequest(
|
||
`${baseUrl}/api/auth/risk-blocks`,
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${entry.token}`,
|
||
Cookie: entry.refreshCookie || '',
|
||
},
|
||
},
|
||
);
|
||
const blocksPayload = (await blocksResponse.json()) as {
|
||
blocks: Array<{
|
||
scopeType: string;
|
||
remainingSeconds: number;
|
||
}>;
|
||
};
|
||
|
||
assert.equal(blocksResponse.status, 200);
|
||
assert.ok(
|
||
blocksPayload.blocks.some((block) => block.scopeType === 'phone'),
|
||
);
|
||
assert.ok((blocksPayload.blocks[0]?.remainingSeconds ?? 0) > 0);
|
||
});
|
||
});
|
||
|
||
test('risk block lift endpoint clears current phone protection', async () => {
|
||
await withTestServer('risk-block-lift', async ({ baseUrl }) => {
|
||
await sendPhoneCode(baseUrl, '13800138000');
|
||
const entry = await phoneLogin(baseUrl, '13800138000');
|
||
|
||
for (let attempt = 0; attempt < 6; attempt += 1) {
|
||
const response = await httpRequest(`${baseUrl}/api/auth/phone/login`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
phone: '13800138000',
|
||
code: '000000',
|
||
}),
|
||
});
|
||
assert.equal(response.status, 401);
|
||
}
|
||
|
||
const liftResponse = await httpRequest(
|
||
`${baseUrl}/api/auth/risk-blocks/phone/lift`,
|
||
withBearer(entry.token, {
|
||
method: 'POST',
|
||
headers: {
|
||
Cookie: entry.refreshCookie || '',
|
||
},
|
||
}),
|
||
);
|
||
const liftPayload = (await liftResponse.json()) as {
|
||
ok: true;
|
||
};
|
||
|
||
assert.equal(liftResponse.status, 200);
|
||
assert.equal(liftPayload.ok, true);
|
||
|
||
const blocksResponse = await httpRequest(
|
||
`${baseUrl}/api/auth/risk-blocks`,
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${entry.token}`,
|
||
Cookie: entry.refreshCookie || '',
|
||
},
|
||
},
|
||
);
|
||
const blocksPayload = (await blocksResponse.json()) as {
|
||
blocks: Array<{
|
||
scopeType: string;
|
||
}>;
|
||
};
|
||
|
||
assert.equal(blocksResponse.status, 200);
|
||
assert.equal(
|
||
blocksPayload.blocks.some((block) => block.scopeType === 'phone'),
|
||
false,
|
||
);
|
||
});
|
||
});
|
||
|
||
test('wechat mock login redirects back with pending bind status and token', async () => {
|
||
await withTestServer('wechat-mock-login', async ({ baseUrl }) => {
|
||
const result = await startWechatMockFlow(baseUrl, '/');
|
||
|
||
assert.equal(result.hash.get('auth_provider'), 'wechat');
|
||
assert.equal(result.hash.get('auth_binding_status'), 'pending_bind_phone');
|
||
|
||
const meResponse = await httpRequest(`${baseUrl}/api/auth/me`, {
|
||
headers: {
|
||
Authorization: `Bearer ${result.token}`,
|
||
},
|
||
});
|
||
const mePayload = (await meResponse.json()) as {
|
||
user: {
|
||
loginMethod: 'wechat';
|
||
bindingStatus: 'pending_bind_phone';
|
||
wechatBound: boolean;
|
||
phoneNumberMasked: string | null;
|
||
};
|
||
availableLoginMethods: string[];
|
||
};
|
||
|
||
assert.equal(meResponse.status, 200);
|
||
assert.equal(mePayload.user.loginMethod, 'wechat');
|
||
assert.equal(mePayload.user.bindingStatus, 'pending_bind_phone');
|
||
assert.equal(mePayload.user.wechatBound, true);
|
||
assert.equal(mePayload.user.phoneNumberMasked, null);
|
||
assert.deepEqual(mePayload.availableLoginMethods, ['phone', 'wechat']);
|
||
});
|
||
});
|
||
|
||
test('wechat pending user can bind a new phone number and become active', async () => {
|
||
await withTestServer('wechat-bind-phone', async ({ baseUrl }) => {
|
||
const wechatSession = await startWechatMockFlow(baseUrl, '/');
|
||
await sendPhoneCode(baseUrl, '13600136000');
|
||
|
||
const bindResponse = await httpRequest(
|
||
`${baseUrl}/api/auth/wechat/bind-phone`,
|
||
withBearer(wechatSession.token, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
phone: '13600136000',
|
||
code: '123456',
|
||
}),
|
||
}),
|
||
);
|
||
const bindPayload = (await bindResponse.json()) as {
|
||
token: string;
|
||
user: {
|
||
loginMethod: 'wechat';
|
||
bindingStatus: 'active';
|
||
phoneNumberMasked: string;
|
||
wechatBound: boolean;
|
||
};
|
||
};
|
||
|
||
assert.equal(bindResponse.status, 200);
|
||
assert.ok(bindPayload.token);
|
||
assert.equal(bindPayload.user.loginMethod, 'wechat');
|
||
assert.equal(bindPayload.user.bindingStatus, 'active');
|
||
assert.equal(bindPayload.user.phoneNumberMasked, '136****6000');
|
||
assert.equal(bindPayload.user.wechatBound, true);
|
||
});
|
||
});
|
||
|
||
test('wechat binding to an existing phone account merges into that account', async () => {
|
||
await withTestServer('wechat-bind-existing-phone', async ({ baseUrl }) => {
|
||
await sendPhoneCode(baseUrl, '13500135000');
|
||
const phoneAccount = await phoneLogin(baseUrl, '13500135000');
|
||
|
||
const wechatSession = await startWechatMockFlow(baseUrl, '/');
|
||
await sendPhoneCode(baseUrl, '13500135000');
|
||
|
||
const bindResponse = await httpRequest(
|
||
`${baseUrl}/api/auth/wechat/bind-phone`,
|
||
withBearer(wechatSession.token, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
phone: '13500135000',
|
||
code: '123456',
|
||
}),
|
||
}),
|
||
);
|
||
const bindPayload = (await bindResponse.json()) as {
|
||
token: string;
|
||
user: {
|
||
id: string;
|
||
loginMethod: 'phone' | 'wechat';
|
||
bindingStatus: 'active';
|
||
phoneNumberMasked: string;
|
||
wechatBound: boolean;
|
||
};
|
||
};
|
||
|
||
assert.equal(bindResponse.status, 200);
|
||
assert.equal(bindPayload.user.id, phoneAccount.user.id);
|
||
assert.equal(bindPayload.user.bindingStatus, 'active');
|
||
assert.equal(bindPayload.user.phoneNumberMasked, '135****5000');
|
||
assert.equal(bindPayload.user.wechatBound, true);
|
||
});
|
||
});
|
||
|
||
test('response envelope can be explicitly enabled without breaking existing routes', async () => {
|
||
await withTestServer('response-envelope', async ({ baseUrl }) => {
|
||
const entry = await authEntry(baseUrl, 'hero_envelope', 'secret123');
|
||
|
||
const response = await httpRequest(`${baseUrl}/api/auth/me`, {
|
||
headers: {
|
||
Authorization: `Bearer ${entry.token}`,
|
||
'X-Genarrative-Response-Envelope': 'v1',
|
||
},
|
||
});
|
||
const payload = await response.json<{
|
||
ok: true;
|
||
data: {
|
||
user: {
|
||
username: string;
|
||
};
|
||
};
|
||
error: null;
|
||
meta: {
|
||
requestId: string;
|
||
apiVersion: string;
|
||
routeVersion: string;
|
||
operation: string;
|
||
latencyMs: number;
|
||
timestamp: string;
|
||
};
|
||
}>();
|
||
|
||
assert.equal(response.status, 200);
|
||
assert.equal(payload.ok, true);
|
||
assert.equal(payload.data.user.username, 'hero_envelope');
|
||
assert.equal(payload.error, null);
|
||
assert.equal(payload.meta.apiVersion, '2026-04-08');
|
||
assert.equal(payload.meta.routeVersion, '2026-04-08');
|
||
assert.equal(payload.meta.operation, 'auth.me');
|
||
assert.ok(payload.meta.requestId);
|
||
assert.ok(payload.meta.latencyMs >= 0);
|
||
assert.ok(payload.meta.timestamp);
|
||
});
|
||
});
|
||
|
||
test('issued jwt now carries exp and refresh route can mint a new access token', async () => {
|
||
await withTestServer('expiring-jwt', async ({ baseUrl }) => {
|
||
const entry = await authEntry(baseUrl, 'hero_eternal', 'secret123');
|
||
const tokenParts = entry.token.split('.');
|
||
assert.equal(tokenParts.length, 3);
|
||
|
||
const payloadJson = JSON.parse(
|
||
Buffer.from(tokenParts[1] || '', 'base64url').toString('utf8'),
|
||
) as {
|
||
exp?: number;
|
||
sub?: string;
|
||
ver?: number;
|
||
};
|
||
|
||
assert.equal(typeof payloadJson.sub, 'string');
|
||
assert.equal(typeof payloadJson.ver, 'number');
|
||
assert.equal(typeof payloadJson.exp, 'number');
|
||
assert.ok((payloadJson.exp ?? 0) > 0);
|
||
|
||
const meResponse = await httpRequest(`${baseUrl}/api/auth/me`, {
|
||
headers: {
|
||
Authorization: `Bearer ${entry.token}`,
|
||
},
|
||
});
|
||
assert.equal(meResponse.status, 200);
|
||
|
||
const refreshResponse = await httpRequest(`${baseUrl}/api/auth/refresh`, {
|
||
method: 'POST',
|
||
headers: {
|
||
Cookie: entry.refreshCookie || '',
|
||
},
|
||
});
|
||
const refreshPayload = (await refreshResponse.json()) as {
|
||
token: string;
|
||
};
|
||
|
||
assert.equal(refreshResponse.status, 200);
|
||
assert.ok(refreshPayload.token);
|
||
assert.ok(refreshResponse.headers.get('set-cookie'));
|
||
|
||
const logoutResponse = await httpRequest(
|
||
`${baseUrl}/api/auth/logout`,
|
||
withBearer(entry.token, {
|
||
method: 'POST',
|
||
headers: {
|
||
Cookie: entry.refreshCookie || '',
|
||
},
|
||
}),
|
||
);
|
||
assert.equal(logoutResponse.status, 200);
|
||
|
||
const invalidatedResponse = await httpRequest(`${baseUrl}/api/auth/me`, {
|
||
headers: {
|
||
Authorization: `Bearer ${entry.token}`,
|
||
},
|
||
});
|
||
assert.equal(invalidatedResponse.status, 401);
|
||
});
|
||
});
|
||
|
||
test('refresh route rejects revoked refresh sessions after logout', async () => {
|
||
await withTestServer('refresh-revoked', async ({ baseUrl }) => {
|
||
const entry = await authEntry(baseUrl, 'hero_refresh_revoked', 'secret123');
|
||
|
||
const logoutResponse = await httpRequest(
|
||
`${baseUrl}/api/auth/logout`,
|
||
withBearer(entry.token, {
|
||
method: 'POST',
|
||
headers: {
|
||
Cookie: entry.refreshCookie || '',
|
||
},
|
||
}),
|
||
);
|
||
assert.equal(logoutResponse.status, 200);
|
||
|
||
const refreshResponse = await httpRequest(`${baseUrl}/api/auth/refresh`, {
|
||
method: 'POST',
|
||
headers: {
|
||
Cookie: entry.refreshCookie || '',
|
||
},
|
||
});
|
||
const refreshPayload = (await refreshResponse.json()) as {
|
||
error: {
|
||
code: string;
|
||
message: string;
|
||
};
|
||
};
|
||
|
||
assert.equal(refreshResponse.status, 401);
|
||
assert.equal(refreshPayload.error.code, 'UNAUTHORIZED');
|
||
});
|
||
});
|
||
|
||
test('session list returns current active browser sessions for the user', async () => {
|
||
await withTestServer('session-list', async ({ baseUrl }) => {
|
||
const entry = await authEntry(baseUrl, 'hero_sessions', 'secret123');
|
||
|
||
const sessionsResponse = await httpRequest(`${baseUrl}/api/auth/sessions`, {
|
||
headers: {
|
||
Authorization: `Bearer ${entry.token}`,
|
||
Cookie: entry.refreshCookie || '',
|
||
},
|
||
});
|
||
const sessionsPayload = (await sessionsResponse.json()) as {
|
||
sessions: Array<{
|
||
sessionId: string;
|
||
clientType: string;
|
||
clientLabel: string;
|
||
isCurrent: boolean;
|
||
userAgent: string | null;
|
||
ipMasked: string | null;
|
||
}>;
|
||
};
|
||
|
||
assert.equal(sessionsResponse.status, 200);
|
||
assert.equal(sessionsPayload.sessions.length, 1);
|
||
assert.equal(sessionsPayload.sessions[0]?.clientType, 'browser');
|
||
assert.equal(sessionsPayload.sessions[0]?.clientLabel, '网页端浏览器');
|
||
assert.equal(sessionsPayload.sessions[0]?.isCurrent, true);
|
||
});
|
||
});
|
||
|
||
test('session revoke removes a remote device but keeps the current session alive', async () => {
|
||
await withTestServer('session-revoke', async ({ baseUrl }) => {
|
||
const firstEntry = await authEntry(
|
||
baseUrl,
|
||
'hero_session_revoke',
|
||
'secret123',
|
||
);
|
||
const secondEntry = await authEntry(
|
||
baseUrl,
|
||
'hero_session_revoke',
|
||
'secret123',
|
||
);
|
||
|
||
const sessionsResponse = await httpRequest(`${baseUrl}/api/auth/sessions`, {
|
||
headers: {
|
||
Authorization: `Bearer ${secondEntry.token}`,
|
||
Cookie: secondEntry.refreshCookie || '',
|
||
},
|
||
});
|
||
const sessionsPayload = (await sessionsResponse.json()) as {
|
||
sessions: Array<{
|
||
sessionId: string;
|
||
isCurrent: boolean;
|
||
}>;
|
||
};
|
||
const remoteSession = sessionsPayload.sessions.find(
|
||
(session) => !session.isCurrent,
|
||
);
|
||
assert.ok(remoteSession);
|
||
|
||
const revokeResponse = await httpRequest(
|
||
`${baseUrl}/api/auth/sessions/${encodeURIComponent(remoteSession?.sessionId || '')}/revoke`,
|
||
withBearer(secondEntry.token, {
|
||
method: 'POST',
|
||
headers: {
|
||
Cookie: secondEntry.refreshCookie || '',
|
||
},
|
||
}),
|
||
);
|
||
const revokePayload = (await revokeResponse.json()) as {
|
||
ok: true;
|
||
};
|
||
|
||
assert.equal(revokeResponse.status, 200);
|
||
assert.equal(revokePayload.ok, true);
|
||
|
||
const remoteRefreshResponse = await httpRequest(
|
||
`${baseUrl}/api/auth/refresh`,
|
||
{
|
||
method: 'POST',
|
||
headers: {
|
||
Cookie: firstEntry.refreshCookie || '',
|
||
},
|
||
},
|
||
);
|
||
assert.equal(remoteRefreshResponse.status, 401);
|
||
|
||
const currentMeResponse = await httpRequest(`${baseUrl}/api/auth/me`, {
|
||
headers: {
|
||
Authorization: `Bearer ${secondEntry.token}`,
|
||
},
|
||
});
|
||
assert.equal(currentMeResponse.status, 200);
|
||
});
|
||
});
|
||
|
||
test('audit log endpoint returns recent auth activities', async () => {
|
||
await withTestServer('audit-logs', async ({ baseUrl }) => {
|
||
await sendPhoneCode(baseUrl, '13800138000');
|
||
const entry = await phoneLogin(baseUrl, '13800138000');
|
||
await sendPhoneCode(baseUrl, '13900139000');
|
||
|
||
const changeResponse = await httpRequest(
|
||
`${baseUrl}/api/auth/phone/change`,
|
||
withBearer(entry.token, {
|
||
method: 'POST',
|
||
headers: {
|
||
Cookie: entry.refreshCookie || '',
|
||
},
|
||
body: JSON.stringify({
|
||
phone: '13900139000',
|
||
code: '123456',
|
||
}),
|
||
}),
|
||
);
|
||
assert.equal(changeResponse.status, 200);
|
||
|
||
const logsResponse = await httpRequest(`${baseUrl}/api/auth/audit-logs`, {
|
||
headers: {
|
||
Authorization: `Bearer ${entry.token}`,
|
||
Cookie: entry.refreshCookie || '',
|
||
},
|
||
});
|
||
const logsPayload = (await logsResponse.json()) as {
|
||
logs: Array<{
|
||
eventType: string;
|
||
title: string;
|
||
}>;
|
||
};
|
||
|
||
assert.equal(logsResponse.status, 200);
|
||
assert.ok(logsPayload.logs.length >= 2);
|
||
assert.ok(logsPayload.logs.some((log) => log.eventType === 'phone_login'));
|
||
assert.ok(logsPayload.logs.some((log) => log.eventType === 'change_phone'));
|
||
});
|
||
});
|
||
|
||
test('active account can change phone number after verifying the new phone', async () => {
|
||
await withTestServer('change-phone', async ({ baseUrl }) => {
|
||
await sendPhoneCode(baseUrl, '13800138000');
|
||
const entry = await phoneLogin(baseUrl, '13800138000');
|
||
|
||
await sendPhoneCode(baseUrl, '13900139000');
|
||
const changeResponse = await httpRequest(
|
||
`${baseUrl}/api/auth/phone/change`,
|
||
withBearer(entry.token, {
|
||
method: 'POST',
|
||
headers: {
|
||
Cookie: entry.refreshCookie || '',
|
||
},
|
||
body: JSON.stringify({
|
||
phone: '13900139000',
|
||
code: '123456',
|
||
}),
|
||
}),
|
||
);
|
||
const changePayload = (await changeResponse.json()) as {
|
||
user: {
|
||
phoneNumberMasked: string;
|
||
displayName: string;
|
||
};
|
||
};
|
||
|
||
assert.equal(changeResponse.status, 200);
|
||
assert.equal(changePayload.user.phoneNumberMasked, '139****9000');
|
||
assert.equal(changePayload.user.displayName, '139****9000');
|
||
|
||
const meResponse = await httpRequest(`${baseUrl}/api/auth/me`, {
|
||
headers: {
|
||
Authorization: `Bearer ${entry.token}`,
|
||
},
|
||
});
|
||
const mePayload = (await meResponse.json()) as {
|
||
user: {
|
||
phoneNumberMasked: string;
|
||
};
|
||
};
|
||
|
||
assert.equal(meResponse.status, 200);
|
||
assert.equal(mePayload.user.phoneNumberMasked, '139****9000');
|
||
});
|
||
});
|
||
|
||
test('change phone rejects numbers already bound to another account', async () => {
|
||
await withTestServer('change-phone-conflict', async ({ baseUrl }) => {
|
||
await sendPhoneCode(baseUrl, '13800138000');
|
||
const sourceEntry = await phoneLogin(baseUrl, '13800138000');
|
||
|
||
await sendPhoneCode(baseUrl, '13900139000');
|
||
await phoneLogin(baseUrl, '13900139000');
|
||
|
||
const changeResponse = await httpRequest(
|
||
`${baseUrl}/api/auth/phone/change`,
|
||
withBearer(sourceEntry.token, {
|
||
method: 'POST',
|
||
headers: {
|
||
Cookie: sourceEntry.refreshCookie || '',
|
||
},
|
||
body: JSON.stringify({
|
||
phone: '13900139000',
|
||
code: '123456',
|
||
}),
|
||
}),
|
||
);
|
||
const changePayload = (await changeResponse.json()) as {
|
||
error: {
|
||
code: string;
|
||
message: string;
|
||
};
|
||
};
|
||
|
||
assert.equal(changeResponse.status, 409);
|
||
assert.equal(changePayload.error.code, 'CONFLICT');
|
||
assert.equal(changePayload.error.message, '该手机号已绑定其他账号');
|
||
});
|
||
});
|
||
|
||
test('logout-all revokes all refresh sessions and invalidates existing access tokens', async () => {
|
||
await withTestServer('logout-all', async ({ baseUrl }) => {
|
||
const entryA = await authEntry(baseUrl, 'hero_logout_all', 'secret123');
|
||
const refreshResponse = await httpRequest(`${baseUrl}/api/auth/refresh`, {
|
||
method: 'POST',
|
||
headers: {
|
||
Cookie: entryA.refreshCookie || '',
|
||
},
|
||
});
|
||
const refreshPayload = (await refreshResponse.json()) as {
|
||
token: string;
|
||
};
|
||
|
||
assert.equal(refreshResponse.status, 200);
|
||
const entryB = {
|
||
token: refreshPayload.token,
|
||
refreshCookie: buildCookieHeader(
|
||
refreshResponse.headers.get('set-cookie'),
|
||
'genarrative_refresh_session',
|
||
),
|
||
};
|
||
|
||
const logoutAllResponse = await httpRequest(
|
||
`${baseUrl}/api/auth/logout-all`,
|
||
{
|
||
method: 'POST',
|
||
headers: {
|
||
Authorization: `Bearer ${entryB.token}`,
|
||
Cookie: entryB.refreshCookie,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
},
|
||
);
|
||
const logoutAllPayload = (await logoutAllResponse.json()) as {
|
||
ok: true;
|
||
};
|
||
|
||
assert.equal(logoutAllResponse.status, 200);
|
||
assert.equal(logoutAllPayload.ok, true);
|
||
|
||
const meAResponse = await httpRequest(`${baseUrl}/api/auth/me`, {
|
||
headers: {
|
||
Authorization: `Bearer ${entryA.token}`,
|
||
},
|
||
});
|
||
assert.equal(meAResponse.status, 401);
|
||
|
||
const meBResponse = await httpRequest(`${baseUrl}/api/auth/me`, {
|
||
headers: {
|
||
Authorization: `Bearer ${entryB.token}`,
|
||
},
|
||
});
|
||
assert.equal(meBResponse.status, 401);
|
||
|
||
const refreshAfterLogoutAll = await httpRequest(
|
||
`${baseUrl}/api/auth/refresh`,
|
||
{
|
||
method: 'POST',
|
||
headers: {
|
||
Cookie: entryB.refreshCookie,
|
||
},
|
||
},
|
||
);
|
||
assert.equal(refreshAfterLogoutAll.status, 401);
|
||
});
|
||
});
|
||
|
||
test('error responses share one structure and preserve request ids', async () => {
|
||
await withTestServer('error-envelope', async ({ baseUrl }) => {
|
||
const requestId = 'req-error-envelope';
|
||
const response = await httpRequest(`${baseUrl}/api/auth/me`, {
|
||
headers: {
|
||
'X-Request-Id': requestId,
|
||
},
|
||
});
|
||
const payload = await response.json<{
|
||
error: {
|
||
code: string;
|
||
message: string;
|
||
};
|
||
meta: {
|
||
requestId: string;
|
||
apiVersion: string;
|
||
routeVersion: string;
|
||
operation: string;
|
||
};
|
||
}>();
|
||
|
||
assert.equal(response.status, 401);
|
||
assert.equal(payload.error.code, 'UNAUTHORIZED');
|
||
assert.equal(payload.error.message, '缺少 Authorization Bearer Token');
|
||
assert.equal(payload.meta.requestId, requestId);
|
||
assert.equal(payload.meta.apiVersion, '2026-04-08');
|
||
assert.equal(payload.meta.routeVersion, '2026-04-08');
|
||
assert.equal(payload.meta.operation, 'auth.me');
|
||
assert.equal(response.headers.get('x-request-id'), requestId);
|
||
});
|
||
});
|
||
|
||
test('validation errors are normalized with code, meta and issue details', async () => {
|
||
await withTestServer('invalid-request', async ({ baseUrl }) => {
|
||
const response = await httpRequest(`${baseUrl}/api/auth/entry`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({}),
|
||
});
|
||
const payload = (await response.json()) as {
|
||
error: {
|
||
code: string;
|
||
message: string;
|
||
details?: {
|
||
issues?: Array<{
|
||
path: string;
|
||
message: string;
|
||
code: string;
|
||
}>;
|
||
};
|
||
};
|
||
meta: {
|
||
operation: string;
|
||
};
|
||
};
|
||
|
||
assert.equal(response.status, 400);
|
||
assert.equal(payload.error.code, 'INVALID_REQUEST');
|
||
assert.equal(payload.error.message, '请求参数不合法');
|
||
assert.equal(payload.meta.operation, 'auth.entry');
|
||
assert.ok(Array.isArray(payload.error.details?.issues));
|
||
assert.ok((payload.error.details?.issues?.length ?? 0) > 0);
|
||
});
|
||
});
|
||
|
||
test('malformed json bodies are normalized as bad requests', async () => {
|
||
await withTestServer('malformed-json', async ({ baseUrl }) => {
|
||
const response = await httpRequest(`${baseUrl}/api/auth/entry`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: '{"username":"broken"',
|
||
});
|
||
const payload = (await response.json()) as {
|
||
error: {
|
||
code: string;
|
||
message: string;
|
||
};
|
||
meta: {
|
||
operation: string;
|
||
};
|
||
};
|
||
|
||
assert.equal(response.status, 400);
|
||
assert.equal(payload.error.code, 'BAD_REQUEST');
|
||
assert.equal(payload.error.message, 'JSON 请求体格式错误');
|
||
assert.equal(payload.meta.operation, 'POST /api/auth/entry');
|
||
});
|
||
});
|
||
|
||
test('authenticated missing routes return unified not found errors', async () => {
|
||
await withTestServer('not-found', async ({ baseUrl }) => {
|
||
const entry = await authEntry(baseUrl, 'hero_not_found', 'secret123');
|
||
const response = await httpRequest(`${baseUrl}/api/runtime/unknown-route`, {
|
||
headers: {
|
||
Authorization: `Bearer ${entry.token}`,
|
||
'X-Genarrative-Response-Envelope': 'v1',
|
||
},
|
||
});
|
||
const payload = await response.json<{
|
||
ok: false;
|
||
data: null;
|
||
error: {
|
||
code: string;
|
||
message: string;
|
||
};
|
||
meta: {
|
||
operation: string;
|
||
};
|
||
}>();
|
||
|
||
assert.equal(response.status, 404);
|
||
assert.equal(payload.ok, false);
|
||
assert.equal(payload.data, null);
|
||
assert.equal(payload.error.code, 'NOT_FOUND');
|
||
assert.match(
|
||
payload.error.message,
|
||
/^接口不存在:GET \/api\/runtime\/unknown-route$/u,
|
||
);
|
||
assert.equal(payload.meta.operation, 'GET /api/runtime/unknown-route');
|
||
});
|
||
});
|
||
|
||
test('public runtime assets are served from the app root', async () => {
|
||
const publicDir = fs.mkdtempSync(
|
||
path.join(os.tmpdir(), 'genarrative-public-static-'),
|
||
);
|
||
const generatedDir = path.join(
|
||
publicDir,
|
||
'generated-character-drafts',
|
||
'test-npc',
|
||
'visual',
|
||
'visual-draft-1',
|
||
);
|
||
fs.mkdirSync(generatedDir, { recursive: true });
|
||
const imagePath = path.join(generatedDir, 'candidate-01.png');
|
||
fs.writeFileSync(imagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
||
|
||
await withTestServer(
|
||
'public-runtime-assets',
|
||
async ({ baseUrl }) => {
|
||
const response = await httpRequest(
|
||
`${baseUrl}/generated-character-drafts/test-npc/visual/visual-draft-1/candidate-01.png`,
|
||
);
|
||
|
||
assert.equal(response.status, 200);
|
||
assert.equal(response.headers.get('content-type'), 'image/png');
|
||
},
|
||
{
|
||
publicDir,
|
||
},
|
||
);
|
||
});
|
||
|
||
test('stream responses also carry api version and route metadata headers', async () => {
|
||
const app = express();
|
||
app.use(requestIdMiddleware);
|
||
app.get('/events', (request, response) => {
|
||
prepareEventStreamResponse(request, response, {
|
||
routeMeta: {
|
||
operation: 'test.events.stream',
|
||
},
|
||
});
|
||
response.write('event: ping\n');
|
||
response.write('data: {"ok":true}\n\n');
|
||
response.end();
|
||
});
|
||
|
||
await withListeningApp(app, async ({ baseUrl }) => {
|
||
const requestId = 'req-stream-metadata';
|
||
const response = await httpRequest(`${baseUrl}/events`, {
|
||
headers: {
|
||
'X-Request-Id': requestId,
|
||
},
|
||
});
|
||
const body = await response.text();
|
||
|
||
assert.equal(response.status, 200);
|
||
assert.equal(response.headers.get('x-request-id'), requestId);
|
||
assert.equal(response.headers.get('x-api-version'), '2026-04-08');
|
||
assert.equal(response.headers.get('x-route-version'), '2026-04-08');
|
||
assert.ok(Number(response.headers.get('x-response-time-ms')) >= 0);
|
||
assert.match(
|
||
response.headers.get('content-type') ?? '',
|
||
/^text\/event-stream/u,
|
||
);
|
||
assert.match(body, /event: ping/u);
|
||
assert.ok(body.includes('data: {"ok":true}'));
|
||
});
|
||
});
|
||
|
||
test('runtime persistence is isolated by user', async () => {
|
||
await withTestServer('persistence', async ({ baseUrl }) => {
|
||
const userA = await authEntry(baseUrl, 'player_one', 'secret123');
|
||
const userB = await authEntry(baseUrl, 'player_two', 'secret123');
|
||
|
||
const saveResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/save/snapshot`,
|
||
withBearer(userA.token, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
gameState: { worldType: 'WUXIA', value: 1 },
|
||
bottomTab: 'adventure',
|
||
currentStory: { text: 'story A' },
|
||
}),
|
||
}),
|
||
);
|
||
assert.equal(saveResponse.status, 200);
|
||
|
||
const settingsResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/settings`,
|
||
withBearer(userA.token, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
musicVolume: 0.25,
|
||
platformTheme: 'dark',
|
||
}),
|
||
}),
|
||
);
|
||
assert.equal(settingsResponse.status, 200);
|
||
const settingsPayload = (await settingsResponse.json()) as {
|
||
musicVolume: number;
|
||
platformTheme: 'light' | 'dark';
|
||
};
|
||
assert.equal(settingsPayload.musicVolume, 0.25);
|
||
assert.equal(settingsPayload.platformTheme, 'dark');
|
||
|
||
const libraryResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world-library/world-a`,
|
||
withBearer(userA.token, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
profile: {
|
||
id: 'world-a',
|
||
name: '世界 A',
|
||
},
|
||
}),
|
||
}),
|
||
);
|
||
assert.equal(libraryResponse.status, 200);
|
||
|
||
const userASave = await httpRequest(
|
||
`${baseUrl}/api/runtime/save/snapshot`,
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${userA.token}`,
|
||
},
|
||
},
|
||
);
|
||
const userASavePayload = (await userASave.json()) as {
|
||
gameState: {
|
||
value: number;
|
||
};
|
||
};
|
||
assert.equal(userASavePayload.gameState.value, 1);
|
||
|
||
const userBSave = await httpRequest(
|
||
`${baseUrl}/api/runtime/save/snapshot`,
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${userB.token}`,
|
||
},
|
||
},
|
||
);
|
||
const userBSavePayload = await userBSave.json();
|
||
assert.equal(userBSavePayload, null);
|
||
|
||
const userBSettings = await httpRequest(`${baseUrl}/api/runtime/settings`, {
|
||
headers: {
|
||
Authorization: `Bearer ${userB.token}`,
|
||
},
|
||
});
|
||
const userBSettingsPayload = (await userBSettings.json()) as {
|
||
musicVolume: number;
|
||
platformTheme: 'light' | 'dark';
|
||
};
|
||
assert.equal(userBSettingsPayload.musicVolume, 0.42);
|
||
assert.equal(userBSettingsPayload.platformTheme, 'light');
|
||
|
||
const userBLibrary = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world-library`,
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${userB.token}`,
|
||
},
|
||
},
|
||
);
|
||
const userBLibraryPayload = (await userBLibrary.json()) as {
|
||
entries: unknown[];
|
||
};
|
||
assert.deepEqual(userBLibraryPayload.entries, []);
|
||
});
|
||
});
|
||
|
||
test('profile dashboard aggregates wallet, play time and played works at the account level', async () => {
|
||
await withTestServer('profile-dashboard', async ({ baseUrl }) => {
|
||
const user = await authEntry(baseUrl, 'dashboard_user', 'secret123');
|
||
|
||
const firstSaveResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/save/snapshot`,
|
||
withBearer(user.token, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
savedAt: '2026-04-16T08:00:00.000Z',
|
||
bottomTab: 'adventure',
|
||
currentStory: null,
|
||
gameState: {
|
||
worldType: 'CUSTOM',
|
||
playerCurrency: 120,
|
||
runtimeStats: {
|
||
playTimeMs: 5400000,
|
||
},
|
||
customWorldProfile: {
|
||
id: 'world-aurora',
|
||
name: '裂潮边城',
|
||
summary: '潮声与城线之间的冷铁边疆。',
|
||
},
|
||
},
|
||
}),
|
||
}),
|
||
);
|
||
assert.equal(firstSaveResponse.status, 200);
|
||
|
||
const secondSaveResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/save/snapshot`,
|
||
withBearer(user.token, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
savedAt: '2026-04-16T09:30:00.000Z',
|
||
bottomTab: 'adventure',
|
||
currentStory: null,
|
||
gameState: {
|
||
worldType: 'CUSTOM',
|
||
playerCurrency: 86,
|
||
runtimeStats: {
|
||
playTimeMs: 7200000,
|
||
},
|
||
customWorldProfile: {
|
||
id: 'world-aurora',
|
||
name: '裂潮边城',
|
||
summary: '潮声与城线之间的冷铁边疆。',
|
||
},
|
||
},
|
||
}),
|
||
}),
|
||
);
|
||
assert.equal(secondSaveResponse.status, 200);
|
||
|
||
const thirdSaveResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/save/snapshot`,
|
||
withBearer(user.token, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
savedAt: '2026-04-16T10:15:00.000Z',
|
||
bottomTab: 'adventure',
|
||
currentStory: null,
|
||
gameState: {
|
||
worldType: 'WUXIA',
|
||
playerCurrency: 86,
|
||
runtimeStats: {
|
||
playTimeMs: 900000,
|
||
},
|
||
currentScenePreset: {
|
||
name: '江湖新章',
|
||
},
|
||
},
|
||
}),
|
||
}),
|
||
);
|
||
assert.equal(thirdSaveResponse.status, 200);
|
||
|
||
const dashboardResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/profile/dashboard`,
|
||
withBearer(user.token),
|
||
);
|
||
const dashboardPayload = (await dashboardResponse.json()) as {
|
||
walletBalance: number;
|
||
totalPlayTimeMs: number;
|
||
playedWorldCount: number;
|
||
updatedAt: string | null;
|
||
};
|
||
|
||
assert.equal(dashboardResponse.status, 200);
|
||
assert.equal(dashboardPayload.walletBalance, 86);
|
||
assert.equal(dashboardPayload.totalPlayTimeMs, 8100000);
|
||
assert.equal(dashboardPayload.playedWorldCount, 2);
|
||
assert.equal(dashboardPayload.updatedAt, '2026-04-16T10:15:00.000Z');
|
||
|
||
const legacyDashboardResponse = await httpRequest(
|
||
`${baseUrl}/api/profile/dashboard`,
|
||
withBearer(user.token),
|
||
);
|
||
const legacyDashboardPayload = (await legacyDashboardResponse.json()) as {
|
||
walletBalance: number;
|
||
totalPlayTimeMs: number;
|
||
playedWorldCount: number;
|
||
updatedAt: string | null;
|
||
};
|
||
|
||
assert.equal(legacyDashboardResponse.status, 200);
|
||
assert.deepEqual(legacyDashboardPayload, dashboardPayload);
|
||
|
||
const walletLedgerResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/profile/wallet-ledger`,
|
||
withBearer(user.token),
|
||
);
|
||
const walletLedgerPayload = (await walletLedgerResponse.json()) as {
|
||
entries: Array<{
|
||
amountDelta: number;
|
||
balanceAfter: number;
|
||
sourceType: string;
|
||
}>;
|
||
};
|
||
|
||
assert.equal(walletLedgerResponse.status, 200);
|
||
assert.equal(walletLedgerPayload.entries.length, 2);
|
||
assert.equal(walletLedgerPayload.entries[0]?.amountDelta, -34);
|
||
assert.equal(walletLedgerPayload.entries[0]?.balanceAfter, 86);
|
||
assert.equal(walletLedgerPayload.entries[0]?.sourceType, 'snapshot_sync');
|
||
assert.equal(walletLedgerPayload.entries[1]?.amountDelta, 120);
|
||
|
||
const playStatsResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/profile/play-stats`,
|
||
withBearer(user.token),
|
||
);
|
||
const playStatsPayload = (await playStatsResponse.json()) as {
|
||
totalPlayTimeMs: number;
|
||
playedWorks: Array<{
|
||
worldKey: string;
|
||
worldTitle: string;
|
||
lastObservedPlayTimeMs: number;
|
||
}>;
|
||
updatedAt: string | null;
|
||
};
|
||
|
||
assert.equal(playStatsResponse.status, 200);
|
||
assert.equal(playStatsPayload.totalPlayTimeMs, 8100000);
|
||
assert.equal(playStatsPayload.updatedAt, '2026-04-16T10:15:00.000Z');
|
||
assert.equal(playStatsPayload.playedWorks.length, 2);
|
||
assert.equal(playStatsPayload.playedWorks[0]?.worldKey, 'builtin:WUXIA');
|
||
assert.equal(playStatsPayload.playedWorks[0]?.worldTitle, '江湖新章');
|
||
assert.equal(
|
||
playStatsPayload.playedWorks[1]?.worldKey,
|
||
'custom:world-aurora',
|
||
);
|
||
assert.equal(
|
||
playStatsPayload.playedWorks[1]?.lastObservedPlayTimeMs,
|
||
7200000,
|
||
);
|
||
});
|
||
});
|
||
|
||
test('profile save archives list worlds by last played time and can resume a selected archive', async () => {
|
||
await withTestServer('profile-save-archives', async ({ baseUrl }) => {
|
||
const user = await authEntry(baseUrl, 'archive_user', 'secret123');
|
||
|
||
const firstSaveResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/save/snapshot`,
|
||
withBearer(user.token, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
savedAt: '2026-04-19T08:00:00.000Z',
|
||
bottomTab: 'adventure',
|
||
currentStory: {
|
||
text: '潮声还在旧灯塔下回荡。',
|
||
options: [],
|
||
},
|
||
gameState: {
|
||
worldType: 'CUSTOM',
|
||
playerCurrency: 120,
|
||
runtimeStats: {
|
||
playTimeMs: 5400000,
|
||
},
|
||
storyEngineMemory: {
|
||
continueGameDigest: '回到裂潮边城的旧灯塔继续追查假航灯。',
|
||
},
|
||
customWorldProfile: {
|
||
id: 'world-aurora',
|
||
name: '裂潮边城',
|
||
summary: '潮声与城线之间的冷铁边疆。',
|
||
},
|
||
},
|
||
}),
|
||
}),
|
||
);
|
||
assert.equal(firstSaveResponse.status, 200);
|
||
|
||
const secondSaveResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/save/snapshot`,
|
||
withBearer(user.token, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
savedAt: '2026-04-19T10:15:00.000Z',
|
||
bottomTab: 'inventory',
|
||
currentStory: {
|
||
text: '江湖新章的风雨夜刚刚开始。',
|
||
options: [],
|
||
},
|
||
gameState: {
|
||
worldType: 'WUXIA',
|
||
playerCurrency: 86,
|
||
runtimeStats: {
|
||
playTimeMs: 900000,
|
||
},
|
||
currentScenePreset: {
|
||
name: '江湖新章',
|
||
summary: '雨夜客栈里的新委托。',
|
||
},
|
||
},
|
||
}),
|
||
}),
|
||
);
|
||
assert.equal(secondSaveResponse.status, 200);
|
||
|
||
const listResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/profile/save-archives`,
|
||
withBearer(user.token),
|
||
);
|
||
const listPayload = (await listResponse.json()) as {
|
||
entries: Array<{
|
||
worldKey: string;
|
||
worldName: string;
|
||
summaryText: string;
|
||
lastPlayedAt: string;
|
||
}>;
|
||
};
|
||
|
||
assert.equal(listResponse.status, 200);
|
||
assert.deepEqual(
|
||
listPayload.entries.map((entry) => entry.worldKey),
|
||
['builtin:WUXIA', 'custom:world-aurora'],
|
||
);
|
||
assert.equal(listPayload.entries[0]?.worldName, '江湖新章');
|
||
assert.equal(
|
||
listPayload.entries[1]?.summaryText,
|
||
'回到裂潮边城的旧灯塔继续追查假航灯。',
|
||
);
|
||
assert.equal(
|
||
listPayload.entries[0]?.lastPlayedAt,
|
||
'2026-04-19T10:15:00.000Z',
|
||
);
|
||
|
||
const resumeResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/profile/save-archives/${encodeURIComponent('custom:world-aurora')}`,
|
||
withBearer(user.token, {
|
||
method: 'POST',
|
||
}),
|
||
);
|
||
const resumePayload = (await resumeResponse.json()) as {
|
||
entry: {
|
||
worldKey: string;
|
||
};
|
||
snapshot: {
|
||
bottomTab: string;
|
||
gameState: {
|
||
playerCurrency: number;
|
||
customWorldProfile: {
|
||
id: string;
|
||
name: string;
|
||
} | null;
|
||
};
|
||
};
|
||
};
|
||
|
||
assert.equal(resumeResponse.status, 200);
|
||
assert.equal(resumePayload.entry.worldKey, 'custom:world-aurora');
|
||
assert.equal(resumePayload.snapshot.bottomTab, 'adventure');
|
||
assert.equal(resumePayload.snapshot.gameState.playerCurrency, 120);
|
||
assert.equal(
|
||
resumePayload.snapshot.gameState.customWorldProfile?.id,
|
||
'world-aurora',
|
||
);
|
||
|
||
const currentSnapshotResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/save/snapshot`,
|
||
withBearer(user.token),
|
||
);
|
||
const currentSnapshotPayload = (await currentSnapshotResponse.json()) as {
|
||
bottomTab: string;
|
||
gameState: {
|
||
playerCurrency: number;
|
||
customWorldProfile: {
|
||
id: string;
|
||
} | null;
|
||
};
|
||
};
|
||
|
||
assert.equal(currentSnapshotResponse.status, 200);
|
||
assert.equal(currentSnapshotPayload.bottomTab, 'adventure');
|
||
assert.equal(currentSnapshotPayload.gameState.playerCurrency, 120);
|
||
assert.equal(
|
||
currentSnapshotPayload.gameState.customWorldProfile?.id,
|
||
'world-aurora',
|
||
);
|
||
|
||
const dashboardResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/profile/dashboard`,
|
||
withBearer(user.token),
|
||
);
|
||
const dashboardPayload = (await dashboardResponse.json()) as {
|
||
walletBalance: number;
|
||
totalPlayTimeMs: number;
|
||
playedWorldCount: number;
|
||
};
|
||
|
||
assert.equal(dashboardResponse.status, 200);
|
||
assert.equal(dashboardPayload.walletBalance, 86);
|
||
assert.equal(dashboardPayload.totalPlayTimeMs, 6300000);
|
||
assert.equal(dashboardPayload.playedWorldCount, 2);
|
||
});
|
||
});
|
||
|
||
test('custom worlds stay private until published and then appear in the public gallery', async () => {
|
||
await withTestServer('custom-world-gallery', async ({ baseUrl }) => {
|
||
const owner = await authEntry(baseUrl, 'gallery_owner', 'secret123');
|
||
|
||
const upsertResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world-library/world-a`,
|
||
withBearer(owner.token, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
profile: {
|
||
id: 'world-a',
|
||
name: '裂桥前线',
|
||
subtitle: '边境上空的断层回响',
|
||
summary: '围绕裂桥哨线与失序潮汐展开的前线世界。',
|
||
tone: '压迫、冷峻、持续失衡',
|
||
playerGoal: '在裂桥崩塌前守住归路',
|
||
majorFactions: ['裂桥守军'],
|
||
coreConflicts: ['断层外压正在逼近城线'],
|
||
playableNpcs: [
|
||
{
|
||
id: 'role-1',
|
||
name: '沈昼',
|
||
},
|
||
],
|
||
storyNpcs: [],
|
||
landmarks: [
|
||
{
|
||
id: 'landmark-1',
|
||
name: '裂桥前哨',
|
||
description: '裂谷边缘的前线哨卡。',
|
||
dangerLevel: '高',
|
||
sceneNpcIds: [],
|
||
connections: [],
|
||
},
|
||
],
|
||
},
|
||
}),
|
||
}),
|
||
);
|
||
const upsertPayload = (await upsertResponse.json()) as {
|
||
entry: {
|
||
visibility: 'draft' | 'published';
|
||
authorDisplayName: string;
|
||
};
|
||
entries: unknown[];
|
||
};
|
||
|
||
assert.equal(upsertResponse.status, 200);
|
||
assert.equal(upsertPayload.entry.visibility, 'draft');
|
||
assert.equal(upsertPayload.entry.authorDisplayName, 'gallery_owner');
|
||
|
||
const galleryBeforePublish = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world-gallery`,
|
||
);
|
||
const galleryBeforePayload = (await galleryBeforePublish.json()) as {
|
||
entries: unknown[];
|
||
};
|
||
assert.equal(galleryBeforePublish.status, 200);
|
||
assert.deepEqual(galleryBeforePayload.entries, []);
|
||
|
||
const publishResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world-library/world-a/publish`,
|
||
withBearer(owner.token, {
|
||
method: 'POST',
|
||
}),
|
||
);
|
||
const publishPayload = (await publishResponse.json()) as {
|
||
entry: {
|
||
visibility: 'draft' | 'published';
|
||
publishedAt: string | null;
|
||
};
|
||
};
|
||
|
||
assert.equal(publishResponse.status, 200);
|
||
assert.equal(publishPayload.entry.visibility, 'published');
|
||
assert.ok(publishPayload.entry.publishedAt);
|
||
|
||
const galleryAfterPublish = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world-gallery`,
|
||
);
|
||
const galleryAfterPayload = (await galleryAfterPublish.json()) as {
|
||
entries: Array<{
|
||
ownerUserId: string;
|
||
profileId: string;
|
||
worldName: string;
|
||
authorDisplayName: string;
|
||
}>;
|
||
};
|
||
|
||
assert.equal(galleryAfterPublish.status, 200);
|
||
assert.equal(galleryAfterPayload.entries.length, 1);
|
||
assert.equal(galleryAfterPayload.entries[0]?.worldName, '裂桥前线');
|
||
assert.equal(
|
||
galleryAfterPayload.entries[0]?.authorDisplayName,
|
||
'gallery_owner',
|
||
);
|
||
|
||
const galleryDetail = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world-gallery/${encodeURIComponent(galleryAfterPayload.entries[0]?.ownerUserId || '')}/${encodeURIComponent(galleryAfterPayload.entries[0]?.profileId || '')}`,
|
||
);
|
||
const galleryDetailPayload = (await galleryDetail.json()) as {
|
||
entry: {
|
||
worldName: string;
|
||
profile: {
|
||
name: string;
|
||
};
|
||
};
|
||
};
|
||
|
||
assert.equal(galleryDetail.status, 200);
|
||
assert.equal(galleryDetailPayload.entry.worldName, '裂桥前线');
|
||
assert.equal(galleryDetailPayload.entry.profile.name, '裂桥前线');
|
||
|
||
const unpublishResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world-library/world-a/unpublish`,
|
||
withBearer(owner.token, {
|
||
method: 'POST',
|
||
}),
|
||
);
|
||
const unpublishPayload = (await unpublishResponse.json()) as {
|
||
entry: {
|
||
visibility: 'draft' | 'published';
|
||
};
|
||
};
|
||
|
||
assert.equal(unpublishResponse.status, 200);
|
||
assert.equal(unpublishPayload.entry.visibility, 'draft');
|
||
|
||
const galleryAfterUnpublish = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world-gallery`,
|
||
);
|
||
const galleryAfterUnpublishPayload =
|
||
(await galleryAfterUnpublish.json()) as {
|
||
entries: unknown[];
|
||
};
|
||
assert.deepEqual(galleryAfterUnpublishPayload.entries, []);
|
||
});
|
||
});
|
||
|
||
test('deleting a custom world uses soft delete and hides the work from library and gallery', async () => {
|
||
await withTestServer(
|
||
'custom-world-soft-delete',
|
||
async ({ baseUrl, context }) => {
|
||
const owner = await authEntry(baseUrl, 'soft_delete_owner', 'secret123');
|
||
const viewer = await authEntry(
|
||
baseUrl,
|
||
'soft_delete_viewer',
|
||
'secret123',
|
||
);
|
||
|
||
const upsertResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world-library/world-soft-delete`,
|
||
withBearer(owner.token, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
profile: {
|
||
id: 'world-soft-delete',
|
||
name: '潮雾裂港',
|
||
subtitle: '被旧航灯切开的海港',
|
||
summary: '用于验证作品删除软删除逻辑的测试世界。',
|
||
playableNpcs: [],
|
||
storyNpcs: [],
|
||
landmarks: [],
|
||
},
|
||
}),
|
||
}),
|
||
);
|
||
|
||
assert.equal(upsertResponse.status, 200);
|
||
|
||
const publishResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world-library/world-soft-delete/publish`,
|
||
withBearer(owner.token, {
|
||
method: 'POST',
|
||
}),
|
||
);
|
||
assert.equal(publishResponse.status, 200);
|
||
|
||
const deleteResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world-library/world-soft-delete`,
|
||
withBearer(owner.token, {
|
||
method: 'DELETE',
|
||
}),
|
||
);
|
||
const deletePayload = (await deleteResponse.json()) as {
|
||
entries: Array<unknown>;
|
||
};
|
||
|
||
assert.equal(deleteResponse.status, 200);
|
||
assert.deepEqual(deletePayload.entries, []);
|
||
|
||
const ownerLibraryResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world-library`,
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${owner.token}`,
|
||
},
|
||
},
|
||
);
|
||
const ownerLibraryPayload = (await ownerLibraryResponse.json()) as {
|
||
entries: Array<unknown>;
|
||
};
|
||
|
||
assert.equal(ownerLibraryResponse.status, 200);
|
||
assert.deepEqual(ownerLibraryPayload.entries, []);
|
||
|
||
const galleryResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world-gallery`,
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${viewer.token}`,
|
||
},
|
||
},
|
||
);
|
||
const galleryPayload = (await galleryResponse.json()) as {
|
||
entries: Array<unknown>;
|
||
};
|
||
|
||
assert.equal(galleryResponse.status, 200);
|
||
assert.deepEqual(galleryPayload.entries, []);
|
||
|
||
const galleryDetailResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world-gallery/${encodeURIComponent(owner.user.id)}/${encodeURIComponent('world-soft-delete')}`,
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${viewer.token}`,
|
||
},
|
||
},
|
||
);
|
||
|
||
assert.equal(galleryDetailResponse.status, 404);
|
||
|
||
const persistedRows = await context.db.query<{
|
||
profileId: string;
|
||
visibility: string;
|
||
publishedAt: string | null;
|
||
deletedAt: string | null;
|
||
payload: {
|
||
name?: string;
|
||
};
|
||
}>(
|
||
`SELECT profile_id AS "profileId",
|
||
visibility,
|
||
published_at AS "publishedAt",
|
||
deleted_at AS "deletedAt",
|
||
payload_json AS payload
|
||
FROM custom_world_profiles
|
||
WHERE user_id = $1
|
||
AND profile_id = $2`,
|
||
[owner.user.id, 'world-soft-delete'],
|
||
);
|
||
|
||
assert.equal(persistedRows.rows.length, 1);
|
||
assert.equal(persistedRows.rows[0]?.profileId, 'world-soft-delete');
|
||
assert.equal(persistedRows.rows[0]?.visibility, 'draft');
|
||
assert.equal(persistedRows.rows[0]?.publishedAt, null);
|
||
assert.ok(persistedRows.rows[0]?.deletedAt);
|
||
assert.equal(persistedRows.rows[0]?.payload?.name, '潮雾裂港');
|
||
},
|
||
);
|
||
});
|
||
|
||
test('custom world works endpoint returns draft sessions and published worlds together', async () => {
|
||
await withTestServer('custom-world-works', async ({ baseUrl }) => {
|
||
const entry = await authEntry(baseUrl, 'cw_works', 'secret123');
|
||
|
||
const createSessionResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world/agent/sessions`,
|
||
withBearer(entry.token, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
seedText: '一个被潮雾切开的列岛世界。',
|
||
}),
|
||
}),
|
||
);
|
||
const createdSession = (await createSessionResponse.json()) as {
|
||
session: {
|
||
sessionId: string;
|
||
stage: string;
|
||
};
|
||
};
|
||
|
||
assert.equal(createSessionResponse.status, 200);
|
||
assert.equal(createdSession.session.stage, 'clarifying');
|
||
|
||
const publishResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world-library/world-published`,
|
||
withBearer(entry.token, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
profile: {
|
||
id: 'world-published',
|
||
name: '雾潮列岛',
|
||
subtitle: '已发布作品',
|
||
summary: '灯塔、沉船秘术与旧盟约在雾潮里重新苏醒。',
|
||
playableNpcs: [{ id: 'hero', name: '沈灯' }],
|
||
landmarks: [{ id: 'port', name: '潮港' }],
|
||
},
|
||
}),
|
||
}),
|
||
);
|
||
|
||
assert.equal(publishResponse.status, 200);
|
||
|
||
const publishMutationResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world-library/world-published/publish`,
|
||
withBearer(entry.token, {
|
||
method: 'POST',
|
||
}),
|
||
);
|
||
|
||
assert.equal(publishMutationResponse.status, 200);
|
||
|
||
const draftOnlyResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world-library/world-draft-only`,
|
||
withBearer(entry.token, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
profile: {
|
||
id: 'world-draft-only',
|
||
name: '旧兼容草稿',
|
||
subtitle: '仍保留在作品库,但不再进入创作中心',
|
||
summary: '这个条目用来验证阶段三不会继续把 library draft 当主草稿展示。',
|
||
playableNpcs: [{ id: 'hero-draft', name: '旧草稿角色' }],
|
||
landmarks: [{ id: 'port-draft', name: '旧草稿地点' }],
|
||
},
|
||
}),
|
||
}),
|
||
);
|
||
|
||
assert.equal(draftOnlyResponse.status, 200);
|
||
|
||
const worksResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world/works`,
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${entry.token}`,
|
||
},
|
||
},
|
||
);
|
||
const worksPayload = (await worksResponse.json()) as {
|
||
items: Array<{
|
||
status: string;
|
||
title: string;
|
||
sessionId?: string | null;
|
||
profileId?: string | null;
|
||
canResume: boolean;
|
||
canEnterWorld: boolean;
|
||
}>;
|
||
};
|
||
|
||
assert.equal(worksResponse.status, 200);
|
||
assert.ok(
|
||
worksPayload.items.some(
|
||
(item) =>
|
||
item.status === 'draft' &&
|
||
item.sessionId === createdSession.session.sessionId &&
|
||
item.canResume === true &&
|
||
item.canEnterWorld === false,
|
||
),
|
||
);
|
||
assert.ok(
|
||
worksPayload.items.some(
|
||
(item) =>
|
||
item.status === 'published' &&
|
||
item.profileId === 'world-published' &&
|
||
item.title === '雾潮列岛' &&
|
||
item.canResume === false &&
|
||
item.canEnterWorld === true,
|
||
),
|
||
);
|
||
assert.equal(
|
||
worksPayload.items.some((item) => item.profileId === 'world-draft-only'),
|
||
false,
|
||
);
|
||
});
|
||
});
|
||
|
||
test('custom world agent session accepts messages and exposes completed operations', async () => {
|
||
await withTestServer(
|
||
'custom-world-agent-messages',
|
||
async ({ baseUrl, context }) => {
|
||
installTestCustomWorldAgentSingleTurnLlm(context);
|
||
const entry = await authEntry(baseUrl, 'cw_agent', 'secret123');
|
||
|
||
const createResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world/agent/sessions`,
|
||
withBearer(entry.token, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
seedText: '一个围绕灯塔与沉船秘术的边境世界。',
|
||
}),
|
||
}),
|
||
);
|
||
const created = (await createResponse.json()) as {
|
||
session: {
|
||
sessionId: string;
|
||
messages: Array<{ role: string }>;
|
||
};
|
||
};
|
||
|
||
assert.equal(createResponse.status, 200);
|
||
assert.equal(created.session.messages[0]?.role, 'assistant');
|
||
|
||
const messageResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}/messages`,
|
||
withBearer(entry.token, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
clientMessageId: 'client-1',
|
||
text: '玩家是一个被迫回到故乡灯塔的失职守望者。',
|
||
focusCardId: null,
|
||
selectedCardIds: [],
|
||
}),
|
||
}),
|
||
);
|
||
const messagePayload = (await messageResponse.json()) as {
|
||
operation: {
|
||
operationId: string;
|
||
status: string;
|
||
progress: number;
|
||
};
|
||
};
|
||
|
||
assert.equal(messageResponse.status, 200);
|
||
assert.equal(messagePayload.operation.status, 'queued');
|
||
assert.equal(messagePayload.operation.progress, 10);
|
||
|
||
let operationText = '';
|
||
|
||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||
const operationResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}/operations/${encodeURIComponent(messagePayload.operation.operationId)}`,
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${entry.token}`,
|
||
},
|
||
},
|
||
);
|
||
assert.equal(operationResponse.status, 200);
|
||
operationText = await operationResponse.text();
|
||
|
||
if (/"status":"completed"/u.test(operationText)) {
|
||
break;
|
||
}
|
||
|
||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||
}
|
||
|
||
assert.match(operationText, /"status":"completed"/u);
|
||
assert.match(operationText, /"progress":100/u);
|
||
|
||
const sessionResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}`,
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${entry.token}`,
|
||
},
|
||
},
|
||
);
|
||
const sessionPayload = (await sessionResponse.json()) as {
|
||
stage: string;
|
||
creatorIntent: {
|
||
playerPremise?: string | null;
|
||
} | null;
|
||
messages: Array<{ role: string; text: string }>;
|
||
pendingClarifications: Array<{ question: string }>;
|
||
};
|
||
|
||
assert.equal(sessionResponse.status, 200);
|
||
assert.equal(sessionPayload.stage, 'clarifying');
|
||
assert.ok(
|
||
sessionPayload.messages.some((message) => message.role === 'user'),
|
||
);
|
||
assert.ok(
|
||
sessionPayload.messages.some((message) => message.role === 'assistant'),
|
||
);
|
||
assert.match(
|
||
sessionPayload.creatorIntent?.playerPremise ?? '',
|
||
/玩家|守望者/u,
|
||
);
|
||
assert.ok(sessionPayload.pendingClarifications.length > 0);
|
||
},
|
||
);
|
||
});
|
||
|
||
test('custom world agent missing session returns 404', async () => {
|
||
await withTestServer(
|
||
'custom-world-agent-missing-session',
|
||
async ({ baseUrl }) => {
|
||
const entry = await authEntry(baseUrl, 'cw_agent_missing', 'secret123');
|
||
|
||
const response = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world/agent/sessions/unknown-session`,
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${entry.token}`,
|
||
},
|
||
},
|
||
);
|
||
const payload = (await response.json()) as {
|
||
error: {
|
||
code: string;
|
||
};
|
||
};
|
||
|
||
assert.equal(response.status, 404);
|
||
assert.equal(payload.error.code, 'NOT_FOUND');
|
||
},
|
||
);
|
||
});
|
||
|
||
test('custom world agent operation can fail and expose failed status', async () => {
|
||
await withTestServer(
|
||
'custom-world-agent-failed-operation',
|
||
async ({ baseUrl }) => {
|
||
const entry = await authEntry(baseUrl, 'cw_agent_fail', 'secret123');
|
||
|
||
const createResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world/agent/sessions`,
|
||
withBearer(entry.token, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
seedText: '一个潮雾列岛世界。',
|
||
}),
|
||
}),
|
||
);
|
||
const created = (await createResponse.json()) as {
|
||
session: {
|
||
sessionId: string;
|
||
};
|
||
};
|
||
|
||
assert.equal(createResponse.status, 200);
|
||
|
||
const messageResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}/messages`,
|
||
withBearer(entry.token, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
clientMessageId: 'client-fail',
|
||
text: '__phase1_force_fail__',
|
||
focusCardId: null,
|
||
selectedCardIds: [],
|
||
}),
|
||
}),
|
||
);
|
||
const messagePayload = (await messageResponse.json()) as {
|
||
operation: {
|
||
operationId: string;
|
||
};
|
||
};
|
||
|
||
assert.equal(messageResponse.status, 200);
|
||
|
||
let operationText = '';
|
||
|
||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||
const operationResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}/operations/${encodeURIComponent(messagePayload.operation.operationId)}`,
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${entry.token}`,
|
||
},
|
||
},
|
||
);
|
||
assert.equal(operationResponse.status, 200);
|
||
operationText = await operationResponse.text();
|
||
|
||
if (/"status":"failed"/u.test(operationText)) {
|
||
break;
|
||
}
|
||
|
||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||
}
|
||
|
||
assert.match(operationText, /"status":"failed"/u);
|
||
assert.match(operationText, /forced failure/u);
|
||
},
|
||
);
|
||
});
|
||
|
||
test('custom world agent draft_foundation action generates draft cards and card detail over http', async () => {
|
||
await withTestServer(
|
||
'custom-world-agent-phase3-http',
|
||
async ({ baseUrl, context }) => {
|
||
installTestCustomWorldAgentSingleTurnLlm(context);
|
||
const entry = await authEntry(baseUrl, 'cw_agent_phase3', 'secret123');
|
||
const readySession = await createReadyCustomWorldAgentSession({
|
||
baseUrl,
|
||
token: entry.token,
|
||
});
|
||
|
||
const actionResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(readySession.sessionId)}/actions`,
|
||
withBearer(entry.token, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
action: 'draft_foundation',
|
||
}),
|
||
}),
|
||
);
|
||
const actionPayload = (await actionResponse.json()) as {
|
||
operation: {
|
||
operationId: string;
|
||
status: string;
|
||
};
|
||
};
|
||
|
||
assert.equal(actionResponse.status, 200);
|
||
assert.equal(actionPayload.operation.status, 'queued');
|
||
|
||
await waitForCustomWorldAgentOperation({
|
||
baseUrl,
|
||
token: entry.token,
|
||
sessionId: readySession.sessionId,
|
||
operationId: actionPayload.operation.operationId,
|
||
expectedStatus: 'completed',
|
||
});
|
||
|
||
const sessionResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(readySession.sessionId)}`,
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${entry.token}`,
|
||
},
|
||
},
|
||
);
|
||
const sessionPayload = (await sessionResponse.json()) as {
|
||
stage: string;
|
||
draftProfile: {
|
||
name?: string;
|
||
summary?: string;
|
||
} | null;
|
||
draftCards: Array<{
|
||
id: string;
|
||
kind: string;
|
||
}>;
|
||
};
|
||
|
||
assert.equal(sessionResponse.status, 200);
|
||
assert.equal(sessionPayload.stage, 'object_refining');
|
||
assert.ok(sessionPayload.draftProfile?.name);
|
||
assert.ok(sessionPayload.draftCards.length > 0);
|
||
|
||
const worldCard = sessionPayload.draftCards.find(
|
||
(card) => card.kind === 'world',
|
||
);
|
||
assert.ok(worldCard);
|
||
|
||
const cardDetailResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(readySession.sessionId)}/cards/${encodeURIComponent(worldCard!.id)}`,
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${entry.token}`,
|
||
},
|
||
},
|
||
);
|
||
const cardDetailPayload = (await cardDetailResponse.json()) as {
|
||
card: {
|
||
kind: string;
|
||
sections: Array<{
|
||
label: string;
|
||
value: string;
|
||
}>;
|
||
};
|
||
};
|
||
|
||
assert.equal(cardDetailResponse.status, 200);
|
||
assert.equal(cardDetailPayload.card.kind, 'world');
|
||
assert.ok(
|
||
cardDetailPayload.card.sections.some(
|
||
(section) =>
|
||
section.label === '世界一句话' && section.value.length > 0,
|
||
),
|
||
);
|
||
},
|
||
);
|
||
});
|
||
|
||
test('custom world agent stream message returns enriched session payload over sse', async () => {
|
||
await withTestServer(
|
||
'custom-world-agent-stream-session',
|
||
async ({ baseUrl, context }) => {
|
||
installTestCustomWorldAgentSingleTurnLlm(context);
|
||
const entry = await authEntry(baseUrl, 'cw_agent_stream', 'secret123');
|
||
const readySession = await createReadyCustomWorldAgentSession({
|
||
baseUrl,
|
||
token: entry.token,
|
||
});
|
||
|
||
const foundationResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(readySession.sessionId)}/actions`,
|
||
withBearer(entry.token, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
action: 'draft_foundation',
|
||
}),
|
||
}),
|
||
);
|
||
const foundationPayload = (await foundationResponse.json()) as {
|
||
operation: {
|
||
operationId: string;
|
||
};
|
||
};
|
||
|
||
assert.equal(foundationResponse.status, 200);
|
||
|
||
await waitForCustomWorldAgentOperation({
|
||
baseUrl,
|
||
token: entry.token,
|
||
sessionId: readySession.sessionId,
|
||
operationId: foundationPayload.operation.operationId,
|
||
expectedStatus: 'completed',
|
||
});
|
||
|
||
const streamResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(readySession.sessionId)}/messages/stream`,
|
||
withBearer(entry.token, {
|
||
method: 'POST',
|
||
headers: {
|
||
Accept: 'text/event-stream',
|
||
},
|
||
body: JSON.stringify({
|
||
clientMessageId: 'stream-client-1',
|
||
text: '把守灯会的压力再收紧一些,玩家与沈砺的旧案关系也再明显一点。',
|
||
focusCardId: null,
|
||
selectedCardIds: [],
|
||
}),
|
||
}),
|
||
);
|
||
const streamText = await streamResponse.text();
|
||
const sessionEventMatch = streamText.match(/event: session\s+data: (\{.*\})/u);
|
||
|
||
assert.equal(streamResponse.status, 200);
|
||
assert.match(
|
||
streamResponse.headers.get('content-type') ?? '',
|
||
/text\/event-stream/u,
|
||
);
|
||
assert.match(streamText, /event: reply_delta/u);
|
||
assert.match(streamText, /event: session/u);
|
||
assert.match(streamText, /event: done/u);
|
||
assert.ok(sessionEventMatch?.[1]);
|
||
|
||
const sessionEvent = JSON.parse(sessionEventMatch![1]) as {
|
||
session: {
|
||
stage: string;
|
||
supportedActions?: Array<{ action: string; enabled: boolean }>;
|
||
resultPreview?: {
|
||
source: string;
|
||
preview: { name?: string };
|
||
} | null;
|
||
};
|
||
};
|
||
|
||
assert.equal(sessionEvent.session.stage, 'object_refining');
|
||
assert.equal(
|
||
sessionEvent.session.supportedActions?.some(
|
||
(entry) =>
|
||
entry.action === 'update_draft_card' && entry.enabled === true,
|
||
),
|
||
true,
|
||
);
|
||
assert.equal(
|
||
sessionEvent.session.resultPreview?.source,
|
||
'session_preview',
|
||
);
|
||
assert.ok(sessionEvent.session.resultPreview?.preview?.name);
|
||
},
|
||
);
|
||
});
|
||
|
||
test('custom world agent draft_foundation action rejects not-ready sessions over http', async () => {
|
||
await withTestServer(
|
||
'custom-world-agent-phase3-http-not-ready',
|
||
async ({ baseUrl }) => {
|
||
const entry = await authEntry(baseUrl, 'cw_agent_p3_nr', 'secret123');
|
||
|
||
const createResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world/agent/sessions`,
|
||
withBearer(entry.token, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
seedText: '一个被潮雾切开的列岛世界。',
|
||
}),
|
||
}),
|
||
);
|
||
const created = (await createResponse.json()) as {
|
||
session: {
|
||
sessionId: string;
|
||
};
|
||
};
|
||
|
||
assert.equal(createResponse.status, 200);
|
||
|
||
const actionResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(created.session.sessionId)}/actions`,
|
||
withBearer(entry.token, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
action: 'draft_foundation',
|
||
}),
|
||
}),
|
||
);
|
||
const actionPayload = (await actionResponse.json()) as {
|
||
error: {
|
||
code: string;
|
||
message: string;
|
||
};
|
||
};
|
||
|
||
assert.equal(actionResponse.status, 400);
|
||
assert.equal(actionPayload.error.code, 'BAD_REQUEST');
|
||
assert.match(
|
||
actionPayload.error.message,
|
||
/progressPercent >= 100|draft_foundation/u,
|
||
);
|
||
},
|
||
);
|
||
});
|
||
|
||
test('custom world agent update_draft_card action updates draft profile and cards over http', async () => {
|
||
await withTestServer(
|
||
'custom-world-agent-phase4-update-http',
|
||
async ({ baseUrl, context }) => {
|
||
installTestCustomWorldAgentSingleTurnLlm(context);
|
||
const entry = await authEntry(
|
||
baseUrl,
|
||
'cw_agent_phase4_update',
|
||
'secret123',
|
||
);
|
||
const session = await createObjectRefiningCustomWorldAgentSession({
|
||
baseUrl,
|
||
token: entry.token,
|
||
});
|
||
const worldCard = session.draftCards.find(
|
||
(card) => card.kind === 'world',
|
||
);
|
||
|
||
assert.ok(worldCard);
|
||
|
||
const actionResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}/actions`,
|
||
withBearer(entry.token, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
action: 'update_draft_card',
|
||
cardId: worldCard!.id,
|
||
sections: [
|
||
{
|
||
sectionId: 'title',
|
||
value: '潮雾列岛·回潮版',
|
||
},
|
||
{
|
||
sectionId: 'summary',
|
||
value: '世界总卡和主要对象已经继续往回潮暗线收紧。',
|
||
},
|
||
],
|
||
}),
|
||
}),
|
||
);
|
||
const actionPayload = (await actionResponse.json()) as {
|
||
operation: {
|
||
operationId: string;
|
||
status: string;
|
||
};
|
||
};
|
||
|
||
assert.equal(actionResponse.status, 200);
|
||
assert.equal(actionPayload.operation.status, 'queued');
|
||
|
||
await waitForCustomWorldAgentOperation({
|
||
baseUrl,
|
||
token: entry.token,
|
||
sessionId: session.sessionId,
|
||
operationId: actionPayload.operation.operationId,
|
||
expectedStatus: 'completed',
|
||
});
|
||
|
||
const sessionResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}`,
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${entry.token}`,
|
||
},
|
||
},
|
||
);
|
||
const sessionPayload = (await sessionResponse.json()) as {
|
||
draftProfile: {
|
||
name?: string;
|
||
summary?: string;
|
||
} | null;
|
||
draftCards: Array<{
|
||
id: string;
|
||
kind: string;
|
||
title: string;
|
||
summary: string;
|
||
}>;
|
||
messages: Array<{
|
||
kind: string;
|
||
text: string;
|
||
}>;
|
||
};
|
||
|
||
assert.equal(sessionResponse.status, 200);
|
||
assert.equal(sessionPayload.draftProfile?.name, '潮雾列岛·回潮版');
|
||
assert.equal(
|
||
sessionPayload.draftProfile?.summary,
|
||
'世界总卡和主要对象已经继续往回潮暗线收紧。',
|
||
);
|
||
assert.ok(
|
||
sessionPayload.draftCards.some(
|
||
(card) =>
|
||
card.id === worldCard!.id &&
|
||
card.title === '潮雾列岛·回潮版' &&
|
||
card.summary === '世界总卡和主要对象已经继续往回潮暗线收紧。',
|
||
),
|
||
);
|
||
assert.ok(
|
||
sessionPayload.messages.some(
|
||
(message) =>
|
||
message.kind === 'action_result' && message.text.includes('已更新'),
|
||
),
|
||
);
|
||
|
||
const cardDetailResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}/cards/${encodeURIComponent(worldCard!.id)}`,
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${entry.token}`,
|
||
},
|
||
},
|
||
);
|
||
const cardDetailPayload = (await cardDetailResponse.json()) as {
|
||
card: {
|
||
sections: Array<{
|
||
label: string;
|
||
value: string;
|
||
}>;
|
||
};
|
||
};
|
||
|
||
assert.equal(cardDetailResponse.status, 200);
|
||
assert.ok(
|
||
cardDetailPayload.card.sections.some(
|
||
(section) =>
|
||
section.label === '标题' && section.value === '潮雾列岛·回潮版',
|
||
),
|
||
);
|
||
|
||
const sessionRecord = await context.customWorldAgentSessions.get(
|
||
entry.user.id,
|
||
session.sessionId,
|
||
);
|
||
assert.ok(
|
||
sessionRecord?.checkpoints.some((checkpoint) =>
|
||
checkpoint.label.includes('编辑'),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
});
|
||
|
||
test('custom world agent sync_result_profile action writes result snapshot back over http', async () => {
|
||
await withTestServer(
|
||
'custom-world-agent-sync-result-profile-http',
|
||
async ({ baseUrl, context }) => {
|
||
installTestCustomWorldAgentSingleTurnLlm(context);
|
||
const entry = await authEntry(
|
||
baseUrl,
|
||
'cw_agent_sync_result',
|
||
'secret123',
|
||
);
|
||
const session = await createObjectRefiningCustomWorldAgentSession({
|
||
baseUrl,
|
||
token: entry.token,
|
||
});
|
||
|
||
const actionResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}/actions`,
|
||
withBearer(entry.token, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
action: 'sync_result_profile',
|
||
profile: {
|
||
id: `agent-draft-${session.sessionId}`,
|
||
settingText: '被海雾吞没的旧航路群岛',
|
||
name: '潮雾列岛·结果页回写版',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summary: '结果页里的最新世界概述已经回写到当前草稿。',
|
||
tone: '压抑、潮湿、悬疑',
|
||
playerGoal: '查清沉船夜与假航灯背后的操盘链。',
|
||
templateWorldType: 'WUXIA',
|
||
majorFactions: ['守灯会', '航运公会'],
|
||
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
|
||
attributeSchema: {
|
||
id: 'schema:test',
|
||
worldId: 'CUSTOM',
|
||
schemaVersion: 1,
|
||
schemaName: '测试',
|
||
generatedFrom: {
|
||
worldType: 'CUSTOM',
|
||
worldName: '潮雾列岛·结果页回写版',
|
||
settingSummary: '测试',
|
||
tone: '测试',
|
||
conflictCore: '测试',
|
||
},
|
||
slots: [],
|
||
},
|
||
playableNpcs: [],
|
||
storyNpcs: [],
|
||
items: [],
|
||
landmarks: [],
|
||
generationMode: 'full',
|
||
generationStatus: 'complete',
|
||
},
|
||
}),
|
||
}),
|
||
);
|
||
const actionPayload = (await actionResponse.json()) as {
|
||
operation: {
|
||
operationId: string;
|
||
status: string;
|
||
};
|
||
};
|
||
|
||
assert.equal(actionResponse.status, 200);
|
||
assert.equal(actionPayload.operation.status, 'queued');
|
||
|
||
await waitForCustomWorldAgentOperation({
|
||
baseUrl,
|
||
token: entry.token,
|
||
sessionId: session.sessionId,
|
||
operationId: actionPayload.operation.operationId,
|
||
expectedStatus: 'completed',
|
||
});
|
||
|
||
const sessionResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}`,
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${entry.token}`,
|
||
},
|
||
},
|
||
);
|
||
const sessionPayload = (await sessionResponse.json()) as {
|
||
draftProfile: {
|
||
name?: string;
|
||
summary?: string;
|
||
legacyResultProfile?: {
|
||
name?: string;
|
||
playerGoal?: string;
|
||
};
|
||
} | null;
|
||
};
|
||
|
||
assert.equal(sessionResponse.status, 200);
|
||
assert.equal(sessionPayload.draftProfile?.name, '潮雾列岛·结果页回写版');
|
||
assert.equal(
|
||
sessionPayload.draftProfile?.summary,
|
||
'结果页里的最新世界概述已经回写到当前草稿。',
|
||
);
|
||
assert.equal(
|
||
sessionPayload.draftProfile?.legacyResultProfile?.name,
|
||
'潮雾列岛·结果页回写版',
|
||
);
|
||
assert.equal(
|
||
sessionPayload.draftProfile?.legacyResultProfile?.playerGoal,
|
||
'查清沉船夜与假航灯背后的操盘链。',
|
||
);
|
||
},
|
||
);
|
||
});
|
||
|
||
test('library publish for agent-backed draft returns explicit blocker message when Phase4 gate is not ready', async () => {
|
||
await withTestServer(
|
||
'custom-world-library-agent-publish-blocked',
|
||
async ({ baseUrl, context }) => {
|
||
installTestCustomWorldAgentSingleTurnLlm(context);
|
||
const entry = await authEntry(
|
||
baseUrl,
|
||
'cw_library_agent_blocked',
|
||
'secret123',
|
||
);
|
||
const session = await createObjectRefiningCustomWorldAgentSession({
|
||
baseUrl,
|
||
token: entry.token,
|
||
});
|
||
const profileId = `agent-draft-${session.sessionId}`;
|
||
|
||
const publishResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world-library/${encodeURIComponent(profileId)}/publish`,
|
||
withBearer(entry.token, {
|
||
method: 'POST',
|
||
}),
|
||
);
|
||
const publishPayload = (await publishResponse.json()) as {
|
||
error: {
|
||
code: string;
|
||
message: string;
|
||
};
|
||
};
|
||
const sessionAfterPublishAttempt =
|
||
await context.customWorldAgentOrchestrator.getSessionSnapshot(
|
||
entry.user.id,
|
||
session.sessionId,
|
||
);
|
||
|
||
assert.equal(publishResponse.status, 409);
|
||
assert.equal(publishPayload.error.code, 'CONFLICT');
|
||
assert.match(
|
||
publishPayload.error.message,
|
||
/当前世界仍有 \d+ 个 blocker/u,
|
||
);
|
||
assert.match(
|
||
publishPayload.error.message,
|
||
/缺少正式主图|缺少正式场景图|主线第一幕/u,
|
||
);
|
||
assert.notEqual(sessionAfterPublishAttempt?.stage, 'published');
|
||
},
|
||
);
|
||
});
|
||
|
||
test('library publish for agent-backed draft reuses Phase4 gate and syncs session into published stage', async () => {
|
||
await withTestServer(
|
||
'custom-world-library-agent-publish-success',
|
||
async ({ baseUrl, context }) => {
|
||
installTestCustomWorldAgentSingleTurnLlm(context);
|
||
const entry = await authEntry(
|
||
baseUrl,
|
||
'cw_library_agent_success',
|
||
'secret123',
|
||
);
|
||
const session = await createObjectRefiningCustomWorldAgentSession({
|
||
baseUrl,
|
||
token: entry.token,
|
||
});
|
||
const profileId = `agent-draft-${session.sessionId}`;
|
||
|
||
await markAgentSessionPublishReady({
|
||
context,
|
||
userId: entry.user.id,
|
||
sessionId: session.sessionId,
|
||
});
|
||
|
||
const publishResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world-library/${encodeURIComponent(profileId)}/publish`,
|
||
withBearer(entry.token, {
|
||
method: 'POST',
|
||
}),
|
||
);
|
||
const publishPayload = (await publishResponse.json()) as {
|
||
entry: {
|
||
profileId: string;
|
||
visibility: 'draft' | 'published';
|
||
};
|
||
};
|
||
const libraryResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world-library`,
|
||
withBearer(entry.token),
|
||
);
|
||
const libraryPayload = (await libraryResponse.json()) as {
|
||
entries: Array<{
|
||
profileId: string;
|
||
visibility: 'draft' | 'published';
|
||
}>;
|
||
};
|
||
const sessionAfterPublish =
|
||
await context.customWorldAgentOrchestrator.getSessionSnapshot(
|
||
entry.user.id,
|
||
session.sessionId,
|
||
);
|
||
|
||
assert.equal(publishResponse.status, 200);
|
||
assert.equal(publishPayload.entry.profileId, profileId);
|
||
assert.equal(publishPayload.entry.visibility, 'published');
|
||
assert.equal(libraryResponse.status, 200);
|
||
assert.equal(
|
||
libraryPayload.entries.find((item) => item.profileId === profileId)
|
||
?.visibility,
|
||
'published',
|
||
);
|
||
assert.equal(sessionAfterPublish?.stage, 'published');
|
||
assert.equal(sessionAfterPublish?.resultPreview?.publishReady, true);
|
||
assert.equal(sessionAfterPublish?.resultPreview?.canEnterWorld, true);
|
||
assert.deepEqual(sessionAfterPublish?.resultPreview?.blockers ?? [], []);
|
||
assert.ok(
|
||
sessionAfterPublish?.messages.some(
|
||
(message) =>
|
||
message.kind === 'action_result' &&
|
||
message.text.includes('已正式发布'),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
});
|
||
|
||
test('custom world agent generate_characters action appends character cards over http', async () => {
|
||
await withTestServer(
|
||
'custom-world-agent-phase4-generate-characters-http',
|
||
async ({ baseUrl, context }) => {
|
||
installTestCustomWorldAgentSingleTurnLlm(context);
|
||
const entry = await authEntry(baseUrl, 'cw_agent_p4_ch', 'secret123');
|
||
const session = await createObjectRefiningCustomWorldAgentSession({
|
||
baseUrl,
|
||
token: entry.token,
|
||
});
|
||
const baselineCharacterCount = session.draftCards.filter(
|
||
(card) => card.kind === 'character',
|
||
).length;
|
||
const anchorCardId =
|
||
session.draftCards.find((card) => card.kind === 'character')?.id ??
|
||
session.draftCards.find((card) => card.kind === 'thread')?.id;
|
||
|
||
assert.ok(anchorCardId);
|
||
|
||
const actionResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}/actions`,
|
||
withBearer(entry.token, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
action: 'generate_characters',
|
||
count: 2,
|
||
promptText: '补两位更贴近旧航道线的边缘角色。',
|
||
anchorCardIds: [anchorCardId],
|
||
}),
|
||
}),
|
||
);
|
||
const actionPayload = (await actionResponse.json()) as {
|
||
operation: {
|
||
operationId: string;
|
||
status: string;
|
||
};
|
||
};
|
||
|
||
assert.equal(actionResponse.status, 200);
|
||
assert.equal(actionPayload.operation.status, 'queued');
|
||
|
||
await waitForCustomWorldAgentOperation({
|
||
baseUrl,
|
||
token: entry.token,
|
||
sessionId: session.sessionId,
|
||
operationId: actionPayload.operation.operationId,
|
||
expectedStatus: 'completed',
|
||
});
|
||
|
||
const sessionResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}`,
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${entry.token}`,
|
||
},
|
||
},
|
||
);
|
||
const sessionPayload = (await sessionResponse.json()) as {
|
||
focusCardId: string | null;
|
||
draftProfile: {
|
||
storyNpcs?: Array<{ id: string }>;
|
||
} | null;
|
||
draftCards: Array<{
|
||
kind: string;
|
||
title: string;
|
||
}>;
|
||
messages: Array<{
|
||
kind: string;
|
||
text: string;
|
||
}>;
|
||
};
|
||
|
||
assert.equal(sessionResponse.status, 200);
|
||
assert.ok((sessionPayload.draftProfile?.storyNpcs?.length ?? 0) >= 2);
|
||
assert.ok(
|
||
sessionPayload.draftCards.filter((card) => card.kind === 'character')
|
||
.length >=
|
||
baselineCharacterCount + 2,
|
||
);
|
||
assert.ok(sessionPayload.focusCardId);
|
||
assert.ok(
|
||
sessionPayload.messages.some(
|
||
(message) =>
|
||
message.kind === 'action_result' && message.text.includes('新角色'),
|
||
),
|
||
);
|
||
|
||
const sessionRecord = await context.customWorldAgentSessions.get(
|
||
entry.user.id,
|
||
session.sessionId,
|
||
);
|
||
assert.ok(
|
||
sessionRecord?.checkpoints.some((checkpoint) =>
|
||
checkpoint.label.includes('新增角色'),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
});
|
||
|
||
test('custom world agent generate_landmarks action appends landmark cards over http', async () => {
|
||
await withTestServer(
|
||
'custom-world-agent-phase4-generate-landmarks-http',
|
||
async ({ baseUrl, context }) => {
|
||
installTestCustomWorldAgentSingleTurnLlm(context);
|
||
const entry = await authEntry(baseUrl, 'cw_agent_p4_lm', 'secret123');
|
||
const session = await createObjectRefiningCustomWorldAgentSession({
|
||
baseUrl,
|
||
token: entry.token,
|
||
});
|
||
const baselineLandmarkCount =
|
||
session.draftProfile?.landmarks?.length ??
|
||
session.draftCards.filter((card) => card.kind === 'landmark').length;
|
||
const anchorCardId =
|
||
session.draftCards.find((card) => card.kind === 'character')?.id ??
|
||
session.draftCards.find((card) => card.kind === 'thread')?.id;
|
||
|
||
assert.ok(anchorCardId);
|
||
|
||
const actionResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}/actions`,
|
||
withBearer(entry.token, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
action: 'generate_landmarks',
|
||
count: 2,
|
||
promptText: '补两个适合藏旧航道秘密的地点。',
|
||
anchorCardIds: [anchorCardId],
|
||
}),
|
||
}),
|
||
);
|
||
const actionPayload = (await actionResponse.json()) as {
|
||
operation: {
|
||
operationId: string;
|
||
status: string;
|
||
};
|
||
};
|
||
|
||
assert.equal(actionResponse.status, 200);
|
||
assert.equal(actionPayload.operation.status, 'queued');
|
||
|
||
await waitForCustomWorldAgentOperation({
|
||
baseUrl,
|
||
token: entry.token,
|
||
sessionId: session.sessionId,
|
||
operationId: actionPayload.operation.operationId,
|
||
expectedStatus: 'completed',
|
||
});
|
||
|
||
const sessionResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}`,
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${entry.token}`,
|
||
},
|
||
},
|
||
);
|
||
const sessionPayload = (await sessionResponse.json()) as {
|
||
focusCardId: string | null;
|
||
draftProfile: {
|
||
landmarks?: Array<{ id: string }>;
|
||
} | null;
|
||
draftCards: Array<{
|
||
kind: string;
|
||
title: string;
|
||
}>;
|
||
messages: Array<{
|
||
kind: string;
|
||
text: string;
|
||
}>;
|
||
};
|
||
|
||
assert.equal(sessionResponse.status, 200);
|
||
assert.ok(
|
||
(sessionPayload.draftProfile?.landmarks?.length ?? 0) >=
|
||
baselineLandmarkCount + 2,
|
||
);
|
||
assert.ok(
|
||
sessionPayload.draftCards.filter((card) => card.kind === 'landmark')
|
||
.length >=
|
||
baselineLandmarkCount + 2,
|
||
);
|
||
assert.ok(sessionPayload.focusCardId);
|
||
assert.ok(
|
||
sessionPayload.messages.some(
|
||
(message) =>
|
||
message.kind === 'action_result' && message.text.includes('新地点'),
|
||
),
|
||
);
|
||
|
||
const sessionRecord = await context.customWorldAgentSessions.get(
|
||
entry.user.id,
|
||
session.sessionId,
|
||
);
|
||
assert.ok(
|
||
sessionRecord?.checkpoints.some((checkpoint) =>
|
||
checkpoint.label.includes('新增地点'),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
});
|
||
|
||
test('runtime snapshot persistence accepts null currentStory payloads', async () => {
|
||
await withTestServer('persistence-null-story', async ({ baseUrl }) => {
|
||
const entry = await authEntry(baseUrl, 'player_null_story', 'secret123');
|
||
|
||
const saveResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/save/snapshot`,
|
||
withBearer(entry.token, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
gameState: {
|
||
worldType: 'WUXIA',
|
||
currentScene: 'Story',
|
||
},
|
||
bottomTab: 'adventure',
|
||
currentStory: null,
|
||
}),
|
||
}),
|
||
);
|
||
const savePayload = (await saveResponse.json()) as {
|
||
currentStory: null;
|
||
};
|
||
|
||
assert.equal(saveResponse.status, 200);
|
||
assert.equal(savePayload.currentStory, null);
|
||
|
||
const loadResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/save/snapshot`,
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${entry.token}`,
|
||
},
|
||
},
|
||
);
|
||
const loadPayload = (await loadResponse.json()) as {
|
||
currentStory: null;
|
||
};
|
||
|
||
assert.equal(loadResponse.status, 200);
|
||
assert.equal(loadPayload.currentStory, null);
|
||
});
|
||
});
|
||
|
||
test('runtime snapshot persistence syncs custom world asset configs into snapshot and profile storage', async () => {
|
||
await withTestServer(
|
||
'persistence-custom-world-assets',
|
||
async ({ baseUrl, context }) => {
|
||
const entry = await authEntry(
|
||
baseUrl,
|
||
'playercustomworldassets',
|
||
'secret123',
|
||
);
|
||
|
||
const saveResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/save/snapshot`,
|
||
withBearer(entry.token, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
gameState: {
|
||
currentScene: 'Story',
|
||
worldType: 'CUSTOM',
|
||
playerCharacter: {
|
||
id: 'playable-asset-role',
|
||
portrait:
|
||
'/generated-characters/playable-asset-role/visual/visual-1/master.png',
|
||
generatedVisualAssetId: 'visual-1',
|
||
generatedAnimationSetId: 'animation-set-1',
|
||
animationMap: {
|
||
idle: {
|
||
folder: 'idle',
|
||
prefix: 'Idle',
|
||
frames: 4,
|
||
basePath:
|
||
'/generated-animations/playable-asset-role/animation-set-1/idle',
|
||
},
|
||
},
|
||
},
|
||
currentScenePreset: {
|
||
id: 'custom-scene-landmark-1',
|
||
name: '潮声断桥',
|
||
description: '旧桥横在潮雾之上。',
|
||
imageSrc:
|
||
'/generated-custom-world-scenes/cw-profile-asset/landmark-1/scene.png',
|
||
},
|
||
customWorldProfile: {
|
||
id: 'cw-profile-asset',
|
||
name: '潮雾裂港',
|
||
subtitle: '退潮时响起旧讯号',
|
||
summary: '雾与潮共同切开港湾边境。',
|
||
tone: '冷潮压城,旧案未散',
|
||
playerGoal: '追出失落讯标的去向',
|
||
settingText: '一座被潮雾与旧讯号撕开的港湾世界。',
|
||
templateWorldType: 'WUXIA',
|
||
compatibilityTemplateWorldType: 'WUXIA',
|
||
majorFactions: ['潮关守备'],
|
||
coreConflicts: ['讯标争夺'],
|
||
playableNpcs: [
|
||
{
|
||
id: 'playable-asset-role',
|
||
name: '沈潮',
|
||
title: '归港行者',
|
||
role: '可扮演角色',
|
||
description: '总盯着退潮后的暗线。',
|
||
backstory: '他从失讯后的航路里活着回来。',
|
||
personality: '谨慎克制',
|
||
motivation: '找回失落讯标',
|
||
combatStyle: '借潮势游走压制',
|
||
initialAffinity: 18,
|
||
relationshipHooks: ['识得旧港规矩'],
|
||
tags: ['潮港', '追迹'],
|
||
backstoryReveal: {
|
||
publicSummary: '他像一直在等潮声回信。',
|
||
chapters: [],
|
||
},
|
||
skills: [],
|
||
initialItems: [],
|
||
},
|
||
],
|
||
storyNpcs: [],
|
||
items: [],
|
||
camp: {
|
||
name: '归潮居',
|
||
description: '退潮后还能落脚的旧屋。',
|
||
dangerLevel: 'low',
|
||
},
|
||
landmarks: [
|
||
{
|
||
id: 'landmark-1',
|
||
name: '潮声断桥',
|
||
description: '旧桥横在潮雾之上。',
|
||
dangerLevel: 'medium',
|
||
sceneNpcIds: [],
|
||
connections: [],
|
||
},
|
||
],
|
||
attributeSchema: {
|
||
slots: [],
|
||
},
|
||
},
|
||
},
|
||
bottomTab: 'adventure',
|
||
currentStory: {
|
||
text: '潮声还在桥下回荡。',
|
||
options: [],
|
||
},
|
||
}),
|
||
}),
|
||
);
|
||
|
||
const savePayload = (await saveResponse.json()) as {
|
||
gameState: {
|
||
customWorldProfile: {
|
||
playableNpcs: Array<{
|
||
imageSrc?: string;
|
||
generatedVisualAssetId?: string;
|
||
generatedAnimationSetId?: string;
|
||
animationMap?: Record<string, { basePath?: string }>;
|
||
}>;
|
||
landmarks: Array<{
|
||
imageSrc?: string;
|
||
}>;
|
||
} | null;
|
||
};
|
||
};
|
||
|
||
assert.equal(saveResponse.status, 200);
|
||
assert.equal(
|
||
savePayload.gameState.customWorldProfile?.playableNpcs[0]?.imageSrc,
|
||
'/generated-characters/playable-asset-role/visual/visual-1/master.png',
|
||
);
|
||
assert.equal(
|
||
savePayload.gameState.customWorldProfile?.playableNpcs[0]
|
||
?.generatedVisualAssetId,
|
||
'visual-1',
|
||
);
|
||
assert.equal(
|
||
savePayload.gameState.customWorldProfile?.playableNpcs[0]
|
||
?.generatedAnimationSetId,
|
||
'animation-set-1',
|
||
);
|
||
assert.equal(
|
||
savePayload.gameState.customWorldProfile?.playableNpcs[0]?.animationMap
|
||
?.idle?.basePath,
|
||
'/generated-animations/playable-asset-role/animation-set-1/idle',
|
||
);
|
||
assert.equal(
|
||
savePayload.gameState.customWorldProfile?.landmarks[0]?.imageSrc,
|
||
'/generated-custom-world-scenes/cw-profile-asset/landmark-1/scene.png',
|
||
);
|
||
|
||
const persistedRows = await context.db.query<{
|
||
payload: {
|
||
playableNpcs?: Array<{
|
||
imageSrc?: string;
|
||
generatedVisualAssetId?: string;
|
||
generatedAnimationSetId?: string;
|
||
animationMap?: Record<string, { basePath?: string }>;
|
||
}>;
|
||
landmarks?: Array<{
|
||
imageSrc?: string;
|
||
}>;
|
||
};
|
||
}>(
|
||
`SELECT payload_json AS payload
|
||
FROM custom_world_profiles
|
||
WHERE user_id = $1
|
||
AND profile_id = $2`,
|
||
[entry.user.id, 'cw-profile-asset'],
|
||
);
|
||
|
||
assert.equal(persistedRows.rows.length, 1);
|
||
assert.equal(
|
||
persistedRows.rows[0]?.payload?.playableNpcs?.[0]?.imageSrc,
|
||
'/generated-characters/playable-asset-role/visual/visual-1/master.png',
|
||
);
|
||
assert.equal(
|
||
persistedRows.rows[0]?.payload?.playableNpcs?.[0]
|
||
?.generatedAnimationSetId,
|
||
'animation-set-1',
|
||
);
|
||
assert.equal(
|
||
persistedRows.rows[0]?.payload?.playableNpcs?.[0]?.animationMap?.idle
|
||
?.basePath,
|
||
'/generated-animations/playable-asset-role/animation-set-1/idle',
|
||
);
|
||
assert.equal(
|
||
persistedRows.rows[0]?.payload?.landmarks?.[0]?.imageSrc,
|
||
'/generated-custom-world-scenes/cw-profile-asset/landmark-1/scene.png',
|
||
);
|
||
},
|
||
);
|
||
});
|
||
|
||
test('runtime snapshot persistence returns hydrated snapshots for frontend restore flows', async () => {
|
||
await withTestServer('persistence-hydrated-snapshot', async ({ baseUrl }) => {
|
||
const entry = await authEntry(
|
||
baseUrl,
|
||
'player_hydrated_snapshot',
|
||
'secret123',
|
||
);
|
||
|
||
const saveResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/save/snapshot`,
|
||
withBearer(entry.token, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
gameState: {
|
||
currentScene: 'Story',
|
||
worldType: 'WUXIA',
|
||
playerCharacter: {
|
||
id: 'hero',
|
||
title: '试剑客',
|
||
description: '在风里试探局势的人。',
|
||
personality: '谨慎而果断',
|
||
attributes: {
|
||
strength: 8,
|
||
spirit: 6,
|
||
},
|
||
skills: [],
|
||
},
|
||
playerHp: 140,
|
||
playerMaxHp: 140,
|
||
playerMana: 60,
|
||
playerMaxMana: 60,
|
||
},
|
||
bottomTab: 'unknown-tab',
|
||
currentStory: {
|
||
text: '恢复中的故事',
|
||
options: [],
|
||
streaming: true,
|
||
},
|
||
}),
|
||
}),
|
||
);
|
||
const savePayload = (await saveResponse.json()) as {
|
||
bottomTab: string;
|
||
currentStory: {
|
||
streaming: boolean;
|
||
};
|
||
gameState: {
|
||
storyEngineMemory: {
|
||
saveMigrationManifest?: {
|
||
version: string;
|
||
} | null;
|
||
};
|
||
playerMaxHp: number;
|
||
playerMaxMana: number;
|
||
playerEquipment: {
|
||
weapon: { id: string } | null;
|
||
armor: { id: string } | null;
|
||
relic: { id: string } | null;
|
||
};
|
||
};
|
||
};
|
||
|
||
assert.equal(saveResponse.status, 200);
|
||
assert.equal(savePayload.bottomTab, 'adventure');
|
||
assert.equal(savePayload.currentStory.streaming, false);
|
||
assert.equal(
|
||
savePayload.gameState.storyEngineMemory.saveMigrationManifest?.version,
|
||
'story-engine-v5',
|
||
);
|
||
assert.equal(savePayload.gameState.playerMaxHp, 208);
|
||
assert.equal(savePayload.gameState.playerMaxMana, 1009);
|
||
assert.equal(
|
||
savePayload.gameState.playerEquipment.weapon?.id,
|
||
'starter:hero:weapon',
|
||
);
|
||
assert.equal(
|
||
savePayload.gameState.playerEquipment.armor?.id,
|
||
'starter:hero:armor',
|
||
);
|
||
assert.equal(
|
||
savePayload.gameState.playerEquipment.relic?.id,
|
||
'starter:hero:relic',
|
||
);
|
||
|
||
const loadResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/save/snapshot`,
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${entry.token}`,
|
||
},
|
||
},
|
||
);
|
||
const loadPayload = (await loadResponse.json()) as typeof savePayload;
|
||
|
||
assert.equal(loadResponse.status, 200);
|
||
assert.equal(loadPayload.bottomTab, 'adventure');
|
||
assert.equal(loadPayload.currentStory.streaming, false);
|
||
assert.equal(
|
||
loadPayload.gameState.storyEngineMemory.saveMigrationManifest?.version,
|
||
'story-engine-v5',
|
||
);
|
||
assert.equal(
|
||
loadPayload.gameState.playerEquipment.weapon?.id,
|
||
'starter:hero:weapon',
|
||
);
|
||
assert.equal(
|
||
loadPayload.gameState.playerEquipment.armor?.id,
|
||
'starter:hero:armor',
|
||
);
|
||
assert.equal(
|
||
loadPayload.gameState.playerEquipment.relic?.id,
|
||
'starter:hero:relic',
|
||
);
|
||
});
|
||
});
|
||
|
||
test('runtime snapshot persistence returns hydrated snapshots for frontend restore flows', async () => {
|
||
await withTestServer('persistence-hydrated-snapshot', async ({ baseUrl }) => {
|
||
const entry = await authEntry(
|
||
baseUrl,
|
||
'player_hydrated_story',
|
||
'secret123',
|
||
);
|
||
|
||
const saveResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/save/snapshot`,
|
||
withBearer(entry.token, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
gameState: {
|
||
currentScene: 'Story',
|
||
worldType: 'WUXIA',
|
||
playerCharacter: {
|
||
id: 'hero',
|
||
title: '试剑客',
|
||
description: '在风里试探局势的人。',
|
||
personality: '谨慎而果断',
|
||
attributes: {
|
||
strength: 8,
|
||
spirit: 6,
|
||
},
|
||
skills: [{ id: 'skill-1' }],
|
||
resourceProfile: {
|
||
maxHp: 150,
|
||
maxMana: 80,
|
||
},
|
||
},
|
||
playerHp: 80,
|
||
playerMaxHp: 70,
|
||
playerMana: 90,
|
||
playerMaxMana: 18,
|
||
playerEquipment: {
|
||
weapon: null,
|
||
armor: {
|
||
id: 'armor-1',
|
||
category: '护甲',
|
||
name: '试炼轻甲',
|
||
quantity: 1,
|
||
rarity: 'rare',
|
||
tags: ['armor'],
|
||
statProfile: {
|
||
maxHpBonus: 20,
|
||
},
|
||
},
|
||
relic: {
|
||
id: 'relic-1',
|
||
category: '饰品',
|
||
name: '回气坠',
|
||
quantity: 1,
|
||
rarity: 'rare',
|
||
tags: ['relic'],
|
||
statProfile: {
|
||
maxManaBonus: 15,
|
||
},
|
||
},
|
||
},
|
||
},
|
||
bottomTab: 'unknown-tab',
|
||
currentStory: {
|
||
text: '服务端恢复故事',
|
||
options: [],
|
||
streaming: true,
|
||
},
|
||
}),
|
||
}),
|
||
);
|
||
const savePayload = (await saveResponse.json()) as {
|
||
bottomTab: string;
|
||
currentStory: {
|
||
streaming: boolean;
|
||
};
|
||
gameState: {
|
||
runtimeActionVersion: number;
|
||
storyEngineMemory: {
|
||
activeThreadIds: string[];
|
||
saveMigrationManifest?: {
|
||
version: string;
|
||
} | null;
|
||
};
|
||
runtimeStats: {
|
||
itemsUsed: number;
|
||
};
|
||
playerEquipment: {
|
||
weapon: null;
|
||
};
|
||
playerHp: number;
|
||
playerMaxHp: number;
|
||
playerMana: number;
|
||
playerMaxMana: number;
|
||
};
|
||
};
|
||
|
||
assert.equal(saveResponse.status, 200);
|
||
assert.equal(savePayload.bottomTab, 'adventure');
|
||
assert.equal(savePayload.currentStory.streaming, false);
|
||
assert.equal(savePayload.gameState.runtimeActionVersion, 0);
|
||
assert.deepEqual(
|
||
savePayload.gameState.storyEngineMemory.activeThreadIds,
|
||
[],
|
||
);
|
||
assert.equal(
|
||
savePayload.gameState.storyEngineMemory.saveMigrationManifest?.version,
|
||
'story-engine-v5',
|
||
);
|
||
assert.equal(savePayload.gameState.runtimeStats.itemsUsed, 0);
|
||
assert.equal(savePayload.gameState.playerEquipment.weapon, null);
|
||
assert.equal(savePayload.gameState.playerHp, 80);
|
||
assert.equal(savePayload.gameState.playerMaxHp, 170);
|
||
assert.equal(savePayload.gameState.playerMana, 90);
|
||
assert.equal(savePayload.gameState.playerMaxMana, 95);
|
||
|
||
const loadResponse = await httpRequest(
|
||
`${baseUrl}/api/runtime/save/snapshot`,
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${entry.token}`,
|
||
},
|
||
},
|
||
);
|
||
const loadPayload = (await loadResponse.json()) as {
|
||
bottomTab: string;
|
||
currentStory: {
|
||
streaming: boolean;
|
||
};
|
||
gameState: {
|
||
storyEngineMemory: {
|
||
saveMigrationManifest?: {
|
||
version: string;
|
||
} | null;
|
||
};
|
||
playerMaxHp: number;
|
||
};
|
||
};
|
||
|
||
assert.equal(loadResponse.status, 200);
|
||
assert.equal(loadPayload.bottomTab, 'adventure');
|
||
assert.equal(loadPayload.currentStory.streaming, false);
|
||
assert.equal(
|
||
loadPayload.gameState.storyEngineMemory.saveMigrationManifest?.version,
|
||
'story-engine-v5',
|
||
);
|
||
assert.equal(loadPayload.gameState.playerMaxHp, 170);
|
||
});
|
||
});
|
||
|
||
test('profile browse history supports batch sync, dedupe ordering, isolation and clear', async () => {
|
||
await withTestServer('profile-browse-history', async ({ baseUrl }) => {
|
||
const viewer = await authEntry(baseUrl, 'browse_viewer', 'secret123');
|
||
const author = await authEntry(baseUrl, 'browse_author', 'secret123');
|
||
const browseHistoryUrl = `${baseUrl}/api/runtime/profile/browse-history`;
|
||
|
||
const createResponse = await httpRequest(
|
||
browseHistoryUrl,
|
||
withBearer(viewer.token, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
ownerUserId: author.user.id,
|
||
profileId: 'world-1',
|
||
worldName: '潮雾列岛',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summaryText: '第一次浏览记录',
|
||
coverImageSrc: '/covers/world-1.png',
|
||
themeMode: 'tide',
|
||
authorDisplayName: '潮汐作者',
|
||
visitedAt: '2026-04-16T10:00:00.000Z',
|
||
}),
|
||
}),
|
||
);
|
||
const createPayload = (await createResponse.json()) as {
|
||
entries: Array<{
|
||
profileId: string;
|
||
}>;
|
||
};
|
||
|
||
assert.equal(createResponse.status, 200);
|
||
assert.deepEqual(
|
||
createPayload.entries.map((entry) => entry.profileId),
|
||
['world-1'],
|
||
);
|
||
|
||
const batchResponse = await httpRequest(
|
||
browseHistoryUrl,
|
||
withBearer(viewer.token, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
entries: [
|
||
{
|
||
ownerUserId: author.user.id,
|
||
profileId: 'world-2',
|
||
worldName: '灰潮港',
|
||
subtitle: '海雾中的残灯码头',
|
||
summaryText: '第二条浏览记录',
|
||
coverImageSrc: '/covers/world-2.png',
|
||
themeMode: 'mythic',
|
||
authorDisplayName: '潮汐作者',
|
||
visitedAt: '2026-04-16T11:00:00.000Z',
|
||
},
|
||
{
|
||
ownerUserId: author.user.id,
|
||
profileId: 'world-1',
|
||
worldName: '潮雾列岛',
|
||
subtitle: '旧灯塔与失控航路',
|
||
summaryText: '第二次浏览后更新',
|
||
coverImageSrc: '/covers/world-1-updated.png',
|
||
themeMode: 'tide',
|
||
authorDisplayName: '潮汐作者',
|
||
visitedAt: '2026-04-16T12:00:00.000Z',
|
||
},
|
||
],
|
||
}),
|
||
}),
|
||
);
|
||
const batchPayload = (await batchResponse.json()) as {
|
||
entries: Array<{
|
||
profileId: string;
|
||
summaryText: string;
|
||
visitedAt: string;
|
||
}>;
|
||
};
|
||
|
||
assert.equal(batchResponse.status, 200);
|
||
assert.deepEqual(
|
||
batchPayload.entries.map((entry) => entry.profileId),
|
||
['world-1', 'world-2'],
|
||
);
|
||
assert.equal(batchPayload.entries[0]?.summaryText, '第二次浏览后更新');
|
||
assert.equal(
|
||
batchPayload.entries[0]?.visitedAt,
|
||
'2026-04-16T12:00:00.000Z',
|
||
);
|
||
|
||
const viewerHistoryResponse = await httpRequest(browseHistoryUrl, {
|
||
headers: {
|
||
Authorization: `Bearer ${viewer.token}`,
|
||
},
|
||
});
|
||
const viewerHistoryPayload = (await viewerHistoryResponse.json()) as {
|
||
entries: Array<{
|
||
profileId: string;
|
||
}>;
|
||
};
|
||
|
||
assert.equal(viewerHistoryResponse.status, 200);
|
||
assert.deepEqual(
|
||
viewerHistoryPayload.entries.map((entry) => entry.profileId),
|
||
['world-1', 'world-2'],
|
||
);
|
||
|
||
const legacyViewerHistoryResponse = await httpRequest(
|
||
`${baseUrl}/api/profile/browse-history`,
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${viewer.token}`,
|
||
},
|
||
},
|
||
);
|
||
const legacyViewerHistoryPayload =
|
||
(await legacyViewerHistoryResponse.json()) as {
|
||
entries: Array<{
|
||
profileId: string;
|
||
}>;
|
||
};
|
||
|
||
assert.equal(legacyViewerHistoryResponse.status, 200);
|
||
assert.deepEqual(
|
||
legacyViewerHistoryPayload.entries.map((entry) => entry.profileId),
|
||
['world-1', 'world-2'],
|
||
);
|
||
|
||
const authorHistoryResponse = await httpRequest(browseHistoryUrl, {
|
||
headers: {
|
||
Authorization: `Bearer ${author.token}`,
|
||
},
|
||
});
|
||
const authorHistoryPayload = (await authorHistoryResponse.json()) as {
|
||
entries: Array<unknown>;
|
||
};
|
||
|
||
assert.equal(authorHistoryResponse.status, 200);
|
||
assert.deepEqual(authorHistoryPayload.entries, []);
|
||
|
||
const clearResponse = await httpRequest(
|
||
browseHistoryUrl,
|
||
withBearer(viewer.token, {
|
||
method: 'DELETE',
|
||
}),
|
||
);
|
||
const clearPayload = (await clearResponse.json()) as {
|
||
entries: Array<unknown>;
|
||
};
|
||
|
||
assert.equal(clearResponse.status, 200);
|
||
assert.deepEqual(clearPayload.entries, []);
|
||
|
||
const clearedHistoryResponse = await httpRequest(browseHistoryUrl, {
|
||
headers: {
|
||
Authorization: `Bearer ${viewer.token}`,
|
||
},
|
||
});
|
||
const clearedHistoryPayload = (await clearedHistoryResponse.json()) as {
|
||
entries: Array<unknown>;
|
||
};
|
||
|
||
assert.equal(clearedHistoryResponse.status, 200);
|
||
assert.deepEqual(clearedHistoryPayload.entries, []);
|
||
});
|
||
});
|