Files
makelore/tests/unit/works-square.test.ts

569 lines
17 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
createWorksProject,
downloadWorksAssetToProject,
fetchCurrentWorksProjectStatus,
fetchMyWorksProjectStatus,
fetchMyWorksProjects,
fetchWorksAsset,
fetchWorksAssets,
fetchWorksResetCards,
fetchWorksTokenPointBalance,
fetchWorksProjectVersions,
fetchWorksProjects,
publishWorksProjectSource,
redeemWorksResetCard,
toPlazaCard,
WorksSquareApiError,
type ProjectPublic,
} from '@/lib/works-square';
const hostApiFetchMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/host-api', () => ({
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
}));
describe('works square client', () => {
beforeEach(() => {
hostApiFetchMock.mockReset();
});
it('loads public projects through the host api proxy', async () => {
const page = {
items: [
{
app_id: 'space-cleaner',
title: '太空清洁队',
summary: '收集漂浮垃圾的小游戏。',
cover_url: null,
category: 'game',
age_band: '8-12',
difficulty: 'beginner',
updated_at: '2026-06-20T22:55:37.790408+08:00',
},
],
next_cursor: null,
limit: 12,
};
hostApiFetchMock.mockResolvedValueOnce({ success: true, page });
const result = await fetchWorksProjects({ q: 'space game', category: 'game', limit: 12 });
expect(result).toEqual(page);
expect(hostApiFetchMock).toHaveBeenCalledWith(
'/api/works/projects?q=space+game&category=game&limit=12',
);
});
it('loads public assets through the host api proxy', async () => {
const page = {
items: [
{
slug: 'tiny-farm',
title: 'Tiny Farm',
summary: 'Farm pixel resource package',
category: '2D',
source: 'Kenney',
source_url: 'https://kenney.nl/assets/tiny-farm',
license: 'CC0 1.0 Universal',
license_url: 'https://creativecommons.org/publicdomain/zero/1.0/',
series: 'Tiny',
tags: ['farm', 'pixel'],
preview_url: 'https://example.com/tiny-farm.png',
image_urls: ['https://example.com/tiny-farm.png'],
archive_size_bytes: 204800,
extracted_file_count: 141,
published_at: '2026-07-09T00:00:00Z',
updated_at: '2026-07-09T08:30:00Z',
},
],
next_cursor: null,
limit: 12,
};
hostApiFetchMock.mockResolvedValueOnce({ success: true, assets: page });
const result = await fetchWorksAssets({
q: 'farm pack',
category: '2D',
tag: 'pixel',
source: 'Kenney',
limit: 12,
});
expect(result).toEqual(page);
expect(hostApiFetchMock).toHaveBeenCalledWith(
'/api/works/assets?q=farm+pack&category=2D&tag=pixel&source=Kenney&limit=12',
);
});
it('loads a public asset detail by slug', async () => {
const asset = {
slug: 'tiny-farm',
title: 'Tiny Farm',
summary: 'Farm pixel resource package',
category: '2D',
source: 'Kenney',
tags: ['farm'],
preview_url: null,
image_urls: [],
archive_size_bytes: null,
extracted_file_count: null,
published_at: null,
updated_at: null,
};
hostApiFetchMock.mockResolvedValueOnce({ success: true, asset });
const result = await fetchWorksAsset('tiny farm');
expect(result).toEqual(asset);
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/assets/tiny%20farm');
});
it('downloads a public asset into a selected project', async () => {
const download = {
slug: 'tiny-farm',
filePath: 'D:/repo/game/assets/resource-square/tiny-farm.zip',
relativePath: 'assets/resource-square/tiny-farm.zip',
bytesWritten: 11,
};
hostApiFetchMock.mockResolvedValueOnce({ success: true, download });
const result = await downloadWorksAssetToProject({
slug: 'tiny farm',
projectId: 'prj_123',
});
expect(result).toEqual(download);
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/assets/tiny%20farm/download', {
method: 'POST',
body: JSON.stringify({ projectId: 'prj_123' }),
});
});
it('creates project metadata with the current access token', async () => {
const project = {
app_id: 'space-cleaner',
title: '太空清洁队',
summary: '收集漂浮垃圾的小游戏。',
cover_url: null,
category: 'game',
age_band: '8-12',
difficulty: 'beginner',
};
hostApiFetchMock.mockResolvedValueOnce({ success: true, project });
await createWorksProject('access-token', project);
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/projects', {
method: 'POST',
body: JSON.stringify({ accessToken: 'access-token', project }),
});
});
it('throws api errors with upstream status for project metadata creation failures', async () => {
hostApiFetchMock.mockResolvedValueOnce({
success: false,
status: 409,
error: 'App ID already exists',
});
await expect(createWorksProject('access-token', {
app_id: 'space-cleaner',
title: 'Space Cleaner',
summary: 'Catch space trash',
})).rejects.toMatchObject({
name: 'WorksSquareApiError',
statusCode: 409,
message: 'App ID already exists',
});
});
it('loads the current user projects with the current access token', async () => {
const page = {
items: [
{
app_id: 'space-cleaner',
title: 'Space Cleaner',
summary: 'Catch space trash',
cover_url: null,
category: 'game',
age_band: '8-12',
difficulty: 'beginner',
status: 'draft',
updated_at: '2026-06-20T22:55:37.790408+08:00',
playable: false,
runtime_url: null,
},
],
next_cursor: null,
limit: 50,
};
hostApiFetchMock.mockResolvedValueOnce({ success: true, page });
const result = await fetchMyWorksProjects('access-token', { limit: 50 });
expect(result).toEqual(page);
expect(hostApiFetchMock).toHaveBeenCalledWith(
'/api/works/projects/mine?limit=50',
{
headers: {
'X-NianCode-Access-Token': 'access-token',
},
},
);
});
it('loads the V2 token point balance with the current access token', async () => {
const points = {
plan_code: 'mastery',
plan_name: '精通',
cycle_start: '2026-09-01T00:00:00Z',
cycle_end: '2026-09-08T00:00:00Z',
next_refresh_at: '2026-09-08T00:00:00Z',
weekly_allowance: '500.00',
weekly_used: '100.00',
weekly_reserved: '25.00',
weekly_remaining: '375.00',
permanent_total: '50.00',
permanent_used: '0.00',
permanent_reserved: '0.00',
permanent_remaining: '50.00',
total_remaining: '425.00',
entitlement_source: 'self',
family_shared: false,
can_manage_membership: true,
upgrade_action: 'self_service',
shared_available: null,
};
hostApiFetchMock.mockResolvedValueOnce({ success: true, points });
const result = await fetchWorksTokenPointBalance('access-token');
expect(result).toEqual(points);
expect(result.plan_code).toBe('mastery');
expect(result.plan_name).toBe('精通');
expect(hostApiFetchMock).toHaveBeenCalledWith(
'/api/works/billing/points',
{
headers: {
'X-NianCode-Access-Token': 'access-token',
},
},
);
});
it('lists and redeems granted reset cards through Main-owned billing routes', async () => {
const availableCard = {
id: 'card one',
status: 'available' as const,
granted_at: '2026-09-08T00:00:00Z',
expires_at: '2099-09-15T00:00:00Z',
redeemed_at: null,
redeemed_cycle_id: null,
};
const redeemedCard = {
...availableCard,
status: 'redeemed' as const,
redeemed_at: '2026-09-09T00:00:00Z',
redeemed_cycle_id: 'cycle-new',
};
hostApiFetchMock
.mockResolvedValueOnce({ success: true, cards: [availableCard] })
.mockResolvedValueOnce({ success: true, card: redeemedCard });
await expect(fetchWorksResetCards('access-token')).resolves.toEqual([availableCard]);
await expect(redeemWorksResetCard('access-token', availableCard.id)).resolves.toEqual(redeemedCard);
expect(hostApiFetchMock).toHaveBeenNthCalledWith(
1,
'/api/works/billing/reset-cards',
{ headers: { 'X-NianCode-Access-Token': 'access-token' } },
);
expect(hostApiFetchMock).toHaveBeenNthCalledWith(
2,
'/api/works/billing/reset-cards/card%20one/redeem',
{
method: 'POST',
headers: { 'X-NianCode-Access-Token': 'access-token' },
},
);
});
it('loads the current user project status with versions', async () => {
const status = {
project: {
app_id: 'space-cleaner',
title: 'Space Cleaner',
summary: 'Catch space trash',
cover_url: null,
category: 'game',
age_band: '8-12',
difficulty: 'beginner',
status: 'draft',
updated_at: '2026-06-20T22:55:37.790408+08:00',
playable: false,
runtime_url: null,
},
latest_version: {
id: 'version-new',
version_name: 'v1.1.0',
review_status: 'building',
change_log: 'Add score board',
build_job_id: 'job-new',
created_at: '2026-06-21T10:30:00+08:00',
},
versions: [],
};
hostApiFetchMock.mockResolvedValueOnce({ success: true, status });
const result = await fetchMyWorksProjectStatus('access-token', 'space cleaner');
expect(result).toEqual(status);
expect(hostApiFetchMock).toHaveBeenCalledWith(
'/api/works/projects/mine/space%20cleaner/status',
{
headers: {
'X-NianCode-Access-Token': 'access-token',
},
},
);
});
it('loads the current project status without exposing a Works Square token', async () => {
const status = {
project: {
app_id: 'space-cleaner',
title: 'Space Cleaner',
summary: 'Catch space trash',
},
latest_version: {
id: 'version-new',
version_name: 'v1.1.0',
review_status: 'building',
change_log: '通过 Makelore 一键提交',
build_job_id: 'job-new',
build_status: 'failed',
build_error_code: 'BUILD_COMMAND_FAILED',
release_id: null,
created_at: '2026-08-07T10:30:00+08:00',
},
versions: [],
};
hostApiFetchMock.mockResolvedValueOnce({ success: true, status });
await expect(fetchCurrentWorksProjectStatus('space cleaner')).resolves.toEqual(status);
expect(hostApiFetchMock).toHaveBeenCalledWith(
'/api/works/projects/mine/space%20cleaner/status',
);
});
it('exposes an api error class for status-based recovery', () => {
const error = new WorksSquareApiError('Conflict', 409);
expect(error.name).toBe('WorksSquareApiError');
expect(error.statusCode).toBe(409);
expect(error.message).toBe('Conflict');
});
it('asks Main to package and submit source without renderer-owned secrets or archive fields', async () => {
const publishResult = {
package: {
archiveName: 'project.zip',
sha256: 'a'.repeat(64),
fileCount: 18,
sourceBytes: 42_000,
archiveBytes: 12_000,
excludedCount: 3,
excludedPaths: ['.env', 'dist/', 'node_modules/'],
manifest: {
schema_version: 1,
kind: 'web',
runtime: 'static',
build: {
preset: 'vite',
package_manager: 'npm',
entry: 'index.html',
},
},
},
upload: {
version_id: 'version-1',
review_status: 'building',
build_job_id: 'job-1',
build_status: 'queued',
},
binding_warning: {
code: 'LOCAL_PREVIEW_BINDING_SAVE_FAILED',
message: '已提交云端,但本机预览绑定保存失败;可重新打开项目/重新提交。',
},
};
hostApiFetchMock.mockResolvedValueOnce({ success: true, ...publishResult });
const result = await publishWorksProjectSource({
projectId: 'project-1',
project: {
app_id: 'space-cleaner',
title: '太空清洁队',
summary: '收集漂浮垃圾的小游戏。',
category: 'game',
},
});
expect(result).toEqual({
package: publishResult.package,
upload: publishResult.upload,
bindingWarning: publishResult.binding_warning,
});
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/projects/publish-source', {
method: 'POST',
body: JSON.stringify({
projectId: 'project-1',
project: {
app_id: 'space-cleaner',
title: '太空清洁队',
summary: '收集漂浮垃圾的小游戏。',
category: 'game',
},
}),
});
expect(hostApiFetchMock.mock.calls[0]?.[1]?.body).not.toContain('accessToken');
expect(hostApiFetchMock.mock.calls[0]?.[1]?.body).not.toContain('zipFilePath');
expect(hostApiFetchMock.mock.calls[0]?.[1]?.body).not.toContain('versionName');
});
it('keeps the safe Main error code for understandable publish failures', async () => {
hostApiFetchMock.mockResolvedValueOnce({
success: false,
status: 400,
code: 'PROJECT_FILE_MISSING',
error: '项目根目录缺少 package-lock.json',
});
await expect(publishWorksProjectSource({
projectId: 'project-1',
project: {
app_id: 'space-cleaner',
title: '太空清洁队',
summary: '收集漂浮垃圾的小游戏。',
},
})).rejects.toMatchObject({
name: 'WorksSquareApiError',
code: 'PROJECT_FILE_MISSING',
statusCode: 400,
message: '项目根目录缺少 package-lock.json',
});
});
it('forwards the project cover as part of the source publish request', async () => {
hostApiFetchMock.mockResolvedValueOnce({
success: true,
package: {},
upload: { version_id: 'version-1', review_status: 'building' },
});
const cover = {
fileName: 'cover.webp',
mimeType: 'image/webp',
dataBase64: 'Y292ZXI=',
};
await publishWorksProjectSource({
projectId: 'project-1',
project: {
app_id: 'space-cleaner',
title: '太空清洁队',
summary: '收集漂浮垃圾的小游戏。',
creator_name: '小明',
creator_age: 12,
},
cover,
});
const requestBody = JSON.parse(String(hostApiFetchMock.mock.calls[0]?.[1]?.body));
expect(requestBody.cover).toEqual(cover);
expect(requestBody.project.creator_age).toBe(12);
});
it('loads uploaded versions with the current access token', async () => {
hostApiFetchMock.mockResolvedValueOnce({
success: true,
versions: {
items: [
{
id: 'ver_1',
version_name: 'v1.0.0',
review_status: 'building',
change_log: 'First submitted version',
build_job_id: 'job_1',
created_at: '2026-06-21T10:30:00+08:00',
},
],
},
});
const result = await fetchWorksProjectVersions('access-token', 'space cleaner');
expect(result).toEqual({
items: [
{
id: 'ver_1',
version_name: 'v1.0.0',
review_status: 'building',
change_log: 'First submitted version',
build_job_id: 'job_1',
created_at: '2026-06-21T10:30:00+08:00',
},
],
});
expect(hostApiFetchMock).toHaveBeenCalledWith(
'/api/works/projects/space%20cleaner/versions',
{
headers: {
'X-NianCode-Access-Token': 'access-token',
},
},
);
});
it('maps api projects to gallery cards', () => {
const project: ProjectPublic = {
app_id: 'space-cleaner',
title: '太空清洁队',
summary: '收集漂浮垃圾的小游戏。',
cover_url: 'https://example.com/cover.png',
category: 'game',
age_band: '8-12',
difficulty: 'beginner',
updated_at: '2026-06-20T22:55:37.790408+08:00',
playable: true,
play_url: '/apps/space-cleaner/',
runtime_url: '/apps/legacy-space-cleaner/',
};
expect(toPlazaCard(project)).toEqual({
id: 'space-cleaner',
title: '太空清洁队',
summary: '收集漂浮垃圾的小游戏。',
coverImage: 'https://example.com/cover.png',
type: 'game',
ageBand: '8-12',
difficulty: 'beginner',
updatedAt: '2026-06-20T22:55:37.790408+08:00',
playable: true,
runtimeUrl: '/apps/space-cleaner/',
});
});
it('keeps runtime_url as a compatibility fallback for one release', () => {
const project: ProjectPublic = {
app_id: 'legacy-game',
title: 'Legacy Game',
summary: 'Compatibility fixture',
playable: true,
runtime_url: '/apps/legacy-game/',
};
expect(toPlazaCard(project).runtimeUrl).toBe('/apps/legacy-game/');
});
});