// @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(); 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 => 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(); }); });