- lib/user-settings.ts: nieuw workflow.pendingSprintDraft veld met compacte intent-shape (pbiIntent + per-PBI storyOverrides). - actions/sprint-draft.ts: setPendingSprintDraftAction + clearPendingSprintDraftAction met product-membership-check + Zod-validatie. - stores/user-settings/store.ts: setPendingSprintDraft / clearPendingSprintDraft optimistic acties + fine-grained mutators upsertPbiIntent / upsertStoryOverride. Sprint-draft actions worden dynamisch geïmporteerd zodat jsdom-tests zonder DATABASE_URL niet falen. - Tests: nieuwe sprint-draft.test.ts (action-laag), uitbreiding user-settings store-tests (5 nieuwe cases) en schema-tests (4 cases). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
167 lines
5.1 KiB
TypeScript
167 lines
5.1 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
|
vi.mock('next/headers', () => ({
|
|
cookies: vi.fn().mockResolvedValue({
|
|
set: vi.fn(),
|
|
get: vi.fn(),
|
|
delete: vi.fn(),
|
|
}),
|
|
}))
|
|
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: {
|
|
product: { findFirst: vi.fn() },
|
|
user: {
|
|
findUnique: vi.fn(),
|
|
update: vi.fn().mockResolvedValue({}),
|
|
},
|
|
$executeRaw: vi.fn().mockResolvedValue(1),
|
|
},
|
|
}))
|
|
|
|
import { prisma } from '@/lib/prisma'
|
|
import {
|
|
clearPendingSprintDraftAction,
|
|
setPendingSprintDraftAction,
|
|
} from '@/actions/sprint-draft'
|
|
import type { PendingSprintDraft, UserSettings } from '@/lib/user-settings'
|
|
|
|
const mockPrisma = prisma as unknown as {
|
|
product: { findFirst: ReturnType<typeof vi.fn> }
|
|
user: {
|
|
findUnique: ReturnType<typeof vi.fn>
|
|
update: ReturnType<typeof vi.fn>
|
|
}
|
|
}
|
|
|
|
const validDraft: PendingSprintDraft = {
|
|
goal: 'Sprint 1',
|
|
pbiIntent: { pbiA: 'all' },
|
|
storyOverrides: { pbiA: { add: [], remove: ['story-1'] } },
|
|
}
|
|
|
|
describe('setPendingSprintDraftAction', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockPrisma.product.findFirst.mockReset()
|
|
mockPrisma.user.findUnique.mockReset()
|
|
mockPrisma.user.update.mockReset().mockResolvedValue({})
|
|
})
|
|
|
|
it('persists draft for accessible product', async () => {
|
|
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
|
|
mockPrisma.user.findUnique.mockResolvedValueOnce({ settings: {} })
|
|
|
|
const result = await setPendingSprintDraftAction('p1', validDraft)
|
|
|
|
expect(result).toEqual({ success: true })
|
|
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
|
|
data: { settings: UserSettings }
|
|
}
|
|
expect(updateArg.data.settings.workflow?.pendingSprintDraft?.p1).toMatchObject({
|
|
goal: 'Sprint 1',
|
|
pbiIntent: { pbiA: 'all' },
|
|
})
|
|
})
|
|
|
|
it('preserves drafts for other products', async () => {
|
|
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
|
|
mockPrisma.user.findUnique.mockResolvedValueOnce({
|
|
settings: {
|
|
workflow: {
|
|
pendingSprintDraft: {
|
|
p2: { goal: 'P2 draft', pbiIntent: {}, storyOverrides: {} },
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
await setPendingSprintDraftAction('p1', validDraft)
|
|
|
|
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
|
|
data: { settings: UserSettings }
|
|
}
|
|
const drafts = updateArg.data.settings.workflow?.pendingSprintDraft
|
|
expect(Object.keys(drafts ?? {})).toEqual(expect.arrayContaining(['p1', 'p2']))
|
|
})
|
|
|
|
it('rejects invalid draft (empty goal)', async () => {
|
|
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
|
|
|
|
const result = await setPendingSprintDraftAction('p1', {
|
|
...validDraft,
|
|
goal: '',
|
|
} as PendingSprintDraft)
|
|
|
|
expect(result).toHaveProperty('error')
|
|
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects when product not accessible', async () => {
|
|
mockPrisma.product.findFirst.mockResolvedValueOnce(null)
|
|
|
|
const result = await setPendingSprintDraftAction('p1', validDraft)
|
|
|
|
expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' })
|
|
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('clearPendingSprintDraftAction', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockPrisma.product.findFirst.mockReset()
|
|
mockPrisma.user.findUnique.mockReset()
|
|
mockPrisma.user.update.mockReset().mockResolvedValue({})
|
|
})
|
|
|
|
it('removes draft key for product', async () => {
|
|
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
|
|
mockPrisma.user.findUnique.mockResolvedValueOnce({
|
|
settings: {
|
|
workflow: {
|
|
pendingSprintDraft: {
|
|
p1: { goal: 'gone', pbiIntent: {}, storyOverrides: {} },
|
|
p2: { goal: 'keep', pbiIntent: {}, storyOverrides: {} },
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
await clearPendingSprintDraftAction('p1')
|
|
|
|
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
|
|
data: { settings: UserSettings }
|
|
}
|
|
expect(updateArg.data.settings.workflow?.pendingSprintDraft).toEqual({
|
|
p2: { goal: 'keep', pbiIntent: {}, storyOverrides: {} },
|
|
})
|
|
})
|
|
|
|
it('is a no-op when there is no draft for the product', async () => {
|
|
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
|
|
mockPrisma.user.findUnique.mockResolvedValueOnce({ settings: {} })
|
|
|
|
const result = await clearPendingSprintDraftAction('p1')
|
|
|
|
expect(result).toEqual({ success: true })
|
|
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects when product not accessible', async () => {
|
|
mockPrisma.product.findFirst.mockResolvedValueOnce(null)
|
|
|
|
const result = await clearPendingSprintDraftAction('p1')
|
|
|
|
expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' })
|
|
})
|
|
})
|