import { describe, it, expect, vi, beforeEach } from 'vitest' vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) })) vi.mock('iron-session', () => ({ getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), })) vi.mock('@/lib/session', () => ({ sessionOptions: { cookieName: 'test', password: 'test' }, })) vi.mock('@/lib/product-access', () => ({ productAccessFilter: vi.fn().mockReturnValue({}), })) vi.mock('@/lib/prisma', () => ({ prisma: { task: { findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), findMany: vi.fn(), }, story: { findFirst: vi.fn(), findUniqueOrThrow: vi.fn(), update: vi.fn(), }, $transaction: vi.fn(), }, })) import { prisma } from '@/lib/prisma' import { getIronSession } from 'iron-session' import { saveTask, deleteTask } from '@/actions/tasks' const mockPrisma = prisma as unknown as { task: { findFirst: ReturnType create: ReturnType update: ReturnType delete: ReturnType findMany: ReturnType } story: { findFirst: ReturnType findUniqueOrThrow: ReturnType update: ReturnType } $transaction: ReturnType } const mockSession = getIronSession as ReturnType const VALID_INPUT = { title: 'Test taak', description: 'Beschrijving', implementation_plan: 'Plan', priority: 3, } const TASK = { id: 'task-1', title: 'Test taak', status: 'TO_DO', } const STORY = { sprint_id: 'sprint-1' } beforeEach(() => { vi.clearAllMocks() mockSession.mockResolvedValue({ userId: 'user-1', isDemo: false }) // Pass-through transaction so saveTask's $transaction wrapper executes its callback inline. mockPrisma.$transaction.mockImplementation(async (run: (tx: typeof prisma) => Promise) => { return run(prisma) }) }) // ─── saveTask ──────────────────────────────────────────────────────────────── describe('saveTask — demo-readonly (laag 2)', () => { it('blokkeert demo-sessie', async () => { mockSession.mockResolvedValue({ userId: 'user-1', isDemo: true }) const result = await saveTask(VALID_INPUT, { productId: 'p-1' }) expect(result).toEqual({ ok: false, code: 403, error: 'demo_readonly' }) }) }) describe('saveTask — unauthenticated', () => { it('blokkeert niet-ingelogde gebruiker', async () => { mockSession.mockResolvedValue({ userId: undefined, isDemo: false }) const result = await saveTask(VALID_INPUT, { productId: 'p-1' }) expect(result).toEqual({ ok: false, code: 403, error: 'forbidden' }) }) }) describe('saveTask — validatie', () => { it('retourneert 422 bij lege titel', async () => { const result = await saveTask({ ...VALID_INPUT, title: '' }, { productId: 'p-1', storyId: 's-1' }) expect(result).toMatchObject({ ok: false, code: 422, error: 'validation' }) }) it('retourneert 422 bij te lange titel (>120 tekens)', async () => { const result = await saveTask( { ...VALID_INPUT, title: 'a'.repeat(121) }, { productId: 'p-1', storyId: 's-1' }, ) expect(result).toMatchObject({ ok: false, code: 422, error: 'validation' }) }) }) describe('saveTask — edit (cross-tenant scope)', () => { it('retourneert forbidden als task buiten scope valt', async () => { mockPrisma.task.findFirst.mockResolvedValue(null) // out-of-scope const result = await saveTask(VALID_INPUT, { taskId: 'task-1', productId: 'p-1' }) expect(result).toEqual({ ok: false, code: 403, error: 'forbidden' }) }) it('update slaagt voor een geautoriseerde task', async () => { mockPrisma.task.findFirst.mockResolvedValue(TASK) mockPrisma.task.update.mockResolvedValue(TASK) const result = await saveTask(VALID_INPUT, { taskId: 'task-1', productId: 'p-1' }) expect(result).toMatchObject({ ok: true }) // scope-filter is toegepast: findFirst bevat `story.product` expect(mockPrisma.task.findFirst).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ id: 'task-1', story: expect.anything() }), }), ) }) }) describe('saveTask — edit met status-promotie', () => { it('promotes story naar DONE wanneer status flip naar DONE alle siblings DONE maakt', async () => { mockPrisma.task.findFirst.mockResolvedValue({ id: 'task-1', status: 'IN_PROGRESS' }) mockPrisma.task.update.mockResolvedValue({ id: 'task-1', title: 'Test taak', status: 'IN_PROGRESS', story_id: 'story-1', implementation_plan: null, }) // Wanneer de helper draait, gebruikt-ie tx.task.update voor de status-flip. // Dezelfde mock vangt beide updates op; tweede return-value voor de status-update. mockPrisma.task.update.mockResolvedValueOnce({ id: 'task-1', title: 'Test taak', status: 'IN_PROGRESS', story_id: 'story-1', implementation_plan: null, }).mockResolvedValueOnce({ id: 'task-1', title: 'Test taak', status: 'DONE', story_id: 'story-1', implementation_plan: null, }) mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }, { status: 'DONE' }]) mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) const result = await saveTask( { ...VALID_INPUT, status: 'DONE' }, { taskId: 'task-1', productId: 'p-1' }, ) expect(result).toMatchObject({ ok: true }) expect(mockPrisma.story.update).toHaveBeenCalledWith({ where: { id: 'story-1' }, data: { status: 'DONE' }, }) }) }) describe('saveTask — create (cross-tenant scope)', () => { it('retourneert forbidden als story buiten scope valt', async () => { mockPrisma.story.findFirst.mockResolvedValue(null) const result = await saveTask(VALID_INPUT, { storyId: 's-1', productId: 'p-1' }) expect(result).toEqual({ ok: false, code: 403, error: 'forbidden' }) }) it('aanmaken slaagt voor een geautoriseerde story', async () => { mockPrisma.story.findFirst.mockResolvedValue(STORY) mockPrisma.task.findFirst.mockResolvedValue(null) // geen vorige taak mockPrisma.task.create.mockResolvedValue(TASK) const result = await saveTask(VALID_INPUT, { storyId: 's-1', productId: 'p-1' }) expect(result).toMatchObject({ ok: true }) }) }) // ─── deleteTask ────────────────────────────────────────────────────────────── describe('deleteTask — demo-readonly (laag 2)', () => { it('blokkeert demo-sessie', async () => { mockSession.mockResolvedValue({ userId: 'user-1', isDemo: true }) const result = await deleteTask('task-1', { productId: 'p-1' }) expect(result).toEqual({ ok: false, code: 403, error: 'demo_readonly' }) }) }) describe('deleteTask — unauthenticated', () => { it('blokkeert niet-ingelogde gebruiker', async () => { mockSession.mockResolvedValue({ userId: undefined, isDemo: false }) const result = await deleteTask('task-1', { productId: 'p-1' }) expect(result).toEqual({ ok: false, code: 403, error: 'forbidden' }) }) }) describe('deleteTask — cross-tenant scope', () => { it('retourneert forbidden als task buiten scope valt', async () => { mockPrisma.task.findFirst.mockResolvedValue(null) const result = await deleteTask('task-1', { productId: 'p-1' }) expect(result).toEqual({ ok: false, code: 403, error: 'forbidden' }) }) it('verwijderen slaagt voor een geautoriseerde task', async () => { mockPrisma.task.findFirst.mockResolvedValue(TASK) mockPrisma.task.delete.mockResolvedValue(TASK) const result = await deleteTask('task-1', { productId: 'p-1' }) expect(result).toEqual({ ok: true }) // scope-filter toegepast expect(mockPrisma.task.findFirst).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ id: 'task-1', story: expect.anything() }), }), ) }) })