Files
makelore/tests/unit/learning-route.test.ts
brother7 f7171a471a
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
merge: integrate remote learning module safely
2026-08-17 01:05:49 +08:00

457 lines
15 KiB
TypeScript

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<string, unknown> = {}) {
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<string, unknown> = {}) {
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<string, unknown> = {}) {
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<string, unknown>;
},
};
}
describe('Learning Main route boundary', () => {
const fetchImpl = vi.fn<typeof fetch>();
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: '学习服务暂时不可用' });
});
});