56 lines
3.3 KiB
TypeScript
56 lines
3.3 KiB
TypeScript
/** @jest-environment node */
|
|
import { deleteLinkedProject } 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, 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 []
|
|
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('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('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)
|
|
})
|