补齐runtime story兼容桥测试闭环
This commit is contained in:
248
server-node/src/modules/story/storySpacetimeBridge.test.ts
Normal file
248
server-node/src/modules/story/storySpacetimeBridge.test.ts
Normal 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();
|
||||
});
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user