补齐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;
|
config: AppConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type StoryBridgeDbConnection = Awaited<ReturnType<typeof connectWithToken>>;
|
||||||
|
|
||||||
function readBearerToken(request: Request) {
|
function readBearerToken(request: Request) {
|
||||||
const authorization = request.header('authorization')?.trim() || '';
|
const authorization = request.header('authorization')?.trim() || '';
|
||||||
if (!authorization.startsWith('Bearer ')) {
|
if (!authorization.startsWith('Bearer ')) {
|
||||||
@@ -102,9 +104,9 @@ async function connectWithToken(config: AppConfig, token: string) {
|
|||||||
async function withBridgeConnection<T>(
|
async function withBridgeConnection<T>(
|
||||||
config: AppConfig,
|
config: AppConfig,
|
||||||
token: string,
|
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 {
|
try {
|
||||||
return await run(connection);
|
return await run(connection);
|
||||||
} finally {
|
} 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) {
|
export function shouldUseSpacetimeStoryAuth(request: Request) {
|
||||||
return (
|
return (
|
||||||
request.header(STORY_STDB_AUTH_HEADER)?.trim() === 'spacetime-token'
|
request.header(STORY_STDB_AUTH_HEADER)?.trim() === 'spacetime-token'
|
||||||
|
|||||||
Reference in New Issue
Block a user