Files
Genarrative/server-node/src/app.test.ts
victo 3d6f31433a
Some checks failed
CI / verify (push) Has been cancelled
update: 表改动 主页改动
2026-04-14 18:58:33 +08:00

1997 lines
60 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 { 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']>;
};
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 }) => 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}`,
});
} 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,
};
}
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('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('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('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('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 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);
});
});