Files
Genarrative/scripts/smoke-server-node.ts
2026-04-10 15:37:02 +08:00

288 lines
7.9 KiB
TypeScript

import assert from 'node:assert/strict';
import fs from 'node:fs';
import type { AddressInfo } from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { 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,
},
};
}
async function withSmokeServer<T>(
run: (options: { baseUrl: string }) => Promise<T>,
) {
const context = await createAppContext(createSmokeConfig());
const app = createApp(context);
const server = await new Promise<import('node:http').Server>((resolve) => {
const nextServer = app.listen(0, '127.0.0.1', () => resolve(nextServer));
});
try {
const address = server.address() as AddressInfo;
return await run({
baseUrl: `http://127.0.0.1:${address.port}`,
});
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
await context.db.close();
}
}
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);
});