import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createWorksProject, downloadWorksAssetToProject, fetchCurrentWorksProjectStatus, fetchMyWorksProjectStatus, fetchMyWorksProjects, fetchWorksAsset, fetchWorksAssets, fetchWorksTokenUsage, fetchWorksProjectVersions, fetchWorksProjects, publishWorksProjectSource, 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 billing token usage with the current access token', async () => { const usage = { five_hour_remaining_percent: 72.5, weekly_remaining_percent: 48, plan_code: 'pro', plan_name: '专业版', }; hostApiFetchMock.mockResolvedValueOnce({ success: true, usage }); const result = await fetchWorksTokenUsage('access-token'); expect(result).toEqual(usage); expect(result.plan_code).toBe('pro'); expect(result.plan_name).toBe('专业版'); expect(hostApiFetchMock).toHaveBeenCalledWith( '/api/works/billing/token-usage', { 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('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/'); }); });