Compare commits

..

17 commits

Author SHA1 Message Date
a9616ff122 fix(M10): close pair/stream race + demo-block on cancelPairing
Twee P1's uit code-review:

(1) pair/stream race: de findUnique die de pairing-status leest gebeurde vóór
LISTEN actief was. Als de mobiel approvet tussen die query en LISTEN: pg_notify
fired in dat venster gaat verloren (Postgres queuet niet voor abonnees die
nog niet listen) én was de eerder gelezen status stale. De catch-up state-
event emitte dus 'pending' terwijl de DB inmiddels 'approved' was, en de
desktop bleef hangen tot expiry.

Tweede findUnique toegevoegd ná LISTEN actief is: het venster sluit, omdat
elke approve na dat punt via de notify-handler doorkomt. Aanvullend op de
eerdere client-side fix die 'state' events nu ook routeert (commit d6e71f9).

(2) cancelPairing demo-block: cancel was een DB-write zonder demo-guard,
in tegenspraak met de "demo = 403 op writes"-regel. Demo-blokkade
toegevoegd; bestaande test omgedraaid naar 'wordt geblokkeerd, geen DB-write'.

Quality gates: lint 0 errors, tsc clean, vitest 139/139.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:56:21 +02:00
d6e71f915c fix(ST-1007): listen for SSE 'state' event so approve-during-connect resolves
De SSE-route in ST-1004 stuurt de catch-up payload als `event: state\ndata: …`
om een race te dichten: tussen pair/start en SSE-open kan de mobiel approven,
de pg_notify fired vóór onze LISTEN actief is en gaat verloren (Postgres
queuet niet). De server compenseert door direct na connect een `state`-event
te sturen met de huidige status uit de DB.

Maar de client luisterde alleen op 'message'. EventSource routeert events met
`event: <name>` enkel naar listeners voor die exacte naam — het catch-up event
werd dus genegeerd. Gevolg bij een (zeldzame) race: QR blijft hangen tot
expiry omdat noch de notify noch de catch-up doorkomt.

Fix: dezelfde onMessage-handler ook aan 'state' binden (en netjes
unsubscriben bij cleanup). Geen server-side wijziging nodig — protocol bleef
bewust om de semantische scheiding 'initial state' vs 'live notify' te
behouden voor toekomstige clients die er onderscheid in willen maken.

Severity: middel-laag — kleine race-window, geen data/security-impact, alleen
"QR doet niks" tot user op Vernieuwen klikt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:53:50 +02:00
5cbf543c16 fix: call logoutAction directly via useTransition instead of form-ref submit
De form-ref-dance werkte niet betrouwbaar in de huidige base-ui:
- onSelect vuurde requestSubmit() op een hidden form
- Form zat eerst binnen DropdownMenuContent (form geunmount → ref null)
- Form daarna naar top-level verplaatst — vuurde nog steeds geen request af,
  vermoedelijk doordat onSelect in deze base-ui-build niet (consistent) een
  click-event genereerde dat de form-API trigger'de

Vervang door directe call: Server Actions kunnen sinds Next.js 14 als async
functie worden aangeroepen vanuit Client Components. useTransition voorkomt
dat de UI bevriest tijdens de redirect.

Naast onSelect ook onClick als veiligheid voor het geval base-ui later weer
van event-prop wisselt — beide handlers wijzen naar dezelfde idempotente
function (handleLogout via startTransition).

Pendingstate ('Uitloggen…' label, disabled item) zodat dubbele klikken niet
dubbele logoutAction-calls afvuren.

Quality gates: lint 0 errors, tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:40:42 +02:00
4f9a6d2d9e fix: move logout form outside DropdownMenuContent so requestSubmit fires
UserMenu's hidden logout-form zat binnen <DropdownMenuContent>. Wanneer een
DropdownMenuItem onSelect vuurt, sluit base-ui de menu en unmount het
content-portal in dezelfde tick — waardoor de form verdwijnt voordat
requestSubmit() wordt aangeroepen, en logoutFormRef.current null is.

Form naar top-level van het component verplaatsen (als sibling van DropdownMenu,
binnen Fragment) houdt de ref geldig. Geen DOM-side-effecten — form is hidden,
zat nooit visueel in het menu.

Quality gates: lint 0 errors, tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:33:09 +02:00
c87b6156ae docs(ST-1008): document QR-pairing endpoints, flow, threat-model + pattern
docs/API.md — nieuwe sectie 'Auth — QR-pairing (M10)' met alle drie endpoints
(start, stream, claim), cookie-mechaniek, foutcodes (400/401/410/429),
curl-voorbeelden inclusief --cookie-jar.

docs/scrum4me-architecture.md — sectie 'QR-pairing flow' met:
- Mermaid sequence-diagram (start → QR → scan → approve → claim)
- Threat-model (replay, phishing-QR, demo-block, rate-limit, secret-leak,
  long-lived sessie) met expliciete mitigaties
- TTL-rationale voor de drie tijden (5min pending / +5min approved / 8u paired)
- Subsectie 'Waarom geen secret in URL' — fragment-eigenschap + HttpOnly
  cookie + twee gescheiden hashes

docs/patterns/qr-login.md — herbruikbaar pattern 'QR-pairing via unauth-SSE +
pre-auth cookie' met de drie endpoints, vier security-uitgangspunten,
sjabloon-bestanden, TTL-richtlijn, en wanneer NIET te gebruiken.

CLAUDE.md — extra rij in Implementatiepatronen-tabel die naar het nieuwe
pattern-doc verwijst.

Acceptatie ST-1008 (zeven scenario's):
- Happy path: gedekt door manuele E2E in vorige stories (gebruiker bevestigde
  dat M10-stories op Solo bord verschijnen + curl-roundtrip werkt)
- Demo-block: actions/pairing.test.ts → approvePairing demo → Niet beschikbaar
- Replay: pair-claim.test.ts → 410 op tweede claim
- Expiry tijdens pending: pair-stream.test.ts + pairing.test.ts → 410/error
- Expiry tussen approve+claim: pair-claim.test.ts → 410
- Cookie-mismatch op SSE/claim: pair-stream.test.ts + pair-claim.test.ts → 401
- Secret niet in URL/logs: per ontwerp — fragment + cookie reizen niet via
  URL-paden of querystrings (gedocumenteerd in architecture.md)

Quality gates: lint 0 errors, tsc clean, vitest 139/139 (16 files).

M10 is hiermee compleet — feat/M10-qr-login bevat 13 commits klaar voor
gebruiker-acceptatie en PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:26:35 +02:00
3a90fa9d13 feat(ST-1007): add QR login button on /login with SSE listener
Voltooit de desktop-zijde van de QR-pairing-flow. Gebruiker klikt "Inloggen
via mobiel" naast het wachtwoord-formulier → krijgt een QR-code → telefoon
scant en bevestigt → desktop wordt automatisch ingelogd zonder dat er ooit
een wachtwoord is getypt op het publieke apparaat.

app/(auth)/login/qr-login-button.tsx (Client Component):
- Phase-state: idle | starting | showing | expired | claiming
- klik → POST /api/auth/pair/start (credentials:'same-origin' voor s4m_pair)
- QRCodeSVG met fragment-URL als value (level=M, 200px); aria-label
- EventSource('/api/auth/pair/stream/<id>', { withCredentials: true })
  vereist voor cookie-auth — standaard verstuurt EventSource geen credentials
- bij data.status === 'approved': es.close → POST /pair/claim → router.push('/dashboard')
- aftellende timer (mm:ss); bij 0s → 'expired' state met Vernieuwen-knop
- cleanup bij unmount: removeEventListener + close
- A11y: <details> sectie toont fragment-URL als kopieerbare tekst voor screenreaders en gebruikers zonder camera

app/(auth)/login/page.tsx: QrLoginButton onder het bestaande wachtwoord-form
met "of"-divider, achter de bestaande surface-container-low styling.

Dependency: qrcode.react ^4.2.0 (client-side SVG; geen extra round-trip;
mobileSecret blijft op desktop in JS-geheugen).

Quality gates: lint 0 errors, tsc clean, vitest 139/139, next build slaagt
(login-route static, m/pair en pair/* dynamic).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:21:10 +02:00
c48e30df1f fix(M10): bump pending-TTL to 5min + repair MD3 contrast on pair page
TTL: 2 min was te kort voor handmatig curl-paste-confirm-testen — gebruiker
zag 'Pairing verlopen' voor hij kon bevestigen. Bumpt naar 5 min (gelijk aan
approved-TTL): nog steeds tight voor security, ruim voor menselijke reactie.
- app/api/auth/pair/start/route.ts: PENDING_TTL_MS 120s → 300s
- lib/auth/pair-cookie.ts: MAX_AGE_SECONDS 120 → 300
- __tests__/api/pair-start.test.ts: maxAge en expires_at-window meegegroeid

Kleuren: bevestigingspagina gebruikte bg-destructive/10 + text-destructive-
foreground — beide lichte kleuren, te weinig contrast. Vervangen door MD3
container-tokens (zelfde patroon als components/auth/auth-form.tsx):
- error-state: bg-error-container + text-error-container-foreground + border-l-4 border-error
- approved-state: bg-success-container + foreground + accent-border
- cancelled-state: bg-surface-container-high + neutral foreground

Quality gates: lint 0 errors, tsc clean, vitest 139/139.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:15:02 +02:00
5c4ee150ea feat(ST-1006): add /api/auth/pair/claim with atomic consume + iron-session
POST /api/auth/pair/claim (cookie-auth, runtime: 'nodejs'):
- Auth via s4m_pair HttpOnly cookie alleen — body bevat enkel pairingId, geen
  secret. Het cookie-token is het bewijs.
- Atomic state-transitie via prisma.loginPairing.updateMany met composite
  WHERE (id + status='approved' + desktop_token_hash + expires_at > now);
  PostgreSQL row-locking garandeert dat concurrent dubbele claims slechts één
  count=1 zien — de rest 410.
- Bij geen rij geüpdate: tweede findFirst om te disambigueren tussen 401
  (cookie matcht geen pairing) en 410 (al consumed/cancelled). Cookie altijd
  gecleared bij faalpaden om herhaalde verwerking te voorkomen.
- Bij succes: getIronSession schrijft scrum4me-session-cookie met userId +
  isDemo (uit user-record als vangnet) + paired=true + pairedExpiresAt = now+8h
  (kortere TTL voor publieke desktops). s4m_pair wordt gecleared.
- Logging onder NODE_ENV !== 'production' alleen pairingId, nooit cookie of
  mobileSecret.

Tests __tests__/api/pair-claim.test.ts (7 cases):
- 200 happy: updateMany met juiste WHERE, iron-session payload (userId, isDemo,
  paired, pairedExpiresAt ~8h), save() called, s4m_pair cleared
- demo-vangnet: isDemo=true wordt doorgezet
- 401 zonder cookie (geen DB-call)
- 400 op malformed body
- 400 zonder pairingId
- 410 op tweede claim (al consumed, cookie cleared, geen session.save)
- 401 op cookie/hash-mismatch (cookie cleared)

Quality gates: lint 0 errors, tsc clean, vitest 139/139.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 22:58:17 +02:00
625221f9ee feat(ST-1005): add pairing server actions + mobile confirmation page
actions/pairing.ts (Server Actions, volgt docs/patterns/server-action.md):
- getPairingForApproval(pairingId, mobileSecret): auth + Zod + lookup + status
  + expiry + verifyToken-check; retourneert UA/IP/username voor de
  bevestigingspagina. Demo MAG aanroepen (read-only).
- approvePairing: zelfde checks PLUS demo-blokkade (session.isDemo). Update
  status pending→approved, zet user_id + approved_at, bumpt expires_at +5min.
  Postgres-trigger emit pg_notify automatisch — desktop-SSE pikt het op.
- cancelPairing: status pending→cancelled. Demo mag annuleren.
- Tagged-union return-type uit loadPendingPairing voor schone discriminatie.

app/(app)/m/pair/page.tsx (Server Component, achter (app)/layout-guard):
- Geen searchParams uitlezen — page leest URL niet. Alleen statische uitleg +
  PairConfirmation client-island.

app/(app)/m/pair/pair-confirmation.tsx (Client Component):
- useEffect parseert window.location.hash voor #id=…&s=… (server ziet de
  fragment nooit)
- Roept getPairingForApproval om UA/IP/username op te halen
- Toont kaart "Inloggen als <username> op dit apparaat?" met UA + IP +
  expliciete waarschuwing tegen phishing-QR; Bevestig/Annuleer-knoppen
- Na approve: window.history.replaceState wist de hash zodat back/forward de
  secret niet meer onthult; transitioneert naar success-state
- queueMicrotask voor synchrone setState om React-Compiler "cascading renders"
  warning te vermijden

Tests __tests__/actions/pairing.test.ts (11 cases):
- getPairingForApproval: ok + 5 fail-paths (geen sessie, approved, verlopen,
  verkeerd secret, ongeldige cuid)
- approvePairing: happy + demo-block + verkeerd secret (geen DB-write)
- cancelPairing: happy + demo mag annuleren

Quality gates: lint 0 errors, tsc clean, vitest 132/132.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 22:50:42 +02:00
2a0c6a512d feat(ST-1004): add SSE /api/auth/pair/stream with cookie auth
GET /api/auth/pair/stream/[pairingId]:
- runtime: 'nodejs', maxDuration: 300, dynamic: 'force-dynamic'
- Auth via s4m_pair HttpOnly cookie (readPairCookie + verifyToken tegen
  desktop_token_hash); 401 zonder cookie of bij hash-mismatch, 404 als pairing
  onbekend, 410 als verlopen — geen geheim materiaal in URL of querystring
- Hergebruikt LISTEN/NOTIFY-pattern uit app/api/realtime/solo/route.ts:
  ReadableStream + dedicated pg.Client + heartbeat 25s + hard-close 240s
- Channel: scrum4me_pairing; filter notifies op pairing_id-match
- Initial 'state'-event direct na connect met huidige status (voorkomt race
  waarbij approve net vóór SSE-open landt — desktop ziet 'm alsnog)
- Auto-close zodra status consumed/cancelled binnenkomt
- Fallback DIRECT_URL → DATABASE_URL (de eerste staat lokaal op een placeholder)

Tests __tests__/api/pair-stream.test.ts (4 cases — auth-paden):
- 401 zonder cookie (en geen DB-call gedaan)
- 404 op onbekende pairingId
- 410 op verlopen pairing
- 401 op cookie/hash-mismatch

Full-stream-test (LISTEN+notify-roundtrip) is een handmatige acceptatietest in
ST-1008 — niet zinvol te mocken voor v1.

Quality gates: lint 0 errors, tsc clean, vitest 121/121.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 22:42:07 +02:00
e0bec8c55c feat(ST-1003): add /api/auth/pair/start with rate-limit + pre-auth cookie
POST /api/auth/pair/start (anon, runtime: 'nodejs'):
- Geen authenticateApiRequest — desktop heeft nog geen sessie
- Genereert los mobileSecret + desktopToken via lib/auth/pairing
- Persisteert alleen sha256-hashes in login_pairings; status='pending', expires_at = now + 2 min
- Slaat user-agent + best-effort IP op (afgekapt op kolom-grootte)
- Set-Cookie via setPairCookie helper: HttpOnly, Path=/api/auth/pair, Max-Age=120, SameSite=Lax
- Response body: { pairingId, mobileSecret, expiresAt, qrUrl } met qrUrl = origin/m/pair#id=…&s=…
  → secret reist alleen via fragment (#…), nooit in querystring of access logs

Rate-limit: 'pair-start' expliciet aan lib/rate-limit.ts CONFIGS toegevoegd
voor self-documentatie (10/min, gelijk aan login).

Tests __tests__/api/pair-start.test.ts (6 cases):
- 200 met body-shape (pairingId, mobileSecret 43-char base64url, qrUrl met
  fragment, expiresAt ISO)
- alleen hashes in DB, geen plaintext
- cookie set met juiste opties
- UA + IP afgekapt op kolom-grootte
- IP=null als x-forwarded-for ontbreekt
- 11e POST levert 429 met NL foutmelding

Quality gates: lint 0 errors, tsc clean (na prisma generate), vitest 117/117.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 22:34:49 +02:00
b4813e6e54 feat(ST-1002): add pairing helpers, pre-auth cookie + paired-session guard
lib/auth/pairing.ts: pure crypto-helpers voor de QR-pairing flow.
- generateMobileSecret() / generateDesktopToken() — beide 32 bytes base64url, los
  zodat ze elkaar niet onthullen
- hashToken(t) — sha256-hex
- verifyToken(t, hash) — timingSafeEqual met length-guard
- isPairedSessionExpired(session) — geëxtraheerde helper zodat de Server-
  Component-render Date.now() niet rechtstreeks aanroept (React Compiler-flag)

lib/auth/pair-cookie.ts: HttpOnly pre-auth cookie helpers (s4m_pair).
- Path=/api/auth/pair, Max-Age=120s (gelijk aan pending-TTL pairing),
  SameSite=Lax, Secure in productie

lib/session.ts: SessionData uitgebreid met optionele paired + pairedExpiresAt.

app/(app)/layout.tsx: guard die paired-sessies vernietigt zodra
pairedExpiresAt verstreken is en redirect naar /login.

Tests: 14 unit-tests in __tests__/lib/auth/pairing.test.ts dekken hash-
determinisme, timing-safe verify (true/false/length-mismatch), generator-
uniciteit en vier expiry-scenario's voor isPairedSessionExpired.

Quality gates: npm run lint (0 errors), tsc --noEmit clean, vitest 111/111.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 22:23:00 +02:00
075cf28a5e feat(ST-1001): add LoginPairing model + pg_notify trigger via migration
Schema (prisma/schema.prisma):
- model LoginPairing met id (cuid), secret_hash + desktop_token_hash (beide NOT
  NULL — scheiden mobiel- en desktop-bewijs), status (pending|approved|consumed
  |cancelled), optionele user_id met onDelete: SetNull, desktop_ua VarChar(255),
  desktop_ip VarChar(45) voor IPv6, created_at + expires_at + approved_at +
  consumed_at, indexes op (expires_at) en (status, expires_at)
- back-relation login_pairings LoginPairing[] op User

Migratie (20260427200734_add_login_pairing):
- Prisma-gegenereerde DDL voor login_pairings + indexes + FK
- Toegevoegde notify_pairing_change() functie + login_pairings_notify trigger
  op AFTER INSERT/UPDATE; emit pg_notify('scrum4me_pairing', payload) met
  { op: 'I'|'U', pairing_id, status }
- DELETE niet ondersteund — pairings gaan naar consumed/cancelled, niet weg
- Channel naam analoog aan scrum4me_changes uit ST-801

Verification: Node pg-client roundtrip-test via DATABASE_URL toonde notifies bij
INSERT (op=I) en UPDATE (op=U) met correcte payload-shape.

Bouwt voort op M8 LISTEN/NOTIFY-infra. SSE-route /api/auth/pair/stream/[id] in
ST-1004 abonneert hierop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 22:10:51 +02:00
f0203fe314 chore(M10): add npm run seed shortcut
Wrapt prisma db seed (die de bestaande prisma.seed-config in package.json gebruikt)
zodat re-seeden één korte invocatie wordt zonder de prisma-CLI-syntaxis te onthouden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 22:02:36 +02:00
414ef58aa3 chore(M10): drop hardcoded Solo Paneel demo data from seed
DB wordt voortaan leidend voor de werkstaat; testdata voor andere projecten /
demo-scenario's komt elders. Deze hardgecodeerde set was specifiek gemaakt voor
de M3.5 Solo Paneel-demo en raakt nu het next_story-resultaat: priority=2 won
van de M10 parser-stories (priority=4) waardoor get_claude_context op
'Gebruikersauthenticatie opzetten' bleef hangen i.p.v. ST-1001.

Vervangt de eerdere M3.5-gating-aanpak (commit 0e3228d) — schoner om het
helemaal weg te halen dan met een conditional aanwezig te houden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 22:00:58 +02:00
0e3228d56f chore(M10): gate Solo demo-stories on M3.5 active milestone
De hardcoded Solo Paneel-demoset uit M3.5 (priority=2) schreeuwt over de
parser-driven M10-stories heen (priority=4) en laat get_claude_context op
"Gebruikersauthenticatie opzetten" wijzen i.p.v. ST-1001.

Sluit het blok nu alleen open als de actieve sprint van het Scrum4Me-product
M3.5 betreft. Voor M10+ leveren de parser-stories zelf de bord-content; de
demo-set blijft beschikbaar als M3.5 ooit weer ACTIVE wordt voor demo-doeleinden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:57:20 +02:00
4af3b302b4 chore(M10): swap demo-active sprint from M3.5 to M10
M3.5 was de demo-actieve sprint zolang er geen recentere milestone in progress
was. Nu M10 het actieve werk is, willen we dat get_claude_context (en
implement_next_story) ST-1001 als next-story teruggeven i.p.v. ST-350.

Vereist een herhaling van npx prisma db seed na deze commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:29 +02:00
687 changed files with 8268 additions and 85377 deletions

View file

@ -0,0 +1,12 @@
{
"permissions": {
"allow": [
"Bash(npx tsc *)",
"Bash(git add *)",
"Bash(git commit *)",
"Bash(git push *)",
"Bash(npx eslint *)",
"Bash(npm run *)"
]
}
}

View file

@ -8,36 +8,3 @@ SESSION_SECRET="replace-with-at-least-32-characters"
# Optional; Vercel and Node set this automatically in deployed environments.
NODE_ENV="development"
# M11 (ST-1107) — shared secret between Vercel cron-trigger and the
# /api/cron/expire-questions handler. Required in production; optional in
# local dev (the route returns 401 if the Authorization header doesn't match).
# Generate with: openssl rand -base64 32
CRON_SECRET=""
# PBI-55 — Web Push (VAPID). All optional; app starts without these.
# Generate keys with: npx web-push generate-vapid-keys
NEXT_PUBLIC_VAPID_PUBLIC_KEY=""
VAPID_PRIVATE_KEY=""
# Must start with mailto: e.g. mailto:admin@example.com
VAPID_SUBJECT="mailto:admin@example.com"
# Shared secret for POST /api/internal/push/send — min 32 chars
# Generate with: openssl rand -base64 32
INTERNAL_PUSH_SECRET=""
# PBI-66 — Anthropic API key voor `npm run db:sync-model-prices`.
# Optional. Alleen nodig om wekelijks de model_prices tabel te synchroniseren.
# Genereer op https://console.anthropic.com/ → API Keys.
# /v1/models is een gratis metadata-call (geen tokens, geen credit nodig).
ANTHROPIC_API_KEY=""
# v1-readiness item 2 — Sentry error monitoring.
# Optional. Without DSN, the SDK is a no-op (no network, no overhead).
# Get a DSN at https://sentry.io → Project → Settings → Client Keys (DSN).
NEXT_PUBLIC_SENTRY_DSN=""
# Required ONLY if you want source-map upload during build (production deploy).
# In Vercel: project settings → Environment Variables → add as encrypted.
SENTRY_ORG=""
SENTRY_PROJECT=""
SENTRY_AUTH_TOKEN=""

View file

@ -5,23 +5,11 @@ on:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
inputs:
target:
type: choice
description: Deploy target
options: [preview, production]
default: preview
permissions:
contents: read
pull-requests: read
jobs:
ci:
name: Lint, Typecheck, Test & Build
runs-on: ubuntu-latest
if: github.event_name != 'workflow_dispatch'
steps:
- name: Checkout
@ -51,9 +39,6 @@ jobs:
- name: Test
run: npm test
- name: Check doc links
run: npm run docs:check-links
- name: Build
run: npm run build
env:
@ -61,52 +46,11 @@ jobs:
DIRECT_URL: ${{ secrets.DIRECT_URL }}
SESSION_SECRET: ${{ secrets.SESSION_SECRET }}
changes:
name: Detect deploy-relevant changes
runs-on: ubuntu-latest
needs: ci
# Alleen relevant voor auto-deploy jobs; skip wanneer auto-deploy uit staat.
if: vars.AUTO_DEPLOY_ENABLED == 'true' && github.event_name != 'workflow_dispatch'
outputs:
code: ${{ steps.filter.outputs.code }}
steps:
- uses: actions/checkout@v5
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
code:
- 'app/**'
- 'components/**'
- 'lib/**'
- 'actions/**'
- 'stores/**'
- 'prisma/**'
- 'public/**'
- 'package.json'
- 'package-lock.json'
- 'next.config.ts'
- 'tsconfig.json'
- 'vercel.json'
- 'proxy.ts'
- 'middleware.ts'
- '.github/workflows/**'
deploy-preview:
name: Deploy Preview (PR)
runs-on: ubuntu-latest
needs: [ci, changes]
# Auto-deploy is uit. Gebruik "Run workflow" (workflow_dispatch) op de
# Actions-pagina voor handmatige deploys. Zet repo-variable
# AUTO_DEPLOY_ENABLED=true in Settings → Secrets and variables → Actions
# om PR-preview-deploys weer in te schakelen.
if: |
vars.AUTO_DEPLOY_ENABLED == 'true'
&& github.event_name == 'pull_request' && (
(needs.changes.outputs.code == 'true'
&& !contains(github.event.pull_request.labels.*.name, 'skip-deploy'))
|| contains(github.event.pull_request.labels.*.name, 'force-deploy')
)
needs: ci
if: github.event_name == 'pull_request'
steps:
- name: Checkout
@ -133,15 +77,8 @@ jobs:
deploy-production:
name: Deploy Production (main)
runs-on: ubuntu-latest
needs: [ci, changes]
# Auto-deploy is uit. Gebruik "Run workflow" (workflow_dispatch) →
# target=production voor handmatige productie-deploys. Zet repo-variable
# AUTO_DEPLOY_ENABLED=true om push-naar-main weer auto te deployen.
if: |
vars.AUTO_DEPLOY_ENABLED == 'true'
&& github.ref == 'refs/heads/main'
&& github.event_name == 'push'
&& needs.changes.outputs.code == 'true'
needs: ci
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- name: Checkout
@ -170,42 +107,3 @@ jobs:
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
deploy-manual:
name: Deploy Manual (workflow_dispatch)
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch'
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: '24'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Vercel CLI
run: npm install -g vercel@latest
- name: Run database migrations (production only)
if: inputs.target == 'production'
run: npx prisma migrate deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
DIRECT_URL: ${{ secrets.DIRECT_URL }}
- name: Deploy
run: |
if [ "${{ inputs.target }}" = "production" ]; then
vercel deploy --prod --token=${{ secrets.VERCEL_TOKEN }}
else
vercel deploy --token=${{ secrets.VERCEL_TOKEN }}
fi
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}

View file

@ -1,41 +0,0 @@
name: Daily Neon Database Backup
on:
schedule:
- cron: "0 2 * * *"
workflow_dispatch:
jobs:
backup:
runs-on: ubuntu-latest
steps:
- name: Install PostgreSQL 17 client
run: |
sudo apt-get update
sudo apt-get install -y curl ca-certificates gnupg
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo gpg --dearmor -o /usr/share/keyrings/postgresql.gpg
echo "deb [signed-by=/usr/share/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt noble-pgdg main" | sudo tee /etc/apt/sources.list.d/pgdg.list
sudo apt-get update
sudo apt-get install -y postgresql-client-17
pg_dump --version
- name: Create backup
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: |
mkdir -p backups
DATE=$(date +"%Y-%m-%d_%H-%M-%S")
/usr/lib/postgresql/17/bin/pg_dump "$DATABASE_URL" \
--format=custom \
--no-owner \
--no-privileges \
--file="backups/neon-backup-$DATE.dump"
- name: Upload backup artifact
uses: actions/upload-artifact@v4
with:
name: neon-database-backup
path: backups/*.dump
retention-days: 30

19
.gitignore vendored
View file

@ -50,9 +50,9 @@ next-env.d.ts
# Claude Code local settings
.claude/settings.local.json
.claude/worktrees/
# Local plan/scratch files (per-developer, not shared)
.Plans/
# Editor
.vscode/
@ -60,20 +60,3 @@ next-env.d.ts
#Screenshots (lokale bron-bestanden negeren, maar /public/screenshots wordt wel gecommit)
screenshots/
!public/screenshots/
# Testomgeving
jp.sh
# MCP config (bevat credentials)
.mcp.json
# Codex local config
.codex/
# Lokale scratch-bestanden
Brainstro
/graphify-out
# Personal Obsidian authoring layer (vault config + sidecar files prefixed `_`)
.obsidian/
_*.md

View file

@ -1,6 +1 @@
npx lint-staged
if git diff --cached --name-only | grep -q '^docs/.*\.md$'; then
npm run docs:index
git add docs/INDEX.md
fi

View file

@ -1,23 +1,38 @@
---
title: "AGENTS.md — Scrum4Me agent rules"
status: active
audience: [ai-agent]
language: en
last_updated: 2026-05-03
---
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
# Agent Instructions — Scrum4Me
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
This file is a redirect stub. All agent instructions live in **[CLAUDE.md](./CLAUDE.md)**.
# Scrum4Me Codex Rules
For Claude Code specifically, CLAUDE.md is loaded automatically. Start there.
Read `CLAUDE.md` and the relevant files in `docs/` before changing behavior. The same product and security rules apply to Codex work.
## Branch & PR-flow (quick reference)
## Access Control
| Moment | Actie | Verbod |
|---|---|---|
| Start run | `git checkout -b feat/<batch-slug>` | `gh pr create` |
| Na elke taak | `git add -A && git commit -m "<type>(ST-XXX): <title>"` | `git push` |
| Queue leeg | `git push -u origin <branch>` + `gh pr create` | — |
- Product-scoped access is owner-or-member: use `productAccessFilter(userId)` from `lib/product-access.ts`.
- Use owner-only `user_id` checks only for actions that truly require ownership, such as product archiving and team management.
- Never trust client-provided IDs by themselves. For reorder, promotion, completion, or bulk updates, fetch the records with both `id in (...)` and the parent scope (`product_id`, `pbi_id`, `sprint_id`, or `story_id`) before writing.
- Reject duplicate IDs in ordered lists or decision payloads.
- Derive denormalized fields from database parents, for example `pbi.product_id`, not from form data or JSON bodies.
- Demo users and demo API tokens must receive 403 on write operations.
Full details: [docs/runbooks/branch-and-commit.md § Agent-batch flow](./docs/runbooks/branch-and-commit.md)
## Documentation Sync
When changing behavior, API responses, dependencies, environment variables, deployment behavior, or analytics, update the matching docs in the same change:
- `README.md` for setup, dependencies, deployment, and API overview.
- `docs/scrum4me-functional-spec.md` for user-facing/API requirements.
- `docs/scrum4me-architecture.md` for stack, access model, data model, env vars, and deployment.
- `docs/patterns/` when a reusable implementation rule changes.
- `CLAUDE.md` and this file when an agent instruction would have prevented the issue.
## Verification
Before handing work back, run:
```bash
npm run lint
npm test
npm run build
```

View file

@ -1,106 +0,0 @@
# Changelog
All notable changes to **Scrum4Me** are documented in this file.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [Unreleased]
---
## [1.0.0] — 2026-05-04
**Eerste stabiele release** — MVP volgens functional spec is af, getest en in
productie. Geen breaking changes ten opzichte van 0.9.0; deze tag markeert de
launch-ready state na de v1-readiness-checklist (Now + Before-launch items).
### Added
- Rate-limiting: `enforceUserRateLimit(scope, userId)` helper toegepast op alle
high-value mutation paths — PBI/Story/Task/Todo/Sprint/Product/Token create,
Claude job enqueue, answerQuestion, story-log POST, avatar upload.
([#86](https://github.com/madhura68/Scrum4Me/pull/86))
- Sentry error-monitoring scaffolding (`@sentry/nextjs`) met no-op fallback
zonder DSN. Activeer via `NEXT_PUBLIC_SENTRY_DSN` in Vercel env-vars.
([#85](https://github.com/madhura68/Scrum4Me/pull/85))
- `CHANGELOG.md` (Keep a Changelog formaat) + `docs/runbooks/v1-smoke-test.md`
— 11-secties pre-launch verificatie. ([#89](https://github.com/madhura68/Scrum4Me/pull/89))
### Changed
- A11y Lighthouse score op `/products/[id]` van 86 → ≥95: `aria-selected`
`aria-pressed` op PBI-cards (correct ARIA role-attribute pairing); tap-targets
≥28×28 px op hover-icon-buttons. ([#88](https://github.com/madhura68/Scrum4Me/pull/88))
- A11y form-label associaties (`htmlFor` + `id`) op happy-path dialogen
(Story/Task + Promote-PBI/Story); auth-pages krijgen `<main>` landmark.
([#87](https://github.com/madhura68/Scrum4Me/pull/87))
- README: test-count 69 → 445, env-vars-tabel uitgebreid met `CRON_SECRET` en
Sentry-vars. ([#89](https://github.com/madhura68/Scrum4Me/pull/89))
### Fixed
- Demo-policy: drie mutation-paden zonder `isDemo`-check gedicht
(`toggleTodoAction`, `archiveCompletedTodosAction`, `leaveProductAction`).
([#89](https://github.com/madhura68/Scrum4Me/pull/89))
### Security
- Vier debug-routes (`/debug-env`, `/debug-realtime`, `/api/debug/*`) krijgen
een NODE_ENV-guard → 404 in productie. ([#89](https://github.com/madhura68/Scrum4Me/pull/89))
---
## [0.9.0] — 2026-05-04
[GitHub Release](https://github.com/madhura68/Scrum4Me/releases/tag/v0.9.0)
### Added
- **PBI-11: Mobile-shell met landscape-lock** ([#81](https://github.com/madhura68/Scrum4Me/pull/81)):
- Aparte route group `app/(mobile)/m/{settings,pair,products}/...` met eigen
layout (zonder NavBar/StatusBar/MinWidthBanner)
- `LandscapeGuard` (rotate-overlay in portrait), `MobileTabBar` (3 lucide-iconen)
- PWA-manifest met `"orientation": "landscape"`
- UA-redirect bij login: telefoons (`Mobi`-substring) → `/m/products/[active]/solo`,
tablets en desktop → `/dashboard`
- Gedeelde `lib/auth-guard.ts` `requireSession()` helper, hergebruikt door beide layouts
- Mobile-fullscreen voor entity-dialogen via gedeelde `entityDialogContentClasses`
- Sprint Product-Backlog kolom: filter-popover (prioriteit + status) en
edit-iconen op PBI/story/task-rijen. ([#79](https://github.com/madhura68/Scrum4Me/pull/79))
- Edit-icoon op product-card in dashboard (consistent met PBI/story/task-pattern).
([#83](https://github.com/madhura68/Scrum4Me/pull/83))
- v1.0 readiness checklist in `docs/old/plans/v1-readiness.md`.
([#82](https://github.com/madhura68/Scrum4Me/pull/82))
### Changed
- Refactor `app/(app)/layout.tsx` om gedeelde `requireSession()` te gebruiken
(gedrag onveranderd). ([#81](https://github.com/madhura68/Scrum4Me/pull/81))
- `/m/pair` filesystem-verhuisd uit `(app)/` naar `(mobile)/` — URL onveranderd.
([#81](https://github.com/madhura68/Scrum4Me/pull/81))
---
## [0.4.0] — eerder
### Added
- M9 — Actief Product Backlog: persistente actieve PB-keuze, gesplitste
navigatie, disabled-states bij geen actief product
---
## [0.3.1] — eerder
Initiële stabilisatie-release.
---
## Pre-0.3.x
Foundation-werk (M0 t/m M8) is niet retroactief in dit changelog opgenomen.
Voor de volledige milestone-historie zie [docs/old/backlog/index.md](./docs/old/backlog/index.md).
---
[Unreleased]: https://github.com/madhura68/Scrum4Me/compare/v1.0.0...HEAD
[1.0.0]: https://github.com/madhura68/Scrum4Me/releases/tag/v1.0.0
[0.9.0]: https://github.com/madhura68/Scrum4Me/releases/tag/v0.9.0
[0.4.0]: https://github.com/madhura68/Scrum4Me/commit/615f0c8
[0.3.1]: https://github.com/madhura68/Scrum4Me/commit/ecc05dd

321
CLAUDE.md
View file

@ -1,100 +1,106 @@
---
title: "CLAUDE.md — Scrum4Me"
status: active
audience: [ai-agent]
language: nl
last_updated: 2026-05-11
---
# CLAUDE.md — Scrum4Me
Desktop-first Scrum-app voor solo developers en kleine teams. Hiërarchie: product → PBI → story → taak. Zie [README.md](./README.md) voor setup.
Dit is het centrale instructiedocument voor Claude Code. Lees dit volledig voordat je iets bouwt.
---
## Orientatie
## Wat is Scrum4Me?
| Bestand | Waarvoor |
Een desktop-first fullstack webapplicatie voor solo developers en kleine Scrum Teams die meerdere softwareprojecten parallel beheren. De app organiseert werk hiërarchisch (product → PBI → story → taak), biedt gesplitste planningsschermen met drag-and-drop, en integreert met Claude Code via een REST API.
---
## Specificatiedocumenten
Lees het relevante document voordat je aan een feature begint. Nooit gokken over requirements.
| Document | Gebruik voor |
|---|---|
| `docs/INDEX.md` | Gegenereerde index van alle docs — begin hier |
| `docs/specs/functional.md` | Acceptatiecriteria, user flows |
| `docs/architecture.md` | Breadcrumb → 6 topische arch-bestanden |
| `docs/api/rest-contract.md` | REST API contract voor Claude Code |
| `docs/design/styling.md` | **Lees vóór elk component** — MD3-tokens, shadcn |
| `docs/adr/` | Architecture Decision Records — tech-keuzes (base-ui vs Radix, sort-order, demo-policy, …) |
| `docs/architecture/` | 6 topische architecture-bestanden (data-model, auth, sprint-execution, …) — uitwerking van `docs/architecture.md` |
| `docs/runbooks/plan-to-pbi-flow.md` | **Na goedgekeurd plan** — PBI/Story/Task aanmaken via MCP, zónder direct uitvoeren |
| `docs/scrum4me-functional-spec.md` | Acceptatiecriteria, randgevallen, user flows |
| `docs/scrum4me-architecture.md` | Stack, datamodel, Prisma schema, Zustand stores |
| `docs/scrum4me-backlog.md` | Welke task bouwen, volgorde, "done when"-criteria |
| `docs/scrum4me-personas.md` | Lars (primair), Dina, Remi — gebruik bij UI-beslissingen |
| `docs/scrum4me-product-backlog.md` | Historische domein-backlog (referentie); seed wordt sinds ST-004 gegenereerd uit `scrum4me-backlog.md` via `prisma/seed-data/parse-backlog.ts` |
| `docs/API.md` | REST-API contract voor Claude Code — endpoints, status-enums, foutcodes, voorbeeld-curls |
| `docs/scrum4me-styling.md` | **Lees dit voor elk component** — MD3-kleuren, shadcn patronen |
| `docs/agent-instruction-audit.md` | Waarom de agent-instructies zijn aangescherpt; checklist voor toekomstige wijzigingen |
| `docs/plans/<milestone-key>-*.md` | Implementatieplan per milestone — Bestanden, Stappen, Aandachtspunten, Verificatie. Lees vóór je aan een ST begint. Milestone-key matcht backlog-header (`M9`, `M3.5`, `PBI-9`, …). |
| [`madhura68/scrum4me-mcp`](https://github.com/madhura68/scrum4me-mcp) | MCP-server repo: native tools voor Claude Code, schema-sync via git submodule |
---
## Hoe werk vinden
## Waar te beginnen
1. Branch aanmaken: `git checkout -b feat/<batch-slug>` — nog **geen** `gh pr create`
2. `mcp__scrum4me__get_claude_context` → pak de next story
3. Voer taken uit in `sort_order`; update status per taak
4. Lees het relevante patroon en styling vóór je begint
5. Verifieer: `npm run verify && npm run build``verify` = lint + typecheck + test
6. Commit per laag: `git add -A && git commit`**geen** `git push` — zie [docs/runbooks/branch-and-commit.md](./docs/runbooks/branch-and-commit.md)
7. Herhaal stap 26 per story; branch blijft dezelfde
8. Queue leeg → `git push -u origin <branch>` + `gh pr create`
Volg de backlog strikt op volgorde. Start bij **ST-001**. Sla geen milestone over.
Volledige MCP-tool documentatie: [docs/runbooks/mcp-integration.md](./docs/runbooks/mcp-integration.md)
```
M0 (ST-001008) → M1 (ST-101110) → M2 (ST-201210)
→ M3 (ST-301312) → M4 (ST-401410) → M5 (ST-501506)
→ M6 (ST-601612)
```
Werken aan een task kan via twee tracks. Track A heeft de voorkeur als je in Claude Code zit; Track B is voor Codex of omgevingen zonder MCP.
### Track A — via Claude Code MCP (aanbevolen)
1. Roep `mcp__scrum4me__implement_next_story` aan met `product_id` (gebruik `mcp__scrum4me__list_products` als je het id niet weet)
2. De prompt orkestreert: `get_claude_context``log_implementation` → per task `update_task_status(in_progress)` → bouw → `update_task_status(done)``log_test_result``log_commit`
3. Bouw de tasks in volgorde van `sort_order`; lees per task de relevante pattern-doc en styling
4. Verifieer: `npm run lint && npm test && npm run build`
5. Commit per laag (zie Commit Strategy)
### Track B — manueel (Codex of zonder MCP)
1. Lees de task in `scrum4me-backlog.md`
2. Zoek de bijbehorende feature-spec in `scrum4me-functional-spec.md`
3. Lees het relevante patroon in `docs/patterns/` en styling in `docs/scrum4me-styling.md` als dat van toepassing is
4. Bouw — test — verifieer de "Done when"-criteria
5. Vraag of de code correct is
6. Commit (zie Commit Strategy hieronder)
7. Vraag of de volgende taak gedaan moet worden
---
## Hardstop regels
## Tech stack
- **Styling:** nooit `bg-blue-500`; altijd MD3-tokens (`bg-primary`, `bg-status-done`, …)
- **UI:** gebruik `@base-ui/react` met `render`-prop, niet Radix `asChild`
- **Push:** commits accumuleren lokaal per taak (`git add -A && git commit`); push + PR pas bij lege queue of na expliciete gebruikersbevestiging — zie [branch-and-commit.md](./docs/runbooks/branch-and-commit.md)
- **Demo:** drie lagen — proxy.ts + server action + UI disabled knop
- **Proxy:** `proxy.ts` in repo-root (géén `middleware.ts`) onverzegelt de iron-session, redirect niet-geauthenticeerde users op `/dashboard|/products|/ideas`, en blokkeert niet-GET API-writes voor demo-users behalve `/api/cron/*`
- **Enum:** DB UPPER_SNAKE ↔ API lowercase — uitsluitend via `lib/task-status.ts`
- **Foutcodes:** 400 = parse-fout, 422 = Zod-validatie, 403 = demo-token
- **Server/client grens:** `*-server.ts` bevat DB/node-only; nooit importeren in client component
- **Worker/jobs:** `ClaudeJob` queue (`QUEUED → CLAIMED → RUNNING → DONE|FAILED|SKIPPED`); MCP-worker claimt via `wait_for_job` en sluit met `update_job_status` — zie [worker-idempotency.md](./docs/runbooks/worker-idempotency.md)
- **Model/mode per ClaudeJob:** kind-default → product → job-snapshot → `task.requires_opus`. Resolver in `scrum4me-mcp/src/lib/job-config.ts` (en gespiegeld in `lib/job-config.ts`) — zie [job-model-selection.md](./docs/runbooks/job-model-selection.md)
- **Deployment:** `npm run verify && npm run build` vóór elke PR. Selectieve deploy-controle (labels + path-filter): zie [docs/runbooks/deploy-control.md](./docs/runbooks/deploy-control.md)
```
Next.js 16 (App Router) + React 19
TypeScript strict
Tailwind CSS + shadcn/ui
MD3 kleurensysteem via app/styles/theme.css
Zustand (client state)
dnd-kit (drag-and-drop)
Prisma v7 + PostgreSQL (Neon)
iron-session (auth cookies)
bcryptjs + Zod + Sonner
Sharp (avatarverwerking)
Vercel Analytics (@vercel/analytics/next)
```
> ⚠️ **Stylingregel:** Gebruik **nooit** `bg-blue-500` of willekeurige Tailwind-kleuren.
> Gebruik altijd semantische MD3-tokens: `bg-primary`, `bg-status-done`, `bg-priority-critical`.
> Zie `scrum4me-styling.md` voor alle patronen.
> ⚠️ **Next.js-versie:** Lees `node_modules/next/dist/docs/` bij twijfel — API's kunnen afwijken van trainingsdata.
---
## Stack
## Implementatiepatronen
| Laag | Technologie |
|---|---|
| Framework | Next.js 16.2 (App Router) + React 19.2 — PPR/Cache Components beschikbaar |
| Taal | TypeScript strict |
| Styling | Tailwind CSS v4 + shadcn/ui + MD3 via `app/styles/theme.css` |
| State | Zustand + dnd-kit |
| DB | Prisma v7.8 + PostgreSQL (Neon) |
| Auth | iron-session + bcryptjs |
| Test | Vitest (`__tests__/`, config in `vitest.config.ts`) |
| Utilities | Zod, Sonner, Sharp, Vercel Analytics |
---
## Patterns quickref
Lees het relevante patroon vóór je begint. Nooit uit het hoofd schrijven.
| Patroon | Bestand |
|---|---|
| iron-session | `docs/patterns/iron-session.md` |
| Prisma singleton | `docs/patterns/prisma-client.md` |
| Server Action (auth + Zod) | `docs/patterns/server-action.md` |
| Route Handler (REST) | `docs/patterns/route-handler.md` |
| Workspace-store + realtime (PBI-74) | `docs/patterns/workspace-store.md` |
| Zustand optimistic update | `docs/patterns/zustand-optimistic.md` |
| Float sort_order / drag-and-drop | `docs/patterns/sort-order.md` |
| Proxy / route protection | `docs/patterns/proxy.md` |
| QR-pairing | `docs/patterns/qr-login.md` |
| Claude ↔ user vraagkanaal | `docs/patterns/claude-question-channel.md` |
| Entity Dialog (verplicht) | `docs/patterns/dialog.md` |
| Realtime NOTIFY-payload | `docs/patterns/realtime-notify-payload.md` |
| Story met UI-component | `docs/patterns/story-with-ui-component.md` |
| Web Push | `docs/patterns/web-push.md` |
| Job-config resolver (PBI-67) | `lib/job-config.ts``scrum4me-mcp/src/lib/job-config.ts` |
| Debug-id op component-root | `docs/patterns/debug-id.md` |
| Debug-labels (BEM) | `docs/patterns/debug-labels.md` |
| Demo client-state (PBI-80) | `docs/patterns/demo-client-state.md` |
| iron-session (auth cookies) | `docs/patterns/iron-session.md` |
| Prisma Client singleton | `docs/patterns/prisma-client.md` |
| Server Action (met auth + Zod) | `docs/patterns/server-action.md` |
| Route Handler (REST API) | `docs/patterns/route-handler.md` |
| Zustand optimistische update + rollback | `docs/patterns/zustand-optimistic.md` |
| Float sort_order drag-and-drop | `docs/patterns/sort-order.md` |
| Middleware (route protection) | `docs/patterns/middleware.md` |
| QR-pairing (unauth-SSE + pre-auth cookie) | `docs/patterns/qr-login.md` |
| Status-enum mapping (DB ↔ API) | `lib/task-status.ts` |
| Client/server module-boundary | `*-server.ts` bevat DB-calls of node-only deps; `*.ts` is pure (client-safe). Nooit `import { ... } from '@/lib/foo-server'` in een client-component, anders krijg je `Module not found: 'dns'`/`'pg'`-style runtime fouten |
---
@ -102,53 +108,156 @@ Volledige MCP-tool documentatie: [docs/runbooks/mcp-integration.md](./docs/runbo
```bash
DATABASE_URL="" # postgresql://...
DIRECT_URL="" # pooler-bypass voor LISTEN/NOTIFY
SESSION_SECRET="" # min 32 chars
CRON_SECRET="" # Bearer-secret /api/cron/*
DIRECT_URL="" # alleen bij Neon/cloud
SESSION_SECRET="" # openssl rand -base64 32
```
Volledig schema: `lib/env.ts`. Canonieke lijst: `.env.example` — bevat ook web-push (`VAPID_*`, `INTERNAL_PUSH_SECRET`), Sentry (`SENTRY_*`) en optioneel `ANTHROPIC_API_KEY`.
---
## MCP & cron
## Conventies
- **MCP-server (extern):** standalone Node-proces in `~/Development/scrum4me-mcp/` — Prisma-schema gesynced via `sync-schema.sh`. 30+ tools (`get_claude_context`, `wait_for_job`, `update_task_status`, …)
- **Bewuste duplicaten:** `lib/job-config.ts` (deze repo) en `scrum4me-mcp/src/lib/job-config.ts` (externe MCP) bevatten dezelfde resolver-logica; dit voorkomt dat de MCP-server Next-deps importeert. **Wijzig beide** bij elke job-config aanpassing
- **Cron (vercel.json):**
- `/api/cron/expire-questions` — dagelijks 04:00 UTC
- `/api/cron/cleanup-agent-artifacts` — dagelijks 03:00 UTC
- **Realtime:** SSE op `/api/realtime/*`, gevoed door PostgreSQL `LISTEN`/`NOTIFY` op kanaal `scrum4me_changes` (vereist `DIRECT_URL` voor pooler-bypass)
- **Branches:** `feat/ST-001-scaffolding`
- **Server Actions:** altijd in `actions/[domein].ts`, nooit inline in page.tsx
- **Validatie:** altijd Zod, nooit handmatige checks
- **Toegangsmodel:** product-scoped resources gebruiken `productAccessFilter(userId)` tenzij het expliciet een eigenaarsactie is
- **Bulk-ID's:** reorder- en beslissingsacties valideren dat alle meegegeven IDs binnen dezelfde parent-scope vallen voordat er geschreven wordt
- **Foreign keys:** denormalized keys zoals `story.product_id` worden afgeleid uit de database-parent (`pbi.product_id`), nooit uit client-input
- **Demo-check:** elke Server Action controleert `session.isDemo` vóór schrijven
- **Foutberichten:** Nederlands voor eindgebruikers — comments in code: Engels
- **Dependencies:** elke geïmporteerde runtime package staat direct in `dependencies`, niet alleen transitief in `package-lock.json`
- **Docs-sync:** elke gedrags-, dependency-, API- of deploymentwijziging werkt README, relevante docs en patterns bij in dezelfde change
- **Entity codes:** gebruik product/PBI/story-codes in commit-titles wanneer aanwezig (`feat(ST-356.2): ...`); branchnaam blijft `feat/ST-XXX-slug`
- **Status-enums op API:** lowercase (`todo|in_progress|review|done`, `open|in_sprint|done`); DB houdt UPPER_SNAKE; conversie uitsluitend via `lib/task-status.ts`-mappers — nooit ad-hoc `.toLowerCase()` elders
- **Foutcodes API:** `400` alleen voor malformed JSON-body (parse-fout via `request.json()`); `422` voor zod-validatie en well-formed-maar-niet-acceptabel; `403` voor demo-tokens. Documenteer per endpoint in `docs/API.md`
- **Tests volgen contract:** bij een API-contract-wijziging (status, foutcode, response-shape) MOET in dezelfde commit ook `__tests__/api/` mee — een test die rood gaat omdat de oude waarde wordt verwacht is een onvolledige wijziging, niet een "kapotte test"
- **Dev port:** `npm run dev` draait altijd op **3000**. Een `predev`-hook killt vooraf elk proces op 3000 (stale Next.js dev-server, vorige sessie) zodat sessies, cookies en MCP-config consistent op één poort werken. Wijk hier niet van af — geen `-p 3001` o.i.d. tenzij je expliciet twee dev-servers naast elkaar wil draaien
---
## Branch & PR Strategy (STRICT — kostenbeheersing)
> **Core rule: één branch per milestone, PR alleen na gebruikerstest**
Elke `git push` naar een feature-branch triggert een Vercel preview-deployment. Op het huidige Hobby-account zijn die schaars en kosten geld; we minimaliseren preview-builds tot er werkelijk iets te reviewen valt.
### Wel doen
- Eén branch voor de hele milestone — `feat/M{N}-{slug}` (bv. `feat/M10-qr-login`); voor losse stories zonder milestone blijft `feat/ST-XXX-{slug}` geldig
- Commits accumuleren lokaal volgens de Commit Strategy hieronder — één commit per stap, ST-code in de titel
- Pushen + PR openen **pas nadat de gebruiker de milestone handmatig heeft getest en goedgekeurd** — vraag expliciet om bevestiging vóór `git push`
- Tussentijdse "klaar voor jouw test"-momenten markeren met een lokale tag of een berichtje in chat, niet met een push
### Niet doen
- Pushen na elke story of commit
- Een PR per story openen tijdens de implementatie
- "Just-in-case" pushen om backup te hebben — gebruik `git stash`, een lokale tag, of meerdere lokale branches
- `--force-push` om eerdere preview-builds "weg te toveren" (kost dezelfde build opnieuw bij hercreatie)
### Uitzonderingen
- Een **planning-PR** zonder code-wijzigingen (alleen docs in `docs/plans/` of `docs/`) mag direct gepusht worden — die triggert geen functional regressie en is goedkoop te bouwen
- Een **bugfix-hotfix** op `main` met aantoonbare productie-impact mag direct gepusht worden
### Wanneer aanpassen
Zodra het Vercel-account naar Pro (of andere omgeving zonder per-build-kosten) gaat: vervang deze regel door "branch + PR per story" zoals oorspronkelijk in dit document stond. Werk deze sectie bij én documenteer de wijziging in `docs/agent-instruction-audit.md`.
---
## Commit Strategy (STRICT)
> **Core rule: één commit = één verantwoordelijkheid**
### Nooit doen
- Database + API + UI in één commit mengen
- Feature + documentatie combineren
- Grote "alles gewijzigd" commits
- Vage berichten zoals "update stuff"
### Verplichte structuur
Splits werk op in logische lagen:
1. Database / Prisma
2. API / server actions
3. UI / components
4. Config / infra
5. Documentatie
### Commit-formaat
```
feat(ST-XXX): korte beschrijving
fix(ST-XXX): korte beschrijving
chore(ST-XXX): korte beschrijving
docs(ST-XXX): korte beschrijving
```
### Voorbeeld (verplicht patroon)
In plaats van:
```bash
feat: add profile system
```
Splits altijd op in:
```bash
feat(ST-XXX): add user profile fields to Prisma schema
feat(ST-XXX): add avatar upload endpoint
feat(ST-XXX): add profile editor component
chore(ST-XXX): configure sharp for avatar processing
docs(ST-XXX): document profile feature
```
---
## Scrum-terminologie
PBI (niet: Feature/Epic) · Story (niet: Ticket) · Sprint Goal (niet: Objective)
| Correct | Niet gebruiken |
|---|---|
| Product Backlog Item (PBI) | Feature, Epic, Issue |
| Story | User Story, Ticket |
| Sprint Goal | Sprint Objective |
| Scrum Team | Team |
---
## Verificatie
## MCP-integratie
```bash
npm run verify && npm run build # verify = lint + typecheck + test
```
Scrum4Me heeft een eigen MCP-server in repo [`madhura68/scrum4me-mcp`](https://github.com/madhura68/scrum4me-mcp) die de REST-API als native tools voor Claude Code aanbiedt. Schema's worden gedeeld via een git submodule (`vendor/scrum4me`), niet gedupliceerd.
Worker job-status protocol (wanneer `DONE` / `SKIPPED` / `FAILED`): zie [docs/runbooks/worker-idempotency.md](./docs/runbooks/worker-idempotency.md).
### Tools beschikbaar in Claude Code
### Scripts
- `mcp__scrum4me__health` — service + DB ping
- `mcp__scrum4me__list_products` — producten waar de tokengebruiker toegang tot heeft
- `mcp__scrum4me__get_claude_context` — bundled product / actieve sprint / next story (met tasks) / open todos
- `mcp__scrum4me__update_task_status`, `mcp__scrum4me__update_task_plan`
- `mcp__scrum4me__log_implementation`, `mcp__scrum4me__log_test_result`, `mcp__scrum4me__log_commit`
- `mcp__scrum4me__create_todo`
| Commando | Doel |
|---|---|
| `npm run dev` | Next dev op poort 3000 (`predev` kill-port draait automatisch) |
| `npm test` | Vitest eenmalig (`vitest run`) |
| `npm run test:watch` | Vitest watch-mode |
| `npm test -- <pad>` | Eén bestand draaien — bv. `npm test -- lib/env` |
| `npm run seed` | Prisma seed via `prisma/seed.ts` |
| `npm run create-admin` | Admin-user toevoegen (`scripts/create-admin.ts`) |
| `npm run db:insert-milestone` | Milestone-script (`scripts/insert-milestone.ts`) |
| `npm run db:sync-model-prices` | Sync Anthropic-model-prijzen — vereist `ANTHROPIC_API_KEY` |
| `npm run docs` | Regenereer `docs/INDEX.md` + check links |
| `npm run diagrams` | Mermaid → SVG (`public/diagrams/architecture-{light,dark}.svg`) |
### Prompt
> Vitest sluit `.claude/**` uit (relevant voor worktrees). `server-only` wordt via alias gemockt naar `tests/stubs/server-only.ts`, zodat `*-server.ts` modules laadbaar zijn in jsdom-tests.
- `implement_next_story` (arg: `product_id`) — end-to-end workflow
### Schema-drift bewaking
Wekelijks (maandag 08:00 Amsterdam) draait de remote agent `trig_015FFUnxjz9WMuhhWNGBQKFD` die `vendor/scrum4me` syncet en `prisma:generate` + `tsc --noEmit` uitvoert in scrum4me-mcp. Als die agent drift rapporteert, hoort dat **vóór** een Scrum4Me-PR met schema-wijziging gemerged kan worden — anders breekt de MCP-server stilletjes op runtime.
---
## Definition of Done (MVP)
M7 (MCP-server) is post-MVP en heeft eigen acceptatie in `docs/scrum4me-backlog.md`.
- [ ] Alle 62 tasks (ST-001 t/m ST-612) afgerond
- [ ] Volledige Lars-flow zonder fouten (ST-612)
- [ ] Alle 7 API-endpoints werken via curl
- [ ] Demo-gebruiker heeft geen schrijfrechten
- [ ] App opzetbaar via README zonder extra hulp
- [ ] CI/CD actief — falende build blokkeert merge
- [ ] Beveiligingsreview API geslaagd (cross-user toegang onmogelijk)
- [ ] Documentatie is bijgewerkt voor gewijzigde API's, dependencies, deployment en agent-instructies

View file

@ -47,13 +47,6 @@ Scrum4Me biedt een lichtgewicht, web-based oplossing voor het beheren van sprint
- Vercel hosting
- GitHub Actions / CI-CD
## Documentation
- [CHANGELOG.md](CHANGELOG.md) — release-historie (Keep a Changelog)
- [docs/INDEX.md](docs/INDEX.md) — generated index of all docs (front-matter driven)
- [docs/glossary.md](docs/glossary.md) — domain terms (PBI, Story, MCP-job, etc.)
- [CLAUDE.md](CLAUDE.md) / [AGENTS.md](AGENTS.md) — agent instructions
## Architectuur (kort)
- Frontend en backend via Next.js App Router
@ -123,12 +116,16 @@ Vul daarna `DATABASE_URL` en `SESSION_SECRET` in. `DIRECT_URL` is optioneel loka
npx prisma db push
```
4. Genereer Prisma Client:
4. Genereer Prisma Client en de ERD:
```bash
npx prisma generate
npm run db:erd
```
Deze command voert lokaal `prisma generate` uit. Daardoor worden zowel de Prisma Client als `docs/erd.svg` opnieuw opgebouwd.
In CI en deployment wordt bewust alleen de Prisma Client gegenereerd met `prisma generate --generator client`. Het ERD-diagram gebruikt Mermaid/Puppeteer en wordt daarom niet in GitHub Actions of Vercel gegenereerd.
5. Seed testdata indien nodig:
```bash
@ -149,7 +146,7 @@ npm run dev
npm test
```
Verwacht: alle 445 tests slagen, 0 failures.
Verwacht: alle 69 tests slagen, 0 failures.
**API curl-tests (vereist lopende dev server + API token):**
@ -158,13 +155,23 @@ Verwacht: alle 445 tests slagen, 0 failures.
bash scripts/test-api.sh
```
De curl-tests dekken alle 7 API-endpoints: auth (401), demo-blokkering (403), inputvalidatie (400) en happy paths. Zie `docs/qa/api-test-plan.md` voor het volledige testplan.
De curl-tests dekken alle 7 API-endpoints: auth (401), demo-blokkering (403), inputvalidatie (400) en happy paths. Zie `docs/scrum4me-test-plan.md` voor het volledige testplan.
## Database
Het schema staat in `prisma/schema.prisma`; uitgebreide documentatie in [`docs/architecture/data-model.md`](./docs/architecture/data-model.md).
![ERD](./docs/erd.svg)
Gebruik `npx prisma db push` om schema-wijzigingen naar de database te synchroniseren. `npx prisma generate` (of `prisma generate --generator client` in CI) genereert de Prisma Client.
De databasevisualisatie wordt lokaal gegenereerd uit `prisma/schema.prisma` via `prisma-erd-generator`.
Handmatige generatie:
```bash
npm run db:erd
```
Tijdens lokale development draait `npm run dev` naast Next.js ook `npm run db:erd:watch`. Bij wijzigingen in `prisma/schema.prisma` wordt `docs/erd.svg` automatisch opnieuw gegenereerd.
Gebruik `npx prisma db push` alleen om het schema naar de database te synchroniseren. Gebruik `npm run db:erd` om lokaal Prisma Client en de ERD te genereren. Gebruik in CI uitsluitend `npx prisma generate --generator client`.
De app draait standaard op `http://localhost:3000`.
@ -175,6 +182,7 @@ npm run dev # lokale development server
npm run lint # ESLint
npm test # Vitest test suite
npm run build # productiebuild zoals Vercel die verwacht
npm run db:erd # Prisma Client + docs/erd.svg genereren
```
### Environment variables
@ -184,15 +192,8 @@ Zie [.env.example](.env.example).
| Variabele | Verplicht | Doel |
|---|---:|---|
| `DATABASE_URL` | Ja | PostgreSQL connection string voor Prisma |
| `DIRECT_URL` | Nee | Directe Neon connection string voor migraties (Prisma `directUrl`) |
| `DIRECT_URL` | Nee | Directe Neon connection string voor migraties |
| `SESSION_SECRET` | Ja | Minimaal 32 tekens; gebruikt door iron-session |
| `CRON_SECRET` | Productie | Bearer-secret voor `/api/cron/*` routes — required als crons aan staan |
| `NEXT_PUBLIC_VAPID_PUBLIC_KEY` | Nee | VAPID public key voor Web Push — genereer met `npx web-push generate-vapid-keys` |
| `VAPID_PRIVATE_KEY` | Nee | VAPID private key voor Web Push |
| `VAPID_SUBJECT` | Nee | Contact URI voor Web Push (bijv. `mailto:admin@example.com`) |
| `INTERNAL_PUSH_SECRET` | Nee | Bearer-secret voor `/api/internal/push/*` routes (min 32 tekens) |
| `NEXT_PUBLIC_SENTRY_DSN` | Nee | Sentry DSN — zonder is de SDK een no-op |
| `SENTRY_ORG` / `SENTRY_PROJECT` / `SENTRY_AUTH_TOKEN` | Nee | Source-map upload tijdens build |
| `NODE_ENV` | Nee | Wordt door Node/Vercel gezet |
Vercel Analytics gebruikt geen project-specifieke environment variabele in deze app; de component staat in `app/layout.tsx`.
@ -247,20 +248,13 @@ Authorization: Bearer <token>
| Methode | Endpoint | Doel |
|---|---|---|
| `GET` | `/api/health` | Liveness; `?db=1` doet ook een DB-ping (geen auth) |
| `GET` | `/api/products` | Actieve producten waarvoor de tokengebruiker eigenaar of teamlid is |
| `GET` | `/api/products/:id/next-story` | Hoogst geprioriteerde open story uit de actieve sprint |
| `GET` | `/api/products/:id/claude-context` | Bundled product / actieve sprint / next-story (met tasks) / open ideas voor MCP |
| `GET` | `/api/products/:id/next-story` | Volgende story uit de actieve sprint |
| `GET` | `/api/sprints/:id/tasks?limit=10` | Eerste taken van een sprint |
| `PATCH` | `/api/stories/:id/tasks/reorder` | Taakvolgorde aanpassen; alle IDs moeten bij de story horen |
| `POST` | `/api/stories/:id/log` | Implementatieplan, testresultaat of commit vastleggen |
| `PATCH` | `/api/tasks/:id` | Taakstatus of `implementation_plan` bijwerken |
| `GET / POST` | `/api/ideas` · `GET / PATCH /api/ideas/:id` | Idea CRUD (M12 — vervangt voormalige `/api/todos`) |
| `GET` | `/api/jobs/:id/sub-tasks` | `sprint_task_executions` van een SPRINT_IMPLEMENTATION-job |
| `GET` | `/api/users/:id/avatar` | Avatar van een specifieke gebruiker |
| `POST / GET` | `/api/profile/avatar` | Eigen avatar uploaden of opvragen |
Daarnaast leveren `/api/realtime/{backlog,solo,jobs,notifications}` SSE-streams en zijn er auth-helpers `/api/auth/pair/*` (QR-pairing, M10), interne push-routes onder `/api/internal/push/*`, en cron-handlers (`/api/cron/cleanup-agent-artifacts`, `/api/cron/expire-questions`).
| `PATCH` | `/api/tasks/:id` | Taakstatus of implementatieplan bijwerken |
| `POST` | `/api/todos` | Todo aanmaken binnen een productcontext |
### Security-regels
@ -285,6 +279,7 @@ De productieomgeving is gericht op Vercel + Neon.
### Documentatie
- [Functionele specificatie](docs/specs/functional.md)
- [Technische architectuur](docs/architecture.md)
- [Agent-instructie audit](docs/decisions/agent-instructions-history.md)
- [Functionele specificatie](docs/scrum4me-functional-spec.md)
- [Technische architectuur](docs/scrum4me-architecture.md)
- [Backlog](docs/scrum4me-backlog.md)
- [Agent-instructie audit](docs/agent-instruction-audit.md)

View file

@ -1,103 +0,0 @@
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: {
sprint: { findFirst: vi.fn() },
product: { findFirst: vi.fn() },
user: {
findUnique: vi.fn(),
update: vi.fn().mockResolvedValue({}),
},
$executeRaw: vi.fn().mockResolvedValue(1),
},
}))
import { prisma } from '@/lib/prisma'
import { clearActiveSprintAction } from '@/actions/active-sprint'
const mockPrisma = prisma as unknown as {
product: { findFirst: ReturnType<typeof vi.fn> }
user: {
findUnique: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
}
describe('clearActiveSprintAction', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('writes null instead of deleting the key', async () => {
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
mockPrisma.user.findUnique.mockResolvedValueOnce({
settings: { layout: { activeSprints: { p1: 'sprint-1', p2: 'sprint-2' } } },
})
const result = await clearActiveSprintAction('p1')
expect(result).toEqual({ success: true })
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
data: { settings: { layout?: { activeSprints?: Record<string, string | null> } } }
}
expect(updateArg.data.settings.layout?.activeSprints).toEqual({
p1: null,
p2: 'sprint-2',
})
})
it('preserves other product keys when clearing one', async () => {
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
mockPrisma.user.findUnique.mockResolvedValueOnce({
settings: {
layout: {
activeSprints: { p1: 'sprint-1', p2: 'sprint-2', p3: null },
},
},
})
await clearActiveSprintAction('p1')
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
data: { settings: { layout?: { activeSprints?: Record<string, string | null> } } }
}
expect(updateArg.data.settings.layout?.activeSprints).toEqual({
p1: null,
p2: 'sprint-2',
p3: null,
})
})
it('rejects when product is not accessible', async () => {
mockPrisma.product.findFirst.mockResolvedValueOnce(null)
const result = await clearActiveSprintAction('p1')
expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' })
expect(mockPrisma.user.update).not.toHaveBeenCalled()
})
it('rejects invalid productId', async () => {
const result = await clearActiveSprintAction('')
expect(result).toEqual({ error: 'Ongeldig product-id' })
expect(mockPrisma.user.update).not.toHaveBeenCalled()
})
})

View file

@ -1,141 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const {
redirectMock,
verifyUserMock,
headerGetMock,
sessionSaveMock,
requireSessionMock,
prismaUserUpdateMock,
prismaUserRoleFindFirstMock,
} = vi.hoisted(() => ({
redirectMock: vi.fn((path: string) => { throw new Error(`REDIRECT:${path}`) }),
verifyUserMock: vi.fn(),
headerGetMock: vi.fn(),
sessionSaveMock: vi.fn(),
requireSessionMock: vi.fn(),
prismaUserUpdateMock: vi.fn(),
prismaUserRoleFindFirstMock: vi.fn().mockResolvedValue(null),
}))
vi.mock('next/navigation', () => ({ redirect: redirectMock }))
vi.mock('next/headers', () => ({
cookies: vi.fn().mockResolvedValue({}),
headers: vi.fn().mockResolvedValue({ get: headerGetMock }),
}))
vi.mock('iron-session', () => ({
getIronSession: vi.fn().mockResolvedValue({
userId: '',
isDemo: false,
save: sessionSaveMock,
}),
}))
vi.mock('@/lib/session', () => ({ sessionOptions: { cookieName: 't', password: 't' } }))
vi.mock('@/lib/auth', () => ({
verifyUser: verifyUserMock,
registerUser: vi.fn(),
hashPassword: vi.fn().mockResolvedValue('hashed'),
}))
vi.mock('@/lib/auth-guard', () => ({ requireSession: requireSessionMock }))
vi.mock('@/lib/prisma', () => ({
prisma: {
user: { update: prismaUserUpdateMock },
userRole: { findFirst: prismaUserRoleFindFirstMock },
},
}))
vi.mock('@/lib/rate-limit', () => ({ checkRateLimit: vi.fn().mockReturnValue(true) }))
import { loginAction, resetPasswordAction } from '@/actions/auth'
const IPHONE_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) Mobile/15E148 Safari/604.1'
const IPAD_UA = 'Mozilla/5.0 (iPad; CPU OS 17_4 like Mac OS X) Safari/604.1'
const DESKTOP_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) Chrome/124.0.0.0 Safari/537.36'
function fd(username: string, password: string) {
const f = new FormData()
f.set('username', username)
f.set('password', password)
return f
}
beforeEach(() => {
redirectMock.mockClear()
verifyUserMock.mockReset()
headerGetMock.mockReset()
sessionSaveMock.mockReset()
requireSessionMock.mockReset()
prismaUserUpdateMock.mockReset()
prismaUserRoleFindFirstMock.mockResolvedValue(null)
})
describe('loginAction UA-redirect', () => {
it('phone-UA + actief product → /m/products/[id]/solo', async () => {
verifyUserMock.mockResolvedValue({ id: 'u1', is_demo: false, active_product_id: 'p1' })
headerGetMock.mockReturnValue(IPHONE_UA)
await expect(loginAction(undefined, fd('alice', 'secret123'))).rejects.toThrow('REDIRECT:/m/products/p1/solo')
})
it('phone-UA zonder actief product → /m/settings', async () => {
verifyUserMock.mockResolvedValue({ id: 'u1', is_demo: false, active_product_id: null })
headerGetMock.mockReturnValue(IPHONE_UA)
await expect(loginAction(undefined, fd('alice', 'secret123'))).rejects.toThrow('REDIRECT:/m/settings')
})
it('tablet-UA (iPad) → /dashboard', async () => {
verifyUserMock.mockResolvedValue({ id: 'u1', is_demo: false, active_product_id: 'p1' })
headerGetMock.mockReturnValue(IPAD_UA)
await expect(loginAction(undefined, fd('alice', 'secret123'))).rejects.toThrow('REDIRECT:/dashboard')
})
it('desktop-UA → /dashboard', async () => {
verifyUserMock.mockResolvedValue({ id: 'u1', is_demo: false, active_product_id: 'p1' })
headerGetMock.mockReturnValue(DESKTOP_UA)
await expect(loginAction(undefined, fd('alice', 'secret123'))).rejects.toThrow('REDIRECT:/dashboard')
})
it('geen UA-header → /dashboard', async () => {
verifyUserMock.mockResolvedValue({ id: 'u1', is_demo: false, active_product_id: 'p1' })
headerGetMock.mockReturnValue(null)
await expect(loginAction(undefined, fd('alice', 'secret123'))).rejects.toThrow('REDIRECT:/dashboard')
})
it('demo-user op phone volgt dezelfde routing', async () => {
verifyUserMock.mockResolvedValue({ id: 'demo', is_demo: true, active_product_id: 'p1' })
headerGetMock.mockReturnValue(IPHONE_UA)
await expect(loginAction(undefined, fd('demo', 'demo123pw'))).rejects.toThrow('REDIRECT:/m/products/p1/solo')
})
})
describe('resetPasswordAction', () => {
function fdReset(password: string, confirm: string) {
const f = new FormData()
f.set('password', password)
f.set('confirm', confirm)
return f
}
it('redirect /dashboard na succesvolle reset', async () => {
requireSessionMock.mockResolvedValue({ userId: 'u1' })
prismaUserUpdateMock.mockResolvedValue({})
await expect(resetPasswordAction(undefined, fdReset('nieuwpass1', 'nieuwpass1'))).rejects.toThrow('REDIRECT:/dashboard')
expect(prismaUserUpdateMock).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'u1' },
data: expect.objectContaining({ password_hash: 'hashed', must_reset_password: false }),
})
)
})
it('fout als wachtwoorden niet overeenkomen', async () => {
requireSessionMock.mockResolvedValue({ userId: 'u1' })
const result = await resetPasswordAction(undefined, fdReset('nieuwpass1', 'anderpass1'))
expect(result).toMatchObject({ error: expect.objectContaining({ confirm: expect.any(Array) }) })
expect(prismaUserUpdateMock).not.toHaveBeenCalled()
})
it('fout als wachtwoord te kort is', async () => {
requireSessionMock.mockResolvedValue({ userId: 'u1' })
const result = await resetPasswordAction(undefined, fdReset('kort', 'kort'))
expect(result).toMatchObject({ error: expect.objectContaining({ password: expect.any(Array) }) })
})
})

View file

@ -1,29 +0,0 @@
/**
* Per-task batch enqueue is gedeprecateerd ten gunste van startSprintRunAction
* (zie actions/sprint-runs.ts). De functies blijven exporteerbaar als stub voor
* backwards-compat met UI-componenten die in F4 worden vervangen.
*/
import { describe, it, expect, vi } from 'vitest'
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
vi.mock('@/lib/auth', () => ({ getSession: vi.fn() }))
vi.mock('@/lib/prisma', () => ({ prisma: {} }))
import {
previewEnqueueAllAction,
enqueueClaudeJobsBatchAction,
} from '@/actions/claude-jobs'
describe('previewEnqueueAllAction (deprecated)', () => {
it('retourneert een deprecation-error', async () => {
const result = await previewEnqueueAllAction('prod-1')
expect(result).toMatchObject({ error: expect.stringContaining('vervangen') })
})
})
describe('enqueueClaudeJobsBatchAction (deprecated)', () => {
it('retourneert een deprecation-error', async () => {
const result = await enqueueClaudeJobsBatchAction('prod-1', ['t1', 't2'])
expect(result).toMatchObject({ error: expect.stringContaining('Start Sprint') })
})
})

View file

@ -1,241 +0,0 @@
/**
* Per-task enqueue-acties zijn gedeprecateerd. cancelClaudeJobAction blijft
* actief gebruikt voor het annuleren van losse jobs (bv. idea-jobs).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const {
mockGetSession,
mockFindFirstJob,
mockUpdateJob,
mockUpdateManyJob,
mockUpdateManySprintTaskExecution,
mockTransaction,
mockExecuteRaw,
} = vi.hoisted(() => {
const mockUpdateManyJob = vi.fn()
const mockUpdateManySprintTaskExecution = vi.fn()
const mockTransaction = vi.fn()
return {
mockGetSession: vi.fn(),
mockFindFirstJob: vi.fn(),
mockUpdateJob: vi.fn(),
mockUpdateManyJob,
mockUpdateManySprintTaskExecution,
mockTransaction,
mockExecuteRaw: vi.fn().mockResolvedValue(undefined),
}
})
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
vi.mock('@/lib/auth', () => ({ getSession: mockGetSession }))
vi.mock('@/lib/prisma', () => ({
prisma: {
claudeJob: {
findFirst: mockFindFirstJob,
update: mockUpdateJob,
updateMany: mockUpdateManyJob,
},
sprintTaskExecution: {
updateMany: mockUpdateManySprintTaskExecution,
},
$transaction: mockTransaction,
$executeRaw: mockExecuteRaw,
},
}))
import {
enqueueClaudeJobAction,
enqueueAllTodoJobsAction,
cancelClaudeJobAction,
restartClaudeJobAction,
} from '@/actions/claude-jobs'
const SESSION_USER = { userId: 'user-1', isDemo: false }
beforeEach(() => {
vi.clearAllMocks()
mockExecuteRaw.mockResolvedValue(undefined)
mockTransaction.mockImplementation(async (fn: (tx: unknown) => Promise<unknown>) =>
fn({
claudeJob: { updateMany: mockUpdateManyJob },
sprintTaskExecution: { updateMany: mockUpdateManySprintTaskExecution },
})
)
})
describe('enqueueClaudeJobAction (deprecated)', () => {
it('retourneert een deprecation-error', async () => {
const result = await enqueueClaudeJobAction('task-1')
expect(result).toMatchObject({ error: expect.stringContaining('Start Sprint') })
})
})
describe('enqueueAllTodoJobsAction (deprecated)', () => {
it('retourneert een deprecation-error', async () => {
const result = await enqueueAllTodoJobsAction('prod-1')
expect(result).toMatchObject({ error: expect.stringContaining('Start Sprint') })
})
})
describe('cancelClaudeJobAction', () => {
it('cancelt een actieve job', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstJob.mockResolvedValue({
id: 'job-1',
status: 'QUEUED',
task_id: 'task-1',
product_id: 'prod-1',
})
mockUpdateJob.mockResolvedValue(undefined)
const result = await cancelClaudeJobAction('job-1')
expect(result).toEqual({ success: true })
expect(mockUpdateJob).toHaveBeenCalledWith({
where: { id: 'job-1' },
data: expect.objectContaining({ status: 'CANCELLED' }),
})
})
it('weigert demo-sessie', async () => {
mockGetSession.mockResolvedValue({ userId: 'demo', isDemo: true })
const result = await cancelClaudeJobAction('job-1')
expect(result).toMatchObject({ error: expect.stringContaining('demo') })
expect(mockUpdateJob).not.toHaveBeenCalled()
})
it('retourneert error als job niet gevonden', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstJob.mockResolvedValue(null)
const result = await cancelClaudeJobAction('nonexistent')
expect(result).toMatchObject({ error: expect.stringContaining('niet gevonden') })
})
it('weigert wanneer job niet meer actief is', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstJob.mockResolvedValue({
id: 'job-1',
status: 'DONE',
task_id: 'task-1',
product_id: 'prod-1',
})
const result = await cancelClaudeJobAction('job-1')
expect(result).toMatchObject({ error: expect.stringContaining('actieve') })
})
})
describe('restartClaudeJobAction', () => {
const FAILED_JOB = {
id: 'job-1',
status: 'FAILED',
kind: 'TASK_IMPLEMENTATION',
task_id: 'task-1',
idea_id: null,
sprint_run_id: null,
product_id: 'prod-1',
}
it('reset een FAILED job naar QUEUED (happy path)', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstJob.mockResolvedValue(FAILED_JOB)
mockUpdateManyJob.mockResolvedValue({ count: 1 })
const result = await restartClaudeJobAction('job-1')
expect(result).toEqual({ success: true })
expect(mockUpdateManyJob).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ id: 'job-1', status: { in: ['FAILED', 'CANCELLED', 'SKIPPED'] } }),
data: expect.objectContaining({ status: 'QUEUED' }),
})
)
expect(mockExecuteRaw).toHaveBeenCalled()
})
it('reset een CANCELLED job naar QUEUED', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstJob.mockResolvedValue({ ...FAILED_JOB, status: 'CANCELLED' })
mockUpdateManyJob.mockResolvedValue({ count: 1 })
const result = await restartClaudeJobAction('job-1')
expect(result).toEqual({ success: true })
})
it('reset een SKIPPED job naar QUEUED', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstJob.mockResolvedValue({ ...FAILED_JOB, status: 'SKIPPED' })
mockUpdateManyJob.mockResolvedValue({ count: 1 })
const result = await restartClaudeJobAction('job-1')
expect(result).toEqual({ success: true })
})
it('weigert demo-sessie', async () => {
mockGetSession.mockResolvedValue({ userId: 'demo', isDemo: true })
const result = await restartClaudeJobAction('job-1')
expect(result).toMatchObject({ error: expect.stringContaining('demo') })
expect(mockUpdateManyJob).not.toHaveBeenCalled()
})
it('retourneert error als job niet gevonden', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstJob.mockResolvedValue(null)
const result = await restartClaudeJobAction('job-1')
expect(result).toMatchObject({ error: expect.stringContaining('niet gevonden') })
})
it('weigert wanneer job een niet-restartbare status heeft', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstJob.mockResolvedValue({ ...FAILED_JOB, status: 'DONE' })
const result = await restartClaudeJobAction('job-1')
expect(result).toMatchObject({ error: expect.stringContaining('mislukte') })
expect(mockUpdateManyJob).not.toHaveBeenCalled()
})
it('retourneert error bij race-conditie (updateMany count === 0)', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstJob.mockResolvedValue(FAILED_JOB)
mockUpdateManyJob.mockResolvedValue({ count: 0 })
const result = await restartClaudeJobAction('job-1')
expect(result).toMatchObject({ error: expect.stringContaining('gewijzigd') })
})
it('reset ook SprintTaskExecution-rows bij SPRINT_IMPLEMENTATION', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstJob.mockResolvedValue({
...FAILED_JOB,
kind: 'SPRINT_IMPLEMENTATION',
sprint_run_id: 'run-1',
})
mockUpdateManyJob.mockResolvedValue({ count: 1 })
mockUpdateManySprintTaskExecution.mockResolvedValue({ count: 3 })
const result = await restartClaudeJobAction('job-1')
expect(result).toEqual({ success: true })
expect(mockUpdateManySprintTaskExecution).toHaveBeenCalledWith(
expect.objectContaining({
where: { sprint_job_id: 'job-1' },
data: expect.objectContaining({ status: 'PENDING' }),
})
)
})
it('reset geen SprintTaskExecution-rows bij TASK_IMPLEMENTATION', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstJob.mockResolvedValue(FAILED_JOB)
mockUpdateManyJob.mockResolvedValue({ count: 1 })
await restartClaudeJobAction('job-1')
expect(mockUpdateManySprintTaskExecution).not.toHaveBeenCalled()
})
})

View file

@ -1,290 +0,0 @@
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({}),
getAccessibleProduct: vi.fn().mockResolvedValue({ id: 'product-1' }),
}))
vi.mock('@/lib/rate-limit', () => ({
enforceUserRateLimit: vi.fn().mockReturnValue(null),
}))
vi.mock('@/lib/code-server', () => ({
createWithCodeRetry: vi.fn(),
generateNextSprintCode: vi.fn(),
}))
vi.mock('@/lib/active-sprint', () => ({
setActiveSprintInSettings: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/prisma', () => {
const txClient = {
sprint: { create: vi.fn() },
story: { updateMany: vi.fn() },
task: { updateMany: vi.fn() },
}
return {
prisma: {
sprint: { findFirst: vi.fn() },
story: {
findMany: vi.fn(),
updateMany: vi.fn(),
},
task: {
findMany: vi.fn(),
updateMany: vi.fn(),
},
$transaction: vi.fn(async (fn: (tx: typeof txClient) => unknown) => fn(txClient)),
__txClient: txClient,
},
}
})
import { prisma } from '@/lib/prisma'
import { commitSprintMembershipAction } from '@/actions/sprints'
type Mocked = {
sprint: { findFirst: ReturnType<typeof vi.fn> }
story: {
findMany: ReturnType<typeof vi.fn>
updateMany: ReturnType<typeof vi.fn>
}
task: {
findMany: ReturnType<typeof vi.fn>
updateMany: ReturnType<typeof vi.fn>
}
$transaction: ReturnType<typeof vi.fn>
__txClient: {
sprint: { create: ReturnType<typeof vi.fn> }
story: { updateMany: ReturnType<typeof vi.fn> }
task: { updateMany: ReturnType<typeof vi.fn> }
}
}
const mockPrisma = prisma as unknown as Mocked
beforeEach(() => {
vi.clearAllMocks()
mockPrisma.sprint.findFirst.mockReset().mockResolvedValue({
id: 'sprint-active',
product_id: 'product-1',
})
mockPrisma.story.findMany.mockReset()
mockPrisma.story.updateMany.mockReset()
mockPrisma.task.findMany.mockReset()
mockPrisma.task.updateMany.mockReset()
mockPrisma.$transaction.mockImplementation(
async (fn: (tx: typeof mockPrisma.__txClient) => unknown) =>
fn(mockPrisma.__txClient),
)
mockPrisma.__txClient.story.updateMany.mockReset().mockResolvedValue({ count: 0 })
mockPrisma.__txClient.task.updateMany.mockReset().mockResolvedValue({ count: 0 })
})
describe('commitSprintMembershipAction', () => {
it('happy path: eligible adds + valid removes → transactie commits', async () => {
// adds-partition: alle eligible (sprint_id=null + niet DONE)
mockPrisma.story.findMany
// partition lookup
.mockResolvedValueOnce([
{ id: 's-add-1', sprint_id: null, status: 'OPEN', sprint: null },
])
// removes-filter (sprint_id == activeSprintId)
.mockResolvedValueOnce([{ id: 's-rem-1' }])
// affectedStories
.mockResolvedValueOnce([
{ pbi_id: 'pbiA' },
{ pbi_id: 'pbiB' },
])
mockPrisma.task.findMany.mockResolvedValueOnce([{ id: 't1' }])
const result = await commitSprintMembershipAction({
activeSprintId: 'sprint-active',
adds: ['s-add-1'],
removes: ['s-rem-1'],
})
expect('success' in result).toBe(true)
if ('success' in result) {
expect(result.affectedStoryIds.sort()).toEqual(['s-add-1', 's-rem-1'])
expect(result.affectedPbiIds.sort()).toEqual(['pbiA', 'pbiB'])
expect(result.affectedTaskIds).toEqual(['t1'])
expect(result.conflicts.notEligible).toEqual([])
expect(result.conflicts.alreadyRemoved).toEqual([])
}
expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1)
expect(mockPrisma.__txClient.story.updateMany).toHaveBeenCalledTimes(2)
expect(mockPrisma.__txClient.task.updateMany).toHaveBeenCalledTimes(2)
})
it('add met status=DONE → conflicts.notEligible, story niet ge-update', async () => {
mockPrisma.story.findMany
.mockResolvedValueOnce([
{ id: 's-done', sprint_id: null, status: 'DONE', sprint: null },
])
// removes-filter (geen removes)
.mockResolvedValueOnce([])
const result = await commitSprintMembershipAction({
activeSprintId: 'sprint-active',
adds: ['s-done'],
removes: [],
})
expect('success' in result).toBe(true)
if ('success' in result) {
expect(result.affectedStoryIds).toEqual([])
expect(result.conflicts.notEligible).toEqual([
{ storyId: 's-done', reason: 'DONE' },
])
}
// Geen transaction omdat er niets te commiten valt.
expect(mockPrisma.$transaction).not.toHaveBeenCalled()
})
it('add met sprint_id in andere OPEN sprint → conflicts.notEligible IN_OTHER_SPRINT', async () => {
mockPrisma.story.findMany
.mockResolvedValueOnce([
{
id: 's-elsewhere',
sprint_id: 'sprint-other',
status: 'IN_SPRINT',
sprint: { id: 'sprint-other', code: 'SP-O', status: 'OPEN' },
},
])
.mockResolvedValueOnce([])
const result = await commitSprintMembershipAction({
activeSprintId: 'sprint-active',
adds: ['s-elsewhere'],
removes: [],
})
if ('success' in result) {
expect(result.conflicts.notEligible).toEqual([
{ storyId: 's-elsewhere', reason: 'IN_OTHER_SPRINT' },
])
}
})
it('remove voor story die niet in actieve sprint zit → conflicts.alreadyRemoved', async () => {
mockPrisma.story.findMany
// adds-partition (geen adds)
.mockResolvedValueOnce([])
// removes-filter — race scenario: story zit niet meer in active sprint
.mockResolvedValueOnce([])
const result = await commitSprintMembershipAction({
activeSprintId: 'sprint-active',
adds: [],
removes: ['s-was-removed'],
})
if ('success' in result) {
expect(result.affectedStoryIds).toEqual([])
expect(result.conflicts.alreadyRemoved).toEqual(['s-was-removed'])
}
})
it('transactie: story.status=IN_SPRINT bij add, =OPEN bij remove', async () => {
mockPrisma.story.findMany
.mockResolvedValueOnce([
{ id: 's-add', sprint_id: null, status: 'OPEN', sprint: null },
])
.mockResolvedValueOnce([{ id: 's-rem' }])
.mockResolvedValueOnce([{ pbi_id: 'pbiA' }])
mockPrisma.task.findMany.mockResolvedValueOnce([])
await commitSprintMembershipAction({
activeSprintId: 'sprint-active',
adds: ['s-add'],
removes: ['s-rem'],
})
const calls = mockPrisma.__txClient.story.updateMany.mock.calls
// Add: status=IN_SPRINT + sprint_id=sprint-active
expect(calls[0][0].data).toEqual({
sprint_id: 'sprint-active',
status: 'IN_SPRINT',
})
// Remove: status=OPEN + sprint_id=null
expect(calls[1][0].data).toEqual({ sprint_id: null, status: 'OPEN' })
})
it('task.sprint_id wordt in dezelfde transactie ge-update', async () => {
mockPrisma.story.findMany
.mockResolvedValueOnce([
{ id: 's-add', sprint_id: null, status: 'OPEN', sprint: null },
])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ pbi_id: 'pbiA' }])
mockPrisma.task.findMany.mockResolvedValueOnce([])
await commitSprintMembershipAction({
activeSprintId: 'sprint-active',
adds: ['s-add'],
removes: [],
})
expect(mockPrisma.__txClient.task.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { story_id: { in: ['s-add'] } },
data: { sprint_id: 'sprint-active' },
}),
)
})
it('return: affectedStoryIds + affectedPbiIds + affectedTaskIds + conflicts', async () => {
mockPrisma.story.findMany
.mockResolvedValueOnce([
{ id: 's-add', sprint_id: null, status: 'OPEN', sprint: null },
])
.mockResolvedValueOnce([{ id: 's-rem' }])
.mockResolvedValueOnce([
{ pbi_id: 'pbiA' },
{ pbi_id: 'pbiB' },
])
mockPrisma.task.findMany.mockResolvedValueOnce([
{ id: 't1' },
{ id: 't2' },
])
const result = await commitSprintMembershipAction({
activeSprintId: 'sprint-active',
adds: ['s-add'],
removes: ['s-rem'],
})
expect(result).toMatchObject({
success: true,
affectedStoryIds: expect.arrayContaining(['s-add', 's-rem']),
affectedPbiIds: expect.arrayContaining(['pbiA', 'pbiB']),
affectedTaskIds: expect.arrayContaining(['t1', 't2']),
})
})
it('rejects when sprint is not accessible', async () => {
mockPrisma.sprint.findFirst.mockResolvedValue(null)
const result = await commitSprintMembershipAction({
activeSprintId: 'sprint-active',
adds: [],
removes: [],
})
expect('error' in result).toBe(true)
if ('error' in result) {
expect(result.code).toBe(403)
}
})
})

View file

@ -1,300 +0,0 @@
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({}),
getAccessibleProduct: vi.fn().mockResolvedValue({
id: 'product-1',
user_id: 'user-1',
}),
}))
vi.mock('@/lib/rate-limit', () => ({
enforceUserRateLimit: vi.fn().mockReturnValue(null),
}))
vi.mock('@/lib/code-server', () => ({
createWithCodeRetry: vi.fn(async (_gen, fn) => fn('SP-1')),
generateNextSprintCode: vi.fn().mockResolvedValue('SP-1'),
}))
vi.mock('@/lib/active-sprint', () => ({
setActiveSprintInSettings: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/prisma', () => {
const txClient = {
sprint: { create: vi.fn() },
story: { updateMany: vi.fn() },
task: { updateMany: vi.fn() },
}
return {
prisma: {
sprint: {
create: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
},
story: {
findMany: vi.fn(),
updateMany: vi.fn(),
},
task: {
findMany: vi.fn(),
updateMany: vi.fn(),
},
pbi: { findMany: vi.fn() },
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
$transaction: vi.fn(async (fn: (tx: typeof txClient) => unknown) => fn(txClient)),
__txClient: txClient,
},
}
})
import { prisma } from '@/lib/prisma'
import {
createSprintWithSelectionAction,
type CreateSprintWithSelectionInput,
} from '@/actions/sprints'
type Mocked = {
sprint: {
create: ReturnType<typeof vi.fn>
findFirst: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
story: {
findMany: ReturnType<typeof vi.fn>
updateMany: ReturnType<typeof vi.fn>
}
task: {
findMany: ReturnType<typeof vi.fn>
updateMany: ReturnType<typeof vi.fn>
}
$transaction: ReturnType<typeof vi.fn>
__txClient: {
sprint: { create: ReturnType<typeof vi.fn> }
story: { updateMany: ReturnType<typeof vi.fn> }
task: { updateMany: ReturnType<typeof vi.fn> }
}
}
const mockPrisma = prisma as unknown as Mocked
function baseInput(
overrides: Partial<CreateSprintWithSelectionInput> = {},
): CreateSprintWithSelectionInput {
return {
productId: 'product-1',
metadata: { goal: 'Sprint 1' },
pbiIntent: {},
storyOverrides: {},
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
mockPrisma.sprint.create.mockReset()
mockPrisma.story.findMany.mockReset()
mockPrisma.story.updateMany.mockReset()
mockPrisma.task.findMany.mockReset()
mockPrisma.task.updateMany.mockReset()
mockPrisma.$transaction.mockImplementation(
async (fn: (tx: typeof mockPrisma.__txClient) => unknown) =>
fn(mockPrisma.__txClient),
)
mockPrisma.__txClient.sprint.create
.mockReset()
.mockResolvedValue({ id: 'sprint-1', code: 'SP-1' })
mockPrisma.__txClient.story.updateMany
.mockReset()
.mockResolvedValue({ count: 0 })
mockPrisma.__txClient.task.updateMany
.mockReset()
.mockResolvedValue({ count: 0 })
})
describe('createSprintWithSelectionAction', () => {
it('resolves intent=all naar alle child-stories en weert overrides.remove', async () => {
// Stap 1: stories voor PBI-A (intent=all). Plus eligibility-fetch.
mockPrisma.story.findMany
// resolve step (only for pbis with intent='all')
.mockResolvedValueOnce([
{ id: 's1', pbi_id: 'pbiA' },
{ id: 's2', pbi_id: 'pbiA' },
{ id: 's3', pbi_id: 'pbiA' },
])
// partitionByEligibility — alle eligible
.mockResolvedValueOnce([
{ id: 's1', sprint_id: null, status: 'OPEN', sprint: null },
{ id: 's3', sprint_id: null, status: 'OPEN', sprint: null },
])
// affectedStories
.mockResolvedValueOnce([
{ pbi_id: 'pbiA' },
{ pbi_id: 'pbiA' },
])
mockPrisma.task.findMany.mockResolvedValueOnce([{ id: 't1' }])
const result = await createSprintWithSelectionAction(
baseInput({
pbiIntent: { pbiA: 'all' },
storyOverrides: { pbiA: { add: [], remove: ['s2'] } },
}),
)
expect('success' in result).toBe(true)
if ('success' in result) {
expect(result.affectedStoryIds).toEqual(['s1', 's3'])
expect(result.conflicts.notEligible).toEqual([])
}
})
it('voegt storyOverrides.add toe over PBI heen (zelfs intent=none)', async () => {
// Geen PBI met intent=all → stap 1 wordt niet uitgevoerd.
mockPrisma.story.findMany
// partition
.mockResolvedValueOnce([
{ id: 's10', sprint_id: null, status: 'OPEN', sprint: null },
])
// affectedStories
.mockResolvedValueOnce([{ pbi_id: 'pbiB' }])
mockPrisma.task.findMany.mockResolvedValueOnce([])
const result = await createSprintWithSelectionAction(
baseInput({
pbiIntent: { pbiB: 'none' },
storyOverrides: { pbiB: { add: ['s10'], remove: [] } },
}),
)
expect('success' in result).toBe(true)
if ('success' in result) {
expect(result.affectedStoryIds).toEqual(['s10'])
}
})
it('eligibility-filter classificeert DONE en cross-sprint stories', async () => {
mockPrisma.story.findMany
// resolve
.mockResolvedValueOnce([
{ id: 's1', pbi_id: 'pbiA' },
{ id: 's2', pbi_id: 'pbiA' },
{ id: 's3', pbi_id: 'pbiA' },
])
// partition: s1=DONE, s2=eligible, s3=in andere OPEN sprint
.mockResolvedValueOnce([
{ id: 's1', sprint_id: null, status: 'DONE', sprint: null },
{ id: 's2', sprint_id: null, status: 'OPEN', sprint: null },
{
id: 's3',
sprint_id: 'sprint-other',
status: 'IN_SPRINT',
sprint: { id: 'sprint-other', code: 'SP-O', status: 'OPEN' },
},
])
// affectedStories
.mockResolvedValueOnce([{ pbi_id: 'pbiA' }])
mockPrisma.task.findMany.mockResolvedValueOnce([])
const result = await createSprintWithSelectionAction(
baseInput({ pbiIntent: { pbiA: 'all' } }),
)
expect('success' in result).toBe(true)
if ('success' in result) {
expect(result.affectedStoryIds).toEqual(['s2'])
expect(result.conflicts.notEligible.map((n) => n.storyId).sort()).toEqual(
['s1', 's3'],
)
expect(result.conflicts.crossSprint.map((c) => c.storyId)).toEqual(['s3'])
}
})
it('zet story.status=IN_SPRINT en task.sprint_id mee in dezelfde transactie', async () => {
mockPrisma.story.findMany
.mockResolvedValueOnce([{ id: 's1', pbi_id: 'pbiA' }])
.mockResolvedValueOnce([
{ id: 's1', sprint_id: null, status: 'OPEN', sprint: null },
])
.mockResolvedValueOnce([{ pbi_id: 'pbiA' }])
mockPrisma.task.findMany.mockResolvedValueOnce([{ id: 't1' }])
await createSprintWithSelectionAction(
baseInput({ pbiIntent: { pbiA: 'all' } }),
)
expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1)
expect(mockPrisma.__txClient.story.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
sprint_id: 'sprint-1',
status: 'IN_SPRINT',
}),
}),
)
expect(mockPrisma.__txClient.task.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
data: { sprint_id: 'sprint-1' },
}),
)
})
it('returnt affectedStoryIds + affectedPbiIds + affectedTaskIds', async () => {
mockPrisma.story.findMany
.mockResolvedValueOnce([
{ id: 's1', pbi_id: 'pbiA' },
{ id: 's2', pbi_id: 'pbiB' },
])
.mockResolvedValueOnce([
{ id: 's1', sprint_id: null, status: 'OPEN', sprint: null },
{ id: 's2', sprint_id: null, status: 'OPEN', sprint: null },
])
.mockResolvedValueOnce([{ pbi_id: 'pbiA' }, { pbi_id: 'pbiB' }])
mockPrisma.task.findMany.mockResolvedValueOnce([
{ id: 't1' },
{ id: 't2' },
])
const result = await createSprintWithSelectionAction(
baseInput({ pbiIntent: { pbiA: 'all', pbiB: 'all' } }),
)
expect('success' in result).toBe(true)
if ('success' in result) {
expect(result.affectedStoryIds.sort()).toEqual(['s1', 's2'])
expect(result.affectedPbiIds.sort()).toEqual(['pbiA', 'pbiB'])
expect(result.affectedTaskIds.sort()).toEqual(['t1', 't2'])
}
})
it('returnt error wanneer geen eligible stories overblijven', async () => {
mockPrisma.story.findMany
.mockResolvedValueOnce([{ id: 's1', pbi_id: 'pbiA' }])
// s1 is DONE → notEligible
.mockResolvedValueOnce([
{ id: 's1', sprint_id: null, status: 'DONE', sprint: null },
])
const result = await createSprintWithSelectionAction(
baseInput({ pbiIntent: { pbiA: 'all' } }),
)
expect('error' in result).toBe(true)
if ('error' in result) {
expect(result.code).toBe(422)
}
})
})

View file

@ -1,717 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockSession } = vi.hoisted(() => ({
mockSession: { userId: 'user-1', isDemo: false },
}))
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) }))
vi.mock('iron-session', () => ({
getIronSession: vi.fn().mockImplementation(async () => mockSession),
}))
vi.mock('@/lib/session', () => ({
sessionOptions: { cookieName: 'test', password: 'test-password-32-chars-minimum-len' },
}))
vi.mock('@/lib/idea-code-server', () => ({
nextIdeaCode: vi.fn().mockResolvedValue('IDEA-001'),
}))
vi.mock('@/lib/prisma', () => ({
prisma: {
idea: {
create: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
ideaLog: { create: vi.fn() },
claudeJob: {
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
claudeWorker: {
count: vi.fn(),
},
pbi: {
findFirst: vi.fn(),
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
},
story: {
findMany: vi.fn(),
create: vi.fn(),
},
task: {
findMany: vi.fn(),
create: vi.fn(),
count: vi.fn(),
findUnique: vi.fn().mockResolvedValue(null),
},
product: {
findUnique: vi.fn().mockResolvedValue(null),
},
$transaction: vi.fn(),
$executeRaw: vi.fn().mockResolvedValue(0),
},
}))
import { prisma } from '@/lib/prisma'
import {
createIdeaAction,
updateIdeaAction,
archiveIdeaAction,
deleteIdeaAction,
updateGrillMdAction,
updatePlanMdAction,
uploadPlanMdAction,
downloadIdeaMdAction,
startGrillJobAction,
startMakePlanJobAction,
cancelIdeaJobAction,
materializeIdeaPlanAction,
relinkIdeaPlanAction,
} from '@/actions/ideas'
type MockIdea = {
idea: { create: ReturnType<typeof vi.fn>; findFirst: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> }
ideaLog: { create: ReturnType<typeof vi.fn> }
claudeJob: { findFirst: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
claudeWorker: { count: ReturnType<typeof vi.fn> }
pbi: { findFirst: ReturnType<typeof vi.fn>; findMany: ReturnType<typeof vi.fn>; findUnique: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> }
story: { findMany: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn> }
task: { findMany: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn>; count: ReturnType<typeof vi.fn> }
$transaction: ReturnType<typeof vi.fn>
$executeRaw: ReturnType<typeof vi.fn>
}
const m = prisma as unknown as MockIdea
beforeEach(() => {
vi.clearAllMocks()
mockSession.userId = 'user-1'
mockSession.isDemo = false
// Default: $transaction passes its callback through with our mocked prisma
m.$transaction.mockImplementation(async (arg: unknown) => {
if (typeof arg === 'function') {
return (arg as (tx: unknown) => unknown)(m)
}
return arg
})
})
describe('createIdeaAction', () => {
it('happy path: creates DRAFT idea with auto-generated code', async () => {
m.idea.create.mockResolvedValueOnce({ id: 'idea-1', code: 'IDEA-001' })
const r = await createIdeaAction({ title: 'Plant-watering reminder' })
expect(r).toEqual({ success: true, data: { id: 'idea-1', code: 'IDEA-001' } })
expect(m.idea.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
user_id: 'user-1',
code: 'IDEA-001',
title: 'Plant-watering reminder',
status: 'DRAFT',
}),
}),
)
})
it('rejects unauthenticated', async () => {
mockSession.userId = ''
const r = await createIdeaAction({ title: 'x' })
expect(r).toMatchObject({ error: expect.stringMatching(/ingelogd/), code: 401 })
expect(m.idea.create).not.toHaveBeenCalled()
})
it('rejects demo-user', async () => {
mockSession.isDemo = true
const r = await createIdeaAction({ title: 'x' })
expect(r).toMatchObject({ error: expect.stringMatching(/demo/), code: 403 })
expect(m.idea.create).not.toHaveBeenCalled()
})
it('rejects invalid title (zod 422)', async () => {
const r = await createIdeaAction({ title: ' ' })
expect(r).toMatchObject({ code: 422 })
expect(m.idea.create).not.toHaveBeenCalled()
})
})
describe('updateIdeaAction', () => {
it('happy: updates editable idea (DRAFT)', async () => {
m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', status: 'DRAFT' })
m.idea.update.mockResolvedValueOnce({})
const r = await updateIdeaAction('idea-1', { title: 'Updated' })
expect(r).toEqual({ success: true })
expect(m.idea.update).toHaveBeenCalledWith({
where: { id: 'idea-1' },
data: { title: 'Updated' },
})
})
it('blocks update on PLANNED (status-mismatch 422)', async () => {
m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', status: 'PLANNED' })
const r = await updateIdeaAction('idea-1', { title: 'x' })
expect(r).toMatchObject({ code: 422 })
expect(m.idea.update).not.toHaveBeenCalled()
})
it('blocks update during GRILLING', async () => {
m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', status: 'GRILLING' })
const r = await updateIdeaAction('idea-1', { title: 'x' })
expect(r).toMatchObject({ code: 422 })
})
it('returns 404 when idea belongs to another user', async () => {
m.idea.findFirst.mockResolvedValueOnce(null)
const r = await updateIdeaAction('idea-1', { title: 'x' })
expect(r).toMatchObject({ code: 404 })
})
})
describe('deleteIdeaAction', () => {
it('happy: deletes idea without pbi', async () => {
m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', pbi_id: null })
const r = await deleteIdeaAction('idea-1')
expect(r).toEqual({ success: true })
expect(m.idea.delete).toHaveBeenCalledWith({ where: { id: 'idea-1' } })
})
it('blocks deletion when PBI is linked', async () => {
m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', pbi_id: 'pbi-1' })
const r = await deleteIdeaAction('idea-1')
expect(r).toMatchObject({ code: 422 })
expect(m.idea.delete).not.toHaveBeenCalled()
})
})
describe('archiveIdeaAction', () => {
it('archives owned idea', async () => {
m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1' })
const r = await archiveIdeaAction('idea-1')
expect(r).toEqual({ success: true })
expect(m.idea.update).toHaveBeenCalledWith({
where: { id: 'idea-1' },
data: { archived: true },
})
})
})
describe('updateGrillMdAction', () => {
it('happy: updates grill_md in GRILLED', async () => {
m.idea.findFirst.mockResolvedValueOnce({ status: 'GRILLED' })
const r = await updateGrillMdAction('idea-1', '# Updated grill')
expect(r).toEqual({ success: true })
expect(m.$transaction).toHaveBeenCalled()
})
it('blocks in DRAFT', async () => {
m.idea.findFirst.mockResolvedValueOnce({ status: 'DRAFT' })
const r = await updateGrillMdAction('idea-1', 'x')
expect(r).toMatchObject({ code: 422 })
expect(m.$transaction).not.toHaveBeenCalled()
})
})
describe('updatePlanMdAction', () => {
const VALID_PLAN = `---
pbi:
title: Test
priority: 2
stories:
- title: S1
priority: 2
tasks:
- title: T1
priority: 2
---
body
`
it('happy: updates plan_md in PLAN_READY with valid yaml', async () => {
m.idea.findFirst.mockResolvedValueOnce({ status: 'PLAN_READY' })
const r = await updatePlanMdAction('idea-1', VALID_PLAN)
expect(r).toEqual({ success: true })
})
it('rejects invalid yaml (parse-fail 422 with details)', async () => {
m.idea.findFirst.mockResolvedValueOnce({ status: 'PLAN_READY' })
const r = await updatePlanMdAction('idea-1', '# no frontmatter')
expect(r).toMatchObject({ code: 422 })
expect((r as { details?: unknown }).details).toBeDefined()
})
it('blocks in PLANNED', async () => {
m.idea.findFirst.mockResolvedValueOnce({ status: 'PLANNED' })
const r = await updatePlanMdAction('idea-1', VALID_PLAN)
expect(r).toMatchObject({ code: 422 })
})
})
describe('uploadPlanMdAction', () => {
const VALID_PLAN = `---
pbi:
title: Uploaded
priority: 2
stories:
- title: S1
priority: 2
tasks:
- title: T1
priority: 2
---
body
`
it('happy: uploads from DRAFT — skips grill, sets PLAN_READY', async () => {
m.idea.findFirst.mockResolvedValueOnce({ status: 'DRAFT' })
const r = await uploadPlanMdAction('idea-1', VALID_PLAN)
expect(r).toEqual({ success: true })
expect(m.$transaction).toHaveBeenCalled()
const txnArg = m.$transaction.mock.calls.at(-1)?.[0] as unknown[] | undefined
expect(txnArg).toBeDefined()
// The first call in the transaction is the update — confirm status=PLAN_READY.
expect(m.idea.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ plan_md: VALID_PLAN, status: 'PLAN_READY' }),
}),
)
})
it('happy: uploads from GRILLED', async () => {
m.idea.findFirst.mockResolvedValueOnce({ status: 'GRILLED' })
const r = await uploadPlanMdAction('idea-1', VALID_PLAN)
expect(r).toEqual({ success: true })
})
it('happy: overwrites existing plan from PLAN_READY', async () => {
m.idea.findFirst.mockResolvedValueOnce({ status: 'PLAN_READY' })
const r = await uploadPlanMdAction('idea-1', VALID_PLAN)
expect(r).toEqual({ success: true })
})
it('happy: uploads from PLAN_FAILED (retry)', async () => {
m.idea.findFirst.mockResolvedValueOnce({ status: 'PLAN_FAILED' })
const r = await uploadPlanMdAction('idea-1', VALID_PLAN)
expect(r).toEqual({ success: true })
})
it('rejects from PLANNED (already materialized)', async () => {
m.idea.findFirst.mockResolvedValueOnce({ status: 'PLANNED' })
const r = await uploadPlanMdAction('idea-1', VALID_PLAN)
expect(r).toMatchObject({ code: 422 })
expect(m.$transaction).not.toHaveBeenCalled()
})
it('rejects from GRILLING (job running)', async () => {
m.idea.findFirst.mockResolvedValueOnce({ status: 'GRILLING' })
const r = await uploadPlanMdAction('idea-1', VALID_PLAN)
expect(r).toMatchObject({ code: 422 })
})
it('rejects empty markdown', async () => {
const r = await uploadPlanMdAction('idea-1', ' \n ')
expect(r).toMatchObject({ code: 422 })
// Should fail before touching DB
expect(m.idea.findFirst).not.toHaveBeenCalled()
})
it('rejects oversized markdown', async () => {
const huge = 'a'.repeat(100_001)
const r = await uploadPlanMdAction('idea-1', huge)
expect(r).toMatchObject({ code: 422 })
expect(m.idea.findFirst).not.toHaveBeenCalled()
})
it('rejects invalid yaml (parse-fail 422 with details)', async () => {
m.idea.findFirst.mockResolvedValueOnce({ status: 'DRAFT' })
const r = await uploadPlanMdAction('idea-1', '# no frontmatter')
expect(r).toMatchObject({ code: 422 })
expect((r as { details?: unknown }).details).toBeDefined()
expect(m.$transaction).not.toHaveBeenCalled()
})
it('returns 404 when idea not found', async () => {
m.idea.findFirst.mockResolvedValueOnce(null)
const r = await uploadPlanMdAction('nope', VALID_PLAN)
expect(r).toMatchObject({ code: 404 })
})
})
describe('startGrillJobAction', () => {
const idea = {
id: 'idea-1',
status: 'DRAFT',
product_id: 'prod-1',
product: { id: 'prod-1', repo_url: 'https://github.com/x/y' },
}
beforeEach(() => {
m.idea.findFirst.mockResolvedValue(idea)
m.claudeJob.findFirst.mockResolvedValue(null)
m.claudeWorker.count.mockResolvedValue(1)
m.claudeJob.create.mockResolvedValue({ id: 'job-1' })
})
it('happy path: creates IDEA_GRILL job, flips status to GRILLING', async () => {
const r = await startGrillJobAction('idea-1')
expect(r).toMatchObject({ success: true, data: { job_id: 'job-1' } })
expect(m.$executeRaw).toHaveBeenCalled()
})
it('blocks demo-user', async () => {
mockSession.isDemo = true
const r = await startGrillJobAction('idea-1')
expect(r).toMatchObject({ code: 403 })
expect(m.claudeJob.create).not.toHaveBeenCalled()
})
it('blocks when product has no repo_url', async () => {
m.idea.findFirst.mockResolvedValueOnce({
...idea,
product: { id: 'prod-1', repo_url: null },
})
const r = await startGrillJobAction('idea-1')
expect(r).toMatchObject({ code: 422, error: expect.stringMatching(/repo_url/i) })
})
it('blocks when no idea is unlinked', async () => {
m.idea.findFirst.mockResolvedValueOnce({ ...idea, product_id: null, product: null })
const r = await startGrillJobAction('idea-1')
expect(r).toMatchObject({ code: 422 })
})
it('blocks when no worker is active', async () => {
m.claudeWorker.count.mockResolvedValueOnce(0)
const r = await startGrillJobAction('idea-1')
expect(r).toMatchObject({ code: 422, error: expect.stringMatching(/worker/i) })
expect(m.claudeJob.create).not.toHaveBeenCalled()
})
it('blocks when an active job already exists (409)', async () => {
m.claudeJob.findFirst.mockResolvedValueOnce({ id: 'existing-job' })
const r = await startGrillJobAction('idea-1')
expect(r).toMatchObject({ code: 409 })
})
it('blocks invalid status (PLANNING)', async () => {
m.idea.findFirst.mockResolvedValueOnce({ ...idea, status: 'PLANNING' })
const r = await startGrillJobAction('idea-1')
expect(r).toMatchObject({ code: 422 })
})
})
describe('startMakePlanJobAction', () => {
const idea = {
id: 'idea-1',
status: 'GRILLED',
product_id: 'prod-1',
product: { id: 'prod-1', repo_url: 'https://github.com/x/y' },
}
beforeEach(() => {
m.idea.findFirst.mockResolvedValue(idea)
m.claudeJob.findFirst.mockResolvedValue(null)
m.claudeWorker.count.mockResolvedValue(1)
m.claudeJob.create.mockResolvedValue({ id: 'job-2' })
})
it('happy: GRILLED → PLANNING', async () => {
const r = await startMakePlanJobAction('idea-1')
expect(r).toMatchObject({ success: true })
})
it('blocks from DRAFT (must grill first)', async () => {
m.idea.findFirst.mockResolvedValueOnce({ ...idea, status: 'DRAFT' })
const r = await startMakePlanJobAction('idea-1')
expect(r).toMatchObject({ code: 422 })
})
})
describe('cancelIdeaJobAction', () => {
it('grill cancel without prior grill_md → DRAFT', async () => {
m.idea.findFirst.mockResolvedValueOnce({
id: 'idea-1',
status: 'GRILLING',
grill_md: null,
plan_md: null,
})
m.claudeJob.findFirst.mockResolvedValueOnce({ id: 'job-1', kind: 'IDEA_GRILL' })
const r = await cancelIdeaJobAction('idea-1')
expect(r).toEqual({ success: true })
// Verify $transaction was called with 3 ops (job-update, idea-update, log)
expect(m.$transaction).toHaveBeenCalled()
})
it('grill re-grill cancel with prior grill_md → GRILLED', async () => {
m.idea.findFirst.mockResolvedValueOnce({
id: 'idea-1',
status: 'GRILLING',
grill_md: '# old grill',
plan_md: null,
})
m.claudeJob.findFirst.mockResolvedValueOnce({ id: 'job-1', kind: 'IDEA_GRILL' })
const r = await cancelIdeaJobAction('idea-1')
expect(r).toEqual({ success: true })
})
it('returns 404 when no active job', async () => {
m.idea.findFirst.mockResolvedValueOnce({
id: 'idea-1',
status: 'GRILLED',
grill_md: null,
plan_md: null,
})
m.claudeJob.findFirst.mockResolvedValueOnce(null)
const r = await cancelIdeaJobAction('idea-1')
expect(r).toMatchObject({ code: 404 })
})
})
describe('materializeIdeaPlanAction', () => {
const VALID_PLAN = `---
pbi:
title: New PBI
priority: 2
stories:
- title: Story A
priority: 2
tasks:
- title: Task A1
priority: 2
implementation_plan: "1. Doe X"
- title: Task A2
priority: 2
- title: Story B
priority: 3
tasks:
- title: Task B1
priority: 3
---
body
`
beforeEach(() => {
m.idea.findFirst.mockResolvedValue({
id: 'idea-1',
status: 'PLAN_READY',
product_id: 'prod-1',
plan_md: VALID_PLAN,
})
m.pbi.findMany.mockResolvedValue([])
m.story.findMany.mockResolvedValue([])
m.task.findMany.mockResolvedValue([])
m.pbi.findFirst.mockResolvedValue(null)
m.pbi.create.mockResolvedValue({ id: 'pbi-1', code: 'PBI-1' })
m.story.create
.mockResolvedValueOnce({ id: 's-A' })
.mockResolvedValueOnce({ id: 's-B' })
m.task.create
.mockResolvedValueOnce({ id: 't-A1' })
.mockResolvedValueOnce({ id: 't-A2' })
.mockResolvedValueOnce({ id: 't-B1' })
})
it('happy: creates PBI + 2 stories + 3 tasks, links idea, returns ids; sort_order = parseCodeNumber(code)', async () => {
const r = await materializeIdeaPlanAction('idea-1')
expect(r).toMatchObject({
success: true,
data: {
pbi_id: 'pbi-1',
pbi_code: 'PBI-1',
story_ids: ['s-A', 's-B'],
task_ids: ['t-A1', 't-A2', 't-B1'],
},
})
expect(m.pbi.create).toHaveBeenCalledTimes(1)
expect(m.story.create).toHaveBeenCalledTimes(2)
expect(m.task.create).toHaveBeenCalledTimes(3)
// story sort_order = parseCodeNumber(auto-code): ST-001→1, ST-002→2
expect(m.story.create.mock.calls[0][0].data.sort_order).toBe(1)
expect(m.story.create.mock.calls[1][0].data.sort_order).toBe(2)
// task sort_order = parseCodeNumber(auto-code): T-1→1, T-2→2, T-3→3
expect(m.task.create.mock.calls[0][0].data.sort_order).toBe(1)
expect(m.task.create.mock.calls[1][0].data.sort_order).toBe(2)
expect(m.task.create.mock.calls[2][0].data.sort_order).toBe(3)
})
it('blocks when not PLAN_READY (e.g. GRILLED)', async () => {
m.idea.findFirst.mockResolvedValueOnce({
id: 'idea-1',
status: 'GRILLED',
product_id: 'prod-1',
plan_md: VALID_PLAN,
})
const r = await materializeIdeaPlanAction('idea-1')
expect(r).toMatchObject({ code: 422 })
expect(m.pbi.create).not.toHaveBeenCalled()
})
it('returns 422 with details on parse-fail', async () => {
m.idea.findFirst.mockResolvedValueOnce({
id: 'idea-1',
status: 'PLAN_READY',
product_id: 'prod-1',
plan_md: '# no frontmatter',
})
const r = await materializeIdeaPlanAction('idea-1')
expect(r).toMatchObject({ code: 422 })
expect((r as { details?: unknown }).details).toBeDefined()
})
it('blocks demo-user', async () => {
mockSession.isDemo = true
const r = await materializeIdeaPlanAction('idea-1')
expect(r).toMatchObject({ code: 403 })
})
it('returns 409 on P2002 race', async () => {
m.$transaction.mockImplementationOnce(async () => {
throw new Error('Unique constraint failed (P2002)')
})
const r = await materializeIdeaPlanAction('idea-1')
expect(r).toMatchObject({ code: 409 })
})
})
describe('materializeIdeaPlanAction — existing PBI pre-check', () => {
const VALID_PLAN = `---
pbi:
title: New PBI
priority: 2
stories:
- title: Story A
priority: 2
tasks:
- title: Task A1
priority: 2
---
body
`
beforeEach(() => {
// Use a distinct userId to avoid sharing the rate-limit bucket with the
// materializeIdeaPlanAction describe block above.
mockSession.userId = 'user-precheck'
m.idea.findFirst.mockResolvedValue({
id: 'idea-1',
status: 'PLAN_READY',
product_id: 'prod-1',
plan_md: VALID_PLAN,
pbi_id: 'old-pbi',
})
m.pbi.findMany.mockResolvedValue([])
m.story.findMany.mockResolvedValue([])
m.task.findMany.mockResolvedValue([])
m.pbi.findFirst.mockResolvedValue(null)
m.pbi.findUnique.mockResolvedValue({ code: 'PBI-X' })
m.pbi.create.mockResolvedValue({ id: 'pbi-new', code: 'PBI-2' })
m.pbi.delete.mockResolvedValue({})
m.story.create.mockResolvedValue({ id: 's-1' })
m.task.create.mockResolvedValue({ id: 't-1' })
})
it('auto-vervang: deletes old PBI in transaction when no tasks executed', async () => {
m.task.count.mockResolvedValueOnce(0)
const r = await materializeIdeaPlanAction('idea-1')
expect(r).toMatchObject({ success: true, data: { pbi_id: 'pbi-new' } })
expect(m.pbi.delete).toHaveBeenCalledWith({ where: { id: 'old-pbi' } })
expect(m.pbi.create).toHaveBeenCalledTimes(1)
})
it('conflict-409: returns PBI_HAS_ACTIVE_TASKS when executed tasks exist', async () => {
m.task.count.mockResolvedValueOnce(1)
const r = await materializeIdeaPlanAction('idea-1')
expect(r).toMatchObject({ code: 409, error: 'PBI_HAS_ACTIVE_TASKS:PBI-X' })
expect(m.pbi.create).not.toHaveBeenCalled()
expect(m.pbi.delete).not.toHaveBeenCalled()
})
it('alongside: skips old PBI delete and creates new PBI when allowAlongside=true', async () => {
m.task.count.mockResolvedValueOnce(1)
const r = await materializeIdeaPlanAction('idea-1', { allowAlongside: true })
expect(r).toMatchObject({ success: true, data: { pbi_id: 'pbi-new' } })
expect(m.pbi.delete).not.toHaveBeenCalled()
expect(m.pbi.create).toHaveBeenCalledTimes(1)
})
})
describe('relinkIdeaPlanAction', () => {
it('happy: PLANNED with pbi_id=null → PLAN_READY', async () => {
m.idea.findFirst.mockResolvedValueOnce({
id: 'idea-1',
status: 'PLANNED',
pbi_id: null,
})
const r = await relinkIdeaPlanAction('idea-1')
expect(r).toEqual({ success: true })
expect(m.$transaction).toHaveBeenCalled()
})
it('blocks when pbi still linked', async () => {
m.idea.findFirst.mockResolvedValueOnce({
id: 'idea-1',
status: 'PLANNED',
pbi_id: 'pbi-1',
})
const r = await relinkIdeaPlanAction('idea-1')
expect(r).toMatchObject({ code: 422 })
})
it('blocks when not PLANNED', async () => {
m.idea.findFirst.mockResolvedValueOnce({
id: 'idea-1',
status: 'PLAN_READY',
pbi_id: null,
})
const r = await relinkIdeaPlanAction('idea-1')
expect(r).toMatchObject({ code: 422 })
})
})
describe('downloadIdeaMdAction', () => {
it('returns grill_md when present', async () => {
m.idea.findFirst.mockResolvedValueOnce({
code: 'IDEA-001',
grill_md: '# Idee\nscope',
plan_md: null,
})
const r = await downloadIdeaMdAction('idea-1', 'grill')
expect(r).toMatchObject({
success: true,
data: { filename: 'IDEA-001-grill.md', markdown: '# Idee\nscope' },
})
})
it('404 when md not yet generated', async () => {
m.idea.findFirst.mockResolvedValueOnce({
code: 'IDEA-001',
grill_md: null,
plan_md: null,
})
const r = await downloadIdeaMdAction('idea-1', 'plan')
expect(r).toMatchObject({ code: 404 })
})
it('demo MAY download (read-only operation)', async () => {
mockSession.isDemo = true
m.idea.findFirst.mockResolvedValueOnce({
code: 'IDEA-001',
grill_md: 'x',
plan_md: null,
})
const r = await downloadIdeaMdAction('idea-1', 'grill')
expect(r).toMatchObject({ success: true })
})
})

View file

@ -1,163 +0,0 @@
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({
code: 422,
fieldErrors: { code: expect.arrayContaining([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()
})
})

View file

@ -1,102 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockGetSession } = vi.hoisted(() => ({
mockGetSession: vi.fn(),
}))
vi.mock('@/lib/auth', () => ({
getSession: mockGetSession,
}))
const { mockUpsert, mockDeleteMany } = vi.hoisted(() => ({
mockUpsert: vi.fn(),
mockDeleteMany: vi.fn(),
}))
vi.mock('@/lib/prisma', () => ({
prisma: {
pushSubscription: {
upsert: mockUpsert,
deleteMany: mockDeleteMany,
},
},
}))
import { subscribeToPushAction, unsubscribeFromPushAction } from '@/actions/push'
const VALID_INPUT = {
endpoint: 'https://push.example.com/subscription/abc123',
keys: { p256dh: 'aBcDeFgH', auth: 'xYzAbC' },
}
const SESSION_USER = { userId: 'user-1', isDemo: false }
const SESSION_DEMO = { userId: 'demo-1', isDemo: true }
beforeEach(() => {
vi.clearAllMocks()
mockUpsert.mockResolvedValue({})
mockDeleteMany.mockResolvedValue({ count: 1 })
})
describe('subscribeToPushAction', () => {
it('upserts subscription for authenticated user', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
await subscribeToPushAction(VALID_INPUT)
expect(mockUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { endpoint: VALID_INPUT.endpoint },
create: expect.objectContaining({ user_id: 'user-1', endpoint: VALID_INPUT.endpoint }),
})
)
})
it('is idempotent — calling twice upserts twice without error', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
await subscribeToPushAction(VALID_INPUT)
await subscribeToPushAction(VALID_INPUT)
expect(mockUpsert).toHaveBeenCalledTimes(2)
})
it('returns without writing for demo user', async () => {
mockGetSession.mockResolvedValue(SESSION_DEMO)
await subscribeToPushAction(VALID_INPUT)
expect(mockUpsert).not.toHaveBeenCalled()
})
it('returns without writing when not authenticated', async () => {
mockGetSession.mockResolvedValue({})
await subscribeToPushAction(VALID_INPUT)
expect(mockUpsert).not.toHaveBeenCalled()
})
it('returns without writing for invalid input', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
// @ts-expect-error intentionally invalid
await subscribeToPushAction({ endpoint: 'not-a-url', keys: {} })
expect(mockUpsert).not.toHaveBeenCalled()
})
})
describe('unsubscribeFromPushAction', () => {
it('deletes subscription scoped to user_id', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
await unsubscribeFromPushAction({ endpoint: VALID_INPUT.endpoint })
expect(mockDeleteMany).toHaveBeenCalledWith({
where: { endpoint: VALID_INPUT.endpoint, user_id: 'user-1' },
})
})
it('does not touch subscriptions of other users', async () => {
mockGetSession.mockResolvedValue({ userId: 'other-user', isDemo: false })
await unsubscribeFromPushAction({ endpoint: VALID_INPUT.endpoint })
expect(mockDeleteMany).toHaveBeenCalledWith(
expect.objectContaining({ where: expect.objectContaining({ user_id: 'other-user' }) })
)
})
it('returns without writing when not authenticated', async () => {
mockGetSession.mockResolvedValue({})
await unsubscribeFromPushAction({ endpoint: VALID_INPUT.endpoint })
expect(mockDeleteMany).not.toHaveBeenCalled()
})
})

View file

@ -1,143 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockGetSession } = vi.hoisted(() => ({
mockGetSession: vi.fn(),
}))
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
vi.mock('@/lib/auth', () => ({
getSession: mockGetSession,
}))
vi.mock('@/lib/prisma', () => ({
prisma: {
claudeQuestion: {
findFirst: vi.fn(),
updateMany: vi.fn(),
},
product: {
findFirst: vi.fn().mockResolvedValue({ id: 'product-1' }),
},
},
}))
import { revalidatePath } from 'next/cache'
import { prisma } from '@/lib/prisma'
import { answerQuestion } from '@/actions/questions'
const mockPrisma = prisma as unknown as {
claudeQuestion: {
findFirst: ReturnType<typeof vi.fn>
updateMany: ReturnType<typeof vi.fn>
}
}
const mockRevalidate = revalidatePath as ReturnType<typeof vi.fn>
const VALID_ID = 'cmohrz0jra1aaaaaaaaaaaaaa'
const VALID_ANSWER = 'Antwoord van de gebruiker'
const SESSION_USER = { userId: 'user-1', isDemo: false }
const SESSION_DEMO = { userId: 'demo-1', isDemo: true }
beforeEach(() => {
vi.clearAllMocks()
})
describe('actions/questions — answerQuestion', () => {
it('happy: status pending→answered, revalidatePath geroepen', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({
id: VALID_ID,
story_id: 'story-1',
idea_id: null,
product_id: 'product-1',
idea: null,
})
mockPrisma.claudeQuestion.updateMany.mockResolvedValueOnce({ count: 1 })
const res = await answerQuestion(VALID_ID, VALID_ANSWER)
expect(res).toEqual({ ok: true })
const updateArg = mockPrisma.claudeQuestion.updateMany.mock.calls[0][0]
expect(updateArg.where).toMatchObject({
id: VALID_ID,
status: 'open',
})
expect(updateArg.where.expires_at).toMatchObject({ gt: expect.any(Date) })
expect(updateArg.data).toMatchObject({
status: 'answered',
answer: VALID_ANSWER,
answered_by: 'user-1',
})
expect(mockRevalidate).toHaveBeenCalledWith('/', 'layout')
})
it('demo-user wordt geblokkeerd, geen DB-call', async () => {
mockGetSession.mockResolvedValue(SESSION_DEMO)
const res = await answerQuestion(VALID_ID, VALID_ANSWER)
expect(res).toEqual({ ok: false, error: 'Niet beschikbaar in demo-modus' })
expect(mockPrisma.claudeQuestion.findFirst).not.toHaveBeenCalled()
expect(mockPrisma.claudeQuestion.updateMany).not.toHaveBeenCalled()
expect(mockRevalidate).not.toHaveBeenCalled()
})
it('user zonder product-access: error, geen update', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce(null)
const res = await answerQuestion(VALID_ID, VALID_ANSWER)
expect(res).toEqual({ ok: false, error: 'Vraag niet gevonden of geen toegang' })
expect(mockPrisma.claudeQuestion.updateMany).not.toHaveBeenCalled()
})
it('al-answered: race-error met begrijpelijke melding', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({
id: VALID_ID,
story_id: 'story-1',
idea_id: null,
product_id: 'product-1',
idea: null,
})
mockPrisma.claudeQuestion.updateMany.mockResolvedValueOnce({ count: 0 })
mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({
status: 'answered',
expires_at: new Date(Date.now() + 60_000),
})
const res = await answerQuestion(VALID_ID, VALID_ANSWER)
expect(res).toEqual({ ok: false, error: 'Vraag is al answered' })
expect(mockRevalidate).not.toHaveBeenCalled()
})
it('verlopen: updateMany count=0, nog open status maar voorbij expiry', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({
id: VALID_ID,
story_id: 'story-1',
idea_id: null,
product_id: 'product-1',
idea: null,
})
mockPrisma.claudeQuestion.updateMany.mockResolvedValueOnce({ count: 0 })
mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({
status: 'open',
expires_at: new Date(Date.now() - 60_000),
})
const res = await answerQuestion(VALID_ID, VALID_ANSWER)
expect(res).toEqual({ ok: false, error: 'Vraag is verlopen' })
})
it('lege answer: Zod-validatie faalt', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
const res = await answerQuestion(VALID_ID, '')
expect(res.ok).toBe(false)
if (!res.ok) {
expect(res.error.toLowerCase()).toMatch(/string|character|leeg|empty|small/i)
}
expect(mockPrisma.claudeQuestion.findFirst).not.toHaveBeenCalled()
})
})

View file

@ -1,72 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockUserUpdate, mockGetIronSession } = vi.hoisted(() => ({
mockUserUpdate: vi.fn(),
mockGetIronSession: vi.fn(),
}))
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) }))
vi.mock('iron-session', () => ({ getIronSession: mockGetIronSession }))
vi.mock('@/lib/session', () => ({ sessionOptions: { cookieName: 'test', password: 'test' } }))
vi.mock('@/lib/prisma', () => ({
prisma: { user: { update: mockUserUpdate } },
}))
import { updateMinQuotaPctAction } from '@/actions/settings'
const SESSION_USER = { userId: 'user-1', isDemo: false }
const SESSION_DEMO = { userId: 'demo-1', isDemo: true }
const SESSION_UNAUTH = { userId: undefined, isDemo: false }
describe('updateMinQuotaPctAction', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUserUpdate.mockResolvedValue({})
})
it('returns error when not authenticated', async () => {
mockGetIronSession.mockResolvedValue(SESSION_UNAUTH)
const result = await updateMinQuotaPctAction(20)
expect(result).toMatchObject({ error: expect.any(String) })
expect(mockUserUpdate).not.toHaveBeenCalled()
})
it('returns 403 error for demo session', async () => {
mockGetIronSession.mockResolvedValue(SESSION_DEMO)
const result = await updateMinQuotaPctAction(20)
expect(result).toMatchObject({ status: 403 })
expect(mockUserUpdate).not.toHaveBeenCalled()
})
it('returns 422 error when value is 0 (below min)', async () => {
mockGetIronSession.mockResolvedValue(SESSION_USER)
const result = await updateMinQuotaPctAction(0)
expect(result).toMatchObject({ status: 422 })
expect(mockUserUpdate).not.toHaveBeenCalled()
})
it('returns 422 error when value is 101 (above max)', async () => {
mockGetIronSession.mockResolvedValue(SESSION_USER)
const result = await updateMinQuotaPctAction(101)
expect(result).toMatchObject({ status: 422 })
expect(mockUserUpdate).not.toHaveBeenCalled()
})
it('saves valid value and returns success', async () => {
mockGetIronSession.mockResolvedValue(SESSION_USER)
const result = await updateMinQuotaPctAction(35)
expect(result).toEqual({ success: true })
expect(mockUserUpdate).toHaveBeenCalledWith({
where: { id: 'user-1' },
data: { min_quota_pct: 35 },
})
})
it('accepts boundary values 1 and 100', async () => {
mockGetIronSession.mockResolvedValue(SESSION_USER)
await updateMinQuotaPctAction(1)
await updateMinQuotaPctAction(100)
expect(mockUserUpdate).toHaveBeenCalledTimes(2)
})
})

View file

@ -1,102 +0,0 @@
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({}),
getAccessibleProduct: vi.fn().mockResolvedValue({ id: 'product-1', user_id: 'user-1' }),
}))
vi.mock('@/lib/prisma', () => ({
prisma: {
sprint: {
findFirst: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
user: {
findUnique: vi.fn().mockResolvedValue({ settings: {} }),
update: vi.fn().mockResolvedValue({}),
},
$executeRaw: vi.fn().mockResolvedValue(1),
},
}))
import { prisma } from '@/lib/prisma'
import { createSprintAction, updateSprintDatesAction } from '@/actions/sprints'
const mockSprint = prisma as unknown as { sprint: { findFirst: ReturnType<typeof vi.fn>; findMany: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } }
function makeFormData(data: Record<string, string | null>) {
const fd = new FormData()
for (const [k, v] of Object.entries(data)) {
if (v !== null) fd.append(k, v)
}
return fd
}
describe('createSprintAction — date validation', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSprint.sprint.findFirst.mockResolvedValue(null)
mockSprint.sprint.findMany.mockResolvedValue([])
mockSprint.sprint.create.mockResolvedValue({ id: 'sprint-1' })
})
it('accepts valid start_date + end_date', async () => {
const fd = makeFormData({ productId: 'product-1', sprint_goal: 'Doel', start_date: '2026-05-01', end_date: '2026-05-14' })
const result = await createSprintAction(undefined, fd)
expect(result.success).toBe(true)
expect(mockSprint.sprint.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ start_date: new Date('2026-05-01'), end_date: new Date('2026-05-14') }) })
)
})
it('rejects end_date before start_date', async () => {
const fd = makeFormData({ productId: 'product-1', sprint_goal: 'Doel', start_date: '2026-05-14', end_date: '2026-05-01' })
const result = await createSprintAction(undefined, fd) as { code?: number; fieldErrors?: Record<string, string[]> }
expect(result.code).toBe(422)
expect(result.fieldErrors?.end_date?.[0]).toContain('Einddatum')
})
it('accepts no dates (both optional)', async () => {
const fd = makeFormData({ productId: 'product-1', sprint_goal: 'Doel', start_date: '', end_date: '' })
const result = await createSprintAction(undefined, fd)
expect(result.success).toBe(true)
})
})
describe('updateSprintDatesAction — date validation', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSprint.sprint.findFirst.mockResolvedValue({ id: 'sprint-1', product_id: 'product-1' })
mockSprint.sprint.update.mockResolvedValue({})
})
it('saves valid dates', async () => {
const fd = makeFormData({ id: 'sprint-1', start_date: '2026-05-01', end_date: '2026-05-14' })
const result = await updateSprintDatesAction(undefined, fd)
expect(result.success).toBe(true)
})
it('rejects end_date before start_date', async () => {
const fd = makeFormData({ id: 'sprint-1', start_date: '2026-05-10', end_date: '2026-05-05' })
const result = await updateSprintDatesAction(undefined, fd) as { code?: number; fieldErrors?: Record<string, string[]> }
expect(result.code).toBe(422)
expect(result.fieldErrors?.end_date?.[0]).toContain('Einddatum')
})
it('blocks demo users', async () => {
const { getIronSession } = await import('iron-session')
vi.mocked(getIronSession).mockResolvedValueOnce({ userId: 'user-1', isDemo: true } as never)
const fd = makeFormData({ id: 'sprint-1', start_date: '', end_date: '' })
const result = await updateSprintDatesAction(undefined, fd)
expect(result.error).toBe('Niet beschikbaar in demo-modus')
})
})

View file

@ -1,167 +0,0 @@
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' })
})
})

View file

@ -1,407 +0,0 @@
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(),
}))
vi.mock('@/lib/session', () => ({
sessionOptions: { cookieName: 'test', password: 'test' },
}))
vi.mock('@/lib/prisma', () => ({
prisma: {
sprint: {
findUnique: vi.fn(),
update: vi.fn(),
},
sprintRun: {
findFirst: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
story: {
findMany: vi.fn(),
updateMany: vi.fn(),
},
pbi: {
updateMany: vi.fn(),
},
task: {
updateMany: vi.fn(),
findUnique: vi.fn().mockResolvedValue(null),
},
claudeQuestion: {
findMany: vi.fn(),
},
claudeJob: {
create: vi.fn(),
updateMany: vi.fn(),
},
product: {
findUnique: vi.fn().mockResolvedValue(null),
},
$transaction: vi.fn(),
},
}))
import { prisma } from '@/lib/prisma'
import { getIronSession } from 'iron-session'
import {
startSprintRunAction,
resumeSprintAction,
cancelSprintRunAction,
} from '@/actions/sprint-runs'
const mockSession = getIronSession as ReturnType<typeof vi.fn>
type Mocked = {
sprint: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
sprintRun: {
findFirst: ReturnType<typeof vi.fn>
findUnique: ReturnType<typeof vi.fn>
create: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
story: {
findMany: ReturnType<typeof vi.fn>
updateMany: ReturnType<typeof vi.fn>
}
pbi: { updateMany: ReturnType<typeof vi.fn> }
task: { updateMany: ReturnType<typeof vi.fn> }
claudeQuestion: { findMany: ReturnType<typeof vi.fn> }
claudeJob: {
create: ReturnType<typeof vi.fn>
updateMany: ReturnType<typeof vi.fn>
}
$transaction: ReturnType<typeof vi.fn>
}
const mockPrisma = prisma as unknown as Mocked
const SPRINT_OK = {
id: 'sprint-1',
status: 'OPEN',
product_id: 'prod-1',
product: { id: 'prod-1', pr_strategy: 'SPRINT' },
}
const STORY_OK = {
id: 'story-1',
pbi_id: 'pbi-1',
priority: 1,
sort_order: 1,
pbi: {
id: 'pbi-1',
code: 'PBI-1',
title: 'PBI',
status: 'READY',
priority: 1,
sort_order: 1,
},
tasks: [
{ id: 'task-1', code: 'T-1', title: 'T1', priority: 1, sort_order: 1, implementation_plan: 'plan' },
{ id: 'task-2', code: 'T-2', title: 'T2', priority: 1, sort_order: 2, implementation_plan: 'plan' },
],
}
beforeEach(() => {
vi.clearAllMocks()
mockSession.mockResolvedValue({ userId: 'user-1', isDemo: false })
mockPrisma.$transaction.mockImplementation(
async (run: (tx: typeof prisma) => Promise<unknown>) => run(prisma),
)
})
describe('startSprintRunAction — happy path', () => {
it('maakt SprintRun + 2 ClaudeJobs voor 2 TO_DO tasks', async () => {
mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_OK)
mockPrisma.sprintRun.findFirst.mockResolvedValue(null)
mockPrisma.story.findMany.mockResolvedValue([STORY_OK])
mockPrisma.claudeQuestion.findMany.mockResolvedValue([])
mockPrisma.sprintRun.create.mockResolvedValue({ id: 'run-1' })
mockPrisma.claudeJob.create.mockResolvedValue({ id: 'job-x' })
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
expect(result).toEqual({ ok: true, sprint_run_id: 'run-1', jobs_count: 2 })
expect(mockPrisma.sprintRun.create).toHaveBeenCalledWith({
data: expect.objectContaining({
sprint_id: 'sprint-1',
started_by_id: 'user-1',
status: 'QUEUED',
pr_strategy: 'SPRINT',
}),
})
expect(mockPrisma.claudeJob.create).toHaveBeenCalledTimes(2)
})
})
describe('startSprintRunAction — pre-flight blockers', () => {
it('blokkeert wanneer task geen implementation_plan heeft', async () => {
mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_OK)
mockPrisma.sprintRun.findFirst.mockResolvedValue(null)
mockPrisma.story.findMany.mockResolvedValue([
{
...STORY_OK,
tasks: [
{ id: 'task-1', code: 'T-1', title: 'T1', priority: 1, sort_order: 1, implementation_plan: null },
],
},
])
mockPrisma.claudeQuestion.findMany.mockResolvedValue([])
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
expect(result).toMatchObject({ ok: false, error: 'PRE_FLIGHT_BLOCKED' })
if (result.ok === false && 'blockers' in result) {
expect(result.blockers).toContainEqual({
type: 'task_no_plan',
id: 'task-1',
label: 'T-1: T1',
})
}
expect(mockPrisma.sprintRun.create).not.toHaveBeenCalled()
})
it('blokkeert wanneer er een open ClaudeQuestion in scope is', async () => {
mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_OK)
mockPrisma.sprintRun.findFirst.mockResolvedValue(null)
mockPrisma.story.findMany.mockResolvedValue([STORY_OK])
mockPrisma.claudeQuestion.findMany.mockResolvedValue([
{ id: 'q-1', question: 'Welke route?' },
])
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
expect(result).toMatchObject({ ok: false, error: 'PRE_FLIGHT_BLOCKED' })
if (result.ok === false && 'blockers' in result) {
expect(result.blockers).toContainEqual({
type: 'open_question',
id: 'q-1',
label: 'Welke route?',
})
}
})
it('blokkeert wanneer een PBI BLOCKED of FAILED is', async () => {
mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_OK)
mockPrisma.sprintRun.findFirst.mockResolvedValue(null)
mockPrisma.story.findMany.mockResolvedValue([
{ ...STORY_OK, pbi: { ...STORY_OK.pbi, status: 'BLOCKED' } },
])
mockPrisma.claudeQuestion.findMany.mockResolvedValue([])
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
expect(result).toMatchObject({ ok: false, error: 'PRE_FLIGHT_BLOCKED' })
if (result.ok === false && 'blockers' in result) {
expect(result.blockers).toContainEqual({
type: 'pbi_blocked',
id: 'pbi-1',
label: 'PBI-1: PBI',
})
}
})
})
describe('startSprintRunAction — SPRINT_BATCH', () => {
const SPRINT_BATCH = {
...SPRINT_OK,
product: {
id: 'prod-1',
pr_strategy: 'SPRINT_BATCH',
repo_url: 'https://github.com/example/main',
},
}
it('blokkeert task met afwijkende repo_url', async () => {
mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_BATCH)
mockPrisma.sprintRun.findFirst.mockResolvedValue(null)
mockPrisma.story.findMany.mockResolvedValue([
{
...STORY_OK,
tasks: [
{
id: 'task-1',
code: 'T-1',
title: 'In main repo',
priority: 1,
sort_order: 1,
implementation_plan: 'plan',
repo_url: null,
},
{
id: 'task-2',
code: 'T-2',
title: 'Cross-repo',
priority: 1,
sort_order: 2,
implementation_plan: 'plan',
repo_url: 'https://github.com/example/other',
},
],
},
])
mockPrisma.claudeQuestion.findMany.mockResolvedValue([])
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
expect(result).toMatchObject({ ok: false, error: 'PRE_FLIGHT_BLOCKED' })
if (result.ok === false && 'blockers' in result) {
expect(result.blockers).toContainEqual({
type: 'task_cross_repo',
id: 'task-2',
label: 'T-2: Cross-repo',
})
}
expect(mockPrisma.sprintRun.create).not.toHaveBeenCalled()
})
it('staat tasks toe wanneer repo_url leeg is of gelijk aan product.repo_url', async () => {
mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_BATCH)
mockPrisma.sprintRun.findFirst.mockResolvedValue(null)
mockPrisma.story.findMany.mockResolvedValue([
{
...STORY_OK,
tasks: [
{
id: 'task-1',
code: 'T-1',
title: 'No override',
priority: 1,
sort_order: 1,
implementation_plan: 'plan',
repo_url: null,
},
{
id: 'task-2',
code: 'T-2',
title: 'Same repo',
priority: 1,
sort_order: 2,
implementation_plan: 'plan',
repo_url: 'https://github.com/example/main',
},
],
},
])
mockPrisma.claudeQuestion.findMany.mockResolvedValue([])
mockPrisma.sprintRun.create.mockResolvedValue({ id: 'run-batch' })
mockPrisma.claudeJob.create.mockResolvedValue({ id: 'job-sprint' })
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
expect(result).toMatchObject({ ok: true, sprint_run_id: 'run-batch' })
// Eén SPRINT_IMPLEMENTATION-job, niet per-task
expect(mockPrisma.claudeJob.create).toHaveBeenCalledTimes(1)
expect(mockPrisma.claudeJob.create).toHaveBeenCalledWith({
data: expect.objectContaining({
kind: 'SPRINT_IMPLEMENTATION',
sprint_run_id: 'run-batch',
product_id: 'prod-1',
}),
})
})
})
describe('startSprintRunAction — guards', () => {
it('weigert wanneer Sprint niet ACTIVE is', async () => {
mockPrisma.sprint.findUnique.mockResolvedValue({ ...SPRINT_OK, status: 'CLOSED' })
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
expect(result).toMatchObject({ ok: false, error: 'SPRINT_NOT_ACTIVE' })
})
it('weigert wanneer er al een actieve SprintRun is', async () => {
mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_OK)
mockPrisma.sprintRun.findFirst.mockResolvedValue({ id: 'run-existing', status: 'RUNNING' })
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
expect(result).toMatchObject({ ok: false, error: 'SPRINT_RUN_ALREADY_ACTIVE' })
})
it('weigert demo-sessie', async () => {
mockSession.mockResolvedValue({ userId: 'demo', isDemo: true })
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
expect(result).toMatchObject({ ok: false, code: 403 })
})
})
describe('resumeSprintAction', () => {
it('zet sprint en cascade-statuses terug en maakt nieuwe SprintRun', async () => {
// Eerste findUnique (resume) ziet de sprint nog op FAILED;
// de tweede call (binnen startSprintRunCore na de update) ziet ACTIVE.
mockPrisma.sprint.findUnique
.mockResolvedValueOnce({ ...SPRINT_OK, status: 'FAILED' })
.mockResolvedValue(SPRINT_OK)
mockPrisma.sprintRun.findFirst.mockResolvedValue(null)
mockPrisma.story.findMany.mockImplementation(async (args: { select?: { pbi_id?: boolean } }) => {
if (args.select?.pbi_id) return [{ pbi_id: 'pbi-1' }]
return [STORY_OK]
})
mockPrisma.claudeQuestion.findMany.mockResolvedValue([])
mockPrisma.sprintRun.create.mockResolvedValue({ id: 'run-2' })
mockPrisma.claudeJob.create.mockResolvedValue({ id: 'job-x' })
const result = await resumeSprintAction({ sprint_id: 'sprint-1' })
expect(result).toMatchObject({ ok: true, sprint_run_id: 'run-2' })
expect(mockPrisma.sprint.update).toHaveBeenCalledWith({
where: { id: 'sprint-1' },
data: { status: 'OPEN', completed_at: null },
})
expect(mockPrisma.story.updateMany).toHaveBeenCalledWith({
where: { sprint_id: 'sprint-1', status: 'FAILED' },
data: { status: 'IN_SPRINT' },
})
expect(mockPrisma.task.updateMany).toHaveBeenCalledWith({
where: { story: { sprint_id: 'sprint-1' }, status: 'FAILED' },
data: { status: 'TO_DO' },
})
})
it('weigert als sprint niet FAILED is', async () => {
mockPrisma.sprint.findUnique.mockResolvedValue({ ...SPRINT_OK, status: 'OPEN' })
const result = await resumeSprintAction({ sprint_id: 'sprint-1' })
expect(result).toMatchObject({ ok: false, error: 'SPRINT_NOT_FAILED' })
})
})
describe('cancelSprintRunAction', () => {
it('zet SprintRun op CANCELLED en cancelt openstaande jobs', async () => {
mockPrisma.sprintRun.findUnique.mockResolvedValue({
id: 'run-1',
status: 'RUNNING',
sprint_id: 'sprint-1',
})
const result = await cancelSprintRunAction({ sprint_run_id: 'run-1' })
expect(result).toEqual({ ok: true })
expect(mockPrisma.sprintRun.update).toHaveBeenCalledWith({
where: { id: 'run-1' },
data: expect.objectContaining({ status: 'CANCELLED' }),
})
expect(mockPrisma.claudeJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
sprint_run_id: 'run-1',
status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] },
}),
data: expect.objectContaining({ status: 'CANCELLED' }),
}))
})
it('weigert wanneer SprintRun al DONE is', async () => {
mockPrisma.sprintRun.findUnique.mockResolvedValue({
id: 'run-1',
status: 'DONE',
sprint_id: 'sprint-1',
})
const result = await cancelSprintRunAction({ sprint_run_id: 'run-1' })
expect(result).toMatchObject({ ok: false, error: 'SPRINT_RUN_NOT_CANCELLABLE' })
})
})

View file

@ -1,238 +0,0 @@
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({}),
getAccessibleProduct: vi.fn().mockResolvedValue({ id: 'product-1' }),
}))
vi.mock('@/lib/prisma', () => ({
prisma: {
sprint: {
findFirst: vi.fn(),
update: vi.fn(),
},
story: {
findMany: vi.fn(),
update: vi.fn(),
},
pbi: {
findMany: vi.fn(),
update: vi.fn(),
},
$transaction: vi.fn().mockResolvedValue([]),
},
}))
import { prisma } from '@/lib/prisma'
import { completeSprintAction } from '@/actions/sprints'
const mockPrisma = prisma as unknown as {
sprint: {
findFirst: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
story: {
findMany: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
pbi: {
findMany: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
$transaction: ReturnType<typeof vi.fn>
}
const SPRINT = { id: 'sprint-1', product_id: 'product-1', status: 'OPEN' }
beforeEach(() => {
vi.clearAllMocks()
mockPrisma.sprint.findFirst.mockResolvedValue(SPRINT)
mockPrisma.$transaction.mockResolvedValue([])
})
describe('completeSprintAction — PBI auto-DONE cascade', () => {
it('marks PBI DONE when all its stories are decided DONE', async () => {
mockPrisma.story.findMany.mockResolvedValue([
{ id: 'story-a', pbi_id: 'pbi-1' },
{ id: 'story-b', pbi_id: 'pbi-1' },
])
mockPrisma.pbi.findMany.mockResolvedValue([
{
id: 'pbi-1',
stories: [
{ id: 'story-a', status: 'IN_SPRINT' },
{ id: 'story-b', status: 'IN_SPRINT' },
],
},
])
const result = await completeSprintAction('sprint-1', {
'story-a': 'DONE',
'story-b': 'DONE',
})
expect(result).toEqual({ success: true })
expect(mockPrisma.pbi.update).toHaveBeenCalledTimes(1)
expect(mockPrisma.pbi.update).toHaveBeenCalledWith({
where: { id: 'pbi-1' },
data: { status: 'DONE' },
})
})
it('does not mark PBI DONE when a story is decided OPEN', async () => {
mockPrisma.story.findMany.mockResolvedValue([
{ id: 'story-a', pbi_id: 'pbi-1' },
{ id: 'story-b', pbi_id: 'pbi-1' },
])
mockPrisma.pbi.findMany.mockResolvedValue([
{
id: 'pbi-1',
stories: [
{ id: 'story-a', status: 'IN_SPRINT' },
{ id: 'story-b', status: 'IN_SPRINT' },
],
},
])
const result = await completeSprintAction('sprint-1', {
'story-a': 'DONE',
'story-b': 'OPEN',
})
expect(result).toEqual({ success: true })
expect(mockPrisma.pbi.update).not.toHaveBeenCalled()
})
it('does not mark PBI DONE when it has stories outside this sprint that are not DONE', async () => {
mockPrisma.story.findMany.mockResolvedValue([
{ id: 'story-a', pbi_id: 'pbi-1' },
])
mockPrisma.pbi.findMany.mockResolvedValue([
{
id: 'pbi-1',
stories: [
{ id: 'story-a', status: 'IN_SPRINT' },
{ id: 'story-b', status: 'OPEN' },
],
},
])
const result = await completeSprintAction('sprint-1', {
'story-a': 'DONE',
})
expect(result).toEqual({ success: true })
expect(mockPrisma.pbi.update).not.toHaveBeenCalled()
})
it('marks PBI DONE when its in-sprint stories are DONE and out-of-sprint stories are already DONE', async () => {
mockPrisma.story.findMany.mockResolvedValue([
{ id: 'story-a', pbi_id: 'pbi-1' },
])
mockPrisma.pbi.findMany.mockResolvedValue([
{
id: 'pbi-1',
stories: [
{ id: 'story-a', status: 'IN_SPRINT' },
{ id: 'story-b', status: 'DONE' },
],
},
])
const result = await completeSprintAction('sprint-1', {
'story-a': 'DONE',
})
expect(result).toEqual({ success: true })
expect(mockPrisma.pbi.update).toHaveBeenCalledWith({
where: { id: 'pbi-1' },
data: { status: 'DONE' },
})
})
it('skips PBIs that are already DONE (promote-only)', async () => {
mockPrisma.story.findMany.mockResolvedValue([
{ id: 'story-a', pbi_id: 'pbi-1' },
])
// pbi.findMany filters via { status: { not: 'DONE' } } in the action,
// so an already-DONE PBI just doesn't appear in candidatePbis.
mockPrisma.pbi.findMany.mockResolvedValue([])
const result = await completeSprintAction('sprint-1', {
'story-a': 'DONE',
})
expect(result).toEqual({ success: true })
expect(mockPrisma.pbi.update).not.toHaveBeenCalled()
expect(mockPrisma.pbi.findMany).toHaveBeenCalledWith({
where: { id: { in: ['pbi-1'] }, status: { not: 'DONE' } },
select: { id: true, stories: { select: { id: true, status: true } } },
})
})
it('cascades across multiple PBIs in one sprint close', async () => {
mockPrisma.story.findMany.mockResolvedValue([
{ id: 'story-a', pbi_id: 'pbi-1' },
{ id: 'story-b', pbi_id: 'pbi-2' },
])
mockPrisma.pbi.findMany.mockResolvedValue([
{ id: 'pbi-1', stories: [{ id: 'story-a', status: 'IN_SPRINT' }] },
{ id: 'pbi-2', stories: [{ id: 'story-b', status: 'IN_SPRINT' }] },
])
const result = await completeSprintAction('sprint-1', {
'story-a': 'DONE',
'story-b': 'DONE',
})
expect(result).toEqual({ success: true })
expect(mockPrisma.pbi.update).toHaveBeenCalledTimes(2)
expect(mockPrisma.pbi.update).toHaveBeenCalledWith({
where: { id: 'pbi-1' },
data: { status: 'DONE' },
})
expect(mockPrisma.pbi.update).toHaveBeenCalledWith({
where: { id: 'pbi-2' },
data: { status: 'DONE' },
})
})
it('does not include 0-story PBIs in cascade', async () => {
mockPrisma.story.findMany.mockResolvedValue([
{ id: 'story-a', pbi_id: 'pbi-1' },
])
mockPrisma.pbi.findMany.mockResolvedValue([
{ id: 'pbi-1', stories: [] },
])
const result = await completeSprintAction('sprint-1', {
'story-a': 'DONE',
})
expect(result).toEqual({ success: true })
expect(mockPrisma.pbi.update).not.toHaveBeenCalled()
})
it('blocks sprint close for demo users', async () => {
const { getIronSession } = await import('iron-session')
;(getIronSession as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
userId: 'user-demo',
isDemo: true,
})
const result = await completeSprintAction('sprint-1', {
'story-a': 'DONE',
})
expect(result).toEqual({ error: 'Niet beschikbaar in demo-modus' })
expect(mockPrisma.$transaction).not.toHaveBeenCalled()
expect(mockPrisma.pbi.update).not.toHaveBeenCalled()
})
})

View file

@ -50,7 +50,7 @@ const mockRequireProductWriter = requireProductWriter as ReturnType<typeof vi.fn
const mockGetIronSession = getIronSession as ReturnType<typeof vi.fn>
const STORY = { id: 'story-1', product_id: 'product-1', assignee_id: null }
const SPRINT = { id: 'sprint-1', product_id: 'product-1', status: 'OPEN' }
const SPRINT = { id: 'sprint-1', product_id: 'product-1', status: 'ACTIVE' }
beforeEach(() => {
vi.clearAllMocks()

View file

@ -1,268 +0,0 @@
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(),
findMany: vi.fn(),
update: vi.fn(),
},
pbi: {
findUniqueOrThrow: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
},
sprint: {
findUniqueOrThrow: vi.fn(),
update: vi.fn(),
},
claudeJob: {
findFirst: vi.fn(),
updateMany: vi.fn(),
},
sprintRun: {
findUnique: 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<typeof vi.fn>
create: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
delete: ReturnType<typeof vi.fn>
findMany: ReturnType<typeof vi.fn>
}
story: {
findFirst: ReturnType<typeof vi.fn>
findUniqueOrThrow: ReturnType<typeof vi.fn>
findMany: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
pbi: {
findUniqueOrThrow: ReturnType<typeof vi.fn>
findMany: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
sprint: {
findUniqueOrThrow: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
claudeJob: {
findFirst: ReturnType<typeof vi.fn>
updateMany: ReturnType<typeof vi.fn>
}
sprintRun: {
findUnique: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
$transaction: ReturnType<typeof vi.fn>
}
const mockSession = getIronSession as ReturnType<typeof vi.fn>
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<unknown>) => {
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({
id: 'story-1',
status: 'IN_SPRINT',
pbi_id: 'pbi-1',
sprint_id: null,
})
mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }])
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
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() }),
}),
)
})
})

View file

@ -1,148 +0,0 @@
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({}),
getAccessibleProduct: vi.fn().mockResolvedValue({ id: 'product-1' }),
}))
vi.mock('@/lib/rate-limit', () => ({
enforceUserRateLimit: vi.fn().mockReturnValue(null),
}))
vi.mock('@/lib/code-server', () => ({
createWithCodeRetry: vi.fn(),
generateNextSprintCode: vi.fn(),
}))
vi.mock('@/lib/active-sprint', () => ({
setActiveSprintInSettings: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/prisma', () => ({
prisma: {
sprint: {
findFirst: vi.fn(),
update: vi.fn(),
},
story: {
findMany: vi.fn(),
updateMany: vi.fn(),
},
task: {
findMany: vi.fn(),
updateMany: vi.fn(),
},
$transaction: vi.fn(),
},
}))
import { prisma } from '@/lib/prisma'
import { updateSprintAction } from '@/actions/sprints'
type Mocked = {
sprint: {
findFirst: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
}
const mockPrisma = prisma as unknown as Mocked
beforeEach(() => {
vi.clearAllMocks()
mockPrisma.sprint.findFirst.mockReset().mockResolvedValue({
id: 'sprint-1',
product_id: 'product-1',
})
mockPrisma.sprint.update.mockReset().mockResolvedValue({})
})
describe('updateSprintAction', () => {
it('updates sprint_goal alone', async () => {
const result = await updateSprintAction({
sprintId: 'sprint-1',
fields: { goal: 'Nieuw doel' },
})
expect('success' in result).toBe(true)
expect(mockPrisma.sprint.update).toHaveBeenCalledWith({
where: { id: 'sprint-1' },
data: { sprint_goal: 'Nieuw doel' },
})
})
it('updates dates only', async () => {
await updateSprintAction({
sprintId: 'sprint-1',
fields: { startAt: '2026-06-01', endAt: '2026-06-14' },
})
expect(mockPrisma.sprint.update).toHaveBeenCalledWith({
where: { id: 'sprint-1' },
data: {
start_date: new Date('2026-06-01'),
end_date: new Date('2026-06-14'),
},
})
})
it('accepts null to clear a date', async () => {
await updateSprintAction({
sprintId: 'sprint-1',
fields: { startAt: null },
})
expect(mockPrisma.sprint.update).toHaveBeenCalledWith({
where: { id: 'sprint-1' },
data: { start_date: null },
})
})
it('rejects when sprint not accessible', async () => {
mockPrisma.sprint.findFirst.mockResolvedValue(null)
const result = await updateSprintAction({
sprintId: 'sprint-1',
fields: { goal: 'x' },
})
expect('error' in result).toBe(true)
if ('error' in result) {
expect(result.code).toBe(403)
}
expect(mockPrisma.sprint.update).not.toHaveBeenCalled()
})
it('rejects empty goal', async () => {
const result = await updateSprintAction({
sprintId: 'sprint-1',
fields: { goal: '' },
})
expect('error' in result).toBe(true)
expect(mockPrisma.sprint.update).not.toHaveBeenCalled()
})
it('rejects when no fields are supplied', async () => {
const result = await updateSprintAction({
sprintId: 'sprint-1',
fields: {},
})
// Schema-refine should reject; OR action treats empty data as no-op success.
// Current implementation: refine forces minstens één veld → 422 error.
expect('error' in result).toBe(true)
if ('error' in result) {
expect(result.code).toBe(422)
}
})
})

View file

@ -1,82 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
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/prisma', () => ({
prisma: {
user: { findUnique: vi.fn() },
$transaction: vi.fn(async (fn: (tx: unknown) => Promise<unknown>) => {
return fn({
user: {
findUnique: vi.fn().mockResolvedValue({ settings: {} }),
update: vi.fn().mockResolvedValue({}),
},
})
}),
$executeRaw: vi.fn().mockResolvedValue(1),
},
}))
import { prisma } from '@/lib/prisma'
import { getIronSession } from 'iron-session'
import { updateUserSettingsAction } from '@/actions/user-settings'
const mockPrisma = prisma as unknown as {
user: { findUnique: ReturnType<typeof vi.fn> }
$transaction: ReturnType<typeof vi.fn>
$executeRaw: ReturnType<typeof vi.fn>
}
const mockGetIronSession = getIronSession as ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
mockGetIronSession.mockResolvedValue({ userId: 'user-1', isDemo: false })
mockPrisma.$executeRaw.mockResolvedValue(1)
})
describe('updateUserSettingsAction', () => {
it('returns 401 when not logged in', async () => {
mockGetIronSession.mockResolvedValue({ userId: undefined, isDemo: false })
const result = await updateUserSettingsAction({})
expect(result).toEqual({ error: 'Niet ingelogd', code: 401 })
})
it('returns 403 for demo accounts', async () => {
mockGetIronSession.mockResolvedValue({ userId: 'user-1', isDemo: true })
const result = await updateUserSettingsAction({})
expect('error' in result && result.code).toBe(403)
})
it('returns 422 when patch is invalid', async () => {
const result = await updateUserSettingsAction({
views: { sprintBacklog: { filterStatus: 'NONSENSE' } },
} as never)
expect('error' in result && result.code).toBe(422)
})
it('merges with current settings and emits notify on success', async () => {
const existingFindUnique = vi.fn().mockResolvedValue({
settings: { views: { sprintBacklog: { sort: 'code' } } },
})
const update = vi.fn().mockResolvedValue({})
mockPrisma.$transaction.mockImplementationOnce(async (fn: (tx: unknown) => Promise<unknown>) => {
return fn({ user: { findUnique: existingFindUnique, update } })
})
const result = await updateUserSettingsAction({
views: { sprintBacklog: { sortDir: 'desc' } },
})
expect('success' in result && result.success).toBe(true)
expect(update).toHaveBeenCalledWith({
where: { id: 'user-1' },
data: { settings: { views: { sprintBacklog: { sort: 'code', sortDir: 'desc' } } } },
})
expect(mockPrisma.$executeRaw).toHaveBeenCalled()
})
})

View file

@ -1,63 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/lib/prisma', () => ({
prisma: {
claudeJob: { deleteMany: vi.fn() },
},
}))
import { prisma } from '@/lib/prisma'
import { POST } from '@/app/api/cron/cleanup-agent-artifacts/route'
const mockPrisma = prisma as unknown as {
claudeJob: { deleteMany: ReturnType<typeof vi.fn> }
}
const SECRET = 'test-cron-secret-abc123'
function makeReq(headers: Record<string, string> = {}): Request {
return new Request('http://localhost:3000/api/cron/cleanup-agent-artifacts', {
method: 'POST',
headers,
})
}
beforeEach(() => {
vi.clearAllMocks()
process.env.CRON_SECRET = SECRET
mockPrisma.claudeJob.deleteMany.mockResolvedValue({ count: 0 })
})
describe('POST /api/cron/cleanup-agent-artifacts', () => {
it('401 zonder Authorization-header', async () => {
const res = await POST(makeReq())
expect(res.status).toBe(401)
expect(mockPrisma.claudeJob.deleteMany).not.toHaveBeenCalled()
})
it('401 met verkeerde secret', async () => {
const res = await POST(makeReq({ authorization: 'Bearer wrong-secret' }))
expect(res.status).toBe(401)
expect(mockPrisma.claudeJob.deleteMany).not.toHaveBeenCalled()
})
it('200 met juiste secret + deleteMany aangeroepen voor FAILED/CANCELLED/SKIPPED ouder dan 7 dagen', async () => {
mockPrisma.claudeJob.deleteMany.mockResolvedValue({ count: 5 })
const res = await POST(makeReq({ authorization: 'Bearer ' + SECRET }))
expect(res.status).toBe(200)
const body = await res.json()
expect(body.deleted).toBe(5)
expect(body.ran_at).toMatch(/^\d{4}-\d{2}-\d{2}T/)
const arg = mockPrisma.claudeJob.deleteMany.mock.calls[0][0]
expect(arg.where.status).toEqual({ in: ['FAILED', 'CANCELLED', 'SKIPPED'] })
expect(arg.where.finished_at.lt).toBeInstanceOf(Date)
// cutoff should be approximately 7 days ago
const cutoff = arg.where.finished_at.lt as Date
const diffMs = Date.now() - cutoff.getTime()
const diffDays = diffMs / (1000 * 60 * 60 * 24)
expect(diffDays).toBeCloseTo(7, 0)
})
})

View file

@ -1,77 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/lib/prisma', () => ({
prisma: {
claudeQuestion: { updateMany: vi.fn() },
loginPairing: { updateMany: vi.fn() },
},
}))
import { prisma } from '@/lib/prisma'
import { POST } from '@/app/api/cron/expire-questions/route'
const mockPrisma = prisma as unknown as {
claudeQuestion: { updateMany: ReturnType<typeof vi.fn> }
loginPairing: { updateMany: ReturnType<typeof vi.fn> }
}
const SECRET = 'test-cron-secret-abc123'
function makeReq(headers: Record<string, string> = {}): Request {
return new Request('http://localhost:3000/api/cron/expire-questions', {
method: 'POST',
headers,
})
}
beforeEach(() => {
vi.clearAllMocks()
process.env.CRON_SECRET = SECRET
mockPrisma.claudeQuestion.updateMany.mockResolvedValue({ count: 0 })
mockPrisma.loginPairing.updateMany.mockResolvedValue({ count: 0 })
})
describe('POST /api/cron/expire-questions', () => {
it('401 zonder Authorization-header', async () => {
const res = await POST(makeReq())
expect(res.status).toBe(401)
expect(mockPrisma.claudeQuestion.updateMany).not.toHaveBeenCalled()
expect(mockPrisma.loginPairing.updateMany).not.toHaveBeenCalled()
})
it('401 met verkeerde secret', async () => {
const res = await POST(makeReq({ authorization: 'Bearer wrong-secret' }))
expect(res.status).toBe(401)
})
it('401 als CRON_SECRET niet is gezet (faal-veilig)', async () => {
delete process.env.CRON_SECRET
const res = await POST(makeReq({ authorization: 'Bearer ' + SECRET }))
expect(res.status).toBe(401)
})
it('200 met juiste secret + beide updateMany aangeroepen', async () => {
mockPrisma.claudeQuestion.updateMany.mockResolvedValue({ count: 3 })
mockPrisma.loginPairing.updateMany.mockResolvedValue({ count: 1 })
const res = await POST(makeReq({ authorization: 'Bearer ' + SECRET }))
expect(res.status).toBe(200)
const body = await res.json()
expect(body).toMatchObject({
expired_questions: 3,
expired_pairings: 1,
})
expect(body.ran_at).toMatch(/^\d{4}-\d{2}-\d{2}T/)
// Question-update: status='open' AND expires_at < now → 'expired'
const qArg = mockPrisma.claudeQuestion.updateMany.mock.calls[0][0]
expect(qArg.where).toMatchObject({ status: 'open' })
expect(qArg.where.expires_at).toMatchObject({ lt: expect.any(Date) })
expect(qArg.data).toEqual({ status: 'expired' })
// Pairing-update: status='pending' AND expires_at < now → 'cancelled'
const pArg = mockPrisma.loginPairing.updateMany.mock.calls[0][0]
expect(pArg.where).toMatchObject({ status: 'pending' })
expect(pArg.data).toEqual({ status: 'cancelled' })
})
})

View file

@ -1,120 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/lib/prisma', () => ({
prisma: {
product: { findFirst: vi.fn() },
story: { findMany: vi.fn() },
},
}))
vi.mock('@/lib/api-auth', () => ({
authenticateApiRequest: vi.fn(),
}))
vi.mock('@/lib/product-access', () => ({
productAccessFilter: vi.fn().mockReturnValue({}),
}))
import { prisma } from '@/lib/prisma'
import { authenticateApiRequest } from '@/lib/api-auth'
import { GET } from '@/app/api/products/[id]/cross-sprint-blocks/route'
const mockPrisma = prisma as unknown as {
product: { findFirst: ReturnType<typeof vi.fn> }
story: { findMany: ReturnType<typeof vi.fn> }
}
const mockAuth = authenticateApiRequest as unknown as ReturnType<typeof vi.fn>
function makeRequest(url: string) {
return new Request(url)
}
describe('GET /api/products/[id]/cross-sprint-blocks', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPrisma.product.findFirst.mockReset()
mockPrisma.story.findMany.mockReset()
mockAuth.mockReset().mockResolvedValue({ userId: 'user-1' })
})
it('returns blocking sprint info per story for happy path', async () => {
mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' })
mockPrisma.story.findMany.mockResolvedValue([
{
id: 'story-1',
sprint: { id: 'sprint-x', code: 'SP-X' },
},
{
id: 'story-2',
sprint: { id: 'sprint-y', code: 'SP-Y' },
},
])
const req = makeRequest(
'http://localhost/api/products/p1/cross-sprint-blocks?excludeSprintId=sp-1&pbiIds=pbiA',
)
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
expect(res.status).toBe(200)
const body = await res.json()
expect(body).toEqual({
'story-1': { sprintId: 'sprint-x', sprintName: 'SP-X' },
'story-2': { sprintId: 'sprint-y', sprintName: 'SP-Y' },
})
})
it('rejects when pbiIds is missing', async () => {
const req = makeRequest(
'http://localhost/api/products/p1/cross-sprint-blocks?excludeSprintId=sp-1',
)
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
expect(res.status).toBe(400)
})
it('rejects when pbiIds is empty', async () => {
const req = makeRequest(
'http://localhost/api/products/p1/cross-sprint-blocks?pbiIds=',
)
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
expect(res.status).toBe(400)
})
it('returns 404 when product is not accessible', async () => {
mockPrisma.product.findFirst.mockResolvedValue(null)
const req = makeRequest(
'http://localhost/api/products/p1/cross-sprint-blocks?pbiIds=pbiA',
)
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
expect(res.status).toBe(404)
})
it('returns auth error when authenticate fails', async () => {
mockAuth.mockResolvedValue({ error: 'Niet ingelogd', status: 401 })
const req = makeRequest(
'http://localhost/api/products/p1/cross-sprint-blocks?pbiIds=pbiA',
)
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
expect(res.status).toBe(401)
})
it('passes NOT excludeSprintId to prisma when provided', async () => {
mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' })
mockPrisma.story.findMany.mockResolvedValue([])
const req = makeRequest(
'http://localhost/api/products/p1/cross-sprint-blocks?excludeSprintId=sp-active&pbiIds=pbiA',
)
await GET(req, { params: Promise.resolve({ id: 'p1' }) })
const callArg = mockPrisma.story.findMany.mock.calls[0][0] as {
where: Record<string, unknown>
}
expect(callArg.where).toMatchObject({
pbi_id: { in: ['pbiA'] },
product_id: 'p1',
sprint_id: { not: null },
NOT: { sprint_id: 'sp-active' },
sprint: { status: 'OPEN' },
})
})
})

View file

@ -1,194 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/lib/prisma', () => ({
prisma: {
product: { findFirst: vi.fn() },
idea: {
findFirst: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
ideaLog: { findMany: vi.fn() },
$transaction: vi.fn(),
},
}))
vi.mock('@/lib/api-auth', () => ({
authenticateApiRequest: vi.fn(),
}))
vi.mock('@/lib/idea-code-server', () => ({
nextIdeaCode: vi.fn().mockResolvedValue('IDEA-001'),
}))
import { prisma } from '@/lib/prisma'
import { authenticateApiRequest } from '@/lib/api-auth'
import { GET as getIdeas, POST as postIdea } from '@/app/api/ideas/route'
import { GET as getIdea, PATCH as patchIdea } from '@/app/api/ideas/[id]/route'
type M = {
product: { findFirst: ReturnType<typeof vi.fn> }
idea: { findFirst: ReturnType<typeof vi.fn>; findMany: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
ideaLog: { findMany: ReturnType<typeof vi.fn> }
$transaction: ReturnType<typeof vi.fn>
}
const m = prisma as unknown as M
const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn>
const NOW = new Date('2026-05-04T19:00:00Z')
const IDEA_ROW = {
id: 'idea-1',
user_id: 'user-1',
code: 'IDEA-001',
title: 'Plant-watering reminder',
description: null,
status: 'DRAFT' as const,
product_id: null,
product: null,
pbi: null,
pbi_id: null,
archived: false,
grill_md: null,
plan_md: null,
created_at: NOW,
updated_at: NOW,
}
function makeRequest(method: 'GET' | 'POST' | 'PATCH', url: string, body?: unknown): Request {
return new Request(`http://localhost${url}`, {
method,
headers: {
Authorization: 'Bearer test-token',
'Content-Type': 'application/json',
},
body: body !== undefined ? JSON.stringify(body) : undefined,
})
}
beforeEach(() => {
vi.clearAllMocks()
mockAuth.mockResolvedValue({ userId: 'user-1', isDemo: false })
m.$transaction.mockImplementation(async (arg: unknown) => {
if (typeof arg === 'function') return (arg as (tx: unknown) => unknown)(m)
return arg
})
})
describe('GET /api/ideas', () => {
it('returns user ideas (DTO shape)', async () => {
m.idea.findMany.mockResolvedValueOnce([IDEA_ROW])
const res = await getIdeas(makeRequest('GET', '/api/ideas'))
expect(res.status).toBe(200)
const body = await res.json()
expect(body.ideas).toHaveLength(1)
expect(body.ideas[0]).toMatchObject({
id: 'idea-1',
code: 'IDEA-001',
status: 'draft',
has_grill_md: false,
})
})
it('rejects unauthenticated', async () => {
mockAuth.mockResolvedValueOnce({ error: 'Unauthorized', status: 401 })
const res = await getIdeas(makeRequest('GET', '/api/ideas'))
expect(res.status).toBe(401)
})
it('filters by archived=false param', async () => {
m.idea.findMany.mockResolvedValueOnce([])
await getIdeas(makeRequest('GET', '/api/ideas?archived=false'))
expect(m.idea.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ archived: false, user_id: 'user-1' }),
}),
)
})
})
describe('POST /api/ideas', () => {
it('creates idea and returns 201', async () => {
m.idea.create.mockResolvedValueOnce(IDEA_ROW)
const res = await postIdea(makeRequest('POST', '/api/ideas', { title: 'Plant-watering reminder' }))
expect(res.status).toBe(201)
const body = await res.json()
expect(body.idea).toMatchObject({ id: 'idea-1', code: 'IDEA-001', status: 'draft' })
})
it('rejects demo with 403', async () => {
mockAuth.mockResolvedValueOnce({ userId: 'demo-1', isDemo: true })
const res = await postIdea(makeRequest('POST', '/api/ideas', { title: 'x' }))
expect(res.status).toBe(403)
})
it('rejects empty title with 422', async () => {
const res = await postIdea(makeRequest('POST', '/api/ideas', { title: '' }))
expect(res.status).toBe(422)
})
it('rejects malformed JSON with 400', async () => {
const req = new Request('http://localhost/api/ideas', {
method: 'POST',
headers: { Authorization: 'Bearer test', 'Content-Type': 'application/json' },
body: 'not-json',
})
const res = await postIdea(req)
expect(res.status).toBe(400)
})
it('returns 404 when product_id refers to a foreign product', async () => {
m.product.findFirst.mockResolvedValueOnce(null)
const res = await postIdea(
makeRequest('POST', '/api/ideas', {
title: 'x',
product_id: 'cmohrysyj0000rd17clnjy4tc',
}),
)
expect(res.status).toBe(404)
})
})
describe('GET /api/ideas/[id]', () => {
it('returns idea + logs', async () => {
m.idea.findFirst.mockResolvedValueOnce(IDEA_ROW)
m.ideaLog.findMany.mockResolvedValueOnce([
{ id: 'l-1', type: 'NOTE', content: 'x', metadata: null, created_at: NOW },
])
const ctx = { params: Promise.resolve({ id: 'idea-1' }) }
const res = await getIdea(makeRequest('GET', '/api/ideas/idea-1'), ctx)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.idea).toMatchObject({ id: 'idea-1' })
expect(body.logs).toHaveLength(1)
})
it('returns 404 (not 403) for foreign user — anti-enumeration', async () => {
m.idea.findFirst.mockResolvedValueOnce(null)
const ctx = { params: Promise.resolve({ id: 'idea-1' }) }
const res = await getIdea(makeRequest('GET', '/api/ideas/idea-1'), ctx)
expect(res.status).toBe(404)
})
})
describe('PATCH /api/ideas/[id]', () => {
const ctx = { params: Promise.resolve({ id: 'idea-1' }) }
it('updates editable idea', async () => {
m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', status: 'DRAFT' })
m.idea.update.mockResolvedValueOnce({ ...IDEA_ROW, title: 'Updated' })
const res = await patchIdea(makeRequest('PATCH', '/api/ideas/idea-1', { title: 'Updated' }), ctx)
expect(res.status).toBe(200)
})
it('blocks demo with 403', async () => {
mockAuth.mockResolvedValueOnce({ userId: 'demo-1', isDemo: true })
const res = await patchIdea(makeRequest('PATCH', '/api/ideas/idea-1', { title: 'x' }), ctx)
expect(res.status).toBe(403)
})
it('blocks update on PLANNED with 422', async () => {
m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', status: 'PLANNED' })
const res = await patchIdea(makeRequest('PATCH', '/api/ideas/idea-1', { title: 'x' }), ctx)
expect(res.status).toBe(422)
})
})

View file

@ -25,7 +25,7 @@ const mockPrisma = prisma as unknown as {
}
const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn>
const SPRINT = { id: 'sprint-1', product_id: 'prod-1', status: 'OPEN' }
const SPRINT = { id: 'sprint-1', product_id: 'prod-1', status: 'ACTIVE' }
const STORY = {
id: 'story-1',
title: 'Account aanmaken',
@ -95,7 +95,7 @@ describe('GET /api/products/:id/next-story', () => {
expect(data.tasks[0]).toMatchObject({ id: 'task-1', status: 'todo' })
})
it('queries story ordered by sort_order only', async () => {
it('queries story ordered by priority then sort_order', async () => {
mockPrisma.sprint.findFirst.mockResolvedValue(SPRINT)
mockPrisma.story.findFirst.mockResolvedValue(STORY)
@ -103,7 +103,7 @@ describe('GET /api/products/:id/next-story', () => {
expect(mockPrisma.story.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
orderBy: [{ sort_order: 'asc' }],
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
})
)
})

View file

@ -1,85 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockGetSession } = vi.hoisted(() => ({ mockGetSession: vi.fn() }))
vi.mock('@/lib/auth', () => ({
getSession: mockGetSession,
}))
vi.mock('@/lib/prisma', () => ({
prisma: {
product: { findMany: vi.fn() },
claudeQuestion: { findMany: vi.fn() },
idea: { findMany: vi.fn().mockResolvedValue([]) },
},
}))
vi.mock('@/lib/product-access', () => ({
productAccessFilter: vi.fn().mockReturnValue({}),
getAccessibleProduct: vi.fn(),
}))
import { prisma } from '@/lib/prisma'
import type { NextRequest } from 'next/server'
import { GET } from '@/app/api/realtime/notifications/route'
const mockPrisma = prisma as unknown as {
product: { findMany: ReturnType<typeof vi.fn> }
claudeQuestion: { findMany: ReturnType<typeof vi.fn> }
}
function makeReq(): NextRequest {
// Minimaal NextRequest-shape voor de auth-pad — we komen niet bij de
// pg-stream-setup omdat de auth-fail vóór dat punt gebeurt.
return { signal: new AbortController().signal } as unknown as NextRequest
}
beforeEach(() => {
vi.clearAllMocks()
})
import { productAccessFilter } from '@/lib/product-access'
const mockProductAccessFilter = productAccessFilter as ReturnType<typeof vi.fn>
describe('GET /api/realtime/notifications', () => {
it('401 zonder iron-session cookie, geen DB-call', async () => {
mockGetSession.mockResolvedValue({ userId: undefined, isDemo: false })
const res = await GET(makeReq())
expect(res.status).toBe(401)
expect(mockPrisma.product.findMany).not.toHaveBeenCalled()
})
it('access-isolation: productAccessFilter wordt met de juiste userId aangeroepen', async () => {
// ST-1106: cross-product-isolatie zit in de productAccessFilter-Set die we
// bij connect opbouwen. We mocken de filter zodat product.findMany wel
// aangeroepen wordt maar geen producten retourneert; daarna stoppen we
// vóór de pg-stream-setup (DIRECT_URL ontbreekt → 500).
mockGetSession.mockResolvedValue({ userId: 'user-A', isDemo: false })
mockProductAccessFilter.mockReturnValue({ user_id: 'user-A' })
mockPrisma.product.findMany.mockResolvedValue([])
// We laten de stream zelf falen door DIRECT_URL/DATABASE_URL niet te zetten.
const before = { ...process.env }
delete process.env.DIRECT_URL
delete process.env.DATABASE_URL
try {
const res = await GET(makeReq())
expect(res.status).toBe(500)
} finally {
process.env.DIRECT_URL = before.DIRECT_URL
process.env.DATABASE_URL = before.DATABASE_URL
}
// De filter is met de echte userId aangeroepen (cross-user lekt niet)
expect(mockProductAccessFilter).toHaveBeenCalledWith('user-A')
expect(mockPrisma.product.findMany).toHaveBeenCalledWith({
where: { archived: false, user_id: 'user-A' },
select: { id: true },
})
})
})
// Solo-route filter (entity='question' uitgesloten) is een 1-regel-fix in
// app/api/realtime/solo/route.ts. Visueel reviewbaar in de diff; full-stream-
// regressie wordt handmatig gedekt in ST-1108-acceptatie.

View file

@ -103,7 +103,7 @@ describe('POST /api/auth/pair/claim', () => {
expect(mockClearPairCookie).toHaveBeenCalledTimes(1)
})
it('demo-user: claim geblokkeerd met 403 (ST-1110.4)', async () => {
it('demo-user: isDemo doorgezet als vangnet', async () => {
mockReadPairCookie.mockResolvedValue(COOKIE_TOKEN)
mockPrisma.loginPairing.updateMany.mockResolvedValue({ count: 1 })
mockPrisma.loginPairing.findUnique.mockResolvedValue({
@ -112,10 +112,8 @@ describe('POST /api/auth/pair/claim', () => {
})
const res = await POST(makePost({ pairingId: PAIRING_ID }))
expect(res.status).toBe(403)
const body = await res.json()
expect(body.error).toMatch(/demo-modus/i)
expect(mockClearPairCookie).toHaveBeenCalledTimes(1)
expect(res.status).toBe(200)
expect(mockSession.isDemo).toBe(true)
})
it('401 zonder s4m_pair-cookie', async () => {

View file

@ -1,12 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { cookieJar, mockGetIronSession } = vi.hoisted(() => ({
const { cookieJar } = vi.hoisted(() => ({
cookieJar: { set: vi.fn(), get: vi.fn(), delete: vi.fn() },
mockGetIronSession: vi.fn().mockResolvedValue({ isDemo: false }),
}))
vi.mock('iron-session', () => ({
getIronSession: mockGetIronSession,
}))
vi.mock('@/lib/prisma', () => ({

View file

@ -1,75 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('server-only', () => ({}))
const { mockSendPushToUser } = vi.hoisted(() => ({
mockSendPushToUser: vi.fn(),
}))
vi.mock('@/lib/push-server', () => ({
sendPushToUser: mockSendPushToUser,
enabled: true,
}))
vi.hoisted(() => {
process.env.INTERNAL_PUSH_SECRET = 'a-valid-secret-that-is-at-least-32-chars'
})
import { POST } from '@/app/api/internal/push/send/route'
const VALID_BODY = {
userId: 'user-1',
payload: { title: 'Hello', body: 'World', url: '/dashboard' },
}
const SECRET = 'a-valid-secret-that-is-at-least-32-chars'
function makeRequest(body: unknown, bearer?: string) {
return new Request('http://localhost/api/internal/push/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(bearer !== undefined ? { Authorization: bearer } : {}),
},
body: JSON.stringify(body),
})
}
beforeEach(() => {
vi.clearAllMocks()
mockSendPushToUser.mockResolvedValue(undefined)
})
describe('POST /api/internal/push/send', () => {
it('returns 401 without authorization header', async () => {
const res = await POST(makeRequest(VALID_BODY))
expect(res.status).toBe(401)
expect(mockSendPushToUser).not.toHaveBeenCalled()
})
it('returns 401 with wrong bearer secret', async () => {
const res = await POST(makeRequest(VALID_BODY, 'Bearer wrong-secret'))
expect(res.status).toBe(401)
})
it('returns 422 with invalid body', async () => {
const res = await POST(makeRequest({ userId: '', payload: {} }, `Bearer ${SECRET}`))
expect(res.status).toBe(422)
expect(mockSendPushToUser).not.toHaveBeenCalled()
})
it('returns 204 and calls sendPushToUser on success', async () => {
const res = await POST(makeRequest(VALID_BODY, `Bearer ${SECRET}`))
expect(res.status).toBe(204)
expect(mockSendPushToUser).toHaveBeenCalledWith('user-1', VALID_BODY.payload)
})
it('returns 400 for invalid JSON', async () => {
const req = new Request('http://localhost/api/internal/push/send', {
method: 'POST',
headers: { Authorization: `Bearer ${SECRET}`, 'Content-Type': 'application/json' },
body: 'not-json',
})
const res = await POST(req)
expect(res.status).toBe(400)
})
})

View file

@ -0,0 +1,111 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/lib/prisma', () => ({
prisma: {
story: {
findFirst: vi.fn(),
},
task: {
update: vi.fn(),
},
$transaction: vi.fn(),
},
}))
vi.mock('@/lib/api-auth', () => ({
authenticateApiRequest: vi.fn(),
}))
import { prisma } from '@/lib/prisma'
import { authenticateApiRequest } from '@/lib/api-auth'
import { PATCH as patchReorder } from '@/app/api/stories/[id]/tasks/reorder/route'
const mockPrisma = prisma as unknown as {
story: { findFirst: ReturnType<typeof vi.fn> }
task: { update: ReturnType<typeof vi.fn> }
$transaction: ReturnType<typeof vi.fn>
}
const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn>
function makeStory(taskIds: string[]) {
return {
id: 'story-1',
product_id: 'prod-1',
tasks: taskIds.map(id => ({ id })),
}
}
function makeRequest(body: unknown, storyId = 'story-1'): [Request, { params: Promise<{ id: string }> }] {
return [
new Request(`http://localhost/api/stories/${storyId}/tasks/reorder`, {
method: 'PATCH',
headers: { Authorization: 'Bearer test-token', 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
{ params: Promise.resolve({ id: storyId }) },
]
}
describe('PATCH /api/stories/:id/tasks/reorder', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAuth.mockResolvedValue({ userId: 'user-1', isDemo: false })
mockPrisma.$transaction.mockResolvedValue([])
mockPrisma.task.update.mockResolvedValue({ id: 'task-1', sort_order: 1 })
})
// TC-RO-06 — body validation fires before story lookup
it('returns 422 when task_ids is an empty array', async () => {
const res = await patchReorder(...makeRequest({ task_ids: [] }))
expect(res.status).toBe(422)
expect(mockPrisma.story.findFirst).not.toHaveBeenCalled()
})
// TC-RO-07
it('returns 422 when task_ids is not an array', async () => {
const res = await patchReorder(...makeRequest({ task_ids: 'task-1' }))
expect(res.status).toBe(422)
expect(mockPrisma.story.findFirst).not.toHaveBeenCalled()
})
it('returns 422 when task_ids is missing entirely', async () => {
const res = await patchReorder(...makeRequest({}))
expect(res.status).toBe(422)
})
// TC-RO-08
it('returns 422 when task_ids contains an ID not belonging to the story', async () => {
mockPrisma.story.findFirst.mockResolvedValue(makeStory(['task-1', 'task-2']))
const res = await patchReorder(...makeRequest({ task_ids: ['task-1', 'task-from-other-story'] }))
const data = await res.json()
expect(res.status).toBe(422)
expect(data.error).toContain('task-from-other-story')
})
// TC-RO-09
it('reorders tasks and returns 200 with success: true', async () => {
mockPrisma.story.findFirst.mockResolvedValue(makeStory(['task-1', 'task-2', 'task-3']))
const res = await patchReorder(...makeRequest({ task_ids: ['task-3', 'task-1', 'task-2'] }))
const data = await res.json()
expect(res.status).toBe(200)
expect(data).toEqual({ success: true })
expect(mockPrisma.$transaction).toHaveBeenCalled()
})
it('updates each task with its new sort_order index', async () => {
mockPrisma.story.findFirst.mockResolvedValue(makeStory(['task-1', 'task-2']))
await patchReorder(...makeRequest({ task_ids: ['task-2', 'task-1'] }))
expect(mockPrisma.task.update).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 'task-2' }, data: { sort_order: 1 } })
)
expect(mockPrisma.task.update).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 'task-1' }, data: { sort_order: 2 } })
)
})
})

View file

@ -8,32 +8,13 @@ vi.mock('@/lib/prisma', () => ({
},
sprint: {
findFirst: vi.fn(),
findUniqueOrThrow: vi.fn(),
update: vi.fn(),
},
story: {
findFirst: vi.fn(),
findUniqueOrThrow: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
},
task: {
findFirst: vi.fn(),
update: vi.fn(),
findMany: vi.fn(),
},
pbi: {
findUniqueOrThrow: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
},
claudeJob: {
findFirst: vi.fn(),
updateMany: vi.fn(),
},
sprintRun: {
findUnique: vi.fn(),
update: vi.fn(),
},
storyLog: {
create: vi.fn(),
@ -54,40 +35,16 @@ import { authenticateApiRequest } from '@/lib/api-auth'
import { GET as getProducts } from '@/app/api/products/route'
import { GET as getNextStory } from '@/app/api/products/[id]/next-story/route'
import { GET as getSprintTasks } from '@/app/api/sprints/[id]/tasks/route'
import { PATCH as patchReorder } from '@/app/api/stories/[id]/tasks/reorder/route'
import { POST as postStoryLog } from '@/app/api/stories/[id]/log/route'
import { PATCH as patchTask } from '@/app/api/tasks/[id]/route'
import { POST as postTodo } from '@/app/api/todos/route'
const mockPrisma = prisma as unknown as {
product: { findMany: ReturnType<typeof vi.fn>; findFirst: ReturnType<typeof vi.fn> }
sprint: {
findFirst: ReturnType<typeof vi.fn>
findUniqueOrThrow: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
story: {
findFirst: ReturnType<typeof vi.fn>
findUniqueOrThrow: ReturnType<typeof vi.fn>
findMany: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
task: {
findFirst: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
findMany: ReturnType<typeof vi.fn>
}
pbi: {
findUniqueOrThrow: ReturnType<typeof vi.fn>
findMany: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
claudeJob: {
findFirst: ReturnType<typeof vi.fn>
updateMany: ReturnType<typeof vi.fn>
}
sprintRun: {
findUnique: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
sprint: { findFirst: ReturnType<typeof vi.fn> }
story: { findFirst: ReturnType<typeof vi.fn> }
task: { findFirst: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
storyLog: { create: ReturnType<typeof vi.fn> }
todo: { create: ReturnType<typeof vi.fn> }
$transaction: ReturnType<typeof vi.fn>
@ -128,11 +85,6 @@ function routeCtx(id: string) {
beforeEach(() => {
vi.clearAllMocks()
// Pass-through transaction so callers can `prisma.$transaction(async tx => ...)` in routes.
mockPrisma.$transaction.mockImplementation(async (run: unknown) => {
if (typeof run === 'function') return (run as (tx: typeof prisma) => Promise<unknown>)(prisma)
return run
})
})
// ─── GET /api/products ────────────────────────────────────────────────────────
@ -196,7 +148,7 @@ describe('GET /api/products/:id/next-story', () => {
expect.objectContaining({
where: expect.objectContaining({
product_id: 'prod-other',
status: 'OPEN',
status: 'ACTIVE',
product: expect.objectContaining({
OR: expect.arrayContaining([{ user_id: 'user-1' }]),
}),
@ -275,6 +227,56 @@ describe('GET /api/sprints/:id/tasks', () => {
})
})
// ─── PATCH /api/stories/:id/tasks/reorder ────────────────────────────────────
describe('PATCH /api/stories/:id/tasks/reorder', () => {
const VALID_BODY = { task_ids: ['task-x'] }
// TC-RO-01
it('returns 401 when no valid token provided', async () => {
mockAuth.mockResolvedValue(UNAUTHORIZED)
const res = await patchReorder(
makePatch('http://localhost/api/stories/story-1/tasks/reorder', VALID_BODY),
routeCtx('story-1')
)
expect(res.status).toBe(401)
})
// TC-RO-03
it('returns 403 for demo users', async () => {
mockAuth.mockResolvedValue(DEMO_AUTH)
const res = await patchReorder(
makePatch('http://localhost/api/stories/story-1/tasks/reorder', VALID_BODY),
routeCtx('story-1')
)
expect(res.status).toBe(403)
const data = await res.json()
expect(data.error).toBe('Niet beschikbaar in demo-modus')
})
// TC-RO-04 / TC-RO-05
it('returns 404 when story is not accessible to the authenticated user', async () => {
mockAuth.mockResolvedValue(USER_2_AUTH)
mockPrisma.story.findFirst.mockResolvedValue(null)
const res = await patchReorder(
makePatch('http://localhost/api/stories/story-1/tasks/reorder', VALID_BODY),
routeCtx('story-1')
)
expect(res.status).toBe(404)
expect(mockPrisma.story.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
id: 'story-1',
product: expect.objectContaining({
OR: expect.arrayContaining([{ user_id: 'user-2' }]),
}),
}),
})
)
})
})
// ─── POST /api/stories/:id/log ────────────────────────────────────────────────
describe('POST /api/stories/:id/log', () => {
@ -384,22 +386,7 @@ describe('PATCH /api/tasks/:id', () => {
id: 'task-1',
story: { product: { user_id: 'user-1' } },
})
mockPrisma.task.update.mockResolvedValue({
id: 'task-1',
title: 'Task',
status: 'DONE',
story_id: 'story-1',
implementation_plan: null,
})
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
id: 'story-1',
status: 'DONE',
pbi_id: 'pbi-1',
sprint_id: null,
})
mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }])
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'DONE' })
mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'DONE' })
const res = await patchTask(
makePatch('http://localhost/api/tasks/task-1', { status: 'done' }),
@ -408,3 +395,46 @@ describe('PATCH /api/tasks/:id', () => {
expect(res.status).toBe(200)
})
})
// ─── POST /api/todos ──────────────────────────────────────────────────────────
describe('POST /api/todos', () => {
// product_id is required by the Zod schema (z.string().min(1))
const VALID_BODY = { title: 'Test todo', product_id: 'prod-1' }
// TC-TD-01
it('returns 401 when no valid token provided', async () => {
mockAuth.mockResolvedValue(UNAUTHORIZED)
const res = await postTodo(makePost('http://localhost/api/todos', VALID_BODY))
expect(res.status).toBe(401)
})
// TC-TD-03
it('returns 403 for demo users', async () => {
mockAuth.mockResolvedValue(DEMO_AUTH)
const res = await postTodo(makePost('http://localhost/api/todos', VALID_BODY))
expect(res.status).toBe(403)
const data = await res.json()
expect(data.error).toBe('Niet beschikbaar in demo-modus')
})
// TC-TD-08
it('returns 404 when product_id belongs to another user', async () => {
mockAuth.mockResolvedValue(USER_2_AUTH)
mockPrisma.product.findFirst.mockResolvedValue(null)
const res = await postTodo(
makePost('http://localhost/api/todos', { title: 'Todo', product_id: 'prod-owned-by-user-1' })
)
expect(res.status).toBe(404)
// Verify it queries by user_id, not productAccessFilter
expect(mockPrisma.product.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
id: 'prod-owned-by-user-1',
user_id: 'user-2',
}),
})
)
})
})

View file

@ -1,121 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/lib/prisma', () => ({
prisma: {
product: { findFirst: vi.fn() },
story: { groupBy: vi.fn() },
},
}))
vi.mock('@/lib/api-auth', () => ({
authenticateApiRequest: vi.fn(),
}))
vi.mock('@/lib/product-access', () => ({
productAccessFilter: vi.fn().mockReturnValue({}),
}))
import { prisma } from '@/lib/prisma'
import { authenticateApiRequest } from '@/lib/api-auth'
import { GET } from '@/app/api/products/[id]/sprint-membership-summary/route'
const mockPrisma = prisma as unknown as {
product: { findFirst: ReturnType<typeof vi.fn> }
story: { groupBy: ReturnType<typeof vi.fn> }
}
const mockAuth = authenticateApiRequest as unknown as ReturnType<typeof vi.fn>
function makeRequest(url: string) {
return new Request(url)
}
describe('GET /api/products/[id]/sprint-membership-summary', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPrisma.product.findFirst.mockReset()
mockPrisma.story.groupBy.mockReset()
mockAuth.mockReset().mockResolvedValue({ userId: 'user-1' })
})
it('returns counts per PBI for happy path', async () => {
mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' })
mockPrisma.story.groupBy
.mockResolvedValueOnce([
{ pbi_id: 'pbiA', _count: { _all: 5 } },
{ pbi_id: 'pbiB', _count: { _all: 3 } },
])
.mockResolvedValueOnce([{ pbi_id: 'pbiA', _count: { _all: 2 } }])
const req = makeRequest(
'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA,pbiB',
)
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
expect(res.status).toBe(200)
const body = await res.json()
expect(body).toEqual({
pbiA: { total: 5, inSprint: 2 },
pbiB: { total: 3, inSprint: 0 },
})
})
it('rejects when pbiIds is missing', async () => {
const req = makeRequest(
'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1',
)
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
expect(res.status).toBe(400)
})
it('rejects when pbiIds is empty', async () => {
const req = makeRequest(
'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=',
)
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
expect(res.status).toBe(400)
})
it('rejects when sprintId is missing', async () => {
const req = makeRequest(
'http://localhost/api/products/p1/sprint-membership-summary?pbiIds=pbiA',
)
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
expect(res.status).toBe(400)
})
it('returns 404 when product is not accessible', async () => {
mockPrisma.product.findFirst.mockResolvedValue(null)
const req = makeRequest(
'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA',
)
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
expect(res.status).toBe(404)
})
it('returns auth error when authenticate fails', async () => {
mockAuth.mockResolvedValue({ error: 'Niet ingelogd', status: 401 })
const req = makeRequest(
'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA',
)
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
expect(res.status).toBe(401)
})
it('returns zero counts for PBIs without stories', async () => {
mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' })
mockPrisma.story.groupBy
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
const req = makeRequest(
'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA,pbiB',
)
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
const body = await res.json()
expect(body).toEqual({
pbiA: { total: 0, inSprint: 0 },
pbiB: { total: 0, inSprint: 0 },
})
})
})

View file

@ -25,7 +25,7 @@ const mockPrisma = prisma as unknown as {
}
const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn>
const SPRINT = { id: 'sprint-1', product_id: 'prod-1', status: 'OPEN' }
const SPRINT = { id: 'sprint-1', product_id: 'prod-1', status: 'ACTIVE' }
function makeTask(n: number) {
return {

View file

@ -129,7 +129,7 @@ describe('POST /api/stories/:id/log', () => {
const res = await postStoryLog(
...makeRequest({ type: 'TEST_RESULT', content: 'Test gefaald.', status: 'FAILED' })
)
await res.json()
const data = await res.json()
expect(res.status).toBe(201)
expect(mockPrisma.storyLog.create).toHaveBeenCalledWith(

View file

@ -5,31 +5,7 @@ vi.mock('@/lib/prisma', () => ({
task: {
findFirst: vi.fn(),
update: vi.fn(),
findMany: vi.fn(),
},
story: {
findUniqueOrThrow: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
},
pbi: {
findUniqueOrThrow: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
},
sprint: {
findUniqueOrThrow: vi.fn(),
update: vi.fn(),
},
claudeJob: {
findFirst: vi.fn(),
updateMany: vi.fn(),
},
sprintRun: {
findUnique: vi.fn(),
update: vi.fn(),
},
$transaction: vi.fn(),
},
}))
@ -42,34 +18,7 @@ import { authenticateApiRequest } from '@/lib/api-auth'
import { PATCH as patchTask } from '@/app/api/tasks/[id]/route'
const mockPrisma = prisma as unknown as {
task: {
findFirst: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
findMany: ReturnType<typeof vi.fn>
}
story: {
findUniqueOrThrow: ReturnType<typeof vi.fn>
findMany: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
pbi: {
findUniqueOrThrow: ReturnType<typeof vi.fn>
findMany: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
sprint: {
findUniqueOrThrow: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
claudeJob: {
findFirst: ReturnType<typeof vi.fn>
updateMany: ReturnType<typeof vi.fn>
}
sprintRun: {
findUnique: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
$transaction: ReturnType<typeof vi.fn>
task: { findFirst: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
}
const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn>
@ -106,22 +55,6 @@ describe('PATCH /api/tasks/:id', () => {
id: 'task-1',
status: 'DONE',
implementation_plan: null,
title: 'Task',
story_id: 'story-1',
})
// Default sibling state: only this task, already DONE → no story-promotion
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
id: 'story-1',
status: 'DONE',
pbi_id: 'pbi-1',
sprint_id: null,
})
mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }])
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'DONE' })
// Pass-through for $transaction so tests behave as if Prisma ran the run-fn directly.
mockPrisma.$transaction.mockImplementation(async (run: (tx: typeof prisma) => Promise<unknown>) => {
return run(prisma)
})
})
@ -178,28 +111,17 @@ describe('PATCH /api/tasks/:id', () => {
// TC-T-10
it('updates both status and implementation_plan and returns 200', async () => {
const plan = 'Full plan here.'
// First update writes the implementation_plan; second is the helper's status write.
mockPrisma.task.update
.mockResolvedValueOnce({ id: 'task-1', status: 'TO_DO', implementation_plan: plan })
.mockResolvedValueOnce({
id: 'task-1',
title: 'Task',
status: 'DONE',
story_id: 'story-1',
implementation_plan: plan,
})
mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'DONE', implementation_plan: plan })
const res = await patchTask(...makeRequest({ status: 'done', implementation_plan: plan }))
const data = await res.json()
expect(res.status).toBe(200)
expect(data).toMatchObject({ status: 'done', implementation_plan: plan })
// implementation_plan written via direct update; status written via helper update.
expect(mockPrisma.task.update).toHaveBeenCalledWith(
expect.objectContaining({ data: { implementation_plan: plan } }),
)
expect(mockPrisma.task.update).toHaveBeenCalledWith(
expect.objectContaining({ data: { status: 'DONE' } }),
expect.objectContaining({
data: { status: 'DONE', implementation_plan: plan },
})
)
})
@ -224,61 +146,6 @@ describe('PATCH /api/tasks/:id', () => {
expect(reviewRes.status).toBe(422)
})
it('promotes story to DONE when last sibling task transitions to DONE', async () => {
mockPrisma.task.update.mockResolvedValue({
id: 'task-1',
status: 'DONE',
implementation_plan: null,
title: 'Task',
story_id: 'story-1',
})
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }, { status: 'DONE' }])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
id: 'story-1',
status: 'IN_SPRINT',
pbi_id: 'pbi-1',
sprint_id: null,
})
mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }])
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
const res = await patchTask(...makeRequest({ status: 'done' }))
expect(res.status).toBe(200)
expect(mockPrisma.story.update).toHaveBeenCalledWith({
where: { id: 'story-1' },
data: { status: 'DONE' },
})
})
// TC-T-12
it('updates verify_only alone and returns 200 with verify_only in response', async () => {
mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'TO_DO', implementation_plan: null, verify_only: true })
const res = await patchTask(...makeRequest({ verify_only: true }))
const data = await res.json()
expect(res.status).toBe(200)
expect(data.verify_only).toBe(true)
expect(mockPrisma.task.update).toHaveBeenCalledWith(
expect.objectContaining({ data: { verify_only: true } }),
)
})
it('combines verify_only and implementation_plan into one update call', async () => {
const plan = 'Verify only: check test results.'
mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'TO_DO', implementation_plan: plan, verify_only: true })
const res = await patchTask(...makeRequest({ implementation_plan: plan, verify_only: true }))
const data = await res.json()
expect(res.status).toBe(200)
expect(data).toMatchObject({ implementation_plan: plan, verify_only: true })
expect(mockPrisma.task.update).toHaveBeenCalledTimes(1)
expect(mockPrisma.task.update).toHaveBeenCalledWith(
expect.objectContaining({ data: { implementation_plan: plan, verify_only: true } }),
)
})
it('returns 400 for malformed JSON', async () => {
const req = new Request('http://localhost/api/tasks/task-1', {
method: 'PATCH',

109
__tests__/api/todos.test.ts Normal file
View file

@ -0,0 +1,109 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/lib/prisma', () => ({
prisma: {
product: {
findFirst: vi.fn(),
},
todo: {
create: vi.fn(),
},
},
}))
vi.mock('@/lib/api-auth', () => ({
authenticateApiRequest: vi.fn(),
}))
import { prisma } from '@/lib/prisma'
import { authenticateApiRequest } from '@/lib/api-auth'
import { POST as postTodo } from '@/app/api/todos/route'
const mockPrisma = prisma as unknown as {
product: { findFirst: ReturnType<typeof vi.fn> }
todo: { create: ReturnType<typeof vi.fn> }
}
const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn>
const PRODUCT = { id: 'prod-1', name: 'DevPlanner', archived: false, user_id: 'user-1' }
const TODO_RESULT = { id: 'todo-1', title: 'Test todo', created_at: new Date('2026-04-30T10:00:00Z') }
function makeRequest(body: unknown): Request {
return new Request('http://localhost/api/todos', {
method: 'POST',
headers: { Authorization: 'Bearer test-token', 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
describe('POST /api/todos', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAuth.mockResolvedValue({ userId: 'user-1', isDemo: false })
mockPrisma.product.findFirst.mockResolvedValue(PRODUCT)
mockPrisma.todo.create.mockResolvedValue(TODO_RESULT)
})
// TC-TD-04
it('returns 422 when title is missing', async () => {
const res = await postTodo(makeRequest({ product_id: 'prod-1' }))
expect(res.status).toBe(422)
})
// TC-TD-05
it('returns 422 when title is empty string', async () => {
const res = await postTodo(makeRequest({ title: '', product_id: 'prod-1' }))
expect(res.status).toBe(422)
})
it('returns 422 when product_id is missing', async () => {
// product_id is required by the Zod schema (z.string().min(1))
const res = await postTodo(makeRequest({ title: 'My todo' }))
expect(res.status).toBe(422)
})
it('returns 422 when product_id is empty string', async () => {
const res = await postTodo(makeRequest({ title: 'My todo', product_id: '' }))
expect(res.status).toBe(422)
})
// TC-TD-07
it('creates todo with valid product_id and returns 201', async () => {
const res = await postTodo(makeRequest({ title: 'Test todo', product_id: 'prod-1' }))
const data = await res.json()
expect(res.status).toBe(201)
expect(data).toMatchObject({ id: 'todo-1', title: 'Test todo' })
expect(data).toHaveProperty('created_at')
expect(mockPrisma.todo.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
user_id: 'user-1',
product_id: 'prod-1',
title: 'Test todo',
}),
})
)
})
it('queries product by user_id (not productAccessFilter) to enforce ownership', async () => {
await postTodo(makeRequest({ title: 'Test todo', product_id: 'prod-1' }))
expect(mockPrisma.product.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
id: 'prod-1',
user_id: 'user-1',
archived: false,
}),
})
)
})
it('returns 404 when product does not exist or is archived', async () => {
mockPrisma.product.findFirst.mockResolvedValue(null)
const res = await postTodo(makeRequest({ title: 'My todo', product_id: 'nonexistent' }))
expect(res.status).toBe(404)
})
})

View file

@ -1,106 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockGetSession, mockFindFirstJob, mockFindManyPrice } = vi.hoisted(() => ({
mockGetSession: vi.fn(),
mockFindFirstJob: vi.fn(),
mockFindManyPrice: vi.fn(),
}))
vi.mock('@/lib/auth', () => ({ getSession: mockGetSession }))
vi.mock('@/lib/prisma', () => ({
prisma: {
claudeJob: { findFirst: mockFindFirstJob },
modelPrice: { findMany: mockFindManyPrice },
},
}))
import { GET } from '@/app/api/jobs/[id]/route'
function makeParams(id = 'job-1'): { params: Promise<{ id: string }> } {
return { params: Promise.resolve({ id }) }
}
function makeRequest(id = 'job-1'): Request {
return new Request(`http://localhost/api/jobs/${id}`)
}
const RAW_JOB = {
id: 'job-1',
kind: 'TASK_IMPLEMENTATION' as const,
status: 'DONE' as const,
model_id: 'claude-sonnet-4-6',
input_tokens: 100,
output_tokens: 50,
cache_read_tokens: 0,
cache_write_tokens: 0,
branch: 'feat/test',
pr_url: null,
error: null,
summary: 'Done',
verify_result: 'ALIGNED' as const,
started_at: new Date('2026-01-01T10:00:00Z'),
finished_at: new Date('2026-01-01T10:05:00Z'),
created_at: new Date('2026-01-01T09:59:00Z'),
sprint_run_id: null,
task: {
code: 'T-42',
title: 'Some task',
description: null,
implementation_plan: 'Do the thing',
story: { code: 'S-10', pbi: { code: 'PBI-5' } },
},
idea: null,
product: { name: 'Scrum4Me', code: 'SCR' },
sprint_run: null,
}
describe('GET /api/jobs/:id', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetSession.mockResolvedValue({ userId: 'user-1' })
mockFindFirstJob.mockResolvedValue(RAW_JOB)
mockFindManyPrice.mockResolvedValue([])
})
it('returns 401 when not logged in', async () => {
mockGetSession.mockResolvedValue({ userId: undefined })
const res = await GET(makeRequest() as never, makeParams())
expect(res.status).toBe(401)
const body = await res.json()
expect(body.error).toBeTruthy()
})
it('returns 404 when job not found', async () => {
mockFindFirstJob.mockResolvedValue(null)
const res = await GET(makeRequest() as never, makeParams())
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error).toBeTruthy()
})
it('queries with user_id filter to prevent cross-user access', async () => {
await GET(makeRequest() as never, makeParams())
expect(mockFindFirstJob).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'job-1', user_id: 'user-1' },
})
)
})
it('returns 200 with mapped job shape including breadcrumb codes', async () => {
const res = await GET(makeRequest() as never, makeParams())
expect(res.status).toBe(200)
const body = await res.json()
expect(body).toMatchObject({
id: 'job-1',
kind: 'TASK_IMPLEMENTATION',
status: 'DONE',
taskCode: 'T-42',
taskTitle: 'Some task',
productCode: 'SCR',
storyCode: 'S-10',
pbiCode: 'PBI-5',
branch: 'feat/test',
})
})
})

View file

@ -1,38 +0,0 @@
// Lichte regressie-tests voor de mobile backlog-page. Server-component render
// vereist te veel mocking; we asserten op statische source-eigenschappen die
// kritisch zijn voor de mobile-shell (cookie-key gescheiden, /m/-paden).
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const PAGE = resolve(process.cwd(), 'app/(mobile)/m/products/[id]/page.tsx')
const src = readFileSync(PAGE, 'utf-8')
describe('mobile backlog page (ST-1137)', () => {
it('gebruikt gescheiden cookie-key (backlog-{id}-mobile)', () => {
// Beslissing C: tab-mode-gebruikers vervuilen desktop-split niet.
expect(src).toMatch(/cookieKey=\{`backlog-\$\{id\}-mobile`\}/)
})
it('closePath en TaskDialog redirect blijven onder /m/products/', () => {
expect(src).toContain('const closePath = `/m/products/${id}`')
})
it('hergebruikt BacklogHydrationWrapper + BacklogSplitPane (geen content-componenten dupliceren)', () => {
expect(src).toContain('BacklogHydrationWrapper')
expect(src).toContain('BacklogSplitPane')
expect(src).toContain('PbiList')
expect(src).toContain('StoryPanel')
expect(src).toContain('TaskPanel')
})
it('auth via requireSession() (gedeelde guard)', () => {
expect(src).toContain("from '@/lib/auth-guard'")
expect(src).toContain('requireSession()')
})
it('rendert TaskDialog op ?newTask en EditTaskLoader op ?editTask', () => {
expect(src).toContain('{newTask &&')
expect(src).toContain('{editTask && !newTask &&')
})
})

View file

@ -1,35 +0,0 @@
// ST-1138: regressie-vangnet voor mobile solo-page (server component).
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const PAGE = resolve(process.cwd(), 'app/(mobile)/m/products/[id]/solo/page.tsx')
const TASK_DETAIL = resolve(process.cwd(), 'components/solo/task-detail-dialog.tsx')
describe('mobile solo page (ST-1138)', () => {
const src = readFileSync(PAGE, 'utf-8')
it('hergebruikt SoloBoard zonder content-aanpassingen', () => {
expect(src).toContain('SoloBoard')
expect(src).toContain("from '@/components/solo/solo-board'")
})
it('auth via gedeelde requireSession()', () => {
expect(src).toContain("from '@/lib/auth-guard'")
expect(src).toContain('requireSession()')
})
it('geeft NoActiveSprint terug als geen actieve sprint (zelfde gedrag als desktop)', () => {
expect(src).toContain('NoActiveSprint')
})
})
describe('TaskDetailDialog erft mobile-fullscreen (ST-1138 T-332 verify-only)', () => {
// Beslissing A: TaskDetailDialog gebruikt entityDialogContentClasses; mobile-classes
// komen automatisch door uit T-317. Dit test bewijst de wiring blijft staan.
const src = readFileSync(TASK_DETAIL, 'utf-8')
it('rendert DialogContent met entityDialogContentClasses (geen eigen className-override)', () => {
expect(src).toContain('className={entityDialogContentClasses}')
})
})

View file

@ -1,97 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
vi.mock('@/actions/user-settings', () => ({
updateUserSettingsAction: vi.fn().mockResolvedValue({ success: true, settings: {} }),
}))
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
import { BacklogSplitPane } from '@/components/backlog/backlog-split-pane'
function setSelection(pbiId: string | null, storyId: string | null) {
useProductWorkspaceStore.setState((s) => {
s.context.activePbiId = pbiId
s.context.activeStoryId = storyId
})
}
const PANES = [
<div key="a">PBI pane</div>,
<div key="b">Stories pane</div>,
<div key="c">Tasks pane</div>,
]
function renderPane() {
return render(
<BacklogSplitPane
panes={PANES}
defaultSplit={[33, 33, 34]}
cookieKey="test-backlog"
tabLabels={["PBI's", 'Stories', 'Taken']}
/>
)
}
beforeEach(() => {
setSelection(null, null)
// Force mobile viewport
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 })
window.dispatchEvent(new Event('resize'))
})
describe('BacklogSplitPane auto-switch', () => {
it('starts on tab 0 with no selection', () => {
renderPane()
expect(screen.getByText('PBI pane')).toBeTruthy()
expect(screen.queryByText('Stories pane')).toBeNull()
})
it('auto-switches to tab 1 when PBI is selected', () => {
const { rerender } = renderPane()
setSelection('pbi-1', null)
rerender(
<BacklogSplitPane
panes={PANES}
defaultSplit={[33, 33, 34]}
cookieKey="test-backlog"
tabLabels={["PBI's", 'Stories', 'Taken']}
/>
)
expect(screen.getByText('Stories pane')).toBeTruthy()
expect(screen.queryByText('PBI pane')).toBeNull()
})
it('auto-switches to tab 2 when story is selected', () => {
const { rerender } = renderPane()
setSelection('pbi-1', 'story-1')
rerender(
<BacklogSplitPane
panes={PANES}
defaultSplit={[33, 33, 34]}
cookieKey="test-backlog"
tabLabels={["PBI's", 'Stories', 'Taken']}
/>
)
expect(screen.getByText('Tasks pane')).toBeTruthy()
expect(screen.queryByText('PBI pane')).toBeNull()
})
it('switches to tab 1 on cascade-reset (story cleared when new PBI selected)', () => {
// Start with story selected (tab 2)
setSelection('pbi-1', 'story-1')
const { rerender } = renderPane()
// Cascade-reset: new PBI → story clears
setSelection('pbi-2', null)
rerender(
<BacklogSplitPane
panes={PANES}
defaultSplit={[33, 33, 34]}
cookieKey="test-backlog"
tabLabels={["PBI's", 'Stories', 'Taken']}
/>
)
expect(screen.getByText('Stories pane')).toBeTruthy()
})
})

View file

@ -1,156 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
import type {
BacklogStory,
BacklogTask,
} from '@/stores/product-workspace/types'
// Mock next/navigation
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mockPush }) }))
// localStorage mock for StoryPanel sort mode persistence
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: (k: string) => store[k] ?? null,
setItem: (k: string, v: string) => { store[k] = v },
removeItem: (k: string) => { delete store[k] },
clear: () => { store = {} },
}
})()
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
// Mock server actions
vi.mock('@/actions/stories', () => ({
reorderPbisAction: vi.fn().mockResolvedValue({ success: true }),
updatePbiPriorityAction: vi.fn().mockResolvedValue({ success: true }),
}))
vi.mock('@/actions/pbis', () => ({ deletePbiAction: vi.fn().mockResolvedValue({ success: true }) }))
vi.mock('@/actions/user-settings', () => ({
updateUserSettingsAction: vi.fn().mockResolvedValue({ success: true, settings: {} }),
}))
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
// Mock dnd-kit (still needed for PBI panel which supports drag-and-drop)
vi.mock('@dnd-kit/core', () => ({
DndContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
PointerSensor: class {},
KeyboardSensor: class {},
useSensor: vi.fn(),
useSensors: vi.fn(() => []),
closestCenter: vi.fn(),
DragOverlay: () => null,
}))
vi.mock('@dnd-kit/sortable', () => ({
SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
useSortable: () => ({
attributes: {}, listeners: {}, setNodeRef: vi.fn(),
transform: null, transition: undefined, isDragging: false,
}),
verticalListSortingStrategy: {},
rectSortingStrategy: {},
sortableKeyboardCoordinates: {},
arrayMove: (arr: unknown[]) => arr,
}))
vi.mock('@dnd-kit/utilities', () => ({ CSS: { Transform: { toString: () => '' } } }))
import { StoryPanel } from '@/components/backlog/story-panel'
import { TaskPanel } from '@/components/backlog/task-panel'
const PRODUCT_ID = 'prod-1'
const PBI_ID = 'pbi-1'
const ALT_PBI_ID = 'pbi-2'
const STORY_ID = 'story-1'
const STORIES: BacklogStory[] = [
{ id: STORY_ID, code: 'ST-1', title: 'Eerste story', description: null, acceptance_criteria: null, priority: 2, sort_order: 1, status: 'OPEN', pbi_id: PBI_ID, sprint_id: null, created_at: new Date() },
]
const TASKS: BacklogTask[] = [
{ id: 'task-1', code: null, title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_ID, created_at: new Date() },
]
function resetStores() {
useProductWorkspaceStore.setState((s) => {
s.context.activeProduct = null
s.context.activePbiId = null
s.context.activeStoryId = null
s.context.activeTaskId = null
s.entities.pbisById = {}
s.entities.storiesById = Object.fromEntries(STORIES.map((st) => [st.id, st]))
s.entities.tasksById = Object.fromEntries(TASKS.map((t) => [t.id, t]))
s.relations.pbiIds = []
s.relations.storyIdsByPbi = { [PBI_ID]: STORIES.map((st) => st.id) }
s.relations.taskIdsByStory = { [STORY_ID]: TASKS.map((t) => t.id) }
})
}
function selectPbi(pbiId: string | null) {
useProductWorkspaceStore.setState((s) => {
s.context.activePbiId = pbiId
s.context.activeStoryId = null
s.context.activeTaskId = null
})
}
function selectStory(pbiId: string | null, storyId: string | null) {
useProductWorkspaceStore.setState((s) => {
s.context.activePbiId = pbiId
s.context.activeStoryId = storyId
})
}
describe('Backlog 3-pane integration', () => {
beforeEach(() => {
mockPush.mockClear()
resetStores()
})
it('StoryPanel shows empty state when no PBI selected', () => {
render(<StoryPanel productId={PRODUCT_ID} isDemo={false} />)
expect(screen.getByText('Selecteer een PBI om de stories te bekijken.')).toBeTruthy()
})
it('StoryPanel shows stories when PBI is selected', () => {
selectPbi(PBI_ID)
render(<StoryPanel productId={PRODUCT_ID} isDemo={false} />)
expect(screen.getByText('Eerste story')).toBeTruthy()
})
it('clicking a story dispatches setActiveStory to the workspace-store', () => {
selectPbi(PBI_ID)
render(<StoryPanel productId={PRODUCT_ID} isDemo={false} />)
fireEvent.click(screen.getByText('Eerste story'))
expect(useProductWorkspaceStore.getState().context.activeStoryId).toBe(STORY_ID)
})
it('cascade-reset: selecting different PBI clears activeStoryId', () => {
selectStory(PBI_ID, STORY_ID)
useProductWorkspaceStore.getState().setActivePbi(ALT_PBI_ID)
expect(useProductWorkspaceStore.getState().context.activeStoryId).toBeNull()
})
it('TaskPanel shows tasks after story is selected', () => {
selectStory(PBI_ID, STORY_ID)
render(<TaskPanel productId={PRODUCT_ID} isDemo={false} closePath={`/products/${PRODUCT_ID}`} />)
expect(screen.getByText('Eerste taak')).toBeTruthy()
})
it('TaskPanel shows empty state after cascade-reset', () => {
selectStory(PBI_ID, STORY_ID)
render(<TaskPanel productId={PRODUCT_ID} isDemo={false} closePath={`/products/${PRODUCT_ID}`} />)
useProductWorkspaceStore.getState().setActivePbi(ALT_PBI_ID)
render(<TaskPanel productId={PRODUCT_ID} isDemo={false} closePath={`/products/${PRODUCT_ID}`} />)
expect(screen.getAllByText('Selecteer een story om de taken te bekijken.').length).toBeGreaterThan(0)
})
it('selected story card has isSelected highlight class applied', () => {
selectStory(PBI_ID, STORY_ID)
const { container } = render(<StoryPanel productId={PRODUCT_ID} isDemo={false} />)
// bg-primary-container is applied when isSelected
const selected = container.querySelector('.bg-primary-container')
expect(selected).toBeTruthy()
})
})

View file

@ -1,57 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import type { ReactNode } from 'react'
const workflowMock: {
value: { pendingSprintDraft?: Record<string, unknown> } | undefined
} = { value: undefined }
vi.mock('@/stores/user-settings/store', () => ({
useUserSettingsStore: (
selector: (s: {
entities: {
settings: {
workflow: { pendingSprintDraft?: Record<string, unknown> } | undefined
}
}
}) => unknown,
) => selector({ entities: { settings: { workflow: workflowMock.value } } }),
}))
vi.mock('./new-sprint-metadata-dialog', () => ({
NewSprintMetadataDialog: () => null,
}))
vi.mock('@/components/shared/demo-tooltip', () => ({
DemoTooltip: ({ children }: { children: ReactNode }) => children,
}))
import { NewSprintTrigger } from '@/components/backlog/new-sprint-trigger'
beforeEach(() => {
workflowMock.value = undefined
})
describe('NewSprintTrigger', () => {
it('renders the button on an active product without a draft', () => {
render(<NewSprintTrigger productId="p1" isDemo={false} isActiveProduct={true} />)
expect(screen.getByText('Nieuwe sprint')).toBeInTheDocument()
})
it('renders nothing on a non-active product (G6)', () => {
const { container } = render(
<NewSprintTrigger productId="p1" isDemo={false} isActiveProduct={false} />,
)
expect(container).toBeEmptyDOMElement()
})
it('renders nothing when a sprint draft is pending', () => {
workflowMock.value = { pendingSprintDraft: { p1: { goal: 'X' } } }
const { container } = render(
<NewSprintTrigger productId="p1" isDemo={false} isActiveProduct={true} />,
)
expect(container).toBeEmptyDOMElement()
})
})

View file

@ -1,115 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
import type { BacklogTask } from '@/stores/product-workspace/types'
function resetWorkspace() {
useProductWorkspaceStore.setState((s) => {
s.context.activeProduct = null
s.context.activePbiId = null
s.context.activeStoryId = null
s.context.activeTaskId = null
s.entities.pbisById = {}
s.entities.storiesById = {}
s.entities.tasksById = {}
s.relations.pbiIds = []
s.relations.storyIdsByPbi = {}
s.relations.taskIdsByStory = {}
})
}
function setActiveStoryAndTasks(storyId: string | null, tasks: BacklogTask[] = []) {
useProductWorkspaceStore.setState((s) => {
s.context.activeStoryId = storyId
if (storyId) {
s.relations.taskIdsByStory[storyId] = tasks.map((t) => t.id)
for (const task of tasks) s.entities.tasksById[task.id] = task
}
})
}
// Mock next/navigation
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mockPush }) }))
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
import { TaskPanel } from '@/components/backlog/task-panel'
const PRODUCT_ID = 'prod-1'
const STORY_ID = 'story-1'
const CLOSE_PATH = `/products/${PRODUCT_ID}`
const TASKS = [
{ id: 'task-1', code: null, title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_ID, created_at: new Date() },
{ id: 'task-2', code: null, title: 'Tweede taak', description: null, priority: 3, status: 'IN_PROGRESS', sort_order: 2, story_id: STORY_ID, created_at: new Date() },
]
function renderPanel(isDemo = false) {
return render(<TaskPanel productId={PRODUCT_ID} isDemo={isDemo} closePath={CLOSE_PATH} />)
}
describe('TaskPanel', () => {
beforeEach(() => {
mockPush.mockClear()
resetWorkspace()
})
it('shows empty state when no story is selected', () => {
renderPanel()
expect(screen.getByText('Selecteer een story om de taken te bekijken.')).toBeTruthy()
})
it('shows empty state with action when story selected but no tasks', () => {
setActiveStoryAndTasks(STORY_ID, [])
renderPanel()
expect(screen.getByText('Nog geen taken voor deze story.')).toBeTruthy()
expect(screen.getAllByText('+ Nieuwe taak').length).toBeGreaterThanOrEqual(1)
})
it('renders task cards when tasks are present', () => {
setActiveStoryAndTasks(STORY_ID, TASKS)
renderPanel()
expect(screen.getByText('Eerste taak')).toBeTruthy()
expect(screen.getByText('Tweede taak')).toBeTruthy()
})
it('renders status badges on task cards', () => {
setActiveStoryAndTasks(STORY_ID, TASKS)
renderPanel()
expect(screen.getByText('To Do')).toBeTruthy()
expect(screen.getByText('Bezig')).toBeTruthy()
})
it('task cards are rendered inside a grid container', () => {
setActiveStoryAndTasks(STORY_ID, TASKS)
const { container } = renderPanel()
const grid = container.querySelector('.grid')
expect(grid).toBeTruthy()
})
it('clicking + button calls router.push with newTask params', () => {
setActiveStoryAndTasks(STORY_ID, [])
renderPanel()
const buttons = screen.getAllByText('+ Nieuwe taak')
fireEvent.click(buttons[0])
expect(mockPush).toHaveBeenCalledWith(`${CLOSE_PATH}?newTask=1&storyId=${STORY_ID}`)
})
it('clicking task card calls router.push with editTask param', () => {
setActiveStoryAndTasks(STORY_ID, TASKS)
renderPanel()
fireEvent.click(screen.getByText('Eerste taak'))
expect(mockPush).toHaveBeenCalledWith(`${CLOSE_PATH}?editTask=task-1`)
})
it('+ button is disabled in demo mode', () => {
setActiveStoryAndTasks(STORY_ID, [])
renderPanel(true)
const btn = screen.getAllByText('+ Nieuwe taak')[0].closest('button')
expect(btn).toBeTruthy()
expect((btn as HTMLButtonElement).disabled).toBe(true)
})
})

View file

@ -1,56 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
const { pushMock } = vi.hoisted(() => ({ pushMock: vi.fn() }))
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: pushMock, refresh: vi.fn() }) }))
vi.mock('@/actions/products', () => ({ restoreProductAction: vi.fn() }))
vi.mock('@/actions/active-product', () => ({ setActiveProductAction: vi.fn() }))
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
vi.mock('@/components/dialogs/product-dialog', () => ({
ProductDialog: ({ open }: { open: boolean }) => (open ? <div role="dialog">ProductDialog</div> : null),
}))
import { ProductList } from '@/components/dashboard/product-list'
const PRODUCT = {
id: 'p1',
name: 'Mijn Product',
code: 'MP',
description: 'Een product',
repo_url: 'https://github.com/foo/bar',
definition_of_done: 'klaar als het werkt',
auto_pr: false,
}
beforeEach(() => {
pushMock.mockClear()
})
describe('ProductList — edit-icoon (todo cmoq3ox51)', () => {
it('rendert pencil-icoon (Bewerk product) op active card, geen tekstknop "Bewerken"', () => {
render(<ProductList products={[PRODUCT]} isDemo={false} activeProductId="p1" />)
expect(screen.getByLabelText('Bewerk product')).toBeTruthy()
// Oude tekstknop is weg
expect(screen.queryByText('Bewerken')).toBeNull()
})
it('opent ProductDialog op klik (en stopt propagation zodat card-click niet navigeert)', () => {
render(<ProductList products={[PRODUCT]} isDemo={false} activeProductId="p1" />)
expect(screen.queryByRole('dialog')).toBeNull()
fireEvent.click(screen.getByLabelText('Bewerk product'))
expect(screen.getByRole('dialog')).toBeTruthy()
expect(pushMock).not.toHaveBeenCalled() // card-navigation niet getriggerd
})
it('demo-user: knop is disabled', () => {
render(<ProductList products={[PRODUCT]} isDemo={true} activeProductId="p1" />)
const btn = screen.getByLabelText('Bewerk product') as HTMLButtonElement
expect(btn.disabled).toBe(true)
})
it('toont geen edit-icoon bij gearchiveerde producten', () => {
render(<ProductList products={[PRODUCT]} isDemo={false} showArchived={true} activeProductId={null} />)
expect(screen.queryByLabelText('Bewerk product')).toBeNull()
})
})

View file

@ -1,104 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import React from 'react'
vi.mock('@/actions/questions', () => ({
answerQuestion: vi.fn(),
}))
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
vi.mock('@/stores/notifications-store', () => ({
useNotificationsStore: {
getState: () => ({ remove: vi.fn() }),
},
}))
vi.mock('next/link', () => ({
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
<a href={href}>{children}</a>
),
}))
import { AnswerModal } from '@/components/notifications/answer-modal'
import { answerQuestion } from '@/actions/questions'
import { toast } from 'sonner'
import type { NotificationQuestion } from '@/stores/notifications-store'
const mockAnswerQuestion = answerQuestion as ReturnType<typeof vi.fn>
const mockToast = toast as unknown as {
success: ReturnType<typeof vi.fn>
error: ReturnType<typeof vi.fn>
}
const QUESTION: NotificationQuestion = {
kind: 'idea',
id: 'q-1',
product_id: 'prod-1',
idea_id: 'idea-1',
idea_code: 'IDEA-42',
idea_title: 'Mijn Idee',
question: 'Wat denk jij?',
options: ['Optie A', 'Optie B'],
created_at: '2026-01-01T00:00:00Z',
expires_at: '2026-12-31T00:00:00Z',
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('AnswerModal — met opties', () => {
it('toont optieknoppen, textarea en Verstuur-knop', () => {
render(<AnswerModal question={QUESTION} isDemo={false} onClose={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Optie A' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Optie B' })).toBeTruthy()
expect(screen.getByLabelText(/Antwoord op Claude/)).toBeTruthy()
expect(screen.getByRole('button', { name: 'Verstuur' })).toBeTruthy()
})
it('roept answerQuestion aan met optiewaarde bij klik op optieknop', async () => {
mockAnswerQuestion.mockResolvedValue({ ok: true })
render(<AnswerModal question={QUESTION} isDemo={false} onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Optie A' }))
await waitFor(() => {
expect(mockAnswerQuestion).toHaveBeenCalledWith('q-1', 'Optie A')
})
})
it('roept answerQuestion aan met getypte tekst bij klik op Verstuur', async () => {
mockAnswerQuestion.mockResolvedValue({ ok: true })
render(<AnswerModal question={QUESTION} isDemo={false} onClose={vi.fn()} />)
fireEvent.change(screen.getByLabelText(/Antwoord op Claude/), {
target: { value: 'Mijn eigen antwoord' },
})
fireEvent.click(screen.getByRole('button', { name: 'Verstuur' }))
await waitFor(() => {
expect(mockAnswerQuestion).toHaveBeenCalledWith('q-1', 'Mijn eigen antwoord')
})
})
it('Verstuur-knop is disabled zolang het tekstveld leeg is', () => {
render(<AnswerModal question={QUESTION} isDemo={false} onClose={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Verstuur' })).toHaveProperty('disabled', true)
})
})
describe('AnswerModal — demo-modus', () => {
it('textarea is disabled en Verstuur is disabled bij isDemo=true', () => {
render(<AnswerModal question={QUESTION} isDemo={true} onClose={vi.fn()} />)
expect(screen.getByLabelText(/Antwoord op Claude/)).toHaveProperty('disabled', true)
expect(screen.getByRole('button', { name: 'Verstuur' })).toHaveProperty('disabled', true)
})
})
describe('AnswerModal — geen vraag', () => {
it('rendert niets wanneer question null is', () => {
const { container } = render(
<AnswerModal question={null} isDemo={false} onClose={vi.fn()} />,
)
expect(container.firstChild).toBeNull()
})
})

View file

@ -1,134 +0,0 @@
// @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)
})
})

View file

@ -1,277 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import React from 'react'
// --- Navigation mock ---
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
}))
// --- Actions mocks ---
vi.mock('@/actions/ideas', () => ({
createIdeaAction: vi.fn(),
archiveIdeaAction: vi.fn(),
}))
vi.mock('@/actions/user-settings', () => ({
updateUserSettingsAction: vi.fn().mockResolvedValue({ success: true, settings: {} }),
}))
// --- Sonner mock ---
vi.mock('sonner', () => ({
toast: { error: vi.fn(), success: vi.fn() },
}))
// --- IdeaRowActions mock (complex component with many deps) ---
vi.mock('@/components/ideas/idea-row-actions', () => ({
IdeaRowActions: () => <div data-testid="idea-row-actions" />,
}))
// --- DemoTooltip mock ---
vi.mock('@/components/shared/demo-tooltip', () => ({
DemoTooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}))
// --- Popover mock — controlled via open prop ---
vi.mock('@/components/ui/popover', () => {
const PopoverCtx = React.createContext<{
open: boolean
onOpenChange: (v: boolean) => void
}>({ open: false, onOpenChange: () => {} })
return {
Popover: ({
children,
open,
onOpenChange,
}: {
children: React.ReactNode
open?: boolean
onOpenChange?: (v: boolean) => void
}) => (
<PopoverCtx.Provider value={{ open: open ?? false, onOpenChange: onOpenChange ?? (() => {}) }}>
{children}
</PopoverCtx.Provider>
),
PopoverTrigger: ({ render: renderEl }: { render: React.ReactElement<{ onClick?: (e: React.MouseEvent) => void }> }) => {
const { open, onOpenChange } = React.useContext(PopoverCtx)
return React.cloneElement(renderEl, {
onClick: (e: React.MouseEvent) => {
onOpenChange(!open)
renderEl.props.onClick?.(e)
},
})
},
PopoverContent: ({ children }: { children: React.ReactNode }) => {
const { open } = React.useContext(PopoverCtx)
return open ? <div data-testid="popover-content">{children}</div> : null
},
}
})
// Import after mocks
import { useUserSettingsStore } from '@/stores/user-settings/store'
import { IdeaList } from '@/components/ideas/idea-list'
import { createIdeaAction } from '@/actions/ideas'
import type { IdeaDto } from '@/lib/idea-dto'
const PRODUCTS = [
{ id: 'prod-1', name: 'Product A', repo_url: null },
// repo_url ingesteld zodat de optietekst gewoon "Product B" is (zonder "(geen repo)" suffix)
{ id: 'prod-2', name: 'Product B', repo_url: 'https://github.com/org/prod-b' },
]
// Minimal IdeaDto factory
function makeIdea(overrides: Partial<IdeaDto> = {}): IdeaDto {
return {
id: 'idea-1',
code: 'ID-1',
title: 'Test Idee',
description: null,
status: 'draft',
product_id: null,
product: null,
pbi_id: null,
pbi: null,
secondary_products: [],
archived: false,
has_grill_md: false,
has_plan_md: false,
created_at: '2024-01-01T00:00:00.000Z',
updated_at: '2024-01-01T00:00:00.000Z',
...overrides,
}
}
const IDEAS: IdeaDto[] = [
makeIdea({ id: 'idea-1', code: 'ID-1', title: 'Idee Concept', status: 'draft' }),
makeIdea({ id: 'idea-2', code: 'ID-2', title: 'Idee Gegrilld', status: 'grilled' }),
makeIdea({ id: 'idea-3', code: 'ID-3', title: 'Idee Gepland', status: 'planned' }),
]
beforeEach(() => {
vi.clearAllMocks()
useUserSettingsStore.getState().hydrate({}, false)
})
describe('IdeaList — filterpopover', () => {
it('toont de "Filters"-knop in de toolbar (geen inline chip-rij)', () => {
render(<IdeaList ideas={IDEAS} products={[]} isDemo={false} activeProductId={null} />)
// Filters-knop aanwezig
expect(screen.getByText('Filters')).toBeInTheDocument()
// Status-labels zoals "Concept" mogen NIET los zichtbaar zijn zonder popover te openen
// (anders was de oude inline chip-rij er nog)
expect(screen.queryByRole('button', { name: 'Concept' })).not.toBeInTheDocument()
})
it('klik op "Filters" opent de popover en toont 11 statusopties', () => {
render(<IdeaList ideas={IDEAS} products={[]} isDemo={false} activeProductId={null} />)
// Popover nog niet open: content niet zichtbaar
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('Filters'))
// Content verschijnt
expect(screen.getByTestId('popover-content')).toBeInTheDocument()
// 11 statusopties + "Alle" = 12 buttons in de popover
// Controleer specifiek de 11 status-labels
const statusLabels = [
'Concept', 'Grillen', 'Gegrilld', 'Plannen', 'Plan klaar',
'Plan beoordelen', 'Gepland', 'Grill mislukt', 'Plan mislukt',
'Beoordeling mislukt', 'Plan beoordeeld',
]
for (const label of statusLabels) {
expect(screen.getByRole('button', { name: label })).toBeInTheDocument()
}
})
it('klik op een statuschip schrijft de status naar de store', () => {
render(<IdeaList ideas={IDEAS} products={[]} isDemo={false} activeProductId={null} />)
fireEvent.click(screen.getByText('Filters'))
fireEvent.click(screen.getByRole('button', { name: 'Concept' }))
const stored =
useUserSettingsStore.getState().entities.settings.views?.ideasList?.filterStatuses
expect(stored).toContain('draft')
})
it('gehydrateerde filter toont "Filters (1)" en filtert de tabel', () => {
useUserSettingsStore
.getState()
.hydrate({ views: { ideasList: { filterStatuses: ['draft'] } } }, false)
render(<IdeaList ideas={IDEAS} products={[]} isDemo={false} activeProductId={null} />)
// Trigger toont het actieve filteraantal
expect(screen.getByText('Filters (1)')).toBeInTheDocument()
// Alleen het concept-idee is zichtbaar; de andere twee worden weggefilterd
expect(screen.getByText('Idee Concept')).toBeInTheDocument()
expect(screen.queryByText('Idee Gegrilld')).not.toBeInTheDocument()
expect(screen.queryByText('Idee Gepland')).not.toBeInTheDocument()
})
it('"Wis filters" is disabled wanneer geen filter actief is', () => {
render(<IdeaList ideas={IDEAS} products={[]} isDemo={false} activeProductId={null} />)
fireEvent.click(screen.getByText('Filters'))
const wisButton = screen.getByRole('button', { name: 'Wis filters' })
expect(wisButton).toBeDisabled()
})
it('"Wis filters" is enabled en wist de filter wanneer een filter actief is', () => {
useUserSettingsStore
.getState()
.hydrate({ views: { ideasList: { filterStatuses: ['draft'] } } }, false)
render(<IdeaList ideas={IDEAS} products={[]} isDemo={false} activeProductId={null} />)
fireEvent.click(screen.getByText('Filters (1)'))
const wisButton = screen.getByRole('button', { name: 'Wis filters' })
expect(wisButton).not.toBeDisabled()
fireEvent.click(wisButton)
const stored =
useUserSettingsStore.getState().entities.settings.views?.ideasList?.filterStatuses
expect(stored).toEqual([])
})
})
describe('IdeaList — activeProductId voorvullen', () => {
// Hulpfunctie: vind een knop op basis van gedeeltelijke tekstinhoud.
// getByText() werkt hier betrouwbaarder dan getByRole({name}) voor knoppen
// met SVG-icoon omdat de accessible-name-berekening van Base UI knoppen in
// jsdom soms afwijkt van wat we verwachten.
function clickButton(label: string) {
const btn = Array.from(document.querySelectorAll('button')).find(
(b) => b.textContent?.trim().includes(label)
)
if (!btn) throw new Error(`Knop met tekst "${label}" niet gevonden`)
fireEvent.click(btn)
}
it('AC1: "Nieuw idee"-select is voorgevuld met het actieve product', async () => {
render(
<IdeaList ideas={[]} products={PRODUCTS} isDemo={false} activeProductId="prod-2" />
)
clickButton('Nieuw idee')
// Wacht tot het formulier verschijnt; create-form-select toont "Product B" (waarde 'prod-2').
// De toolbar-select toont "Alle producten" (waarde 'all'), zodat displayValue uniek is.
const createFormSelect = await waitFor(() => screen.getByDisplayValue('Product B'))
expect(createFormSelect).toHaveValue('prod-2')
})
it('AC2: "Nieuw idee"-select staat op leeg wanneer activeProductId null is', async () => {
render(
<IdeaList ideas={[]} products={PRODUCTS} isDemo={false} activeProductId={null} />
)
clickButton('Nieuw idee')
// Toolbar-select toont "Alle producten"; create-form-select toont de placeholder (waarde '').
const createFormSelect = await waitFor(() =>
screen.getByDisplayValue('Geen product (kan later worden gekoppeld)')
)
expect(createFormSelect).toHaveValue('')
})
it('AC3: "Snel idee" stuurt product_id gelijk aan activeProductId mee', async () => {
vi.mocked(createIdeaAction).mockResolvedValue({ data: { code: 'ID-99', id: 'idea-99' } } as never)
render(
<IdeaList ideas={[]} products={PRODUCTS} isDemo={false} activeProductId="prod-2" />
)
// Open "Snel idee"-formulier en wacht tot het verschijnt
clickButton('Snel idee')
await waitFor(() => screen.getByPlaceholderText('Titel *'))
// Vul de verplichte titel in
fireEvent.change(screen.getByPlaceholderText('Titel *'), {
target: { value: 'Mijn snel idee' },
})
// Klik Opslaan — startTransition roept createIdeaAction synchroon aan
clickButton('Opslaan')
await waitFor(() => {
expect(createIdeaAction).toHaveBeenCalledWith({
title: 'Mijn snel idee',
description: null,
product_id: 'prod-2',
})
})
})
})

View file

@ -1,85 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import JobCard from '@/components/jobs/job-card'
const BASE_PROPS = {
id: 'job-1',
kind: 'TASK_IMPLEMENTATION' as const,
status: 'RUNNING' as const,
productName: 'Scrum4Me',
productCode: 'S4M',
pbiCode: 'PBI-1',
storyCode: 'ST-1',
createdAt: new Date('2026-01-01T10:00:00Z'),
}
describe('JobCard breadcrumb', () => {
it('TASK-job toont productCode, pbiCode en storyCode in de breadcrumb', () => {
render(<JobCard {...BASE_PROPS} />)
const breadcrumb = screen.getByText('S4M PBI-1 ST-1')
expect(breadcrumb).toBeInTheDocument()
})
it('TASK-job zonder productCode valt terug op productName in de breadcrumb', () => {
render(<JobCard {...BASE_PROPS} productCode={null} />)
expect(screen.getByText('Scrum4Me PBI-1 ST-1')).toBeInTheDocument()
})
it('TASK-job laat ontbrekende codes weg uit de breadcrumb', () => {
render(<JobCard {...BASE_PROPS} pbiCode={null} storyCode={null} />)
expect(screen.getByText('S4M')).toBeInTheDocument()
})
it('GRILL-job toont productCode en ideaCode', () => {
render(
<JobCard
{...BASE_PROPS}
kind="IDEA_GRILL"
productCode="S4M"
ideaCode="IDEA-5"
pbiCode={null}
storyCode={null}
/>,
)
expect(screen.getByText('S4M IDEA-5')).toBeInTheDocument()
})
it('SPRINT-job toont productCode en sprintCode', () => {
render(
<JobCard
{...BASE_PROPS}
kind="SPRINT_IMPLEMENTATION"
productCode="S4M"
sprintCode="SP-3"
pbiCode={null}
storyCode={null}
/>,
)
expect(screen.getByText('S4M SP-3')).toBeInTheDocument()
})
})
describe('JobCard datumweergave', () => {
it('toont finishedAt als die beschikbaar is', () => {
const finishedAt = new Date('2026-03-15T14:30:00Z')
render(<JobCard {...BASE_PROPS} startedAt={new Date('2026-03-10T09:00:00Z')} finishedAt={finishedAt} />)
const formatted = finishedAt.toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' })
expect(screen.getByText(formatted)).toBeInTheDocument()
})
it('toont startedAt als finishedAt ontbreekt', () => {
const startedAt = new Date('2026-03-10T09:00:00Z')
render(<JobCard {...BASE_PROPS} startedAt={startedAt} finishedAt={null} />)
const formatted = startedAt.toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' })
expect(screen.getByText(formatted)).toBeInTheDocument()
})
it('toont createdAt als zowel finishedAt als startedAt ontbreken', () => {
const createdAt = new Date('2026-01-01T10:00:00Z')
render(<JobCard {...BASE_PROPS} createdAt={createdAt} startedAt={null} finishedAt={null} />)
const formatted = createdAt.toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' })
expect(screen.getByText(formatted)).toBeInTheDocument()
})
})

View file

@ -1,78 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import '@testing-library/jest-dom'
import type { JobWithRelations } from '@/actions/jobs-page'
vi.mock('@/actions/claude-jobs', () => ({
restartClaudeJobAction: vi.fn(),
}))
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
import { restartClaudeJobAction } from '@/actions/claude-jobs'
import JobDetailPane from '@/components/jobs/job-detail-pane'
const mockAction = restartClaudeJobAction as ReturnType<typeof vi.fn>
function makeJob(status: JobWithRelations['status']): JobWithRelations {
return {
id: 'job-1',
kind: 'TASK_IMPLEMENTATION',
status,
taskCode: 'T-1',
taskTitle: 'Test taak',
ideaCode: null,
ideaTitle: null,
sprintGoal: null,
sprintCode: null,
productName: 'Scrum4Me',
productCode: null,
storyCode: null,
pbiCode: null,
modelId: null,
inputTokens: null,
outputTokens: null,
cacheReadTokens: null,
cacheWriteTokens: null,
costUsd: null,
branch: null,
prUrl: null,
error: null,
summary: null,
description: null,
verifyResult: null,
startedAt: null,
finishedAt: null,
createdAt: new Date('2026-01-01'),
sprintRunId: null,
}
}
beforeEach(() => {
vi.clearAllMocks()
mockAction.mockResolvedValue({ success: true })
})
describe('JobDetailPane restart button', () => {
it('toont de knop voor FAILED-jobs', () => {
render(<JobDetailPane job={makeJob('FAILED')} isDemo={false} />)
expect(screen.getByRole('button', { name: /opnieuw starten/i })).toBeInTheDocument()
})
it('toont de knop niet voor DONE-jobs', () => {
render(<JobDetailPane job={makeJob('DONE')} isDemo={false} />)
expect(screen.queryByRole('button', { name: /opnieuw starten/i })).not.toBeInTheDocument()
})
it('roept restartClaudeJobAction aan met het juiste id bij klik', () => {
render(<JobDetailPane job={makeJob('FAILED')} isDemo={false} />)
fireEvent.click(screen.getByRole('button', { name: /opnieuw starten/i }))
expect(mockAction).toHaveBeenCalledWith('job-1')
})
it('knop is disabled in demo-modus', () => {
render(<JobDetailPane job={makeJob('FAILED')} isDemo={true} />)
expect(screen.getByRole('button', { name: /opnieuw starten/i })).toBeDisabled()
})
})

View file

@ -1,73 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, act } from '@testing-library/react'
import { LandscapeGuard } from '@/components/mobile/landscape-guard'
type Listener = (e: MediaQueryListEvent) => void
function mockMatchMedia(initialPortrait: boolean) {
let matches = initialPortrait
let listener: Listener | null = null
const mql = {
get matches() { return matches },
media: '(orientation: portrait)',
onchange: null,
addEventListener: (_: string, l: Listener) => { listener = l },
removeEventListener: () => { listener = null },
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
}
Object.defineProperty(window, 'matchMedia', {
writable: true,
configurable: true,
value: () => mql,
})
return {
setPortrait(p: boolean) {
matches = p
if (listener) listener({ matches: p } as MediaQueryListEvent)
},
}
}
describe('LandscapeGuard', () => {
beforeEach(() => {})
afterEach(() => {
vi.restoreAllMocks()
})
it('renders children always', () => {
mockMatchMedia(false)
render(<LandscapeGuard><div>kids</div></LandscapeGuard>)
expect(screen.getByText('kids')).toBeTruthy()
})
it('shows overlay in portrait', () => {
mockMatchMedia(true)
render(<LandscapeGuard><div>kids</div></LandscapeGuard>)
expect(screen.getByRole('alert').textContent).toContain('Draai je telefoon naar landscape')
// children blijven in DOM (geen unmount → SSE-streams blijven leven)
expect(screen.getByText('kids')).toBeTruthy()
})
it('hides overlay in landscape', () => {
mockMatchMedia(false)
render(<LandscapeGuard><div>kids</div></LandscapeGuard>)
expect(screen.queryByRole('alert')).toBeNull()
})
it('toggles overlay on orientation change', () => {
const ctl = mockMatchMedia(false)
render(<LandscapeGuard><div>kids</div></LandscapeGuard>)
expect(screen.queryByRole('alert')).toBeNull()
act(() => ctl.setPortrait(true))
expect(screen.getByRole('alert')).toBeTruthy()
act(() => ctl.setPortrait(false))
expect(screen.queryByRole('alert')).toBeNull()
})
})

View file

@ -1,46 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
const { logoutMock } = vi.hoisted(() => ({
logoutMock: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/actions/auth', () => ({ logoutAction: logoutMock }))
import { LogoutButton } from '@/components/mobile/logout-button'
beforeEach(() => {
logoutMock.mockClear()
})
describe('LogoutButton', () => {
it('toont initieel alleen de Uitloggen-knop, geen dialog', () => {
render(<LogoutButton />)
expect(screen.getByRole('button', { name: /Uitloggen/ })).toBeTruthy()
expect(screen.queryByText(/Weet je zeker/)).toBeNull()
})
it('opent AlertDialog bij klikken op de knop', () => {
render(<LogoutButton />)
fireEvent.click(screen.getByRole('button', { name: /Uitloggen/ }))
expect(screen.getByText('Uitloggen?')).toBeTruthy()
expect(screen.getByText(/Weet je zeker/)).toBeTruthy()
})
it('roept logoutAction aan op bevestigen', async () => {
const { container } = render(<LogoutButton />)
fireEvent.click(screen.getByRole('button', { name: /Uitloggen/ }))
// Het body-portal wordt buiten container gerenderd; query op document.body.
const allButtons = Array.from(document.body.querySelectorAll('button'))
const confirmBtn = allButtons.find(b => b.textContent?.trim() === 'Uitloggen' && !container.contains(b)) ?? allButtons[allButtons.length - 1]
fireEvent.click(confirmBtn)
await waitFor(() => expect(logoutMock).toHaveBeenCalledTimes(1))
})
it('roept logoutAction NIET aan bij annuleren', () => {
render(<LogoutButton />)
fireEvent.click(screen.getByRole('button', { name: /Uitloggen/ }))
fireEvent.click(screen.getByText('Annuleren'))
expect(logoutMock).not.toHaveBeenCalled()
})
})

View file

@ -1,57 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { MobileTabBar } from '@/components/mobile/mobile-tab-bar'
let pathname = '/m/products/p1'
vi.mock('next/navigation', () => ({
usePathname: () => pathname,
}))
function setPathname(p: string) { pathname = p }
describe('MobileTabBar', () => {
it('toont 3 tabs als activeProductId aanwezig is', () => {
setPathname('/m/products/p1')
render(<MobileTabBar activeProductId="p1" />)
expect(screen.getByLabelText('Backlog')).toBeTruthy()
expect(screen.getByLabelText('Solo')).toBeTruthy()
expect(screen.getByLabelText('Settings')).toBeTruthy()
})
it('toont alleen Settings als activeProductId null is', () => {
setPathname('/m/settings')
render(<MobileTabBar activeProductId={null} />)
expect(screen.queryByLabelText('Backlog')).toBeNull()
expect(screen.queryByLabelText('Solo')).toBeNull()
expect(screen.getByLabelText('Settings')).toBeTruthy()
})
it('Backlog-tab is aria-current op /m/products/[id]', () => {
setPathname('/m/products/p1')
render(<MobileTabBar activeProductId="p1" />)
expect(screen.getByLabelText('Backlog').getAttribute('aria-current')).toBe('page')
expect(screen.getByLabelText('Solo').getAttribute('aria-current')).toBeNull()
})
it('Solo-tab is aria-current op /m/products/[id]/solo', () => {
setPathname('/m/products/p1/solo')
render(<MobileTabBar activeProductId="p1" />)
expect(screen.getByLabelText('Solo').getAttribute('aria-current')).toBe('page')
expect(screen.getByLabelText('Backlog').getAttribute('aria-current')).toBeNull()
})
it('Settings-tab is aria-current op /m/settings', () => {
setPathname('/m/settings')
render(<MobileTabBar activeProductId="p1" />)
expect(screen.getByLabelText('Settings').getAttribute('aria-current')).toBe('page')
})
it('tap-targets >=44x44 (h-14 = 56px breedtevulling per tab)', () => {
setPathname('/m/products/p1')
render(<MobileTabBar activeProductId="p1" />)
const tab = screen.getByLabelText('Backlog')
expect(tab.className).toContain('h-14')
expect(tab.className).toContain('flex-1')
})
})

View file

@ -1,46 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import '@testing-library/jest-dom'
vi.mock('@/actions/products', () => ({
updateAutoPrAction: vi.fn(),
}))
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
import { updateAutoPrAction } from '@/actions/products'
import { AutoPrToggle } from '@/components/products/auto-pr-toggle'
const mockAction = updateAutoPrAction as ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
mockAction.mockResolvedValue({ success: true })
})
describe('AutoPrToggle', () => {
it('renders in off state with aria-checked=false', () => {
render(<AutoPrToggle productId="prod-1" initialValue={false} />)
const toggle = screen.getByRole('switch')
expect(toggle).toHaveAttribute('aria-checked', 'false')
})
it('renders in on state with aria-checked=true', () => {
render(<AutoPrToggle productId="prod-1" initialValue={true} />)
const toggle = screen.getByRole('switch')
expect(toggle).toHaveAttribute('aria-checked', 'true')
})
it('calls updateAutoPrAction with true when toggled on', async () => {
render(<AutoPrToggle productId="prod-1" initialValue={false} />)
fireEvent.click(screen.getByRole('switch'))
expect(mockAction).toHaveBeenCalledWith('prod-1', true)
})
it('calls updateAutoPrAction with false when toggled off', async () => {
render(<AutoPrToggle productId="prod-1" initialValue={true} />)
fireEvent.click(screen.getByRole('switch'))
expect(mockAction).toHaveBeenCalledWith('prod-1', false)
})
})

View file

@ -1,38 +0,0 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { entityDialogContentClasses } from '@/components/shared/entity-dialog-layout'
describe('entityDialogContentClasses', () => {
it('bevat mobile-fullscreen classes (<640px)', () => {
const cls = entityDialogContentClasses
expect(cls).toContain('max-sm:w-screen')
expect(cls).toContain('max-sm:h-screen')
expect(cls).toContain('max-sm:max-w-none')
expect(cls).toContain('max-sm:rounded-none')
})
it('behoudt desktop-classes (>=640px)', () => {
const cls = entityDialogContentClasses
expect(cls).toContain('sm:max-w-[90vw]')
expect(cls).toContain('sm:max-h-[85vh]')
expect(cls).toContain('lg:max-w-[50vw]')
})
})
describe('alle entity-dialogen gebruiken entityDialogContentClasses', () => {
// Regressie-vangnet: voorkomt dat een dialog zijn eigen className meegeeft en
// daarmee de gedeelde mobile-fullscreen-classes ontwijkt.
const files = [
'app/_components/tasks/task-dialog.tsx',
'components/solo/task-detail-dialog.tsx',
'components/backlog/pbi-dialog.tsx',
'components/backlog/story-dialog.tsx',
]
for (const f of files) {
it(`${f} importeert + gebruikt entityDialogContentClasses`, () => {
const src = readFileSync(resolve(process.cwd(), f), 'utf-8')
expect(src).toContain('entityDialogContentClasses')
})
}
})

View file

@ -1,179 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import '@testing-library/jest-dom'
import React from 'react'
const pushMock = vi.fn()
const refreshMock = vi.fn()
const pathnameMock = vi.fn(() => '/dashboard')
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: pushMock, refresh: refreshMock }),
usePathname: () => pathnameMock(),
}))
vi.mock('@/actions/active-product', () => ({
setActiveProductAction: vi.fn(),
}))
vi.mock('sonner', () => ({
toast: { error: vi.fn(), success: vi.fn() },
}))
vi.mock('@/components/ui/dropdown-menu', () => {
type Props = React.HTMLAttributes<HTMLDivElement> & {
children?: React.ReactNode
onClick?: () => void
}
const PassThrough = ({ children }: Props) => <>{children}</>
const Forwarding = ({ children, ...rest }: Props) => <div {...rest}>{children}</div>
return {
DropdownMenu: PassThrough,
DropdownMenuTrigger: Forwarding,
DropdownMenuContent: PassThrough,
DropdownMenuItem: ({ children, onClick, className }: Props) => (
<button type="button" onClick={onClick} className={className} data-testid="dd-item">
{children}
</button>
),
DropdownMenuSeparator: () => null,
}
})
vi.mock('@/components/ui/tooltip', () => {
type Props = { children?: React.ReactNode }
const PassThrough = ({ children }: Props) => <>{children}</>
return {
Tooltip: PassThrough,
TooltipContent: PassThrough,
TooltipProvider: PassThrough,
TooltipTrigger: PassThrough,
}
})
vi.mock('@/components/shared/app-icon', () => ({ AppIcon: () => null }))
vi.mock('@/components/shared/user-menu', () => ({ UserMenu: () => null }))
vi.mock('@/components/shared/notifications-bell', () => ({ NotificationsBell: () => null }))
vi.mock('@/components/solo/nav-status-indicators', () => ({
SoloNavStatusIndicators: () => null,
}))
import { setActiveProductAction } from '@/actions/active-product'
import { toast } from 'sonner'
import { NavBar } from '@/components/shared/nav-bar'
const actionMock = setActiveProductAction as unknown as ReturnType<typeof vi.fn>
const toastSuccess = toast.success as unknown as ReturnType<typeof vi.fn>
const products = [
{ id: 'A', name: 'Alpha' },
{ id: 'B', name: 'Beta' },
]
function renderNavBar(overrides: { isDemo?: boolean; activeProductId?: string } = {}) {
const isDemo = overrides.isDemo ?? false
const activeId = overrides.activeProductId ?? 'A'
const activeProduct = products.find(p => p.id === activeId) ?? null
return render(
<NavBar
isDemo={isDemo}
roles={[]}
userId="u1"
username="user"
email={null}
activeProduct={activeProduct}
products={products}
hasActiveSprint={false}
minQuotaPct={100}
/>,
)
}
beforeEach(() => {
vi.clearAllMocks()
actionMock.mockResolvedValue({ success: true })
pathnameMock.mockReturnValue('/dashboard')
})
describe('NavBar — product switch', () => {
it('demo: clicking another product navigates via router.push without calling the action', () => {
renderNavBar({ isDemo: true, activeProductId: 'A' })
fireEvent.click(screen.getByText('Beta'))
expect(pushMock).toHaveBeenCalledWith('/products/B')
expect(actionMock).not.toHaveBeenCalled()
expect(toastSuccess).not.toHaveBeenCalled()
})
it('non-demo: clicking another product calls setActiveProductAction', async () => {
renderNavBar({ isDemo: false, activeProductId: 'A' })
fireEvent.click(screen.getByText('Beta'))
await Promise.resolve()
expect(actionMock).toHaveBeenCalledWith('B')
})
it('non-demo: on /products/A navigates to /products/B', async () => {
pathnameMock.mockReturnValue('/products/A')
renderNavBar({ isDemo: false, activeProductId: 'A' })
fireEvent.click(screen.getByText('Beta'))
await Promise.resolve()
await Promise.resolve()
expect(pushMock).toHaveBeenCalledWith('/products/B')
expect(toastSuccess).toHaveBeenCalled()
})
it('non-demo: on /products/A/sprint/SPR1 navigates to /products/B/sprint', async () => {
pathnameMock.mockReturnValue('/products/A/sprint/SPR1')
renderNavBar({ isDemo: false, activeProductId: 'A' })
fireEvent.click(screen.getByText('Beta'))
await Promise.resolve()
await Promise.resolve()
expect(pushMock).toHaveBeenCalledWith('/products/B/sprint')
expect(toastSuccess).toHaveBeenCalled()
})
it('non-demo: on /products/A/solo navigates to /products/B/solo', async () => {
pathnameMock.mockReturnValue('/products/A/solo')
renderNavBar({ isDemo: false, activeProductId: 'A' })
fireEvent.click(screen.getByText('Beta'))
await Promise.resolve()
await Promise.resolve()
expect(pushMock).toHaveBeenCalledWith('/products/B/solo')
expect(toastSuccess).toHaveBeenCalled()
})
it('non-demo: on /dashboard calls router.refresh and not router.push', async () => {
pathnameMock.mockReturnValue('/dashboard')
renderNavBar({ isDemo: false, activeProductId: 'A' })
fireEvent.click(screen.getByText('Beta'))
await Promise.resolve()
await Promise.resolve()
expect(refreshMock).toHaveBeenCalled()
expect(pushMock).not.toHaveBeenCalled()
expect(toastSuccess).toHaveBeenCalled()
})
})
describe('NavBar — URL-derived active product (demo only)', () => {
it('demo: label and dropdown highlight follow pathname, not the activeProduct prop', () => {
pathnameMock.mockReturnValue('/products/B/sprint')
const { container } = renderNavBar({ isDemo: true, activeProductId: 'A' })
const trigger = container.querySelector('[data-debug-id="nav-bar__product-switcher"]')
expect(trigger?.textContent).toContain('Beta')
expect(trigger?.textContent).not.toContain('Alpha')
const items = screen.getAllByTestId('dd-item')
const itemB = items.find(el => el.textContent?.includes('Beta'))
expect(itemB?.className).toContain('bg-primary-container')
const itemA = items.find(el => el.textContent?.includes('Alpha'))
expect(itemA?.className ?? '').not.toContain('bg-primary-container')
})
it('non-demo: pathname does NOT override the activeProduct prop', () => {
pathnameMock.mockReturnValue('/products/B/sprint')
renderNavBar({ isDemo: false, activeProductId: 'A' })
// Label still reflects server-rendered activeProduct (Alpha)
const items = screen.getAllByTestId('dd-item')
const itemA = items.find(el => el.textContent?.includes('Alpha'))
expect(itemA?.className).toContain('bg-primary-container')
})
})

View file

@ -1,174 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import '@testing-library/jest-dom'
import React from 'react'
const pushMock = vi.fn()
const refreshMock = vi.fn()
const pathnameMock = vi.fn(() => '/products/p1/sprint')
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: pushMock, refresh: refreshMock }),
usePathname: () => pathnameMock(),
}))
vi.mock('@/actions/active-sprint', () => ({
setActiveSprintAction: vi.fn(),
switchActiveSprintAction: vi.fn(),
clearActiveSprintAction: vi.fn(),
}))
vi.mock('sonner', () => ({
toast: { error: vi.fn(), success: vi.fn() },
}))
const isDemoMock = { value: false }
const workflowMock: {
value:
| { pendingSprintDraft?: Record<string, { goal: string } | undefined> }
| undefined
} = { value: undefined }
// Mock-state shape moet alle paden dekken die SprintSwitcher selecteert:
// - s.context.isDemo (oude code)
// - s.entities.settings.workflow?.pendingSprintDraft?.[productId]?.goal (PBI-79)
type MockStoreState = {
context: { isDemo: boolean }
entities: {
settings: {
workflow?: {
pendingSprintDraft?: Record<string, { goal: string } | undefined>
}
}
}
}
vi.mock('@/stores/user-settings/store', () => ({
useUserSettingsStore: (selector: (s: MockStoreState) => unknown) =>
selector({
context: { isDemo: isDemoMock.value },
entities: { settings: { workflow: workflowMock.value } },
}),
}))
vi.mock('@/components/ui/dropdown-menu', () => {
type Props = { children?: React.ReactNode; onClick?: () => void; className?: string }
const PassThrough = ({ children }: Props) => <>{children}</>
return {
DropdownMenu: PassThrough,
DropdownMenuTrigger: PassThrough,
DropdownMenuContent: PassThrough,
DropdownMenuItem: ({ children, onClick, className }: Props) => (
<button type="button" onClick={onClick} className={className}>
{children}
</button>
),
DropdownMenuSeparator: () => null,
}
})
vi.mock('@/components/ui/tooltip', () => {
type Props = { children?: React.ReactNode }
const PassThrough = ({ children }: Props) => <>{children}</>
return {
Tooltip: PassThrough,
TooltipContent: PassThrough,
TooltipProvider: PassThrough,
TooltipTrigger: PassThrough,
}
})
import { switchActiveSprintAction } from '@/actions/active-sprint'
import { toast } from 'sonner'
import { SprintSwitcher } from '@/components/shared/sprint-switcher'
const actionMock = switchActiveSprintAction as unknown as ReturnType<typeof vi.fn>
const toastError = toast.error as unknown as ReturnType<typeof vi.fn>
const toastSuccess = toast.success as unknown as ReturnType<typeof vi.fn>
const sprints = [
{ id: 's1', code: 'SP-1', sprint_goal: 'Goal 1', status: 'open' as const },
{ id: 's2', code: 'SP-2', sprint_goal: 'Goal 2', status: 'open' as const },
]
beforeEach(() => {
vi.clearAllMocks()
isDemoMock.value = false
workflowMock.value = undefined
actionMock.mockResolvedValue({ success: true })
pathnameMock.mockReturnValue('/products/p1/sprint')
})
describe('SprintSwitcher', () => {
it('demo: clicking another sprint navigates via router.push without calling the action', () => {
isDemoMock.value = true
render(
<SprintSwitcher
productId="p1"
sprints={sprints}
activeSprint={sprints[0]}
buildingSprintIds={[]}
/>,
)
fireEvent.click(screen.getByText('Goal 2'))
expect(pushMock).toHaveBeenCalledWith('/products/p1/sprint/s2')
expect(actionMock).not.toHaveBeenCalled()
expect(toastError).not.toHaveBeenCalled()
expect(toastSuccess).not.toHaveBeenCalled()
})
it('non-demo: clicking another sprint calls setActiveSprintAction', async () => {
isDemoMock.value = false
render(
<SprintSwitcher
productId="p1"
sprints={sprints}
activeSprint={sprints[0]}
buildingSprintIds={[]}
/>,
)
fireEvent.click(screen.getByText('Goal 2'))
// Wait microtask for the transition to flush.
await Promise.resolve()
expect(actionMock).toHaveBeenCalledWith('p1', 's2')
})
it('clicking the already-active sprint does nothing', () => {
isDemoMock.value = true
render(
<SprintSwitcher
productId="p1"
sprints={sprints}
activeSprint={sprints[0]}
buildingSprintIds={[]}
/>,
)
fireEvent.click(screen.getByText('Goal 1'))
expect(pushMock).not.toHaveBeenCalled()
expect(actionMock).not.toHaveBeenCalled()
})
it('shows the concept-sprint on the trigger when a draft is pending (G5)', () => {
workflowMock.value = { pendingSprintDraft: { p1: { goal: 'Test goal' } } }
render(
<SprintSwitcher
productId="p1"
sprints={sprints}
activeSprint={null}
buildingSprintIds={[]}
/>,
)
expect(screen.getByText('⚙ Concept — Test goal')).toBeInTheDocument()
})
it('shows no concept label on the trigger when no draft is pending', () => {
render(
<SprintSwitcher
productId="p1"
sprints={sprints}
activeSprint={sprints[0]}
buildingSprintIds={[]}
/>,
)
expect(screen.queryByText(/⚙ Concept/)).not.toBeInTheDocument()
})
})

View file

@ -1,114 +0,0 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ open, children }: { open: boolean; onOpenChange?: (v: boolean) => void; children: React.ReactNode }) =>
open ? <div data-testid="dialog">{children}</div> : null,
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
}))
vi.mock('@/components/ui/button', () => ({
Button: ({
children,
onClick,
disabled,
variant,
}: {
children?: React.ReactNode
onClick?: () => void
disabled?: boolean
variant?: string
}) => (
<button onClick={onClick} disabled={disabled} data-variant={variant}>
{children}
</button>
),
}))
vi.mock('@/components/ui/tooltip', () => ({
TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ render: r, children }: { render?: React.ReactElement; children?: React.ReactNode }) =>
r ? <>{r}</> : <>{children}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => (
<span data-testid="tooltip-content">{children}</span>
),
}))
import { BatchEnqueueBlockerDialog } from '@/components/solo/batch-enqueue-blocker-dialog'
const DEFAULT_PROPS = {
open: true,
onOpenChange: vi.fn(),
prefixCount: 3,
blockerReason: 'task-review' as const,
blockerLabel: 'Story X — Task Y (in review)',
onConfirm: vi.fn(),
onCancel: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('BatchEnqueueBlockerDialog', () => {
it('renders title and blocker info for task-review', () => {
render(<BatchEnqueueBlockerDialog {...DEFAULT_PROPS} />)
expect(screen.getByRole('heading')).toHaveTextContent('Blokkade gedetecteerd')
expect(screen.getByText(/Een taak staat op 'review'/)).toBeInTheDocument()
expect(screen.getByText(/Story X — Task Y/)).toBeInTheDocument()
})
it('renders correct blocker label for pbi-blocked', () => {
render(
<BatchEnqueueBlockerDialog
{...DEFAULT_PROPS}
blockerReason="pbi-blocked"
blockerLabel="PBI Z — geblokkeerd"
/>
)
expect(screen.getByText(/De PBI is geblokkeerd/)).toBeInTheDocument()
expect(screen.getByText(/PBI Z/)).toBeInTheDocument()
})
it('calls onConfirm when primary button is clicked', () => {
render(<BatchEnqueueBlockerDialog {...DEFAULT_PROPS} />)
fireEvent.click(screen.getByText(/Stuur 3 taken tot aan blokkade/))
expect(DEFAULT_PROPS.onConfirm).toHaveBeenCalledTimes(1)
})
it('calls onCancel when cancel button is clicked', () => {
render(<BatchEnqueueBlockerDialog {...DEFAULT_PROPS} />)
fireEvent.click(screen.getByText('Annuleer'))
expect(DEFAULT_PROPS.onCancel).toHaveBeenCalledTimes(1)
})
it('disables confirm button and shows tooltip when prefixCount is 0', () => {
render(<BatchEnqueueBlockerDialog {...DEFAULT_PROPS} prefixCount={0} />)
const confirmBtn = screen.getByText(/Stuur 0/).closest('button')
expect(confirmBtn).toBeDisabled()
expect(screen.getByTestId('tooltip-content')).toHaveTextContent('Geen taken vóór blokkade')
})
it('does not render when open is false', () => {
render(<BatchEnqueueBlockerDialog {...DEFAULT_PROPS} open={false} />)
expect(screen.queryByTestId('dialog')).not.toBeInTheDocument()
})
it('uses singular taak when prefixCount is 1', () => {
render(<BatchEnqueueBlockerDialog {...DEFAULT_PROPS} prefixCount={1} />)
expect(screen.getByText(/Stuur 1 taak tot aan blokkade/)).toBeInTheDocument()
expect(screen.getByText(/1 taak vóór de blokkade/)).toBeInTheDocument()
})
})

View file

@ -1,207 +0,0 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
const { mockPreviewEnqueueAllAction, mockEnqueueClaudeJobsBatchAction } = vi.hoisted(() => ({
mockPreviewEnqueueAllAction: vi.fn(),
mockEnqueueClaudeJobsBatchAction: vi.fn(),
}))
vi.mock('@/actions/claude-jobs', () => ({
previewEnqueueAllAction: mockPreviewEnqueueAllAction,
enqueueClaudeJobsBatchAction: mockEnqueueClaudeJobsBatchAction,
cancelClaudeJobAction: vi.fn(),
enqueueClaudeJobAction: vi.fn(),
}))
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } }))
vi.mock('@dnd-kit/core', () => ({
DndContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DragOverlay: () => null,
PointerSensor: class {},
useSensor: vi.fn(() => ({})),
useSensors: vi.fn(() => []),
closestCorners: vi.fn(),
}))
vi.mock('@/components/ui/button', () => ({
Button: ({
children,
onClick,
disabled,
}: {
children?: React.ReactNode
onClick?: () => void
disabled?: boolean
}) => (
<button onClick={onClick} disabled={disabled}>
{children}
</button>
),
}))
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div data-testid="dialog">{children}</div> : null,
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
}))
vi.mock('@/components/ui/tooltip', () => ({
TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ render: r, children }: { render?: React.ReactElement; children?: React.ReactNode }) =>
r ? <>{r}</> : <>{children}</>,
TooltipContent: () => null,
}))
vi.mock('@/components/shared/demo-tooltip', () => ({
DemoTooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}))
vi.mock('@/components/split-pane/split-pane', () => ({
SplitPane: ({ panes }: { panes: React.ReactNode[] }) => <>{panes}</>,
}))
vi.mock('@/components/solo/solo-column', () => ({
SoloColumn: () => <div data-testid="solo-column" />,
}))
vi.mock('@/components/solo/solo-task-card', () => ({
SoloTaskCardOverlay: () => null,
}))
vi.mock('@/components/solo/task-detail-dialog', () => ({
TaskDetailDialog: () => null,
}))
vi.mock('@/components/solo/unassigned-stories-sheet', () => ({
UnassignedStoriesSheet: () => null,
}))
vi.mock('@/lib/task-status', () => ({
taskStatusToApi: (s: string) => s.toLowerCase(),
}))
import { useSoloStore } from '@/stores/solo-store'
import { SoloBoard } from '@/components/solo/solo-board'
import { toast } from 'sonner'
const PRODUCT_ID = 'prod-1'
const TODO_TASK = {
id: 't1',
title: 'Task 1',
description: null,
implementation_plan: null,
priority: 1,
sort_order: 1,
status: 'TO_DO' as const,
verify_only: false,
verify_required: 'ALIGNED_OR_PARTIAL' as const,
story_id: 'story-1',
story_code: 'ST-1',
story_title: 'Story 1',
task_code: 'ST-1.1',
pbi_code: null,
pbi_title: null,
pbi_description: null,
}
const DEFAULT_PROPS = {
productId: PRODUCT_ID,
sprintGoal: 'Sprint goal',
tasks: [TODO_TASK],
unassignedStories: [],
isDemo: false,
currentUserId: 'user-1',
}
const PREVIEW_NO_BLOCKER = {
tasks: [{ id: 't1', title: 'Task 1', status: 'TO_DO', story_title: 'Story 1', pbi_id: 'pbi-1', pbi_status: 'READY' }],
blockerIndex: null,
blockerReason: null,
}
const PREVIEW_WITH_BLOCKER = {
tasks: [
{ id: 't1', title: 'Task 1', status: 'TO_DO', story_title: 'Story 1', pbi_id: 'pbi-1', pbi_status: 'READY' },
{ id: 't2', title: 'Task 2', status: 'TO_DO', story_title: 'Story 1', pbi_id: 'pbi-1', pbi_status: 'READY' },
{ id: 't3', title: 'Task Review', status: 'REVIEW', story_title: 'Story 1', pbi_id: 'pbi-1', pbi_status: 'READY' },
],
blockerIndex: 2,
blockerReason: 'task-review' as const,
}
beforeEach(() => {
vi.clearAllMocks()
useSoloStore.setState({ tasks: {}, claudeJobsByTaskId: {}, connectedWorkers: 1 })
})
describe('SoloBoard — batch-enqueue flow', () => {
it('no blocker: calls enqueueClaudeJobsBatchAction with TO_DO task IDs directly', async () => {
mockPreviewEnqueueAllAction.mockResolvedValue(PREVIEW_NO_BLOCKER)
mockEnqueueClaudeJobsBatchAction.mockResolvedValue({ success: true, count: 1 })
render(<SoloBoard {...DEFAULT_PROPS} />)
fireEvent.click(screen.getByText(/Start agents/))
await waitFor(() => {
expect(mockPreviewEnqueueAllAction).toHaveBeenCalledWith(PRODUCT_ID)
expect(mockEnqueueClaudeJobsBatchAction).toHaveBeenCalledWith(PRODUCT_ID, ['t1'])
expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('1 agent'))
})
})
it('blocker: shows dialog when preview returns blockerIndex', async () => {
mockPreviewEnqueueAllAction.mockResolvedValue(PREVIEW_WITH_BLOCKER)
render(<SoloBoard {...DEFAULT_PROPS} />)
fireEvent.click(screen.getByText(/Start agents/))
await waitFor(() => {
expect(screen.getByTestId('dialog')).toBeInTheDocument()
expect(screen.getByText(/Blokkade gedetecteerd/)).toBeInTheDocument()
})
expect(mockEnqueueClaudeJobsBatchAction).not.toHaveBeenCalled()
})
it('blocker dialog confirm: enqueues prefix tasks and closes', async () => {
mockPreviewEnqueueAllAction.mockResolvedValue(PREVIEW_WITH_BLOCKER)
mockEnqueueClaudeJobsBatchAction.mockResolvedValue({ success: true, count: 2 })
render(<SoloBoard {...DEFAULT_PROPS} />)
fireEvent.click(screen.getByText(/Start agents/))
await waitFor(() => screen.getByTestId('dialog'))
fireEvent.click(screen.getByText(/Stuur 2 taken tot aan blokkade/))
await waitFor(() => {
expect(mockEnqueueClaudeJobsBatchAction).toHaveBeenCalledWith(PRODUCT_ID, ['t1', 't2'])
expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('2 agents'))
expect(screen.queryByTestId('dialog')).not.toBeInTheDocument()
})
})
it('blocker dialog cancel: closes dialog without enqueuing', async () => {
mockPreviewEnqueueAllAction.mockResolvedValue(PREVIEW_WITH_BLOCKER)
render(<SoloBoard {...DEFAULT_PROPS} />)
fireEvent.click(screen.getByText(/Start agents/))
await waitFor(() => screen.getByTestId('dialog'))
fireEvent.click(screen.getByText('Annuleer'))
await waitFor(() => {
expect(screen.queryByTestId('dialog')).not.toBeInTheDocument()
})
expect(mockEnqueueClaudeJobsBatchAction).not.toHaveBeenCalled()
})
it('preview error: shows toast without opening dialog', async () => {
mockPreviewEnqueueAllAction.mockResolvedValue({ error: 'Geen toegang' })
render(<SoloBoard {...DEFAULT_PROPS} />)
fireEvent.click(screen.getByText(/Start agents/))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('Geen toegang')
})
expect(screen.queryByTestId('dialog')).not.toBeInTheDocument()
})
})

View file

@ -1,84 +0,0 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom'
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import type { SoloTask } from '@/components/solo/solo-board'
vi.mock('@/components/ui/tooltip', () => ({
TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <span data-testid="tooltip-content">{children}</span>,
}))
vi.mock('@dnd-kit/core', () => ({
useDraggable: () => ({ attributes: {}, listeners: {}, setNodeRef: vi.fn(), transform: null, isDragging: false }),
}))
vi.mock('@/stores/solo-store', () => ({
useSoloStore: () => null,
}))
vi.mock('@/components/shared/code-badge', () => ({
CodeBadge: ({ code }: { code: string }) => <span data-testid="code-badge">{code}</span>,
}))
import { SoloTaskCard, SoloTaskCardOverlay } from '@/components/solo/solo-task-card'
function makeSoloTask(overrides: Partial<SoloTask> = {}): SoloTask {
return {
id: 'task-1',
title: 'Taak titel',
description: 'Omschrijving van de taak die langer is dan tachtig tekens voor test',
implementation_plan: null,
priority: 2,
sort_order: 0,
status: 'TO_DO',
verify_only: false,
verify_required: 'ALIGNED',
story_id: 'story-1',
story_code: 'ST-1',
story_title: 'Story titel',
task_code: 'T-1',
pbi_code: 'PBI-1',
pbi_title: 'PBI titel',
pbi_description: 'PBI omschrijving',
...overrides,
}
}
describe('SoloTaskCard', () => {
it('toont taaknaam, task_code, pbi_code, story_code, story_title', () => {
render(<SoloTaskCard task={makeSoloTask()} isDemo={false} onClick={vi.fn()} />)
expect(screen.getAllByText('Taak titel').length).toBeGreaterThan(0)
expect(screen.getAllByText('T-1').length).toBeGreaterThan(0)
expect(screen.getAllByText('PBI-1').length).toBeGreaterThan(0)
expect(screen.getByText('ST-1')).toBeInTheDocument()
expect(screen.getByText('Story titel')).toBeInTheDocument()
})
it('verbergt pbi_code badge als pbi_code null is', () => {
render(<SoloTaskCard task={makeSoloTask({ pbi_code: null })} isDemo={false} onClick={vi.fn()} />)
const badges = screen.queryAllByTestId('code-badge')
const codes = badges.map(b => b.textContent)
expect(codes).not.toContain('PBI-1')
})
it('verbergt description als description null is', () => {
const task = makeSoloTask({ description: null })
render(<SoloTaskCard task={task} isDemo={false} onClick={vi.fn()} />)
expect(screen.queryByText(/Omschrijving/)).toBeNull()
})
it('toont description als tekst', () => {
render(<SoloTaskCard task={makeSoloTask()} isDemo={false} onClick={vi.fn()} />)
expect(screen.getAllByText('Omschrijving van de taak die langer is dan tachtig tekens voor test').length).toBeGreaterThan(0)
})
})
describe('SoloTaskCardOverlay', () => {
it('toont taaknaam en codes zonder tooltip-wrappers', () => {
render(<SoloTaskCardOverlay task={makeSoloTask()} />)
expect(screen.getByText('Taak titel')).toBeInTheDocument()
expect(screen.getByText('T-1')).toBeInTheDocument()
expect(screen.getByText('PBI-1')).toBeInTheDocument()
expect(screen.queryAllByTestId('tooltip-content')).toHaveLength(0)
})
})

View file

@ -1,232 +0,0 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import type { SoloTask } from '@/components/solo/solo-board'
// Mock heavy UI primitives to avoid portal/JSDOM issues
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ open, children }: { open: boolean; onOpenChange?: () => void; children: React.ReactNode }) =>
open ? <div data-testid="dialog">{children}</div> : null,
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
}))
vi.mock('@/components/ui/tooltip', () => ({
TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ render: r, children }: { render?: React.ReactElement; children?: React.ReactNode }) =>
r ? <>{r}</> : <>{children}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <span data-testid="tooltip-content">{children}</span>,
}))
vi.mock('@/components/ui/textarea', () => ({
Textarea: (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => <textarea {...props} />,
}))
vi.mock('@/components/ui/badge', () => ({
Badge: ({ children, className }: { children: React.ReactNode; className?: string }) =>
<span className={className}>{children}</span>,
}))
vi.mock('@/components/ui/button', () => ({
Button: ({ children, onClick, disabled }: { children?: React.ReactNode; onClick?: () => void; disabled?: boolean }) =>
<button onClick={onClick} disabled={disabled}>{children}</button>,
}))
vi.mock('@/components/markdown', () => ({
Markdown: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/components/shared/demo-tooltip', () => ({
DemoTooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}))
vi.mock('next/link', () => ({
default: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a>,
}))
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
vi.mock('@/actions/claude-jobs', () => ({
enqueueClaudeJobAction: vi.fn(),
cancelClaudeJobAction: vi.fn(),
}))
vi.mock('@/lib/job-status-url', () => ({
getBranchUrl: (repoUrl: string, branch: string) => `${repoUrl}/tree/${branch}`,
}))
import { useSoloStore } from '@/stores/solo-store'
import { TaskDetailDialog } from '@/components/solo/task-detail-dialog'
const baseTask: SoloTask = {
id: 'task-1',
title: 'Test taak',
description: null,
implementation_plan: null,
priority: 2,
sort_order: 1,
status: 'TO_DO',
verify_only: false,
verify_required: 'ALIGNED_OR_PARTIAL',
story_id: 'story-1',
story_code: 'ST-100',
story_title: 'Test Story',
task_code: 'ST-100.1',
pbi_code: null,
pbi_title: null,
pbi_description: null,
}
const DEFAULT_PROPS = {
productId: 'prod-1',
isDemo: false,
repoUrl: 'https://github.com/user/repo',
onClose: vi.fn(),
}
function jobDone(verify_result?: string) {
return {
'task-1': {
job_id: 'j1',
task_id: 'task-1',
status: 'done' as const,
branch: 'feat/job-abc',
pushed_at: '2026-01-01T00:00:00Z',
...(verify_result && { verify_result: verify_result as import('@/stores/solo-store').VerifyResultApi }),
},
}
}
describe('TaskDetailDialog — verify_result display', () => {
beforeEach(() => {
vi.clearAllMocks()
useSoloStore.setState({
tasks: { 'task-1': baseTask },
claudeJobsByTaskId: {},
connectedWorkers: 0,
})
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })
})
it('shows no verify label when job has no verify_result', () => {
useSoloStore.setState({ claudeJobsByTaskId: jobDone() })
render(<TaskDetailDialog {...DEFAULT_PROPS} task={baseTask} />)
expect(screen.queryByText(/Aligned|Gedeeltelijk|Divergent|Geen wijzigingen/)).toBeNull()
})
it('shows Aligned label with text-status-done class for verify_result=aligned', () => {
useSoloStore.setState({ claudeJobsByTaskId: jobDone('aligned') })
render(<TaskDetailDialog {...DEFAULT_PROPS} task={baseTask} />)
const label = screen.getByText(/Aligned/)
expect(label).toHaveClass('text-status-done')
})
it('shows Gedeeltelijk label with text-warning class for verify_result=partial', () => {
useSoloStore.setState({ claudeJobsByTaskId: jobDone('partial') })
render(<TaskDetailDialog {...DEFAULT_PROPS} task={baseTask} />)
const label = screen.getByText(/Gedeeltelijk/)
expect(label).toHaveClass('text-warning')
})
it('shows Divergent label with text-error class for verify_result=divergent', () => {
useSoloStore.setState({ claudeJobsByTaskId: jobDone('divergent') })
render(<TaskDetailDialog {...DEFAULT_PROPS} task={baseTask} />)
const label = screen.getByText(/Divergent/)
expect(label).toHaveClass('text-error')
})
it('shows Geen wijzigingen label with text-muted-foreground class for verify_result=empty', () => {
useSoloStore.setState({ claudeJobsByTaskId: jobDone('empty') })
render(<TaskDetailDialog {...DEFAULT_PROPS} task={baseTask} />)
const label = screen.getByText(/Geen wijzigingen/)
expect(label).toHaveClass('text-muted-foreground')
})
})
describe('TaskDetailDialog — PR link display', () => {
beforeEach(() => {
vi.clearAllMocks()
useSoloStore.setState({
tasks: { 'task-1': baseTask },
claudeJobsByTaskId: {},
connectedWorkers: 0,
})
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })
})
it('shows "Open PR" link when pr_url is set', () => {
useSoloStore.setState({
claudeJobsByTaskId: {
'task-1': {
job_id: 'j1',
task_id: 'task-1',
status: 'done',
branch: 'feat/job-abc',
pushed_at: '2026-01-01T00:00:00Z',
pr_url: 'https://github.com/org/repo/pull/42',
},
},
})
render(<TaskDetailDialog {...DEFAULT_PROPS} task={baseTask} />)
const link = screen.getByRole('link', { name: /Open PR/i })
expect(link).toHaveAttribute('href', 'https://github.com/org/repo/pull/42')
})
it('shows "Open op GitHub" branch link when pushed_at is set but no pr_url', () => {
useSoloStore.setState({
claudeJobsByTaskId: {
'task-1': {
job_id: 'j1',
task_id: 'task-1',
status: 'done',
branch: 'feat/job-abc',
pushed_at: '2026-01-01T00:00:00Z',
},
},
})
render(<TaskDetailDialog {...DEFAULT_PROPS} task={baseTask} />)
expect(screen.queryByText(/Open PR/)).toBeNull()
const link = screen.getByRole('link', { name: /Open op GitHub/i })
expect(link).toHaveAttribute('href', expect.stringContaining('feat/job-abc'))
})
})
describe('TaskDetailDialog — verify_only checkbox', () => {
beforeEach(() => {
vi.clearAllMocks()
useSoloStore.setState({
tasks: { 'task-1': baseTask },
claudeJobsByTaskId: {},
connectedWorkers: 0,
})
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })
})
it('renders verify_only checkbox unchecked when task.verify_only is false', () => {
render(<TaskDetailDialog {...DEFAULT_PROPS} task={{ ...baseTask, verify_only: false }} />)
const checkbox = screen.getByRole('checkbox')
expect(checkbox).toHaveAttribute('aria-checked', 'false')
})
it('renders verify_only checkbox checked when task.verify_only is true', () => {
render(<TaskDetailDialog {...DEFAULT_PROPS} task={{ ...baseTask, verify_only: true }} />)
const checkbox = screen.getByRole('checkbox')
expect(checkbox).toHaveAttribute('aria-checked', 'true')
})
it('calls PATCH with verify_only toggled on click', async () => {
render(<TaskDetailDialog {...DEFAULT_PROPS} task={{ ...baseTask, verify_only: false }} />)
fireEvent.click(screen.getByRole('checkbox'))
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
'/api/tasks/task-1',
expect.objectContaining({
method: 'PATCH',
body: JSON.stringify({ verify_only: true }),
}),
)
})
})
it('reverts optimistic toggle when PATCH fails', async () => {
global.fetch = vi.fn().mockResolvedValue({ ok: false })
render(<TaskDetailDialog {...DEFAULT_PROPS} task={{ ...baseTask, verify_only: false }} />)
fireEvent.click(screen.getByRole('checkbox'))
await waitFor(() => {
expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'false')
})
})
})

View file

@ -1,234 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
vi.mock('@/actions/user-settings', () => ({
updateUserSettingsAction: vi.fn().mockResolvedValue({ success: true, settings: {} }),
}))
import { SplitPane } from '@/components/split-pane/split-pane'
import { useUserSettingsStore } from '@/stores/user-settings/store'
function seedPositions(key: string, positions: number[]) {
useUserSettingsStore.setState((s) => {
s.entities.settings = {
layout: {
splitPanePositions: { [key]: positions },
},
}
})
}
function resetStore() {
useUserSettingsStore.setState((s) => {
s.entities.settings = {}
s.context.hydrated = false
s.context.isDemo = false
})
}
describe('SplitPane', () => {
beforeEach(() => {
resetStore()
// Default: desktop viewport
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1440 })
window.dispatchEvent(new Event('resize'))
})
afterEach(() => {
vi.restoreAllMocks()
})
it('renders 2 panes', () => {
render(
<SplitPane
panes={[<div key="a">Pane A</div>, <div key="b">Pane B</div>]}
defaultSplit={[30, 70]}
cookieKey="test-2pane"
/>
)
expect(screen.getByText('Pane A')).toBeTruthy()
expect(screen.getByText('Pane B')).toBeTruthy()
})
it('renders 3 panes with 2 dividers', () => {
const { container } = render(
<SplitPane
panes={[
<div key="a">Left</div>,
<div key="b">Middle</div>,
<div key="c">Right</div>,
]}
defaultSplit={[28, 35, 37]}
cookieKey="test-3pane"
/>
)
expect(screen.getByText('Left')).toBeTruthy()
expect(screen.getByText('Middle')).toBeTruthy()
expect(screen.getByText('Right')).toBeTruthy()
// 2 dividers: cursor-col-resize elements
const dividers = container.querySelectorAll('.cursor-col-resize')
expect(dividers).toHaveLength(2)
})
it('restores splits from user-settings store on mount', () => {
seedPositions('test-restore', [40, 60])
const { container } = render(
<SplitPane
panes={[<div key="a">A</div>, <div key="b">B</div>]}
defaultSplit={[20, 80]}
cookieKey="test-restore"
/>
)
// Left pane should have width 40%, not the default 20%
const paneDiv = container.querySelector<HTMLElement>('[style*="40%"]')
expect(paneDiv).toBeTruthy()
})
it('falls back to defaultSplit when persisted positions are invalid', () => {
// Wrong number of values for a 2-pane layout
seedPositions('test-invalid', [10, 30, 60])
const { container } = render(
<SplitPane
panes={[<div key="a">A</div>, <div key="b">B</div>]}
defaultSplit={[25, 75]}
cookieKey="test-invalid"
/>
)
const paneDiv = container.querySelector<HTMLElement>('[style*="25%"]')
expect(paneDiv).toBeTruthy()
})
it('renders tabs on mobile viewport', () => {
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 768 })
window.dispatchEvent(new Event('resize'))
render(
<SplitPane
panes={[<div key="a">Content A</div>, <div key="b">Content B</div>]}
defaultSplit={[50, 50]}
cookieKey="test-mobile"
tabLabels={['Tab A', 'Tab B']}
/>
)
expect(screen.getByText('Tab A')).toBeTruthy()
expect(screen.getByText('Tab B')).toBeTruthy()
// Only first tab content visible by default
expect(screen.getByText('Content A')).toBeTruthy()
expect(screen.queryByText('Content B')).toBeNull()
})
it('switches tab content on mobile', () => {
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 })
window.dispatchEvent(new Event('resize'))
render(
<SplitPane
panes={[<div key="a">Content A</div>, <div key="b">Content B</div>]}
defaultSplit={[50, 50]}
cookieKey="test-mobile-switch"
tabLabels={['Tab A', 'Tab B']}
/>
)
// Click second tab
fireEvent.click(screen.getByText('Tab B'))
expect(screen.queryByText('Content A')).toBeNull()
expect(screen.getByText('Content B')).toBeTruthy()
})
it('back button not visible on tab 0 in mobile', () => {
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 })
window.dispatchEvent(new Event('resize'))
render(
<SplitPane
panes={[<div key="a">A</div>, <div key="b">B</div>, <div key="c">C</div>]}
defaultSplit={[33, 33, 34]}
cookieKey="test-back-hidden"
tabLabels={['T1', 'T2', 'T3']}
/>
)
// On tab 0, no back button
expect(screen.queryByLabelText('Terug')).toBeNull()
})
it('back button visible on tab > 0 and navigates back', () => {
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 })
window.dispatchEvent(new Event('resize'))
render(
<SplitPane
panes={[<div key="a">A</div>, <div key="b">B</div>, <div key="c">C</div>]}
defaultSplit={[33, 33, 34]}
cookieKey="test-back-nav"
tabLabels={['T1', 'T2', 'T3']}
/>
)
// Switch to tab 2
fireEvent.click(screen.getByText('T3'))
expect(screen.getByText('C')).toBeTruthy()
expect(screen.getByLabelText('Terug')).toBeTruthy()
// Click back → tab 1
fireEvent.click(screen.getByLabelText('Terug'))
expect(screen.getByText('B')).toBeTruthy()
// Click back again → tab 0, no back button
fireEvent.click(screen.getByLabelText('Terug'))
expect(screen.getByText('A')).toBeTruthy()
expect(screen.queryByLabelText('Terug')).toBeNull()
})
it('controlled activeTab prop switches the active pane', () => {
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 })
window.dispatchEvent(new Event('resize'))
const { rerender } = render(
<SplitPane
panes={[<div key="a">A</div>, <div key="b">B</div>, <div key="c">C</div>]}
defaultSplit={[33, 33, 34]}
cookieKey="test-controlled"
tabLabels={['T1', 'T2', 'T3']}
activeTab={0}
onActiveTabChange={vi.fn()}
/>
)
expect(screen.getByText('A')).toBeTruthy()
rerender(
<SplitPane
panes={[<div key="a">A</div>, <div key="b">B</div>, <div key="c">C</div>]}
defaultSplit={[33, 33, 34]}
cookieKey="test-controlled"
tabLabels={['T1', 'T2', 'T3']}
activeTab={2}
onActiveTabChange={vi.fn()}
/>
)
expect(screen.getByText('C')).toBeTruthy()
})
it('does not render dividers on mobile', () => {
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 })
window.dispatchEvent(new Event('resize'))
const { container } = render(
<SplitPane
panes={[<div key="a">A</div>, <div key="b">B</div>]}
defaultSplit={[50, 50]}
cookieKey="test-no-dividers"
/>
)
const dividers = container.querySelectorAll('.cursor-col-resize')
expect(dividers).toHaveLength(0)
})
})

View file

@ -1,119 +0,0 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/actions/tasks', () => ({
saveTask: vi.fn(),
deleteTask: vi.fn(),
}))
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
import { SprintTaskDialogMount } from '@/components/sprint/sprint-task-dialog-mount'
import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store'
import type { SprintWorkspaceTaskDetail } from '@/stores/sprint-workspace/types'
const TASK_DETAIL: SprintWorkspaceTaskDetail = {
id: 't1',
code: 'T-1',
title: 'Mijn taak',
description: 'Beschrijving',
priority: 2,
sort_order: 1,
status: 'in_progress',
story_id: 'story-1',
sprint_id: 'sprint-1',
created_at: new Date('2026-01-15'),
_detail: true,
implementation_plan: 'Stap 1\nStap 2',
}
function resetStore() {
useSprintWorkspaceStore.setState((s) => {
s.context.activeProduct = null
s.context.activeSprintId = null
s.context.activeStoryId = null
s.context.activeTaskId = null
s.entities.sprintsById = {}
s.entities.storiesById = {}
s.entities.tasksById = {}
s.relations.sprintIdsByProduct = {}
s.relations.storyIdsBySprint = {}
s.relations.taskIdsByStory = {}
s.loading.loadedProductSprintsIds = {}
s.loading.loadingProductId = null
s.loading.loadedSprintIds = {}
s.loading.loadingSprintId = null
s.loading.loadedStoryIds = {}
s.loading.loadedTaskIds = {}
s.loading.activeRequestId = null
s.pendingMutations = {}
})
}
beforeEach(() => {
resetStore()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('SprintTaskDialogMount', () => {
it('rendert niets wanneer er geen active task is', () => {
const { container } = render(
<SprintTaskDialogMount productId="p1" isDemo={false} />,
)
expect(container.textContent).toBe('')
})
it('rendert niets wanneer active task geen _detail heeft', () => {
useSprintWorkspaceStore.setState((s) => {
s.entities.tasksById['t1'] = {
id: 't1',
code: 'T-1',
title: 'Mijn taak',
description: null,
priority: 2,
sort_order: 1,
status: 'todo',
story_id: 'story-1',
sprint_id: 'sprint-1',
created_at: new Date(),
}
s.context.activeTaskId = 't1'
})
const { container } = render(
<SprintTaskDialogMount productId="p1" isDemo={false} />,
)
expect(container.textContent).toBe('')
})
it('rendert TaskDialog met titel "Taak bewerken" wanneer detail aanwezig is', () => {
useSprintWorkspaceStore.setState((s) => {
s.entities.tasksById['t1'] = TASK_DETAIL
s.context.activeTaskId = 't1'
})
render(<SprintTaskDialogMount productId="p1" isDemo={false} />)
expect(screen.getByText('Taak bewerken')).toBeTruthy()
expect((screen.getByLabelText(/Titel/) as HTMLInputElement).value).toBe('Mijn taak')
})
it('clear activeTaskId wanneer Annuleren wordt geklikt', async () => {
useSprintWorkspaceStore.setState((s) => {
s.entities.tasksById['t1'] = TASK_DETAIL
s.context.activeTaskId = 't1'
})
render(<SprintTaskDialogMount productId="p1" isDemo={false} />)
fireEvent.click(screen.getByRole('button', { name: 'Annuleren' }))
await waitFor(() => {
expect(useSprintWorkspaceStore.getState().context.activeTaskId).toBeNull()
})
})
})

View file

@ -1,57 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest'
import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut'
function makeEvent(opts: Partial<KeyboardEvent>) {
return {
metaKey: false,
ctrlKey: false,
key: '',
preventDefault: vi.fn(),
...opts,
} as unknown as React.KeyboardEvent
}
describe('useDialogSubmitShortcut', () => {
it('triggert submit op Cmd+Enter', () => {
const submit = vi.fn()
const handler = useDialogSubmitShortcut(submit)
const e = makeEvent({ metaKey: true, key: 'Enter' })
handler(e)
expect(submit).toHaveBeenCalledTimes(1)
expect(e.preventDefault).toHaveBeenCalled()
})
it('triggert submit op Ctrl+Enter', () => {
const submit = vi.fn()
const handler = useDialogSubmitShortcut(submit)
const e = makeEvent({ ctrlKey: true, key: 'Enter' })
handler(e)
expect(submit).toHaveBeenCalledTimes(1)
})
it('triggert NIET op Enter zonder modifier', () => {
const submit = vi.fn()
const handler = useDialogSubmitShortcut(submit)
const e = makeEvent({ key: 'Enter' })
handler(e)
expect(submit).not.toHaveBeenCalled()
expect(e.preventDefault).not.toHaveBeenCalled()
})
it('triggert NIET op Cmd+andere toets', () => {
const submit = vi.fn()
const handler = useDialogSubmitShortcut(submit)
const e = makeEvent({ metaKey: true, key: 'a' })
handler(e)
expect(submit).not.toHaveBeenCalled()
})
})

View file

@ -1,50 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useDirtyCloseGuard } from '@/components/shared/use-dirty-close-guard'
describe('useDirtyCloseGuard', () => {
it('sluit direct als form niet dirty is', () => {
const onClose = vi.fn()
const { result } = renderHook(() => useDirtyCloseGuard(false, onClose))
act(() => result.current.attemptClose())
expect(onClose).toHaveBeenCalledTimes(1)
expect(result.current.confirmOpen).toBe(false)
})
it('opent confirm als form dirty is', () => {
const onClose = vi.fn()
const { result } = renderHook(() => useDirtyCloseGuard(true, onClose))
act(() => result.current.attemptClose())
expect(onClose).not.toHaveBeenCalled()
expect(result.current.confirmOpen).toBe(true)
})
it('confirmDiscard sluit confirm en roept onClose', () => {
const onClose = vi.fn()
const { result } = renderHook(() => useDirtyCloseGuard(true, onClose))
act(() => result.current.attemptClose())
expect(result.current.confirmOpen).toBe(true)
act(() => result.current.confirmDiscard())
expect(onClose).toHaveBeenCalledTimes(1)
expect(result.current.confirmOpen).toBe(false)
})
it('setConfirmOpen(false) annuleert zonder onClose te roepen', () => {
const onClose = vi.fn()
const { result } = renderHook(() => useDirtyCloseGuard(true, onClose))
act(() => result.current.attemptClose())
act(() => result.current.setConfirmOpen(false))
expect(onClose).not.toHaveBeenCalled()
expect(result.current.confirmOpen).toBe(false)
})
})

View file

@ -1,147 +0,0 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useJobsStore } from '@/stores/jobs-store'
import useJobsRealtime from '@/hooks/use-jobs-realtime'
type Listener = (event: { data: string }) => void
class MockEventSource {
static instance: MockEventSource | null = null
private listeners: Record<string, Listener[]> = {}
onerror: (() => void) | null = null
constructor(_url: string) {
MockEventSource.instance = this
}
addEventListener(type: string, listener: Listener) {
if (!this.listeners[type]) this.listeners[type] = []
this.listeners[type].push(listener)
}
dispatch(type: string, data: unknown) {
for (const l of this.listeners[type] ?? []) {
l({ data: JSON.stringify(data) })
}
}
close() {}
}
const fullJob = {
id: 'job-unknown-1',
kind: 'TASK_IMPLEMENTATION',
status: 'RUNNING',
taskCode: 'T-1',
taskTitle: 'Test',
ideaCode: null,
ideaTitle: null,
sprintGoal: null,
sprintCode: null,
productName: 'Scrum4Me',
productCode: null,
storyCode: null,
pbiCode: null,
modelId: null,
inputTokens: null,
outputTokens: null,
cacheReadTokens: null,
cacheWriteTokens: null,
costUsd: null,
branch: null,
prUrl: null,
error: null,
summary: null,
description: null,
verifyResult: null,
startedAt: null,
finishedAt: null,
createdAt: new Date('2026-01-01'),
sprintRunId: null,
}
beforeEach(() => {
vi.stubGlobal('EventSource', MockEventSource)
MockEventSource.instance = null
// Lege store
useJobsStore.setState({ activeJobs: [], doneJobs: [], selectedJobId: null })
// fetch resolveert naar de volledige job
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation(async () => ({
ok: true,
json: async () => fullJob,
}))
)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('useJobsRealtime: fetch-on-unknown', () => {
it('haalt onbekende job op via REST bij message-event', async () => {
renderHook(() => useJobsRealtime())
const es = MockEventSource.instance!
// Dispatch twee events met hetzelfde onbekende job_id gelijktijdig
act(() => {
es.dispatch('message', { job_id: 'job-unknown-1', status: 'RUNNING' })
es.dispatch('message', { job_id: 'job-unknown-1', status: 'RUNNING' })
})
// Wacht op alle microtasks / fetch-promises
await act(async () => {
await Promise.resolve()
})
expect(fetch).toHaveBeenCalledTimes(1)
expect(fetch).toHaveBeenCalledWith('/api/jobs/job-unknown-1')
const { activeJobs } = useJobsStore.getState()
expect(activeJobs.some(j => j.id === 'job-unknown-1')).toBe(true)
expect(activeJobs.find(j => j.id === 'job-unknown-1')?.taskTitle).toBe('Test')
})
it('gebruikt partial-upsert voor bekende jobs bij message-event', async () => {
// Zet een bekende job in de store
useJobsStore.setState({
activeJobs: [{ ...fullJob, id: 'job-known-1', status: 'QUEUED' } as never],
doneJobs: [],
selectedJobId: null,
})
renderHook(() => useJobsRealtime())
const es = MockEventSource.instance!
act(() => {
es.dispatch('message', { job_id: 'job-known-1', status: 'RUNNING', branch: 'feat/x' })
})
await act(async () => { await Promise.resolve() })
expect(fetch).not.toHaveBeenCalled()
const { activeJobs } = useJobsStore.getState()
expect(activeJobs.find(j => j.id === 'job-known-1')?.status).toBe('RUNNING')
})
it('haalt onbekende job op via REST bij jobs_initial-event', async () => {
renderHook(() => useJobsRealtime())
const es = MockEventSource.instance!
act(() => {
es.dispatch('jobs_initial', [{ job_id: 'job-unknown-1', status: 'RUNNING' }])
})
await act(async () => { await Promise.resolve() })
expect(fetch).toHaveBeenCalledTimes(1)
expect(fetch).toHaveBeenCalledWith('/api/jobs/job-unknown-1')
const { activeJobs } = useJobsStore.getState()
expect(activeJobs.some(j => j.id === 'job-unknown-1')).toBe(true)
})
})

View file

@ -1,190 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/lib/prisma', () => ({
prisma: {
sprint: { findFirst: vi.fn() },
user: {
findUnique: vi.fn(),
update: vi.fn().mockResolvedValue({}),
},
$executeRaw: vi.fn().mockResolvedValue(1),
},
}))
import { prisma } from '@/lib/prisma'
import type { UserSettings } from '@/lib/user-settings'
import {
clearActiveSprintInSettings,
readStoredActiveSprintState,
resolveActiveSprint,
} from '@/lib/active-sprint'
const mockPrisma = prisma as unknown as {
sprint: { findFirst: ReturnType<typeof vi.fn> }
user: {
findUnique: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
$executeRaw: ReturnType<typeof vi.fn>
}
function withSettings(settings: UserSettings) {
mockPrisma.user.findUnique.mockResolvedValueOnce({ settings })
}
describe('readStoredActiveSprintState', () => {
it('returns unset when activeSprints map is absent', () => {
expect(readStoredActiveSprintState({}, 'p1')).toEqual({ kind: 'unset' })
})
it('returns unset when productId key is absent', () => {
const settings: UserSettings = {
layout: { activeSprints: { p2: 'sprint-2' } },
}
expect(readStoredActiveSprintState(settings, 'p1')).toEqual({
kind: 'unset',
})
})
it('returns cleared when key is present with null value', () => {
const settings: UserSettings = {
layout: { activeSprints: { p1: null } },
}
expect(readStoredActiveSprintState(settings, 'p1')).toEqual({
kind: 'cleared',
})
})
it('returns set when key is present with string value', () => {
const settings: UserSettings = {
layout: { activeSprints: { p1: 'sprint-1' } },
}
expect(readStoredActiveSprintState(settings, 'p1')).toEqual({
kind: 'set',
sprintId: 'sprint-1',
})
})
})
describe('resolveActiveSprint', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns null without fallback when key is explicitly null (cleared)', async () => {
withSettings({ layout: { activeSprints: { p1: null } } })
const result = await resolveActiveSprint('p1', 'user-1')
expect(result).toBeNull()
expect(mockPrisma.sprint.findFirst).not.toHaveBeenCalled()
})
it('returns the stored sprint when key is set and sprint exists', async () => {
withSettings({ layout: { activeSprints: { p1: 'sprint-1' } } })
mockPrisma.sprint.findFirst.mockResolvedValueOnce({
id: 'sprint-1',
code: 'SP-1',
status: 'OPEN',
})
const result = await resolveActiveSprint('p1', 'user-1')
expect(result).toEqual({ id: 'sprint-1', code: 'SP-1', status: 'OPEN' })
expect(mockPrisma.sprint.findFirst).toHaveBeenCalledTimes(1)
})
it('falls back when stored sprint is not found in DB', async () => {
withSettings({ layout: { activeSprints: { p1: 'stale-id' } } })
mockPrisma.sprint.findFirst
.mockResolvedValueOnce(null) // stored lookup misses
.mockResolvedValueOnce({ id: 'sprint-open', code: 'SP-O', status: 'OPEN' })
const result = await resolveActiveSprint('p1', 'user-1')
expect(result).toEqual({
id: 'sprint-open',
code: 'SP-O',
status: 'OPEN',
})
})
it('falls back to first OPEN sprint when key is absent', async () => {
withSettings({})
mockPrisma.sprint.findFirst.mockResolvedValueOnce({
id: 'sprint-open',
code: 'SP-O',
status: 'OPEN',
})
const result = await resolveActiveSprint('p1', 'user-1')
expect(result).toEqual({
id: 'sprint-open',
code: 'SP-O',
status: 'OPEN',
})
})
it('falls back to recent CLOSED sprint when no OPEN exists', async () => {
withSettings({})
mockPrisma.sprint.findFirst
.mockResolvedValueOnce(null) // no OPEN
.mockResolvedValueOnce({
id: 'sprint-closed',
code: 'SP-C',
status: 'CLOSED',
})
const result = await resolveActiveSprint('p1', 'user-1')
expect(result).toEqual({
id: 'sprint-closed',
code: 'SP-C',
status: 'CLOSED',
})
})
it('returns null when key absent and no sprints exist', async () => {
withSettings({})
mockPrisma.sprint.findFirst.mockResolvedValue(null)
const result = await resolveActiveSprint('p1', 'user-1')
expect(result).toBeNull()
})
})
describe('clearActiveSprintInSettings', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('writes null instead of deleting the key', async () => {
withSettings({
layout: { activeSprints: { p1: 'sprint-1', p2: 'sprint-2' } },
})
await clearActiveSprintInSettings('user-1', 'p1')
expect(mockPrisma.user.update).toHaveBeenCalledTimes(1)
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
data: { settings: UserSettings }
}
expect(updateArg.data.settings.layout?.activeSprints).toEqual({
p1: null,
p2: 'sprint-2',
})
})
it('adds the key with null when previously unset', async () => {
withSettings({})
await clearActiveSprintInSettings('user-1', 'p1')
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
data: { settings: UserSettings }
}
expect(updateArg.data.settings.layout?.activeSprints).toEqual({ p1: null })
})
})

View file

@ -1,53 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
const getSessionMock = vi.fn()
const isPairedSessionExpiredMock = vi.fn()
const redirectMock = vi.fn(() => { throw new Error('REDIRECT_CALLED') })
const prismaUserRoleFindFirstMock = vi.fn()
vi.mock('@/lib/auth', () => ({ getSession: getSessionMock }))
vi.mock('@/lib/auth/pairing', () => ({ isPairedSessionExpired: isPairedSessionExpiredMock }))
vi.mock('next/navigation', () => ({ redirect: redirectMock }))
vi.mock('@/lib/prisma', () => ({
prisma: { userRole: { findFirst: prismaUserRoleFindFirstMock } },
}))
describe('requireSession', () => {
beforeEach(() => {
getSessionMock.mockReset()
isPairedSessionExpiredMock.mockReset()
redirectMock.mockClear()
})
afterEach(() => {
vi.resetModules()
})
it('redirect /login als userId ontbreekt', async () => {
getSessionMock.mockResolvedValue({ userId: undefined, destroy: vi.fn() })
isPairedSessionExpiredMock.mockReturnValue(false)
const { requireSession } = await import('@/lib/auth-guard')
await expect(requireSession()).rejects.toThrow('REDIRECT_CALLED')
expect(redirectMock).toHaveBeenCalledWith('/login')
})
it('vernietigt + redirect /login als paired-sessie verlopen is', async () => {
const destroy = vi.fn().mockResolvedValue(undefined)
getSessionMock.mockResolvedValue({ userId: 'u1', destroy })
isPairedSessionExpiredMock.mockReturnValue(true)
const { requireSession } = await import('@/lib/auth-guard')
await expect(requireSession()).rejects.toThrow('REDIRECT_CALLED')
expect(destroy).toHaveBeenCalled()
expect(redirectMock).toHaveBeenCalledWith('/login')
})
it('geeft sessie terug als alles ok', async () => {
const sess = { userId: 'u1', destroy: vi.fn() }
getSessionMock.mockResolvedValue(sess)
isPairedSessionExpiredMock.mockReturnValue(false)
const { requireSession } = await import('@/lib/auth-guard')
const result = await requireSession()
expect(result).toBe(sess)
expect(redirectMock).not.toHaveBeenCalled()
})
})

View file

@ -1,52 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
STATUS_COLORS,
PRIORITY_COLORS,
VERIFY_COLORS,
JOB_STATUS_COLORS,
SERIES_COLORS,
} from '@/lib/chart-colors'
describe('chart-colors', () => {
it('STATUS_COLORS has all TaskStatus keys and non-empty values', () => {
const keys: (keyof typeof STATUS_COLORS)[] = ['TO_DO', 'IN_PROGRESS', 'REVIEW', 'DONE']
for (const key of keys) {
expect(STATUS_COLORS[key]).toBeTruthy()
expect(typeof STATUS_COLORS[key]).toBe('string')
}
})
it('PRIORITY_COLORS has keys 1-4 with non-empty values', () => {
const keys = [1, 2, 3, 4] as const
for (const key of keys) {
expect(PRIORITY_COLORS[key]).toBeTruthy()
expect(typeof PRIORITY_COLORS[key]).toBe('string')
}
})
it('VERIFY_COLORS has all VerifyResult keys and non-empty values', () => {
const keys: (keyof typeof VERIFY_COLORS)[] = ['ALIGNED', 'PARTIAL', 'EMPTY', 'DIVERGENT']
for (const key of keys) {
expect(VERIFY_COLORS[key]).toBeTruthy()
expect(typeof VERIFY_COLORS[key]).toBe('string')
}
})
it('JOB_STATUS_COLORS has all ClaudeJobStatus keys and non-empty values', () => {
const keys: (keyof typeof JOB_STATUS_COLORS)[] = [
'queued', 'claimed', 'running', 'done', 'failed', 'cancelled', 'skipped',
]
for (const key of keys) {
expect(JOB_STATUS_COLORS[key]).toBeTruthy()
expect(typeof JOB_STATUS_COLORS[key]).toBe('string')
}
})
it('SERIES_COLORS has 5 non-empty entries', () => {
expect(SERIES_COLORS).toHaveLength(5)
for (const color of SERIES_COLORS) {
expect(color).toBeTruthy()
expect(typeof color).toBe('string')
}
})
})

View file

@ -1,25 +0,0 @@
import { describe, it, expect } from 'vitest'
import { parseCodeNumber } from '@/lib/code'
describe('parseCodeNumber', () => {
it('parses a standard story code', () => {
expect(parseCodeNumber('ST-001')).toBe(1)
})
it('parses a task code', () => {
expect(parseCodeNumber('T-42')).toBe(42)
})
it('parses a large number', () => {
expect(parseCodeNumber('ST-1000')).toBe(1000)
})
it('returns MAX_SAFE_INTEGER for a code with no trailing digits', () => {
expect(parseCodeNumber('FOO')).toBe(Number.MAX_SAFE_INTEGER)
})
it('returns MAX_SAFE_INTEGER for an empty string', () => {
expect(parseCodeNumber('')).toBe(Number.MAX_SAFE_INTEGER)
})
})

View file

@ -1,23 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
import { debugProps } from '@/lib/debug'
describe('debugProps', () => {
it('returns data-debug-id attr in dev mode', () => {
const result = debugProps('sprint-board', 'SprintBoard', 'components/sprint/sprint-board.tsx')
expect(result).toEqual({
'data-debug-id': 'sprint-board',
})
})
it('returns empty object in production mode', () => {
const original = process.env.NODE_ENV
try {
vi.stubEnv('NODE_ENV', 'production')
const result = debugProps('sprint-board', 'SprintBoard', 'components/sprint/sprint-board.tsx')
expect(result).toEqual({})
} finally {
vi.stubEnv('NODE_ENV', original ?? 'test')
}
})
})

View file

@ -1,21 +0,0 @@
import { describe, it, expect } from 'vitest'
import { formatIdeaCode } from '@/lib/idea-code'
describe('formatIdeaCode', () => {
it('pads to 3 digits', () => {
expect(formatIdeaCode(1)).toBe('IDEA-001')
expect(formatIdeaCode(42)).toBe('IDEA-042')
expect(formatIdeaCode(999)).toBe('IDEA-999')
})
it('does not truncate beyond pad-width', () => {
expect(formatIdeaCode(1000)).toBe('IDEA-1000')
expect(formatIdeaCode(99999)).toBe('IDEA-99999')
})
})
// Integration-style concurrency-test op nextIdeaCode is in
// __tests__/integration/ tests die de echte DB raken (zie M12 verificatie-stap).
// Hier alleen de pure formatter; de increment-logica leunt op Prisma's
// row-lock in $transaction die we per-database vertrouwen.

View file

@ -1,138 +0,0 @@
import { describe, it, expect } from 'vitest'
import { parsePlanMd } from '@/lib/idea-plan-parser'
const VALID = `---
pbi:
title: Test PBI
priority: 2
stories:
- title: Eerste flow
priority: 2
tasks:
- title: Setup
priority: 2
implementation_plan: |
1. Doe X
2. Doe Y
---
# Overwegingen
Dit is de body, niet geparsed.
`
describe('parsePlanMd', () => {
it('parses a valid plan', () => {
const r = parsePlanMd(VALID)
expect(r.ok).toBe(true)
if (r.ok) {
expect(r.plan.pbi.title).toBe('Test PBI')
expect(r.plan.stories).toHaveLength(1)
expect(r.plan.stories[0].tasks).toHaveLength(1)
expect(r.plan.stories[0].tasks[0].implementation_plan).toContain('Doe X')
expect(r.body).toContain('# Overwegingen')
}
})
it('rejects when frontmatter is missing', () => {
const r = parsePlanMd('# Just markdown\n\nNo frontmatter here.')
expect(r.ok).toBe(false)
if (!r.ok) {
expect(r.errors[0].line).toBe(1)
expect(r.errors[0].message).toMatch(/frontmatter/i)
}
})
it('reports yaml syntax error with line info', () => {
const broken = `---
pbi:
title: Test
priority: [unclosed
stories:
- foo
---
body
`
const r = parsePlanMd(broken)
expect(r.ok).toBe(false)
if (!r.ok) {
expect(r.errors[0].message.length).toBeGreaterThan(0)
}
})
it('hints when markdown sneaks into frontmatter', () => {
// "1. **...**: [unclosed" triggers a YAMLParseError at the markdown line
// (plain-list-with-bold parses as valid YAML without an unclosed flow)
const broken = `---
pbi:
title: Test
priority: 2
stories:
1. **Toggle zichtbaar in productie**: [unclosed
---
body
`
const r = parsePlanMd(broken)
expect(r.ok).toBe(false)
if (!r.ok) {
expect(r.errors[0].hint).toMatch(/markdown/i)
expect(r.errors[0].line).toBeGreaterThan(1)
}
})
it('omits hint for non-markdown yaml errors', () => {
const broken = `---
pbi:
title: Test
priority: [unclosed
stories:
- foo
---
`
const r = parsePlanMd(broken)
expect(r.ok).toBe(false)
if (!r.ok) expect(r.errors[0].hint).toBeUndefined()
})
it('reports schema-validation error when pbi-section missing', () => {
const noPbi = `---
stories:
- title: x
priority: 2
tasks:
- title: y
priority: 2
---
body
`
const r = parsePlanMd(noPbi)
expect(r.ok).toBe(false)
if (!r.ok) {
expect(r.errors.some((e) => e.message.includes('pbi'))).toBe(true)
}
})
it('rejects empty stories array', () => {
const noStories = `---
pbi:
title: x
priority: 2
stories: []
---
body
`
const r = parsePlanMd(noStories)
expect(r.ok).toBe(false)
})
it('handles CRLF line endings', () => {
const crlf = VALID.replace(/\n/g, '\r\n')
const r = parsePlanMd(crlf)
expect(r.ok).toBe(true)
})
})

View file

@ -1,148 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
ideaCreateSchema,
ideaUpdateSchema,
ideaPlanMdFrontmatterSchema,
} from '@/lib/schemas/idea'
describe('ideaCreateSchema', () => {
it('accepts minimal valid input', () => {
const r = ideaCreateSchema.safeParse({ title: 'Plant-watering reminder' })
expect(r.success).toBe(true)
})
it('trims and enforces non-empty title', () => {
const r = ideaCreateSchema.safeParse({ title: ' ' })
expect(r.success).toBe(false)
})
it('rejects oversized title and description', () => {
expect(ideaCreateSchema.safeParse({ title: 'x'.repeat(201) }).success).toBe(false)
expect(
ideaCreateSchema.safeParse({ title: 'ok', description: 'x'.repeat(4001) }).success,
).toBe(false)
})
it('accepts cuid-like product_id', () => {
const r = ideaCreateSchema.safeParse({
title: 'Idee',
product_id: 'cmohrysyj0000rd17clnjy4tc',
})
expect(r.success).toBe(true)
})
it('rejects non-cuid product_id', () => {
const r = ideaCreateSchema.safeParse({ title: 'Idee', product_id: 'not-a-cuid' })
expect(r.success).toBe(false)
})
})
describe('ideaUpdateSchema', () => {
it('allows empty object (no-op update)', () => {
expect(ideaUpdateSchema.safeParse({}).success).toBe(true)
})
it('allows partial title update', () => {
expect(ideaUpdateSchema.safeParse({ title: 'Updated' }).success).toBe(true)
})
})
describe('ideaPlanMdFrontmatterSchema', () => {
const validPlan = {
pbi: { title: 'Test PBI', priority: 2 },
stories: [
{
title: 'Eerste flow',
priority: 2,
tasks: [
{ title: 'Setup', priority: 2, implementation_plan: '1. Doe X' },
],
},
],
}
it('accepts a minimal valid plan', () => {
expect(ideaPlanMdFrontmatterSchema.safeParse(validPlan).success).toBe(true)
})
it('requires at least one story', () => {
const r = ideaPlanMdFrontmatterSchema.safeParse({ ...validPlan, stories: [] })
expect(r.success).toBe(false)
})
it('requires at least one task per story', () => {
const r = ideaPlanMdFrontmatterSchema.safeParse({
...validPlan,
stories: [{ ...validPlan.stories[0], tasks: [] }],
})
expect(r.success).toBe(false)
})
it('validates priority bounds 1-4', () => {
expect(
ideaPlanMdFrontmatterSchema.safeParse({
...validPlan,
pbi: { ...validPlan.pbi, priority: 5 },
}).success,
).toBe(false)
expect(
ideaPlanMdFrontmatterSchema.safeParse({
...validPlan,
pbi: { ...validPlan.pbi, priority: 0 },
}).success,
).toBe(false)
})
it('accepts optional verify_required + verify_only', () => {
const r = ideaPlanMdFrontmatterSchema.safeParse({
...validPlan,
stories: [
{
...validPlan.stories[0],
tasks: [
{
title: 'Verify-only task',
priority: 2,
verify_required: 'ALIGNED_OR_PARTIAL',
verify_only: true,
},
],
},
],
})
expect(r.success).toBe(true)
})
it('rejects invalid verify_required enum', () => {
const r = ideaPlanMdFrontmatterSchema.safeParse({
...validPlan,
stories: [
{
...validPlan.stories[0],
tasks: [
{ title: 't', priority: 2, verify_required: 'INVALID' },
],
},
],
})
expect(r.success).toBe(false)
})
it('accepts plan with task.priority omitted (inherits story-priority via materialize)', () => {
const r = ideaPlanMdFrontmatterSchema.safeParse({
...validPlan,
stories: [
{
title: 'Story zonder task-priorities',
priority: 2,
tasks: [
{ title: 'Taak 1' }, // geen priority — moet geaccepteerd
{ title: 'Taak 2', verify_required: 'ALIGNED' },
],
},
],
})
expect(r.success).toBe(true)
})
})

View file

@ -1,108 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
ideaStatusToApi,
ideaStatusFromApi,
canTransition,
isIdeaEditable,
isGrillMdEditable,
isPlanMdEditable,
IDEA_STATUS_API_VALUES,
} from '@/lib/idea-status'
describe('idea-status mappers', () => {
it('round-trips every API value', () => {
for (const api of IDEA_STATUS_API_VALUES) {
const db = ideaStatusFromApi(api)
expect(db).not.toBeNull()
expect(ideaStatusToApi(db!)).toBe(api)
}
})
it('returns null for invalid input', () => {
expect(ideaStatusFromApi('NOT_A_STATUS')).toBeNull()
})
it('is case-insensitive on the API side', () => {
expect(ideaStatusFromApi('PLAN_READY')).toBe('PLAN_READY')
expect(ideaStatusFromApi('Plan_Ready')).toBe('PLAN_READY')
})
})
describe('canTransition', () => {
it('allows valid forward transitions', () => {
expect(canTransition('DRAFT', 'GRILLING')).toBe(true)
expect(canTransition('GRILLING', 'GRILLED')).toBe(true)
expect(canTransition('GRILLED', 'PLANNING')).toBe(true)
expect(canTransition('PLANNING', 'PLAN_READY')).toBe(true)
expect(canTransition('PLAN_READY', 'PLANNED')).toBe(true)
})
it('allows re-grill from GRILLED and PLAN_READY-ish states', () => {
expect(canTransition('GRILLED', 'GRILLING')).toBe(true)
expect(canTransition('PLAN_FAILED', 'PLANNING')).toBe(true)
expect(canTransition('PLAN_READY', 'GRILLING')).toBe(true)
})
it('allows fail-side transitions', () => {
expect(canTransition('GRILLING', 'GRILL_FAILED')).toBe(true)
expect(canTransition('PLANNING', 'PLAN_FAILED')).toBe(true)
})
it('allows recovery from failed states', () => {
expect(canTransition('GRILL_FAILED', 'GRILLING')).toBe(true)
expect(canTransition('PLAN_FAILED', 'GRILLED')).toBe(true)
})
it('allows PLANNED → PLAN_READY (relink) and PLANNED → GRILLING (re-grill)', () => {
expect(canTransition('PLANNED', 'PLAN_READY')).toBe(true)
expect(canTransition('PLANNED', 'GRILLING')).toBe(true)
expect(canTransition('PLANNED', 'DRAFT')).toBe(false)
})
it('canTransition to GRILLING from all statuses that allow re-grill', () => {
// GRILL_TRIGGERABLE_FROM in actions/ideas.ts — alle statussen die re-grill ondersteunen.
const regrill = ['DRAFT', 'GRILLED', 'GRILL_FAILED', 'PLAN_READY', 'PLANNED'] as const
for (const status of regrill) {
expect(canTransition(status, 'GRILLING')).toBe(true)
}
})
it('rejects invalid jumps', () => {
expect(canTransition('DRAFT', 'PLANNED')).toBe(false)
expect(canTransition('DRAFT', 'PLAN_READY')).toBe(false)
expect(canTransition('GRILLING', 'PLANNED')).toBe(false)
})
})
describe('isIdeaEditable', () => {
it('allows edit in non-running, non-PLANNED states', () => {
expect(isIdeaEditable('DRAFT')).toBe(true)
expect(isIdeaEditable('GRILLED')).toBe(true)
expect(isIdeaEditable('GRILL_FAILED')).toBe(true)
expect(isIdeaEditable('PLAN_FAILED')).toBe(true)
expect(isIdeaEditable('PLAN_READY')).toBe(true)
})
it('blocks edit while a job is running or after PLANNED', () => {
expect(isIdeaEditable('GRILLING')).toBe(false)
expect(isIdeaEditable('PLANNING')).toBe(false)
expect(isIdeaEditable('PLANNED')).toBe(false)
})
})
describe('isGrillMdEditable / isPlanMdEditable', () => {
it('grill_md only editable in GRILLED or PLAN_READY', () => {
expect(isGrillMdEditable('GRILLED')).toBe(true)
expect(isGrillMdEditable('PLAN_READY')).toBe(true)
expect(isGrillMdEditable('DRAFT')).toBe(false)
expect(isGrillMdEditable('PLANNED')).toBe(false)
})
it('plan_md only editable in PLAN_READY', () => {
expect(isPlanMdEditable('PLAN_READY')).toBe(true)
expect(isPlanMdEditable('GRILLED')).toBe(false)
expect(isPlanMdEditable('PLAN_FAILED')).toBe(false)
expect(isPlanMdEditable('PLANNED')).toBe(false)
})
})

View file

@ -1,82 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockQueryRaw } = vi.hoisted(() => ({ mockQueryRaw: vi.fn() }))
vi.mock('@/lib/prisma', () => ({
prisma: { $queryRaw: mockQueryRaw },
}))
import { getJobsPerDay } from '@/lib/insights/agent-throughput'
// Build a date string for N days ago (UTC)
function daysAgo(n: number): Date {
const d = new Date()
d.setUTCDate(d.getUTCDate() - n)
return d
}
function toUTCDate(d: Date): Date {
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()))
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('getJobsPerDay', () => {
it('returns a 14-day array zero-filled for missing days', async () => {
// Only 3 days have data; the rest should be 0
const day0 = toUTCDate(daysAgo(0))
const day3 = toUTCDate(daysAgo(3))
const day7 = toUTCDate(daysAgo(7))
const dayRows = [
{ day: day0, status: 'done', count: BigInt(2) },
{ day: day3, status: 'failed', count: BigInt(1) },
{ day: day7, status: 'done', count: BigInt(5) },
]
const kpiRows = [
{ today_count: BigInt(2), done_7d: BigInt(7), terminal_7d: BigInt(10), avg_seconds: 120 },
]
mockQueryRaw.mockResolvedValueOnce(dayRows).mockResolvedValueOnce(kpiRows)
const result = await getJobsPerDay('user-1')
expect(result.perDay).toHaveLength(14)
// All days should have zero counts except the three we seeded
const nonZero = result.perDay.filter(
d => d.done + d.failed + d.queued + d.claimed + d.running + d.cancelled + d.skipped > 0,
)
expect(nonZero).toHaveLength(3)
// Today's done count should be 2
const today = result.perDay[result.perDay.length - 1]
expect(today.done).toBe(2)
})
it('calculates KPIs correctly', async () => {
mockQueryRaw.mockResolvedValueOnce([]).mockResolvedValueOnce([
{ today_count: BigInt(3), done_7d: BigInt(7), terminal_7d: BigInt(10), avg_seconds: 90 },
])
const result = await getJobsPerDay('user-1')
expect(result.kpi.todayCount).toBe(3)
expect(result.kpi.successRate7d).toBe(0.7)
expect(result.kpi.avgDurationSeconds7d).toBe(90)
})
it('returns zero successRate and null avgDuration when no terminal jobs', async () => {
mockQueryRaw.mockResolvedValueOnce([]).mockResolvedValueOnce([
{ today_count: BigInt(0), done_7d: BigInt(0), terminal_7d: BigInt(0), avg_seconds: null },
])
const result = await getJobsPerDay('user-1')
expect(result.kpi.successRate7d).toBe(0)
expect(result.kpi.avgDurationSeconds7d).toBeNull()
})
})

View file

@ -1,82 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockStoryCount, mockTaskCount, mockTaskFindMany } = vi.hoisted(() => ({
mockStoryCount: vi.fn(),
mockTaskCount: vi.fn(),
mockTaskFindMany: vi.fn(),
}))
vi.mock('@/lib/prisma', () => ({
prisma: {
story: { count: mockStoryCount },
task: { count: mockTaskCount, findMany: mockTaskFindMany },
},
}))
vi.mock('@/lib/product-access', () => ({
productAccessFilter: () => ({ some: 'filter' }),
}))
import { getBacklogHealth } from '@/lib/insights/backlog-health'
function makeTask(id: string, daysAgo: number) {
const updatedAt = new Date(Date.now() - daysAgo * 86_400_000)
return {
id,
title: `Task ${id}`,
updated_at: updatedAt,
story: {
product: { id: 'prod-1', name: 'My Product' },
sprint: { sprint_goal: 'Sprint goal' },
},
}
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('getBacklogHealth', () => {
it('returns all zeros when backlog is healthy', async () => {
mockStoryCount.mockResolvedValue(0)
mockTaskCount.mockResolvedValue(0)
mockTaskFindMany.mockResolvedValue([])
const result = await getBacklogHealth('user-1')
expect(result.storiesWithoutAc).toBe(0)
expect(result.tasksWithoutPlan).toBe(0)
expect(result.stuckTasks).toEqual([])
})
it('returns counts and stuck tasks when everything is flagged', async () => {
mockStoryCount.mockResolvedValue(5)
mockTaskCount.mockResolvedValue(3)
mockTaskFindMany.mockResolvedValue([makeTask('t1', 10), makeTask('t2', 8)])
const result = await getBacklogHealth('user-1')
expect(result.storiesWithoutAc).toBe(5)
expect(result.tasksWithoutPlan).toBe(3)
expect(result.stuckTasks).toHaveLength(2)
expect(result.stuckTasks[0].taskId).toBe('t1')
expect(result.stuckTasks[0].daysStuck).toBeGreaterThanOrEqual(10)
expect(result.stuckTasks[0].productName).toBe('My Product')
expect(result.stuckTasks[0].sprintGoal).toBe('Sprint goal')
})
it('mixed: some counters non-zero, one stuck task, no sprint', async () => {
mockStoryCount.mockResolvedValue(2)
mockTaskCount.mockResolvedValue(0)
const task = makeTask('t3', 14)
task.story.sprint = null as unknown as { sprint_goal: string }
mockTaskFindMany.mockResolvedValue([task])
const result = await getBacklogHealth('user-1')
expect(result.storiesWithoutAc).toBe(2)
expect(result.tasksWithoutPlan).toBe(0)
expect(result.stuckTasks[0].sprintGoal).toBeNull()
expect(result.stuckTasks[0].daysStuck).toBeGreaterThanOrEqual(14)
})
})

View file

@ -1,57 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
vi.mock('@/lib/prisma', () => ({ prisma: {} }))
import { computeBurndownDays } from '@/lib/insights/burndown'
describe('computeBurndownDays', () => {
it('5-day sprint: remaining and ideal match spec', () => {
const start = new Date('2024-01-01T00:00:00.000Z')
const end = new Date('2024-01-05T00:00:00.000Z')
const tasks = [
{ status: 'DONE', updated_at: new Date('2024-01-02T12:00:00.000Z') },
{ status: 'DONE', updated_at: new Date('2024-01-04T12:00:00.000Z') },
{ status: 'IN_PROGRESS', updated_at: new Date('2024-01-05T12:00:00.000Z') },
]
const days = computeBurndownDays(tasks, start, end)
expect(days).toHaveLength(5)
expect(days.map(d => d.remaining)).toEqual([3, 2, 2, 1, 1])
expect(days.map(d => d.ideal)).toEqual([3, 2.25, 1.5, 0.75, 0])
expect(days.map(d => d.day)).toEqual([
'2024-01-01',
'2024-01-02',
'2024-01-03',
'2024-01-04',
'2024-01-05',
])
})
it('returns empty array when end is before start', () => {
const start = new Date('2024-01-05T00:00:00.000Z')
const end = new Date('2024-01-01T00:00:00.000Z')
expect(computeBurndownDays([], start, end)).toEqual([])
})
it('single-day sprint has ideal = 0', () => {
const day = new Date('2024-01-01T00:00:00.000Z')
const tasks = [{ status: 'TO_DO', updated_at: new Date('2024-01-01T08:00:00.000Z') }]
const days = computeBurndownDays(tasks, day, day)
expect(days).toHaveLength(1)
expect(days[0].ideal).toBe(0)
expect(days[0].remaining).toBe(1)
})
it('all tasks done on first day: remaining drops to 0', () => {
const start = new Date('2024-01-01T00:00:00.000Z')
const end = new Date('2024-01-03T00:00:00.000Z')
const tasks = [
{ status: 'DONE', updated_at: new Date('2024-01-01T10:00:00.000Z') },
{ status: 'DONE', updated_at: new Date('2024-01-01T11:00:00.000Z') },
]
const days = computeBurndownDays(tasks, start, end)
expect(days.map(d => d.remaining)).toEqual([0, 0, 0])
})
})

View file

@ -1,74 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockQueryRaw } = vi.hoisted(() => ({ mockQueryRaw: vi.fn() }))
vi.mock('@/lib/prisma', () => ({
prisma: { $queryRaw: mockQueryRaw },
}))
import {
getSprintTokenHistory,
getDayTokenData,
getPbiTokenAggregates,
} from '@/lib/insights/token-history'
beforeEach(() => {
vi.clearAllMocks()
})
describe('getSprintTokenHistory', () => {
it('returns mapped sprint rows', async () => {
mockQueryRaw.mockResolvedValueOnce([
{ sprint_id: 'sp-1', sprint_goal: 'Goal A', total_tokens: BigInt(5000), total_cost: 0.1, job_count: BigInt(2) },
])
const rows = await getSprintTokenHistory('user-1')
expect(rows).toHaveLength(1)
expect(rows[0].sprintId).toBe('sp-1')
expect(rows[0].totalTokens).toBe(5000)
expect(rows[0].totalCostUsd).toBe(0.1)
expect(rows[0].jobCount).toBe(2)
})
it('returns zero cost when total_cost is null', async () => {
mockQueryRaw.mockResolvedValueOnce([
{ sprint_id: 'sp-2', sprint_goal: 'Goal B', total_tokens: BigInt(0), total_cost: null, job_count: BigInt(0) },
])
const rows = await getSprintTokenHistory('user-1')
expect(rows[0].totalCostUsd).toBe(0)
})
})
describe('getDayTokenData', () => {
it('returns empty array for empty sprintId', async () => {
const rows = await getDayTokenData('user-1', '')
expect(rows).toHaveLength(0)
expect(mockQueryRaw).not.toHaveBeenCalled()
})
it('maps day rows with ISO date string', async () => {
mockQueryRaw.mockResolvedValueOnce([
{ day: new Date('2026-05-01T00:00:00Z'), total_tokens: BigInt(2000), total_cost: 0.05 },
])
const rows = await getDayTokenData('user-1', 'sprint-1')
expect(rows).toHaveLength(1)
expect(rows[0].day).toBe('2026-05-01')
expect(rows[0].totalTokens).toBe(2000)
})
})
describe('getPbiTokenAggregates', () => {
it('returns empty array for empty sprintId', async () => {
const rows = await getPbiTokenAggregates('user-1', '')
expect(rows).toHaveLength(0)
expect(mockQueryRaw).not.toHaveBeenCalled()
})
it('maps pbi rows', async () => {
mockQueryRaw.mockResolvedValueOnce([
{ pbi_id: 'pbi-1', pbi_code: 'M1', pbi_title: 'First PBI', total_tokens: BigInt(3000), total_cost: 0.08 },
])
const rows = await getPbiTokenAggregates('user-1', 'sprint-1')
expect(rows[0].pbiCode).toBe('M1')
expect(rows[0].totalTokens).toBe(3000)
})
})

View file

@ -1,67 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockQueryRaw } = vi.hoisted(() => ({ mockQueryRaw: vi.fn() }))
vi.mock('@/lib/prisma', () => ({
prisma: { $queryRaw: mockQueryRaw },
}))
import { getTokenStats } from '@/lib/insights/token-stats'
beforeEach(() => {
vi.clearAllMocks()
})
describe('getTokenStats', () => {
it('returns empty result for empty sprintId', async () => {
const result = await getTokenStats('user-1', '')
expect(result.kpi.totalTokens).toBe(0)
expect(result.kpi.totalCostUsd).toBe(0)
expect(result.kpi.avgCostPerJob).toBe(0)
expect(result.kpi.jobCount).toBe(0)
expect(result.jobs).toHaveLength(0)
expect(mockQueryRaw).not.toHaveBeenCalled()
})
it('maps kpi rows correctly', async () => {
const kpiRows = [{ total_tokens: BigInt(10000), total_cost: 0.15, avg_cost: 0.05, job_count: BigInt(3) }]
const jobRows: unknown[] = []
mockQueryRaw.mockResolvedValueOnce(kpiRows).mockResolvedValueOnce(jobRows)
const result = await getTokenStats('user-1', 'sprint-1')
expect(result.kpi.totalTokens).toBe(10000)
expect(result.kpi.totalCostUsd).toBe(0.15)
expect(result.kpi.avgCostPerJob).toBe(0.05)
expect(result.kpi.jobCount).toBe(3)
})
it('maps job rows and handles null token data', async () => {
const kpiRows = [{ total_tokens: BigInt(0), total_cost: null, avg_cost: null, job_count: BigInt(0) }]
const jobRows = [
{
job_id: 'job-1',
task_title: 'My Task',
idea_code: null,
model_id: 'claude-sonnet-4-6',
input_tokens: null,
output_tokens: null,
cache_read_tokens: null,
cache_write_tokens: null,
cost_usd: null,
duration_seconds: 42,
},
]
mockQueryRaw.mockResolvedValueOnce(kpiRows).mockResolvedValueOnce(jobRows)
const result = await getTokenStats('user-1', 'sprint-1')
expect(result.jobs).toHaveLength(1)
const job = result.jobs[0]
expect(job.jobId).toBe('job-1')
expect(job.taskTitle).toBe('My Task')
expect(job.costUsd).toBeNull()
expect(job.durationSeconds).toBe(42)
})
})

View file

@ -1,77 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
const { mockFindMany } = vi.hoisted(() => ({ mockFindMany: vi.fn() }))
vi.mock('@/lib/prisma', () => ({
prisma: {
sprint: { findMany: mockFindMany },
},
}))
vi.mock('@/lib/product-access', () => ({
productAccessFilter: () => ({ some: 'filter' }),
}))
import { getVelocity } from '@/lib/insights/velocity'
const completedAt = (iso: string) => new Date(iso)
function makeSprint(id: string, goal: string, productId: string, productName: string, doneCounts: number, completedIso: string) {
const tasks = Array.from({ length: doneCounts }, () => ({ status: 'DONE' }))
return {
id,
sprint_goal: goal,
completed_at: completedAt(completedIso),
product: { id: productId, name: productName },
tasks,
}
}
describe('getVelocity', () => {
it('returns 3 sprints in chronological order with correct done counts', async () => {
// DB returns newest-first (orderBy: completed_at desc), getVelocity reverses to oldest-first
mockFindMany.mockResolvedValue([
makeSprint('s3', 'Sprint C', 'p1', 'Prod A', 3, '2024-03-01T00:00:00.000Z'),
makeSprint('s2', 'Sprint B', 'p1', 'Prod A', 5, '2024-02-01T00:00:00.000Z'),
makeSprint('s1', 'Sprint A', 'p1', 'Prod A', 2, '2024-01-01T00:00:00.000Z'),
])
const result = await getVelocity('user-1')
expect(result.sprints).toHaveLength(3)
expect(result.sprints.map(s => s.doneCount)).toEqual([2, 5, 3])
expect(result.sprints.map(s => s.sprintId)).toEqual(['s1', 's2', 's3'])
})
it('deduplicates productNames from sprints', async () => {
mockFindMany.mockResolvedValue([
makeSprint('s2', 'Sprint B', 'p2', 'Prod B', 1, '2024-02-01T00:00:00.000Z'),
makeSprint('s1', 'Sprint A', 'p1', 'Prod A', 2, '2024-01-01T00:00:00.000Z'),
])
const result = await getVelocity('user-1')
const ids = result.productNames.map(p => p.id)
expect(new Set(ids).size).toBe(ids.length)
expect(result.productNames).toHaveLength(2)
})
it('returns empty sprints and productNames when no completed sprints exist', async () => {
mockFindMany.mockResolvedValue([])
const result = await getVelocity('user-1')
expect(result.sprints).toEqual([])
expect(result.productNames).toEqual([])
})
it('passes sprintsBack as take parameter', async () => {
mockFindMany.mockResolvedValue([])
await getVelocity('user-1', 3)
expect(mockFindMany).toHaveBeenCalledWith(
expect.objectContaining({ take: 3 }),
)
})
})

View file

@ -1,112 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockGroupBy, mockFindMany } = vi.hoisted(() => ({
mockGroupBy: vi.fn(),
mockFindMany: vi.fn(),
}))
vi.mock('@/lib/prisma', () => ({
prisma: {
claudeJob: {
groupBy: mockGroupBy,
findMany: mockFindMany,
},
},
}))
import { getVerifyResultStats } from '@/lib/insights/verify-stats'
const USER_ID = 'user-1'
const makeJob = (id: string, verifyResult: string, daysAgo: number) => {
const finishedAt = new Date()
finishedAt.setDate(finishedAt.getDate() - daysAgo)
return {
id,
finished_at: finishedAt,
task: { id: `task-${id}`, title: `Task ${id}` },
product: { id: 'prod-1', name: 'Scrum4Me' },
verify_result: verifyResult,
}
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('getVerifyResultStats', () => {
it('returns counts in ALIGNED→PARTIAL→EMPTY→DIVERGENT order', async () => {
mockGroupBy.mockResolvedValue([
{ verify_result: 'DIVERGENT', _count: { _all: 2 } },
{ verify_result: 'ALIGNED', _count: { _all: 10 } },
{ verify_result: 'EMPTY', _count: { _all: 3 } },
{ verify_result: 'PARTIAL', _count: { _all: 1 } },
])
mockFindMany.mockResolvedValue([])
const stats = await getVerifyResultStats(USER_ID)
expect(stats.counts.map(c => c.result)).toEqual([
'ALIGNED', 'PARTIAL', 'EMPTY', 'DIVERGENT',
])
expect(stats.counts.map(c => c.count)).toEqual([10, 1, 3, 2])
})
it('omits results with zero count from groupBy', async () => {
mockGroupBy.mockResolvedValue([
{ verify_result: 'ALIGNED', _count: { _all: 5 } },
])
mockFindMany.mockResolvedValue([])
const stats = await getVerifyResultStats(USER_ID)
expect(stats.counts).toHaveLength(1)
expect(stats.counts[0]).toEqual({ result: 'ALIGNED', count: 5 })
})
it('maps topEmpty jobs correctly', async () => {
mockGroupBy.mockResolvedValue([])
const job = makeJob('j1', 'EMPTY', 2)
// First findMany call → topEmpty, second → topDivergent
mockFindMany
.mockResolvedValueOnce([job])
.mockResolvedValueOnce([])
const stats = await getVerifyResultStats(USER_ID)
expect(stats.topEmpty).toHaveLength(1)
expect(stats.topEmpty[0]).toMatchObject({
jobId: 'j1',
taskId: 'task-j1',
taskTitle: 'Task j1',
productId: 'prod-1',
productName: 'Scrum4Me',
})
})
it('topDivergent is ordered most-recent first (from DB order)', async () => {
mockGroupBy.mockResolvedValue([])
const jobs = [
makeJob('jOld', 'DIVERGENT', 10),
makeJob('jNew', 'DIVERGENT', 1),
]
mockFindMany
.mockResolvedValueOnce([]) // topEmpty
.mockResolvedValueOnce(jobs) // topDivergent (already sorted by Prisma orderBy)
const stats = await getVerifyResultStats(USER_ID)
expect(stats.topDivergent.map(j => j.jobId)).toEqual(['jOld', 'jNew'])
})
it('returns empty stats when no jobs found', async () => {
mockGroupBy.mockResolvedValue([])
mockFindMany.mockResolvedValue([])
const stats = await getVerifyResultStats(USER_ID)
expect(stats.counts).toEqual([])
expect(stats.topEmpty).toEqual([])
expect(stats.topDivergent).toEqual([])
})
})

View file

@ -1,101 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
getKindDefault,
resolveJobConfig,
mapBudgetToEffort,
} from '@/lib/job-config'
describe('mapBudgetToEffort', () => {
it.each([
[0, null],
[-1, null],
[1, 'medium'],
[3000, 'medium'],
[6000, 'medium'],
[6001, 'high'],
[9000, 'high'],
[12000, 'high'],
[12001, 'xhigh'],
[18000, 'xhigh'],
[24000, 'xhigh'],
[24001, 'max'],
[50000, 'max'],
[100000, 'max'],
])('budget %i → %s', (budget, expected) => {
expect(mapBudgetToEffort(budget)).toBe(expected)
})
})
describe('KIND_DEFAULTS.allowed_tools — sync met scrum4me-mcp', () => {
it('TASK_IMPLEMENTATION bevat geen claim-tools', () => {
const cfg = getKindDefault('TASK_IMPLEMENTATION')
expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__wait_for_job')
expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__check_queue_empty')
expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__get_idea_context')
})
it('TASK_IMPLEMENTATION bevat de essentiële task-tools', () => {
const cfg = getKindDefault('TASK_IMPLEMENTATION')
expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_task_status')
expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_job_status')
expect(cfg.allowed_tools).toContain('mcp__scrum4me__verify_task_against_plan')
expect(cfg.allowed_tools).toContain('Bash')
expect(cfg.allowed_tools).toContain('Edit')
expect(cfg.allowed_tools).toContain('Write')
})
it('SPRINT_IMPLEMENTATION bevat sprint-specifieke tools maar GEEN job_heartbeat (runner doet die)', () => {
const cfg = getKindDefault('SPRINT_IMPLEMENTATION')
expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_task_execution')
expect(cfg.allowed_tools).toContain('mcp__scrum4me__verify_sprint_task')
expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__job_heartbeat')
})
it('IDEA_GRILL bevat update_idea_grill_md en geen wait_for_job', () => {
const cfg = getKindDefault('IDEA_GRILL')
expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_idea_grill_md')
expect(cfg.allowed_tools).toContain('mcp__scrum4me__log_idea_decision')
expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_job_status')
expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__wait_for_job')
})
it('IDEA_MAKE_PLAN bevat update_idea_plan_md en geen wait_for_job', () => {
const cfg = getKindDefault('IDEA_MAKE_PLAN')
expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_idea_plan_md')
expect(cfg.allowed_tools).toContain('mcp__scrum4me__log_idea_decision')
expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__wait_for_job')
})
it('alle kinds hebben non-null allowed_tools', () => {
for (const kind of [
'IDEA_GRILL',
'IDEA_MAKE_PLAN',
'PLAN_CHAT',
'TASK_IMPLEMENTATION',
'SPRINT_IMPLEMENTATION',
]) {
const cfg = getKindDefault(kind)
expect(cfg.allowed_tools).not.toBeNull()
expect(Array.isArray(cfg.allowed_tools)).toBe(true)
}
})
})
describe('resolveJobConfig — cascade (regression)', () => {
it('task.requires_opus overrult product.preferred_model', () => {
const cfg = resolveJobConfig(
{ kind: 'TASK_IMPLEMENTATION' },
{ preferred_model: 'claude-sonnet-4-6' },
{ requires_opus: true },
)
expect(cfg.model).toBe('claude-opus-4-7')
})
it('product.preferred_permission_mode overrult bypassPermissions', () => {
const cfg = resolveJobConfig(
{ kind: 'TASK_IMPLEMENTATION' },
{ preferred_permission_mode: 'acceptEdits' },
)
expect(cfg.permission_mode).toBe('acceptEdits')
})
})

View file

@ -1,22 +0,0 @@
import { describe, it, expect } from 'vitest'
import { getBranchUrl } from '@/lib/job-status-url'
describe('getBranchUrl', () => {
it('builds a GitHub tree URL from repo URL and branch', () => {
expect(getBranchUrl('https://github.com/owner/repo', 'feat/job-abc12345')).toBe(
'https://github.com/owner/repo/tree/feat/job-abc12345',
)
})
it('strips trailing .git suffix', () => {
expect(getBranchUrl('https://github.com/owner/repo.git', 'feat/job-abc')).toBe(
'https://github.com/owner/repo/tree/feat/job-abc',
)
})
it('strips trailing slash', () => {
expect(getBranchUrl('https://github.com/owner/repo/', 'feat/job-abc')).toBe(
'https://github.com/owner/repo/tree/feat/job-abc',
)
})
})

View file

@ -1,44 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
jobStatusToApi,
jobStatusFromApi,
JOB_STATUS_API_VALUES,
ACTIVE_JOB_STATUSES,
} from '@/lib/job-status'
describe('job-status mappers', () => {
it('round-trips every API value', () => {
for (const api of JOB_STATUS_API_VALUES) {
const db = jobStatusFromApi(api)
expect(db).not.toBeNull()
expect(jobStatusToApi(db!)).toBe(api)
}
})
it('returns null for invalid input', () => {
expect(jobStatusFromApi('NOT_A_STATUS')).toBeNull()
expect(jobStatusFromApi('')).toBeNull()
expect(jobStatusFromApi('active')).toBeNull()
})
it('is case-insensitive on the API side (accepts both upper and lower)', () => {
expect(jobStatusFromApi('running')).toBe('RUNNING')
expect(jobStatusFromApi('RUNNING')).toBe('RUNNING')
expect(jobStatusFromApi('QUEUED')).toBe('QUEUED')
})
it('maps all 7 DB statuses to API', () => {
expect(jobStatusToApi('QUEUED')).toBe('queued')
expect(jobStatusToApi('CLAIMED')).toBe('claimed')
expect(jobStatusToApi('RUNNING')).toBe('running')
expect(jobStatusToApi('DONE')).toBe('done')
expect(jobStatusToApi('FAILED')).toBe('failed')
expect(jobStatusToApi('CANCELLED')).toBe('cancelled')
expect(jobStatusToApi('SKIPPED')).toBe('skipped')
})
it('ACTIVE_JOB_STATUSES contains exactly QUEUED, CLAIMED, RUNNING', () => {
expect(ACTIVE_JOB_STATUSES).toEqual(expect.arrayContaining(['QUEUED', 'CLAIMED', 'RUNNING']))
expect(ACTIVE_JOB_STATUSES).toHaveLength(3)
})
})

View file

@ -1,57 +0,0 @@
import { describe, expect, it } from 'vitest'
import { isWithinTimeWindow } from '@/lib/jobs-time-filter'
const HOUR_MS = 60 * 60 * 1000
describe('isWithinTimeWindow', () => {
it("returns true for filter='all' regardless of age", () => {
const old = new Date(0)
expect(isWithinTimeWindow(old, 'all')).toBe(true)
})
describe("filter='1h'", () => {
const now = Date.now()
it('returns true for a job created 30 minutes ago', () => {
const createdAt = new Date(now - 30 * 60 * 1000)
expect(isWithinTimeWindow(createdAt, '1h', now)).toBe(true)
})
it('returns false for a job created 90 minutes ago', () => {
const createdAt = new Date(now - 90 * 60 * 1000)
expect(isWithinTimeWindow(createdAt, '1h', now)).toBe(false)
})
})
describe("filter='24h'", () => {
const now = Date.now()
it('returns true for a job created 23 hours ago', () => {
const createdAt = new Date(now - 23 * HOUR_MS)
expect(isWithinTimeWindow(createdAt, '24h', now)).toBe(true)
})
it('returns false for a job created 25 hours ago', () => {
const createdAt = new Date(now - 25 * HOUR_MS)
expect(isWithinTimeWindow(createdAt, '24h', now)).toBe(false)
})
})
describe('accepts both Date and ISO string for createdAt', () => {
const now = Date.now()
const recent = new Date(now - 30 * 60 * 1000)
it('accepts a Date object', () => {
expect(isWithinTimeWindow(recent, '1h', now)).toBe(true)
})
it('accepts an ISO string', () => {
expect(isWithinTimeWindow(recent.toISOString(), '1h', now)).toBe(true)
})
})
it('returns true for an invalid date string (fail-open)', () => {
expect(isWithinTimeWindow('not-a-date', '1h')).toBe(true)
})
})

View file

@ -1,56 +0,0 @@
import { describe, it, expect } from 'vitest'
import { resolveProductSwitchTarget } from '@/lib/product-switch-path'
describe('resolveProductSwitchTarget', () => {
it('returns null for non-product pages', () => {
expect(resolveProductSwitchTarget('/dashboard', 'new-id')).toBeNull()
expect(resolveProductSwitchTarget('/insights', 'new-id')).toBeNull()
expect(resolveProductSwitchTarget('/ideas', 'new-id')).toBeNull()
expect(resolveProductSwitchTarget('/jobs', 'new-id')).toBeNull()
expect(resolveProductSwitchTarget('/', 'new-id')).toBeNull()
})
it('maps /products/<old> to /products/<new>', () => {
expect(resolveProductSwitchTarget('/products/old-id', 'new-id')).toBe('/products/new-id')
})
it('maps /products/<old>/ to /products/<new>', () => {
expect(resolveProductSwitchTarget('/products/old-id/', 'new-id')).toBe('/products/new-id')
})
it('maps /products/<old>/sprint to /products/<new>/sprint', () => {
expect(resolveProductSwitchTarget('/products/old-id/sprint', 'new-id')).toBe(
'/products/new-id/sprint',
)
})
it('maps /products/<old>/sprint/<sprintId> to /products/<new>/sprint', () => {
expect(resolveProductSwitchTarget('/products/old-id/sprint/abc123', 'new-id')).toBe(
'/products/new-id/sprint',
)
})
it('maps /products/<old>/sprint/.../planning to /products/<new>/sprint', () => {
expect(resolveProductSwitchTarget('/products/old-id/sprint/abc123/planning', 'new-id')).toBe(
'/products/new-id/sprint',
)
})
it('maps /products/<old>/solo to /products/<new>/solo', () => {
expect(resolveProductSwitchTarget('/products/old-id/solo', 'new-id')).toBe(
'/products/new-id/solo',
)
})
it('falls back to /products/<new> for /products/<old>/settings', () => {
expect(resolveProductSwitchTarget('/products/old-id/settings', 'new-id')).toBe(
'/products/new-id',
)
})
it('falls back to /products/<new> for unknown sub-segments', () => {
expect(resolveProductSwitchTarget('/products/old-id/unknown/deep', 'new-id')).toBe(
'/products/new-id',
)
})
})

View file

@ -1,35 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
vi.mock('@/actions/push', () => ({
subscribeToPushAction: vi.fn(),
unsubscribeFromPushAction: vi.fn(),
}))
import { urlBase64ToUint8Array } from '@/lib/push-client'
describe('urlBase64ToUint8Array', () => {
it('converts a base64url-encoded VAPID public key to Uint8Array', () => {
// 65-byte uncompressed EC public key encoded as base64url (no padding)
const base64url = 'BNMxB-LJm6XvGGiJSsYLdumcYiM7q9s_1aM9i5lI8lVzZ7GYJw1QkQFmrknwFsI4dI-e1iyvUhYHjNpHJKJD3oc'
const result = urlBase64ToUint8Array(base64url)
expect(result).toBeInstanceOf(Uint8Array)
expect(result.length).toBe(65)
expect(result[0]).toBe(0x04) // uncompressed EC point prefix
})
it('handles base64url with padding', () => {
// simple known vector: "hello" = aGVsbG8= in base64
const result = urlBase64ToUint8Array('aGVsbG8')
expect(result).toBeInstanceOf(Uint8Array)
expect(Array.from(result)).toEqual([104, 101, 108, 108, 111]) // "hello"
})
it('converts - and _ characters correctly', () => {
// base64url uses - and _ instead of + and /
const base64standard = 'AB+/AA=='
const base64url = 'AB-_AA'
const fromStd = urlBase64ToUint8Array(base64standard)
const fromUrl = urlBase64ToUint8Array(base64url)
expect(Array.from(fromStd)).toEqual(Array.from(fromUrl))
})
})

View file

@ -1,77 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('server-only', () => ({}))
const { mockSendNotification } = vi.hoisted(() => ({
mockSendNotification: vi.fn(),
}))
vi.mock('web-push', () => ({
default: {
setVapidDetails: vi.fn(),
sendNotification: mockSendNotification,
},
}))
vi.hoisted(() => {
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY = 'pk'
process.env.VAPID_PRIVATE_KEY = 'sk'
process.env.VAPID_SUBJECT = 'mailto:test@example.com'
})
const { mockPushSubscription } = vi.hoisted(() => ({
mockPushSubscription: {
findMany: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
}))
vi.mock('@/lib/prisma', () => ({
prisma: { pushSubscription: mockPushSubscription },
}))
import { sendPushToUser } from '@/lib/push-server'
const SUB = { id: 'sub-1', endpoint: 'https://push.example.com/1', p256dh: 'p256dh', auth: 'auth' }
const PAYLOAD = { title: 'Test', body: 'Body', url: '/test' }
beforeEach(() => {
vi.clearAllMocks()
mockPushSubscription.findMany.mockResolvedValue([SUB])
mockPushSubscription.update.mockResolvedValue(SUB)
mockPushSubscription.delete.mockResolvedValue(SUB)
})
describe('sendPushToUser', () => {
it('sends notification and updates last_used_at on success', async () => {
mockSendNotification.mockResolvedValue({ statusCode: 201 })
await sendPushToUser('user-1', PAYLOAD)
expect(mockSendNotification).toHaveBeenCalledOnce()
expect(mockPushSubscription.update).toHaveBeenCalledWith({
where: { id: SUB.id },
data: { last_used_at: expect.any(Date) },
})
})
it('deletes subscription on 410 (expired)', async () => {
mockSendNotification.mockRejectedValue({ statusCode: 410 })
await sendPushToUser('user-1', PAYLOAD)
expect(mockPushSubscription.delete).toHaveBeenCalledWith({ where: { id: SUB.id } })
expect(mockPushSubscription.update).not.toHaveBeenCalled()
})
it('deletes subscription on 404 (not found)', async () => {
mockSendNotification.mockRejectedValue({ statusCode: 404 })
await sendPushToUser('user-1', PAYLOAD)
expect(mockPushSubscription.delete).toHaveBeenCalledWith({ where: { id: SUB.id } })
})
it('logs error but does not delete on other error status', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockSendNotification.mockRejectedValue({ statusCode: 500 })
await sendPushToUser('user-1', PAYLOAD)
expect(mockPushSubscription.delete).not.toHaveBeenCalled()
expect(consoleSpy).toHaveBeenCalled()
consoleSpy.mockRestore()
})
})

View file

@ -1,64 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { checkRateLimit, enforceUserRateLimit, _resetRateLimit } from '@/lib/rate-limit'
beforeEach(() => {
_resetRateLimit()
})
describe('checkRateLimit (legacy auth-keys)', () => {
it('staat de eerste request toe', () => {
expect(checkRateLimit('login:1.2.3.4')).toBe(true)
})
it('blokkeert na exceeding max (login: 10/min)', () => {
for (let i = 0; i < 10; i++) checkRateLimit('login:1.2.3.4')
expect(checkRateLimit('login:1.2.3.4')).toBe(false)
})
it('register heeft eigen lagere limiet (5/uur)', () => {
for (let i = 0; i < 5; i++) checkRateLimit('register:9.9.9.9')
expect(checkRateLimit('register:9.9.9.9')).toBe(false)
})
it('verschillende keys hebben hun eigen counter', () => {
for (let i = 0; i < 10; i++) checkRateLimit('login:1.1.1.1')
expect(checkRateLimit('login:1.1.1.1')).toBe(false)
expect(checkRateLimit('login:2.2.2.2')).toBe(true)
})
})
describe('enforceUserRateLimit (v1-readiness #3 mutation-scopes)', () => {
it('returnt null bij eerste call', () => {
expect(enforceUserRateLimit('create-pbi', 'user-1')).toBeNull()
})
it('returnt 429-shape na exceeding limiet', () => {
// create-product limiet = 5/min
for (let i = 0; i < 5; i++) enforceUserRateLimit('create-product', 'user-1')
const result = enforceUserRateLimit('create-product', 'user-1')
expect(result).not.toBeNull()
expect(result?.code).toBe(429)
expect(result?.error).toContain('Te veel acties')
})
it('scope is per (action, user) — andere user heeft eigen quota', () => {
for (let i = 0; i < 5; i++) enforceUserRateLimit('create-product', 'user-A')
expect(enforceUserRateLimit('create-product', 'user-A')).not.toBeNull()
expect(enforceUserRateLimit('create-product', 'user-B')).toBeNull()
})
it('verschillende scopes voor dezelfde user vullen apart', () => {
for (let i = 0; i < 5; i++) enforceUserRateLimit('create-product', 'user-1')
expect(enforceUserRateLimit('create-product', 'user-1')).not.toBeNull()
// create-task heeft eigen counter
expect(enforceUserRateLimit('create-task', 'user-1')).toBeNull()
})
it('create-task limiet (100) is hoger dan create-pbi (30)', () => {
for (let i = 0; i < 30; i++) enforceUserRateLimit('create-pbi', 'u')
expect(enforceUserRateLimit('create-pbi', 'u')).not.toBeNull()
// create-task is nog niet hit
for (let i = 0; i < 30; i++) enforceUserRateLimit('create-task', 'u')
expect(enforceUserRateLimit('create-task', 'u')).toBeNull()
})
})

View file

@ -1,66 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { Client } from 'pg'
import { closePgClientSafely } from '@/lib/realtime/pg-client-cleanup'
function makeFakeClient(opts: {
endResolves?: Promise<void>
destroy?: ReturnType<typeof vi.fn>
}): Client {
const handlers = new Map<string, Array<(...args: unknown[]) => void>>()
const fake = {
end: vi.fn().mockReturnValue(opts.endResolves ?? Promise.resolve()),
on: vi.fn((event: string, fn: (...args: unknown[]) => void) => {
const list = handlers.get(event) ?? []
list.push(fn)
handlers.set(event, list)
return fake
}),
removeAllListeners: vi.fn((event: string) => {
handlers.delete(event)
return fake
}),
connection: {
stream: { destroy: opts.destroy ?? vi.fn() },
},
}
return fake as unknown as Client
}
describe('closePgClientSafely', () => {
beforeEach(() => {
vi.useRealTimers()
})
it('drops listeners and awaits client.end() when it resolves quickly', async () => {
const destroy = vi.fn()
const client = makeFakeClient({ destroy })
await closePgClientSafely(client, 'test')
expect(client.removeAllListeners).toHaveBeenCalledWith('notification')
expect(client.removeAllListeners).toHaveBeenCalledWith('error')
expect(client.end).toHaveBeenCalledOnce()
expect(destroy).not.toHaveBeenCalled() // ended in time
})
it('falls back to socket-destroy when client.end() hangs past the timeout', async () => {
const destroy = vi.fn()
// .end() never resolves
const client = makeFakeClient({ endResolves: new Promise(() => {}), destroy })
vi.useFakeTimers()
const promise = closePgClientSafely(client, 'test-hang')
await vi.advanceTimersByTimeAsync(2_001)
await promise
expect(destroy).toHaveBeenCalledOnce()
const arg = destroy.mock.calls[0][0]
expect(arg).toBeInstanceOf(Error)
})
it('does not throw when client.end() rejects', async () => {
const client = makeFakeClient({ endResolves: Promise.reject(new Error('boom')) })
await expect(closePgClientSafely(client, 'test-reject')).resolves.toBeUndefined()
})
})

View file

@ -1,275 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
import type { StoryStatus } from '@prisma/client'
import {
getBlockingSprintMap,
isEligibleForSprint,
partitionByEligibility,
} from '@/lib/sprint-conflicts'
function mockPrisma(stories: Array<Record<string, unknown>>) {
return {
story: {
findMany: vi.fn().mockResolvedValue(stories),
},
} as unknown as Parameters<typeof partitionByEligibility>[0]
}
describe('isEligibleForSprint', () => {
it('returns true for OPEN story without sprint', () => {
expect(
isEligibleForSprint({ sprint_id: null, status: 'OPEN' as StoryStatus }),
).toBe(true)
})
it('returns true for IN_SPRINT story without sprint_id (edge: restoration)', () => {
expect(
isEligibleForSprint({
sprint_id: null,
status: 'IN_SPRINT' as StoryStatus,
}),
).toBe(true)
})
it('returns false for DONE story without sprint', () => {
expect(
isEligibleForSprint({ sprint_id: null, status: 'DONE' as StoryStatus }),
).toBe(false)
})
it('returns false when story is in an OPEN sprint', () => {
expect(
isEligibleForSprint({
sprint_id: 'abc',
status: 'IN_SPRINT' as StoryStatus,
sprint: { status: 'OPEN' },
}),
).toBe(false)
})
it('returns false when story is DONE (sprint_id irrelevant)', () => {
expect(
isEligibleForSprint({
sprint_id: 'abc',
status: 'DONE' as StoryStatus,
sprint: { status: 'CLOSED' },
}),
).toBe(false)
})
it('returns true when story is in a CLOSED sprint (released back to planning)', () => {
expect(
isEligibleForSprint({
sprint_id: 'abc',
status: 'IN_SPRINT' as StoryStatus,
sprint: { status: 'CLOSED' },
}),
).toBe(true)
})
it('returns true when story is in an ARCHIVED sprint', () => {
expect(
isEligibleForSprint({
sprint_id: 'abc',
status: 'IN_SPRINT' as StoryStatus,
sprint: { status: 'ARCHIVED' },
}),
).toBe(true)
})
it('returns true when story is in a FAILED sprint', () => {
expect(
isEligibleForSprint({
sprint_id: 'abc',
status: 'IN_SPRINT' as StoryStatus,
sprint: { status: 'FAILED' },
}),
).toBe(true)
})
it('returns false when sprint_id is set but sprint relation is missing (defensive)', () => {
// Zonder sprint-data weten we niet of die OPEN is, dus blijven we
// conservatief — niet eligible.
expect(
isEligibleForSprint({
sprint_id: 'abc',
status: 'IN_SPRINT' as StoryStatus,
}),
).toBe(false)
})
})
describe('partitionByEligibility', () => {
it('returns empty partition for empty input', async () => {
const prisma = mockPrisma([])
const result = await partitionByEligibility(prisma, [])
expect(result).toEqual({ eligible: [], notEligible: [], crossSprint: [] })
})
it('classifies all eligible when stories are free + OPEN', async () => {
const prisma = mockPrisma([
{ id: 's1', sprint_id: null, status: 'OPEN', sprint: null },
{ id: 's2', sprint_id: null, status: 'IN_SPRINT', sprint: null },
])
const result = await partitionByEligibility(prisma, ['s1', 's2'])
expect(result.eligible).toEqual(['s1', 's2'])
expect(result.notEligible).toEqual([])
expect(result.crossSprint).toEqual([])
})
it('marks DONE stories as notEligible with reason=DONE', async () => {
const prisma = mockPrisma([
{ id: 's1', sprint_id: null, status: 'DONE', sprint: null },
])
const result = await partitionByEligibility(prisma, ['s1'])
expect(result.eligible).toEqual([])
expect(result.notEligible).toEqual([{ storyId: 's1', reason: 'DONE' }])
})
it('marks stories in other OPEN sprint as crossSprint + notEligible', async () => {
const prisma = mockPrisma([
{
id: 's1',
sprint_id: 'sprint-other',
status: 'IN_SPRINT',
sprint: { id: 'sprint-other', code: 'SP-2', status: 'OPEN' },
},
])
const result = await partitionByEligibility(prisma, ['s1'])
expect(result.crossSprint).toEqual([
{ storyId: 's1', sprintId: 'sprint-other', sprintName: 'SP-2' },
])
expect(result.notEligible).toEqual([
{ storyId: 's1', reason: 'IN_OTHER_SPRINT' },
])
expect(result.eligible).toEqual([])
})
it('classifies story in CLOSED sprint with status=OPEN as eligible (status reset already happened)', async () => {
const prisma = mockPrisma([
{
id: 's1',
sprint_id: null,
status: 'OPEN',
sprint: null,
},
])
const result = await partitionByEligibility(prisma, ['s1'])
expect(result.eligible).toEqual(['s1'])
})
it('frees stories from a CLOSED sprint — they become eligible again', async () => {
const prisma = mockPrisma([
{
id: 's1',
sprint_id: 'sprint-closed',
status: 'IN_SPRINT',
sprint: { id: 'sprint-closed', code: 'SP-C', status: 'CLOSED' },
},
])
const result = await partitionByEligibility(prisma, ['s1'])
expect(result.eligible).toEqual(['s1'])
expect(result.crossSprint).toEqual([])
expect(result.notEligible).toEqual([])
})
it('frees stories from ARCHIVED and FAILED sprints', async () => {
const prisma = mockPrisma([
{
id: 's1',
sprint_id: 'sprint-arch',
status: 'IN_SPRINT',
sprint: { id: 'sprint-arch', code: 'SP-A', status: 'ARCHIVED' },
},
{
id: 's2',
sprint_id: 'sprint-fail',
status: 'IN_SPRINT',
sprint: { id: 'sprint-fail', code: 'SP-F', status: 'FAILED' },
},
])
const result = await partitionByEligibility(prisma, ['s1', 's2'])
expect(result.eligible).toEqual(['s1', 's2'])
expect(result.notEligible).toEqual([])
})
it('a DONE story in a CLOSED sprint is notEligible because DONE (sprint inactive)', async () => {
// Volgorde: niet-actieve sprint blokkeert niet meer, dus de DONE-check
// bepaalt de reason. Vroeger werd dit 'IN_OTHER_SPRINT' — dat was misleidend
// omdat de sprint helemaal niet meer actief was.
const prisma = mockPrisma([
{
id: 's1',
sprint_id: 'sprint-closed',
status: 'DONE',
sprint: { id: 'sprint-closed', code: 'SP-C', status: 'CLOSED' },
},
])
const result = await partitionByEligibility(prisma, ['s1'])
expect(result.crossSprint).toEqual([])
expect(result.notEligible).toEqual([{ storyId: 's1', reason: 'DONE' }])
expect(result.eligible).toEqual([])
})
it('respects excludeSprintId — story in same sprint is eligible', async () => {
const prisma = mockPrisma([
{
id: 's1',
sprint_id: 'sprint-active',
status: 'IN_SPRINT',
sprint: { id: 'sprint-active', code: 'SP-A', status: 'OPEN' },
},
])
const result = await partitionByEligibility(prisma, ['s1'], 'sprint-active')
expect(result.eligible).toEqual(['s1'])
expect(result.crossSprint).toEqual([])
})
})
describe('getBlockingSprintMap', () => {
it('returns empty map for empty input', async () => {
const prisma = mockPrisma([])
const result = await getBlockingSprintMap(prisma, 'p1', [])
expect(result.size).toBe(0)
})
it('returns blocking sprint info for stories in OPEN sprints', async () => {
const prisma = mockPrisma([
{
id: 's1',
sprint_id: 'sprint-x',
sprint: { id: 'sprint-x', code: 'SP-X', status: 'OPEN' },
},
])
const result = await getBlockingSprintMap(prisma, 'p1', ['s1'])
expect(result.get('s1')).toEqual({
sprintId: 'sprint-x',
sprintName: 'SP-X',
})
})
it('excludes the active sprint from blocking', async () => {
const prisma = mockPrisma([
{
id: 's1',
sprint_id: 'sprint-active',
sprint: { id: 'sprint-active', code: 'SP-A', status: 'OPEN' },
},
])
const result = await getBlockingSprintMap(
prisma,
'p1',
['s1'],
'sprint-active',
)
expect(result.size).toBe(0)
})
it('does not include CLOSED sprints (filtered at DB query level)', async () => {
// The prisma mock receives WHERE sprint.status='OPEN' so CLOSED stories
// are already filtered out before reaching this function's mapping logic.
const prisma = mockPrisma([])
const result = await getBlockingSprintMap(prisma, 'p1', ['s1'])
expect(result.size).toBe(0)
})
})

Some files were not shown because too many files have changed in this diff Show more