merge: integrate remote learning module safely
This commit is contained in:
208
tests/unit/learning-generation-client.test.ts
Normal file
208
tests/unit/learning-generation-client.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
// @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<typeof fetch>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user