feat(ST-907): tests for active-product actions and functional spec update for M9
This commit is contained in:
parent
98105fb870
commit
1f2028852a
3 changed files with 166 additions and 1 deletions
101
__tests__/actions/active-product.test.ts
Normal file
101
__tests__/actions/active-product.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
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: {
|
||||
product: { findFirst: vi.fn() },
|
||||
user: { update: vi.fn() },
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getIronSession } from 'iron-session'
|
||||
import { setActiveProductAction, clearActiveProductAction } from '@/actions/active-product'
|
||||
|
||||
const mockPrisma = prisma as unknown as {
|
||||
product: { findFirst: ReturnType<typeof vi.fn> }
|
||||
user: { update: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
const mockGetIronSession = getIronSession as ReturnType<typeof vi.fn>
|
||||
|
||||
const PRODUCT = { id: 'product-1', name: 'Test Product', archived: false }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetIronSession.mockResolvedValue({ userId: 'user-1', isDemo: false })
|
||||
mockPrisma.product.findFirst.mockResolvedValue(PRODUCT)
|
||||
mockPrisma.user.update.mockResolvedValue({})
|
||||
})
|
||||
|
||||
describe('setActiveProductAction', () => {
|
||||
it('sets active_product_id for authenticated user', async () => {
|
||||
const result = await setActiveProductAction('product-1')
|
||||
expect(result).toEqual({ success: true, productId: 'product-1' })
|
||||
expect(mockPrisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: 'user-1' },
|
||||
data: { active_product_id: 'product-1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns error when not logged in', async () => {
|
||||
mockGetIronSession.mockResolvedValue({ userId: undefined, isDemo: false })
|
||||
const result = await setActiveProductAction('product-1')
|
||||
expect(result).toEqual({ error: 'Niet ingelogd' })
|
||||
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns error for demo user', async () => {
|
||||
mockGetIronSession.mockResolvedValue({ userId: 'user-1', isDemo: true })
|
||||
const result = await setActiveProductAction('product-1')
|
||||
expect(result).toEqual({ error: 'Niet beschikbaar in demo-modus' })
|
||||
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns error when product is archived or inaccessible', async () => {
|
||||
mockPrisma.product.findFirst.mockResolvedValue(null)
|
||||
const result = await setActiveProductAction('product-1')
|
||||
expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' })
|
||||
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns error for empty product id', async () => {
|
||||
const result = await setActiveProductAction('')
|
||||
expect(result).toEqual({ error: 'Ongeldig product-id' })
|
||||
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearActiveProductAction', () => {
|
||||
it('clears active_product_id for authenticated user', async () => {
|
||||
const result = await clearActiveProductAction()
|
||||
expect(result).toEqual({ success: true })
|
||||
expect(mockPrisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: 'user-1' },
|
||||
data: { active_product_id: null },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns error when not logged in', async () => {
|
||||
mockGetIronSession.mockResolvedValue({ userId: undefined, isDemo: false })
|
||||
const result = await clearActiveProductAction()
|
||||
expect(result).toEqual({ error: 'Niet ingelogd' })
|
||||
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns error for demo user', async () => {
|
||||
mockGetIronSession.mockResolvedValue({ userId: 'user-1', isDemo: true })
|
||||
const result = await clearActiveProductAction()
|
||||
expect(result).toEqual({ error: 'Niet beschikbaar in demo-modus' })
|
||||
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -25,7 +25,7 @@ De MVP is klaar wanneer Lars — de primaire persona — de volledige cyclus kan
|
|||
| M6: Polish & Launch-ready | Foutafhandeling, toegankelijkheid, CI/CD, beveiliging | ST-601 – ST-612 |
|
||||
| M7: MCP-server voor Claude Code | Native MCP-laag bovenop Scrum4Me-DB (aparte repo `scrum4me-mcp`) | ST-701 – ST-710 |
|
||||
| M8: Realtime Solo Paneel | Live updates voor stories/tasks via SSE + Postgres LISTEN/NOTIFY | ST-801 – ST-806 |
|
||||
|
||||
| M9: Actief Product Backlog | Persistente actieve PB-keuze, gesplitste navigatie, disabled-states | ST-901 – ST-907 |
|
||||
---
|
||||
|
||||
## Backlog
|
||||
|
|
@ -550,6 +550,38 @@ Filtering server-side: alleen events binnen de actieve sprint van een product wa
|
|||
|
||||
Volledig plan in `.Plans/2026-04-27-m8-realtime-solo.md` (lokaal, niet gecommit).
|
||||
|
||||
### M9: Actief Product Backlog
|
||||
|
||||
Eén "actief Product Backlog" per gebruiker — persistent in DB. De NavBar wordt gesplitst in **Producten** (lijst) en **Product Backlog** (PB-view van actief PB), met **Sprint** en **Solo** als aparte tabs die op het actieve PB werken. Geen actief PB → die drie tabs zijn disabled. Vervangt de bestaande `last_product`-cookieflow.
|
||||
|
||||
- [x] **ST-901** Database — `user.active_product_id`
|
||||
- Voeg `active_product_id String? @db.Uuid` toe aan `User` met FK naar `Product.id` en `onDelete: SetNull`; migratie `add_user_active_product_id`; index op `active_product_id` voor join-performance
|
||||
- Done when: `npx prisma migrate dev` slaagt; `prisma studio` toont kolom; `npx prisma validate` zonder fouten; submodule `vendor/scrum4me` in scrum4me-mcp draait `prisma generate` + `tsc --noEmit` zonder fouten
|
||||
|
||||
- [x] **ST-902** Server Actions — actief product zetten en wissen
|
||||
- `actions/active-product.ts` met `setActiveProduct(productId)` en `clearActiveProduct()`; Zod + auth + `productAccessFilter`; demo-gebruikers mogen wisselen (sessie-effect alleen, geen DB-write); `archiveProduct` en `leaveProduct` zetten `active_product_id` op `null` als het hetzelfde product betreft
|
||||
- Done when: setActive met onbekend/onbereikbaar product → 422; archiveren van actief product clearet de keuze; demo-flow geeft toast "Niet beschikbaar in demo-modus"
|
||||
|
||||
- [x] **ST-903** App-layout laadt actief product + redirects
|
||||
- `app/(app)/layout.tsx` haalt `activeProduct` (id, name, archived) op naast user; geef door aan `NavBar`; `app/(app)/solo/page.tsx` gebruikt `user.active_product_id` i.p.v. `getLastProductCookie`; helper `lib/cookies.ts:getLastProductCookie` markeren deprecated of verwijderen plus call-sites opruimen
|
||||
- Done when: ingelogd zonder actief PB toont NavBar zonder geactiveerde tabs; met actief PB redirect `/solo` → `/products/[active]/solo` zonder cookie te raadplegen
|
||||
|
||||
- [x] **ST-904** NavBar — splits + disabled-states + switcher
|
||||
- Tabs worden: **Producten** (`/dashboard`) | **Product Backlog** (`/products/[active]`) | **Sprint** (`/products/[active]/sprint`) | **Solo** (`/products/[active]/solo`) | **Todo's** (`/todos`); zonder actief PB zijn de middelste drie disabled-spans (zelfde stijl als huidige Sprint-disabled); productnaam in midden wordt een dropdown-trigger (shadcn `DropdownMenu`) met je producten + "Producten beheren →"; Sprint krijgt `aria-disabled` + tooltip "Geen actieve sprint" als er geen sprint met status `ACTIVE` is
|
||||
- Done when: handmatige test: zonder PB drie tabs grijs; activeer PB → tabs klikbaar; dropdown wisselt PB en redirect naar Product Backlog; Sprint-tab disabled tot sprint gestart
|
||||
|
||||
- [x] **ST-905** Producten-scherm — Activeer-knop per rij
|
||||
- `components/dashboard/product-list.tsx`: per rij "Activeer"-knop (verborgen voor reeds actief PB); actieve rij krijgt badge "Actief" (MD3-token `bg-primary-container`); klik op Activeer → `setActiveProduct` + `router.push('/products/[id]')`; ook in `/products/[id]` header een Activeer-knop als dat product nog niet actief is
|
||||
- Done when: activeer in dashboard markeert juiste rij + landt op Product Backlog; demo-gebruiker krijgt toast en geen DB-mutatie
|
||||
|
||||
- [x] **ST-906** Edge cases — toegangsverlies en archivering
|
||||
- Wanneer een PB wordt gearchiveerd, ge-leaved, of een productmember wordt verwijderd: `active_product_id` automatisch `null` voor betroffen users (server actions van `archiveProduct`, `leaveProduct`, `removeMember`); guard in `app/(app)/layout.tsx`: als `active_product_id` is gezet maar product is archived/onbereikbaar, server-side clear + redirect naar `/dashboard` met toast "Je actieve product is niet meer beschikbaar"
|
||||
- Done when: scenario test — eigenaar archiveert → membership-gebruikers landen op dashboard met toast en active is gecleared
|
||||
|
||||
- [x] **ST-907** Documentatie en tests
|
||||
- Functional spec: nieuw hoofdstuk "Actief Product Backlog" (concept, menugedrag, edge cases); README: navigatie-screenshot bijwerken; `docs/patterns/` indien nieuwe patroon (n.v.t. tenzij dropdown-switcher een herbruikbaar component wordt); jest-tests in `__tests__/actions/active-product.test.ts` voor setActive (toegang, demo, archived); Playwright/manueel scenario: log in → activeer PB → wissel via dropdown → archiveer → verifieer auto-clear
|
||||
- Done when: `npm run lint && npx tsc --noEmit && npm test && npm run build` groen; spec-secties geschreven; `vendor/scrum4me`-submodule in scrum4me-mcp gesynced
|
||||
|
||||
---
|
||||
|
||||
## v2 Backlog (na MVP)
|
||||
|
|
|
|||
|
|
@ -529,3 +529,35 @@ De app is deployable op Vercel + Neon PostgreSQL en lokaal draaibaar met een Neo
|
|||
6. Lars navigeert naar de Product Backlog → story staat in de juiste prioriteitsgroep
|
||||
|
||||
**Resultaat:** Losse gedachte is in drie stappen onderdeel van de formele Product Backlog.
|
||||
|
||||
---
|
||||
|
||||
## Actief Product Backlog
|
||||
|
||||
### Concept
|
||||
|
||||
Een gebruiker kan één product als "actief" markeren. Dit actieve product wordt in de NavBar centraal getoond en bepaalt welke tabs (Product Backlog, Sprint, Solo) navigeerbaar zijn. Het actieve product wordt opgeslagen in `user.active_product_id` in de database — niet in een cookie.
|
||||
|
||||
### Menugedrag
|
||||
|
||||
- **Producten** — altijd bereikbaar, toont alle producten van de gebruiker
|
||||
- **Product Backlog** — alleen klikbaar als er een actief product is
|
||||
- **Sprint** — alleen klikbaar als er een actief product is én een actieve sprint bestaat; anders tooltip "Geen actieve sprint"
|
||||
- **Solo** — alleen klikbaar als er een actief product is
|
||||
- **Todo's** — altijd bereikbaar
|
||||
|
||||
In het midden van de NavBar staat een dropdown met de naam van het actieve product. Via deze dropdown kan de gebruiker wisselen tussen producten of naar "Producten beheren" navigeren.
|
||||
|
||||
### Activeren
|
||||
|
||||
- **Dashboard**: elke productrij toont een "Activeer"-knop (verborgen voor het al actieve product). Het actieve product krijgt een "Actief"-badge. Klikken → actief product instellen + navigeer naar Product Backlog.
|
||||
- **Product Backlog header**: als dit product nog niet actief is, staat er een "Activeer"-knop in de header.
|
||||
|
||||
Demo-gebruikers zien de knoppen maar krijgen een toast "Niet beschikbaar in demo-modus" bij het klikken.
|
||||
|
||||
### Edge cases
|
||||
|
||||
- **Archiveren**: wanneer een eigenaar een product archiveert, wordt `active_product_id` voor alle leden die dit product actief hadden automatisch op `null` gezet (atomisch via `$transaction`).
|
||||
- **Product verlaten**: wanneer een lid het product verlaat, wordt hun `active_product_id` gecleard.
|
||||
- **Lid verwijderen**: wanneer een eigenaar een lid verwijdert, wordt dat lid's `active_product_id` gecleard.
|
||||
- **Stale referentie**: als bij een request `active_product_id` verwijst naar een gearchiveerd of onbereikbaar product (bijv. toegang ingetrokken in een andere sessie), cleared de layout de referentie server-side en redirect naar `/dashboard` met de toast "Je actieve product is niet meer beschikbaar".
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue