补齐runtime story兼容桥测试闭环

This commit is contained in:
2026-04-20 11:32:30 +00:00
parent 9d27284a64
commit cdda334f62
2 changed files with 264 additions and 2 deletions

View File

@@ -0,0 +1,248 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Request } from 'express';
import type { AppConfig } from '../../config.js';
import {
authenticateRuntimeStoryViaSpacetime,
createRuntimeStorySnapshotRepository,
resetRuntimeStoryBridgeConnectorForTest,
setRuntimeStoryBridgeConnectorForTest,
shouldUseSpacetimeStoryAuth,
} from './storySpacetimeBridge.js';
function createTestConfig(): AppConfig {
return {
nodeEnv: 'test',
projectRoot: '/tmp/genarrative-test',
publicDir: '/tmp/genarrative-test/public',
logsDir: '/tmp/genarrative-test/logs',
dataDir: '/tmp/genarrative-test/data',
rawEnv: {},
databaseUrl: 'pg-mem://genarrative-test',
serverAddr: ':0',
logLevel: 'silent',
editorApiEnabled: true,
assetsApiEnabled: true,
jwtSecret: 'test-secret',
jwtExpiresIn: '7d',
jwtIssuer: 'genarrative-test',
llm: {
baseUrl: 'https://example.invalid',
apiKey: '',
model: 'test-model',
},
dashScope: {
baseUrl: 'https://example.invalid',
apiKey: '',
imageModel: 'test-image-model',
requestTimeoutMs: 1000,
},
smsAuth: {
enabled: true,
provider: 'mock',
endpoint: 'dypnsapi.aliyuncs.com',
accessKeyId: '',
accessKeySecret: '',
signName: 'Test Sign',
templateCode: '100001',
templateParamKey: 'code',
countryCode: '86',
schemeName: '',
codeLength: 6,
codeType: 1,
validTimeSeconds: 300,
intervalSeconds: 60,
duplicatePolicy: 1,
caseAuthPolicy: 1,
returnVerifyCode: false,
mockVerifyCode: '123456',
maxSendPerPhonePerDay: 20,
maxSendPerIpPerHour: 30,
maxVerifyFailuresPerPhonePerHour: 12,
maxVerifyFailuresPerIpPerHour: 24,
captchaTtlSeconds: 180,
captchaTriggerVerifyFailuresPerPhone: 3,
captchaTriggerVerifyFailuresPerIp: 5,
blockPhoneFailureThreshold: 6,
blockIpFailureThreshold: 10,
blockPhoneDurationMinutes: 30,
blockIpDurationMinutes: 30,
},
wechatAuth: {
enabled: true,
provider: 'mock',
appId: '',
appSecret: '',
authorizeEndpoint: 'https://open.weixin.qq.com/connect/qrconnect',
accessTokenEndpoint: 'https://api.weixin.qq.com/sns/oauth2/access_token',
userInfoEndpoint: 'https://api.weixin.qq.com/sns/userinfo',
callbackPath: '/api/auth/wechat/callback',
defaultRedirectPath: '/',
mockUserId: 'mock_wechat_user',
mockUnionId: 'mock_wechat_union',
mockDisplayName: '微信旅人',
mockAvatarUrl: '',
},
authSession: {
refreshCookieName: 'genarrative_refresh_session',
refreshSessionTtlDays: 30,
refreshCookieSecure: false,
refreshCookieSameSite: 'Lax',
refreshCookiePath: '/api/auth',
},
spacetime: {
uri: 'ws://127.0.0.1:3000',
databaseName: 'genarrative-test',
},
};
}
function createRequest(headers: Record<string, string> = {}) {
const normalizedHeaders = Object.fromEntries(
Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]),
);
return {
header(name: string) {
return normalizedHeaders[name.toLowerCase()] ?? undefined;
},
ip: '127.0.0.1',
userId: undefined,
runtimeStoryAuthMode: undefined,
} as unknown as Request;
}
test('shouldUseSpacetimeStoryAuth only accepts the runtime story spacetime marker', () => {
assert.equal(
shouldUseSpacetimeStoryAuth(
createRequest({
'x-genarrative-runtime-story-auth': 'spacetime-token',
}),
),
true,
);
assert.equal(
shouldUseSpacetimeStoryAuth(
createRequest({
'x-genarrative-runtime-story-auth': 'http-access-token',
}),
),
false,
);
});
test('authenticateRuntimeStoryViaSpacetime resolves account id from bridge connection', async () => {
setRuntimeStoryBridgeConnectorForTest(async () => ({
db: {
my_auth_state: {
iter: () =>
[
{
accountId: 'stdb-account-01',
},
][Symbol.iterator](),
},
},
disconnect() {},
}));
const request = createRequest({
authorization: 'Bearer stdb-token',
});
await authenticateRuntimeStoryViaSpacetime(request, {
config: createTestConfig(),
});
assert.equal(request.userId, 'stdb-account-01');
assert.equal(request.runtimeStoryAuthMode, 'spacetime');
resetRuntimeStoryBridgeConnectorForTest();
});
test('createRuntimeStorySnapshotRepository uses STDB snapshot rows when request is marked as spacetime', async () => {
let savedSnapshotInput: Record<string, unknown> | null = null;
setRuntimeStoryBridgeConnectorForTest(async () => ({
db: {
my_snapshot: {
iter: () =>
[
{
version: 8,
savedAtMs: BigInt(Date.parse('2026-04-20T00:00:00.000Z')),
gameStateJson: JSON.stringify({
worldType: 'WUXIA',
currentScene: 'Story',
}),
bottomTab: 'adventure',
currentStoryJson: JSON.stringify({
text: '桥接后的快照',
options: [],
}),
},
][Symbol.iterator](),
},
},
procedures: {
async saveSnapshot(input: Record<string, unknown>) {
savedSnapshotInput = input;
return {
ok: true,
message: 'ok',
};
},
},
disconnect() {},
}));
const request = createRequest({
authorization: 'Bearer stdb-token',
'user-agent': 'test-agent',
});
request.runtimeStoryAuthMode = 'spacetime';
const repository = createRuntimeStorySnapshotRepository({
request,
config: createTestConfig(),
runtimeRepository: {
async getSnapshot() {
throw new Error('should not hit legacy repository');
},
async putSnapshot() {
throw new Error('should not hit legacy repository');
},
} as never,
});
const snapshot = await repository.getSnapshot('ignored-user-id');
assert.equal(snapshot?.version, 8);
assert.equal(snapshot?.gameState.worldType, 'WUXIA');
const persisted = await repository.putSnapshot('ignored-user-id', {
savedAt: '2026-04-20T00:00:00.000Z',
bottomTab: 'adventure',
gameState: {
worldType: 'WUXIA',
currentScene: 'Story',
},
currentStory: {
text: '桥接后的快照',
options: [],
},
});
assert.equal(persisted.version, 8);
assert.equal(savedSnapshotInput?.bottomTab, 'adventure');
assert.equal(
savedSnapshotInput?.currentStoryJson,
JSON.stringify({
text: '桥接后的快照',
options: [],
}),
);
resetRuntimeStoryBridgeConnectorForTest();
});

