import type { IncomingMessage, ServerResponse } from 'node:http'; import { Readable } from 'node:stream'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createLearningRouteHandler } from '@electron/api/routes/learning'; const COURSE_HASH = 'a'.repeat(64); const ARCHIVE_HASH = 'b'.repeat(64); function course(overrides: Record = {}) { return { id: 'course-1', origin: 'user_single', status: 'ready', title: '二次函数', summary: null, language: 'zh-CN', contentHash: COURSE_HASH, archiveSha256: ARCHIVE_HASH, archiveBytes: 1024, formatVersion: 1, minPlayerVersion: '1.0.0', sceneCount: 3, capabilities: { sceneKinds: ['slide', 'interactive'], hasAudio: false, hasWhiteboard: true, hasAgent: true, internalCapability: 'drop-me', }, createdAt: '2026-08-16T00:00:00Z', publishedAt: null, internalPath: 'D:\\secret\\course.zip', ...overrides, }; } function generation(overrides: Record = {}) { return { contractVersion: 2, jobId: 'job-1', status: 'queued', mode: 'single', step: null, progress: 0, message: null, scenesGenerated: 0, totalScenes: null, courseId: null, error: null, done: false, debug: 'drop-me', ...overrides, }; } function progress(overrides: Record = {}) { return { courseId: 'course-1', contentHash: COURSE_HASH, moduleId: null, moduleContentHash: null, sceneOrder: 2, actionIndex: 1, positionMs: 500, completed: false, updatedAt: '2026-08-16T00:00:00Z', serverTrace: 'drop-me', ...overrides, }; } function jsonRequest(method: string, value: unknown): IncomingMessage { const request = Readable.from([typeof value === 'string' ? value : JSON.stringify(value)]) as IncomingMessage; request.method = method; return request; } function createResponse() { const chunks: string[] = []; const res = { statusCode: 0, setHeader: vi.fn(), end: vi.fn((chunk?: string) => { if (chunk) chunks.push(chunk); }), } as unknown as ServerResponse; return { res, get json() { return JSON.parse(chunks.join('')) as Record; }, }; } describe('Learning Main route boundary', () => { const fetchImpl = vi.fn(); const getAccessToken = vi.fn(); beforeEach(() => { fetchImpl.mockReset(); getAccessToken.mockReset(); }); it.each([ '/api/works/projects', '/api/works/learning/courses/course.with.dots', `/api/works/learning/generations/${'x'.repeat(129)}`, ])('does not claim unrelated or invalid-id route %s', async (pathname) => { const handler = createLearningRouteHandler({ fetchImpl, getAccessToken }); const response = createResponse(); await expect(handler( { method: 'GET' } as IncomingMessage, response.res, new URL(`http://127.0.0.1${pathname}`), {} as never, )).resolves.toBe(false); expect(fetchImpl).not.toHaveBeenCalled(); }); it('forwards catalog reads and strictly projects course DTOs', async () => { getAccessToken.mockResolvedValue('works-token'); fetchImpl.mockResolvedValue(new Response(JSON.stringify([course()]), { status: 200, headers: { 'Content-Type': 'application/json' }, })); const handler = createLearningRouteHandler({ fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example/', }); const response = createResponse(); await expect(handler( { method: 'GET' } as IncomingMessage, response.res, new URL('http://127.0.0.1/api/works/learning/courses?limit=24&unknown=no'), {} as never, )).resolves.toBe(true); expect(fetchImpl).toHaveBeenCalledWith( 'https://square.example/api/learning/courses?limit=24', expect.objectContaining({ method: 'GET', headers: { Accept: 'application/json', Authorization: 'Bearer works-token', }, }), ); expect(response.json).toEqual({ success: true, data: [{ id: 'course-1', origin: 'user_single', status: 'ready', title: '二次函数', summary: null, language: 'zh-CN', contentHash: COURSE_HASH, archiveSha256: ARCHIVE_HASH, archiveBytes: 1024, formatVersion: 1, minPlayerVersion: '1.0.0', sceneCount: 3, capabilities: { sceneKinds: ['slide', 'interactive'], hasAudio: false, hasWhiteboard: true, hasAgent: true, }, createdAt: '2026-08-16T00:00:00Z', publishedAt: null, }], }); }); it('rejects a malformed successful DTO instead of forwarding it', async () => { getAccessToken.mockResolvedValue('works-token'); fetchImpl.mockResolvedValue(new Response(JSON.stringify([course({ archiveBytes: -1 })]), { status: 200 })); const handler = createLearningRouteHandler({ fetchImpl, getAccessToken }); const response = createResponse(); await handler( { method: 'GET' } as IncomingMessage, response.res, new URL('http://127.0.0.1/api/works/learning/courses'), {} as never, ); expect(response.res.statusCode).toBe(502); expect(response.json).toEqual({ success: false, status: 502, code: 'LEARNING_INVALID_RESPONSE', error: '学习服务返回了无效数据', }); }); it.each([ ['GET', '/api/works/learning/courses/course-1', course(), 'internalPath'], ['GET', '/api/works/learning/progress', [progress()], 'serverTrace'], ['GET', '/api/works/learning/generations/job-1', generation(), 'debug'], ['POST', '/api/works/learning/generations/job-1/finalize', course(), 'internalPath'], ])('strictly projects the supported %s %s success DTO', async (method, pathname, payload, unknownField) => { getAccessToken.mockResolvedValue('works-token'); fetchImpl.mockResolvedValue(new Response(JSON.stringify(payload), { status: 200 })); const handler = createLearningRouteHandler({ fetchImpl, getAccessToken }); const response = createResponse(); await handler( { method } as IncomingMessage, response.res, new URL(`http://127.0.0.1${pathname}`), {} as never, ); expect(response.json).toMatchObject({ success: true }); expect(JSON.stringify(response.json)).not.toContain(unknownField); }); it('rejects a success DTO whose identity does not match the bounded route identity', async () => { getAccessToken.mockResolvedValue('works-token'); fetchImpl.mockResolvedValue(new Response(JSON.stringify(course({ id: 'course-other' })), { status: 200 })); const handler = createLearningRouteHandler({ fetchImpl, getAccessToken }); const response = createResponse(); await handler( { method: 'GET' } as IncomingMessage, response.res, new URL('http://127.0.0.1/api/works/learning/courses/course-1'), {} as never, ); expect(response.json).toMatchObject({ code: 'LEARNING_INVALID_RESPONSE' }); }); it('refreshes once with a fresh Bearer token after an upstream 401', async () => { getAccessToken.mockResolvedValueOnce('expired-token').mockResolvedValueOnce('new-token'); fetchImpl .mockResolvedValueOnce(new Response(JSON.stringify({ detail: 'expired' }), { status: 401 })) .mockResolvedValueOnce(new Response(JSON.stringify([]), { status: 200 })); const handler = createLearningRouteHandler({ fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example' }); const response = createResponse(); await handler( { method: 'GET' } as IncomingMessage, response.res, new URL('http://127.0.0.1/api/works/learning/courses'), {} as never, ); expect(getAccessToken).toHaveBeenNthCalledWith(2, { fetchImpl, forceRefresh: true }); expect(fetchImpl).toHaveBeenCalledTimes(2); expect(fetchImpl.mock.calls[0][1]?.headers).toMatchObject({ Authorization: 'Bearer expired-token' }); expect(fetchImpl.mock.calls[1][1]?.headers).toMatchObject({ Authorization: 'Bearer new-token' }); expect(response.json).toEqual({ success: true, data: [] }); }); it('returns auth-required after a refresh miss without reusing the cancelled response', async () => { getAccessToken.mockResolvedValueOnce('expired-token').mockResolvedValueOnce(null); fetchImpl.mockResolvedValue(new Response(JSON.stringify({ detail: 'expired-secret' }), { status: 401 })); const handler = createLearningRouteHandler({ fetchImpl, getAccessToken }); const response = createResponse(); await handler( { method: 'GET' } as IncomingMessage, response.res, new URL('http://127.0.0.1/api/works/learning/courses'), {} as never, ); expect(fetchImpl).toHaveBeenCalledTimes(1); expect(response.json).toEqual({ success: false, status: 401, code: 'LEARNING_AUTH_REQUIRED', error: '请先登录', }); }); it('returns a stable auth error without contacting the service', async () => { getAccessToken.mockResolvedValue(null); const handler = createLearningRouteHandler({ fetchImpl, getAccessToken }); const response = createResponse(); await handler( { method: 'GET' } as IncomingMessage, response.res, new URL('http://127.0.0.1/api/works/learning/courses'), {} as never, ); expect(response.res.statusCode).toBe(401); expect(response.json).toMatchObject({ code: 'LEARNING_AUTH_REQUIRED' }); expect(fetchImpl).not.toHaveBeenCalled(); }); it('validates and projects generation options before forwarding', async () => { getAccessToken.mockResolvedValue('works-token'); fetchImpl.mockResolvedValue(new Response(JSON.stringify(generation()), { status: 202 })); const handler = createLearningRouteHandler({ fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example' }); const options = { requirement: ' 互动讲解二次函数 ', enableWebSearch: false, enableImageGeneration: true, enableVideoGeneration: false, enableTTS: true, interactiveMode: true, taskEngineMode: false, injected: 'drop-me', }; const response = createResponse(); await handler( jsonRequest('POST', options), response.res, new URL('http://127.0.0.1/api/works/learning/generations'), {} as never, ); expect(fetchImpl).toHaveBeenCalledWith( 'https://square.example/api/learning/generations', expect.objectContaining({ method: 'POST', body: JSON.stringify({ ...options, requirement: '互动讲解二次函数', injected: undefined }), headers: expect.objectContaining({ Authorization: 'Bearer works-token' }), }), ); expect(response.json).toEqual({ success: true, data: generation({ debug: undefined }) }); }); it.each([ ['{broken-json', 'generation body'], [{ requirement: '课程', enableWebSearch: 'yes' }, 'generation options'], ])('rejects malformed %s before auth or fetch', async (body) => { const handler = createLearningRouteHandler({ fetchImpl, getAccessToken }); const response = createResponse(); await handler( jsonRequest('POST', body), response.res, new URL('http://127.0.0.1/api/works/learning/generations'), {} as never, ); expect(response.res.statusCode).toBe(400); expect(response.json).toMatchObject({ code: 'LEARNING_INVALID_REQUEST' }); expect(getAccessToken).not.toHaveBeenCalled(); expect(fetchImpl).not.toHaveBeenCalled(); }); it('validates and projects progress writes and progress responses', async () => { getAccessToken.mockResolvedValue('works-token'); fetchImpl.mockResolvedValue(new Response(JSON.stringify(progress()), { status: 200 })); const handler = createLearningRouteHandler({ fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example' }); const body = { contentHash: COURSE_HASH, sceneOrder: 2, actionIndex: 1, positionMs: 500, completed: false, moduleContentHash: 'not-accepted-on-write', }; const response = createResponse(); await handler( jsonRequest('PUT', body), response.res, new URL('http://127.0.0.1/api/works/learning/courses/course-1/progress'), {} as never, ); expect(fetchImpl.mock.calls[0][1]?.body).toBe(JSON.stringify({ contentHash: COURSE_HASH, sceneOrder: 2, actionIndex: 1, positionMs: 500, completed: false, })); expect(response.json).toEqual({ success: true, data: progress({ serverTrace: undefined }) }); }); it('rejects malformed progress writes before contacting upstream', async () => { const handler = createLearningRouteHandler({ fetchImpl, getAccessToken }); const response = createResponse(); await handler( jsonRequest('PUT', { contentHash: 'bad', sceneOrder: -1, completed: false }), response.res, new URL('http://127.0.0.1/api/works/learning/courses/course-1/progress'), {} as never, ); expect(response.json).toMatchObject({ code: 'LEARNING_INVALID_REQUEST' }); expect(fetchImpl).not.toHaveBeenCalled(); }); it.each(['cancel', 'resume'])('forwards and projects generation %s without a body', async (operation) => { getAccessToken.mockResolvedValue('works-token'); fetchImpl.mockResolvedValue(new Response(JSON.stringify(generation({ status: operation === 'cancel' ? 'cancelled' : 'queued', done: operation === 'cancel', })), { status: 200 })); const handler = createLearningRouteHandler({ fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example' }); const response = createResponse(); await handler( { method: 'POST' } as IncomingMessage, response.res, new URL(`http://127.0.0.1/api/works/learning/generations/job-1/${operation}`), {} as never, ); expect(fetchImpl).toHaveBeenCalledWith( `https://square.example/api/learning/generations/job-1/${operation}`, expect.objectContaining({ method: 'POST', headers: expect.objectContaining({ Authorization: 'Bearer works-token' }) }), ); expect(fetchImpl.mock.calls[0][1]).not.toHaveProperty('body'); expect(response.json).not.toHaveProperty('data.debug'); }); it('redacts upstream and local error details', async () => { getAccessToken.mockResolvedValue('works-token'); const handler = createLearningRouteHandler({ fetchImpl, getAccessToken }); fetchImpl.mockResolvedValueOnce(new Response(JSON.stringify({ detail: { code: 'INTERNAL_SQL', message: 'token=secret database host' }, }), { status: 500 })); const upstreamResponse = createResponse(); await handler( { method: 'GET' } as IncomingMessage, upstreamResponse.res, new URL('http://127.0.0.1/api/works/learning/courses'), {} as never, ); expect(JSON.stringify(upstreamResponse.json)).not.toContain('secret'); expect(upstreamResponse.json).toEqual({ success: false, status: 500, code: 'LEARNING_UNAVAILABLE', error: '学习服务暂时不可用', }); fetchImpl.mockRejectedValueOnce(new Error('C:\\private\\token.txt ECONNREFUSED')); const localResponse = createResponse(); await handler( { method: 'GET' } as IncomingMessage, localResponse.res, new URL('http://127.0.0.1/api/works/learning/courses'), {} as never, ); expect(JSON.stringify(localResponse.json)).not.toContain('private'); expect(localResponse.json).toMatchObject({ code: 'LEARNING_UNAVAILABLE', error: '学习服务暂时不可用' }); }); });