Scrum4Me/__tests__/actions/product-docs.test.ts
Madhura68 4539de1fff feat(PBI-96/T-1064): toggleProductDocFolderAction + listProductDocsAction
- toggleProductDocFolderAction: owner-only scope (where: id + user_id,
  NIET productAccessFilter — folder-config is product-setting). Idempotent
  (no-op + success als al in target-staat). Disabled folder verwijdert
  GEEN docs uit DB; alleen flag in enabled_doc_folders. Log met doc_id:null
  + FOLDER_ENABLED/FOLDER_DISABLED.
- listProductDocsAction: read-only, scope via productAccessFilter (zonder
  demo-403 — demo MAG lezen, zie plan §B.4). Geen rate-limit. Select
  zonder content_md. OrderBy [folder, slug]. Mapt DB-enum naar API-string.
- Tests: 10 nieuwe (owner-only-check, idempotent, enable+disable-logs,
  demo-read-OK, folder-filter, ontbreken content_md in select). Totaal
  28 tests in product-docs actions; 1008 tests groen in monorepo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:48:24 +02:00

524 lines
18 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(), update: vi.fn() },
productDoc: {
findFirst: vi.fn(),
findMany: 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,
listProductDocsAction,
toggleProductDocFolderAction,
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' } })
})
})
// ---------------------------------------------------------------------------
// toggleProductDocFolderAction — owner-only check
describe('toggleProductDocFolderAction', () => {
const baseInput = {
product_id: VALID_PRODUCT_ID,
folder: 'api' as const,
enabled: false,
}
it('returnt 401 als niet-ingelogd', async () => {
setSession({ userId: undefined })
const r = await toggleProductDocFolderAction(baseInput)
expect(r).toEqual({ error: 'Niet ingelogd', code: 401 })
})
it('returnt 403 voor demo-user', async () => {
setSession({ isDemo: true })
const r = await toggleProductDocFolderAction(baseInput)
expect(r).toEqual({ error: 'Niet beschikbaar in demo-modus', code: 403 })
})
it('returnt 404 als de user geen owner is (ook niet als ProductMember)', async () => {
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null)
const r = await toggleProductDocFolderAction(baseInput)
expect(r).toEqual({ error: 'Product niet gevonden', code: 404 })
// Check dat de scope owner-only is: where bevat user_id (geen OR met members)
const call = (prisma.product.findFirst as ReturnType<typeof vi.fn>).mock.calls[0][0]
expect(call.where.user_id).toBe('user-1')
})
it('idempotent: target-staat == huidige staat → success zonder DB-write', async () => {
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: VALID_PRODUCT_ID,
enabled_doc_folders: ['ADR', 'API'],
})
// enabled:false maar API zit al uit (niet in array) → no-op
const r = await toggleProductDocFolderAction({ ...baseInput, folder: 'manual', enabled: false })
expect('success' in r && r.success).toBe(true)
expect(prisma.$transaction).not.toHaveBeenCalled()
})
it('disable folder: update + FOLDER_DISABLED-log', async () => {
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: VALID_PRODUCT_ID,
enabled_doc_folders: ['ADR', 'API'],
})
;(prisma.$transaction as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
undefined,
undefined,
])
const r = await toggleProductDocFolderAction({
product_id: VALID_PRODUCT_ID,
folder: 'api',
enabled: false,
})
expect('success' in r && r.success).toBe(true)
expect(prisma.product.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: VALID_PRODUCT_ID },
data: { enabled_doc_folders: ['ADR'] },
}),
)
expect(prisma.productDocLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
type: 'FOLDER_DISABLED',
doc_id: null,
metadata: { folder: 'api' },
}),
}),
)
})
it('enable folder: update + FOLDER_ENABLED-log', async () => {
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: VALID_PRODUCT_ID,
enabled_doc_folders: ['ADR'],
})
;(prisma.$transaction as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
undefined,
undefined,
])
const r = await toggleProductDocFolderAction({
product_id: VALID_PRODUCT_ID,
folder: 'api',
enabled: true,
})
expect('success' in r && r.success).toBe(true)
expect(prisma.productDocLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ type: 'FOLDER_ENABLED' }),
}),
)
})
})
// ---------------------------------------------------------------------------
// listProductDocsAction — read-only; demo MAG lezen
describe('listProductDocsAction', () => {
it('returnt 401 als niet-ingelogd', async () => {
setSession({ userId: undefined })
const r = await listProductDocsAction({ product_id: VALID_PRODUCT_ID })
expect(r).toEqual({ error: 'Niet ingelogd', code: 401 })
})
it('demo MAG lezen (geen 403)', async () => {
setSession({ isDemo: true })
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: VALID_PRODUCT_ID,
user_id: 'other',
enabled_doc_folders: [],
})
;(prisma.productDoc.findMany as ReturnType<typeof vi.fn>).mockResolvedValueOnce([])
const r = await listProductDocsAction({ product_id: VALID_PRODUCT_ID })
expect('success' in r && r.success).toBe(true)
})
it('returnt 404 als product niet toegankelijk', async () => {
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null)
const r = await listProductDocsAction({ product_id: VALID_PRODUCT_ID })
expect(r).toEqual({ error: 'Product niet gevonden', code: 404 })
})
it('returnt gemapte items zonder content_md', async () => {
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: VALID_PRODUCT_ID,
user_id: 'user-1',
enabled_doc_folders: ['RUNBOOKS'],
})
;(prisma.productDoc.findMany as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
{
id: 'd1',
folder: 'RUNBOOKS',
slug: 'deploy',
title: 'Deploy',
status: 'active',
updated_at: new Date('2026-05-16'),
},
])
const r = await listProductDocsAction({ product_id: VALID_PRODUCT_ID, folder: 'runbooks' })
expect('success' in r && r.success).toBe(true)
if ('data' in r && r.data) {
expect(r.data).toHaveLength(1)
expect(r.data[0]).toMatchObject({
id: 'd1',
folder: 'runbooks', // lowercase mapping
slug: 'deploy',
title: 'Deploy',
status: 'active',
})
}
// Check dat folder-filter in de query zit
const call = (prisma.productDoc.findMany as ReturnType<typeof vi.fn>).mock.calls[0][0]
expect(call.where.folder).toBe('RUNBOOKS')
// En dat content_md niet geselecteerd is
expect(call.select.content_md).toBeUndefined()
})
})