// @vitest-environment node import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createLearningGenerationClient } from '@electron/services/learning-generation-client'; import type { LearningGenerationOptions } from '../../shared/learning'; describe('Learning generation Main client', () => { const fetchImpl = vi.fn(); const getAccessToken = vi.fn(); const options: LearningGenerationOptions = { requirement: '用案例教我二次函数', enableWebSearch: true, enableImageGeneration: false, enableVideoGeneration: false, enableTTS: true, interactiveMode: true, taskEngineMode: false, }; beforeEach(() => { fetchImpl.mockReset(); getAccessToken.mockReset(); getAccessToken.mockResolvedValue('works-token'); fetchImpl.mockResolvedValue(new Response(JSON.stringify({ contractVersion: 2, jobId: 'job-1', status: 'queued', mode: 'single', step: null, progress: null, message: null, scenesGenerated: null, totalScenes: null, courseId: null, error: null, done: false, secret: 'must-not-cross-main', }), { status: 202, headers: { 'Content-Type': 'application/json' } })); }); it('uses canonical JSON when there are no materials', async () => { const client = createLearningGenerationClient({ fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example' }); await expect(client.start({ options, materials: [] })).resolves.not.toHaveProperty('secret'); expect(fetchImpl).toHaveBeenCalledWith( 'https://square.example/api/learning/generations', expect.objectContaining({ method: 'POST', headers: expect.objectContaining({ Authorization: 'Bearer works-token', 'Content-Type': 'application/json', }), body: JSON.stringify(options), }), ); }); it('uses a fixed error and rejects malformed success DTOs', async () => { const client = createLearningGenerationClient({ fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example' }); fetchImpl.mockResolvedValueOnce(new Response(JSON.stringify({ detail: 'postgres at internal-host:5432' }), { status: 500 })); const failure = await client.start({ options, materials: [] }).catch((error: unknown) => error); expect(failure).toEqual(new Error('课程任务创建失败')); fetchImpl.mockResolvedValueOnce(new Response(JSON.stringify({ contractVersion: 2, jobId: 'job-1', status: 'queued', mode: 'single', step: null, progress: 101, message: null, scenesGenerated: null, totalScenes: null, courseId: null, error: null, done: false, }), { status: 202 })); await expect(client.start({ options, materials: [] })).rejects.toThrow('课程任务创建失败'); }); it('uses options JSON plus repeated material parts in selected order', async () => { const client = createLearningGenerationClient({ fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example' }); await client.start({ options, materials: [ { id: 'b', name: '第二份.txt', mimeType: 'text/plain', size: 3, lastModified: 2, order: 2, bytes: new Uint8Array([4, 5, 6]) }, { id: 'a', name: '第一份.md', mimeType: 'text/markdown', size: 3, lastModified: 1, order: 1, bytes: new Uint8Array([1, 2, 3]) }, ], }); const init = fetchImpl.mock.calls[0][1]!; expect(init.headers).toEqual({ Accept: 'application/json', Authorization: 'Bearer works-token' }); expect(init.body).toBeInstanceOf(FormData); const form = init.body as FormData; expect(JSON.parse(String(form.get('options')))).toEqual(options); const materials = form.getAll('materials') as File[]; expect(materials.map((file) => file.name)).toEqual(['第一份.md', '第二份.txt']); expect(Array.from(new Uint8Array(await materials[0].arrayBuffer()))).toEqual([1, 2, 3]); }); it('rejects malformed outer/options DTOs and oversized requirements with one safe error', async () => { const client = createLearningGenerationClient({ fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example' }); const malformed = [ null, {}, { options: null, materials: [] }, { options, materials: null }, { options: { ...options, enableTTS: 'yes' }, materials: [] }, { options: { ...options, requirement: 'x'.repeat(4_001) }, materials: [] }, { options: { ...options, requirement: `${' '.repeat(4_000)}x` }, materials: [] }, ]; for (const input of malformed) { await expect(client.start(input as never)).rejects.toEqual(new Error('课程任务创建失败')); } expect(getAccessToken).not.toHaveBeenCalled(); expect(fetchImpl).not.toHaveBeenCalled(); }); it('strictly validates and bounds every material metadata field before token lookup', async () => { const client = createLearningGenerationClient({ fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example' }); const material = { id: 'material-1', name: 'notes.txt', mimeType: 'text/plain', size: 3, lastModified: 1, order: 1, bytes: new Uint8Array([1, 2, 3]), }; const malformed = [ null, { ...material, id: '' }, { ...material, id: 'contains space' }, { ...material, name: '../notes.txt' }, { ...material, name: 'bad\nname.txt' }, { ...material, mimeType: 'text/plain; charset=utf-8' }, { ...material, size: 0 }, { ...material, size: 2 }, { ...material, size: 1.5 }, { ...material, lastModified: -1 }, { ...material, lastModified: Number.POSITIVE_INFINITY }, { ...material, order: 0 }, { ...material, order: 1.5 }, { ...material, bytes: material.bytes.buffer }, ]; for (const candidate of malformed) { await expect(client.start({ options, materials: [candidate] } as never)) .rejects.toEqual(new Error('课程任务创建失败')); } expect(getAccessToken).not.toHaveBeenCalled(); expect(fetchImpl).not.toHaveBeenCalled(); }); it('rejects duplicate or non-contiguous material order values', async () => { const client = createLearningGenerationClient({ fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example' }); const material = { id: 'material-1', name: 'notes.txt', mimeType: 'text/plain', size: 1, lastModified: 1, order: 1, bytes: new Uint8Array([1]), }; const duplicateId = [material, { ...material, order: 2 }]; const duplicate = [material, { ...material, id: 'material-2', order: 1 }]; const gap = [material, { ...material, id: 'material-2', order: 3 }]; await expect(client.start({ options, materials: duplicateId })).rejects.toThrow('课程任务创建失败'); await expect(client.start({ options, materials: duplicate })).rejects.toThrow('课程任务创建失败'); await expect(client.start({ options, materials: gap })).rejects.toThrow('课程任务创建失败'); expect(getAccessToken).not.toHaveBeenCalled(); expect(fetchImpl).not.toHaveBeenCalled(); }); it('does not send a generation after its captured account changes while token lookup is pending', async () => { let resolveToken!: (token: string) => void; getAccessToken.mockReturnValueOnce(new Promise((resolve) => { resolveToken = resolve; })); let current = true; const client = createLearningGenerationClient({ fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example' }); const result = client.start({ options, materials: [] }, () => { if (!current) throw new Error('account changed'); }); current = false; resolveToken('new-account-token'); await expect(result).rejects.toThrow('account changed'); expect(fetchImpl).not.toHaveBeenCalled(); }); it('does not retry a 401 after the 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 client = createLearningGenerationClient({ fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example' }); const result = client.start({ options, materials: [] }, () => { if (!current) throw new Error('account changed'); }); await vi.waitFor(() => expect(getAccessToken).toHaveBeenCalledTimes(2)); current = false; resolveRefresh('new-account-token'); await expect(result).rejects.toThrow('account changed'); expect(fetchImpl).toHaveBeenCalledOnce(); }); });