101 lines
6.5 KiB
TypeScript
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)
|
|
})
|