feat(PBI-96/T-1063): deleteProductDocAction with P1-fix (no FK race)
- actions/product-docs.ts: deleteProductDocAction haalt eerst metadata op (folder/slug/title), schrijft dan log met doc_id:null + delete in één $transaction. Geen SetNull-race, geen interactieve tx nodig. - __tests__: 4 nieuwe tests (auth-paden + P1-coverage met expliciete check op doc_id:null, type:'DELETED' en metadata-velden gevuld). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ca301b5792
commit
1d116c44ff
2 changed files with 120 additions and 0 deletions
|
|
@ -36,6 +36,7 @@ import { prisma } from '@/lib/prisma'
|
|||
import { _resetRateLimit } from '@/lib/rate-limit'
|
||||
import {
|
||||
createProductDocAction,
|
||||
deleteProductDocAction,
|
||||
updateProductDocAction,
|
||||
} from '@/actions/product-docs'
|
||||
|
||||
|
|
@ -289,3 +290,66 @@ new body
|
|||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// deleteProductDocAction — P1-review-fix coverage
|
||||
|
||||
describe('deleteProductDocAction', () => {
|
||||
it('returnt 401 als niet-ingelogd', async () => {
|
||||
setSession({ userId: undefined })
|
||||
const r = await deleteProductDocAction('doc-1')
|
||||
expect(r).toEqual({ error: 'Niet ingelogd', code: 401 })
|
||||
})
|
||||
|
||||
it('returnt 403 voor demo-user', async () => {
|
||||
setSession({ isDemo: true })
|
||||
const r = await deleteProductDocAction('doc-1')
|
||||
expect(r).toEqual({ error: 'Niet beschikbaar in demo-modus', code: 403 })
|
||||
})
|
||||
|
||||
it('returnt 404 als doc niet toegankelijk', async () => {
|
||||
;(prisma.productDoc.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null)
|
||||
const r = await deleteProductDocAction('doc-1')
|
||||
expect(r).toEqual({ error: 'Doc niet gevonden', code: 404 })
|
||||
})
|
||||
|
||||
it('P1: log heeft doc_id:null + metadata met folder/slug/title (geen FK-race)', async () => {
|
||||
;(prisma.productDoc.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
id: 'doc-1',
|
||||
product_id: 'product-1',
|
||||
folder: 'RUNBOOKS',
|
||||
slug: 'deploy',
|
||||
title: 'Deploy stappen',
|
||||
})
|
||||
;(prisma.$transaction as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
|
||||
undefined,
|
||||
undefined,
|
||||
])
|
||||
|
||||
const r = await deleteProductDocAction('doc-1')
|
||||
expect('success' in r && r.success).toBe(true)
|
||||
|
||||
// De $transaction krijgt een array met [log, delete] in die volgorde.
|
||||
const txArg = (prisma.$transaction as ReturnType<typeof vi.fn>).mock.calls[0][0]
|
||||
expect(Array.isArray(txArg)).toBe(true)
|
||||
|
||||
// De productDocLog.create call moet doc_id:null hebben + DELETED + metadata
|
||||
expect(prisma.productDocLog.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
product_id: 'product-1',
|
||||
doc_id: null, // P1-fix
|
||||
type: 'DELETED',
|
||||
metadata: expect.objectContaining({
|
||||
folder: 'runbooks',
|
||||
slug: 'deploy',
|
||||
title: 'Deploy stappen',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
// En de delete moet aangeroepen zijn op het juiste id
|
||||
expect(prisma.productDoc.delete).toHaveBeenCalledWith({ where: { id: 'doc-1' } })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -247,3 +247,59 @@ export async function updateProductDocAction(
|
|||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE — verwerkt P1-review-fix (delete-audit FK-race)
|
||||
//
|
||||
// Probleem zoals omschreven in
|
||||
// docs/recommendations/product-docs-storage-system-review-2026-05-16.md (P1):
|
||||
// als de log na de delete met `doc_id:<oldId>` wordt aangemaakt, faalt de
|
||||
// foreign key. Met SetNull-cascade zou de FK 'genezen', maar dat vereist
|
||||
// een interactieve transaction met juiste volgorde.
|
||||
//
|
||||
// Fix: schrijf log met `doc_id: null` VANAF HET BEGIN. Geen FK-race, geen
|
||||
// interactieve tx nodig. Metadata bewaart `folder/slug/title` voor
|
||||
// traceability — de relatie is wel verloren maar de informatie niet.
|
||||
|
||||
export async function deleteProductDocAction(id: string): Promise<ActionResult> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd', code: 401 }
|
||||
if (session.isDemo)
|
||||
return { error: 'Niet beschikbaar in demo-modus', code: 403 }
|
||||
|
||||
const userId = session.userId
|
||||
const existing = await prisma.productDoc.findFirst({
|
||||
where: { id, product: productAccessFilter(userId) },
|
||||
select: {
|
||||
id: true,
|
||||
product_id: true,
|
||||
folder: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
},
|
||||
})
|
||||
if (!existing) return { error: 'Doc niet gevonden', code: 404 }
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.productDocLog.create({
|
||||
data: {
|
||||
product_id: existing.product_id,
|
||||
doc_id: null, // P1-fix: null vanaf het begin
|
||||
actor_user_id: userId,
|
||||
type: 'DELETED',
|
||||
metadata: {
|
||||
folder: productDocFolderToApi(existing.folder),
|
||||
slug: existing.slug,
|
||||
title: existing.title,
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.productDoc.delete({ where: { id } }),
|
||||
])
|
||||
|
||||
const folderApi = productDocFolderToApi(existing.folder)
|
||||
revalidatePath(`/products/${existing.product_id}/docs`)
|
||||
revalidatePath(`/products/${existing.product_id}/docs/${folderApi}`)
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue