feat(learning): replace courses with project catalog
This commit is contained in:
@@ -1,78 +1,44 @@
|
||||
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);
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: vi.fn(() => 'C:\\Downloads') },
|
||||
dialog: { showSaveDialog: vi.fn() },
|
||||
}));
|
||||
|
||||
function course(overrides: Record<string, unknown> = {}) {
|
||||
const ARCHIVE_HASH = 'a'.repeat(64);
|
||||
|
||||
function project(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',
|
||||
id: 'project-1',
|
||||
name: '机械臂入门',
|
||||
summary: '从零搭建桌面机械臂。',
|
||||
cover: {
|
||||
url: '/api/learning/projects/project-1/media/cover',
|
||||
alt: '机械臂封面',
|
||||
width: 1600,
|
||||
height: 900,
|
||||
internalKey: 'private/cover.webp',
|
||||
},
|
||||
createdAt: '2026-08-16T00:00:00Z',
|
||||
publishedAt: null,
|
||||
internalPath: 'D:\\secret\\course.zip',
|
||||
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 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 envelope(data: unknown): Response {
|
||||
return new Response(JSON.stringify({ success: true, data }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function createResponse() {
|
||||
@@ -92,7 +58,7 @@ function createResponse() {
|
||||
};
|
||||
}
|
||||
|
||||
describe('Learning Main route boundary', () => {
|
||||
describe('Learning project Main route boundary', () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>();
|
||||
const getAccessToken = vi.fn();
|
||||
|
||||
@@ -103,9 +69,9 @@ describe('Learning Main route boundary', () => {
|
||||
|
||||
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) => {
|
||||
'/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();
|
||||
|
||||
@@ -118,339 +84,200 @@ describe('Learning Main route boundary', () => {
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards catalog reads and strictly projects course DTOs', async () => {
|
||||
it('forwards bounded pagination and strictly projects project cards', async () => {
|
||||
getAccessToken.mockResolvedValue('works-token');
|
||||
fetchImpl.mockResolvedValue(new Response(JSON.stringify([course()]), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
fetchImpl.mockResolvedValue(envelope({
|
||||
items: [project()],
|
||||
nextCursor: 'next-page',
|
||||
total: 1,
|
||||
internalFacet: 'drop-me',
|
||||
}));
|
||||
const handler = createLearningRouteHandler({
|
||||
fetchImpl,
|
||||
getAccessToken,
|
||||
apiBaseUrl: 'https://square.example/',
|
||||
});
|
||||
const handler = createLearningRouteHandler({ fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example/' });
|
||||
const response = createResponse();
|
||||
|
||||
await expect(handler(
|
||||
await handler(
|
||||
{ method: 'GET' } as IncomingMessage,
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/learning/courses?limit=24&unknown=no'),
|
||||
new URL('http://127.0.0.1/api/works/learning/projects?cursor=opaque&limit=24&unknown=no'),
|
||||
{} as never,
|
||||
)).resolves.toBe(true);
|
||||
);
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
'https://square.example/api/learning/courses?limit=24',
|
||||
'https://square.example/api/learning/projects?cursor=opaque&limit=24',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: 'Bearer works-token',
|
||||
},
|
||||
headers: { Accept: 'application/json', Authorization: 'Bearer works-token' },
|
||||
redirect: 'manual',
|
||||
}),
|
||||
);
|
||||
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,
|
||||
}],
|
||||
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('rejects a malformed successful DTO instead of forwarding it', async () => {
|
||||
it('projects detail Markdown and archive integrity metadata without storage keys', async () => {
|
||||
getAccessToken.mockResolvedValue('works-token');
|
||||
fetchImpl.mockResolvedValue(new Response(JSON.stringify([course({ archiveBytes: -1 })]), { status: 200 }));
|
||||
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/courses'),
|
||||
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('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).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 () => {
|
||||
it('proxies only fixed raster media paths with Works authentication', async () => {
|
||||
getAccessToken.mockResolvedValue('works-token');
|
||||
fetchImpl.mockResolvedValue(new Response(JSON.stringify(generation()), { status: 202 }));
|
||||
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 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),
|
||||
{ method: 'GET' } as IncomingMessage,
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/learning/generations'),
|
||||
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/generations',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...options, requirement: '互动讲解二次函数', injected: undefined }),
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer works-token' }),
|
||||
}),
|
||||
'https://square.example/api/learning/projects/project-1/media/readme-1',
|
||||
expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer works-token' }) }),
|
||||
);
|
||||
expect(response.json).toEqual({ success: true, data: generation({ debug: undefined }) });
|
||||
expect(response.json).toEqual({ dataBase64: 'AQID', mimeType: 'image/webp' });
|
||||
});
|
||||
|
||||
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 () => {
|
||||
it('uses project metadata and a native destination before delegating verified download', 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' });
|
||||
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/generations/job-1/${operation}`),
|
||||
{} as never,
|
||||
new URL('http://127.0.0.1/api/works/learning/projects/project-1/download'),
|
||||
{ mainWindow: null } 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');
|
||||
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('redacts upstream and local error details', async () => {
|
||||
it('does not download when the user cancels the native save dialog', 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.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();
|
||||
|
||||
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'),
|
||||
{ method: 'POST' } as IncomingMessage,
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/learning/projects/project-1/download'),
|
||||
{} as never,
|
||||
);
|
||||
expect(JSON.stringify(localResponse.json)).not.toContain('private');
|
||||
expect(localResponse.json).toMatchObject({ code: 'LEARNING_UNAVAILABLE', error: '学习服务暂时不可用' });
|
||||
|
||||
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 } });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user