gongxiangp2/lib/__tests__/desktop-native-project.test.ts

119 lines
8.7 KiB
TypeScript

/** @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' }])
})