Scrum4Me/__tests__/actions/product-docs.test.ts
Madhura68 1d116c44ff 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>
2026-05-16 11:47:09 +02:00

355 lines
12 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockSession } = vi.hoisted(() => ({
mockSession: { userId: 'user-1', isDemo: false } as {
userId: string | undefined
isDemo: boolean
},
}))
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) }))
vi.mock('iron-session', () => ({
getIronSession: vi.fn().mockImplementation(async () => mockSession),
}))
vi.mock('@/lib/session', () => ({
sessionOptions: {
cookieName: 'test',
password: 'test-password-32-chars-minimum-len',
},
}))
vi.mock('@/lib/prisma', () => ({
prisma: {
product: { findFirst: vi.fn() },
productDoc: {
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
productDocLog: { create: vi.fn() },
$transaction: vi.fn(),
},
}))
import { prisma } from '@/lib/prisma'
import { _resetRateLimit } from '@/lib/rate-limit'
import {
createProductDocAction,
deleteProductDocAction,
updateProductDocAction,
} from '@/actions/product-docs'
const VALID_PRODUCT_ID = 'cmohrysyj0000rd17clnjy4tc'
const VALID_DOC_MD = `---
title: "Deploy stappen"
status: draft
---
# Body
stappen
`
function setSession(s: Partial<typeof mockSession>) {
Object.assign(mockSession, s)
}
beforeEach(() => {
setSession({ userId: 'user-1', isDemo: false })
_resetRateLimit()
vi.clearAllMocks()
})
// ---------------------------------------------------------------------------
// createProductDocAction
describe('createProductDocAction', () => {
const baseInput = {
product_id: VALID_PRODUCT_ID,
folder: 'runbooks' as const,
slug: 'deploy',
content_md: VALID_DOC_MD,
}
it('returnt 401 als niet-ingelogd', async () => {
setSession({ userId: undefined })
const r = await createProductDocAction(baseInput)
expect(r).toEqual({ error: 'Niet ingelogd', code: 401 })
})
it('returnt 403 voor demo-user', async () => {
setSession({ isDemo: true })
const r = await createProductDocAction(baseInput)
expect(r).toEqual({ error: 'Niet beschikbaar in demo-modus', code: 403 })
})
it('returnt 422 bij invalide product_id (zod-fail)', async () => {
const r = await createProductDocAction({ ...baseInput, product_id: 'not-a-cuid' })
expect('code' in r && r.code).toBe(422)
})
it('returnt 422 als content_md geen frontmatter heeft (P2-validation)', async () => {
const r = await createProductDocAction({ ...baseInput, content_md: '# alleen body' })
expect('code' in r && r.code).toBe(422)
expect((prisma.productDoc.create as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled()
})
it('returnt 404 als product niet toegankelijk', async () => {
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null)
const r = await createProductDocAction(baseInput)
expect(r).toEqual({ error: 'Product niet gevonden', code: 404 })
})
it('returnt 422 als folder is uitgeschakeld', async () => {
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: VALID_PRODUCT_ID,
user_id: 'user-1',
enabled_doc_folders: ['ADR'],
})
const r = await createProductDocAction(baseInput)
expect('code' in r && r.code).toBe(422)
expect('error' in r && r.error).toMatch(/staat uit/i)
})
it('schrijft title/status uit frontmatter naar de kolommen (P2-create)', async () => {
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: VALID_PRODUCT_ID,
user_id: 'user-1',
enabled_doc_folders: ['RUNBOOKS', 'ADR'],
})
;(prisma.$transaction as ReturnType<typeof vi.fn>).mockImplementationOnce(
async (cb: (tx: typeof prisma) => Promise<unknown>) => {
const tx = {
productDoc: {
create: vi.fn().mockResolvedValue({
id: 'doc-1',
folder: 'RUNBOOKS',
slug: 'deploy',
}),
},
productDocLog: { create: vi.fn().mockResolvedValue({}) },
}
const result = await cb(tx as unknown as typeof prisma)
;(prisma.productDoc.create as ReturnType<typeof vi.fn>).mockImplementation(
tx.productDoc.create,
)
;(prisma.productDocLog.create as ReturnType<typeof vi.fn>).mockImplementation(
tx.productDocLog.create,
)
// Bewaar de tx-mocks zodat de test ze kan inspecteren
;(prisma.productDoc.create as ReturnType<typeof vi.fn> & { calls: unknown[] })
.calls = tx.productDoc.create.mock.calls
return result
},
)
const r = await createProductDocAction({
...baseInput,
content_md: `---\ntitle: "Custom Title"\nstatus: active\n---\n\nbody`,
})
expect('success' in r && r.success).toBe(true)
const txCalls = (prisma.productDoc.create as ReturnType<typeof vi.fn> & {
calls: unknown[]
}).calls
expect(txCalls.length).toBeGreaterThan(0)
const createArg = (txCalls[0] as [{ data: { title: string; status: string } }])[0]
expect(createArg.data.title).toBe('Custom Title')
expect(createArg.data.status).toBe('active')
})
it('overschrijft user-supplied last_updated met today (P2-last_updated)', async () => {
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: VALID_PRODUCT_ID,
user_id: 'user-1',
enabled_doc_folders: ['RUNBOOKS'],
})
let capturedCreateArg: { data: { content_md: string } } | null = null
;(prisma.$transaction as ReturnType<typeof vi.fn>).mockImplementationOnce(
async (cb: (tx: typeof prisma) => Promise<unknown>) => {
const tx = {
productDoc: {
create: vi
.fn()
.mockImplementation(async (arg: { data: { content_md: string } }) => {
capturedCreateArg = arg
return { id: 'doc-1', folder: 'RUNBOOKS', slug: 'deploy' }
}),
},
productDocLog: { create: vi.fn().mockResolvedValue({}) },
}
return cb(tx as unknown as typeof prisma)
},
)
const stale = `---\ntitle: "X"\nstatus: draft\nlast_updated: 2020-01-01\n---\n\nbody`
const r = await createProductDocAction({ ...baseInput, content_md: stale })
expect('success' in r && r.success).toBe(true)
expect(capturedCreateArg).not.toBeNull()
const savedMd = capturedCreateArg!.data.content_md
expect(savedMd).not.toMatch(/2020-01-01/)
expect(savedMd).toMatch(/last_updated:\s*['"]?\d{4}-\d{2}-\d{2}/)
})
it('returnt 422 bij slug-conflict (P2002)', async () => {
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: VALID_PRODUCT_ID,
user_id: 'user-1',
enabled_doc_folders: ['RUNBOOKS'],
})
;(prisma.$transaction as ReturnType<typeof vi.fn>).mockRejectedValueOnce({
code: 'P2002',
})
const r = await createProductDocAction(baseInput)
expect('code' in r && r.code).toBe(422)
expect('error' in r && r.error).toMatch(/bestaat al/i)
})
})
// ---------------------------------------------------------------------------
// updateProductDocAction
describe('updateProductDocAction', () => {
const NEW_MD = `---
title: "Updated"
status: active
---
new body
`
it('returnt 401 als niet-ingelogd', async () => {
setSession({ userId: undefined })
const r = await updateProductDocAction('doc-1', NEW_MD)
expect(r).toEqual({ error: 'Niet ingelogd', code: 401 })
})
it('returnt 403 voor demo-user', async () => {
setSession({ isDemo: true })
const r = await updateProductDocAction('doc-1', NEW_MD)
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 updateProductDocAction('doc-1', NEW_MD)
expect(r).toEqual({ error: 'Doc niet gevonden', code: 404 })
})
it('returnt 422 bij broken frontmatter (zonder DB-write)', async () => {
;(prisma.productDoc.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: 'doc-1',
product_id: 'p',
folder: 'RUNBOOKS',
slug: 'deploy',
status: 'draft',
})
const r = await updateProductDocAction('doc-1', '# alleen body')
expect('code' in r && r.code).toBe(422)
expect(prisma.$transaction).not.toHaveBeenCalled()
})
it('sync titel/status + logt UPDATED met prev/new-status', async () => {
;(prisma.productDoc.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: 'doc-1',
product_id: 'p',
folder: 'RUNBOOKS',
slug: 'deploy',
status: 'draft',
})
;(prisma.$transaction as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
undefined,
undefined,
])
const r = await updateProductDocAction('doc-1', NEW_MD)
expect('success' in r && r.success).toBe(true)
expect(prisma.productDoc.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'doc-1' },
data: expect.objectContaining({
title: 'Updated',
status: 'active',
}),
}),
)
expect(prisma.productDocLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
type: 'UPDATED',
metadata: expect.objectContaining({
prev_status: 'draft',
new_status: 'active',
}),
}),
}),
)
})
})
// ---------------------------------------------------------------------------
// 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' } })
})
})