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>
This commit is contained in:
parent
60e2b62bbe
commit
0ee03c6b72
11 changed files with 777 additions and 10 deletions
160
__tests__/actions/products.test.ts
Normal file
160
__tests__/actions/products.test.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const {
|
||||
mockGetSession,
|
||||
mockFindFirstProduct,
|
||||
mockCreateProduct,
|
||||
mockUpdateProduct,
|
||||
mockCreateMember,
|
||||
mockExecuteRaw,
|
||||
mockTransaction,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetSession: vi.fn(),
|
||||
mockFindFirstProduct: vi.fn(),
|
||||
mockCreateProduct: vi.fn(),
|
||||
mockUpdateProduct: vi.fn(),
|
||||
mockCreateMember: vi.fn(),
|
||||
mockExecuteRaw: vi.fn().mockResolvedValue(undefined),
|
||||
mockTransaction: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
||||
vi.mock('next/navigation', () => ({ redirect: 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/auth', () => ({ getSession: mockGetSession }))
|
||||
vi.mock('@/lib/product-access', () => ({
|
||||
productAccessFilter: vi.fn().mockReturnValue({ OR: [{ user_id: 'user-1' }] }),
|
||||
}))
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
product: { findFirst: mockFindFirstProduct, create: mockCreateProduct, update: mockUpdateProduct },
|
||||
productMember: { create: mockCreateMember },
|
||||
$executeRaw: mockExecuteRaw,
|
||||
$transaction: mockTransaction,
|
||||
},
|
||||
}))
|
||||
|
||||
import { createProductAction, updateProductAction } from '@/actions/products'
|
||||
import { getIronSession } from 'iron-session'
|
||||
|
||||
const mockSession = getIronSession as ReturnType<typeof vi.fn>
|
||||
|
||||
const SESSION_USER = { userId: 'user-1', isDemo: false }
|
||||
const SESSION_DEMO = { userId: 'demo-1', isDemo: true }
|
||||
const PRODUCT_ID = 'product-1'
|
||||
|
||||
const VALID_DATA = {
|
||||
name: 'Test Product',
|
||||
code: 'TP',
|
||||
description: 'Een product',
|
||||
repo_url: 'https://github.com/org/repo',
|
||||
definition_of_done: 'Alles groen',
|
||||
auto_pr: false,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockExecuteRaw.mockResolvedValue(undefined)
|
||||
mockSession.mockResolvedValue(SESSION_USER)
|
||||
})
|
||||
|
||||
// =============================================================
|
||||
// createProductAction
|
||||
// =============================================================
|
||||
describe('createProductAction', () => {
|
||||
it('happy path: maakt product + member aan en retourneert productId', async () => {
|
||||
mockFindFirstProduct.mockResolvedValue(null) // geen dubbele code
|
||||
mockTransaction.mockImplementation(async (fn: (tx: unknown) => Promise<unknown>) => {
|
||||
return fn({
|
||||
product: {
|
||||
create: vi.fn().mockResolvedValue({ id: PRODUCT_ID }),
|
||||
},
|
||||
productMember: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const result = await createProductAction(VALID_DATA)
|
||||
|
||||
expect(result).toEqual({ success: true, productId: PRODUCT_ID })
|
||||
})
|
||||
|
||||
it('demo-user → error', async () => {
|
||||
mockSession.mockResolvedValue(SESSION_DEMO)
|
||||
|
||||
const result = await createProductAction(VALID_DATA)
|
||||
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('demo') })
|
||||
expect(mockTransaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ongeldige repo_url (niet github) → validatiefout', async () => {
|
||||
const result = await createProductAction({ ...VALID_DATA, repo_url: 'https://gitlab.com/org/repo' })
|
||||
|
||||
expect(result).toMatchObject({ error: expect.any(String) })
|
||||
expect(mockTransaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dubbele code → error', async () => {
|
||||
mockFindFirstProduct.mockResolvedValue({ id: 'other-product' })
|
||||
|
||||
const result = await createProductAction(VALID_DATA)
|
||||
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('gebruik') })
|
||||
expect(mockTransaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('naam ontbreekt → validatiefout', async () => {
|
||||
const result = await createProductAction({ ...VALID_DATA, name: '' })
|
||||
|
||||
expect(result).toMatchObject({ error: expect.any(String) })
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================
|
||||
// updateProductAction
|
||||
// =============================================================
|
||||
describe('updateProductAction', () => {
|
||||
it('happy path: werkt product bij en stuurt pg_notify', async () => {
|
||||
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
|
||||
mockUpdateProduct.mockResolvedValue({ id: PRODUCT_ID })
|
||||
|
||||
const result = await updateProductAction(PRODUCT_ID, VALID_DATA)
|
||||
|
||||
expect(result).toEqual({ success: true })
|
||||
expect(mockUpdateProduct).toHaveBeenCalled()
|
||||
expect(mockExecuteRaw).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('demo-user → error', async () => {
|
||||
mockSession.mockResolvedValue(SESSION_DEMO)
|
||||
|
||||
const result = await updateProductAction(PRODUCT_ID, VALID_DATA)
|
||||
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('demo') })
|
||||
expect(mockUpdateProduct).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('geen toegang tot product → error', async () => {
|
||||
mockFindFirstProduct.mockResolvedValue(null)
|
||||
|
||||
const result = await updateProductAction(PRODUCT_ID, VALID_DATA)
|
||||
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('toegang') })
|
||||
expect(mockUpdateProduct).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ongeldige repo_url → validatiefout', async () => {
|
||||
const result = await updateProductAction(PRODUCT_ID, { ...VALID_DATA, repo_url: 'https://bitbucket.org/x' })
|
||||
|
||||
expect(result).toMatchObject({ error: expect.any(String) })
|
||||
expect(mockUpdateProduct).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
134
__tests__/components/dialogs/product-dialog.test.tsx
Normal file
134
__tests__/components/dialogs/product-dialog.test.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
// @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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue