/** @jest-environment node */ import { deleteLinkedProject, projectDeletionSummaries } from '@/lib/desktop-client/project-lifecycle' import { assertDetailProjectIdle } from '@/lib/detail-generator/repository' jest.mock('@/lib/detail-generator/repository', () => ({ detailTransactionLock: jest.fn(async () => {}), assertDetailProjectIdle: jest.fn(async () => {}) })) function fixture({ unknown = false, webUnknown = false, conversation = [] as any[], revision = 3, owner = true, deleted = false } = {}) { const writes: any[][] = [] const tx = { $queryRawUnsafe: jest.fn(async (sql: string, ...params: any[]) => { expect(params).toContain('owner') if (sql.includes('SELECT id,revision')) return owner ? [{ id: 'desktop-one', revision, sourceWebId: 'web-one', deleted }] : [] if (sql.includes('SELECT id FROM desktop_workspace_projects')) return [{ id: 'desktop-one' }] if (sql.includes('FOR UPDATE')) return [{ revision, deleted, conversation }] if (sql.includes('desktop_creation_tasks')) return unknown ? [{ id: 'unknown-request' }] : [] if (sql.includes('detail_generator_jobs')) return webUnknown ? [{ id: 'original-web-request' }] : [] throw new Error(sql) }), $executeRawUnsafe: jest.fn(async (...args: any[]) => { writes.push(args); return 1 }), } const db = { $transaction: async (fn: (value: typeof tx) => Promise) => fn(tx) } return { db, tx, writes } } beforeEach(() => jest.clearAllMocks()) test('one bounded read-only summary query covers the whole page without requesting task payloads', async () => { const rows = [{ id: 'web-one', source: 'web', deleteBlocked: true, deleteBlockedReason: 'pending_request' }] const db = { $queryRawUnsafe: jest.fn().mockResolvedValue(rows) } const refs = [{ id: 'web-one', source: 'web' as const }, { id: 'desktop-one', source: 'desktop' as const }] expect(await projectDeletionSummaries(db, 'owner', refs)).toEqual(rows) expect(db.$queryRawUnsafe).toHaveBeenCalledTimes(1) const [sql, owner, requested] = db.$queryRawUnsafe.mock.calls[0] expect(owner).toBe('owner'); expect(JSON.parse(requested)).toEqual(refs) expect(sql).toContain('p.source_web_id=s.web_id AND p.owner_id=$1') expect(sql).toContain("j.status IN ('queued','running','unknown')") expect(sql).toContain("j.status IN ('queued','running','received','unknown')") expect(sql).toContain('j.lease_until>NOW()') expect(sql).toContain("item->'pending'") expect(sql).toContain("item->'unknown'") expect(sql).not.toMatch(/UPDATE |DELETE |INSERT |payload|request_id|lease_hash/i) }) test('empty and oversized lists cannot create unbounded deletion-summary work', async () => { const db = { $queryRawUnsafe: jest.fn() } expect(await projectDeletionSummaries(db, 'owner', [])).toEqual([]) await expect(projectDeletionSummaries(db, 'owner', Array.from({ length: 51 }, (_, n) => ({ id: 'project-'+n, source: 'web' as const })))).rejects.toThrow('分页') expect(db.$queryRawUnsafe).not.toHaveBeenCalled() }) test('deletion atomically covers a web project and its desktop continuation under the same owner', async () => { const f = fixture() const result = await deleteLinkedProject(f.db, 'owner', 'desktop', 'desktop-one', { revision: 3 }) expect(result.deletedProjects).toEqual(expect.arrayContaining([{ id: 'web-one', source: 'web' }, { id: 'desktop-one', source: 'desktop' }])) expect(f.writes).toHaveLength(2) for (const [sql, , owner] of f.writes) { expect(sql).toContain('owner_id=$2 AND deleted_at IS NULL'); expect(owner).toBe('owner') } }) test('a stale confirmation never deletes either linked record', async () => { const f = fixture({ revision: 4 }) await expect(deleteLinkedProject(f.db, 'owner', 'desktop', 'desktop-one', { revision: 3 })).rejects.toMatchObject({ status: 409, code: 'revision_conflict' }) expect(f.writes).toHaveLength(0) }) test('an unknown request blocks the entire deletion without modifying task identities', async () => { const f = fixture({ unknown: true }) await expect(deleteLinkedProject(f.db, 'owner', 'desktop', 'desktop-one', { revision: 3 })).rejects.toMatchObject({ code: 'task_busy' }) expect(f.writes).toHaveLength(0) }) test.each([ { pending: true }, { unknown: true }, ])('an unresolved conversation blocks deletion even when the queue is empty: %j', async marker => { const original = { id: 'original-request', role: 'assistant', text: '原结果待核查', requestId: 'upstream-original', ...marker } const f = fixture({ conversation: [original] }) await expect(deleteLinkedProject(f.db, 'owner', 'desktop', 'desktop-one', { revision: 3 })).rejects.toMatchObject({ status: 409, code: 'task_busy' }) expect(f.writes).toHaveLength(0) expect(original).toEqual({ id: 'original-request', role: 'assistant', text: '原结果待核查', requestId: 'upstream-original', ...marker }) }) test('a historical successful or explicitly failed conversation does not block an otherwise idle project', async () => { const f = fixture({ conversation: [ { id: 'complete-original', role: 'assistant', text: '已完成', pending: false, unknown: false }, { id: 'failed-original', role: 'assistant', text: '已明确失败', error: true }, ] }) await expect(deleteLinkedProject(f.db, 'owner', 'desktop', 'desktop-one', { revision: 3 })).resolves.toMatchObject({ deleted: true }) expect(f.writes).toHaveLength(2) }) test('an old web unknown request still protects both linked projects, without clearing history', async () => { const f = fixture({ webUnknown: true }) await expect(deleteLinkedProject(f.db, 'owner', 'desktop', 'desktop-one', { revision: 3 })).rejects.toMatchObject({ status: 409, code: 'task_busy' }) expect(f.writes).toHaveLength(0) }) test('missing ownership, invalid source, and invalid revision cannot delete data', async () => { const f = fixture({ owner: false }) await expect(deleteLinkedProject(f.db, 'owner', 'desktop', 'desktop-one', { revision: 3 })).rejects.toMatchObject({ status: 404 }) await expect(deleteLinkedProject(f.db, 'owner', 'other', 'desktop-one', { revision: 3 })).rejects.toThrow() await expect(deleteLinkedProject(f.db, 'owner', 'desktop', 'desktop-one', { revision: 0 })).rejects.toThrow() expect(f.writes).toHaveLength(0) }) test('web unknown-job checks run before any linked record is written', async () => { const f = fixture() ;(assertDetailProjectIdle as jest.Mock).mockRejectedValueOnce(new Error('运行中')) await expect(deleteLinkedProject(f.db, 'owner', 'desktop', 'desktop-one', { revision: 3 })).rejects.toThrow('运行中') expect(f.writes).toHaveLength(0) })