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>
This commit is contained in:
parent
1d116c44ff
commit
4539de1fff
2 changed files with 292 additions and 1 deletions
|
|
@ -20,9 +20,10 @@ vi.mock('@/lib/session', () => ({
|
|||
}))
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
product: { findFirst: vi.fn() },
|
||||
product: { findFirst: vi.fn(), update: vi.fn() },
|
||||
productDoc: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
|
|
@ -37,6 +38,8 @@ import { _resetRateLimit } from '@/lib/rate-limit'
|
|||
import {
|
||||
createProductDocAction,
|
||||
deleteProductDocAction,
|
||||
listProductDocsAction,
|
||||
toggleProductDocFolderAction,
|
||||
updateProductDocAction,
|
||||
} from '@/actions/product-docs'
|
||||
|
||||
|
|
@ -353,3 +356,169 @@ describe('deleteProductDocAction', () => {
|
|||
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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -30,8 +30,10 @@ import {
|
|||
import { productDocFolderToApi } from '@/lib/product-doc-folder'
|
||||
import {
|
||||
productDocCreateSchema,
|
||||
productDocFolderToggleSchema,
|
||||
productDocUpdateSchema,
|
||||
type ProductDocCreateInput,
|
||||
type ProductDocFolderToggleInput,
|
||||
} from '@/lib/schemas/product-doc'
|
||||
import { parseProductDocMd } from '@/lib/product-doc-parser'
|
||||
import {
|
||||
|
|
@ -303,3 +305,123 @@ export async function deleteProductDocAction(id: string): Promise<ActionResult>
|
|||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FOLDER TOGGLE — owner-only (NIET productAccessFilter, folder-config is
|
||||
// een product-setting, niet een doc-mutation). ProductMember kan dus geen
|
||||
// folders aan/uit zetten.
|
||||
//
|
||||
// Idempotent: als de target-staat al de huidige staat is, doet de action
|
||||
// niets en returnt success — geen log-rij, geen revalidate.
|
||||
|
||||
export async function toggleProductDocFolderAction(
|
||||
input: ProductDocFolderToggleInput,
|
||||
): 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 parsed = productDocFolderToggleSchema.safeParse(input)
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
error: 'Validatie mislukt',
|
||||
code: 422,
|
||||
details: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const userId = session.userId
|
||||
// Owner-only — NIET productAccessFilter
|
||||
const product = await prisma.product.findFirst({
|
||||
where: { id: parsed.data.product_id, user_id: userId },
|
||||
select: { id: true, enabled_doc_folders: true },
|
||||
})
|
||||
if (!product) return { error: 'Product niet gevonden', code: 404 }
|
||||
|
||||
const folderDb = folderApiToDbOrThrow(parsed.data.folder)
|
||||
const isEnabledNow = product.enabled_doc_folders.includes(folderDb)
|
||||
|
||||
if (parsed.data.enabled === isEnabledNow) {
|
||||
return { success: true } // idempotent
|
||||
}
|
||||
|
||||
const next = parsed.data.enabled
|
||||
? Array.from(new Set([...product.enabled_doc_folders, folderDb]))
|
||||
: product.enabled_doc_folders.filter((f) => f !== folderDb)
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.product.update({
|
||||
where: { id: product.id },
|
||||
data: { enabled_doc_folders: next },
|
||||
}),
|
||||
prisma.productDocLog.create({
|
||||
data: {
|
||||
product_id: product.id,
|
||||
doc_id: null,
|
||||
actor_user_id: userId,
|
||||
type: parsed.data.enabled ? 'FOLDER_ENABLED' : 'FOLDER_DISABLED',
|
||||
metadata: { folder: parsed.data.folder },
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
revalidatePath(`/products/${product.id}/docs`)
|
||||
revalidatePath(`/products/${product.id}/docs/settings`)
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LIST — read-only. Demo MAG lezen (zie plan §B.4). Geen rate-limit.
|
||||
|
||||
export interface ProductDocListItem {
|
||||
id: string
|
||||
folder: string
|
||||
slug: string
|
||||
title: string
|
||||
status: string
|
||||
updated_at: Date
|
||||
}
|
||||
|
||||
export async function listProductDocsAction(input: {
|
||||
product_id: string
|
||||
folder?: string
|
||||
}): Promise<ActionResult<ProductDocListItem[]>> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd', code: 401 }
|
||||
|
||||
const userId = session.userId
|
||||
const product = await loadAccessibleProduct(input.product_id, userId)
|
||||
if (!product) return { error: 'Product niet gevonden', code: 404 }
|
||||
|
||||
const folderDb = input.folder ? folderApiToDbOrThrow(input.folder) : undefined
|
||||
|
||||
const docs = await prisma.productDoc.findMany({
|
||||
where: {
|
||||
product_id: product.id,
|
||||
...(folderDb ? { folder: folderDb } : {}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
folder: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
status: true,
|
||||
updated_at: true,
|
||||
},
|
||||
orderBy: [{ folder: 'asc' }, { slug: 'asc' }],
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: docs.map((d) => ({
|
||||
id: d.id,
|
||||
folder: productDocFolderToApi(d.folder),
|
||||
slug: d.slug,
|
||||
title: d.title,
|
||||
status: d.status,
|
||||
updated_at: d.updated_at,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue