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

101 lines
6.5 KiB
TypeScript

/** @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<any>) => 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)
})