merge: integrate remote learning module safely
This commit is contained in:
163
tests/unit/learning-runtime-bridge.test.ts
Normal file
163
tests/unit/learning-runtime-bridge.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
// @vitest-environment node
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createLearningRuntimeBridge } from '@electron/services/learning-runtime-bridge';
|
||||
import type {
|
||||
LearningRuntimeBridgeEvent,
|
||||
LearningRuntimeBridgeRequest,
|
||||
} from '../../shared/learning';
|
||||
|
||||
describe('Learning runtime bridge', () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>();
|
||||
const getAccessToken = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchImpl.mockReset();
|
||||
getAccessToken.mockReset();
|
||||
getAccessToken.mockResolvedValue('works-token');
|
||||
fetchImpl.mockResolvedValue(new Response(JSON.stringify({ score: 1 }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}));
|
||||
});
|
||||
|
||||
it('pins aggregate identity in the path/header and forwards only the module and scene anchor', async () => {
|
||||
const bridge = createLearningRuntimeBridge({
|
||||
fetchImpl,
|
||||
getAccessToken,
|
||||
apiBaseUrl: 'https://square.example',
|
||||
});
|
||||
const events: LearningRuntimeBridgeEvent[] = [];
|
||||
const request = {
|
||||
requestId: 'runtime-1',
|
||||
courseId: 'course-1',
|
||||
contentHash: 'a'.repeat(64),
|
||||
capability: 'pbl/v2/evaluate',
|
||||
method: 'POST',
|
||||
context: {
|
||||
moduleId: 'module-1',
|
||||
moduleContentHash: 'b'.repeat(64),
|
||||
anchor: {
|
||||
sceneId: 'scene-2',
|
||||
sceneOrder: 2,
|
||||
courseId: 'iframe-course',
|
||||
contentHash: 'c'.repeat(64),
|
||||
moduleId: 'iframe-module',
|
||||
actionIndex: 9,
|
||||
},
|
||||
},
|
||||
body: { answer: 'B' },
|
||||
} as unknown as LearningRuntimeBridgeRequest;
|
||||
|
||||
await bridge.request(request, (event) => events.push(event));
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
'https://square.example/api/learning/courses/course-1/runtime/pbl/v2/evaluate',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer works-token',
|
||||
'X-Course-Content-Hash': 'a'.repeat(64),
|
||||
'X-Content-Hash': 'a'.repeat(64),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const init = fetchImpl.mock.calls[0][1]!;
|
||||
expect(JSON.parse(String(init.body))).toEqual({
|
||||
context: {
|
||||
moduleId: 'module-1',
|
||||
moduleContentHash: 'b'.repeat(64),
|
||||
anchor: { sceneId: 'scene-2', sceneOrder: 2 },
|
||||
},
|
||||
body: { answer: 'B' },
|
||||
});
|
||||
expect(events[0]).toMatchObject({ type: 'start', status: 200, contentType: 'application/json' });
|
||||
expect(events.at(-1)).toEqual({ requestId: 'runtime-1', type: 'end' });
|
||||
const responseText = events
|
||||
.filter((event): event is Extract<LearningRuntimeBridgeEvent, { type: 'chunk' }> => event.type === 'chunk')
|
||||
.map((event) => new TextDecoder().decode(event.chunk))
|
||||
.join('');
|
||||
expect(JSON.parse(responseText)).toEqual({ score: 1 });
|
||||
});
|
||||
|
||||
it('redacts upstream and local failures and refreshes authentication at most once', async () => {
|
||||
fetchImpl
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ detail: 'expired token secret' }), { status: 401 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ detail: 'database traceback' }), { status: 500 }));
|
||||
getAccessToken.mockResolvedValueOnce('old-token').mockResolvedValueOnce('new-token');
|
||||
const bridge = createLearningRuntimeBridge({ fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example' });
|
||||
const events: LearningRuntimeBridgeEvent[] = [];
|
||||
await bridge.request({
|
||||
requestId: 'runtime-1',
|
||||
courseId: 'course-1',
|
||||
contentHash: 'a'.repeat(64),
|
||||
capability: 'quiz-grade',
|
||||
method: 'POST',
|
||||
context: { moduleId: null, moduleContentHash: 'a'.repeat(64), anchor: { sceneId: 'scene-1' } },
|
||||
body: {},
|
||||
}, (event) => events.push(event));
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
expect(getAccessToken).toHaveBeenCalledTimes(2);
|
||||
expect(events).toEqual([{
|
||||
requestId: 'runtime-1',
|
||||
type: 'error',
|
||||
code: 'LEARNING_RUNTIME_UNAVAILABLE',
|
||||
message: '课堂联网能力暂时不可用',
|
||||
}]);
|
||||
});
|
||||
|
||||
it('bounds untrusted response content type and hides local exception text', async () => {
|
||||
fetchImpl
|
||||
.mockResolvedValueOnce(new Response('ok', { status: 200, headers: { 'Content-Type': `text/plain; secret=${'x'.repeat(300)}` } }))
|
||||
.mockRejectedValueOnce(new Error('connect ECONNREFUSED 127.0.0.1:9999'));
|
||||
const bridge = createLearningRuntimeBridge({ fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example' });
|
||||
const first: LearningRuntimeBridgeEvent[] = [];
|
||||
const request: LearningRuntimeBridgeRequest = {
|
||||
requestId: 'runtime-1', courseId: 'course-1', contentHash: 'a'.repeat(64), capability: 'quiz-grade', method: 'POST',
|
||||
context: { moduleId: null, moduleContentHash: 'a'.repeat(64), anchor: { sceneId: 'scene-1' } }, body: {},
|
||||
};
|
||||
await bridge.request(request, (event) => first.push(event));
|
||||
expect(first[0]).toMatchObject({ type: 'start', status: 200, contentType: 'text/plain' });
|
||||
const second: LearningRuntimeBridgeEvent[] = [];
|
||||
await bridge.request({ ...request, requestId: 'runtime-2' }, (event) => second.push(event));
|
||||
expect(second).toEqual([{
|
||||
requestId: 'runtime-2', type: 'error', code: 'LEARNING_RUNTIME_UNAVAILABLE', message: '课堂联网能力暂时不可用',
|
||||
}]);
|
||||
});
|
||||
|
||||
it('does not send a runtime request after its captured account changes during token lookup', async () => {
|
||||
let resolveToken!: (token: string) => void;
|
||||
getAccessToken.mockReturnValueOnce(new Promise((resolve) => { resolveToken = resolve; }));
|
||||
let current = true;
|
||||
const bridge = createLearningRuntimeBridge({ fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example' });
|
||||
const events: LearningRuntimeBridgeEvent[] = [];
|
||||
const result = bridge.request({
|
||||
requestId: 'runtime-account-switch', courseId: 'course-1', contentHash: 'a'.repeat(64), capability: 'quiz-grade', method: 'POST',
|
||||
context: { moduleId: null, moduleContentHash: 'a'.repeat(64), anchor: { sceneId: 'scene-1' } }, body: {},
|
||||
}, (event) => events.push(event), () => { if (!current) throw new Error('account changed'); });
|
||||
current = false;
|
||||
resolveToken('new-account-token');
|
||||
await result;
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
expect(events).toEqual([{
|
||||
requestId: 'runtime-account-switch', type: 'error', code: 'LEARNING_RUNTIME_UNAVAILABLE', message: '课堂联网能力暂时不可用',
|
||||
}]);
|
||||
});
|
||||
|
||||
it('does not retry a 401 after its captured account changes during refresh', async () => {
|
||||
let resolveRefresh!: (token: string) => void;
|
||||
getAccessToken.mockResolvedValueOnce('old-token').mockReturnValueOnce(new Promise((resolve) => { resolveRefresh = resolve; }));
|
||||
fetchImpl.mockResolvedValueOnce(new Response(null, { status: 401 }));
|
||||
let current = true;
|
||||
const bridge = createLearningRuntimeBridge({ fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example' });
|
||||
const result = bridge.request({
|
||||
requestId: 'runtime-refresh-switch', courseId: 'course-1', contentHash: 'a'.repeat(64), capability: 'quiz-grade', method: 'POST',
|
||||
context: { moduleId: null, moduleContentHash: 'a'.repeat(64), anchor: { sceneId: 'scene-1' } }, body: {},
|
||||
}, () => undefined, () => { if (!current) throw new Error('account changed'); });
|
||||
await vi.waitFor(() => expect(getAccessToken).toHaveBeenCalledTimes(2));
|
||||
current = false;
|
||||
resolveRefresh('new-account-token');
|
||||
await result;
|
||||
expect(fetchImpl).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user