Files
Genarrative/server-node/src/app.test.ts

3751 lines
113 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: {
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 = response.headers.get('set-cookie');
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 = response.headers.get('set-cookie');
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;
}
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,
);
}
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') || '';
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: refreshResponse.headers.get('set-cookie') || '',
};
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,
}),
}),
);
assert.equal(settingsResponse.status, 200);
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;
};
assert.equal(userBSettingsPayload.musicVolume, 0.42);
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('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 viewer = await authEntry(baseUrl, 'gallery_viewer', '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`,
{
headers: {
Authorization: `Bearer ${viewer.token}`,
},
},
);
const galleryBeforePayload = (await galleryBeforePublish.json()) as {
entries: unknown[];
};
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`,
{
headers: {
Authorization: `Bearer ${viewer.token}`,
},
},
);
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 || '')}`,
{
headers: {
Authorization: `Bearer ${viewer.token}`,
},
},
);
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`,
{
headers: {
Authorization: `Bearer ${viewer.token}`,
},
},
);
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 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,
),
);
});
});
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 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 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.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) >= 6);
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, []);
});
});