1997 lines
60 KiB
TypeScript
1997 lines
60 KiB
TypeScript
import assert from 'node:assert/strict';
|
||
import fs from 'node:fs';
|
||
import type { AddressInfo } from 'node:net';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
import test from 'node:test';
|
||
|
||
import express from 'express';
|
||
|
||
import { createApp } from './app.ts';
|
||
import type { AppConfig } from './config.ts';
|
||
import { prepareEventStreamResponse } from './http.ts';
|
||
import { requestIdMiddleware } from './middleware/requestId.ts';
|
||
import { createAppContext } from './server.ts';
|
||
import { 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);
|
||
});
|
||
});
|