import type { IncomingMessage, ServerResponse } from 'node:http'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createLearningRouteHandler } from '@electron/api/routes/learning'; vi.mock('electron', () => ({ app: { getPath: vi.fn(() => 'C:\\Downloads') }, dialog: { showSaveDialog: vi.fn() }, })); const ARCHIVE_HASH = 'a'.repeat(64); function project(overrides: Record = {}) { return { id: 'project-1', name: '机械臂入门', summary: '从零搭建桌面机械臂。', cover: { url: '/api/learning/projects/project-1/media/cover', alt: '机械臂封面', width: 1600, height: 900, internalKey: 'private/cover.webp', }, tags: ['机器人', 'Python'], version: '1.2.0', archiveBytes: 1024, publishedAt: '2026-08-01T00:00:00Z', updatedAt: '2026-08-18T00:00:00Z', readmeMarkdown: '# 机械臂入门', archiveFileName: 'robot-arm.zip', archiveSha256: ARCHIVE_HASH, objectStorageKey: 'private/project.zip', ...overrides, }; } function envelope(data: unknown): Response { return new Response(JSON.stringify({ success: true, data }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } 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 project 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', '/api/works/learning/projects/project-1/unknown', ])('does not claim old or unrelated 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 bounded pagination and strictly projects project cards', async () => { getAccessToken.mockResolvedValue('works-token'); fetchImpl.mockResolvedValue(envelope({ items: [project()], nextCursor: 'next-page', total: 1, internalFacet: 'drop-me', })); 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/projects?cursor=opaque&limit=24&unknown=no'), {} as never, ); expect(fetchImpl).toHaveBeenCalledWith( 'https://square.example/api/learning/projects?cursor=opaque&limit=24', expect.objectContaining({ method: 'GET', headers: { Accept: 'application/json', Authorization: 'Bearer works-token' }, redirect: 'manual', }), ); expect(response.json).toEqual({ success: true, data: { items: [{ id: 'project-1', name: '机械臂入门', summary: '从零搭建桌面机械臂。', cover: { url: '/api/learning/projects/project-1/media/cover', alt: '机械臂封面', width: 1600, height: 900, }, tags: ['机器人', 'Python'], version: '1.2.0', archiveBytes: 1024, publishedAt: '2026-08-01T00:00:00Z', updatedAt: '2026-08-18T00:00:00Z', }], nextCursor: 'next-page', total: 1, }, }); expect(JSON.stringify(response.json)).not.toContain('objectStorageKey'); }); it('projects detail Markdown and archive integrity metadata without storage keys', async () => { getAccessToken.mockResolvedValue('works-token'); fetchImpl.mockResolvedValue(envelope(project())); 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/projects/project-1'), {} as never, ); expect(response.json).toMatchObject({ success: true, data: { id: 'project-1', readmeMarkdown: '# 机械臂入门', archiveFileName: 'robot-arm.zip', archiveSha256: ARCHIVE_HASH, }, }); expect(JSON.stringify(response.json)).not.toContain('private/project.zip'); }); it('accepts project metadata above the former client archive-size limit', async () => { getAccessToken.mockResolvedValue('works-token'); const archiveBytes = 512 * 1024 * 1024 + 1; fetchImpl.mockResolvedValue(envelope(project({ archiveBytes }))); 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/projects/project-1'), {} as never, ); expect(response.json).toMatchObject({ success: true, data: { id: 'project-1', archiveBytes }, }); }); it('rejects malformed identities and unsafe media URLs in successful DTOs', async () => { getAccessToken.mockResolvedValue('works-token'); fetchImpl.mockResolvedValue(envelope({ items: [project({ cover: { url: 'http://internal.example/cover.png', alt: '不安全' } })], nextCursor: 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/projects'), {} as never, ); expect(response.res.statusCode).toBe(502); expect(response.json).toMatchObject({ code: 'LEARNING_INVALID_RESPONSE' }); }); it('proxies only fixed raster media paths with Works authentication', async () => { getAccessToken.mockResolvedValue('works-token'); fetchImpl.mockResolvedValue(new Response(Uint8Array.from([1, 2, 3]), { status: 200, headers: { 'Content-Type': 'image/webp', 'Content-Length': '3' }, })); 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/projects/project-1/media/readme-1'), {} as never, ); expect(fetchImpl).toHaveBeenCalledWith( 'https://square.example/api/learning/projects/project-1/media/readme-1', expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer works-token' }) }), ); expect(response.json).toEqual({ dataBase64: 'AQID', mimeType: 'image/webp' }); }); it('uses project metadata and a native destination before delegating verified download', async () => { getAccessToken.mockResolvedValue('works-token'); fetchImpl.mockResolvedValue(envelope(project())); const chooseDestination = vi.fn().mockResolvedValue('D:\\Downloads\\robot-arm.zip'); const saveArchive = vi.fn().mockResolvedValue(undefined); const binding = { accountKey: 'b'.repeat(64), epoch: 1 }; const handler = createLearningRouteHandler({ fetchImpl, getAccessToken, getAccountBinding: () => binding, isCurrentAccountBinding: () => true, chooseDestination, saveArchive, 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/projects/project-1/download'), { mainWindow: null } as never, ); expect(chooseDestination).toHaveBeenCalledWith(expect.anything(), 'robot-arm.zip'); expect(saveArchive).toHaveBeenCalledWith(expect.objectContaining({ destinationPath: 'D:\\Downloads\\robot-arm.zip', binding, project: expect.objectContaining({ id: 'project-1', archiveSha256: ARCHIVE_HASH }), })); expect(response.json).toEqual({ success: true, data: { status: 'saved' } }); }); it('does not download when the user cancels the native save dialog', async () => { getAccessToken.mockResolvedValue('works-token'); fetchImpl.mockResolvedValue(envelope(project())); const saveArchive = vi.fn(); const handler = createLearningRouteHandler({ fetchImpl, getAccessToken, getAccountBinding: () => ({ accountKey: 'b'.repeat(64), epoch: 1 }), isCurrentAccountBinding: () => true, chooseDestination: vi.fn().mockResolvedValue(null), saveArchive, }); const response = createResponse(); await handler( { method: 'POST' } as IncomingMessage, response.res, new URL('http://127.0.0.1/api/works/learning/projects/project-1/download'), {} as never, ); expect(saveArchive).not.toHaveBeenCalled(); expect(response.json).toEqual({ success: true, data: { status: 'cancelled' } }); }); it('refreshes one upstream 401 and redacts unavailable-service details', async () => { getAccessToken.mockResolvedValueOnce('expired').mockResolvedValueOnce('fresh'); fetchImpl .mockResolvedValueOnce(new Response('expired secret', { status: 401 })) .mockResolvedValueOnce(envelope({ items: [], nextCursor: 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/projects'), {} as never, ); expect(getAccessToken).toHaveBeenNthCalledWith(2, { fetchImpl, forceRefresh: true }); expect(response.json).toEqual({ success: true, data: { items: [], nextCursor: null } }); }); });