/** @jest-environment node */ import { prisma } from '@/lib/prisma' import { listDesktopProjects, putDesktopProject } from '@/lib/desktop-client/service' jest.mock('@/lib/prisma', () => ({ prisma: { $queryRawUnsafe: jest.fn(), $transaction: jest.fn() } })) jest.mock('@/lib/detail-generator/access', () => ({ assertDetailGeneratorUser: jest.fn() })) jest.mock('@/lib/detail-generator/repository', () => ({ findDetailProject: jest.fn(), listDetailAssets: jest.fn(), findDetailAsset: jest.fn() })) jest.mock('@/lib/detail-generator/storage', () => ({ readDetailFile: jest.fn(), saveDetailFile: jest.fn(), deleteDetailFile: jest.fn() })) const hash = 'a'.repeat(64), source = 'native:' + hash function body() { return { revision: 2, status: 'completed', document: { version: 3, id: 'project', name: '顾家详情', native: { format: 'photocraft', schemaVersion: 1, documentSource: source, files: ['canvas.pcraft','context.json','session-backup.json'].map(path => ({ path, source, sha256: hash, size: 100 })) }, screens: [{ id: 'hero', width: 1440, height: 2200, previewSource: 'local:preview' }] } } } let available: any[] = [], accepted = true, unfinished: any[] = [], storedConversation: any[] = [] beforeEach(() => { jest.clearAllMocks(); accepted = true; unfinished = []; storedConversation = [] available = [{ source, sha256: hash, bytes: 100, mime: 'application/x-photocraft-resource' }, { source: 'local:preview', mime: 'image/png', bytes: 100 }] ;(prisma.$transaction as jest.Mock).mockImplementation(async action => action(prisma)) ;(prisma.$queryRawUnsafe as jest.Mock).mockImplementation(async (sql: string, ...args: any[]) => { if (sql.includes('pg_advisory')) return [] if (sql.startsWith('SELECT document')) return accepted ? [{ document: { version: 3 }, conversation: storedConversation }] : [] if (sql.startsWith('SELECT id FROM desktop_creation_tasks')) { expect(args).toEqual(['project','owner']); return unfinished } if (sql.startsWith('SELECT source')) { expect(args).toEqual(['project','owner']); return available } if (sql.startsWith('UPDATE desktop_workspace_projects')) { expect(sql).toContain('deleted_at IS NULL'); expect(args.slice(0,3)).toEqual(['project','owner',2]); return accepted ? [{ id: 'project', revision: 3 }] : [] } throw new Error(sql) }) }) test('atomically publishes a complete native snapshot only after every same-owner file and preview exists', async () => { expect(await putDesktopProject('owner','project',body())).toMatchObject({ project: { revision: 3 } }) expect((prisma.$queryRawUnsafe as jest.Mock).mock.calls.filter(([sql]) => sql.startsWith('UPDATE'))).toHaveLength(1) }) test('incomplete upload or wrong digest cannot replace a cloud snapshot', async () => { available[0].sha256 = 'b'.repeat(64) await expect(putDesktopProject('owner','project',body())).rejects.toMatchObject({ code: 'assets_incomplete' }) expect((prisma.$queryRawUnsafe as jest.Mock).mock.calls.some(([sql]) => sql.startsWith('UPDATE'))).toBe(false) }) test('a stale revision or deletion remains a conflict and never recreates a project', async () => { accepted = false await expect(putDesktopProject('owner','project',body())).rejects.toMatchObject({ code: 'revision_conflict' }) expect((prisma.$queryRawUnsafe as jest.Mock).mock.calls.some(([sql]) => sql.startsWith('INSERT'))).toBe(false) }) test('completion serializes with task admission before checking the same-owner durable queue and writing', async () => { await putDesktopProject('owner','project',body()) const calls = (prisma.$queryRawUnsafe as jest.Mock).mock.calls expect(calls[0][1]).toBe('desktop-tasks:owner') expect(calls[1][1]).toBe('desktop:project') const taskCheck = calls.findIndex(([sql]) => sql.startsWith('SELECT id FROM desktop_creation_tasks')) const update = calls.findIndex(([sql]) => sql.startsWith('UPDATE desktop_workspace_projects')) expect(taskCheck).toBeGreaterThan(1); expect(update).toBeGreaterThan(taskCheck) expect(calls[taskCheck][0]).toContain("status IN ('queued','running','received','unknown') OR lease_until>NOW()") expect(prisma.$transaction).toHaveBeenCalledTimes(1) }) test.each(['queued','running','received','unknown','leased recovery'])('a durable %s request blocks completion even if the client omits its summary', async status => { unfinished = [{ id: 'original-request', status }] await expect(putDesktopProject('owner','project',body())).rejects.toMatchObject({ status: 409, code: 'task_busy' }) expect((prisma.$queryRawUnsafe as jest.Mock).mock.calls.some(([sql]) => sql.startsWith('UPDATE') || sql.startsWith('INSERT'))).toBe(false) expect(unfinished).toEqual([{ id: 'original-request', status }]) }) test('pending or unknown incoming and persisted summaries cannot be erased while marking completed', async () => { for (const field of ['pending','unknown']) { const summary = { id: 'original-request', role: 'assistant', text: '结果待核查', [field]: true } await expect(putDesktopProject('owner','project',{ ...body(), conversation: [summary] })).rejects.toMatchObject({ code: 'task_busy' }) storedConversation = [summary] await expect(putDesktopProject('owner','project',body())).rejects.toMatchObject({ code: 'task_busy' }) storedConversation = [] } expect((prisma.$queryRawUnsafe as jest.Mock).mock.calls.some(([sql]) => sql.startsWith('UPDATE') || sql.startsWith('INSERT'))).toBe(false) }) test('unknown work can still sync as a draft with its exact original identity and without task mutations', async () => { unfinished = [{ id: 'original-request', status: 'unknown' }] storedConversation = [{ id: 'original-request', role: 'assistant', text: '结果待核查', unknown: true }] await expect(putDesktopProject('owner','project',{ ...body(), status: 'draft', conversation: storedConversation })).resolves.toMatchObject({ project: { revision: 3 } }) const calls = (prisma.$queryRawUnsafe as jest.Mock).mock.calls const update = calls.find(([sql]) => sql.startsWith('UPDATE desktop_workspace_projects'))! expect(JSON.parse(update[5])).toEqual(storedConversation); expect(update[6]).toBe('draft') expect(calls.some(([sql]) => sql.includes('desktop_creation_tasks'))).toBe(false) }) test('a foreign or deleted project is rejected before consulting task state or publishing completion', async () => { accepted = false await expect(putDesktopProject('other-owner','project',body())).rejects.toMatchObject({ status: 409, code: 'revision_conflict' }) const calls = (prisma.$queryRawUnsafe as jest.Mock).mock.calls expect(calls[0][1]).toBe('desktop-tasks:other-owner') expect(calls.find(([sql]) => sql.startsWith('SELECT document'))?.slice(1)).toEqual(['project','other-owner',2]) expect(calls.some(([sql]) => sql.includes('desktop_creation_tasks') || sql.startsWith('UPDATE'))).toBe(false) }) test('linked web tombstones are not hidden by their deleted desktop continuation', async () => { ;(prisma.$queryRawUnsafe as jest.Mock).mockImplementation(async (sql: string) => { expect(sql).toContain('p.deleted_at IS NOT NULL OR NOT EXISTS') return [{ id: 'web-old', source: 'web', deletedAt: new Date(), updatedAt: new Date() }, { id: 'native-old', source: 'desktop', deletedAt: new Date(), updatedAt: new Date() }] }) expect(await listDesktopProjects('owner', null)).toMatchObject({ items: [], deleted: [{ id: 'web-old', source: 'web' }, { id: 'native-old', source: 'desktop' }] }) }) test('the list exposes safe deletion reasons in one batch and keeps tombstones out of that batch', async () => { const updatedAt = new Date('2026-10-23T00:00:00Z') ;(prisma.$queryRawUnsafe as jest.Mock) .mockResolvedValueOnce([ { id: 'web-pending', source: 'web', name: '旧验收', revision: 3, updatedAt }, { id: 'native-idle', source: 'desktop', name: '正常项目', revision: 2, updatedAt }, { id: 'web-old', source: 'web', deletedAt: updatedAt, updatedAt }, ]) .mockResolvedValueOnce([ { id: 'web-pending', source: 'web', deleteBlocked: true, deleteBlockedReason: 'pending_request' }, { id: 'native-idle', source: 'desktop', deleteBlocked: false, deleteBlockedReason: null }, ]) const value = await listDesktopProjects('owner', null) expect(value.items).toEqual(expect.arrayContaining([ expect.objectContaining({ id: 'web-pending', revision: 3, deleteBlocked: true, deleteBlockedReason: 'pending_request' }), expect.objectContaining({ id: 'native-idle', deleteBlocked: false, deleteBlockedReason: null }), ])) expect(value.deleted).toEqual([{ id: 'web-old', source: 'web' }]) const calls = (prisma.$queryRawUnsafe as jest.Mock).mock.calls expect(calls).toHaveLength(2) expect(calls[1][1]).toBe('owner') expect(JSON.parse(calls[1][2])).toEqual([{ id: 'web-pending', source: 'web' }, { id: 'native-idle', source: 'desktop' }]) })