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 { createApp } from '../server-node/src/app.ts'; import type { AppConfig } from '../server-node/src/config.ts'; import { createAppContext } from '../server-node/src/server.ts'; import { httpRequest, type TestRequestInit } from '../server-node/src/testHttp.ts'; function createSmokeConfig(): AppConfig { const tempRoot = fs.mkdtempSync( path.join(os.tmpdir(), 'genarrative-server-node-smoke-'), ); return { nodeEnv: 'test', projectRoot: tempRoot, publicDir: path.join(tempRoot, 'public'), logsDir: path.join(tempRoot, 'logs'), dataDir: path.join(tempRoot, 'data'), rawEnv: {}, databaseUrl: 'pg-mem://genarrative-smoke', serverAddr: ':0', logLevel: 'silent', editorApiEnabled: true, assetsApiEnabled: true, jwtSecret: 'test-secret', jwtExpiresIn: '7d', jwtIssuer: 'genarrative-server-node-smoke', llm: { baseUrl: 'https://example.invalid', apiKey: '', model: 'test-model', }, dashScope: { baseUrl: 'https://example.invalid', apiKey: '', imageModel: 'test-image-model', requestTimeoutMs: 1000, }, smsAuth: { enabled: true, provider: 'mock', endpoint: 'dypnsapi.aliyuncs.com', accessKeyId: '', accessKeySecret: '', signName: 'Test Sign', templateCode: '100001', templateParamKey: 'code', countryCode: '86', schemeName: '', codeLength: 6, codeType: 1, validTimeSeconds: 300, intervalSeconds: 60, duplicatePolicy: 1, caseAuthPolicy: 1, returnVerifyCode: false, mockVerifyCode: '123456', maxSendPerPhonePerDay: 20, maxSendPerIpPerHour: 30, maxVerifyFailuresPerPhonePerHour: 12, maxVerifyFailuresPerIpPerHour: 24, captchaTtlSeconds: 180, captchaTriggerVerifyFailuresPerPhone: 3, captchaTriggerVerifyFailuresPerIp: 5, blockPhoneFailureThreshold: 6, blockIpFailureThreshold: 10, blockPhoneDurationMinutes: 30, blockIpDurationMinutes: 30, }, wechatAuth: { enabled: true, provider: 'mock', appId: '', appSecret: '', authorizeEndpoint: 'https://open.weixin.qq.com/connect/qrconnect', accessTokenEndpoint: 'https://api.weixin.qq.com/sns/oauth2/access_token', userInfoEndpoint: 'https://api.weixin.qq.com/sns/userinfo', callbackPath: '/api/auth/wechat/callback', defaultRedirectPath: '/', mockUserId: 'mock_wechat_user', mockUnionId: 'mock_wechat_union', mockDisplayName: '微信旅人', mockAvatarUrl: '', }, authSession: { refreshCookieName: 'genarrative_refresh_session', refreshSessionTtlDays: 30, refreshCookieSecure: false, refreshCookieSameSite: 'Lax', refreshCookiePath: '/api/auth', }, }; } async function withSmokeServer( run: (options: { baseUrl: string }) => Promise, ) { const context = await createAppContext(createSmokeConfig()); 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}`, }); } finally { await new Promise((resolve, reject) => { server.close((error) => { if (error) { reject(error); return; } resolve(); }); }); await context.db.close(); } } function withBearer(token: string, init: TestRequestInit = {}) { return { ...init, headers: { ...(init.headers ?? {}), Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, } satisfies TestRequestInit; } async function authEntry(baseUrl: string) { const username = `smoke_${Date.now().toString(36)}`; const response = await httpRequest(`${baseUrl}/api/auth/entry`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ username, password: 'smoke-secret-123', }), }); const payload = (await response.json()) as { token: string; user: { id: string; username: string; }; }; assert.equal(response.status, 200); assert.ok(payload.token); assert.equal(payload.user.username, username); return payload; } async function main() { console.log('[server-node:smoke] booting ephemeral Express server'); await withSmokeServer(async ({ baseUrl }) => { const healthzRequestId = 'smoke-healthz-request'; const healthzResponse = await httpRequest(`${baseUrl}/healthz`, { headers: { 'X-Request-Id': healthzRequestId, }, }); const healthzPayload = (await healthzResponse.json()) as { ok: boolean; service: string; }; assert.equal(healthzResponse.status, 200); assert.equal(healthzResponse.headers.get('x-request-id'), healthzRequestId); assert.equal(healthzPayload.ok, true); assert.equal(healthzPayload.service, 'genarrative-node-server'); console.log('[server-node:smoke] healthz ok'); const entry = await authEntry(baseUrl); console.log('[server-node:smoke] auth entry ok'); const meResponse = await httpRequest(`${baseUrl}/api/auth/me`, { headers: { Authorization: `Bearer ${entry.token}`, }, }); const mePayload = (await meResponse.json()) as { user: { id: string; username: string; }; }; assert.equal(meResponse.status, 200); assert.equal(mePayload.user.username, entry.user.username); console.log('[server-node:smoke] auth me ok'); const putSnapshotResponse = await httpRequest( `${baseUrl}/api/runtime/save/snapshot`, withBearer(entry.token, { method: 'PUT', body: JSON.stringify({ gameState: { worldType: 'WUXIA', chapter: 1, }, bottomTab: 'adventure', currentStory: { text: 'smoke story', }, }), }), ); const putSnapshotPayload = (await putSnapshotResponse.json()) as { version: number; bottomTab: string; gameState: { chapter: number; }; }; assert.equal(putSnapshotResponse.status, 200); assert.equal(putSnapshotPayload.version, 2); assert.equal(putSnapshotPayload.bottomTab, 'adventure'); assert.equal(putSnapshotPayload.gameState.chapter, 1); const getSnapshotResponse = await httpRequest( `${baseUrl}/api/runtime/save/snapshot`, { headers: { Authorization: `Bearer ${entry.token}`, }, }, ); const getSnapshotPayload = (await getSnapshotResponse.json()) as { bottomTab: string; gameState: { chapter: number; }; }; assert.equal(getSnapshotResponse.status, 200); assert.equal(getSnapshotPayload.bottomTab, 'adventure'); assert.equal(getSnapshotPayload.gameState.chapter, 1); console.log('[server-node:smoke] runtime snapshot roundtrip ok'); const putSettingsResponse = await httpRequest( `${baseUrl}/api/runtime/settings`, withBearer(entry.token, { method: 'PUT', body: JSON.stringify({ musicVolume: 0.3, }), }), ); const putSettingsPayload = (await putSettingsResponse.json()) as { musicVolume: number; }; assert.equal(putSettingsResponse.status, 200); assert.equal(putSettingsPayload.musicVolume, 0.3); const getSettingsResponse = await httpRequest(`${baseUrl}/api/runtime/settings`, { headers: { Authorization: `Bearer ${entry.token}`, }, }); const getSettingsPayload = (await getSettingsResponse.json()) as { musicVolume: number; }; assert.equal(getSettingsResponse.status, 200); assert.equal(getSettingsPayload.musicVolume, 0.3); console.log('[server-node:smoke] runtime settings roundtrip ok'); const deleteSnapshotResponse = await httpRequest( `${baseUrl}/api/runtime/save/snapshot`, withBearer(entry.token, { method: 'DELETE', }), ); const deleteSnapshotPayload = (await deleteSnapshotResponse.json()) as { ok: boolean; }; assert.equal(deleteSnapshotResponse.status, 200); assert.equal(deleteSnapshotPayload.ok, true); const emptySnapshotResponse = await httpRequest( `${baseUrl}/api/runtime/save/snapshot`, { headers: { Authorization: `Bearer ${entry.token}`, }, }, ); const emptySnapshotPayload = await emptySnapshotResponse.json(); assert.equal(emptySnapshotResponse.status, 200); assert.equal(emptySnapshotPayload, null); console.log('[server-node:smoke] runtime snapshot delete ok'); const logoutResponse = await httpRequest( `${baseUrl}/api/auth/logout`, withBearer(entry.token, { method: 'POST', }), ); const logoutPayload = (await logoutResponse.json()) as { ok: boolean; }; assert.equal(logoutResponse.status, 200); assert.equal(logoutPayload.ok, true); const expiredTokenResponse = await httpRequest(`${baseUrl}/api/auth/me`, { headers: { Authorization: `Bearer ${entry.token}`, }, }); assert.equal(expiredTokenResponse.status, 401); console.log('[server-node:smoke] logout invalidation ok'); }); console.log('[server-node:smoke] all checks passed'); } void main().catch((error) => { console.error('[server-node:smoke] failed'); console.error(error); process.exit(1); });