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