View File

@@ -23,6 +23,8 @@ type StoryStdbBridgeContext = {
config: AppConfig;
};
type StoryBridgeDbConnection = Awaited<ReturnType<typeof connectWithToken>>;
function readBearerToken(request: Request) {
const authorization = request.header('authorization')?.trim() || '';
if (!authorization.startsWith('Bearer ')) {
@@ -102,9 +104,9 @@ async function connectWithToken(config: AppConfig, token: string) {
async function withBridgeConnection<T>(
config: AppConfig,
token: string,
run: (connection: DbConnection) => Promise<T>,
run: (connection: StoryBridgeDbConnection) => Promise<T>,
) {
const connection = await connectWithToken(config, token);
const connection = await runtimeStoryBridgeConnector(config, token);
try {
return await run(connection);
} finally {
@@ -112,6 +114,18 @@ async function withBridgeConnection<T>(
}
}
let runtimeStoryBridgeConnector = connectWithToken;
export function setRuntimeStoryBridgeConnectorForTest(
connector: typeof connectWithToken,
) {
runtimeStoryBridgeConnector = connector;
}
export function resetRuntimeStoryBridgeConnectorForTest() {
runtimeStoryBridgeConnector = connectWithToken;
}
export function shouldUseSpacetimeStoryAuth(request: Request) {
return (
request.header(STORY_STDB_AUTH_HEADER)?.trim() === 'spacetime-token'