Scrum4Me/__tests__/components/dialogs/product-dialog.test.tsx
Janpeter Visser 0ee03c6b72
ProductDialog: create + edit met alle velden (#68)
* feat(ST-?): createProductAction + updateProductAction (data-object API)

Voegt data-object-gebaseerde createProductAction(data) en
updateProductAction(id, data) toe aan actions/products.ts voor gebruik
door ProductDialog. Bevat Zod-validatie (incl. github-regex op repo_url),
productAccessFilter voor update, pg_notify bij update, en productMember-
aanleg bij create. FormData-varianten hernoemd naar ...FormAction; callers
bijgewerkt. 9 nieuwe tests groen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-?): ProductDialog component (create + edit modes)

Voegt components/dialogs/product-dialog.tsx toe op basis van het
entity-dialog-patroon. Gebruikt react-hook-form + zodResolver voor
client-side validatie. Roept createProductAction/updateProductAction
aan en werkt stores/products-store.ts optimistisch bij. Demo-modus
disabled alle velden + submit-knop via DemoTooltip. 7 tests groen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-?): UI triggers voor ProductDialog op dashboard en product-detail

Voegt NewProductButton toe op het dashboard (vervangt de /products/new
link) en EditProductButton op de product-detail pagina. Bewerken-knop
is alleen zichtbaar voor de product-eigenaar en verborgen in demo-modus.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(test): cast toast via unknown to satisfy strict TS

`toast as { success, error }` direct-cast faalt omdat sonner's toast een
callable + properties is. TS2352. Cast via unknown lost het op zonder
gedrag te wijzigen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 17:56:33 +02:00

134 lines
4.4 KiB
TypeScript

// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
vi.mock('@/actions/products', () => ({
createProductAction: vi.fn(),
updateProductAction: vi.fn(),
}))
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
vi.mock('@/stores/products-store', () => ({
useProductsStore: vi.fn((selector: (s: { addProduct: () => void; updateProduct: () => void }) => unknown) =>
selector({ addProduct: vi.fn(), updateProduct: vi.fn() })
),
}))
import { ProductDialog } from '@/components/dialogs/product-dialog'
import { createProductAction, updateProductAction } from '@/actions/products'
import { toast } from 'sonner'
const mockCreate = createProductAction as ReturnType<typeof vi.fn>
const mockUpdate = updateProductAction as ReturnType<typeof vi.fn>
const mockToast = toast as unknown as {
success: ReturnType<typeof vi.fn>
error: ReturnType<typeof vi.fn>
}
const PRODUCT = {
id: 'prod-1',
name: 'Mijn Product',
code: 'MP',
description: 'Een product',
repo_url: 'https://github.com/org/repo',
definition_of_done: 'Alles groen',
auto_pr: false,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('ProductDialog — create mode', () => {
it('rendert met lege velden en "Nieuw product" titel', () => {
render(
<ProductDialog mode="create" open={true} onOpenChange={vi.fn()} />
)
expect(screen.getByText('Nieuw product')).toBeTruthy()
expect(screen.getByLabelText(/Naam/)).toBeTruthy()
expect((screen.getByLabelText(/Naam/) as HTMLInputElement).value).toBe('')
})
it('toont validatiefout als naam leeg is bij submit', async () => {
render(
<ProductDialog mode="create" open={true} onOpenChange={vi.fn()} />
)
fireEvent.click(screen.getByRole('button', { name: 'Aanmaken' }))
await waitFor(() => {
expect(screen.getByText('Naam is verplicht')).toBeTruthy()
})
expect(mockCreate).not.toHaveBeenCalled()
})
it('roept createProductAction aan bij geldig formulier', async () => {
mockCreate.mockResolvedValue({ success: true, productId: 'new-prod' })
render(
<ProductDialog mode="create" open={true} onOpenChange={vi.fn()} />
)
fireEvent.change(screen.getByLabelText(/Naam/), { target: { value: 'Nieuw Product' } })
fireEvent.submit(document.getElementById('product-form')!)
await waitFor(() => {
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({ name: 'Nieuw Product' })
)
})
expect(mockToast.success).toHaveBeenCalledWith('Product aangemaakt')
})
it('toont error-toast als createProductAction een error retourneert', async () => {
mockCreate.mockResolvedValue({ error: 'Code is al in gebruik' })
render(
<ProductDialog mode="create" open={true} onOpenChange={vi.fn()} />
)
fireEvent.change(screen.getByLabelText(/Naam/), { target: { value: 'Test' } })
fireEvent.submit(document.getElementById('product-form')!)
await waitFor(() => {
expect(mockToast.error).toHaveBeenCalledWith('Code is al in gebruik')
})
})
})
describe('ProductDialog — edit mode', () => {
it('rendert met bestaande waarden vooringevuld', () => {
render(
<ProductDialog mode="edit" open={true} onOpenChange={vi.fn()} product={PRODUCT} />
)
expect(screen.getByText('Product bewerken')).toBeTruthy()
expect((screen.getByLabelText(/Naam/) as HTMLInputElement).value).toBe('Mijn Product')
})
it('roept updateProductAction aan bij opslaan', async () => {
mockUpdate.mockResolvedValue({ success: true })
render(
<ProductDialog mode="edit" open={true} onOpenChange={vi.fn()} product={PRODUCT} />
)
fireEvent.change(screen.getByLabelText(/Naam/), { target: { value: 'Gewijzigd Product' } })
fireEvent.submit(document.getElementById('product-form')!)
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith(
PRODUCT.id,
expect.objectContaining({ name: 'Gewijzigd Product' })
)
})
expect(mockToast.success).toHaveBeenCalledWith('Product opgeslagen')
})
})
describe('ProductDialog — demo mode', () => {
it('submit-knop is disabled in demo-modus', () => {
render(
<ProductDialog mode="create" open={true} onOpenChange={vi.fn()} isDemo={true} />
)
const submitBtn = screen.getByRole('button', { name: 'Aanmaken' })
expect(submitBtn).toHaveProperty('disabled', true)
})
})