304 lines
10 KiB
TypeScript
304 lines
10 KiB
TypeScript
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<string, unknown> = {}) {
|
|
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<string, unknown>;
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('Learning project 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',
|
|
'/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 } });
|
|
});
|
|
});
|