Compare commits

..

2 commits

Author SHA1 Message Date
Scrum4Me Agent
c0ded1f482 feat(ST-nma6ylbl): requireAdmin() guard + /admin layout-shell + tests
- lib/auth-guard.ts: requireAdmin() toegevoegd — redirect /dashboard bij !userId of !isAdmin
- app/(app)/admin/layout.tsx: admin-sidebar met links naar /admin/users, /admin/jobs, /admin/products
- app/(app)/admin/page.tsx: redirect-stub naar /admin/users
- __tests__/lib/auth-guard.test.ts: 3 tests voor requireAdmin() (geen userId, isAdmin=false, isAdmin=true)
2026-05-05 14:26:03 +02:00
Scrum4Me Agent
8af5354f22 feat(ST-nma6ylbl): SessionData isAdmin + loginAction admin-redirect + must_reset_password-interceptie
- SessionData: isAdmin: boolean toegevoegd (na isDemo)
- loginAction: UserRole-query voor ADMIN, session.isAdmin gezet, redirect-volgorde:
  must_reset_password → /reset-password, adminRole → /admin, phone-UA, dashboard
- registerAction: session.isAdmin = false
- pair/claim route: session.isAdmin = false (QR-pairing is geen admin-flow)
2026-05-05 14:22:04 +02:00
517 changed files with 7106 additions and 45224 deletions

View file

@ -15,22 +15,6 @@ NODE_ENV="development"
# 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).

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
@ -61,52 +49,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 +80,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 +110,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 }}

1
.gitignore vendored
View file

@ -50,7 +50,6 @@ next-env.d.ts
# Claude Code local settings
.claude/settings.local.json
.claude/worktrees/
# Local plan/scratch files (per-developer, not shared)

View file

@ -67,7 +67,7 @@ launch-ready state na de v1-readiness-checklist (Now + Before-launch items).
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`.
- v1.0 readiness checklist in `docs/plans/v1-readiness.md`.
([#82](https://github.com/madhura68/Scrum4Me/pull/82))
### Changed
@ -95,7 +95,7 @@ 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).
Voor de volledige milestone-historie zie [docs/backlog/index.md](./docs/backlog/index.md).
---

View file

@ -3,7 +3,7 @@ title: "CLAUDE.md — Scrum4Me"
status: active
audience: [ai-agent]
language: nl
last_updated: 2026-05-11
last_updated: 2026-05-03
---
# CLAUDE.md — Scrum4Me
@ -19,25 +19,30 @@ Desktop-first Scrum-app voor solo developers en kleine teams. Hiërarchie: produ
| `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/backlog/index.md` | Implementatievolgorde, "done when"-criteria |
| `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/plans/<key>-*.md` | Implementatieplan per milestone |
---
## Hoe werk vinden
**Track A — MCP (aanbevolen):**
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
5. Verifieer: `npm run lint && npm test && npm run build`
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`
**Track B — manueel:**
1. Lees taak in `docs/backlog/index.md`
2. Zoek spec in `docs/specs/functional.md`
3. Lees patroon + styling → bouw → verifieer → vraag bevestiging → commit
Volledige MCP-tool documentatie: [docs/runbooks/mcp-integration.md](./docs/runbooks/mcp-integration.md)
---
@ -48,13 +53,10 @@ Volledige MCP-tool documentatie: [docs/runbooks/mcp-integration.md](./docs/runbo
- **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)
- **Deployment:** `npm run lint && npm test && npm run build` vóór elke PR
---
@ -62,13 +64,12 @@ Volledige MCP-tool documentatie: [docs/runbooks/mcp-integration.md](./docs/runbo
| Laag | Technologie |
|---|---|
| Framework | Next.js 16.2 (App Router) + React 19.2 — PPR/Cache Components beschikbaar |
| Framework | Next.js 16 (App Router) + React 19 |
| Taal | TypeScript strict |
| Styling | Tailwind CSS v4 + shadcn/ui + MD3 via `app/styles/theme.css` |
| Styling | Tailwind CSS + shadcn/ui + MD3 via `app/styles/theme.css` |
| State | Zustand + dnd-kit |
| DB | Prisma v7.8 + PostgreSQL (Neon) |
| DB | Prisma v7 + PostgreSQL (Neon) |
| Auth | iron-session + bcryptjs |
| Test | Vitest (`__tests__/`, config in `vitest.config.ts`) |
| Utilities | Zod, Sonner, Sharp, Vercel Analytics |
---
@ -81,20 +82,12 @@ Volledige MCP-tool documentatie: [docs/runbooks/mcp-integration.md](./docs/runbo
| 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` |
---
@ -107,18 +100,7 @@ SESSION_SECRET="" # min 32 chars
CRON_SECRET="" # Bearer-secret /api/cron/*
```
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
- **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)
Volledig schema: `lib/env.ts`. Canonieke lijst: `.env.example`.
---
@ -131,24 +113,5 @@ PBI (niet: Feature/Epic) · Story (niet: Ticket) · Sprint Goal (niet: Objective
## Verificatie
```bash
npm run verify && npm run build # verify = lint + typecheck + test
npm run lint && npm test && npm run build
```
Worker job-status protocol (wanneer `DONE` / `SKIPPED` / `FAILED`): zie [docs/runbooks/worker-idempotency.md](./docs/runbooks/worker-idempotency.md).
### Scripts
| 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`) |
> 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.

View file

@ -123,12 +123,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/assets/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
@ -162,9 +166,19 @@ De curl-tests dekken alle 7 API-endpoints: auth (401), demo-blokkering (403), in
## Database
Het schema staat in `prisma/schema.prisma`; uitgebreide documentatie in [`docs/architecture/data-model.md`](./docs/architecture/data-model.md).
![ERD](./docs/assets/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
```
Optioneel: `npm run db:erd:watch` parallel aan `npm run dev` om bij wijzigingen in `prisma/schema.prisma` `docs/assets/erd.svg` automatisch opnieuw te genereren.
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 +189,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/assets/erd.svg genereren
```
### Environment variables
@ -187,10 +202,6 @@ Zie [.env.example](.env.example).
| `DIRECT_URL` | Nee | Directe Neon connection string voor migraties (Prisma `directUrl`) |
| `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 |
@ -247,20 +258,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
@ -287,4 +291,5 @@ De productieomgeving is gericht op Vercel + Neon.
- [Functionele specificatie](docs/specs/functional.md)
- [Technische architectuur](docs/architecture.md)
- [Backlog](docs/backlog/index.md)
- [Agent-instructie audit](docs/decisions/agent-instructions-history.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,21 +1,10 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const {
redirectMock,
verifyUserMock,
headerGetMock,
sessionSaveMock,
requireSessionMock,
prismaUserUpdateMock,
prismaUserRoleFindFirstMock,
} = vi.hoisted(() => ({
const { redirectMock, verifyUserMock, headerGetMock, sessionSaveMock } = 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 }))
@ -34,18 +23,10 @@ vi.mock('@/lib/session', () => ({ sessionOptions: { cookieName: 't', password: '
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'
import { loginAction } 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'
@ -63,9 +44,6 @@ beforeEach(() => {
verifyUserMock.mockReset()
headerGetMock.mockReset()
sessionSaveMock.mockReset()
requireSessionMock.mockReset()
prismaUserUpdateMock.mockReset()
prismaUserRoleFindFirstMock.mockResolvedValue(null)
})
describe('loginAction UA-redirect', () => {
@ -105,37 +83,3 @@ describe('loginAction UA-redirect', () => {
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 +1,232 @@
/**
* 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.
* Uitgebreide integratie-stijl tests voor previewEnqueueAllAction en
* enqueueClaudeJobsBatchAction. Gebruikt realistische seed-data:
* 2 PBIs, elk met 1 story, elk 2 taken (4 taken totaal in PBI-volgorde).
*/
import { describe, it, expect, vi } from 'vitest'
import { describe, it, expect, vi, beforeEach } from 'vitest'
const {
mockGetSession,
mockFindFirstProduct,
mockFindFirstSprint,
mockFindManyTask,
mockTransaction,
mockExecuteRaw,
} = vi.hoisted(() => ({
mockGetSession: vi.fn(),
mockFindFirstProduct: vi.fn(),
mockFindFirstSprint: vi.fn(),
mockFindManyTask: vi.fn(),
mockTransaction: vi.fn(),
mockExecuteRaw: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
vi.mock('@/lib/auth', () => ({ getSession: vi.fn() }))
vi.mock('@/lib/prisma', () => ({ prisma: {} }))
vi.mock('@/lib/auth', () => ({ getSession: mockGetSession }))
vi.mock('@/lib/prisma', () => ({
prisma: {
task: { findMany: mockFindManyTask },
product: { findFirst: mockFindFirstProduct },
sprint: { findFirst: mockFindFirstSprint },
claudeJob: { create: vi.fn() },
$executeRaw: mockExecuteRaw,
$transaction: mockTransaction,
},
}))
import {
previewEnqueueAllAction,
enqueueClaudeJobsBatchAction,
} from '@/actions/claude-jobs'
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') })
const SESSION_USER = { userId: 'user-1', isDemo: false }
const SESSION_DEMO = { userId: 'demo-1', isDemo: true }
const PRODUCT_ID = 'product-1'
const SPRINT_ID = 'sprint-1'
// --- Seed helpers ---
const makePbi1Task = (id: string, status = 'TO_DO') => ({
id,
title: `PBI-1 Taak ${id}`,
status,
story: {
id: 'story-pbi1',
title: 'Story van PBI 1',
code: 'ST-1',
pbi: { id: 'pbi-1', status: 'READY', priority: 1, sort_order: 1.0 },
},
})
const makePbi2Task = (id: string, status = 'TO_DO', pbiStatus = 'READY') => ({
id,
title: `PBI-2 Taak ${id}`,
status,
story: {
id: 'story-pbi2',
title: 'Story van PBI 2',
code: 'ST-2',
pbi: { id: 'pbi-2', status: pbiStatus, priority: 2, sort_order: 2.0 },
},
})
const makeBatchTask = (id: string, hasActiveJob = false) => ({
id,
claude_jobs: hasActiveJob ? [{ id: 'job-active' }] : [],
})
// Canonical seed: [pbi1-t1, pbi1-t2, pbi2-t1, pbi2-t2]
const SEED_ALL_TODO = [
makePbi1Task('pbi1-t1'),
makePbi1Task('pbi1-t2'),
makePbi2Task('pbi2-t1'),
makePbi2Task('pbi2-t2'),
]
beforeEach(() => {
vi.clearAllMocks()
mockExecuteRaw.mockResolvedValue(undefined)
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
mockFindFirstSprint.mockResolvedValue({ id: SPRINT_ID })
})
// =============================================================
// previewEnqueueAllAction
// =============================================================
describe('previewEnqueueAllAction — 2 PBI scenario', () => {
it('geen blocker: alle 4 TO_DO taken → tasks=[4], blockerIndex=null', async () => {
mockFindManyTask.mockResolvedValue(SEED_ALL_TODO)
const result = await previewEnqueueAllAction(PRODUCT_ID)
expect(result).toMatchObject({ blockerIndex: null, blockerReason: null })
if (!('error' in result)) {
expect(result.tasks).toHaveLength(4)
expect(result.tasks.map(t => t.id)).toEqual(['pbi1-t1', 'pbi1-t2', 'pbi2-t1', 'pbi2-t2'])
}
})
it('3e taak (pbi2-t1) REVIEW → blockerIndex=2, reden=task-review, tasks=[3]', async () => {
mockFindManyTask.mockResolvedValue([
makePbi1Task('pbi1-t1'),
makePbi1Task('pbi1-t2'),
makePbi2Task('pbi2-t1', 'REVIEW'),
makePbi2Task('pbi2-t2'),
])
const result = await previewEnqueueAllAction(PRODUCT_ID)
expect(result).toMatchObject({ blockerIndex: 2, blockerReason: 'task-review' })
if (!('error' in result)) {
expect(result.tasks).toHaveLength(3)
expect(result.tasks[2].id).toBe('pbi2-t1')
}
})
it('PBI 1 BLOCKED → blockerIndex=0, reden=pbi-blocked, tasks=[1]', async () => {
mockFindManyTask.mockResolvedValue([
makePbi1Task('pbi1-t1', 'TO_DO'),
makePbi1Task('pbi1-t2', 'TO_DO'),
makePbi2Task('pbi2-t1'),
makePbi2Task('pbi2-t2'),
].map((t, i) => i < 2 ? { ...t, story: { ...t.story, pbi: { ...t.story.pbi, status: 'BLOCKED' } } } : t))
const result = await previewEnqueueAllAction(PRODUCT_ID)
expect(result).toMatchObject({ blockerIndex: 0, blockerReason: 'pbi-blocked' })
if (!('error' in result)) expect(result.tasks).toHaveLength(1)
})
it('ACTIVE job op pbi1-t1 → geskipt door where-clause, geen blocker bij resterende 3', async () => {
// Simuleert dat pbi1-t1 een actieve job heeft: de where-clause sluit die taak uit
mockFindManyTask.mockResolvedValue([
makePbi1Task('pbi1-t2'),
makePbi2Task('pbi2-t1'),
makePbi2Task('pbi2-t2'),
])
const result = await previewEnqueueAllAction(PRODUCT_ID)
expect(result).toMatchObject({ blockerIndex: null, blockerReason: null })
if (!('error' in result)) {
expect(result.tasks).toHaveLength(3)
expect(result.tasks[0].id).toBe('pbi1-t2')
}
})
it('ACTIVE job op pbi1-t1 AND pbi2-t1 REVIEW → blockerIndex=1 in resterende array', async () => {
mockFindManyTask.mockResolvedValue([
makePbi1Task('pbi1-t2'),
makePbi2Task('pbi2-t1', 'REVIEW'),
makePbi2Task('pbi2-t2'),
])
const result = await previewEnqueueAllAction(PRODUCT_ID)
expect(result).toMatchObject({ blockerIndex: 1, blockerReason: 'task-review' })
if (!('error' in result)) expect(result.tasks).toHaveLength(2)
})
it('demo-user → error, findMany niet aangeroepen', async () => {
mockGetSession.mockResolvedValue(SESSION_DEMO)
const result = await previewEnqueueAllAction(PRODUCT_ID)
expect(result).toMatchObject({ error: expect.stringContaining('demo') })
expect(mockFindManyTask).not.toHaveBeenCalled()
})
})
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') })
// =============================================================
// enqueueClaudeJobsBatchAction
// =============================================================
describe('enqueueClaudeJobsBatchAction — 2 PBI scenario', () => {
it('happy path: 2 taskIds → 2 QUEUED ClaudeJobs in invoervolgorde', async () => {
mockFindManyTask.mockResolvedValue([
makeBatchTask('pbi1-t1'),
makeBatchTask('pbi2-t1'),
])
mockTransaction.mockResolvedValue([
{ id: 'job-a', task_id: 'pbi1-t1' },
{ id: 'job-b', task_id: 'pbi2-t1' },
])
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['pbi1-t1', 'pbi2-t1'])
expect(result).toEqual({ success: true, count: 2 })
expect(mockExecuteRaw).toHaveBeenCalledTimes(2)
})
it('IDOR: taskId van niet-toegewezen story → error, geen transaction', async () => {
// Authorized tasks bevat maar 1 van de 2 gevraagde IDs
mockFindManyTask.mockResolvedValue([makeBatchTask('pbi1-t1')])
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['pbi1-t1', 'other-user-task'])
expect(result).toMatchObject({ error: expect.stringContaining('niet toegankelijk') })
expect(mockTransaction).not.toHaveBeenCalled()
})
it('taak met ACTIVE job wordt overgeslagen (idempotent)', async () => {
mockFindManyTask.mockResolvedValue([
makeBatchTask('pbi1-t1'),
makeBatchTask('pbi1-t2', true), // heeft actieve job → skip
makeBatchTask('pbi2-t1'),
])
mockTransaction.mockResolvedValue([
{ id: 'job-a', task_id: 'pbi1-t1' },
{ id: 'job-b', task_id: 'pbi2-t1' },
])
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['pbi1-t1', 'pbi1-t2', 'pbi2-t1'])
expect(result).toEqual({ success: true, count: 2 })
expect(mockExecuteRaw).toHaveBeenCalledTimes(2)
})
it('demo-user → error, geen transaction', async () => {
mockGetSession.mockResolvedValue(SESSION_DEMO)
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1'])
expect(result).toMatchObject({ error: expect.stringContaining('demo') })
expect(mockTransaction).not.toHaveBeenCalled()
})
})

View file

@ -1,46 +1,47 @@
/**
* 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,
mockFindFirstTask,
mockFindManyTask,
mockFindFirstProduct,
mockFindFirstSprint,
mockFindFirstJob,
mockCreateJob,
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,
} = vi.hoisted(() => ({
mockGetSession: vi.fn(),
mockFindFirstTask: vi.fn(),
mockFindManyTask: vi.fn(),
mockFindFirstProduct: vi.fn(),
mockFindFirstSprint: vi.fn(),
mockFindFirstJob: vi.fn(),
mockCreateJob: vi.fn(),
mockUpdateJob: vi.fn(),
mockExecuteRaw: vi.fn().mockResolvedValue(undefined),
}
})
mockTransaction: vi.fn(),
}))
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
vi.mock('@/lib/auth', () => ({ getSession: mockGetSession }))
vi.mock('@/lib/auth', () => ({
getSession: mockGetSession,
}))
vi.mock('@/lib/prisma', () => ({
prisma: {
task: { findFirst: mockFindFirstTask, findMany: mockFindManyTask },
product: { findFirst: mockFindFirstProduct },
sprint: { findFirst: mockFindFirstSprint },
claudeJob: {
findFirst: mockFindFirstJob,
create: mockCreateJob,
update: mockUpdateJob,
updateMany: mockUpdateManyJob,
},
sprintTaskExecution: {
updateMany: mockUpdateManySprintTaskExecution,
},
$transaction: mockTransaction,
$executeRaw: mockExecuteRaw,
$transaction: mockTransaction,
},
}))
@ -48,194 +49,394 @@ import {
enqueueClaudeJobAction,
enqueueAllTodoJobsAction,
cancelClaudeJobAction,
restartClaudeJobAction,
previewEnqueueAllAction,
enqueueClaudeJobsBatchAction,
} from '@/actions/claude-jobs'
const SESSION_USER = { userId: 'user-1', isDemo: false }
const SESSION_DEMO = { userId: 'demo-1', isDemo: true }
const TASK_ID = 'task-cuid-1'
const JOB_ID = 'job-cuid-1'
const PRODUCT_ID = 'product-cuid-1'
const MOCK_TASK = { id: TASK_ID, story: { product_id: PRODUCT_ID } }
const MOCK_JOB_QUEUED = { id: JOB_ID, status: 'QUEUED' as const, task_id: TASK_ID, product_id: PRODUCT_ID }
beforeEach(() => {
vi.clearAllMocks()
mockExecuteRaw.mockResolvedValue(undefined)
mockTransaction.mockImplementation(async (fn: (tx: unknown) => Promise<unknown>) =>
fn({
claudeJob: { updateMany: mockUpdateManyJob },
sprintTaskExecution: { updateMany: mockUpdateManySprintTaskExecution },
})
describe('enqueueClaudeJobAction', () => {
it('happy path: creates job with QUEUED status', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstTask.mockResolvedValue(MOCK_TASK)
mockFindFirstJob.mockResolvedValue(null)
mockCreateJob.mockResolvedValue({ id: JOB_ID })
const result = await enqueueClaudeJobAction(TASK_ID)
expect(result).toEqual({ success: true, jobId: JOB_ID })
expect(mockCreateJob).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ status: 'QUEUED', task_id: TASK_ID }) })
)
})
it('blocks demo user', async () => {
mockGetSession.mockResolvedValue(SESSION_DEMO)
const result = await enqueueClaudeJobAction(TASK_ID)
expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' })
expect(mockCreateJob).not.toHaveBeenCalled()
})
it('returns error when task not found', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstTask.mockResolvedValue(null)
const result = await enqueueClaudeJobAction(TASK_ID)
expect(result).toMatchObject({ error: 'Task niet gevonden' })
expect(mockCreateJob).not.toHaveBeenCalled()
})
it('idempotency: returns existing jobId when QUEUED job exists', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstTask.mockResolvedValue(MOCK_TASK)
mockFindFirstJob.mockResolvedValue({ id: JOB_ID })
const result = await enqueueClaudeJobAction(TASK_ID)
expect(result).toMatchObject({ error: 'Er loopt al een agent voor deze task', jobId: JOB_ID })
expect(mockCreateJob).not.toHaveBeenCalled()
})
it('allows new enqueue after terminal (DONE) job', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstTask.mockResolvedValue(MOCK_TASK)
mockFindFirstJob.mockResolvedValue(null) // no active job
mockCreateJob.mockResolvedValue({ id: 'new-job-id' })
const result = await enqueueClaudeJobAction(TASK_ID)
expect(result).toEqual({ success: true, jobId: 'new-job-id' })
})
})
describe('enqueueAllTodoJobsAction', () => {
it('happy path: scopes to active sprint + assignee, queues all queueable tasks', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
mockFindManyTask.mockResolvedValue([{ id: 'task-a' }, { id: 'task-b' }])
mockTransaction.mockResolvedValue([
{ id: 'job-a', task_id: 'task-a' },
{ id: 'job-b', task_id: 'task-b' },
])
const result = await enqueueAllTodoJobsAction(PRODUCT_ID)
expect(result).toEqual({ success: true, count: 2 })
expect(mockFindManyTask).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
status: 'TO_DO',
story: { sprint_id: 'sprint-1', assignee_id: SESSION_USER.userId },
}),
})
)
})
expect(mockExecuteRaw).toHaveBeenCalledTimes(2)
})
describe('enqueueClaudeJobAction (deprecated)', () => {
it('retourneert een deprecation-error', async () => {
const result = await enqueueClaudeJobAction('task-1')
expect(result).toMatchObject({ error: expect.stringContaining('Start Sprint') })
it('returns count=0 when product has no active sprint', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
mockFindFirstSprint.mockResolvedValue(null)
const result = await enqueueAllTodoJobsAction(PRODUCT_ID)
expect(result).toEqual({ success: true, count: 0 })
expect(mockFindManyTask).not.toHaveBeenCalled()
expect(mockTransaction).not.toHaveBeenCalled()
})
it('returns count=0 when no queueable tasks in sprint+assignee scope', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
mockFindManyTask.mockResolvedValue([])
const result = await enqueueAllTodoJobsAction(PRODUCT_ID)
expect(result).toEqual({ success: true, count: 0 })
expect(mockTransaction).not.toHaveBeenCalled()
expect(mockExecuteRaw).not.toHaveBeenCalled()
})
it('blocks demo user', async () => {
mockGetSession.mockResolvedValue(SESSION_DEMO)
const result = await enqueueAllTodoJobsAction(PRODUCT_ID)
expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' })
expect(mockTransaction).not.toHaveBeenCalled()
})
it('returns error when product not accessible', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstProduct.mockResolvedValue(null)
const result = await enqueueAllTodoJobsAction(PRODUCT_ID)
expect(result).toMatchObject({ error: 'Geen toegang tot dit product' })
expect(mockTransaction).not.toHaveBeenCalled()
})
})
describe('enqueueAllTodoJobsAction (deprecated)', () => {
it('retourneert een deprecation-error', async () => {
const result = await enqueueAllTodoJobsAction('prod-1')
expect(result).toMatchObject({ error: expect.stringContaining('Start Sprint') })
const makePbiTask = (id: string, status: string, pbiStatus = 'READY') => ({
id,
title: `Task ${id}`,
status,
story: { id: 'story-1', title: 'Story 1', code: 'ST-1', pbi: { id: 'pbi-1', status: pbiStatus, priority: 1, sort_order: 1.0 } },
})
describe('previewEnqueueAllAction', () => {
it('blocks demo user', async () => {
mockGetSession.mockResolvedValue(SESSION_DEMO)
const result = await previewEnqueueAllAction(PRODUCT_ID)
expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' })
expect(mockFindManyTask).not.toHaveBeenCalled()
})
it('returns error when product not accessible', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstProduct.mockResolvedValue(null)
const result = await previewEnqueueAllAction(PRODUCT_ID)
expect(result).toMatchObject({ error: 'Geen toegang tot dit product' })
expect(mockFindManyTask).not.toHaveBeenCalled()
})
it('returns empty tasks when no active sprint', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
mockFindFirstSprint.mockResolvedValue(null)
const result = await previewEnqueueAllAction(PRODUCT_ID)
expect(result).toEqual({ tasks: [], blockerIndex: null, blockerReason: null })
expect(mockFindManyTask).not.toHaveBeenCalled()
})
it('returns all tasks with no blocker when only TO_DO tasks', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
mockFindManyTask.mockResolvedValue([
makePbiTask('t1', 'TO_DO'),
makePbiTask('t2', 'TO_DO'),
])
const result = await previewEnqueueAllAction(PRODUCT_ID)
expect(result).toMatchObject({ blockerIndex: null, blockerReason: null })
if (!('error' in result)) expect(result.tasks).toHaveLength(2)
})
it('detects REVIEW task as blocker at correct index', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
mockFindManyTask.mockResolvedValue([
makePbiTask('t1', 'TO_DO'),
makePbiTask('t2', 'TO_DO'),
makePbiTask('t3', 'REVIEW'),
makePbiTask('t4', 'TO_DO'),
])
const result = await previewEnqueueAllAction(PRODUCT_ID)
expect(result).toMatchObject({ blockerIndex: 2, blockerReason: 'task-review' })
if (!('error' in result)) expect(result.tasks).toHaveLength(3)
})
it('detects BLOCKED PBI as blocker at first task of that PBI', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
mockFindManyTask.mockResolvedValue([
makePbiTask('t1', 'TO_DO', 'BLOCKED'),
makePbiTask('t2', 'TO_DO', 'BLOCKED'),
])
const result = await previewEnqueueAllAction(PRODUCT_ID)
expect(result).toMatchObject({ blockerIndex: 0, blockerReason: 'pbi-blocked' })
if (!('error' in result)) expect(result.tasks).toHaveLength(1)
})
it('queries without TO_DO filter to expose REVIEW tasks', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
mockFindManyTask.mockResolvedValue([])
await previewEnqueueAllAction(PRODUCT_ID)
expect(mockFindManyTask).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.not.objectContaining({ status: 'TO_DO' }),
})
)
})
})
const makeBatchTask = (id: string, hasActiveJob = false) => ({
id,
claude_jobs: hasActiveJob ? [{ id: 'job-active' }] : [],
})
describe('enqueueClaudeJobsBatchAction', () => {
it('happy path: 3 taskIds → 3 jobs in input order', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
mockFindManyTask.mockResolvedValue([
makeBatchTask('t1'),
makeBatchTask('t2'),
makeBatchTask('t3'),
])
mockTransaction.mockResolvedValue([
{ id: 'job-1', task_id: 't1' },
{ id: 'job-2', task_id: 't2' },
{ id: 'job-3', task_id: 't3' },
])
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1', 't2', 't3'])
expect(result).toEqual({ success: true, count: 3 })
expect(mockExecuteRaw).toHaveBeenCalledTimes(3)
})
it('blocks demo user', async () => {
mockGetSession.mockResolvedValue(SESSION_DEMO)
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1'])
expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' })
expect(mockTransaction).not.toHaveBeenCalled()
})
it('returns error when product not accessible', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstProduct.mockResolvedValue(null)
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1'])
expect(result).toMatchObject({ error: 'Geen toegang tot dit product' })
expect(mockTransaction).not.toHaveBeenCalled()
})
it('returns error when task belongs to another user (IDOR)', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
// Only 1 of 2 tasks authorized (other-user's task filtered out)
mockFindManyTask.mockResolvedValue([makeBatchTask('t1')])
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1', 't-other-user'])
expect(result).toMatchObject({ error: 'Een of meer taken zijn niet toegankelijk voor deze gebruiker' })
expect(mockTransaction).not.toHaveBeenCalled()
})
it('skips tasks with active jobs (idempotent)', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
mockFindManyTask.mockResolvedValue([
makeBatchTask('t1'),
makeBatchTask('t2', true), // has active job — skip
makeBatchTask('t3'),
])
mockTransaction.mockResolvedValue([
{ id: 'job-1', task_id: 't1' },
{ id: 'job-3', task_id: 't3' },
])
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1', 't2', 't3'])
expect(result).toEqual({ success: true, count: 2 })
expect(mockExecuteRaw).toHaveBeenCalledTimes(2)
})
it('returns count=0 for empty taskIds', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, [])
expect(result).toEqual({ success: true, count: 0 })
expect(mockFindFirstProduct).not.toHaveBeenCalled()
})
})
describe('cancelClaudeJobAction', () => {
it('cancelt een actieve job', async () => {
it('happy path: cancels QUEUED job', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstJob.mockResolvedValue({
id: 'job-1',
status: 'QUEUED',
task_id: 'task-1',
product_id: 'prod-1',
})
mockUpdateJob.mockResolvedValue(undefined)
mockFindFirstJob.mockResolvedValue(MOCK_JOB_QUEUED)
mockUpdateJob.mockResolvedValue({})
const result = await cancelClaudeJobAction('job-1')
const result = await cancelClaudeJobAction(JOB_ID)
expect(result).toEqual({ success: true })
expect(mockUpdateJob).toHaveBeenCalledWith({
where: { id: 'job-1' },
expect(mockUpdateJob).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: JOB_ID },
data: expect.objectContaining({ status: 'CANCELLED' }),
})
)
})
it('weigert demo-sessie', async () => {
mockGetSession.mockResolvedValue({ userId: 'demo', isDemo: true })
it('demo user is blocked', async () => {
mockGetSession.mockResolvedValue(SESSION_DEMO)
const result = await cancelClaudeJobAction('job-1')
expect(result).toMatchObject({ error: expect.stringContaining('demo') })
const result = await cancelClaudeJobAction(JOB_ID)
expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' })
expect(mockUpdateJob).not.toHaveBeenCalled()
})
it('retourneert error als job niet gevonden', async () => {
it('returns error when job not found (ownership check)', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstJob.mockResolvedValue(null)
const result = await cancelClaudeJobAction('nonexistent')
expect(result).toMatchObject({ error: expect.stringContaining('niet gevonden') })
const result = await cancelClaudeJobAction(JOB_ID)
expect(result).toMatchObject({ error: 'Job niet gevonden' })
expect(mockUpdateJob).not.toHaveBeenCalled()
})
it('weigert wanneer job niet meer actief is', async () => {
it('returns error when cancelling terminal (DONE) job', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstJob.mockResolvedValue({
id: 'job-1',
status: 'DONE',
task_id: 'task-1',
product_id: 'prod-1',
mockFindFirstJob.mockResolvedValue({ ...MOCK_JOB_QUEUED, status: 'DONE' as const })
const result = await cancelClaudeJobAction(JOB_ID)
expect(result).toMatchObject({ error: 'Alleen actieve jobs kunnen geannuleerd worden' })
expect(mockUpdateJob).not.toHaveBeenCalled()
})
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()
it('returns error when cancelling FAILED job', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstJob.mockResolvedValue({ ...MOCK_JOB_QUEUED, status: 'FAILED' as const })
const result = await cancelClaudeJobAction(JOB_ID)
expect(result).toMatchObject({ error: 'Alleen actieve jobs kunnen geannuleerd worden' })
})
})

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

@ -35,9 +35,7 @@ vi.mock('@/lib/prisma', () => ({
pbi: {
findFirst: vi.fn(),
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
},
story: {
findMany: vi.fn(),
@ -46,11 +44,6 @@ vi.mock('@/lib/prisma', () => ({
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),
@ -65,7 +58,6 @@ import {
deleteIdeaAction,
updateGrillMdAction,
updatePlanMdAction,
uploadPlanMdAction,
downloadIdeaMdAction,
startGrillJobAction,
startMakePlanJobAction,
@ -79,9 +71,9 @@ type MockIdea = {
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> }
pbi: { findFirst: ReturnType<typeof vi.fn>; findMany: ReturnType<typeof vi.fn>; create: 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> }
task: { findMany: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn> }
$transaction: ReturnType<typeof vi.fn>
$executeRaw: ReturnType<typeof vi.fn>
}
@ -252,97 +244,6 @@ body
})
})
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',
@ -520,7 +421,7 @@ body
.mockResolvedValueOnce({ id: 't-B1' })
})
it('happy: creates PBI + 2 stories + 3 tasks, links idea, returns ids; sort_order = parseCodeNumber(code)', async () => {
it('happy: creates PBI + 2 stories + 3 tasks, links idea, returns ids', async () => {
const r = await materializeIdeaPlanAction('idea-1')
expect(r).toMatchObject({
success: true,
@ -534,15 +435,6 @@ body
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 () => {
@ -584,69 +476,6 @@ body
})
})
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({

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,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,7 +1,7 @@
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('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) }))
vi.mock('iron-session', () => ({
getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }),
}))
@ -16,22 +16,16 @@ 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> } }
const mockSprint = prisma as unknown as { sprint: { findFirst: 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()
@ -45,7 +39,6 @@ describe('createSprintAction — date validation', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSprint.sprint.findFirst.mockResolvedValue(null)
mockSprint.sprint.findMany.mockResolvedValue([])
mockSprint.sprint.create.mockResolvedValue({ id: 'sprint-1' })
})

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

@ -49,7 +49,7 @@ const mockPrisma = prisma as unknown as {
$transaction: ReturnType<typeof vi.fn>
}
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

@ -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

@ -23,24 +23,6 @@ vi.mock('@/lib/prisma', () => ({
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(),
@ -62,24 +44,6 @@ const mockPrisma = prisma as unknown as {
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>
@ -190,14 +154,7 @@ describe('saveTask — edit met status-promotie', () => {
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' })
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
const result = await saveTask(
{ ...VALID_INPUT, status: 'DONE' },

View file

@ -0,0 +1,114 @@
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-005'),
}))
vi.mock('@/lib/product-access', () => ({
productAccessFilter: vi.fn().mockReturnValue({}),
}))
vi.mock('@/lib/code-server', () => ({
generateNextPbiCode: vi.fn(),
generateNextStoryCode: vi.fn(),
}))
vi.mock('@/lib/rate-limit', () => ({
enforceUserRateLimit: vi.fn().mockReturnValue(null),
}))
vi.mock('@/lib/prisma', () => ({
prisma: {
todo: {
findFirst: vi.fn(),
update: vi.fn(),
},
idea: {
create: vi.fn(),
},
ideaLog: { create: vi.fn() },
$transaction: vi.fn(),
},
}))
import { prisma } from '@/lib/prisma'
import { promoteTodoToIdeaAction } from '@/actions/todos'
type M = {
todo: { findFirst: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
idea: { create: ReturnType<typeof vi.fn> }
ideaLog: { create: ReturnType<typeof vi.fn> }
$transaction: ReturnType<typeof vi.fn>
}
const m = prisma as unknown as M
beforeEach(() => {
vi.clearAllMocks()
mockSession.userId = 'user-1'
mockSession.isDemo = false
m.$transaction.mockImplementation(async (arg: unknown) => {
if (typeof arg === 'function') {
return (arg as (tx: unknown) => unknown)(m)
}
return arg
})
})
describe('promoteTodoToIdeaAction', () => {
it('happy: archives todo, creates DRAFT idea, returns idea_id', async () => {
m.todo.findFirst.mockResolvedValueOnce({
id: 'todo-1',
title: 'My idea',
description: 'desc',
product_id: null,
archived: false,
})
m.idea.create.mockResolvedValueOnce({ id: 'idea-9', code: 'IDEA-005' })
const r = await promoteTodoToIdeaAction('todo-1')
expect(r).toMatchObject({ success: true, idea_id: 'idea-9', idea_code: 'IDEA-005' })
expect(m.todo.update).toHaveBeenCalledWith({
where: { id: 'todo-1' },
data: { archived: true },
})
})
it('rejects unauthenticated', async () => {
mockSession.userId = ''
const r = await promoteTodoToIdeaAction('todo-1')
expect(r).toMatchObject({ code: 401 })
})
it('rejects demo-user', async () => {
mockSession.isDemo = true
const r = await promoteTodoToIdeaAction('todo-1')
expect(r).toMatchObject({ code: 403 })
})
it('returns 404 when todo belongs to another user', async () => {
m.todo.findFirst.mockResolvedValueOnce(null)
const r = await promoteTodoToIdeaAction('todo-1')
expect(r).toMatchObject({ code: 404 })
})
it('rejects already-archived todo', async () => {
m.todo.findFirst.mockResolvedValueOnce({
id: 'todo-1',
title: 'x',
description: null,
product_id: null,
archived: true,
})
const r = await promoteTodoToIdeaAction('todo-1')
expect(r).toMatchObject({ code: 422 })
expect(m.idea.create).not.toHaveBeenCalled()
})
})

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

@ -0,0 +1,131 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockGetSession } = vi.hoisted(() => ({ mockGetSession: vi.fn() }))
vi.mock('@/lib/auth', () => ({ getSession: mockGetSession }))
vi.mock('@/lib/product-access', () => ({
getAccessibleProduct: vi.fn(),
}))
import { getAccessibleProduct } from '@/lib/product-access'
import type { NextRequest } from 'next/server'
import { GET } from '@/app/api/realtime/backlog/route'
import { useBacklogStore } from '@/stores/backlog-store'
const mockGetAccessibleProduct = getAccessibleProduct as ReturnType<typeof vi.fn>
function makeReq(productId?: string): NextRequest {
const url = productId
? `http://localhost/api/realtime/backlog?product_id=${productId}`
: 'http://localhost/api/realtime/backlog'
return {
signal: new AbortController().signal,
nextUrl: new URL(url),
} as unknown as NextRequest
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('GET /api/realtime/backlog', () => {
it('401 when not authenticated', async () => {
mockGetSession.mockResolvedValue({ userId: undefined, isDemo: false })
const res = await GET(makeReq('prod-1'))
expect(res.status).toBe(401)
expect(mockGetAccessibleProduct).not.toHaveBeenCalled()
})
it('400 when product_id is missing', async () => {
mockGetSession.mockResolvedValue({ userId: 'user-1', isDemo: false })
const res = await GET(makeReq())
expect(res.status).toBe(400)
})
it('403 when user has no access to the product', async () => {
mockGetSession.mockResolvedValue({ userId: 'user-1', isDemo: false })
mockGetAccessibleProduct.mockResolvedValue(null)
const res = await GET(makeReq('prod-1'))
expect(res.status).toBe(403)
expect(mockGetAccessibleProduct).toHaveBeenCalledWith('prod-1', 'user-1')
})
it('500 when DIRECT_URL and DATABASE_URL are absent', async () => {
mockGetSession.mockResolvedValue({ userId: 'user-1', isDemo: false })
mockGetAccessibleProduct.mockResolvedValue({ id: 'prod-1' })
const before = { DIRECT_URL: process.env.DIRECT_URL, DATABASE_URL: process.env.DATABASE_URL }
delete process.env.DIRECT_URL
delete process.env.DATABASE_URL
try {
const res = await GET(makeReq('prod-1'))
expect(res.status).toBe(500)
} finally {
if (before.DIRECT_URL !== undefined) process.env.DIRECT_URL = before.DIRECT_URL
if (before.DATABASE_URL !== undefined) process.env.DATABASE_URL = before.DATABASE_URL
}
})
it('demo user is allowed (no 403) when product is accessible', async () => {
mockGetSession.mockResolvedValue({ userId: 'demo-user', isDemo: true })
mockGetAccessibleProduct.mockResolvedValue({ id: 'prod-1' })
const before = { DIRECT_URL: process.env.DIRECT_URL, DATABASE_URL: process.env.DATABASE_URL }
delete process.env.DIRECT_URL
delete process.env.DATABASE_URL
try {
const res = await GET(makeReq('prod-1'))
// Fails at 500 (no DB URL) — not 403, confirming demo user is not blocked
expect(res.status).toBe(500)
} finally {
if (before.DIRECT_URL !== undefined) process.env.DIRECT_URL = before.DIRECT_URL
if (before.DATABASE_URL !== undefined) process.env.DATABASE_URL = before.DATABASE_URL
}
})
})
// shouldEmit scope filter — white-box unit tests
describe('shouldEmit scope filter (via backlog-store reducer)', () => {
it('applyChange: pbi INSERT adds to pbis array', () => {
useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: {} })
const pbi = { id: 'pbi-1', code: 'PBI-1', title: 'Test', priority: 2, created_at: new Date(), status: 'ready' as const }
useBacklogStore.getState().applyChange('pbi', 'I', pbi)
expect(useBacklogStore.getState().pbis).toHaveLength(1)
expect(useBacklogStore.getState().pbis[0].id).toBe('pbi-1')
})
it('applyChange: pbi UPDATE patches existing pbi', () => {
const pbi = { id: 'pbi-1', code: 'PBI-1', title: 'Old', priority: 2, created_at: new Date(), status: 'ready' as const }
useBacklogStore.setState({ pbis: [pbi], storiesByPbi: {}, tasksByStory: {} })
useBacklogStore.getState().applyChange('pbi', 'U', { id: 'pbi-1', title: 'New' })
expect(useBacklogStore.getState().pbis[0].title).toBe('New')
})
it('applyChange: pbi DELETE removes pbi', () => {
const pbi = { id: 'pbi-1', code: 'PBI-1', title: 'Test', priority: 2, created_at: new Date(), status: 'ready' as const }
useBacklogStore.setState({ pbis: [pbi], storiesByPbi: {}, tasksByStory: {} })
useBacklogStore.getState().applyChange('pbi', 'D', { id: 'pbi-1' })
expect(useBacklogStore.getState().pbis).toHaveLength(0)
})
it('applyChange: story INSERT adds to storiesByPbi', () => {
useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [] }, tasksByStory: {} })
const story = { id: 'story-1', code: 'ST-1', title: 'S', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: 'pbi-1', created_at: new Date() }
useBacklogStore.getState().applyChange('story', 'I', story)
expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(1)
})
it('applyChange: story DELETE removes from correct pbi bucket', () => {
const story = { id: 'story-1', code: 'ST-1', title: 'S', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: 'pbi-1', created_at: new Date() }
useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [story] }, tasksByStory: {} })
useBacklogStore.getState().applyChange('story', 'D', { id: 'story-1' })
expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(0)
})
it('applyChange: task UPDATE patches task across story buckets', () => {
const task = { id: 'task-1', title: 'Old', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: 'story-1', created_at: new Date() }
useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: { 'story-1': [task] } })
useBacklogStore.getState().applyChange('task', 'U', { id: 'task-1', status: 'IN_PROGRESS' })
expect(useBacklogStore.getState().tasksByStory['story-1'][0].status).toBe('IN_PROGRESS')
})
})

View file

@ -41,7 +41,7 @@ describe('POST /api/cron/cleanup-agent-artifacts', () => {
expect(mockPrisma.claudeJob.deleteMany).not.toHaveBeenCalled()
})
it('200 met juiste secret + deleteMany aangeroepen voor FAILED/CANCELLED/SKIPPED ouder dan 7 dagen', async () => {
it('200 met juiste secret + deleteMany aangeroepen voor FAILED/CANCELLED ouder dan 7 dagen', async () => {
mockPrisma.claudeJob.deleteMany.mockResolvedValue({ count: 5 })
const res = await POST(makeReq({ authorization: 'Bearer ' + SECRET }))
@ -51,7 +51,7 @@ describe('POST /api/cron/cleanup-agent-artifacts', () => {
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.status).toEqual({ in: ['FAILED', 'CANCELLED'] })
expect(arg.where.finished_at.lt).toBeInstanceOf(Date)
// cutoff should be approximately 7 days ago

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

@ -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,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,13 +8,10 @@ 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: {
@ -22,19 +19,6 @@ vi.mock('@/lib/prisma', () => ({
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,20 +38,17 @@ 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>
}
sprint: { findFirst: 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: {
@ -75,19 +56,6 @@ const mockPrisma = prisma as unknown as {
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>
}
storyLog: { create: ReturnType<typeof vi.fn> }
todo: { create: ReturnType<typeof vi.fn> }
$transaction: ReturnType<typeof vi.fn>
@ -196,7 +164,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 +243,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', () => {
@ -392,14 +410,7 @@ describe('PATCH /api/tasks/:id', () => {
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.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' })
const res = await patchTask(
makePatch('http://localhost/api/tasks/task-1', { status: 'done' }),
@ -408,3 +419,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

@ -9,24 +9,6 @@ vi.mock('@/lib/prisma', () => ({
},
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(),
@ -49,24 +31,6 @@ const mockPrisma = prisma as unknown as {
}
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>
@ -111,14 +75,7 @@ describe('PATCH /api/tasks/:id', () => {
})
// 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' })
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ 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)
@ -233,14 +190,7 @@ describe('PATCH /api/tasks/:id', () => {
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' })
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
const res = await patchTask(...makeRequest({ status: 'done' }))
expect(res.status).toBe(200)

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,21 +1,9 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { describe, it, expect, beforeEach } 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 { useSelectionStore } from '@/stores/selection-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>,
@ -34,7 +22,7 @@ function renderPane() {
}
beforeEach(() => {
setSelection(null, null)
useSelectionStore.setState({ selectedPbiId: null, selectedStoryId: null })
// Force mobile viewport
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 })
window.dispatchEvent(new Event('resize'))
@ -49,7 +37,7 @@ describe('BacklogSplitPane auto-switch', () => {
it('auto-switches to tab 1 when PBI is selected', () => {
const { rerender } = renderPane()
setSelection('pbi-1', null)
useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: null })
rerender(
<BacklogSplitPane
panes={PANES}
@ -64,7 +52,7 @@ describe('BacklogSplitPane auto-switch', () => {
it('auto-switches to tab 2 when story is selected', () => {
const { rerender } = renderPane()
setSelection('pbi-1', 'story-1')
useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: 'story-1' })
rerender(
<BacklogSplitPane
panes={PANES}
@ -79,11 +67,11 @@ describe('BacklogSplitPane auto-switch', () => {
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')
useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: 'story-1' })
const { rerender } = renderPane()
// Cascade-reset: new PBI → story clears
setSelection('pbi-2', null)
useSelectionStore.setState({ selectedPbiId: 'pbi-2', selectedStoryId: null })
rerender(
<BacklogSplitPane
panes={PANES}

View file

@ -1,11 +1,8 @@
// @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'
import { useSelectionStore } from '@/stores/selection-store'
import { useBacklogStore } from '@/stores/backlog-store'
// Mock next/navigation
const mockPush = vi.fn()
@ -25,16 +22,15 @@ Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, wri
// Mock server actions
vi.mock('@/actions/stories', () => ({
reorderStoriesAction: vi.fn().mockResolvedValue({ success: true }),
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('@/actions/tasks', () => ({ reorderTasksAction: vi.fn().mockResolvedValue({ success: true }) }))
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
// Mock dnd-kit (still needed for PBI panel which supports drag-and-drop)
// Mock dnd-kit
vi.mock('@dnd-kit/core', () => ({
DndContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
PointerSensor: class {},
@ -65,40 +61,19 @@ 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 STORIES = [
{ id: STORY_ID, code: 'ST-1', title: 'Eerste story', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: PBI_ID, 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() },
const TASKS = [
{ id: 'task-1', 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
useSelectionStore.setState({ selectedPbiId: null, selectedStoryId: null })
useBacklogStore.setState({
pbis: [],
storiesByPbi: { [PBI_ID]: STORIES },
tasksByStory: { [STORY_ID]: TASKS },
})
}
@ -114,40 +89,42 @@ describe('Backlog 3-pane integration', () => {
})
it('StoryPanel shows stories when PBI is selected', () => {
selectPbi(PBI_ID)
useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: null })
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)
it('clicking a story dispatches selectStory to the store', () => {
useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: null })
render(<StoryPanel productId={PRODUCT_ID} isDemo={false} />)
fireEvent.click(screen.getByText('Eerste story'))
expect(useProductWorkspaceStore.getState().context.activeStoryId).toBe(STORY_ID)
expect(useSelectionStore.getState().selectedStoryId).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('cascade-reset: selecting different PBI clears selectedStoryId', () => {
useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: STORY_ID })
useSelectionStore.getState().selectPbi(ALT_PBI_ID)
expect(useSelectionStore.getState().selectedStoryId).toBeNull()
})
it('TaskPanel shows tasks after story is selected', () => {
selectStory(PBI_ID, STORY_ID)
useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: 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)
useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: STORY_ID })
render(<TaskPanel productId={PRODUCT_ID} isDemo={false} closePath={`/products/${PRODUCT_ID}`} />)
useProductWorkspaceStore.getState().setActivePbi(ALT_PBI_ID)
// Reset via selectPbi
useSelectionStore.getState().selectPbi(ALT_PBI_ID)
// Re-render reflects new store state
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)
useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: 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')

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,40 +1,44 @@
// @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
}
})
}
import { useSelectionStore } from '@/stores/selection-store'
import { useBacklogStore } from '@/stores/backlog-store'
// Mock next/navigation
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mockPush }) }))
// Mock reorderTasksAction
vi.mock('@/actions/tasks', () => ({ reorderTasksAction: vi.fn().mockResolvedValue({ success: true }) }))
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
// Mock dnd-kit to avoid jsdom drag complexity
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,
}),
rectSortingStrategy: {},
sortableKeyboardCoordinates: {},
arrayMove: (arr: unknown[], from: number, to: number) => {
const next = [...arr]
next.splice(from, 1)
next.splice(to, 0, arr[from])
return next
},
}))
vi.mock('@dnd-kit/utilities', () => ({ CSS: { Transform: { toString: () => '' } } }))
import { TaskPanel } from '@/components/backlog/task-panel'
const PRODUCT_ID = 'prod-1'
@ -42,8 +46,8 @@ 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() },
{ id: 'task-1', title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_ID, created_at: new Date() },
{ id: 'task-2', 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) {
@ -53,7 +57,8 @@ function renderPanel(isDemo = false) {
describe('TaskPanel', () => {
beforeEach(() => {
mockPush.mockClear()
resetWorkspace()
useSelectionStore.setState({ selectedStoryId: null, selectedPbiId: null })
useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: {} })
})
it('shows empty state when no story is selected', () => {
@ -62,35 +67,40 @@ describe('TaskPanel', () => {
})
it('shows empty state with action when story selected but no tasks', () => {
setActiveStoryAndTasks(STORY_ID, [])
useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null })
useBacklogStore.setState({ tasksByStory: { [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)
useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null })
useBacklogStore.setState({ tasksByStory: { [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)
useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null })
useBacklogStore.setState({ tasksByStory: { [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)
useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null })
useBacklogStore.setState({ tasksByStory: { [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, [])
useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null })
useBacklogStore.setState({ tasksByStory: { [STORY_ID]: [] } })
renderPanel()
const buttons = screen.getAllByText('+ Nieuwe taak')
fireEvent.click(buttons[0])
@ -98,18 +108,29 @@ describe('TaskPanel', () => {
})
it('clicking task card calls router.push with editTask param', () => {
setActiveStoryAndTasks(STORY_ID, TASKS)
useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null })
useBacklogStore.setState({ tasksByStory: { [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, [])
useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null })
useBacklogStore.setState({ tasksByStory: { [STORY_ID]: [] } })
renderPanel(true)
const btn = screen.getAllByText('+ Nieuwe taak')[0].closest('button')
expect(btn).toBeTruthy()
expect((btn as HTMLButtonElement).disabled).toBe(true)
})
it('cards have no drag listeners in demo mode (whole-card drag disabled)', () => {
useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null })
useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } })
// In demo mode, listeners ({} from useSortable mock) are not spread onto the card.
// The mock always returns empty listeners, so we just verify the cards render without error.
renderPanel(true)
expect(screen.getByText('Eerste taak')).toBeTruthy()
expect(screen.getByText('Tweede taak')).toBeTruthy()
})
})

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,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,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

@ -94,9 +94,6 @@ const TODO_TASK = {
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 = {

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

@ -65,9 +65,6 @@ const baseTask: SoloTask = {
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 = {

View file

@ -1,35 +1,28 @@
// @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 },
},
}
// Helper to set a cookie
function setCookie(key: string, value: string) {
Object.defineProperty(document, 'cookie', {
writable: true,
configurable: true,
value: `sp:${key}=${encodeURIComponent(value)}`,
})
}
function resetStore() {
useUserSettingsStore.setState((s) => {
s.entities.settings = {}
s.context.hydrated = false
s.context.isDemo = false
function clearCookies() {
Object.defineProperty(document, 'cookie', {
writable: true,
configurable: true,
value: '',
})
}
describe('SplitPane', () => {
beforeEach(() => {
resetStore()
clearCookies()
// Default: desktop viewport
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1440 })
window.dispatchEvent(new Event('resize'))
@ -71,8 +64,9 @@ describe('SplitPane', () => {
expect(dividers).toHaveLength(2)
})
it('restores splits from user-settings store on mount', () => {
seedPositions('test-restore', [40, 60])
it('restores splits from cookie on mount', () => {
const stored = JSON.stringify([40, 60])
setCookie('test-restore', stored)
const { container } = render(
<SplitPane
@ -87,9 +81,8 @@ describe('SplitPane', () => {
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])
it('falls back to defaultSplit when cookie is invalid', () => {
setCookie('test-invalid', 'not-valid-json')
const { container } = render(
<SplitPane

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,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

@ -3,14 +3,45 @@ 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('requireAdmin', () => {
beforeEach(() => {
getSessionMock.mockReset()
isPairedSessionExpiredMock.mockReset()
redirectMock.mockClear()
})
afterEach(() => {
vi.resetModules()
})
it('redirect /dashboard als userId ontbreekt', async () => {
getSessionMock.mockResolvedValue({ userId: undefined, isAdmin: false })
const { requireAdmin } = await import('@/lib/auth-guard')
await expect(requireAdmin()).rejects.toThrow('REDIRECT_CALLED')
expect(redirectMock).toHaveBeenCalledWith('/dashboard')
})
it('redirect /dashboard als isAdmin false is', async () => {
getSessionMock.mockResolvedValue({ userId: 'u1', isAdmin: false })
const { requireAdmin } = await import('@/lib/auth-guard')
await expect(requireAdmin()).rejects.toThrow('REDIRECT_CALLED')
expect(redirectMock).toHaveBeenCalledWith('/dashboard')
})
it('geeft sessie terug als isAdmin true is', async () => {
const sess = { userId: 'u1', isAdmin: true }
getSessionMock.mockResolvedValue(sess)
const { requireAdmin } = await import('@/lib/auth-guard')
const result = await requireAdmin()
expect(result).toBe(sess)
expect(redirectMock).not.toHaveBeenCalled()
})
})
describe('requireSession', () => {
beforeEach(() => {

View file

@ -34,7 +34,7 @@ describe('chart-colors', () => {
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',
'queued', 'claimed', 'running', 'done', 'failed', 'cancelled',
]
for (const key of keys) {
expect(JOB_STATUS_COLORS[key]).toBeTruthy()

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

@ -62,41 +62,6 @@ body
}
})
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:

View file

@ -128,21 +128,4 @@ describe('ideaPlanMdFrontmatterSchema', () => {
})
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

@ -41,7 +41,6 @@ describe('canTransition', () => {
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', () => {
@ -54,20 +53,12 @@ describe('canTransition', () => {
expect(canTransition('PLAN_FAILED', 'GRILLED')).toBe(true)
})
it('allows PLANNED → PLAN_READY (relink) and PLANNED → GRILLING (re-grill)', () => {
it('only allows PLANNED → PLAN_READY (relink path)', () => {
expect(canTransition('PLANNED', 'PLAN_READY')).toBe(true)
expect(canTransition('PLANNED', 'GRILLING')).toBe(true)
expect(canTransition('PLANNED', 'GRILLING')).toBe(false)
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)

View file

@ -48,7 +48,7 @@ describe('getJobsPerDay', () => {
// 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,
d => d.done + d.failed + d.queued + d.claimed + d.running + d.cancelled > 0,
)
expect(nonZero).toHaveLength(3)

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,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

@ -27,14 +27,13 @@ describe('job-status mappers', () => {
expect(jobStatusFromApi('QUEUED')).toBe('QUEUED')
})
it('maps all 7 DB statuses to API', () => {
it('maps all 6 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', () => {

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,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)
})
})

View file

@ -78,8 +78,8 @@ describe('task-status mappers', () => {
expect(pbiStatusFromApi('todo')).toBeNull()
})
it('exposes alle vier API values', () => {
expect(PBI_STATUS_API_VALUES).toEqual(['ready', 'blocked', 'failed', 'done'])
it('exposes exactly three API values', () => {
expect(PBI_STATUS_API_VALUES).toEqual(['ready', 'blocked', 'done'])
})
})
})

View file

@ -8,23 +8,6 @@ vi.mock('@/lib/prisma', () => ({
},
story: {
findUniqueOrThrow: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
},
pbi: {
findUniqueOrThrow: 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(),
@ -32,35 +15,27 @@ vi.mock('@/lib/prisma', () => ({
}))
import { prisma } from '@/lib/prisma'
import { propagateStatusUpwards } from '@/lib/tasks-status-update'
import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update'
type MockedPrisma = {
task: { update: ReturnType<typeof vi.fn>; findMany: ReturnType<typeof vi.fn> }
const mockPrisma = prisma as unknown as {
task: {
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>
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 mockPrisma = prisma as unknown as MockedPrisma
beforeEach(() => {
vi.clearAllMocks()
// Pass-through: $transaction(run) just calls run with the mocked prisma client.
mockPrisma.$transaction.mockImplementation(async (run: (tx: typeof prisma) => Promise<unknown>) => {
return run(prisma)
})
})
const TASK_BASE = {
id: 'task-1',
@ -69,267 +44,110 @@ const TASK_BASE = {
implementation_plan: null,
}
beforeEach(() => {
vi.clearAllMocks()
mockPrisma.$transaction.mockImplementation(
async (run: (tx: typeof prisma) => Promise<unknown>) => run(prisma),
)
})
describe('propagateStatusUpwards — story-niveau', () => {
it('zet story op DONE wanneer alle siblings DONE zijn', async () => {
describe('updateTaskStatusWithStoryPromotion', () => {
it('promotes story to DONE when last sibling task transitions to DONE', async () => {
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' })
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.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
const result = await propagateStatusUpwards('task-1', 'DONE')
const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE')
expect(result.storyChanged).toBe(true)
expect(result.storyStatusChange).toBe('promoted')
expect(result.storyId).toBe('story-1')
expect(mockPrisma.story.update).toHaveBeenCalledWith({
where: { id: 'story-1' },
data: { status: 'DONE' },
})
})
it('zet story op FAILED wanneer een task FAILED is, ongeacht andere tasks', async () => {
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'FAILED' })
mockPrisma.task.findMany.mockResolvedValue([
{ status: 'FAILED' },
{ status: 'DONE' },
{ status: 'TO_DO' },
])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
id: 'story-1',
status: 'IN_SPRINT',
pbi_id: 'pbi-1',
sprint_id: null,
})
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
mockPrisma.story.findMany.mockResolvedValue([{ status: 'FAILED' }])
const result = await propagateStatusUpwards('task-1', 'FAILED')
expect(result.storyChanged).toBe(true)
expect(mockPrisma.story.update).toHaveBeenCalledWith({
where: { id: 'story-1' },
data: { status: 'FAILED' },
})
})
it('houdt story op IN_SPRINT als nog niet alle tasks DONE en geen FAILED', async () => {
it('does not promote when story is already DONE (idempotent)', async () => {
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' })
mockPrisma.task.findMany.mockResolvedValue([
{ status: 'DONE' },
{ status: 'TO_DO' },
])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
id: 'story-1',
status: 'IN_SPRINT',
pbi_id: 'pbi-1',
sprint_id: 'sprint-1',
})
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
mockPrisma.story.findMany.mockImplementation(async (args: { where?: { pbi_id?: string; sprint_id?: string } }) => {
if (args.where?.pbi_id) return [{ status: 'IN_SPRINT' }]
if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }]
return []
})
mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'OPEN' })
;(mockPrisma.pbi as unknown as { findMany: ReturnType<typeof vi.fn> }).findMany = vi.fn().mockResolvedValue([{ status: 'READY' }])
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' })
const result = await propagateStatusUpwards('task-1', 'DONE')
const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE')
expect(result.storyChanged).toBe(false)
expect(result.storyStatusChange).toBe(null)
expect(mockPrisma.story.update).not.toHaveBeenCalled()
})
it('demoot story uit DONE als een task terug naar TO_DO gaat', async () => {
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'TO_DO' })
it('does not promote when not all siblings are DONE', async () => {
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' })
mockPrisma.task.findMany.mockResolvedValue([
{ status: 'TO_DO' },
{ status: 'DONE' },
{ status: 'IN_PROGRESS' },
])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE')
expect(result.storyStatusChange).toBe(null)
expect(mockPrisma.story.update).not.toHaveBeenCalled()
})
it('demotes story to IN_SPRINT when a task moves out of DONE on a DONE story', async () => {
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' })
mockPrisma.task.findMany.mockResolvedValue([
{ status: 'IN_PROGRESS' },
{ status: 'DONE' },
])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
id: 'story-1',
status: 'DONE',
pbi_id: 'pbi-1',
sprint_id: 'sprint-1',
})
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'DONE' })
mockPrisma.story.findMany.mockImplementation(async (args: { where?: { pbi_id?: string; sprint_id?: string } }) => {
if (args.where?.pbi_id) return [{ status: 'IN_SPRINT' }, { status: 'DONE' }]
if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }]
return []
})
mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'CLOSED' })
;(mockPrisma.pbi as unknown as { findMany: ReturnType<typeof vi.fn> }).findMany = vi.fn().mockResolvedValue([{ status: 'READY' }])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' })
const result = await propagateStatusUpwards('task-1', 'TO_DO')
const result = await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS')
expect(result.storyChanged).toBe(true)
expect(result.storyStatusChange).toBe('demoted')
expect(mockPrisma.story.update).toHaveBeenCalledWith({
where: { id: 'story-1' },
data: { status: 'IN_SPRINT' },
})
})
it('zet story op OPEN als sprint_id null is en niet DONE/FAILED', async () => {
it('does not demote when story is not DONE', async () => {
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' })
mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
id: 'story-1',
status: 'IN_SPRINT',
pbi_id: 'pbi-1',
sprint_id: null,
})
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
mockPrisma.story.findMany.mockResolvedValue([{ status: 'OPEN' }])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
const result = await propagateStatusUpwards('task-1', 'IN_PROGRESS')
const result = await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS')
expect(result.storyChanged).toBe(true)
expect(mockPrisma.story.update).toHaveBeenCalledWith({
where: { id: 'story-1' },
data: { status: 'OPEN' },
})
})
})
describe('propagateStatusUpwards — PBI BLOCKED met rust laten', () => {
it('overschrijft een handmatig BLOCKED PBI niet', async () => {
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' })
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
id: 'story-1',
status: 'IN_SPRINT',
pbi_id: 'pbi-1',
sprint_id: null,
})
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'BLOCKED' })
const result = await propagateStatusUpwards('task-1', 'DONE')
expect(result.pbiChanged).toBe(false)
expect(mockPrisma.pbi.update).not.toHaveBeenCalled()
})
})
describe('propagateStatusUpwards — sprint cascade tot SprintRun', () => {
it('zet bij FAILED de hele keten op FAILED en cancelt sibling-jobs', async () => {
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'FAILED' })
mockPrisma.task.findMany.mockResolvedValue([
{ status: 'FAILED' },
{ status: 'DONE' },
])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
id: 'story-1',
status: 'IN_SPRINT',
pbi_id: 'pbi-1',
sprint_id: 'sprint-1',
})
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
mockPrisma.story.findMany.mockImplementation(async (args: { where?: { pbi_id?: string; sprint_id?: string } }) => {
if (args.where?.pbi_id) return [{ status: 'FAILED' }]
if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }]
return []
})
mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'OPEN' })
// findMany on pbi:
;(mockPrisma.pbi as unknown as { findMany: ReturnType<typeof vi.fn> }).findMany = vi.fn().mockResolvedValue([{ status: 'FAILED' }])
mockPrisma.claudeJob.findFirst.mockResolvedValue({ id: 'job-1', sprint_run_id: 'run-1' })
mockPrisma.sprintRun.findUnique.mockResolvedValue({ id: 'run-1', status: 'RUNNING' })
const result = await propagateStatusUpwards('task-1', 'FAILED')
expect(result.storyChanged).toBe(true)
expect(result.pbiChanged).toBe(true)
expect(result.sprintChanged).toBe(true)
expect(result.sprintRunChanged).toBe(true)
expect(mockPrisma.sprintRun.update).toHaveBeenCalledWith(expect.objectContaining({
where: { id: 'run-1' },
data: expect.objectContaining({ status: 'FAILED', failed_task_id: 'task-1' }),
}))
expect(mockPrisma.claudeJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
sprint_run_id: 'run-1',
status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] },
id: { not: 'job-1' },
}),
data: expect.objectContaining({ status: 'CANCELLED' }),
}))
expect(result.storyStatusChange).toBe(null)
expect(mockPrisma.story.update).not.toHaveBeenCalled()
})
it('zet bij alle DONE de SprintRun op DONE en Sprint op COMPLETED', async () => {
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' })
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
id: 'story-1',
status: 'IN_SPRINT',
pbi_id: 'pbi-1',
sprint_id: 'sprint-1',
})
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
mockPrisma.story.findMany.mockImplementation(async (args: { where?: { pbi_id?: string; sprint_id?: string } }) => {
if (args.where?.pbi_id) return [{ status: 'DONE' }]
if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }]
return []
})
mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'OPEN' })
;(mockPrisma.pbi as unknown as { findMany: ReturnType<typeof vi.fn> }).findMany = vi.fn().mockResolvedValue([{ status: 'DONE' }])
mockPrisma.claudeJob.findFirst.mockResolvedValue({ id: 'job-1', sprint_run_id: 'run-1' })
mockPrisma.sprintRun.findUnique.mockResolvedValue({ id: 'run-1', status: 'RUNNING' })
it('updates the task regardless of story-status change', async () => {
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' })
mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }])
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
const result = await propagateStatusUpwards('task-1', 'DONE')
await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS')
expect(result.sprintRunChanged).toBe(true)
expect(mockPrisma.sprint.update).toHaveBeenCalledWith(expect.objectContaining({
where: { id: 'sprint-1' },
data: expect.objectContaining({ status: 'CLOSED' }),
}))
expect(mockPrisma.sprintRun.update).toHaveBeenCalledWith(expect.objectContaining({
where: { id: 'run-1' },
data: expect.objectContaining({ status: 'DONE' }),
}))
expect(mockPrisma.task.update).toHaveBeenCalledWith({
where: { id: 'task-1' },
data: { status: 'IN_PROGRESS' },
select: expect.any(Object),
})
})
})
describe('propagateStatusUpwards — transactionele aanroep', () => {
it('gebruikt de meegegeven transaction client', async () => {
it('uses the provided transaction client when passed', async () => {
const tx = {
task: { 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() },
story: { findUniqueOrThrow: vi.fn(), update: vi.fn() },
}
tx.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' })
tx.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }])
tx.story.findUniqueOrThrow.mockResolvedValue({
id: 'story-1',
status: 'OPEN',
pbi_id: 'pbi-1',
sprint_id: null,
})
tx.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
tx.story.findMany.mockResolvedValue([{ status: 'OPEN' }])
tx.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' })
tx.task.findMany.mockResolvedValue([{ status: 'DONE' }])
tx.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await propagateStatusUpwards('task-1', 'IN_PROGRESS', tx as any)
const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE', tx as any)
expect(result.storyChanged).toBe(false)
// $transaction wordt niet aangeroepen wanneer caller al een tx meegeeft.
expect(result.storyStatusChange).toBe('promoted')
// $transaction should NOT be called when caller already provides a tx.
expect(mockPrisma.$transaction).not.toHaveBeenCalled()
expect(tx.story.update).toHaveBeenCalledWith({
where: { id: 'story-1' },
data: { status: 'DONE' },
})
})
})

View file

@ -1,147 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
buildMigrationPatch,
clearLegacyStorage,
} from '@/lib/user-settings-migration'
function clearAllCookies() {
for (const part of document.cookie.split(';')) {
const eq = part.indexOf('=')
const name = (eq < 0 ? part : part.slice(0, eq)).trim()
if (name) document.cookie = `${name}=; max-age=0; path=/`
}
}
beforeEach(() => {
localStorage.clear()
clearAllCookies()
})
afterEach(() => {
localStorage.clear()
clearAllCookies()
})
describe('buildMigrationPatch', () => {
it('returns no data when nothing is stored', () => {
const result = buildMigrationPatch()
expect(result.hasData).toBe(false)
expect(result.patch).toEqual({})
expect(result.legacyKeys).toEqual([])
})
it('skips after marker is set to current version', () => {
localStorage.setItem('scrum4me:sprint_pb_filter_status', 'all')
localStorage.setItem('scrum4me:settings_migrated', 'v2')
const result = buildMigrationPatch()
expect(result.hasData).toBe(false)
})
it('still runs when only the v1 marker is set (re-migration)', () => {
localStorage.setItem('scrum4me:sprint_pb_filter_status', 'all')
localStorage.setItem('scrum4me:settings_migrated', 'v1')
const result = buildMigrationPatch()
expect(result.hasData).toBe(true)
})
it('extracts split-pane cookies into layout', () => {
document.cookie = `sp:backlog-p1=${encodeURIComponent(JSON.stringify([25, 35, 40]))}; path=/`
const result = buildMigrationPatch()
expect(result.patch.layout?.splitPanePositions).toEqual({ 'backlog-p1': [25, 35, 40] })
expect(result.legacyCookies).toContain('sp:backlog-p1')
})
it('ignores split-pane cookies that do not sum to 100', () => {
document.cookie = `sp:bad=${encodeURIComponent(JSON.stringify([10, 20]))}; path=/`
const result = buildMigrationPatch()
expect(result.patch.layout).toBeUndefined()
})
it('extracts active-sprint cookies into layout.activeSprints', () => {
document.cookie = `active_sprint_prod-1=sprint-abc; path=/`
document.cookie = `active_sprint_prod-2=sprint-xyz; path=/`
const result = buildMigrationPatch()
expect(result.patch.layout?.activeSprints).toEqual({
'prod-1': 'sprint-abc',
'prod-2': 'sprint-xyz',
})
expect(result.legacyCookies).toContain('active_sprint_prod-1')
})
it('extracts sprint backlog prefs into nested patch', () => {
localStorage.setItem('scrum4me:sprint_pb_filter_status', 'all')
localStorage.setItem('scrum4me:sprint_pb_sort', 'priority')
localStorage.setItem('scrum4me:sprint_pb_sort_dir', 'desc')
localStorage.setItem('scrum4me:sprint_pb_collapsed', JSON.stringify(['pbi-1', 'pbi-2']))
localStorage.setItem('scrum4me:sprint_pb_filter_popover_open', 'true')
const result = buildMigrationPatch()
expect(result.hasData).toBe(true)
expect(result.patch.views?.sprintBacklog).toEqual({
filterStatus: 'all',
sort: 'priority',
sortDir: 'desc',
collapsedPbis: ['pbi-1', 'pbi-2'],
filterPopoverOpen: true,
})
expect(result.legacyKeys).toContain('scrum4me:sprint_pb_filter_status')
expect(result.legacyKeys).toContain('scrum4me:sprint_pb_collapsed')
})
it('extracts pbi-list prefs', () => {
localStorage.setItem('scrum4me:pbi_sort', 'date')
localStorage.setItem('scrum4me:pbi_filter_priority', '2')
const result = buildMigrationPatch()
expect(result.patch.views?.pbiList).toEqual({ sort: 'date', filterPriority: 2 })
})
it('extracts story_sort', () => {
localStorage.setItem('scrum4me:story_sort', 'code')
const result = buildMigrationPatch()
expect(result.patch.views?.storyPanel).toEqual({ sort: 'code' })
})
it('extracts debug-mode', () => {
localStorage.setItem('scrum4me:debug-mode', 'true')
const result = buildMigrationPatch()
expect(result.patch.devTools).toEqual({ debugMode: true })
})
it('extracts jobs-column dynamic prefixes from CSV values', () => {
localStorage.setItem('queue_filter_kind', 'TASK_IMPLEMENTATION,SPRINT_IMPLEMENTATION')
localStorage.setItem('queue_filter_status', 'queued,running')
const result = buildMigrationPatch()
expect(result.patch.views?.jobsColumns?.['queue']).toEqual({
kinds: ['TASK_IMPLEMENTATION', 'SPRINT_IMPLEMENTATION'],
statuses: ['queued', 'running'],
})
})
it('ignores invalid enum values', () => {
localStorage.setItem('scrum4me:sprint_pb_filter_status', 'BOGUS')
const result = buildMigrationPatch()
expect(result.hasData).toBe(false)
})
})
describe('clearLegacyStorage', () => {
it('removes given keys and cookies and sets the v2 marker', () => {
localStorage.setItem('scrum4me:sprint_pb_sort', 'code')
document.cookie = 'sp:x=foo; path=/'
clearLegacyStorage(['scrum4me:sprint_pb_sort'], ['sp:x'])
expect(localStorage.getItem('scrum4me:sprint_pb_sort')).toBeNull()
expect(document.cookie).not.toContain('sp:x=foo')
expect(localStorage.getItem('scrum4me:settings_migrated')).toBe('v2')
})
it('sets marker even with empty lists (no-op migration)', () => {
clearLegacyStorage([], [])
expect(localStorage.getItem('scrum4me:settings_migrated')).toBe('v2')
})
})

View file

@ -1,209 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
DEFAULT_USER_SETTINGS,
UserSettingsSchema,
mergeSettings,
parseUserSettings,
type UserSettings,
} from '@/lib/user-settings'
describe('mergeSettings', () => {
it('returns the patch when previous is empty', () => {
const result = mergeSettings({}, { views: { sprintBacklog: { sort: 'code' } } })
expect(result).toEqual({ views: { sprintBacklog: { sort: 'code' } } })
})
it('preserves existing keys when patch only sets new ones', () => {
const prev: UserSettings = { views: { sprintBacklog: { sort: 'code' } } }
const result = mergeSettings(prev, {
views: { pbiList: { sort: 'date' } },
})
expect(result).toEqual({
views: {
sprintBacklog: { sort: 'code' },
pbiList: { sort: 'date' },
},
})
})
it('merges nested objects without overwriting siblings', () => {
const prev: UserSettings = {
views: { sprintBacklog: { sort: 'code', sortDir: 'asc' } },
}
const result = mergeSettings(prev, {
views: { sprintBacklog: { sort: 'priority' } },
})
expect(result).toEqual({
views: { sprintBacklog: { sort: 'priority', sortDir: 'asc' } },
})
})
it('replaces arrays instead of appending', () => {
const prev: UserSettings = {
views: { sprintBacklog: { collapsedPbis: ['a', 'b'] } },
}
const result = mergeSettings(prev, {
views: { sprintBacklog: { collapsedPbis: ['c'] } },
})
expect(result.views?.sprintBacklog?.collapsedPbis).toEqual(['c'])
})
it('does not mutate the previous object', () => {
const prev: UserSettings = { views: { sprintBacklog: { sort: 'code' } } }
const snapshot = JSON.parse(JSON.stringify(prev))
mergeSettings(prev, { views: { sprintBacklog: { sortDir: 'desc' } } })
expect(prev).toEqual(snapshot)
})
it('skips undefined values in the patch', () => {
const prev: UserSettings = { views: { sprintBacklog: { sort: 'code' } } }
const result = mergeSettings(prev, { views: undefined })
expect(result).toEqual(prev)
})
})
describe('parseUserSettings', () => {
it('returns defaults for null', () => {
expect(parseUserSettings(null)).toEqual(DEFAULT_USER_SETTINGS)
})
it('returns defaults for undefined', () => {
expect(parseUserSettings(undefined)).toEqual(DEFAULT_USER_SETTINGS)
})
it('returns defaults for invalid input', () => {
expect(parseUserSettings({ views: { sprintBacklog: { filterStatus: 'BOGUS' } } }))
.toEqual(DEFAULT_USER_SETTINGS)
})
it('passes valid settings through', () => {
const valid = { views: { sprintBacklog: { sort: 'code' as const } } }
expect(parseUserSettings(valid)).toEqual(valid)
})
})
describe('UserSettingsSchema', () => {
it('rejects unknown top-level keys', () => {
const result = UserSettingsSchema.safeParse({ unknown: 1 })
expect(result.success).toBe(false)
})
it('accepts an empty object', () => {
expect(UserSettingsSchema.safeParse({}).success).toBe(true)
})
it('accepts the full shape', () => {
const result = UserSettingsSchema.safeParse({
views: {
sprintBacklog: {
filterPriority: 1,
filterStatus: 'OPEN',
sort: 'code',
sortDir: 'asc',
collapsedPbis: ['x'],
filterPopoverOpen: true,
},
pbiList: { sort: 'priority', filterPriority: 'all', filterStatus: 'ready', sortDir: 'desc' },
storyPanel: { sort: 'date' },
jobsColumns: { 'queue:active': { kinds: ['TASK_IMPLEMENTATION'], statuses: [] } },
jobs: { timeFilter: '24h' },
ideasList: { filterStatuses: ['draft', 'planned'] },
},
devTools: { debugMode: true },
layout: {
splitPanePositions: { 'backlog-pid': [25, 35, 40] },
activeSprints: { 'product-1': 'sprint-1' },
},
})
expect(result.success).toBe(true)
})
it('accepts views.jobs.timeFilter and returns it via parseUserSettings', () => {
const input = { views: { jobs: { timeFilter: '1h' as const } } }
const result = parseUserSettings(input)
expect(result).toEqual(input)
})
it('rejects an invalid views.jobs.timeFilter value', () => {
const result = UserSettingsSchema.safeParse({ views: { jobs: { timeFilter: 'BOGUS' } } })
expect(result.success).toBe(false)
})
it('accepts layout-only settings', () => {
expect(UserSettingsSchema.safeParse({
layout: { splitPanePositions: { x: [50, 50] }, activeSprints: { p: 's' } },
}).success).toBe(true)
})
it('accepts null values in activeSprints (explicit "no active sprint")', () => {
const result = UserSettingsSchema.safeParse({
layout: { activeSprints: { 'product-1': null, 'product-2': 'sprint-2' } },
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.layout?.activeSprints).toEqual({
'product-1': null,
'product-2': 'sprint-2',
})
}
})
it('accepts pendingSprintDraft with per-PBI intent and overrides', () => {
const result = UserSettingsSchema.safeParse({
workflow: {
pendingSprintDraft: {
'product-1': {
goal: 'Sprint goal',
pbiIntent: { pbiA: 'all', pbiB: 'none' },
storyOverrides: {
pbiA: { add: [], remove: ['story-1'] },
pbiB: { add: ['story-2'], remove: [] },
},
},
},
},
})
expect(result.success).toBe(true)
})
it('fills empty defaults for pbiIntent and storyOverrides in draft', () => {
const result = UserSettingsSchema.safeParse({
workflow: { pendingSprintDraft: { 'product-1': { goal: 'g' } } },
})
expect(result.success).toBe(true)
if (result.success) {
const draft = result.data.workflow?.pendingSprintDraft?.['product-1']
expect(draft?.pbiIntent).toEqual({})
expect(draft?.storyOverrides).toEqual({})
}
})
it('rejects pendingSprintDraft with empty goal', () => {
const result = UserSettingsSchema.safeParse({
workflow: { pendingSprintDraft: { 'p': { goal: '' } } },
})
expect(result.success).toBe(false)
})
it('rejects an invalid ideasList.filterStatuses value', () => {
const result = UserSettingsSchema.safeParse({ views: { ideasList: { filterStatuses: ['BOGUS'] } } })
expect(result.success).toBe(false)
})
it('accepts an empty ideasList.filterStatuses array', () => {
const result = UserSettingsSchema.safeParse({ views: { ideasList: { filterStatuses: [] } } })
expect(result.success).toBe(true)
})
it('rejects unknown intent value', () => {
const result = UserSettingsSchema.safeParse({
workflow: {
pendingSprintDraft: {
p: { goal: 'x', pbiIntent: { a: 'partial' } },
},
},
})
expect(result.success).toBe(false)
})
})

View file

@ -0,0 +1,160 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { useBacklogStore } from '@/stores/backlog-store'
import type { BacklogPbi, BacklogStory, BacklogTask } from '@/stores/backlog-store'
const PBI: BacklogPbi = {
id: 'pbi-1',
code: 'PBI-1',
title: 'Realtime PBI',
priority: 2,
description: 'desc',
created_at: new Date('2024-01-01T00:00:00Z'),
status: 'ready',
}
const STORY: BacklogStory = {
id: 'story-1',
code: 'ST-1',
title: 'Realtime story',
description: null,
acceptance_criteria: null,
priority: 2,
status: 'OPEN',
pbi_id: 'pbi-1',
created_at: new Date('2024-01-01T00:00:00Z'),
}
const TASK: BacklogTask = {
id: 'task-1',
title: 'Realtime task',
description: null,
priority: 2,
status: 'TO_DO',
sort_order: 1,
story_id: 'story-1',
created_at: new Date('2024-01-01T00:00:00Z'),
}
beforeEach(() => {
useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: {} })
})
// ---------------------------------------------------------------------------
// PBI
// ---------------------------------------------------------------------------
describe('PBI payload contract', () => {
it('INSERT: entity appears in pbis with correct title and status', () => {
useBacklogStore.getState().applyChange('pbi', 'I', { ...PBI })
const state = useBacklogStore.getState()
expect(state.pbis).toHaveLength(1)
expect(state.pbis[0].id).toBe('pbi-1')
expect(state.pbis[0].title).toBe('Realtime PBI')
expect(state.pbis[0].status).toBe('ready')
})
it('INSERT is idempotent: duplicate SSE-event does not add a second entry', () => {
useBacklogStore.getState().applyChange('pbi', 'I', { ...PBI })
useBacklogStore.getState().applyChange('pbi', 'I', { ...PBI })
expect(useBacklogStore.getState().pbis).toHaveLength(1)
})
it('UPDATE: changed_fields partial merges into existing entity', () => {
useBacklogStore.setState({ pbis: [{ ...PBI }], storiesByPbi: {}, tasksByStory: {} })
useBacklogStore.getState().applyChange('pbi', 'U', { id: 'pbi-1', title: 'Updated PBI', status: 'in_sprint' as const })
const pbi = useBacklogStore.getState().pbis[0]
expect(pbi.title).toBe('Updated PBI')
expect(pbi.status).toBe('in_sprint')
expect(pbi.priority).toBe(2) // unchanged field retained
})
it('DELETE: entity is removed from pbis', () => {
useBacklogStore.setState({ pbis: [{ ...PBI }], storiesByPbi: {}, tasksByStory: {} })
useBacklogStore.getState().applyChange('pbi', 'D', { id: 'pbi-1' })
expect(useBacklogStore.getState().pbis).toHaveLength(0)
})
})
// ---------------------------------------------------------------------------
// Story
// ---------------------------------------------------------------------------
describe('Story payload contract', () => {
it('INSERT: entity appears in storiesByPbi[pbi_id] with correct title and status', () => {
useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [] }, tasksByStory: {} })
useBacklogStore.getState().applyChange('story', 'I', { ...STORY })
const bucket = useBacklogStore.getState().storiesByPbi['pbi-1']
expect(bucket).toHaveLength(1)
expect(bucket[0].id).toBe('story-1')
expect(bucket[0].title).toBe('Realtime story')
expect(bucket[0].status).toBe('OPEN')
})
it('INSERT: creates bucket when pbi_id was not yet in storiesByPbi', () => {
useBacklogStore.getState().applyChange('story', 'I', { ...STORY })
expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(1)
})
it('INSERT is idempotent: duplicate SSE-event does not add a second entry', () => {
useBacklogStore.getState().applyChange('story', 'I', { ...STORY })
useBacklogStore.getState().applyChange('story', 'I', { ...STORY })
expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(1)
})
it('UPDATE: changed_fields partial merges into existing story', () => {
useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [{ ...STORY }] }, tasksByStory: {} })
useBacklogStore.getState().applyChange('story', 'U', { id: 'story-1', title: 'Updated story', status: 'IN_SPRINT' })
const story = useBacklogStore.getState().storiesByPbi['pbi-1'][0]
expect(story.title).toBe('Updated story')
expect(story.status).toBe('IN_SPRINT')
expect(story.priority).toBe(2) // unchanged field retained
})
it('DELETE: entity is removed from its pbi bucket', () => {
useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [{ ...STORY }] }, tasksByStory: {} })
useBacklogStore.getState().applyChange('story', 'D', { id: 'story-1' })
expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(0)
})
})
// ---------------------------------------------------------------------------
// Task
// ---------------------------------------------------------------------------
describe('Task payload contract', () => {
it('INSERT: entity appears in tasksByStory[story_id] with correct title and status', () => {
useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: { 'story-1': [] } })
useBacklogStore.getState().applyChange('task', 'I', { ...TASK })
const bucket = useBacklogStore.getState().tasksByStory['story-1']
expect(bucket).toHaveLength(1)
expect(bucket[0].id).toBe('task-1')
expect(bucket[0].title).toBe('Realtime task')
expect(bucket[0].status).toBe('TO_DO')
})
it('INSERT: creates bucket when story_id was not yet in tasksByStory', () => {
useBacklogStore.getState().applyChange('task', 'I', { ...TASK })
expect(useBacklogStore.getState().tasksByStory['story-1']).toHaveLength(1)
})
it('INSERT is idempotent: duplicate SSE-event does not add a second entry', () => {
useBacklogStore.getState().applyChange('task', 'I', { ...TASK })
useBacklogStore.getState().applyChange('task', 'I', { ...TASK })
expect(useBacklogStore.getState().tasksByStory['story-1']).toHaveLength(1)
})
it('UPDATE: changed_fields partial merges into existing task', () => {
useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: { 'story-1': [{ ...TASK }] } })
useBacklogStore.getState().applyChange('task', 'U', { id: 'task-1', title: 'Updated task', status: 'IN_PROGRESS' })
const task = useBacklogStore.getState().tasksByStory['story-1'][0]
expect(task.title).toBe('Updated task')
expect(task.status).toBe('IN_PROGRESS')
expect(task.sort_order).toBe(1) // unchanged field retained
})
it('DELETE: entity is removed from its story bucket', () => {
useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: { 'story-1': [{ ...TASK }] } })
useBacklogStore.getState().applyChange('task', 'D', { id: 'task-1' })
expect(useBacklogStore.getState().tasksByStory['story-1']).toHaveLength(0)
})
})

View file

@ -1,69 +0,0 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { renderHook } from '@testing-library/react'
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
import { useWorkspaceResync } from '@/lib/realtime/use-workspace-resync'
let resyncSpy: ReturnType<typeof vi.fn>
beforeEach(() => {
resyncSpy = vi.fn().mockResolvedValue(undefined)
useProductWorkspaceStore.setState((s) => {
s.resyncActiveScopes = resyncSpy as unknown as typeof s.resyncActiveScopes
})
// visibilitychange handler leest document.visibilityState — default is 'visible'
Object.defineProperty(document, 'visibilityState', {
value: 'visible',
writable: true,
configurable: true,
})
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('useWorkspaceResync', () => {
it('triggert resyncActiveScopes("visible") op visibilitychange hidden→visible', () => {
renderHook(() => useWorkspaceResync())
Object.defineProperty(document, 'visibilityState', {
value: 'visible',
writable: true,
configurable: true,
})
document.dispatchEvent(new Event('visibilitychange'))
expect(resyncSpy).toHaveBeenCalledWith('visible')
})
it('triggert resyncActiveScopes("reconnect") op online-event', () => {
renderHook(() => useWorkspaceResync())
window.dispatchEvent(new Event('online'))
expect(resyncSpy).toHaveBeenCalledWith('reconnect')
})
it('triggert geen resync bij visibilitychange naar hidden', () => {
renderHook(() => useWorkspaceResync())
Object.defineProperty(document, 'visibilityState', {
value: 'hidden',
writable: true,
configurable: true,
})
document.dispatchEvent(new Event('visibilitychange'))
expect(resyncSpy).not.toHaveBeenCalled()
})
it('cleanup verwijdert listeners bij unmount', () => {
const { unmount } = renderHook(() => useWorkspaceResync())
unmount()
window.dispatchEvent(new Event('online'))
document.dispatchEvent(new Event('visibilitychange'))
expect(resyncSpy).not.toHaveBeenCalled()
})
})

View file

@ -1,212 +0,0 @@
import { describe, it, expect } from 'vitest'
/**
* Review-Plan Job Tests
*
* Tests for the IDEA_REVIEW_PLAN job kind and review-log schema validation.
*/
// Sample review-log structure for testing
const sampleReviewLog = {
plan_file: 'I-042',
created_at: new Date().toISOString(),
rounds: [
{
round: 0,
model: 'claude-3-5-haiku',
role: 'Structure Review',
focus: 'YAML parsing, format, syntax',
plan_before: '---\npbi:\n title: "Test PBI"\nstories:\n - title: "Story 1"\n---',
plan_after:
'---\npbi:\n title: "Test PBI"\nstories:\n - title: "Story 1"\n priority: 2\n---',
issues: [
{
category: 'structure',
severity: 'warning',
suggestion: 'Add priority field to story',
},
],
score: 75,
plan_diff_lines: 1,
converged: false,
timestamp: new Date().toISOString(),
},
{
round: 1,
model: 'claude-3-5-sonnet',
role: 'Logic & Patterns',
focus: 'Logic gaps, missing patterns, architecture fit',
plan_before: '---\npbi:\n title: "Test PBI"\nstories:\n - title: "Story 1"\n---',
plan_after: '---\npbi:\n title: "Test PBI"\nstories:\n - title: "Story 1"\n---',
issues: [
{
category: 'logic',
severity: 'info',
suggestion: 'Consider adding acceptance criteria',
},
],
score: 80,
plan_diff_lines: 0,
converged: false,
timestamp: new Date().toISOString(),
},
{
round: 2,
model: 'claude-opus-4-7',
role: 'Risk Assessment',
focus: 'Risk assessment, edge cases, refactoring',
plan_before: '---\npbi:\n title: "Test PBI"\nstories:\n - title: "Story 1"\n---',
plan_after: '---\npbi:\n title: "Test PBI"\nstories:\n - title: "Story 1"\n---',
issues: [],
score: 85,
plan_diff_lines: 0,
converged: true,
timestamp: new Date().toISOString(),
},
],
convergence: {
stable_at_round: 2,
final_diff_pct: 0.5,
convergence_metric: 'plan_stability',
},
approval: {
status: 'approved',
timestamp: new Date().toISOString(),
},
summary: 'Plan reviewed across three rounds. Minor structure improvements suggested. Plan approved.',
}
describe('review-plan-job', () => {
describe('ReviewLog Schema', () => {
it('should have required top-level fields', () => {
expect(sampleReviewLog).toHaveProperty('plan_file')
expect(sampleReviewLog).toHaveProperty('created_at')
expect(sampleReviewLog).toHaveProperty('rounds')
expect(sampleReviewLog).toHaveProperty('convergence')
expect(sampleReviewLog).toHaveProperty('approval')
expect(sampleReviewLog).toHaveProperty('summary')
})
it('should have valid plan_file format', () => {
expect(typeof sampleReviewLog.plan_file).toBe('string')
expect(sampleReviewLog.plan_file.length).toBeGreaterThan(0)
})
it('should have valid ISO timestamps', () => {
const isoRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/
expect(sampleReviewLog.created_at).toMatch(isoRegex)
expect(sampleReviewLog.approval.timestamp).toMatch(isoRegex)
})
it('should have at least one round', () => {
expect(sampleReviewLog.rounds.length).toBeGreaterThan(0)
})
it('should have valid round structure', () => {
for (const round of sampleReviewLog.rounds) {
expect(round).toHaveProperty('round')
expect(round).toHaveProperty('model')
expect(round).toHaveProperty('role')
expect(round).toHaveProperty('focus')
expect(round).toHaveProperty('plan_before')
expect(round).toHaveProperty('plan_after')
expect(round).toHaveProperty('issues')
expect(round).toHaveProperty('score')
expect(round).toHaveProperty('plan_diff_lines')
expect(round).toHaveProperty('converged')
expect(round).toHaveProperty('timestamp')
expect(typeof round.round).toBe('number')
expect(round.round).toBeGreaterThanOrEqual(0)
expect(typeof round.score).toBe('number')
expect(round.score).toBeGreaterThanOrEqual(0)
expect(round.score).toBeLessThanOrEqual(100)
expect(typeof round.plan_diff_lines).toBe('number')
expect(round.plan_diff_lines).toBeGreaterThanOrEqual(0)
}
})
it('should have valid issue structure per round', () => {
for (const round of sampleReviewLog.rounds) {
for (const issue of round.issues) {
expect(issue).toHaveProperty('category')
expect(issue).toHaveProperty('severity')
expect(issue).toHaveProperty('suggestion')
expect(['structure', 'logic', 'risk', 'pattern']).toContain(issue.category)
expect(['error', 'warning', 'info']).toContain(issue.severity)
expect(typeof issue.suggestion).toBe('string')
expect(issue.suggestion.length).toBeGreaterThan(0)
}
}
})
it('should have valid convergence structure when present', () => {
if (sampleReviewLog.convergence) {
expect(sampleReviewLog.convergence).toHaveProperty('stable_at_round')
expect(sampleReviewLog.convergence).toHaveProperty('final_diff_pct')
expect(sampleReviewLog.convergence).toHaveProperty('convergence_metric')
expect(typeof sampleReviewLog.convergence.stable_at_round).toBe('number')
expect(sampleReviewLog.convergence.stable_at_round).toBeGreaterThanOrEqual(0)
expect(typeof sampleReviewLog.convergence.final_diff_pct).toBe('number')
expect(sampleReviewLog.convergence.final_diff_pct).toBeGreaterThanOrEqual(0)
expect(sampleReviewLog.convergence.final_diff_pct).toBeLessThanOrEqual(100)
}
})
it('should have valid approval status', () => {
expect(['pending', 'approved', 'rejected']).toContain(sampleReviewLog.approval.status)
if (sampleReviewLog.approval.status !== 'pending') {
expect(sampleReviewLog.approval.timestamp).toBeDefined()
}
})
it('should have non-empty summary', () => {
expect(typeof sampleReviewLog.summary).toBe('string')
expect(sampleReviewLog.summary.length).toBeGreaterThan(0)
})
})
describe('Convergence Detection', () => {
it('should detect convergence when diff_pct < 5% for two consecutive rounds', () => {
// Simulate convergence: round 0 has 1 diff line, rounds 1-2 have 0 diffs
const totalLines = 50
const diff0 = 1
const diff1 = 0
const diff2 = 0
const pct0 = (diff0 / totalLines) * 100 // 2%
const pct1 = (diff1 / totalLines) * 100 // 0%
const pct2 = (diff2 / totalLines) * 100 // 0%
expect(pct0).toBeLessThan(5) // Should converge
expect(pct1).toBeLessThan(5) // Should converge
expect(pct2).toBeLessThan(5) // Should converge
})
it('should not detect convergence when diff_pct >= 5%', () => {
const totalLines = 50
const diff = 3 // 6% change
const pct = (diff / totalLines) * 100
expect(pct).toBeGreaterThanOrEqual(5)
})
})
describe('Status Transitions', () => {
it('should transition REVIEWING_PLAN → PLAN_REVIEWED when approved', () => {
const log = { ...sampleReviewLog, approval: { status: 'approved', timestamp: new Date().toISOString() } }
expect(log.approval.status).toBe('approved')
// In actual implementation: update_idea_plan_reviewed({ approval_status: 'approved' })
// → idea.status = 'PLAN_REVIEWED'
})
it('should transition REVIEWING_PLAN → PLAN_REVIEW_FAILED when rejected', () => {
const log = { ...sampleReviewLog, approval: { status: 'rejected' } }
expect(log.approval.status).toBe('rejected')
// In actual implementation: update_idea_plan_reviewed({ approval_status: 'rejected' })
// → idea.status = 'PLAN_REVIEW_FAILED'
})
})
})

View file

@ -1,117 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
clearHints,
readHints,
writePbiHint,
writeProductHint,
writeStoryHint,
writeTaskHint,
} from '@/stores/product-workspace/restore'
describe('readHints', () => {
it('retourneert lege defaults wanneer localStorage leeg is', () => {
const hints = readHints()
expect(hints.lastActiveProductId).toBeNull()
expect(hints.perProduct).toEqual({})
})
it('herstelt hints uit localStorage', () => {
localStorage.setItem(
'product-workspace-hints',
JSON.stringify({
lastActiveProductId: 'p1',
perProduct: { p1: { lastActivePbiId: 'pbi-1' } },
}),
)
const hints = readHints()
expect(hints.lastActiveProductId).toBe('p1')
expect(hints.perProduct.p1.lastActivePbiId).toBe('pbi-1')
})
it('valt terug op defaults bij ongeldige JSON', () => {
localStorage.setItem('product-workspace-hints', '{not-json')
const hints = readHints()
expect(hints.lastActiveProductId).toBeNull()
expect(hints.perProduct).toEqual({})
})
it('valt terug op defaults bij verkeerde shape', () => {
localStorage.setItem('product-workspace-hints', '"just a string"')
const hints = readHints()
expect(hints.perProduct).toEqual({})
})
})
describe('writeProductHint', () => {
it('schrijft lastActiveProductId', () => {
writeProductHint('p1')
expect(readHints().lastActiveProductId).toBe('p1')
})
it('overschrijft bestaande waarde', () => {
writeProductHint('p1')
writeProductHint('p2')
expect(readHints().lastActiveProductId).toBe('p2')
})
it('accepteert null om hint te wissen', () => {
writeProductHint('p1')
writeProductHint(null)
expect(readHints().lastActiveProductId).toBeNull()
})
})
describe('writePbiHint', () => {
it('schrijft lastActivePbiId per productId', () => {
writePbiHint('prod-1', 'pbi-a')
writePbiHint('prod-2', 'pbi-b')
const hints = readHints()
expect(hints.perProduct['prod-1'].lastActivePbiId).toBe('pbi-a')
expect(hints.perProduct['prod-2'].lastActivePbiId).toBe('pbi-b')
})
it('null wist child story- en task-hints', () => {
writePbiHint('prod-1', 'pbi-1')
writeStoryHint('prod-1', 's-1')
writeTaskHint('prod-1', 't-1')
writePbiHint('prod-1', null)
const hints = readHints()
expect(hints.perProduct['prod-1'].lastActivePbiId).toBeNull()
expect(hints.perProduct['prod-1'].lastActiveStoryId).toBeNull()
expect(hints.perProduct['prod-1'].lastActiveTaskId).toBeNull()
})
})
describe('writeStoryHint', () => {
it('schrijft lastActiveStoryId per productId', () => {
writeStoryHint('prod-1', 's-1')
expect(readHints().perProduct['prod-1'].lastActiveStoryId).toBe('s-1')
})
it('null wist child task-hint', () => {
writeStoryHint('prod-1', 's-1')
writeTaskHint('prod-1', 't-1')
writeStoryHint('prod-1', null)
expect(readHints().perProduct['prod-1'].lastActiveStoryId).toBeNull()
expect(readHints().perProduct['prod-1'].lastActiveTaskId).toBeNull()
})
})
describe('writeTaskHint', () => {
it('schrijft lastActiveTaskId per productId', () => {
writeTaskHint('prod-1', 't-1')
expect(readHints().perProduct['prod-1'].lastActiveTaskId).toBe('t-1')
})
})
describe('clearHints', () => {
it('verwijdert alle hints', () => {
writeProductHint('p1')
writePbiHint('p1', 'pbi-1')
clearHints()
const hints = readHints()
expect(hints.lastActiveProductId).toBeNull()
expect(hints.perProduct).toEqual({})
})
})

View file

@ -1,83 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
deriveScreenState,
type ScreenStateInput,
} from '@/stores/product-workspace/screen-state'
const base: ScreenStateInput = {
activeSprintItem: null,
buildingSprintIds: [],
hasPendingDraft: false,
pendingAdds: [],
pendingRemoves: [],
}
describe('deriveScreenState', () => {
it('returns NO_SPRINT without draft or active sprint', () => {
expect(deriveScreenState(base)).toEqual({ kind: 'NO_SPRINT' })
})
it('returns DRAFT when a pending draft exists', () => {
expect(deriveScreenState({ ...base, hasPendingDraft: true })).toEqual({
kind: 'DRAFT',
})
})
it('lets a draft win over an active sprint with pending changes', () => {
expect(
deriveScreenState({
...base,
hasPendingDraft: true,
activeSprintItem: { id: 's1' },
pendingAdds: ['x'],
}),
).toEqual({ kind: 'DRAFT' })
})
it('returns ACTIVE for an active sprint with no pending changes', () => {
expect(
deriveScreenState({ ...base, activeSprintItem: { id: 's1' } }),
).toEqual({ kind: 'ACTIVE', building: false })
})
it('flags building when the active sprint is in buildingSprintIds', () => {
expect(
deriveScreenState({
...base,
activeSprintItem: { id: 's1' },
buildingSprintIds: ['s1'],
}),
).toEqual({ kind: 'ACTIVE', building: true })
})
it('returns EDITING when there are pending adds', () => {
expect(
deriveScreenState({
...base,
activeSprintItem: { id: 's1' },
pendingAdds: ['x'],
}),
).toEqual({ kind: 'EDITING', building: false })
})
it('returns EDITING when there are pending removes', () => {
expect(
deriveScreenState({
...base,
activeSprintItem: { id: 's1' },
pendingRemoves: ['y'],
}),
).toEqual({ kind: 'EDITING', building: false })
})
it('flags building on EDITING when the active sprint is building', () => {
expect(
deriveScreenState({
...base,
activeSprintItem: { id: 's1' },
pendingAdds: ['x'],
buildingSprintIds: ['s1'],
}),
).toEqual({ kind: 'EDITING', building: true })
})
})

View file

@ -1,341 +0,0 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
import {
selectIsDirty,
selectPbiTriState,
selectPendingCount,
selectStoryEffectiveInSprint,
selectStoryIsBlocked,
} from '@/stores/product-workspace/selectors'
import type { BacklogStory } from '@/stores/product-workspace/types'
function resetMembership() {
useProductWorkspaceStore.setState((s) => {
s.entities.storiesById = {}
s.relations.storyIdsByPbi = {}
s.sprintMembership = {
pbiSummary: {},
crossSprintBlocks: {},
pending: { adds: [], removes: [] },
loadedSummaryForSprintId: null,
}
})
}
function seedStory(id: string, pbiId: string, sprintId: string | null): BacklogStory {
return {
id,
code: id,
title: id,
description: null,
acceptance_criteria: null,
priority: 2,
sort_order: 1,
status: sprintId ? 'IN_SPRINT' : 'OPEN',
pbi_id: pbiId,
sprint_id: sprintId,
created_at: new Date('2026-01-01'),
}
}
beforeEach(() => {
resetMembership()
})
describe('toggleStorySprintMembership', () => {
it('adds storyId to pending.adds when currently not in sprint', () => {
useProductWorkspaceStore.getState().toggleStorySprintMembership('s1', false)
const pending = useProductWorkspaceStore.getState().sprintMembership.pending
expect(pending.adds).toEqual(['s1'])
expect(pending.removes).toEqual([])
})
it('adds storyId to pending.removes when currently in sprint', () => {
useProductWorkspaceStore.getState().toggleStorySprintMembership('s1', true)
const pending = useProductWorkspaceStore.getState().sprintMembership.pending
expect(pending.removes).toEqual(['s1'])
expect(pending.adds).toEqual([])
})
it('cancels out: toggle add → toggle remove same story (in-sprint) clears pending', () => {
const store = useProductWorkspaceStore.getState()
store.toggleStorySprintMembership('s1', false) // adds
// Story now appears to be "in sprint" via pending; calling with true should cancel
store.toggleStorySprintMembership('s1', false) // second click with same baseline
const pending = useProductWorkspaceStore.getState().sprintMembership.pending
expect(pending.adds).toEqual([])
expect(pending.removes).toEqual([])
})
it('removes from pending.removes when toggled back', () => {
const store = useProductWorkspaceStore.getState()
store.toggleStorySprintMembership('s1', true)
store.toggleStorySprintMembership('s1', true)
const pending = useProductWorkspaceStore.getState().sprintMembership.pending
expect(pending.removes).toEqual([])
expect(pending.adds).toEqual([])
})
it('resetSprintMembershipPending empties both arrays', () => {
const store = useProductWorkspaceStore.getState()
store.toggleStorySprintMembership('s1', false)
store.toggleStorySprintMembership('s2', true)
store.resetSprintMembershipPending()
const pending = useProductWorkspaceStore.getState().sprintMembership.pending
expect(pending.adds).toEqual([])
expect(pending.removes).toEqual([])
})
})
describe('selectPbiTriState', () => {
function seedSummary(pbiId: string, total: number, inSprint: number) {
useProductWorkspaceStore.setState((s) => {
s.sprintMembership.pbiSummary[pbiId] = {
totalStoryCount: total,
inActiveSprintStoryCount: inSprint,
}
})
}
it('returns empty for PBI without summary', () => {
expect(
selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'),
).toBe('empty')
})
it('returns empty when totalStoryCount == 0', () => {
seedSummary('pbi-1', 0, 0)
expect(
selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'),
).toBe('empty')
})
it('returns full when all stories in sprint (no pending)', () => {
seedSummary('pbi-1', 3, 3)
expect(
selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'),
).toBe('full')
})
it('returns partial when some stories in sprint', () => {
seedSummary('pbi-1', 3, 2)
expect(
selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'),
).toBe('partial')
})
it('returns empty when inSprint == 0', () => {
seedSummary('pbi-1', 3, 0)
expect(
selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'),
).toBe('empty')
})
it('applies pending adds when stories are loaded for the PBI', () => {
seedSummary('pbi-1', 3, 1)
useProductWorkspaceStore.setState((s) => {
s.relations.storyIdsByPbi['pbi-1'] = ['s1', 's2', 's3']
s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', 'sprint-1')
s.entities.storiesById['s2'] = seedStory('s2', 'pbi-1', null)
s.entities.storiesById['s3'] = seedStory('s3', 'pbi-1', null)
s.sprintMembership.pending.adds = ['s2', 's3']
})
expect(
selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'),
).toBe('full')
})
it('applies pending removes when stories are loaded for the PBI', () => {
seedSummary('pbi-1', 3, 3)
useProductWorkspaceStore.setState((s) => {
s.relations.storyIdsByPbi['pbi-1'] = ['s1', 's2', 's3']
s.sprintMembership.pending.removes = ['s2']
})
expect(
selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'),
).toBe('partial')
})
it('ignores pending entries for stories of other PBIs', () => {
seedSummary('pbi-1', 3, 3)
useProductWorkspaceStore.setState((s) => {
s.relations.storyIdsByPbi['pbi-1'] = ['s1', 's2', 's3']
s.sprintMembership.pending.removes = ['s99'] // not in pbi-1
})
expect(
selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'),
).toBe('full')
})
})
describe('selectStoryEffectiveInSprint', () => {
it('returns true when story.sprint_id matches activeSprintId and no pending', () => {
useProductWorkspaceStore.setState((s) => {
s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', 'sprint-A')
})
expect(
selectStoryEffectiveInSprint(
useProductWorkspaceStore.getState(),
's1',
'sprint-A',
),
).toBe(true)
})
it('returns false when story.sprint_id is null', () => {
useProductWorkspaceStore.setState((s) => {
s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', null)
})
expect(
selectStoryEffectiveInSprint(
useProductWorkspaceStore.getState(),
's1',
'sprint-A',
),
).toBe(false)
})
it('returns true when story in pending.adds even if DB says no', () => {
useProductWorkspaceStore.setState((s) => {
s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', null)
s.sprintMembership.pending.adds = ['s1']
})
expect(
selectStoryEffectiveInSprint(
useProductWorkspaceStore.getState(),
's1',
'sprint-A',
),
).toBe(true)
})
it('returns false when story in pending.removes even if DB says yes', () => {
useProductWorkspaceStore.setState((s) => {
s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', 'sprint-A')
s.sprintMembership.pending.removes = ['s1']
})
expect(
selectStoryEffectiveInSprint(
useProductWorkspaceStore.getState(),
's1',
'sprint-A',
),
).toBe(false)
})
it('returns false when activeSprintId is null', () => {
useProductWorkspaceStore.setState((s) => {
s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', 'sprint-A')
})
expect(
selectStoryEffectiveInSprint(
useProductWorkspaceStore.getState(),
's1',
null,
),
).toBe(false)
})
})
describe('selectStoryIsBlocked', () => {
it('returns null when no block', () => {
expect(
selectStoryIsBlocked(useProductWorkspaceStore.getState(), 's1'),
).toBeNull()
})
it('returns block info when story is in another sprint', () => {
useProductWorkspaceStore.setState((s) => {
s.sprintMembership.crossSprintBlocks['s1'] = {
sprintId: 'sprint-x',
sprintName: 'SP-X',
}
})
expect(
selectStoryIsBlocked(useProductWorkspaceStore.getState(), 's1'),
).toEqual({ sprintId: 'sprint-x', sprintName: 'SP-X' })
})
})
describe('selectIsDirty + selectPendingCount', () => {
it('clean by default', () => {
expect(selectIsDirty(useProductWorkspaceStore.getState())).toBe(false)
expect(selectPendingCount(useProductWorkspaceStore.getState())).toBe(0)
})
it('counts adds + removes', () => {
useProductWorkspaceStore.setState((s) => {
s.sprintMembership.pending = {
adds: ['a1', 'a2'],
removes: ['r1'],
}
})
expect(selectIsDirty(useProductWorkspaceStore.getState())).toBe(true)
expect(selectPendingCount(useProductWorkspaceStore.getState())).toBe(3)
})
})
describe('fetch helpers', () => {
it('fetchSprintMembershipSummary populates store and gates by sprintId', async () => {
const originalFetch = globalThis.fetch
const responseBody = {
pbiA: { totalStoryCount: 5, inActiveSprintStoryCount: 2 },
}
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify(responseBody), { status: 200 }),
) as unknown as typeof fetch
try {
await useProductWorkspaceStore
.getState()
.fetchSprintMembershipSummary('prod-1', 'sprint-A', ['pbiA'])
const slice = useProductWorkspaceStore.getState().sprintMembership
expect(slice.pbiSummary.pbiA).toEqual({
totalStoryCount: 5,
inActiveSprintStoryCount: 2,
})
expect(slice.loadedSummaryForSprintId).toBe('sprint-A')
} finally {
globalThis.fetch = originalFetch
}
})
it('fetchCrossSprintBlocks populates store', async () => {
const originalFetch = globalThis.fetch
const responseBody = {
's1': { sprintId: 'sprint-x', sprintName: 'SP-X' },
}
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify(responseBody), { status: 200 }),
) as unknown as typeof fetch
try {
await useProductWorkspaceStore
.getState()
.fetchCrossSprintBlocks('prod-1', 'sprint-A', ['pbiA'])
const slice = useProductWorkspaceStore.getState().sprintMembership
expect(slice.crossSprintBlocks['s1']).toEqual({
sprintId: 'sprint-x',
sprintName: 'SP-X',
})
} finally {
globalThis.fetch = originalFetch
}
})
it('fetchSprintMembershipSummary is a no-op for empty pbiIds', async () => {
const fetchSpy = vi.fn()
const originalFetch = globalThis.fetch
globalThis.fetch = fetchSpy as unknown as typeof fetch
try {
await useProductWorkspaceStore
.getState()
.fetchSprintMembershipSummary('prod-1', 'sprint-A', [])
expect(fetchSpy).not.toHaveBeenCalled()
} finally {
globalThis.fetch = originalFetch
}
})
})

View file

@ -1,890 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
import type {
BacklogPbi,
BacklogStory,
BacklogTask,
ProductBacklogSnapshot,
TaskDetail,
} from '@/stores/product-workspace/types'
// G5: snapshot original actions on module-load; restore in beforeEach.
// vi.fn-spies on actions could leak across tests otherwise.
const originalActions = (() => {
const s = useProductWorkspaceStore.getState()
return {
hydrateSnapshot: s.hydrateSnapshot,
setActiveProduct: s.setActiveProduct,
setActivePbi: s.setActivePbi,
setActiveStory: s.setActiveStory,
setActiveTask: s.setActiveTask,
ensureProductLoaded: s.ensureProductLoaded,
ensurePbiLoaded: s.ensurePbiLoaded,
ensureStoryLoaded: s.ensureStoryLoaded,
ensureTaskLoaded: s.ensureTaskLoaded,
applyRealtimeEvent: s.applyRealtimeEvent,
resyncActiveScopes: s.resyncActiveScopes,
resyncLoadedScopes: s.resyncLoadedScopes,
applyOptimisticMutation: s.applyOptimisticMutation,
rollbackMutation: s.rollbackMutation,
settleMutation: s.settleMutation,
setRealtimeStatus: s.setRealtimeStatus,
}
})()
function resetStore() {
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 = {}
s.loading.loadedProductId = null
s.loading.loadingProductId = null
s.loading.loadedPbiIds = {}
s.loading.loadedStoryIds = {}
s.loading.loadedTaskIds = {}
s.loading.activeRequestId = null
s.sync.realtimeStatus = 'connecting'
s.sync.lastEventAt = null
s.sync.lastResyncAt = null
s.sync.resyncReason = null
s.pendingMutations = {}
s.sprintMembership = {
pbiSummary: {},
crossSprintBlocks: {},
pending: { adds: [], removes: [] },
loadedSummaryForSprintId: null,
}
Object.assign(s, originalActions)
})
}
beforeEach(() => {
resetStore()
})
afterEach(() => {
vi.restoreAllMocks()
})
function makePbi(overrides: Partial<BacklogPbi> & { id: string }): BacklogPbi {
return {
id: overrides.id,
code: overrides.code ?? overrides.id,
title: overrides.title ?? `PBI ${overrides.id}`,
priority: overrides.priority ?? 2,
sort_order: overrides.sort_order ?? 1,
description: overrides.description ?? null,
created_at: overrides.created_at ?? new Date('2026-01-01'),
status: overrides.status ?? 'ready',
}
}
function makeStory(overrides: Partial<BacklogStory> & { id: string; pbi_id: string }): BacklogStory {
return {
id: overrides.id,
code: overrides.code ?? overrides.id,
title: overrides.title ?? `Story ${overrides.id}`,
description: overrides.description ?? null,
acceptance_criteria: overrides.acceptance_criteria ?? null,
priority: overrides.priority ?? 2,
sort_order: overrides.sort_order ?? 1,
status: overrides.status ?? 'OPEN',
pbi_id: overrides.pbi_id,
sprint_id: overrides.sprint_id ?? null,
created_at: overrides.created_at ?? new Date('2026-01-01'),
}
}
function makeTask(overrides: Partial<BacklogTask> & { id: string; story_id: string }): BacklogTask {
return {
id: overrides.id,
code: overrides.code ?? null,
title: overrides.title ?? `Task ${overrides.id}`,
description: overrides.description ?? null,
priority: overrides.priority ?? 2,
sort_order: overrides.sort_order ?? 1,
status: overrides.status ?? 'TO_DO',
story_id: overrides.story_id,
created_at: overrides.created_at ?? new Date('2026-01-01'),
}
}
function snapshotWith(
pbis: BacklogPbi[],
storiesByPbi: Record<string, BacklogStory[]> = {},
tasksByStory: Record<string, BacklogTask[]> = {},
product?: { id: string; name: string },
): ProductBacklogSnapshot {
return { product, pbis, storiesByPbi, tasksByStory }
}
// G7: mock fetch — never let it fall through to real network
// G8: mockImplementation per call so each fetch gets a fresh Response
function mockFetchSequence(
responses: Array<unknown | ((url: string, init?: RequestInit) => unknown)>,
) {
let i = 0
return vi.spyOn(globalThis, 'fetch').mockImplementation((async (url: string, init?: RequestInit) => {
const r = responses[Math.min(i, responses.length - 1)]
i += 1
const body = typeof r === 'function' ? (r as (u: string, i?: RequestInit) => unknown)(url, init) : r
return new Response(JSON.stringify(body ?? null), { status: 200 })
}) as unknown as typeof fetch)
}
// ─────────────────────────────────────────────────────────────────────────
// hydrateSnapshot
// ─────────────────────────────────────────────────────────────────────────
describe('hydrateSnapshot', () => {
it('vult entities en relations met gesorteerde id-lijsten', () => {
const pbiA = makePbi({ id: 'pbi-a', priority: 2, sort_order: 2 })
const pbiB = makePbi({ id: 'pbi-b', priority: 1, sort_order: 5 })
const pbiC = makePbi({ id: 'pbi-c', priority: 2, sort_order: 1 })
const storyB1 = makeStory({ id: 'st-1', pbi_id: 'pbi-b', sort_order: 2 })
const storyB2 = makeStory({ id: 'st-2', pbi_id: 'pbi-b', sort_order: 1 })
const taskA = makeTask({ id: 'tk-2', story_id: 'st-1', sort_order: 2 })
const taskB = makeTask({ id: 'tk-1', story_id: 'st-1', sort_order: 1 })
useProductWorkspaceStore.getState().hydrateSnapshot(
snapshotWith(
[pbiA, pbiB, pbiC],
{ 'pbi-b': [storyB1, storyB2] },
{ 'st-1': [taskA, taskB] },
{ id: 'prod-1', name: 'Product 1' },
),
)
const s = useProductWorkspaceStore.getState()
expect(s.entities.pbisById['pbi-a']).toBe(pbiA)
expect(s.entities.pbisById['pbi-b']).toBe(pbiB)
expect(s.entities.pbisById['pbi-c']).toBe(pbiC)
// pbi-b heeft priority 1 (komt eerst), dan pbi-c (sort_order 1) en pbi-a (sort_order 2)
expect(s.relations.pbiIds).toEqual(['pbi-b', 'pbi-c', 'pbi-a'])
expect(s.relations.storyIdsByPbi['pbi-b']).toEqual(['st-2', 'st-1'])
expect(s.relations.taskIdsByStory['st-1']).toEqual(['tk-1', 'tk-2'])
expect(s.context.activeProduct).toEqual({ id: 'prod-1', name: 'Product 1' })
expect(s.loading.loadedProductId).toBe('prod-1')
})
it('normaliseert API-statussen naar het interne store-contract', () => {
useProductWorkspaceStore.getState().hydrateSnapshot(
snapshotWith(
[makePbi({ id: 'pbi-1', status: 'READY' as BacklogPbi['status'] })],
{
'pbi-1': [
makeStory({ id: 'st-1', pbi_id: 'pbi-1', status: 'in_sprint' }),
],
},
{
'st-1': [makeTask({ id: 'tk-1', story_id: 'st-1', status: 'todo' })],
},
),
)
const s = useProductWorkspaceStore.getState()
expect(s.entities.pbisById['pbi-1'].status).toBe('ready')
expect(s.entities.storiesById['st-1'].status).toBe('IN_SPRINT')
expect(s.entities.tasksById['tk-1'].status).toBe('TO_DO')
})
it('reset bestaande entities en relations bij her-hydratie', () => {
useProductWorkspaceStore.getState().hydrateSnapshot(
snapshotWith([makePbi({ id: 'old-pbi' })]),
)
expect(useProductWorkspaceStore.getState().relations.pbiIds).toEqual(['old-pbi'])
useProductWorkspaceStore.getState().hydrateSnapshot(
snapshotWith([makePbi({ id: 'new-pbi' })]),
)
const s = useProductWorkspaceStore.getState()
expect(s.entities.pbisById['old-pbi']).toBeUndefined()
expect(s.entities.pbisById['new-pbi']).toBeDefined()
expect(s.relations.pbiIds).toEqual(['new-pbi'])
})
})
// ─────────────────────────────────────────────────────────────────────────
// Selection cascade
// ─────────────────────────────────────────────────────────────────────────
describe('selection cascade', () => {
it('setActivePbi reset story+task; setActiveStory reset task', () => {
useProductWorkspaceStore.setState((s) => {
s.context.activePbiId = 'pbi-old'
s.context.activeStoryId = 'st-old'
s.context.activeTaskId = 'tk-old'
})
useProductWorkspaceStore.getState().setActivePbi('pbi-new')
let s = useProductWorkspaceStore.getState()
expect(s.context.activePbiId).toBe('pbi-new')
expect(s.context.activeStoryId).toBeNull()
expect(s.context.activeTaskId).toBeNull()
useProductWorkspaceStore.setState((draft) => {
draft.context.activeStoryId = 'st-old'
draft.context.activeTaskId = 'tk-old'
})
useProductWorkspaceStore.getState().setActiveStory('st-new')
s = useProductWorkspaceStore.getState()
expect(s.context.activeStoryId).toBe('st-new')
expect(s.context.activeTaskId).toBeNull()
})
it('setActiveProduct(null) ruimt entities en relations op', () => {
useProductWorkspaceStore.getState().hydrateSnapshot(
snapshotWith(
[makePbi({ id: 'p-1' })],
{ 'p-1': [makeStory({ id: 's-1', pbi_id: 'p-1' })] },
{ 's-1': [makeTask({ id: 't-1', story_id: 's-1' })] },
{ id: 'prod-1', name: 'Product 1' },
),
)
useProductWorkspaceStore.getState().setActiveProduct(null)
const s = useProductWorkspaceStore.getState()
expect(s.context.activeProduct).toBeNull()
expect(s.context.activePbiId).toBeNull()
expect(s.context.activeStoryId).toBeNull()
expect(s.context.activeTaskId).toBeNull()
expect(s.entities.pbisById).toEqual({})
expect(s.entities.storiesById).toEqual({})
expect(s.entities.tasksById).toEqual({})
expect(s.relations.pbiIds).toEqual([])
expect(s.relations.storyIdsByPbi).toEqual({})
expect(s.relations.taskIdsByStory).toEqual({})
expect(s.loading.loadedProductId).toBeNull()
})
it('setActiveProduct kan alleen context zetten zonder full backlog load', () => {
useProductWorkspaceStore.getState().hydrateSnapshot(
snapshotWith(
[makePbi({ id: 'p-1' })],
{ 'p-1': [makeStory({ id: 's-1', pbi_id: 'p-1' })] },
{ 's-1': [makeTask({ id: 't-1', story_id: 's-1' })] },
{ id: 'prod-1', name: 'Product 1' },
),
)
useProductWorkspaceStore.setState((s) => {
s.context.activePbiId = 'p-1'
s.context.activeStoryId = 's-1'
})
const fetchSpy = vi.spyOn(globalThis, 'fetch')
useProductWorkspaceStore
.getState()
.setActiveProduct(
{ id: 'prod-1', name: 'Product 1' },
{ load: false, preserveSelection: true },
)
const s = useProductWorkspaceStore.getState()
expect(fetchSpy).not.toHaveBeenCalled()
expect(s.context.activePbiId).toBe('p-1')
expect(s.context.activeStoryId).toBe('s-1')
expect(s.entities.pbisById['p-1']).toBeDefined()
})
})
// ─────────────────────────────────────────────────────────────────────────
// applyRealtimeEvent
// ─────────────────────────────────────────────────────────────────────────
describe('applyRealtimeEvent — pbi', () => {
beforeEach(() => {
useProductWorkspaceStore.setState((s) => {
s.context.activeProduct = { id: 'prod-1', name: 'Product 1' }
})
})
it('I — voegt PBI toe en sorteert', () => {
useProductWorkspaceStore.getState().hydrateSnapshot(
snapshotWith([makePbi({ id: 'a', priority: 2, sort_order: 5 })]),
)
useProductWorkspaceStore.getState().applyRealtimeEvent({
entity: 'pbi',
op: 'I',
id: 'b',
product_id: 'prod-1',
code: 'B',
title: 'New PBI',
priority: 1,
sort_order: 1,
created_at: new Date('2026-02-01').toISOString(),
status: 'ready',
})
const s = useProductWorkspaceStore.getState()
expect(s.entities.pbisById['b']).toBeDefined()
expect(s.relations.pbiIds).toEqual(['b', 'a'])
})
it('I — idempotent voor bestaande id', () => {
useProductWorkspaceStore.getState().hydrateSnapshot(
snapshotWith([makePbi({ id: 'a' })]),
)
useProductWorkspaceStore.getState().applyRealtimeEvent({
entity: 'pbi',
op: 'I',
id: 'a',
product_id: 'prod-1',
title: 'mutated',
})
const s = useProductWorkspaceStore.getState()
expect(s.entities.pbisById['a'].title).toBe('PBI a') // niet overschreven
expect(s.relations.pbiIds).toEqual(['a'])
})
it('U — patch + her-sorteert', () => {
useProductWorkspaceStore.getState().hydrateSnapshot(
snapshotWith([
makePbi({ id: 'a', priority: 2, sort_order: 1 }),
makePbi({ id: 'b', priority: 2, sort_order: 2 }),
]),
)
useProductWorkspaceStore.getState().applyRealtimeEvent({
entity: 'pbi',
op: 'U',
id: 'b',
product_id: 'prod-1',
priority: 1,
})
const s = useProductWorkspaceStore.getState()
expect(s.relations.pbiIds).toEqual(['b', 'a'])
})
it('D — verwijdert PBI inclusief child stories en tasks', () => {
useProductWorkspaceStore.getState().hydrateSnapshot(
snapshotWith(
[makePbi({ id: 'p1' })],
{ p1: [makeStory({ id: 's1', pbi_id: 'p1' })] },
{ s1: [makeTask({ id: 't1', story_id: 's1' })] },
),
)
useProductWorkspaceStore.getState().applyRealtimeEvent({
entity: 'pbi',
op: 'D',
id: 'p1',
product_id: 'prod-1',
})
const s = useProductWorkspaceStore.getState()
expect(s.entities.pbisById['p1']).toBeUndefined()
expect(s.entities.storiesById['s1']).toBeUndefined()
expect(s.entities.tasksById['t1']).toBeUndefined()
expect(s.relations.pbiIds).toEqual([])
expect(s.relations.storyIdsByPbi['p1']).toBeUndefined()
expect(s.relations.taskIdsByStory['s1']).toBeUndefined()
})
it('D — clear actieve PBI selectie als die onder de verwijderde PBI viel', () => {
useProductWorkspaceStore.getState().hydrateSnapshot(
snapshotWith([makePbi({ id: 'p1' })]),
)
useProductWorkspaceStore.setState((s) => {
s.context.activePbiId = 'p1'
s.context.activeStoryId = 's-x'
s.context.activeTaskId = 't-x'
})
useProductWorkspaceStore.getState().applyRealtimeEvent({
entity: 'pbi',
op: 'D',
id: 'p1',
product_id: 'prod-1',
})
const s = useProductWorkspaceStore.getState()
expect(s.context.activePbiId).toBeNull()
expect(s.context.activeStoryId).toBeNull()
expect(s.context.activeTaskId).toBeNull()
})
})
describe('applyRealtimeEvent — story parent-move', () => {
beforeEach(() => {
useProductWorkspaceStore.setState((s) => {
s.context.activeProduct = { id: 'prod-1', name: 'Product 1' }
})
useProductWorkspaceStore.getState().hydrateSnapshot(
snapshotWith(
[makePbi({ id: 'p1' }), makePbi({ id: 'p2' })],
{
p1: [makeStory({ id: 's1', pbi_id: 'p1' })],
p2: [],
},
),
)
})
it('U met andere pbi_id verplaatst story naar nieuwe parent-lijst', () => {
useProductWorkspaceStore.getState().applyRealtimeEvent({
entity: 'story',
op: 'U',
id: 's1',
product_id: 'prod-1',
pbi_id: 'p2',
})
const s = useProductWorkspaceStore.getState()
expect(s.relations.storyIdsByPbi['p1']).toEqual([])
expect(s.relations.storyIdsByPbi['p2']).toEqual(['s1'])
expect(s.entities.storiesById['s1'].pbi_id).toBe('p2')
})
})
describe('applyRealtimeEvent — task parent-move', () => {
beforeEach(() => {
useProductWorkspaceStore.setState((s) => {
s.context.activeProduct = { id: 'prod-1', name: 'Product 1' }
})
useProductWorkspaceStore.getState().hydrateSnapshot(
snapshotWith(
[makePbi({ id: 'p1' })],
{ p1: [makeStory({ id: 's1', pbi_id: 'p1' }), makeStory({ id: 's2', pbi_id: 'p1' })] },
{
s1: [makeTask({ id: 't1', story_id: 's1' })],
s2: [],
},
),
)
})
it('U met andere story_id verplaatst task naar nieuwe parent', () => {
useProductWorkspaceStore.getState().applyRealtimeEvent({
entity: 'task',
op: 'U',
id: 't1',
product_id: 'prod-1',
story_id: 's2',
})
const s = useProductWorkspaceStore.getState()
expect(s.relations.taskIdsByStory['s1']).toEqual([])
expect(s.relations.taskIdsByStory['s2']).toEqual(['t1'])
expect(s.entities.tasksById['t1'].story_id).toBe('s2')
})
})
describe('applyRealtimeEvent — andere product genegeerd', () => {
it('event met ander product_id raakt de store niet', () => {
useProductWorkspaceStore.setState((s) => {
s.context.activeProduct = { id: 'prod-1', name: 'Product 1' }
})
useProductWorkspaceStore.getState().hydrateSnapshot(
snapshotWith([makePbi({ id: 'a' })]),
)
useProductWorkspaceStore.getState().applyRealtimeEvent({
entity: 'pbi',
op: 'I',
id: 'b',
product_id: 'prod-2',
title: 'Other product',
priority: 1,
sort_order: 1,
})
const s = useProductWorkspaceStore.getState()
expect(s.entities.pbisById['b']).toBeUndefined()
expect(s.relations.pbiIds).toEqual(['a'])
})
})
describe('applyRealtimeEvent — unknown entity → resync trigger', () => {
function withSpy(): ReturnType<typeof vi.fn> {
useProductWorkspaceStore.setState((s) => {
s.context.activeProduct = { id: 'prod-1', name: 'Product 1' }
})
const spy = vi.fn().mockResolvedValue(undefined)
useProductWorkspaceStore.setState((s) => {
s.resyncActiveScopes = spy as unknown as typeof s.resyncActiveScopes
})
return spy
}
it('unknown entity (b.v. comment) met matching product triggert resync', () => {
const spy = withSpy()
useProductWorkspaceStore.getState().applyRealtimeEvent({
entity: 'comment',
op: 'I',
id: 'cm-1',
product_id: 'prod-1',
} as unknown as Record<string, unknown>)
expect(spy).toHaveBeenCalledWith('unknown-event')
})
it('unknown entity met ander product_id triggert geen resync', () => {
const spy = withSpy()
useProductWorkspaceStore.getState().applyRealtimeEvent({
entity: 'comment',
op: 'I',
id: 'cm-1',
product_id: 'prod-2',
} as unknown as Record<string, unknown>)
expect(spy).not.toHaveBeenCalled()
})
it('claude_job_status (type-veld) triggert geen resync', () => {
const spy = withSpy()
useProductWorkspaceStore.getState().applyRealtimeEvent({
type: 'claude_job_status',
job_id: 'job-1',
product_id: 'prod-1',
status: 'queued',
} as unknown as Record<string, unknown>)
expect(spy).not.toHaveBeenCalled()
})
it('worker_heartbeat (type-veld) triggert geen resync', () => {
const spy = withSpy()
useProductWorkspaceStore.getState().applyRealtimeEvent({
type: 'worker_heartbeat',
worker_id: 'w-1',
product_id: 'prod-1',
} as unknown as Record<string, unknown>)
expect(spy).not.toHaveBeenCalled()
})
it('claude_job_enqueued (type-veld) triggert geen resync', () => {
const spy = withSpy()
useProductWorkspaceStore.getState().applyRealtimeEvent({
type: 'claude_job_enqueued',
job_id: 'job-2',
product_id: 'prod-1',
kind: 'PER_TASK',
} as unknown as Record<string, unknown>)
expect(spy).not.toHaveBeenCalled()
})
it('payload zonder entity en zonder type wordt genegeerd', () => {
const spy = withSpy()
useProductWorkspaceStore.getState().applyRealtimeEvent({
product_id: 'prod-1',
something: 'else',
} as unknown as Record<string, unknown>)
expect(spy).not.toHaveBeenCalled()
})
it('question-event met entity-veld maar zonder pbi/story/task triggert resync', () => {
// question is geen pbi/story/task entity dus telt als unknown wanneer
// hij geen 'type' draagt — dat zou een nieuwe entiteit kunnen zijn die
// we nog niet kennen.
const spy = withSpy()
useProductWorkspaceStore.getState().applyRealtimeEvent({
entity: 'question',
op: 'I',
id: 'q-1',
product_id: 'prod-1',
} as unknown as Record<string, unknown>)
expect(spy).toHaveBeenCalledWith('unknown-event')
})
})
// ─────────────────────────────────────────────────────────────────────────
// ensure*Loaded fetches + race-safe guard + sortering
// ─────────────────────────────────────────────────────────────────────────
describe('ensureProductLoaded', () => {
it('fetcht backlog snapshot en hydreert met sortering', async () => {
const snapshot: ProductBacklogSnapshot = {
product: { id: 'prod-1', name: 'Product 1' },
pbis: [
makePbi({ id: 'a', priority: 2, sort_order: 5 }),
makePbi({ id: 'b', priority: 1, sort_order: 9 }),
],
storiesByPbi: {},
tasksByStory: {},
}
const fetchSpy = mockFetchSequence([snapshot])
await useProductWorkspaceStore.getState().ensureProductLoaded('prod-1')
expect(fetchSpy).toHaveBeenCalledWith(
'/api/products/prod-1/backlog',
expect.objectContaining({ cache: 'no-store' }),
)
const s = useProductWorkspaceStore.getState()
expect(s.relations.pbiIds).toEqual(['b', 'a'])
expect(s.loading.loadedProductId).toBe('prod-1')
expect(s.loading.loadedPbiIds['a']).toBe(true)
expect(s.loading.loadedPbiIds['b']).toBe(true)
})
})
describe('race-safe ensure*Loaded — activeRequestId guard', () => {
it('oudere in-flight ensurePbiLoaded mag nieuwere selectie niet overschrijven', async () => {
let resolveOld: ((stories: BacklogStory[]) => void) | null = null
vi.spyOn(globalThis, 'fetch').mockImplementation((async (url: string) => {
if (url === '/api/pbis/pbi-old/stories') {
const stories = await new Promise<BacklogStory[]>((resolve) => {
resolveOld = resolve
})
return new Response(JSON.stringify(stories), { status: 200 })
}
if (url === '/api/pbis/pbi-new/stories') {
return new Response(
JSON.stringify([makeStory({ id: 'new-st', pbi_id: 'pbi-new' })]),
{ status: 200 },
)
}
return new Response('null', { status: 200 })
}) as unknown as typeof fetch)
useProductWorkspaceStore.setState((s) => {
s.context.activeProduct = { id: 'prod-1', name: 'Product 1' }
s.context.activePbiId = 'pbi-old'
s.loading.activeRequestId = 'req-old'
})
const oldPromise = useProductWorkspaceStore
.getState()
.ensurePbiLoaded('pbi-old', 'req-old')
// gebruiker selecteert ondertussen pbi-new
useProductWorkspaceStore.setState((s) => {
s.context.activePbiId = 'pbi-new'
s.loading.activeRequestId = 'req-new'
})
await useProductWorkspaceStore.getState().ensurePbiLoaded('pbi-new', 'req-new')
expect(useProductWorkspaceStore.getState().entities.storiesById['new-st']).toBeDefined()
// resolve de oude fetch — guard moet de stale data weigeren
resolveOld!([makeStory({ id: 'old-st', pbi_id: 'pbi-old' })])
await oldPromise
const s = useProductWorkspaceStore.getState()
expect(s.context.activePbiId).toBe('pbi-new')
expect(s.entities.storiesById['old-st']).toBeUndefined()
expect(s.entities.storiesById['new-st']).toBeDefined()
})
})
describe('ensureTaskLoaded — zet detail-flag', () => {
it('verrijkt task naar TaskDetail met _detail: true', async () => {
mockFetchSequence([
{
id: 't-1',
title: 'Task 1',
description: 'desc',
priority: 1,
sort_order: 1,
status: 'todo',
story_id: 's-1',
created_at: new Date('2026-02-01').toISOString(),
implementation_plan: 'detailed plan here',
},
])
await useProductWorkspaceStore.getState().ensureTaskLoaded('t-1')
const task = useProductWorkspaceStore.getState().entities.tasksById['t-1'] as TaskDetail
expect(task._detail).toBe(true)
expect(task.status).toBe('TO_DO')
expect(task.implementation_plan).toBe('detailed plan here')
expect(useProductWorkspaceStore.getState().loading.loadedTaskIds['t-1']).toBe(true)
})
})
// ─────────────────────────────────────────────────────────────────────────
// resyncActiveScopes
// ─────────────────────────────────────────────────────────────────────────
describe('resyncActiveScopes', () => {
it('triggert ensure-keten voor alle actieve scopes en zet sync velden', async () => {
const fetchSpy = mockFetchSequence([
// ensureProductLoaded
{ product: { id: 'prod-1', name: 'P' }, pbis: [], storiesByPbi: {}, tasksByStory: {} },
// ensurePbiLoaded
[],
// ensureStoryLoaded
[],
// ensureTaskLoaded
{
id: 't-1',
title: 'T',
description: null,
priority: 1,
sort_order: 1,
status: 'todo',
story_id: 's-1',
created_at: '2026-02-01',
},
])
useProductWorkspaceStore.setState((s) => {
s.context.activeProduct = { id: 'prod-1', name: 'P' }
s.context.activePbiId = 'pbi-1'
s.context.activeStoryId = 's-1'
s.context.activeTaskId = 't-1'
})
await useProductWorkspaceStore.getState().resyncActiveScopes('manual')
const calls = fetchSpy.mock.calls.map(([url]) => url)
expect(calls).toContain('/api/products/prod-1/backlog')
expect(calls).toContain('/api/pbis/pbi-1/stories')
expect(calls).toContain('/api/stories/s-1/tasks')
expect(calls).toContain('/api/tasks/t-1')
const s = useProductWorkspaceStore.getState()
expect(s.sync.lastResyncAt).toBeTypeOf('number')
expect(s.sync.resyncReason).toBe('manual')
})
})
// ─────────────────────────────────────────────────────────────────────────
// Optimistic mutations
// ─────────────────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────────────
// Restore-hint integratie (Story 4)
// ─────────────────────────────────────────────────────────────────────────
describe('restore-hint flow — setters persisteren hints', () => {
it('setActiveProduct schrijft lastActiveProductId', () => {
useProductWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' })
const raw = localStorage.getItem('product-workspace-hints')
expect(raw).not.toBeNull()
const hints = JSON.parse(raw!)
expect(hints.lastActiveProductId).toBe('prod-1')
})
it('setActivePbi schrijft lastActivePbiId per product', () => {
useProductWorkspaceStore.setState((s) => {
s.context.activeProduct = { id: 'prod-1', name: 'P1' }
})
useProductWorkspaceStore.getState().setActivePbi('pbi-a')
const hints = JSON.parse(localStorage.getItem('product-workspace-hints')!)
expect(hints.perProduct['prod-1'].lastActivePbiId).toBe('pbi-a')
})
it('setActiveStory schrijft lastActiveStoryId per product', () => {
useProductWorkspaceStore.setState((s) => {
s.context.activeProduct = { id: 'prod-1', name: 'P1' }
})
useProductWorkspaceStore.getState().setActiveStory('story-a')
const hints = JSON.parse(localStorage.getItem('product-workspace-hints')!)
expect(hints.perProduct['prod-1'].lastActiveStoryId).toBe('story-a')
})
it('setActiveTask schrijft lastActiveTaskId per product', () => {
useProductWorkspaceStore.setState((s) => {
s.context.activeProduct = { id: 'prod-1', name: 'P1' }
})
useProductWorkspaceStore.getState().setActiveTask('task-a')
const hints = JSON.parse(localStorage.getItem('product-workspace-hints')!)
expect(hints.perProduct['prod-1'].lastActiveTaskId).toBe('task-a')
})
})
describe('restore-hint flow — chain triggert na ensure*Loaded', () => {
it('hint die NIET in entities zit wordt genegeerd', async () => {
// Schrijf een hint voor een PBI die niet bestaat
localStorage.setItem(
'product-workspace-hints',
JSON.stringify({
lastActiveProductId: 'prod-1',
perProduct: { 'prod-1': { lastActivePbiId: 'ghost-pbi' } },
}),
)
// Mock ensureProductLoaded zodat hij een lege snapshot terugstuurt — geen
// ghost-pbi in entities.
mockFetchSequence([
{ product: { id: 'prod-1', name: 'P1' }, pbis: [], storiesByPbi: {}, tasksByStory: {} },
])
useProductWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' })
// Wacht tot async restore-flow afgewikkeld is.
await new Promise((r) => setTimeout(r, 20))
expect(useProductWorkspaceStore.getState().context.activePbiId).toBeNull()
})
it('hint die wel in entities zit wordt toegepast', async () => {
const validPbi = makePbi({ id: 'pbi-known' })
localStorage.setItem(
'product-workspace-hints',
JSON.stringify({
lastActiveProductId: 'prod-1',
perProduct: { 'prod-1': { lastActivePbiId: 'pbi-known' } },
}),
)
mockFetchSequence([
// ensureProductLoaded levert pbi-known
{
product: { id: 'prod-1', name: 'P1' },
pbis: [validPbi],
storiesByPbi: {},
tasksByStory: {},
},
// ensurePbiLoaded triggered door setActivePbi(hint) — geen stories
[],
])
useProductWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' })
await new Promise((r) => setTimeout(r, 30))
expect(useProductWorkspaceStore.getState().context.activePbiId).toBe('pbi-known')
})
})
describe('optimistic mutations', () => {
it('rollback herstelt vorige pbi-order', () => {
useProductWorkspaceStore.getState().hydrateSnapshot(
snapshotWith([
makePbi({ id: 'a', priority: 2, sort_order: 1 }),
makePbi({ id: 'b', priority: 2, sort_order: 2 }),
]),
)
const prevOrder = [...useProductWorkspaceStore.getState().relations.pbiIds]
const id = useProductWorkspaceStore.getState().applyOptimisticMutation({
kind: 'pbi-order',
prevPbiIds: prevOrder,
})
// simuleer de optimistic order-wijziging buiten de mutation
useProductWorkspaceStore.setState((s) => {
s.relations.pbiIds = ['b', 'a']
})
expect(useProductWorkspaceStore.getState().relations.pbiIds).toEqual(['b', 'a'])
useProductWorkspaceStore.getState().rollbackMutation(id)
expect(useProductWorkspaceStore.getState().relations.pbiIds).toEqual(prevOrder)
expect(useProductWorkspaceStore.getState().pendingMutations[id]).toBeUndefined()
})
it('settle ruimt pending op zonder state te wijzigen', () => {
useProductWorkspaceStore.getState().hydrateSnapshot(
snapshotWith([makePbi({ id: 'a' })]),
)
const id = useProductWorkspaceStore.getState().applyOptimisticMutation({
kind: 'pbi-order',
prevPbiIds: ['a'],
})
expect(useProductWorkspaceStore.getState().pendingMutations[id]).toBeDefined()
useProductWorkspaceStore.getState().settleMutation(id)
expect(useProductWorkspaceStore.getState().pendingMutations[id]).toBeUndefined()
expect(useProductWorkspaceStore.getState().relations.pbiIds).toEqual(['a'])
})
it('SSE-echo van een al-bestaande PBI is idempotent', () => {
useProductWorkspaceStore.setState((s) => {
s.context.activeProduct = { id: 'prod-1', name: 'P' }
})
useProductWorkspaceStore.getState().hydrateSnapshot(
snapshotWith([makePbi({ id: 'a', title: 'Origineel' })]),
)
useProductWorkspaceStore.getState().applyRealtimeEvent({
entity: 'pbi',
op: 'I',
id: 'a',
product_id: 'prod-1',
title: 'echo',
})
expect(useProductWorkspaceStore.getState().entities.pbisById['a'].title).toBe('Origineel')
expect(useProductWorkspaceStore.getState().relations.pbiIds).toEqual(['a'])
})
})

View file

@ -17,9 +17,6 @@ const baseTask = (id: string, overrides: Partial<SoloTask> = {}): SoloTask => ({
story_code: 'ST-100',
story_title: 'Original Story',
task_code: 'ST-100.1',
pbi_code: null,
pbi_title: null,
pbi_description: null,
...overrides,
})

View file

@ -1,131 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSoloStore } from '@/stores/solo-store'
import type { RealtimeEvent } from '@/stores/solo-store'
import type { SoloTask, SoloWorkspaceSnapshot } from '@/stores/solo-workspace/types'
function baseTask(id: string, overrides: Partial<SoloTask> = {}): SoloTask {
return {
id,
title: `Task ${id}`,
description: null,
implementation_plan: null,
priority: 1,
sort_order: 1,
status: 'TO_DO',
verify_only: false,
verify_required: 'ALIGNED_OR_PARTIAL',
story_id: 'story-1',
story_code: 'ST-1',
story_title: 'Story 1',
task_code: `ST-1.${id}`,
pbi_code: null,
pbi_title: null,
pbi_description: null,
...overrides,
}
}
function snapshot(tasks: SoloTask[]): SoloWorkspaceSnapshot {
return {
product: { id: 'prod-1', name: 'Product 1' },
sprint: { id: 'sprint-1', sprint_goal: 'Goal' },
activeUserId: 'user-1',
tasks,
unassignedStories: [
{ id: 'story-b', code: 'ST-2', title: 'Story B', tasks: [] },
{ id: 'story-a', code: 'ST-1', title: 'Story A', tasks: [] },
],
}
}
function taskEvent(overrides: Partial<RealtimeEvent>): RealtimeEvent {
return {
op: 'U',
entity: 'task',
id: 'task-1',
story_id: 'story-1',
product_id: 'prod-1',
sprint_id: 'sprint-1',
assignee_id: 'user-1',
...overrides,
}
}
beforeEach(() => {
useSoloStore.setState({
context: { activeProduct: null, activeSprint: null, activeUserId: null },
entities: { tasksById: {}, unassignedStoriesById: {}, jobsByTaskId: {} },
relations: {
taskIdsByColumn: { TO_DO: [], IN_PROGRESS: [], DONE: [] },
unassignedStoryIds: [],
},
loading: {
loadedProductId: null,
loadedSprintId: null,
loadingSprintId: null,
activeRequestId: null,
},
sync: {
realtimeStatus: 'connecting',
showConnectingIndicator: false,
lastEventAt: null,
lastResyncAt: null,
resyncReason: null,
},
pendingOps: new Set(),
tasks: {},
unassignedStoriesById: {},
claudeJobsByTaskId: {},
})
vi.restoreAllMocks()
})
describe('solo workspace store', () => {
it('hydrateert genormaliseerde taken, kolomrelaties en unassigned stories', () => {
useSoloStore.getState().hydrateSnapshot(
snapshot([
baseTask('task-2', { status: 'DONE', sort_order: 2 }),
baseTask('task-1', { status: 'TO_DO', sort_order: 1 }),
baseTask('task-3', { status: 'REVIEW', sort_order: 3 }),
]),
)
const s = useSoloStore.getState()
expect(s.context.activeSprint?.id).toBe('sprint-1')
expect(s.relations.taskIdsByColumn.TO_DO).toEqual(['task-1'])
expect(s.relations.taskIdsByColumn.IN_PROGRESS).toEqual(['task-3'])
expect(s.relations.taskIdsByColumn.DONE).toEqual(['task-2'])
expect(s.relations.unassignedStoryIds).toEqual(['story-a', 'story-b'])
})
it('past realtime task updates toe en herbouwt kolomrelaties', () => {
useSoloStore.getState().hydrateSnapshot(snapshot([baseTask('task-1')]))
useSoloStore.getState().handleRealtimeEvent(
taskEvent({ status: 'DONE', sort_order: 5, title: 'Done task' }),
)
const s = useSoloStore.getState()
expect(s.tasks['task-1'].status).toBe('DONE')
expect(s.tasks['task-1'].title).toBe('Done task')
expect(s.relations.taskIdsByColumn.TO_DO).toEqual([])
expect(s.relations.taskIdsByColumn.DONE).toEqual(['task-1'])
})
it('resynct actieve scopes via de solo-workspace route', async () => {
useSoloStore.getState().hydrateSnapshot(snapshot([baseTask('task-1')]))
const next = snapshot([baseTask('task-1', { status: 'IN_PROGRESS' })])
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(next), { status: 200 }),
)
await useSoloStore.getState().resyncActiveScopes('manual')
expect(fetchSpy).toHaveBeenCalledWith(
'/api/products/prod-1/solo-workspace?sprint_id=sprint-1',
expect.objectContaining({ cache: 'no-store' }),
)
const s = useSoloStore.getState()
expect(s.tasks['task-1'].status).toBe('IN_PROGRESS')
expect(s.sync.resyncReason).toBe('manual')
})
})

View file

@ -1,119 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
clearHints,
readHints,
writeProductHint,
writeSprintHint,
writeStoryHint,
writeTaskHint,
} from '@/stores/sprint-workspace/restore'
describe('readHints', () => {
it('retourneert lege defaults wanneer localStorage leeg is', () => {
const hints = readHints()
expect(hints.lastActiveProductId).toBeNull()
expect(hints.perProduct).toEqual({})
expect(hints.perSprint).toEqual({})
})
it('herstelt hints uit localStorage', () => {
localStorage.setItem(
'sprint-workspace-hints',
JSON.stringify({
lastActiveProductId: 'p1',
perProduct: { p1: { lastActiveSprintId: 'sp-1' } },
perSprint: { 'sp-1': { lastActiveStoryId: 's-1' } },
}),
)
const hints = readHints()
expect(hints.lastActiveProductId).toBe('p1')
expect(hints.perProduct.p1.lastActiveSprintId).toBe('sp-1')
expect(hints.perSprint['sp-1'].lastActiveStoryId).toBe('s-1')
})
it('valt terug op defaults bij ongeldige JSON', () => {
localStorage.setItem('sprint-workspace-hints', '{not-json')
const hints = readHints()
expect(hints.lastActiveProductId).toBeNull()
expect(hints.perProduct).toEqual({})
expect(hints.perSprint).toEqual({})
})
it('valt terug op defaults bij verkeerde shape', () => {
localStorage.setItem('sprint-workspace-hints', '"just a string"')
const hints = readHints()
expect(hints.perProduct).toEqual({})
expect(hints.perSprint).toEqual({})
})
})
describe('writeProductHint', () => {
it('schrijft lastActiveProductId', () => {
writeProductHint('p1')
expect(readHints().lastActiveProductId).toBe('p1')
})
it('overschrijft bestaande waarde', () => {
writeProductHint('p1')
writeProductHint('p2')
expect(readHints().lastActiveProductId).toBe('p2')
})
it('accepteert null om hint te wissen', () => {
writeProductHint('p1')
writeProductHint(null)
expect(readHints().lastActiveProductId).toBeNull()
})
})
describe('writeSprintHint', () => {
it('schrijft lastActiveSprintId per productId', () => {
writeSprintHint('prod-1', 'sp-a')
writeSprintHint('prod-2', 'sp-b')
const hints = readHints()
expect(hints.perProduct['prod-1'].lastActiveSprintId).toBe('sp-a')
expect(hints.perProduct['prod-2'].lastActiveSprintId).toBe('sp-b')
})
it('accepteert null om sprint-hint te wissen', () => {
writeSprintHint('prod-1', 'sp-a')
writeSprintHint('prod-1', null)
expect(readHints().perProduct['prod-1'].lastActiveSprintId).toBeNull()
})
})
describe('writeStoryHint', () => {
it('schrijft lastActiveStoryId per sprintId', () => {
writeStoryHint('sp-1', 's-1')
expect(readHints().perSprint['sp-1'].lastActiveStoryId).toBe('s-1')
})
it('null wist child task-hint', () => {
writeStoryHint('sp-1', 's-1')
writeTaskHint('sp-1', 't-1')
writeStoryHint('sp-1', null)
expect(readHints().perSprint['sp-1'].lastActiveStoryId).toBeNull()
expect(readHints().perSprint['sp-1'].lastActiveTaskId).toBeNull()
})
})
describe('writeTaskHint', () => {
it('schrijft lastActiveTaskId per sprintId', () => {
writeTaskHint('sp-1', 't-1')
expect(readHints().perSprint['sp-1'].lastActiveTaskId).toBe('t-1')
})
})
describe('clearHints', () => {
it('verwijdert alle hints', () => {
writeProductHint('p1')
writeSprintHint('p1', 'sp-1')
writeStoryHint('sp-1', 's-1')
clearHints()
const hints = readHints()
expect(hints.lastActiveProductId).toBeNull()
expect(hints.perProduct).toEqual({})
expect(hints.perSprint).toEqual({})
})
})

View file

@ -1,875 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store'
import type {
SprintWorkspaceSnapshot,
SprintWorkspaceSprint,
SprintWorkspaceStory,
SprintWorkspaceTask,
SprintWorkspaceTaskDetail,
} from '@/stores/sprint-workspace/types'
// G5: snapshot original actions on module-load; restore in beforeEach.
const originalActions = (() => {
const s = useSprintWorkspaceStore.getState()
return {
hydrateSnapshot: s.hydrateSnapshot,
hydrateProductSprints: s.hydrateProductSprints,
setActiveProduct: s.setActiveProduct,
setActiveSprint: s.setActiveSprint,
setActiveStory: s.setActiveStory,
setActiveTask: s.setActiveTask,
ensureProductSprintsLoaded: s.ensureProductSprintsLoaded,
ensureSprintLoaded: s.ensureSprintLoaded,
ensureStoryLoaded: s.ensureStoryLoaded,
ensureTaskLoaded: s.ensureTaskLoaded,
applyRealtimeEvent: s.applyRealtimeEvent,
resyncActiveScopes: s.resyncActiveScopes,
resyncLoadedScopes: s.resyncLoadedScopes,
applyOptimisticMutation: s.applyOptimisticMutation,
rollbackMutation: s.rollbackMutation,
settleMutation: s.settleMutation,
setRealtimeStatus: s.setRealtimeStatus,
}
})()
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.sync.realtimeStatus = 'connecting'
s.sync.lastEventAt = null
s.sync.lastResyncAt = null
s.sync.resyncReason = null
s.pendingMutations = {}
Object.assign(s, originalActions)
})
}
beforeEach(() => {
resetStore()
})
afterEach(() => {
vi.restoreAllMocks()
})
function makeSprint(
overrides: Partial<SprintWorkspaceSprint> & { id: string; product_id: string },
): SprintWorkspaceSprint {
return {
id: overrides.id,
product_id: overrides.product_id,
code: overrides.code ?? `S-${overrides.id}`,
sprint_goal: overrides.sprint_goal ?? `Goal ${overrides.id}`,
status: overrides.status ?? 'OPEN',
start_date: overrides.start_date ?? '2026-04-01',
end_date: overrides.end_date ?? '2026-04-14',
created_at: overrides.created_at ?? new Date('2026-03-15'),
completed_at: overrides.completed_at ?? null,
}
}
function makeStory(
overrides: Partial<SprintWorkspaceStory> & { id: string; pbi_id: string },
): SprintWorkspaceStory {
return {
id: overrides.id,
code: overrides.code ?? overrides.id,
title: overrides.title ?? `Story ${overrides.id}`,
description: overrides.description ?? null,
acceptance_criteria: overrides.acceptance_criteria ?? null,
priority: overrides.priority ?? 2,
sort_order: overrides.sort_order ?? 1,
status: overrides.status ?? 'OPEN',
pbi_id: overrides.pbi_id,
sprint_id: overrides.sprint_id ?? null,
created_at: overrides.created_at ?? new Date('2026-01-01'),
}
}
function makeTask(
overrides: Partial<SprintWorkspaceTask> & { id: string; story_id: string },
): SprintWorkspaceTask {
return {
id: overrides.id,
code: overrides.code ?? null,
title: overrides.title ?? `Task ${overrides.id}`,
description: overrides.description ?? null,
priority: overrides.priority ?? 2,
sort_order: overrides.sort_order ?? 1,
status: overrides.status ?? 'TO_DO',
story_id: overrides.story_id,
sprint_id: overrides.sprint_id ?? null,
created_at: overrides.created_at ?? new Date('2026-01-01'),
}
}
function snapshotWith(
sprint: SprintWorkspaceSprint | undefined,
stories: SprintWorkspaceStory[] = [],
tasksByStory: Record<string, SprintWorkspaceTask[]> = {},
product?: { id: string; name: string },
): SprintWorkspaceSnapshot {
return { product, sprint, stories, tasksByStory }
}
// G7/G8: mock fetch via mockImplementation (vers Response per call)
function mockFetchSequence(
responses: Array<unknown | ((url: string, init?: RequestInit) => unknown)>,
) {
let i = 0
return vi.spyOn(globalThis, 'fetch').mockImplementation((async (url: string, init?: RequestInit) => {
const r = responses[Math.min(i, responses.length - 1)]
i += 1
const body = typeof r === 'function' ? (r as (u: string, i?: RequestInit) => unknown)(url, init) : r
return new Response(JSON.stringify(body ?? null), { status: 200 })
}) as unknown as typeof fetch)
}
// ─────────────────────────────────────────────────────────────────────────
// hydrateSnapshot
// ─────────────────────────────────────────────────────────────────────────
describe('hydrateSnapshot', () => {
it('vult entities, relations en loaded-marker', () => {
const sprint = makeSprint({ id: 'sp-1', product_id: 'prod-1' })
const storyA = makeStory({ id: 's-a', pbi_id: 'pbi-1', sprint_id: 'sp-1', sort_order: 2 })
const storyB = makeStory({ id: 's-b', pbi_id: 'pbi-1', sprint_id: 'sp-1', sort_order: 1 })
const taskA = makeTask({ id: 't-a', story_id: 's-a', sort_order: 2 })
const taskB = makeTask({ id: 't-b', story_id: 's-a', sort_order: 1 })
useSprintWorkspaceStore.getState().hydrateSnapshot(
snapshotWith(
sprint,
[storyA, storyB],
{ 's-a': [taskA, taskB] },
{ id: 'prod-1', name: 'Product 1' },
),
)
const s = useSprintWorkspaceStore.getState()
expect(s.entities.sprintsById['sp-1']).toEqual(sprint)
expect(s.entities.storiesById['s-a']).toEqual(storyA)
expect(s.entities.storiesById['s-b']).toEqual(storyB)
// sort_order: storyB (1) before storyA (2)
expect(s.relations.storyIdsBySprint['sp-1']).toEqual(['s-b', 's-a'])
// sort_order: taskB (1) before taskA (2)
expect(s.relations.taskIdsByStory['s-a']).toEqual(['t-b', 't-a'])
expect(s.context.activeProduct).toEqual({ id: 'prod-1', name: 'Product 1' })
expect(s.loading.loadedSprintIds['sp-1']).toBe(true)
})
it('normaliseert API-statussen naar het interne store-contract', () => {
useSprintWorkspaceStore.getState().hydrateSnapshot(
snapshotWith(
makeSprint({ id: 'sp-1', product_id: 'prod-1' }),
[makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1', status: 'in_sprint' })],
{ 's-1': [makeTask({ id: 't-1', story_id: 's-1', status: 'todo' })] },
),
)
const s = useSprintWorkspaceStore.getState()
expect(s.entities.storiesById['s-1'].status).toBe('IN_SPRINT')
expect(s.entities.tasksById['t-1'].status).toBe('TO_DO')
})
})
describe('hydrateProductSprints', () => {
it('sorteert OPEN voor CLOSED, dan op start_date desc', () => {
const closedOld = makeSprint({
id: 'sp-closed-old',
product_id: 'prod-1',
status: 'CLOSED',
start_date: '2026-01-01',
})
const openNew = makeSprint({
id: 'sp-open-new',
product_id: 'prod-1',
status: 'OPEN',
start_date: '2026-04-01',
})
const openOld = makeSprint({
id: 'sp-open-old',
product_id: 'prod-1',
status: 'OPEN',
start_date: '2026-02-01',
})
useSprintWorkspaceStore
.getState()
.hydrateProductSprints('prod-1', [closedOld, openOld, openNew])
const s = useSprintWorkspaceStore.getState()
expect(s.relations.sprintIdsByProduct['prod-1']).toEqual([
'sp-open-new',
'sp-open-old',
'sp-closed-old',
])
expect(s.loading.loadedProductSprintsIds['prod-1']).toBe(true)
})
})
// ─────────────────────────────────────────────────────────────────────────
// Selection cascade
// ─────────────────────────────────────────────────────────────────────────
describe('selection cascade', () => {
it('setActiveSprint reset story+task; setActiveStory reset task', () => {
useSprintWorkspaceStore.setState((s) => {
s.context.activeSprintId = 'sp-old'
s.context.activeStoryId = 's-old'
s.context.activeTaskId = 't-old'
})
useSprintWorkspaceStore.getState().setActiveSprint('sp-new')
let s = useSprintWorkspaceStore.getState()
expect(s.context.activeSprintId).toBe('sp-new')
expect(s.context.activeStoryId).toBeNull()
expect(s.context.activeTaskId).toBeNull()
useSprintWorkspaceStore.setState((draft) => {
draft.context.activeStoryId = 's-old'
draft.context.activeTaskId = 't-old'
})
useSprintWorkspaceStore.getState().setActiveStory('s-new')
s = useSprintWorkspaceStore.getState()
expect(s.context.activeStoryId).toBe('s-new')
expect(s.context.activeTaskId).toBeNull()
})
it('setActiveProduct(null) ruimt entities en relations op', () => {
useSprintWorkspaceStore.getState().hydrateSnapshot(
snapshotWith(
makeSprint({ id: 'sp-1', product_id: 'prod-1' }),
[makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1' })],
{ 's-1': [makeTask({ id: 't-1', story_id: 's-1' })] },
{ id: 'prod-1', name: 'Product 1' },
),
)
useSprintWorkspaceStore.getState().setActiveProduct(null)
const s = useSprintWorkspaceStore.getState()
expect(s.context.activeProduct).toBeNull()
expect(s.context.activeSprintId).toBeNull()
expect(s.entities.sprintsById).toEqual({})
expect(s.entities.storiesById).toEqual({})
expect(s.entities.tasksById).toEqual({})
expect(s.relations.sprintIdsByProduct).toEqual({})
expect(s.relations.storyIdsBySprint).toEqual({})
expect(s.relations.taskIdsByStory).toEqual({})
})
})
// ─────────────────────────────────────────────────────────────────────────
// applyRealtimeEvent
// ─────────────────────────────────────────────────────────────────────────
describe('applyRealtimeEvent — sprint', () => {
beforeEach(() => {
useSprintWorkspaceStore.setState((s) => {
s.context.activeProduct = { id: 'prod-1', name: 'Product 1' }
})
})
it('I — voegt sprint toe aan product-lijst', () => {
useSprintWorkspaceStore.getState().applyRealtimeEvent({
entity: 'sprint',
op: 'I',
id: 'sp-new',
product_id: 'prod-1',
code: 'S-1',
sprint_goal: 'New Sprint',
status: 'OPEN',
start_date: '2026-05-01',
end_date: '2026-05-14',
created_at: new Date('2026-04-15').toISOString(),
})
const s = useSprintWorkspaceStore.getState()
expect(s.entities.sprintsById['sp-new']).toBeDefined()
expect(s.relations.sprintIdsByProduct['prod-1']).toContain('sp-new')
})
it('I — idempotent voor bestaande id', () => {
useSprintWorkspaceStore
.getState()
.hydrateProductSprints('prod-1', [
makeSprint({ id: 'sp-1', product_id: 'prod-1', sprint_goal: 'Origineel' }),
])
useSprintWorkspaceStore.getState().applyRealtimeEvent({
entity: 'sprint',
op: 'I',
id: 'sp-1',
product_id: 'prod-1',
sprint_goal: 'echo',
})
expect(useSprintWorkspaceStore.getState().entities.sprintsById['sp-1'].sprint_goal).toBe(
'Origineel',
)
})
it('U — patch + her-sorteert', () => {
useSprintWorkspaceStore
.getState()
.hydrateProductSprints('prod-1', [
makeSprint({
id: 'sp-a',
product_id: 'prod-1',
status: 'OPEN',
start_date: '2026-04-01',
}),
makeSprint({
id: 'sp-b',
product_id: 'prod-1',
status: 'OPEN',
start_date: '2026-03-01',
}),
])
// sp-a (newer) komt eerst
expect(
useSprintWorkspaceStore.getState().relations.sprintIdsByProduct['prod-1'],
).toEqual(['sp-a', 'sp-b'])
// Sluit sp-a → moet naar achteren
useSprintWorkspaceStore.getState().applyRealtimeEvent({
entity: 'sprint',
op: 'U',
id: 'sp-a',
product_id: 'prod-1',
status: 'CLOSED',
})
expect(
useSprintWorkspaceStore.getState().relations.sprintIdsByProduct['prod-1'],
).toEqual(['sp-b', 'sp-a'])
})
it('D — verwijdert sprint inclusief child stories en tasks', () => {
useSprintWorkspaceStore.getState().hydrateSnapshot(
snapshotWith(
makeSprint({ id: 'sp-1', product_id: 'prod-1' }),
[makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1' })],
{ 's-1': [makeTask({ id: 't-1', story_id: 's-1' })] },
{ id: 'prod-1', name: 'Product 1' },
),
)
useSprintWorkspaceStore.getState().applyRealtimeEvent({
entity: 'sprint',
op: 'D',
id: 'sp-1',
product_id: 'prod-1',
})
const s = useSprintWorkspaceStore.getState()
expect(s.entities.sprintsById['sp-1']).toBeUndefined()
expect(s.entities.storiesById['s-1']).toBeUndefined()
expect(s.entities.tasksById['t-1']).toBeUndefined()
expect(s.relations.storyIdsBySprint['sp-1']).toBeUndefined()
expect(s.relations.taskIdsByStory['s-1']).toBeUndefined()
})
it('D — clear actieve sprint selectie als die de verwijderde sprint was', () => {
useSprintWorkspaceStore.getState().hydrateSnapshot(
snapshotWith(makeSprint({ id: 'sp-1', product_id: 'prod-1' }), []),
)
useSprintWorkspaceStore.setState((s) => {
s.context.activeSprintId = 'sp-1'
s.context.activeStoryId = 's-x'
s.context.activeTaskId = 't-x'
})
useSprintWorkspaceStore.getState().applyRealtimeEvent({
entity: 'sprint',
op: 'D',
id: 'sp-1',
product_id: 'prod-1',
})
const s = useSprintWorkspaceStore.getState()
expect(s.context.activeSprintId).toBeNull()
expect(s.context.activeStoryId).toBeNull()
expect(s.context.activeTaskId).toBeNull()
})
})
describe('applyRealtimeEvent — story sprint-move', () => {
beforeEach(() => {
useSprintWorkspaceStore.setState((s) => {
s.context.activeProduct = { id: 'prod-1', name: 'Product 1' }
})
useSprintWorkspaceStore.getState().hydrateSnapshot(
snapshotWith(
makeSprint({ id: 'sp-1', product_id: 'prod-1' }),
[makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1' })],
),
)
})
it('U met andere sprint_id verplaatst story uit sprint-relatie', () => {
useSprintWorkspaceStore.getState().applyRealtimeEvent({
entity: 'story',
op: 'U',
id: 's-1',
product_id: 'prod-1',
pbi_id: 'pbi-1',
sprint_id: 'sp-other',
})
const s = useSprintWorkspaceStore.getState()
expect(s.relations.storyIdsBySprint['sp-1']).toEqual([])
expect(s.relations.storyIdsBySprint['sp-other']).toEqual(['s-1'])
expect(s.entities.storiesById['s-1'].sprint_id).toBe('sp-other')
})
it('U met sprint_id=null haalt story uit sprint-relatie', () => {
useSprintWorkspaceStore.getState().applyRealtimeEvent({
entity: 'story',
op: 'U',
id: 's-1',
product_id: 'prod-1',
pbi_id: 'pbi-1',
sprint_id: null,
})
expect(useSprintWorkspaceStore.getState().relations.storyIdsBySprint['sp-1']).toEqual([])
expect(useSprintWorkspaceStore.getState().entities.storiesById['s-1'].sprint_id).toBeNull()
})
})
describe('applyRealtimeEvent — task parent-move', () => {
beforeEach(() => {
useSprintWorkspaceStore.setState((s) => {
s.context.activeProduct = { id: 'prod-1', name: 'Product 1' }
})
useSprintWorkspaceStore.getState().hydrateSnapshot(
snapshotWith(
makeSprint({ id: 'sp-1', product_id: 'prod-1' }),
[
makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1' }),
makeStory({ id: 's-2', pbi_id: 'pbi-1', sprint_id: 'sp-1' }),
],
{
's-1': [makeTask({ id: 't-1', story_id: 's-1' })],
's-2': [],
},
),
)
})
it('U met andere story_id verplaatst task naar nieuwe parent', () => {
useSprintWorkspaceStore.getState().applyRealtimeEvent({
entity: 'task',
op: 'U',
id: 't-1',
product_id: 'prod-1',
story_id: 's-2',
})
const s = useSprintWorkspaceStore.getState()
expect(s.relations.taskIdsByStory['s-1']).toEqual([])
expect(s.relations.taskIdsByStory['s-2']).toEqual(['t-1'])
expect(s.entities.tasksById['t-1'].story_id).toBe('s-2')
})
})
describe('applyRealtimeEvent — andere product genegeerd', () => {
it('event met ander product_id raakt de store niet', () => {
useSprintWorkspaceStore.setState((s) => {
s.context.activeProduct = { id: 'prod-1', name: 'Product 1' }
})
useSprintWorkspaceStore
.getState()
.hydrateProductSprints('prod-1', [makeSprint({ id: 'sp-1', product_id: 'prod-1' })])
useSprintWorkspaceStore.getState().applyRealtimeEvent({
entity: 'sprint',
op: 'I',
id: 'sp-other',
product_id: 'prod-2',
code: 'X',
})
const s = useSprintWorkspaceStore.getState()
expect(s.entities.sprintsById['sp-other']).toBeUndefined()
})
})
describe('applyRealtimeEvent — unknown entity → resync trigger', () => {
function withSpy(): ReturnType<typeof vi.fn> {
useSprintWorkspaceStore.setState((s) => {
s.context.activeProduct = { id: 'prod-1', name: 'Product 1' }
})
const spy = vi.fn().mockResolvedValue(undefined)
useSprintWorkspaceStore.setState((s) => {
s.resyncActiveScopes = spy as unknown as typeof s.resyncActiveScopes
})
return spy
}
it('unknown entity met matching product triggert resync', () => {
const spy = withSpy()
useSprintWorkspaceStore.getState().applyRealtimeEvent({
entity: 'comment',
op: 'I',
id: 'cm-1',
product_id: 'prod-1',
})
expect(spy).toHaveBeenCalledWith('unknown-event')
})
it('unknown entity met ander product_id triggert geen resync', () => {
const spy = withSpy()
useSprintWorkspaceStore.getState().applyRealtimeEvent({
entity: 'comment',
op: 'I',
id: 'cm-1',
product_id: 'prod-2',
})
expect(spy).not.toHaveBeenCalled()
})
it('claude_job_status (type-veld) triggert geen resync', () => {
const spy = withSpy()
useSprintWorkspaceStore.getState().applyRealtimeEvent({
type: 'claude_job_status',
job_id: 'job-1',
product_id: 'prod-1',
status: 'queued',
})
expect(spy).not.toHaveBeenCalled()
})
it('worker_heartbeat (type-veld) triggert geen resync', () => {
const spy = withSpy()
useSprintWorkspaceStore.getState().applyRealtimeEvent({
type: 'worker_heartbeat',
worker_id: 'w-1',
product_id: 'prod-1',
})
expect(spy).not.toHaveBeenCalled()
})
it('payload zonder entity en zonder type wordt genegeerd', () => {
const spy = withSpy()
useSprintWorkspaceStore.getState().applyRealtimeEvent({
product_id: 'prod-1',
something: 'else',
})
expect(spy).not.toHaveBeenCalled()
})
})
// ─────────────────────────────────────────────────────────────────────────
// ensure*Loaded
// ─────────────────────────────────────────────────────────────────────────
describe('ensureProductSprintsLoaded', () => {
it('fetcht sprint-list en hydreert met sortering', async () => {
const sprints = [
makeSprint({
id: 'sp-old',
product_id: 'prod-1',
status: 'CLOSED',
start_date: '2026-01-01',
}),
makeSprint({
id: 'sp-new',
product_id: 'prod-1',
status: 'OPEN',
start_date: '2026-04-01',
}),
]
const fetchSpy = mockFetchSequence([sprints])
await useSprintWorkspaceStore.getState().ensureProductSprintsLoaded('prod-1')
expect(fetchSpy).toHaveBeenCalledWith(
'/api/products/prod-1/sprints',
expect.objectContaining({ cache: 'no-store' }),
)
const s = useSprintWorkspaceStore.getState()
expect(s.relations.sprintIdsByProduct['prod-1']).toEqual(['sp-new', 'sp-old'])
expect(s.loading.loadedProductSprintsIds['prod-1']).toBe(true)
})
})
describe('ensureSprintLoaded', () => {
it('fetcht sprint-snapshot en hydreert', async () => {
const sprint = makeSprint({ id: 'sp-1', product_id: 'prod-1' })
const snapshot: SprintWorkspaceSnapshot = {
product: { id: 'prod-1', name: 'P1' },
sprint,
stories: [makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1' })],
tasksByStory: {
's-1': [makeTask({ id: 't-1', story_id: 's-1' })],
},
}
const fetchSpy = mockFetchSequence([snapshot])
await useSprintWorkspaceStore.getState().ensureSprintLoaded('sp-1')
expect(fetchSpy).toHaveBeenCalledWith(
'/api/sprints/sp-1/workspace',
expect.objectContaining({ cache: 'no-store' }),
)
const s = useSprintWorkspaceStore.getState()
expect(s.entities.sprintsById['sp-1']).toBeDefined()
expect(s.relations.storyIdsBySprint['sp-1']).toEqual(['s-1'])
expect(s.relations.taskIdsByStory['s-1']).toEqual(['t-1'])
expect(s.loading.loadedSprintIds['sp-1']).toBe(true)
})
})
describe('race-safe ensure*Loaded — activeRequestId guard', () => {
it('oudere in-flight ensureSprintLoaded mag nieuwere selectie niet overschrijven', async () => {
let resolveOld: ((snap: SprintWorkspaceSnapshot) => void) | null = null
vi.spyOn(globalThis, 'fetch').mockImplementation((async (url: string) => {
if (url === '/api/sprints/sp-old/workspace') {
const snap = await new Promise<SprintWorkspaceSnapshot>((resolve) => {
resolveOld = resolve
})
return new Response(JSON.stringify(snap), { status: 200 })
}
if (url === '/api/sprints/sp-new/workspace') {
return new Response(
JSON.stringify({
sprint: makeSprint({ id: 'sp-new', product_id: 'prod-1' }),
stories: [makeStory({ id: 'new-st', pbi_id: 'p', sprint_id: 'sp-new' })],
tasksByStory: {},
}),
{ status: 200 },
)
}
return new Response('null', { status: 200 })
}) as unknown as typeof fetch)
useSprintWorkspaceStore.setState((s) => {
s.context.activeProduct = { id: 'prod-1', name: 'Product 1' }
s.context.activeSprintId = 'sp-old'
s.loading.activeRequestId = 'req-old'
})
const oldPromise = useSprintWorkspaceStore
.getState()
.ensureSprintLoaded('sp-old', 'req-old')
useSprintWorkspaceStore.setState((s) => {
s.context.activeSprintId = 'sp-new'
s.loading.activeRequestId = 'req-new'
})
await useSprintWorkspaceStore
.getState()
.ensureSprintLoaded('sp-new', 'req-new')
expect(useSprintWorkspaceStore.getState().entities.storiesById['new-st']).toBeDefined()
resolveOld!({
sprint: makeSprint({ id: 'sp-old', product_id: 'prod-1' }),
stories: [makeStory({ id: 'old-st', pbi_id: 'p', sprint_id: 'sp-old' })],
tasksByStory: {},
})
await oldPromise
const s = useSprintWorkspaceStore.getState()
expect(s.context.activeSprintId).toBe('sp-new')
expect(s.entities.storiesById['old-st']).toBeUndefined()
expect(s.entities.storiesById['new-st']).toBeDefined()
})
})
describe('ensureTaskLoaded — zet detail-flag', () => {
it('verrijkt task naar TaskDetail met _detail: true', async () => {
mockFetchSequence([
{
id: 't-1',
code: 'C1',
title: 'Task 1',
description: 'desc',
priority: 1,
sort_order: 1,
status: 'todo',
story_id: 's-1',
sprint_id: 'sp-1',
created_at: new Date('2026-02-01').toISOString(),
implementation_plan: 'detailed plan here',
},
])
await useSprintWorkspaceStore.getState().ensureTaskLoaded('t-1')
const task = useSprintWorkspaceStore.getState().entities.tasksById[
't-1'
] as SprintWorkspaceTaskDetail
expect(task._detail).toBe(true)
expect(task.status).toBe('TO_DO')
expect(task.implementation_plan).toBe('detailed plan here')
expect(useSprintWorkspaceStore.getState().loading.loadedTaskIds['t-1']).toBe(true)
})
})
// ─────────────────────────────────────────────────────────────────────────
// resyncActiveScopes
// ─────────────────────────────────────────────────────────────────────────
describe('resyncActiveScopes', () => {
it('triggert ensure-keten voor alle actieve scopes en zet sync velden', async () => {
const fetchSpy = mockFetchSequence([
// ensureProductSprintsLoaded
[],
// ensureSprintLoaded
{
sprint: makeSprint({ id: 'sp-1', product_id: 'prod-1' }),
stories: [],
tasksByStory: {},
},
// ensureStoryLoaded
[],
// ensureTaskLoaded
{
id: 't-1',
title: 'T',
description: null,
priority: 1,
sort_order: 1,
status: 'todo',
story_id: 's-1',
sprint_id: 'sp-1',
created_at: '2026-02-01',
},
])
useSprintWorkspaceStore.setState((s) => {
s.context.activeProduct = { id: 'prod-1', name: 'P' }
s.context.activeSprintId = 'sp-1'
s.context.activeStoryId = 's-1'
s.context.activeTaskId = 't-1'
})
await useSprintWorkspaceStore.getState().resyncActiveScopes('manual')
const calls = fetchSpy.mock.calls.map(([url]) => url)
expect(calls).toContain('/api/products/prod-1/sprints')
expect(calls).toContain('/api/sprints/sp-1/workspace')
expect(calls).toContain('/api/stories/s-1/tasks')
expect(calls).toContain('/api/tasks/t-1')
const s = useSprintWorkspaceStore.getState()
expect(s.sync.lastResyncAt).toBeTypeOf('number')
expect(s.sync.resyncReason).toBe('manual')
})
})
// ─────────────────────────────────────────────────────────────────────────
// Restore-hint flow
// ─────────────────────────────────────────────────────────────────────────
describe('restore-hint flow — setters persisteren hints', () => {
it('setActiveProduct schrijft lastActiveProductId', () => {
useSprintWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' })
const raw = localStorage.getItem('sprint-workspace-hints')
expect(raw).not.toBeNull()
const hints = JSON.parse(raw!)
expect(hints.lastActiveProductId).toBe('prod-1')
})
it('setActiveSprint schrijft lastActiveSprintId per product', () => {
useSprintWorkspaceStore.setState((s) => {
s.context.activeProduct = { id: 'prod-1', name: 'P1' }
})
useSprintWorkspaceStore.getState().setActiveSprint('sp-a')
const hints = JSON.parse(localStorage.getItem('sprint-workspace-hints')!)
expect(hints.perProduct['prod-1'].lastActiveSprintId).toBe('sp-a')
})
it('setActiveStory schrijft lastActiveStoryId per sprint', () => {
useSprintWorkspaceStore.setState((s) => {
s.context.activeSprintId = 'sp-1'
})
useSprintWorkspaceStore.getState().setActiveStory('s-a')
const hints = JSON.parse(localStorage.getItem('sprint-workspace-hints')!)
expect(hints.perSprint['sp-1'].lastActiveStoryId).toBe('s-a')
})
it('setActiveTask schrijft lastActiveTaskId per sprint', () => {
useSprintWorkspaceStore.setState((s) => {
s.context.activeSprintId = 'sp-1'
})
useSprintWorkspaceStore.getState().setActiveTask('t-a')
const hints = JSON.parse(localStorage.getItem('sprint-workspace-hints')!)
expect(hints.perSprint['sp-1'].lastActiveTaskId).toBe('t-a')
})
})
describe('restore-hint flow — chain triggert na ensure*Loaded', () => {
it('hint die NIET in entities zit wordt genegeerd', async () => {
localStorage.setItem(
'sprint-workspace-hints',
JSON.stringify({
lastActiveProductId: 'prod-1',
perProduct: { 'prod-1': { lastActiveSprintId: 'ghost-sprint' } },
perSprint: {},
}),
)
mockFetchSequence([[]])
useSprintWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' })
await new Promise((r) => setTimeout(r, 20))
expect(useSprintWorkspaceStore.getState().context.activeSprintId).toBeNull()
})
it('hint die wel in entities zit wordt toegepast', async () => {
const validSprint = makeSprint({ id: 'sp-known', product_id: 'prod-1' })
localStorage.setItem(
'sprint-workspace-hints',
JSON.stringify({
lastActiveProductId: 'prod-1',
perProduct: { 'prod-1': { lastActiveSprintId: 'sp-known' } },
perSprint: {},
}),
)
mockFetchSequence([
// ensureProductSprintsLoaded — levert sp-known
[validSprint],
// ensureSprintLoaded triggered door setActiveSprint(hint)
{ sprint: validSprint, stories: [], tasksByStory: {} },
])
useSprintWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' })
await new Promise((r) => setTimeout(r, 30))
expect(useSprintWorkspaceStore.getState().context.activeSprintId).toBe('sp-known')
})
})
// ─────────────────────────────────────────────────────────────────────────
// Optimistic mutations
// ─────────────────────────────────────────────────────────────────────────
describe('optimistic mutations', () => {
it('SSE-echo van een al-bestaande sprint is idempotent', () => {
useSprintWorkspaceStore.setState((s) => {
s.context.activeProduct = { id: 'prod-1', name: 'P' }
})
useSprintWorkspaceStore
.getState()
.hydrateProductSprints('prod-1', [
makeSprint({ id: 'sp-1', product_id: 'prod-1', sprint_goal: 'Origineel' }),
])
useSprintWorkspaceStore.getState().applyRealtimeEvent({
entity: 'sprint',
op: 'I',
id: 'sp-1',
product_id: 'prod-1',
sprint_goal: 'echo',
})
expect(
useSprintWorkspaceStore.getState().entities.sprintsById['sp-1'].sprint_goal,
).toBe('Origineel')
})
})

View file

@ -1,240 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const updateAction = vi.fn()
const setDraftAction = vi.fn()
const clearDraftAction = vi.fn()
vi.mock('@/actions/user-settings', () => ({
updateUserSettingsAction: (...args: unknown[]) => updateAction(...args),
}))
vi.mock('@/actions/sprint-draft', () => ({
setPendingSprintDraftAction: (...args: unknown[]) => setDraftAction(...args),
clearPendingSprintDraftAction: (...args: unknown[]) =>
clearDraftAction(...args),
}))
import { useUserSettingsStore } from '@/stores/user-settings/store'
import type { PendingSprintDraft } from '@/lib/user-settings'
function resetStore() {
useUserSettingsStore.setState((s) => {
s.entities.settings = {}
s.context.hydrated = false
s.context.isDemo = false
s.pendingMutations = {}
})
}
beforeEach(() => {
resetStore()
updateAction.mockReset()
setDraftAction.mockReset()
clearDraftAction.mockReset()
})
afterEach(() => {
resetStore()
})
describe('useUserSettingsStore', () => {
it('hydrate sets entities and context', () => {
useUserSettingsStore.getState().hydrate(
{ views: { sprintBacklog: { sort: 'code' } } },
false,
)
const s = useUserSettingsStore.getState()
expect(s.entities.settings.views?.sprintBacklog?.sort).toBe('code')
expect(s.context.hydrated).toBe(true)
expect(s.context.isDemo).toBe(false)
})
it('setPref updates state optimistically and settles on success', async () => {
useUserSettingsStore.getState().hydrate({}, false)
updateAction.mockResolvedValueOnce({
success: true,
settings: { views: { sprintBacklog: { filterStatus: 'all' } } },
})
await useUserSettingsStore
.getState()
.setPref(['views', 'sprintBacklog', 'filterStatus'], 'all')
const s = useUserSettingsStore.getState()
expect(s.entities.settings.views?.sprintBacklog?.filterStatus).toBe('all')
expect(Object.keys(s.pendingMutations)).toHaveLength(0)
expect(updateAction).toHaveBeenCalledWith({
views: { sprintBacklog: { filterStatus: 'all' } },
})
})
it('setPref rolls back on server error', async () => {
useUserSettingsStore.getState().hydrate(
{ views: { sprintBacklog: { sort: 'code' } } },
false,
)
updateAction.mockResolvedValueOnce({ error: 'boom', code: 422 })
await useUserSettingsStore
.getState()
.setPref(['views', 'sprintBacklog', 'sort'], 'priority')
const s = useUserSettingsStore.getState()
expect(s.entities.settings.views?.sprintBacklog?.sort).toBe('code')
expect(Object.keys(s.pendingMutations)).toHaveLength(0)
})
it('setPref skips server-call for demo accounts', async () => {
useUserSettingsStore.getState().hydrate({}, true)
await useUserSettingsStore
.getState()
.setPref(['devTools', 'debugMode'], true)
const s = useUserSettingsStore.getState()
expect(s.entities.settings.devTools?.debugMode).toBe(true)
expect(updateAction).not.toHaveBeenCalled()
})
it('setPendingSprintDraft persists draft lokaal (session-only, geen server-call)', async () => {
useUserSettingsStore.getState().hydrate({}, false)
const draft: PendingSprintDraft = {
goal: 'Sprint 1',
pbiIntent: { pbiA: 'all' },
storyOverrides: {},
}
await useUserSettingsStore
.getState()
.setPendingSprintDraft('product-1', draft)
const s = useUserSettingsStore.getState()
expect(
s.entities.settings.workflow?.pendingSprintDraft?.['product-1'],
).toMatchObject({ goal: 'Sprint 1' })
expect(setDraftAction).not.toHaveBeenCalled()
})
it('hydrate strips workflow.pendingSprintDraft uit legacy server-state', () => {
useUserSettingsStore.getState().hydrate(
{
workflow: {
pendingSprintDraft: {
'product-1': {
goal: 'Legacy draft',
pbiIntent: {},
storyOverrides: {},
},
},
},
},
false,
)
const s = useUserSettingsStore.getState()
expect(s.entities.settings.workflow?.pendingSprintDraft).toBeUndefined()
})
it('clearPendingSprintDraft verwijdert de key lokaal zonder server-call', async () => {
useUserSettingsStore.getState().hydrate({}, false)
await useUserSettingsStore.getState().setPendingSprintDraft('product-1', {
goal: 'Old',
pbiIntent: {},
storyOverrides: {},
})
await useUserSettingsStore
.getState()
.clearPendingSprintDraft('product-1')
const s = useUserSettingsStore.getState()
expect(
s.entities.settings.workflow?.pendingSprintDraft?.['product-1'],
).toBeUndefined()
expect(clearDraftAction).not.toHaveBeenCalled()
})
it('upsertPbiIntent updates intent and wipes storyOverrides for that PBI', async () => {
useUserSettingsStore.getState().hydrate({}, false)
await useUserSettingsStore.getState().setPendingSprintDraft('product-1', {
goal: 'g',
pbiIntent: { pbiA: 'none' },
storyOverrides: {
pbiA: { add: ['s-1'], remove: [] },
pbiB: { add: [], remove: ['s-2'] },
},
})
await useUserSettingsStore
.getState()
.upsertPbiIntent('product-1', 'pbiA', 'all')
const draft =
useUserSettingsStore.getState().entities.settings.workflow
?.pendingSprintDraft?.['product-1']
expect(draft?.pbiIntent.pbiA).toBe('all')
expect(draft?.storyOverrides.pbiA).toBeUndefined()
expect(draft?.storyOverrides.pbiB).toEqual({ add: [], remove: ['s-2'] })
})
it('upsertStoryOverride add adds to add[] and removes from remove[]', async () => {
useUserSettingsStore.getState().hydrate({}, false)
await useUserSettingsStore.getState().setPendingSprintDraft('product-1', {
goal: 'g',
pbiIntent: {},
storyOverrides: {
pbiA: { add: [], remove: ['story-1'] },
},
})
await useUserSettingsStore
.getState()
.upsertStoryOverride('product-1', 'pbiA', 'story-1', 'add')
const draft =
useUserSettingsStore.getState().entities.settings.workflow
?.pendingSprintDraft?.['product-1']
expect(draft?.storyOverrides.pbiA).toEqual({
add: ['story-1'],
remove: [],
})
})
it('upsertStoryOverride clear removes from both arrays and drops empty entry', async () => {
useUserSettingsStore.getState().hydrate({}, false)
await useUserSettingsStore.getState().setPendingSprintDraft('product-1', {
goal: 'g',
pbiIntent: {},
storyOverrides: {
pbiA: { add: ['story-1'], remove: [] },
},
})
await useUserSettingsStore
.getState()
.upsertStoryOverride('product-1', 'pbiA', 'story-1', 'clear')
const draft =
useUserSettingsStore.getState().entities.settings.workflow
?.pendingSprintDraft?.['product-1']
expect(draft?.storyOverrides.pbiA).toBeUndefined()
})
it('applyServerPatch merges without optimistic state', () => {
useUserSettingsStore.getState().hydrate(
{ views: { sprintBacklog: { sort: 'code' } } },
false,
)
useUserSettingsStore.getState().applyServerPatch({
views: { sprintBacklog: { sortDir: 'desc' } },
})
const s = useUserSettingsStore.getState()
expect(s.entities.settings.views?.sprintBacklog).toEqual({
sort: 'code',
sortDir: 'desc',
})
expect(Object.keys(s.pendingMutations)).toHaveLength(0)
})
})

View file

@ -1,164 +0,0 @@
'use server'
import { revalidatePath } from 'next/cache'
import { cookies } from 'next/headers'
import { getIronSession } from 'iron-session'
import { z } from 'zod'
import { prisma } from '@/lib/prisma'
import { SessionData, sessionOptions } from '@/lib/session'
import { productAccessFilter } from '@/lib/product-access'
import {
clearActiveSprintInSettings,
setActiveSelectionInSettings,
setActiveSprintInSettings,
} from '@/lib/active-sprint'
async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions)
}
const setSchema = z.object({
productId: z.string().min(1),
sprintId: z.string().min(1),
})
const clearSchema = z.object({
productId: z.string().min(1),
})
export async function setActiveSprintAction(productId: string, sprintId: string) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
const parsed = setSchema.safeParse({ productId, sprintId })
if (!parsed.success) return { error: 'Ongeldig product- of sprint-id' }
const sprint = await prisma.sprint.findFirst({
where: {
id: parsed.data.sprintId,
product_id: parsed.data.productId,
product: productAccessFilter(session.userId),
},
select: { id: true },
})
if (!sprint) return { error: 'Sprint niet gevonden of niet toegankelijk' }
await setActiveSprintInSettings(session.userId, parsed.data.productId, parsed.data.sprintId)
revalidatePath('/', 'layout')
return { success: true, sprintId: parsed.data.sprintId }
}
export async function clearActiveSprintAction(productId: string) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
const parsed = clearSchema.safeParse({ productId })
if (!parsed.success) return { error: 'Ongeldig product-id' }
const product = await prisma.product.findFirst({
where: { id: parsed.data.productId, ...productAccessFilter(session.userId) },
select: { id: true },
})
if (!product) return { error: 'Product niet gevonden of niet toegankelijk' }
await clearActiveSprintInSettings(session.userId, parsed.data.productId)
revalidatePath('/', 'layout')
return { success: true }
}
const selectionSchema = z.object({
productId: z.string().min(1),
sprintId: z.string().min(1),
})
/**
* PBI-79: kies een sprint en auto-select zijn enige PBI/story (indien
* singleton). Resultaat wordt server-side bepaald + atomair in user-settings
* weggeschreven (sprint+pbi+story) zodat cross-device-restore klopt.
*/
export async function switchActiveSprintAction(
productId: string,
sprintId: string,
): Promise<
| {
success: true
sprintId: string
pbiId: string | null
storyId: string | null
}
| { error: string }
> {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
const parsed = selectionSchema.safeParse({ productId, sprintId })
if (!parsed.success) return { error: 'Ongeldig product- of sprint-id' }
const sprint = await prisma.sprint.findFirst({
where: {
id: parsed.data.sprintId,
product_id: parsed.data.productId,
product: productAccessFilter(session.userId),
},
select: { id: true },
})
if (!sprint) return { error: 'Sprint niet gevonden of niet toegankelijk' }
// Auto-select: alleen wanneer sprint exact één PBI heeft. Story-auto-select
// alleen wanneer die PBI exact één story binnen deze sprint heeft.
const sprintStories = await prisma.story.findMany({
where: {
sprint_id: parsed.data.sprintId,
product_id: parsed.data.productId,
},
select: { id: true, pbi_id: true },
})
const uniquePbiIds = Array.from(new Set(sprintStories.map((s) => s.pbi_id)))
let autoPbiId: string | null = null
let autoStoryId: string | null = null
if (uniquePbiIds.length === 1) {
autoPbiId = uniquePbiIds[0]
const storiesForPbi = sprintStories.filter((s) => s.pbi_id === autoPbiId)
if (storiesForPbi.length === 1) {
autoStoryId = storiesForPbi[0].id
}
}
await setActiveSelectionInSettings(session.userId, parsed.data.productId, {
sprintId: parsed.data.sprintId,
pbiId: autoPbiId,
storyId: autoStoryId,
})
revalidatePath('/', 'layout')
return {
success: true,
sprintId: parsed.data.sprintId,
pbiId: autoPbiId,
storyId: autoStoryId,
}
}
export async function syncActiveSprintCookieAction(productId: string, sprintId: string) {
const session = await getSession()
if (!session.userId) return
if (session.isDemo) return
const parsed = setSchema.safeParse({ productId, sprintId })
if (!parsed.success) return
const sprint = await prisma.sprint.findFirst({
where: {
id: parsed.data.sprintId,
product_id: parsed.data.productId,
product: productAccessFilter(session.userId),
},
select: { id: true },
})
if (!sprint) return
await setActiveSprintInSettings(session.userId, parsed.data.productId, parsed.data.sprintId)
}

View file

@ -1,41 +0,0 @@
'use server'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
import { prisma } from '@/lib/prisma'
import { requireAdmin } from '@/lib/auth-guard'
const cuidSchema = z.string().cuid()
export async function cancelJobAction(jobId: string) {
await requireAdmin()
const parsed = cuidSchema.safeParse(jobId)
if (!parsed.success) throw new Error('Ongeldig job-id')
const job = await prisma.claudeJob.findUnique({
where: { id: parsed.data },
select: { status: true },
})
if (!job) throw new Error('Job niet gevonden')
if (job.status === 'DONE' || job.status === 'FAILED' || job.status === 'CANCELLED' || job.status === 'SKIPPED') {
throw new Error('Job is al in eindstatus')
}
await prisma.claudeJob.update({
where: { id: parsed.data },
data: { status: 'CANCELLED', finished_at: new Date() },
})
revalidatePath('/admin/jobs')
}
export async function deleteJobAction(jobId: string) {
await requireAdmin()
const parsed = cuidSchema.safeParse(jobId)
if (!parsed.success) throw new Error('Ongeldig job-id')
await prisma.claudeJob.delete({ where: { id: parsed.data } })
revalidatePath('/admin/jobs')
}

View file

@ -1,86 +0,0 @@
'use server'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
import { prisma } from '@/lib/prisma'
import { requireAdmin } from '@/lib/auth-guard'
const adminProductSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().optional(),
repo_url: z.string().url().optional().or(z.literal('')),
definition_of_done: z.string().min(1),
auto_pr: z.boolean().default(false),
owner_user_id: z.string().cuid(),
})
const adminProductUpdateSchema = adminProductSchema.omit({ owner_user_id: true })
export async function adminCreateProductAction(data: unknown) {
await requireAdmin()
const parsed = adminProductSchema.safeParse(data)
if (!parsed.success) throw new Error(parsed.error.message)
const owner = await prisma.user.findUnique({ where: { id: parsed.data.owner_user_id } })
if (!owner) throw new Error('Eigenaar niet gevonden')
await prisma.product.create({
data: {
user_id: parsed.data.owner_user_id,
name: parsed.data.name,
description: parsed.data.description,
repo_url: parsed.data.repo_url || null,
definition_of_done: parsed.data.definition_of_done,
auto_pr: parsed.data.auto_pr,
},
})
revalidatePath('/admin/products')
}
export async function adminUpdateProductAction(productId: string, data: unknown) {
await requireAdmin()
const parsed = adminProductUpdateSchema.safeParse(data)
if (!parsed.success) throw new Error(parsed.error.message)
await prisma.product.update({
where: { id: productId },
data: {
name: parsed.data.name,
description: parsed.data.description,
repo_url: parsed.data.repo_url || null,
definition_of_done: parsed.data.definition_of_done,
auto_pr: parsed.data.auto_pr,
},
})
revalidatePath('/admin/products')
}
export async function adminArchiveProductAction(productId: string, archived: boolean) {
await requireAdmin()
await prisma.product.update({ where: { id: productId }, data: { archived } })
revalidatePath('/admin/products')
}
export async function adminDeleteProductAction(productId: string) {
await requireAdmin()
await prisma.product.delete({ where: { id: productId } })
revalidatePath('/admin/products')
}
export async function adminAddMemberAction(productId: string, userId: string) {
await requireAdmin()
await prisma.productMember.upsert({
where: { product_id_user_id: { product_id: productId, user_id: userId } },
create: { product_id: productId, user_id: userId },
update: {},
})
revalidatePath(`/admin/products/${productId}`)
}
export async function adminRemoveMemberAction(productId: string, userId: string) {
await requireAdmin()
await prisma.productMember.deleteMany({ where: { product_id: productId, user_id: userId } })
revalidatePath(`/admin/products/${productId}`)
}

View file

@ -1,43 +0,0 @@
'use server'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
import { Role } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import { requireAdmin } from '@/lib/auth-guard'
export async function deleteUserAction(userId: string) {
const session = await requireAdmin()
if (userId === session.userId) {
throw new Error('Zelfverwijdering niet toegestaan')
}
await prisma.user.delete({ where: { id: userId } })
revalidatePath('/admin/users')
}
const rolesSchema = z.array(z.nativeEnum(Role))
export async function updateUserRolesAction(userId: string, roles: Role[]) {
const session = await requireAdmin()
const parsed = rolesSchema.safeParse(roles)
if (!parsed.success) {
throw new Error('Ongeldige rol-waarden')
}
if (userId === session.userId && !parsed.data.includes(Role.ADMIN)) {
throw new Error('Kan eigen ADMIN-rol niet verwijderen')
}
await prisma.$transaction([
prisma.userRole.deleteMany({ where: { user_id: userId } }),
...parsed.data.map((role) => prisma.userRole.create({ data: { user_id: userId, role } })),
])
revalidatePath('/admin/users')
}
export async function setMustResetPasswordAction(userId: string, value: boolean) {
await requireAdmin()
await prisma.user.update({ where: { id: userId }, data: { must_reset_password: value } })
revalidatePath('/admin/users')
}

View file

@ -5,11 +5,10 @@ import { cookies, headers } from 'next/headers'
import { getIronSession } from 'iron-session'
import { z } from 'zod'
import { prisma } from '@/lib/prisma'
import { registerUser, verifyUser, hashPassword } from '@/lib/auth'
import { registerUser, verifyUser } from '@/lib/auth'
import { SessionData, sessionOptions } from '@/lib/session'
import { checkRateLimit } from '@/lib/rate-limit'
import { isPhoneUA } from '@/lib/user-agent'
import { requireSession } from '@/lib/auth-guard'
async function getClientIp(): Promise<string> {
const h = await headers()
@ -76,12 +75,19 @@ export async function loginAction(_prevState: unknown, formData: FormData) {
const adminRole = await prisma.userRole.findFirst({
where: { user_id: user.id, role: 'ADMIN' },
})
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
session.userId = user.id
session.isDemo = user.is_demo
session.isAdmin = !!adminRole
await session.save()
if (user.must_reset_password) {
redirect('/reset-password')
} else if (adminRole) {
redirect('/admin')
}
// PBI-11 / ST-1135: telefoon-UA's krijgen de mobile-shell.
// Tablets en desktop volgen het bestaande /dashboard-pad.
const ua = (await headers()).get('user-agent')
@ -97,39 +103,3 @@ export async function logoutAction() {
session.destroy()
redirect('/login')
}
const resetPasswordSchema = z
.object({
password: z.string().min(8, 'Wachtwoord moet minimaal 8 tekens bevatten'),
confirm: z.string(),
})
.superRefine((data, ctx) => {
if (data.password !== data.confirm) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Wachtwoorden komen niet overeen',
path: ['confirm'],
})
}
})
export async function resetPasswordAction(_prevState: unknown, formData: FormData) {
const session = await requireSession()
const parsed = resetPasswordSchema.safeParse({
password: formData.get('password'),
confirm: formData.get('confirm'),
})
if (!parsed.success) {
return { error: parsed.error.flatten().fieldErrors }
}
const hash = await hashPassword(parsed.data.password)
await prisma.user.update({
where: { id: session.userId },
data: { password_hash: hash, must_reset_password: false },
})
redirect('/dashboard')
}

View file

@ -1,10 +1,11 @@
'use server'
import { revalidatePath } from 'next/cache'
import { type ClaudeJobStatus } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import { getSession } from '@/lib/auth'
import { productAccessFilter } from '@/lib/product-access'
import { ACTIVE_JOB_STATUSES, jobStatusToApi } from '@/lib/job-status'
import { enforceUserRateLimit } from '@/lib/rate-limit'
type EnqueueResult =
| { success: true; jobId: string }
@ -16,9 +17,6 @@ type EnqueueAllResult =
type CancelResult = { success: true } | { error: string }
type RestartResult = { success: true } | { error: string }
const RESTARTABLE_STATUSES: ClaudeJobStatus[] = ['FAILED', 'CANCELLED', 'SKIPPED']
export type PreviewTask = {
id: string
title: string
@ -32,49 +30,273 @@ type PreflightResult =
| { error: string }
| { tasks: PreviewTask[]; blockerIndex: number | null; blockerReason: 'task-review' | 'pbi-blocked' | null }
/**
* @deprecated Vervangen door startSprintRunAction in actions/sprint-runs.ts.
* Per-task starts zijn niet meer toegestaan een sprint draait nu als geheel.
* Wordt verwijderd zodra de UI is omgebouwd (F4).
*/
export async function enqueueClaudeJobAction(_taskId: string): Promise<EnqueueResult> {
return {
error:
'Per-task starten is niet meer mogelijk. Gebruik "Start Sprint" voor de hele actieve sprint.',
export async function enqueueClaudeJobAction(taskId: string): Promise<EnqueueResult> {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
const limited = enforceUserRateLimit('enqueue-job', session.userId)
if (limited) return { error: limited.error }
if (!taskId) return { error: 'task_id is verplicht' }
// Resolve task + product access in one query
const task = await prisma.task.findFirst({
where: {
id: taskId,
story: { product: productAccessFilter(session.userId) },
},
select: { id: true, story: { select: { product_id: true } } },
})
if (!task) return { error: 'Task niet gevonden' }
const productId = task.story.product_id
// Idempotency: weiger als er al een actieve job voor deze task bestaat
const existing = await prisma.claudeJob.findFirst({
where: { task_id: taskId, status: { in: ACTIVE_JOB_STATUSES } },
select: { id: true },
})
if (existing) {
return { error: 'Er loopt al een agent voor deze task', jobId: existing.id }
}
const job = await prisma.claudeJob.create({
data: { user_id: session.userId, product_id: productId, task_id: taskId, status: 'QUEUED' },
})
await prisma.$executeRaw`
SELECT pg_notify('scrum4me_changes', ${JSON.stringify({
type: 'claude_job_enqueued',
job_id: job.id,
task_id: taskId,
user_id: session.userId,
product_id: productId,
status: 'queued',
})}::text)
`
revalidatePath(`/products/${productId}/solo`)
return { success: true, jobId: job.id }
}
/**
* @deprecated Vervangen door startSprintRunAction in actions/sprint-runs.ts.
*/
export async function enqueueAllTodoJobsAction(_productId: string): Promise<EnqueueAllResult> {
return {
error:
'"Alle TO_DO als jobs queueen" is vervangen door "Start Sprint". Gebruik startSprintRunAction.',
export async function enqueueAllTodoJobsAction(productId: string): Promise<EnqueueAllResult> {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
if (!productId) return { error: 'product_id is verplicht' }
const product = await prisma.product.findFirst({
where: { id: productId, ...productAccessFilter(session.userId) },
select: { id: true },
})
if (!product) return { error: 'Geen toegang tot dit product' }
const userId = session.userId
// Match het scope dat de gebruiker op het Solo Paneel ziet:
// alleen TO_DO-taken in de actieve sprint, in stories die aan deze
// gebruiker zijn toegewezen. Anders queue je per ongeluk taken die
// niet in de huidige sprint zitten of aan iemand anders toebehoren.
const sprint = await prisma.sprint.findFirst({
where: { product_id: productId, status: 'ACTIVE' },
select: { id: true },
})
if (!sprint) return { success: true, count: 0 }
const tasks = await prisma.task.findMany({
where: {
status: 'TO_DO',
story: { sprint_id: sprint.id, assignee_id: userId },
claude_jobs: { none: { status: { in: ACTIVE_JOB_STATUSES } } },
},
select: { id: true },
})
if (tasks.length === 0) return { success: true, count: 0 }
const created = await prisma.$transaction(
tasks.map(t =>
prisma.claudeJob.create({
data: { user_id: userId, product_id: productId, task_id: t.id, status: 'QUEUED' },
select: { id: true, task_id: true },
})
)
)
for (const job of created) {
await prisma.$executeRaw`
SELECT pg_notify('scrum4me_changes', ${JSON.stringify({
type: 'claude_job_enqueued',
job_id: job.id,
task_id: job.task_id,
user_id: userId,
product_id: productId,
status: 'queued',
})}::text)
`
}
revalidatePath(`/products/${productId}/solo`)
return { success: true, count: created.length }
}
/**
* @deprecated Vervangen door pre-flight in startSprintRunAction (actions/sprint-runs.ts).
*/
export async function previewEnqueueAllAction(_productId: string): Promise<PreflightResult> {
return {
error:
'Per-product preview is vervangen door de pre-flight check in startSprintRunAction.',
export async function previewEnqueueAllAction(productId: string): Promise<PreflightResult> {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
if (!productId) return { error: 'product_id is verplicht' }
const product = await prisma.product.findFirst({
where: { id: productId, ...productAccessFilter(session.userId) },
select: { id: true },
})
if (!product) return { error: 'Geen toegang tot dit product' }
const userId = session.userId
const sprint = await prisma.sprint.findFirst({
where: { product_id: productId, status: 'ACTIVE' },
select: { id: true },
})
if (!sprint) return { tasks: [], blockerIndex: null, blockerReason: null }
const rawTasks = await prisma.task.findMany({
where: {
story: { sprint_id: sprint.id, assignee_id: userId },
claude_jobs: { none: { status: { in: ACTIVE_JOB_STATUSES } } },
},
select: {
id: true,
title: true,
status: true,
story: {
select: {
id: true,
title: true,
code: true,
pbi: { select: { id: true, status: true, priority: true, sort_order: true } },
},
},
},
orderBy: [
{ story: { pbi: { priority: 'asc' } } },
{ story: { pbi: { sort_order: 'asc' } } },
{ story: { sort_order: 'asc' } },
{ priority: 'asc' },
{ sort_order: 'asc' },
],
})
let blockerIndex: number | null = null
let blockerReason: 'task-review' | 'pbi-blocked' | null = null
for (let i = 0; i < rawTasks.length; i++) {
const t = rawTasks[i]
if (t.status === 'REVIEW') {
blockerIndex = i
blockerReason = 'task-review'
break
}
if (t.story.pbi.status === 'BLOCKED') {
blockerIndex = i
blockerReason = 'pbi-blocked'
break
}
}
const displayTasks = blockerIndex !== null ? rawTasks.slice(0, blockerIndex + 1) : rawTasks
const tasks: PreviewTask[] = displayTasks.map(t => ({
id: t.id,
title: t.title,
status: t.status,
story_title: t.story.title,
pbi_id: t.story.pbi.id,
pbi_status: t.story.pbi.status,
}))
return { tasks, blockerIndex, blockerReason }
}
/**
* @deprecated Vervangen door startSprintRunAction in actions/sprint-runs.ts.
*/
export async function enqueueClaudeJobsBatchAction(
_productId: string,
_taskIds: string[]
productId: string,
taskIds: string[]
): Promise<EnqueueAllResult> {
return {
error:
'Batch-queue per task is vervangen door "Start Sprint". Gebruik startSprintRunAction.',
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
const limited = enforceUserRateLimit('enqueue-job', session.userId)
if (limited) return { error: limited.error }
if (!productId) return { error: 'product_id is verplicht' }
if (!taskIds.length) return { success: true, count: 0 }
const product = await prisma.product.findFirst({
where: { id: productId, ...productAccessFilter(session.userId) },
select: { id: true },
})
if (!product) return { error: 'Geen toegang tot dit product' }
const userId = session.userId
const sprint = await prisma.sprint.findFirst({
where: { product_id: productId, status: 'ACTIVE' },
select: { id: true },
})
if (!sprint) return { error: 'Geen actieve sprint gevonden' }
const authorizedTasks = await prisma.task.findMany({
where: {
id: { in: taskIds },
story: { sprint_id: sprint.id, assignee_id: userId },
},
select: {
id: true,
claude_jobs: {
where: { status: { in: ACTIVE_JOB_STATUSES } },
select: { id: true },
},
},
})
if (authorizedTasks.length !== taskIds.length) {
return { error: 'Een of meer taken zijn niet toegankelijk voor deze gebruiker' }
}
const queueable = authorizedTasks.filter(t => t.claude_jobs.length === 0)
if (queueable.length === 0) return { success: true, count: 0 }
const queueableIds = new Set(queueable.map(t => t.id))
const orderedQueueable = taskIds.filter(id => queueableIds.has(id))
const created = await prisma.$transaction(
orderedQueueable.map(taskId =>
prisma.claudeJob.create({
data: { user_id: userId, product_id: productId, task_id: taskId, status: 'QUEUED' },
select: { id: true, task_id: true },
})
)
)
for (const job of created) {
await prisma.$executeRaw`
SELECT pg_notify('scrum4me_changes', ${JSON.stringify({
type: 'claude_job_enqueued',
job_id: job.id,
task_id: job.task_id,
user_id: userId,
product_id: productId,
status: 'queued',
})}::text)
`
}
revalidatePath(`/products/${productId}/solo`)
return { success: true, count: created.length }
}
export async function cancelClaudeJobAction(jobId: string): Promise<CancelResult> {
@ -113,76 +335,3 @@ export async function cancelClaudeJobAction(jobId: string): Promise<CancelResult
revalidatePath(`/products/${job.product_id}/solo`)
return { success: true }
}
export async function restartClaudeJobAction(jobId: string): Promise<RestartResult> {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
if (!jobId) return { error: 'job_id is verplicht' }
const job = await prisma.claudeJob.findFirst({
where: { id: jobId, user_id: session.userId },
select: { id: true, status: true, kind: true, task_id: true, idea_id: true, sprint_run_id: true, product_id: true },
})
if (!job) return { error: 'Job niet gevonden' }
if (!RESTARTABLE_STATUSES.includes(job.status)) {
return { error: 'Alleen mislukte, geannuleerde of overgeslagen jobs kunnen opnieuw gestart worden' }
}
const updated = await prisma.$transaction(async (tx) => {
const result = await tx.claudeJob.updateMany({
where: { id: jobId, status: { in: RESTARTABLE_STATUSES } },
data: {
status: 'QUEUED',
retry_count: { increment: 1 },
claimed_by_token_id: null,
claimed_at: null,
started_at: null,
finished_at: null,
pushed_at: null,
verify_result: null,
error: null,
summary: null,
branch: null,
head_sha: null,
lease_until: null,
},
})
if (result.count === 0) return 0
if (job.kind === 'SPRINT_IMPLEMENTATION') {
await tx.sprintTaskExecution.updateMany({
where: { sprint_job_id: jobId },
data: {
status: 'PENDING',
verify_result: null,
verify_summary: null,
skip_reason: null,
head_sha: null,
started_at: null,
finished_at: null,
},
})
}
return result.count
})
if (updated === 0) {
return { error: 'Job-status is gewijzigd; herlaad en probeer opnieuw' }
}
await prisma.$executeRaw`
SELECT pg_notify('scrum4me_changes', ${JSON.stringify({
type: 'claude_job_status',
job_id: jobId,
kind: job.kind,
task_id: job.task_id,
idea_id: job.idea_id,
sprint_run_id: job.sprint_run_id,
user_id: session.userId,
product_id: job.product_id,
status: jobStatusToApi('QUEUED'),
})}::text)
`
revalidatePath('/jobs')
return { success: true }
}

View file

@ -10,10 +10,7 @@ import { revalidatePath } from 'next/cache'
import { cookies } from 'next/headers'
import { getIronSession } from 'iron-session'
import { z } from 'zod'
import { prisma } from '@/lib/prisma'
import { getJobConfigSnapshot } from '@/lib/job-config-snapshot'
import { SessionData, sessionOptions } from '@/lib/session'
import { enforceUserRateLimit } from '@/lib/rate-limit'
import { ideaCreateSchema, ideaUpdateSchema } from '@/lib/schemas/idea'
@ -21,7 +18,6 @@ import { canTransition, isGrillMdEditable, isIdeaEditable, isPlanMdEditable } fr
import { nextIdeaCode } from '@/lib/idea-code-server'
import { parsePlanMd } from '@/lib/idea-plan-parser'
import { ACTIVE_JOB_STATUSES } from '@/lib/job-status'
import { parseCodeNumber } from '@/lib/code'
import type { ClaudeJobKind, Idea, IdeaStatus } from '@prisma/client'
@ -169,63 +165,6 @@ export async function deleteIdeaAction(id: string): Promise<ActionResult> {
return { success: true }
}
// ---------------------------------------------------------------------------
// Secondary products
const secondaryProductsSchema = z.object({
ideaId: z.string().cuid(),
productIds: z.array(z.string().cuid()).max(10),
})
export async function updateSecondaryProductsAction(
ideaId: string,
productIds: string[],
): Promise<ActionResult> {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd', code: 401 }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
const parsed = secondaryProductsSchema.safeParse({ ideaId, productIds })
if (!parsed.success) return { error: 'Ongeldige invoer', code: 422 }
const idea = await prisma.idea.findFirst({
where: { id: parsed.data.ideaId, user_id: session.userId },
select: { id: true, product_id: true },
})
if (!idea) return { error: 'Idee niet gevonden', code: 404 }
// Verwijder primair product uit de lijst (mag niet dubbel)
const filtered = parsed.data.productIds.filter((pid) => pid !== idea.product_id)
// Valideer dat alle gevraagde producten toegankelijk zijn voor de user
if (filtered.length > 0) {
const { productAccessFilter } = await import('@/lib/product-access')
const accessible = await prisma.product.findMany({
where: { id: { in: filtered }, ...productAccessFilter(session.userId) },
select: { id: true },
})
if (accessible.length !== filtered.length)
return { error: 'Een of meer producten zijn niet toegankelijk', code: 403 }
}
// Atomisch: verwijder alle bestaande, voeg nieuwe in
await prisma.$transaction([
prisma.ideaProduct.deleteMany({ where: { idea_id: idea.id } }),
...(filtered.length > 0
? [
prisma.ideaProduct.createMany({
data: filtered.map((pid) => ({ idea_id: idea.id, product_id: pid })),
skipDuplicates: true,
}),
]
: []),
])
revalidatePath('/ideas/' + idea.id, 'page')
revalidatePath('/ideas', 'page')
return { success: true }
}
// ---------------------------------------------------------------------------
// Markdown-edits (grill_md & plan_md handmatig fine-tunen)
@ -312,73 +251,6 @@ export async function updatePlanMdAction(
return { success: true }
}
// ---------------------------------------------------------------------------
// Upload — gebruiker plakt/uploadt zelf een plan.md in plaats van de Make-Plan
// AI-flow. Skipt grill als gewenst. Status springt direct naar PLAN_READY.
// Bij parse-failure: NIET opslaan (return 422), zodat een onparseerbaar plan
// nooit in de DB belandt. Geen worker nodig — synchrone parser.
const UPLOAD_PLAN_FROM: IdeaStatus[] = ['DRAFT', 'GRILLED', 'PLAN_FAILED', 'PLAN_READY']
const MAX_PLAN_MD_LENGTH = 100_000
export async function uploadPlanMdAction(
id: string,
markdown: string,
): Promise<ActionResult> {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd', code: 401 }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
const limited = enforceUserRateLimit('upload-idea-plan', session.userId)
if (limited) return limited
if (typeof markdown !== 'string' || markdown.trim().length === 0) {
return { error: 'plan_md is leeg', code: 422 }
}
if (markdown.length > MAX_PLAN_MD_LENGTH) {
return {
error: `plan_md is te groot (${markdown.length} > ${MAX_PLAN_MD_LENGTH} chars)`,
code: 422,
}
}
const idea = await loadOwnedIdea(id, session.userId, ['status'])
if (!idea) return { error: 'Idee niet gevonden', code: 404 }
if (!UPLOAD_PLAN_FROM.includes(idea.status)) {
return {
error: `Upload plan alleen toegestaan vanuit ${UPLOAD_PLAN_FROM.join('/')} (huidige status: ${idea.status})`,
code: 422,
}
}
const parsed = parsePlanMd(markdown)
if (!parsed.ok) {
return {
error: 'plan_md is niet parseerbaar',
code: 422,
details: parsed.errors,
}
}
await prisma.$transaction([
prisma.idea.update({
where: { id },
data: { plan_md: markdown, status: 'PLAN_READY' },
}),
prisma.ideaLog.create({
data: {
idea_id: id,
type: 'NOTE',
content: 'User-uploaded plan_md',
metadata: { length: markdown.length, from_status: idea.status },
},
}),
])
revalidatePath(`/ideas/${id}`)
return { success: true }
}
// ---------------------------------------------------------------------------
// Download — geeft de raw markdown terug; UI bouwt een Blob.
@ -407,9 +279,8 @@ export async function downloadIdeaMdAction(
// ---------------------------------------------------------------------------
// Job-triggers (Grill Me / Make Plan / Cancel)
const GRILL_TRIGGERABLE_FROM: IdeaStatus[] = ['DRAFT', 'GRILLED', 'GRILL_FAILED', 'PLAN_READY', 'PLANNED']
const GRILL_TRIGGERABLE_FROM: IdeaStatus[] = ['DRAFT', 'GRILLED', 'GRILL_FAILED', 'PLAN_READY']
const MAKE_PLAN_TRIGGERABLE_FROM: IdeaStatus[] = ['GRILLED', 'PLAN_FAILED', 'PLAN_READY']
const REVIEW_PLAN_TRIGGERABLE_FROM: IdeaStatus[] = ['PLAN_READY', 'PLAN_REVIEWED']
export async function startGrillJobAction(id: string): Promise<ActionResult<{ job_id: string }>> {
return startIdeaJob(id, 'IDEA_GRILL', 'GRILLING', GRILL_TRIGGERABLE_FROM)
@ -419,10 +290,6 @@ export async function startMakePlanJobAction(id: string): Promise<ActionResult<{
return startIdeaJob(id, 'IDEA_MAKE_PLAN', 'PLANNING', MAKE_PLAN_TRIGGERABLE_FROM)
}
export async function startReviewPlanJobAction(id: string): Promise<ActionResult<{ job_id: string }>> {
return startIdeaJob(id, 'IDEA_REVIEW_PLAN', 'REVIEWING_PLAN', REVIEW_PLAN_TRIGGERABLE_FROM)
}
async function startIdeaJob(
id: string,
kind: ClaudeJobKind,
@ -487,8 +354,6 @@ async function startIdeaJob(
}
}
const ideaSnapshot = await getJobConfigSnapshot({ kind, productId: idea.product_id! })
// Atomic: create job + flip idea-status + log.
const job = await prisma.$transaction(async (tx) => {
const j = await tx.claudeJob.create({
@ -498,7 +363,6 @@ async function startIdeaJob(
idea_id: id,
kind,
status: 'QUEUED',
...ideaSnapshot,
},
select: { id: true },
})
@ -553,15 +417,12 @@ export async function cancelIdeaJobAction(id: string): Promise<ActionResult> {
// Bepaal terugval-status. Bij een lopende grill: terug naar GRILLED als er
// al eerder grill_md was, anders DRAFT. Bij plan-job: PLAN_READY als er al
// plan_md was (re-plan-cancel), anders GRILLED. Bij review-plan: terug naar
// PLAN_READY (review kan altijd opnieuw gestart worden).
// plan_md was (re-plan-cancel), anders GRILLED.
let revertStatus: IdeaStatus
if (job.kind === 'IDEA_GRILL') {
revertStatus = idea.grill_md ? 'GRILLED' : 'DRAFT'
} else if (job.kind === 'IDEA_MAKE_PLAN') {
revertStatus = idea.plan_md ? 'PLAN_READY' : 'GRILLED'
} else if (job.kind === 'IDEA_REVIEW_PLAN') {
revertStatus = 'PLAN_READY'
} else {
return { error: `Job kind ${job.kind} hoort niet bij een idee`, code: 422 }
}
@ -620,7 +481,6 @@ function nextNumber(existing: (string | null)[], re: RegExp): number {
export async function materializeIdeaPlanAction(
id: string,
options?: { allowAlongside?: boolean },
): Promise<ActionResult<{ pbi_id: string; pbi_code: string; story_ids: string[]; task_ids: string[] }>> {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd', code: 401 }
@ -631,7 +491,7 @@ export async function materializeIdeaPlanAction(
const idea = await prisma.idea.findFirst({
where: { id, user_id: session.userId },
select: { id: true, status: true, product_id: true, plan_md: true, pbi_id: true },
select: { id: true, status: true, product_id: true, plan_md: true },
})
if (!idea) return { error: 'Idee niet gevonden', code: 404 }
if (idea.status !== 'PLAN_READY') {
@ -655,36 +515,8 @@ export async function materializeIdeaPlanAction(
const productId = idea.product_id
const plan = parsed.plan
let oldPbiId: string | null = null
if (idea.pbi_id) {
const executedCount = await prisma.task.count({
where: {
story: { pbi_id: idea.pbi_id },
status: { in: ['DONE', 'IN_PROGRESS'] },
},
})
if (executedCount > 0 && !options?.allowAlongside) {
const existingPbi = await prisma.pbi.findUnique({
where: { id: idea.pbi_id },
select: { code: true },
})
return {
error: `PBI_HAS_ACTIVE_TASKS:${existingPbi?.code ?? idea.pbi_id}`,
code: 409,
}
}
if (executedCount === 0) {
oldPbiId = idea.pbi_id
}
// executedCount > 0 && allowAlongside: doorgaan zonder delete
}
try {
const result = await prisma.$transaction(async (tx) => {
if (oldPbiId) {
await tx.pbi.delete({ where: { id: oldPbiId } })
}
// Codes: één keer SELECT max per type binnen de transactie. Bij P2002
// (race met andere materialize) abort de transactie en gooien we 409.
const [existingPbis, existingStories, existingTasks] = await Promise.all([
@ -721,17 +553,16 @@ export async function materializeIdeaPlanAction(
for (let si = 0; si < plan.stories.length; si++) {
const s = plan.stories[si]
const storyCode = `ST-${String(nextStoryN++).padStart(3, '0')}`
const story = await tx.story.create({
data: {
pbi_id: pbi.id,
product_id: productId,
code: storyCode,
code: `ST-${String(nextStoryN++).padStart(3, '0')}`,
title: s.title,
description: s.description ?? null,
acceptance_criteria: s.acceptance_criteria ?? null,
priority: s.priority,
sort_order: parseCodeNumber(storyCode),
sort_order: si + 1, // sequential within PBI
status: 'OPEN',
},
select: { id: true },
@ -740,21 +571,16 @@ export async function materializeIdeaPlanAction(
for (let ti = 0; ti < s.tasks.length; ti++) {
const t = s.tasks[ti]
const taskCode = `T-${nextTaskN++}`
const task = await tx.task.create({
data: {
story_id: story.id,
product_id: productId,
code: taskCode,
code: `T-${nextTaskN++}`,
title: t.title,
description: t.description ?? null,
implementation_plan: t.implementation_plan ?? null,
// Erf priority van de story zodat YAML-volgorde gerespecteerd
// blijft. Worker sorteert op `priority ASC, sort_order ASC`;
// gemixte task-priorities binnen één story zouden anders de
// YAML-volgorde verstoren (zie plan-fix task-volgorde-na-upload).
priority: s.priority,
sort_order: parseCodeNumber(taskCode),
priority: t.priority,
sort_order: ti + 1,
status: 'TO_DO',
verify_required: t.verify_required ?? 'ALIGNED_OR_PARTIAL',
verify_only: t.verify_only ?? false,

View file

@ -1,35 +0,0 @@
'use server'
import { prisma } from '@/lib/prisma'
import { getSession } from '@/lib/auth'
import { JOB_INCLUDE, mapJob, buildPriceMap } from '@/lib/jobs-mapper'
import type { RawJob, JobWithRelations, PriceRow } from '@/lib/jobs-mapper'
export type { JobWithRelations } from '@/lib/jobs-mapper'
export async function fetchJobsPageData(): Promise<{ activeJobs: JobWithRelations[]; doneJobs: JobWithRelations[] } | null> {
const session = await getSession()
if (!session.userId) return null
const [active, done, prices] = await Promise.all([
prisma.claudeJob.findMany({
where: { user_id: session.userId, status: { notIn: ['DONE'] } },
include: JOB_INCLUDE,
orderBy: { created_at: 'desc' },
}),
prisma.claudeJob.findMany({
where: { user_id: session.userId, status: 'DONE' },
include: JOB_INCLUDE,
orderBy: { created_at: 'desc' },
take: 100,
}),
prisma.modelPrice.findMany(),
])
const priceMap = buildPriceMap(prices as unknown as PriceRow[])
return {
activeJobs: active.map((j) => mapJob(j as unknown as RawJob, priceMap)),
doneJobs: done.map((j) => mapJob(j as unknown as RawJob, priceMap)),
}
}

View file

@ -396,27 +396,3 @@ export async function updateAutoPrAction(id: string, auto_pr: boolean) {
revalidatePath(`/products/${id}/settings`)
return { success: true }
}
export async function updatePrStrategyAction(
id: string,
pr_strategy: 'SPRINT' | 'STORY' | 'SPRINT_BATCH',
) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
const parsed = z
.object({ pr_strategy: z.enum(['SPRINT', 'STORY', 'SPRINT_BATCH']) })
.safeParse({ pr_strategy })
if (!parsed.success) return { error: 'Ongeldige waarde voor pr_strategy' }
const product = await prisma.product.findFirst({ where: { id, user_id: session.userId } })
if (!product) return { error: 'Product niet gevonden' }
await prisma.product.update({
where: { id },
data: { pr_strategy: parsed.data.pr_strategy },
})
revalidatePath(`/products/${id}/settings`)
return { success: true }
}

View file

@ -1,52 +0,0 @@
'use server'
import { z } from 'zod'
import { prisma } from '@/lib/prisma'
import { getSession } from '@/lib/auth'
const subscribeSchema = z.object({
endpoint: z.string().url(),
keys: z.object({
p256dh: z.string().min(1),
auth: z.string().min(1),
}),
userAgent: z.string().optional(),
})
export type SubscribeToPushInput = z.infer<typeof subscribeSchema>
export async function subscribeToPushAction(input: SubscribeToPushInput): Promise<void> {
const session = await getSession()
if (!session.userId) return
if (session.isDemo) return
const parsed = subscribeSchema.safeParse(input)
if (!parsed.success) return
const { endpoint, keys, userAgent } = parsed.data
await prisma.pushSubscription.upsert({
where: { endpoint },
create: {
user_id: session.userId,
endpoint,
p256dh: keys.p256dh,
auth: keys.auth,
user_agent: userAgent ?? null,
},
update: {
user_id: session.userId,
p256dh: keys.p256dh,
auth: keys.auth,
last_used_at: new Date(),
},
})
}
export async function unsubscribeFromPushAction(args: { endpoint: string }): Promise<void> {
const session = await getSession()
if (!session.userId) return
await prisma.pushSubscription.deleteMany({
where: { endpoint: args.endpoint, user_id: session.userId },
})
}

View file

@ -1,49 +0,0 @@
'use server'
import { revalidatePath } from 'next/cache'
import { cookies } from 'next/headers'
import { getIronSession } from 'iron-session'
import { prisma } from '@/lib/prisma'
import { SessionData, sessionOptions } from '@/lib/session'
import { minQuotaPctSchema } from '@/lib/schemas/user'
async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions)
}
export async function updateRolesAction(roles: string[]) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
const validRoles = ['PRODUCT_OWNER', 'SCRUM_MASTER', 'DEVELOPER']
const filtered = roles.filter(r => validRoles.includes(r))
if (filtered.length === 0) return { error: 'Minimaal één rol is verplicht' }
await prisma.$transaction([
prisma.userRole.deleteMany({ where: { user_id: session.userId } }),
prisma.userRole.createMany({
data: filtered.map(role => ({ user_id: session.userId, role: role as 'PRODUCT_OWNER' | 'SCRUM_MASTER' | 'DEVELOPER' })),
}),
])
revalidatePath('/settings')
return { success: true }
}
export async function updateMinQuotaPctAction(value: number) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', status: 403 }
const parsed = minQuotaPctSchema.safeParse(value)
if (!parsed.success) return { error: 'Waarde moet tussen 1 en 100 liggen', status: 422 }
await prisma.user.update({
where: { id: session.userId },
data: { min_quota_pct: parsed.data },
})
revalidatePath('/settings')
return { success: true }
}

View file

@ -1,121 +0,0 @@
'use server'
import { revalidatePath } from 'next/cache'
import { cookies } from 'next/headers'
import { getIronSession } from 'iron-session'
import { z } from 'zod'
import type { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import { SessionData, sessionOptions } from '@/lib/session'
import { productAccessFilter } from '@/lib/product-access'
import {
mergeSettings,
parseUserSettings,
type PendingSprintDraft,
type UserSettings,
} from '@/lib/user-settings'
async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions)
}
const StoryOverridesSchema = z.object({
add: z.array(z.string()),
remove: z.array(z.string()),
}).strict()
const DraftSchema = z.object({
goal: z.string().min(1),
startAt: z.string().date().optional(),
endAt: z.string().date().optional(),
pbiIntent: z.record(z.string(), z.enum(['all', 'none'])).default({}),
storyOverrides: z.record(z.string(), StoryOverridesSchema).default({}),
}).strict()
const SetSchema = z.object({
productId: z.string().min(1),
draft: DraftSchema,
})
const ClearSchema = z.object({
productId: z.string().min(1),
})
async function ensureProductAccess(userId: string, productId: string) {
return prisma.product.findFirst({
where: { id: productId, ...productAccessFilter(userId) },
select: { id: true },
})
}
async function readUserSettings(userId: string): Promise<UserSettings> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { settings: true },
})
return parseUserSettings(user?.settings)
}
async function writeUserSettings(userId: string, next: UserSettings) {
await prisma.user.update({
where: { id: userId },
data: { settings: next as unknown as Prisma.InputJsonValue },
})
}
export async function setPendingSprintDraftAction(
productId: string,
draft: PendingSprintDraft,
) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
const parsed = SetSchema.safeParse({ productId, draft })
if (!parsed.success) {
return { error: 'Ongeldige draft', issues: parsed.error.issues }
}
const product = await ensureProductAccess(session.userId, parsed.data.productId)
if (!product) return { error: 'Product niet gevonden of niet toegankelijk' }
const current = await readUserSettings(session.userId)
const patch: Partial<UserSettings> = {
workflow: {
pendingSprintDraft: {
...(current.workflow?.pendingSprintDraft ?? {}),
[parsed.data.productId]: parsed.data.draft,
},
},
}
await writeUserSettings(session.userId, mergeSettings(current, patch))
revalidatePath('/', 'layout')
return { success: true }
}
export async function clearPendingSprintDraftAction(productId: string) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
const parsed = ClearSchema.safeParse({ productId })
if (!parsed.success) return { error: 'Ongeldig product-id' }
const product = await ensureProductAccess(session.userId, parsed.data.productId)
if (!product) return { error: 'Product niet gevonden of niet toegankelijk' }
const current = await readUserSettings(session.userId)
const existingMap = current.workflow?.pendingSprintDraft
if (!existingMap || !(parsed.data.productId in existingMap)) {
return { success: true }
}
const nextMap = { ...existingMap }
delete nextMap[parsed.data.productId]
const next: UserSettings = {
...current,
workflow: { ...current.workflow, pendingSprintDraft: nextMap },
}
await writeUserSettings(session.userId, next)
revalidatePath('/', 'layout')
return { success: true }
}

View file

@ -1,494 +0,0 @@
'use server'
import { revalidatePath } from 'next/cache'
import { cookies } from 'next/headers'
import { getIronSession } from 'iron-session'
import { z } from 'zod'
import { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import { SessionData, sessionOptions } from '@/lib/session'
import { parsePauseContext } from '@/lib/pause-context'
import { getJobConfigSnapshot } from '@/lib/job-config-snapshot'
async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions)
}
export type PreFlightBlockerType =
| 'task_no_plan'
| 'open_question'
| 'pbi_blocked'
| 'task_cross_repo'
export interface PreFlightBlocker {
type: PreFlightBlockerType
id: string
label: string
}
const StartSprintRunInput = z.object({ sprint_id: z.string().min(1) })
const ResumeSprintInput = z.object({ sprint_id: z.string().min(1) })
const CancelSprintRunInput = z.object({ sprint_run_id: z.string().min(1) })
interface StartResultOk {
ok: true
sprint_run_id: string
jobs_count: number
}
interface StartResultBlocked {
ok: false
error: 'PRE_FLIGHT_BLOCKED'
blockers: PreFlightBlocker[]
}
interface ErrorResult {
ok: false
error: string
code: number
}
type StartResult = StartResultOk | StartResultBlocked | ErrorResult
// startSprintRunCore is gedeeld tussen startSprintRunAction en resumeSprintAction.
// Voert de pre-flight uit, maakt een SprintRun + ClaudeJobs (in PBI→Story→Task
// volgorde) binnen één transactie. Aanroeper levert sprint_id, user_id en de
// transactionele Prisma-client.
async function startSprintRunCore(
tx: Prisma.TransactionClient,
sprint_id: string,
user_id: string,
): Promise<StartResultOk | StartResultBlocked | ErrorResult> {
const sprint = await tx.sprint.findUnique({
where: { id: sprint_id },
include: { product: true },
})
if (!sprint) return { ok: false, error: 'SPRINT_NOT_FOUND', code: 404 }
if (sprint.status !== 'OPEN')
return { ok: false, error: 'SPRINT_NOT_ACTIVE', code: 400 }
const activeRun = await tx.sprintRun.findFirst({
where: {
sprint_id,
status: { in: ['QUEUED', 'RUNNING', 'PAUSED'] },
},
})
if (activeRun)
return { ok: false, error: 'SPRINT_RUN_ALREADY_ACTIVE', code: 409 }
const stories = await tx.story.findMany({
where: { sprint_id, status: { not: 'DONE' } },
include: {
pbi: true,
tasks: {
// EXCLUDED-taken worden hier impliciet uitgesloten: de filter is strikt
// TO_DO, dus EXCLUDED/IN_PROGRESS/REVIEW/DONE/FAILED tasks komen niet
// terecht in pre-flight blockers, jobs of SprintTaskExecution-rijen.
where: { status: 'TO_DO' },
orderBy: [{ sort_order: 'asc' }],
},
},
orderBy: [{ sort_order: 'asc' }],
})
const blockers: PreFlightBlocker[] = []
for (const s of stories) {
for (const t of s.tasks) {
if (!t.implementation_plan) {
blockers.push({
type: 'task_no_plan',
id: t.id,
label: `${t.code}: ${t.title}`,
})
}
}
}
const openQuestions = await tx.claudeQuestion.findMany({
where: { story: { sprint_id }, status: 'open' },
select: { id: true, question: true },
})
for (const q of openQuestions) {
blockers.push({
type: 'open_question',
id: q.id,
label: q.question.slice(0, 80),
})
}
const seenPbi = new Set<string>()
for (const s of stories) {
if (seenPbi.has(s.pbi.id)) continue
seenPbi.add(s.pbi.id)
if (s.pbi.status === 'BLOCKED' || s.pbi.status === 'FAILED') {
blockers.push({
type: 'pbi_blocked',
id: s.pbi.id,
label: `${s.pbi.code}: ${s.pbi.title}`,
})
}
}
// PBI-50: SPRINT_BATCH cross-repo blocker. Eén product-worktree =
// alle tasks moeten in product.repo_url werken; task.repo_url-override
// is incompatibel met deze flow.
if (sprint.product.pr_strategy === 'SPRINT_BATCH') {
for (const s of stories) {
for (const t of s.tasks) {
if (t.repo_url && t.repo_url !== sprint.product.repo_url) {
blockers.push({
type: 'task_cross_repo',
id: t.id,
label: `${t.code}: ${t.title}`,
})
}
}
}
}
if (blockers.length > 0) {
return { ok: false, error: 'PRE_FLIGHT_BLOCKED', blockers }
}
const sprintRun = await tx.sprintRun.create({
data: {
sprint_id,
started_by_id: user_id,
status: 'QUEUED',
pr_strategy: sprint.product.pr_strategy,
started_at: new Date(),
},
})
const orderedTasks = stories
.slice()
.sort(
(a, b) =>
a.pbi.priority - b.pbi.priority ||
a.pbi.sort_order - b.pbi.sort_order ||
a.sort_order - b.sort_order,
)
.flatMap((s) => s.tasks)
// PBI-50: SPRINT_BATCH levert één SPRINT_IMPLEMENTATION-job die alle
// tasks in één claude-sessie afhandelt. SprintTaskExecution-rows worden
// server-side bij claim aangemaakt zodat order/base_sha consistent zijn
// met de worktree-state op claim-tijd.
if (sprint.product.pr_strategy === 'SPRINT_BATCH') {
const sprintSnapshot = await getJobConfigSnapshot({
kind: 'SPRINT_IMPLEMENTATION',
productId: sprint.product_id,
})
await tx.claudeJob.create({
data: {
user_id,
product_id: sprint.product_id,
task_id: null,
idea_id: null,
sprint_run_id: sprintRun.id,
kind: 'SPRINT_IMPLEMENTATION',
status: 'QUEUED',
...sprintSnapshot,
},
})
return { ok: true, sprint_run_id: sprintRun.id, jobs_count: 1 }
}
// STORY / SPRINT (per-task): bestaand pad. Snapshot per task zodat
// task.requires_opus de cascade kan overrulen.
for (const t of orderedTasks) {
const taskSnapshot = await getJobConfigSnapshot({
kind: 'TASK_IMPLEMENTATION',
productId: sprint.product_id,
taskId: t.id,
})
await tx.claudeJob.create({
data: {
user_id,
product_id: sprint.product_id,
task_id: t.id,
sprint_run_id: sprintRun.id,
kind: 'TASK_IMPLEMENTATION',
status: 'QUEUED',
...taskSnapshot,
},
})
}
return { ok: true, sprint_run_id: sprintRun.id, jobs_count: orderedTasks.length }
}
export async function startSprintRunAction(input: unknown): Promise<StartResult> {
const session = await getSession()
if (!session.userId) return { ok: false, error: 'Niet ingelogd', code: 403 }
if (session.isDemo)
return { ok: false, error: 'Niet beschikbaar in demo-modus', code: 403 }
const parsed = StartSprintRunInput.safeParse(input)
if (!parsed.success) return { ok: false, error: 'Validatie mislukt', code: 422 }
const userId = session.userId
const result = await prisma.$transaction((tx) =>
startSprintRunCore(tx, parsed.data.sprint_id, userId),
)
if (result.ok) {
revalidatePath(`/sprints/${parsed.data.sprint_id}`)
}
return result
}
export async function resumeSprintAction(input: unknown): Promise<StartResult> {
const session = await getSession()
if (!session.userId) return { ok: false, error: 'Niet ingelogd', code: 403 }
if (session.isDemo)
return { ok: false, error: 'Niet beschikbaar in demo-modus', code: 403 }
const parsed = ResumeSprintInput.safeParse(input)
if (!parsed.success) return { ok: false, error: 'Validatie mislukt', code: 422 }
const userId = session.userId
const sprint_id = parsed.data.sprint_id
const result = await prisma.$transaction(async (tx) => {
const sprint = await tx.sprint.findUnique({ where: { id: sprint_id } })
if (!sprint)
return { ok: false as const, error: 'SPRINT_NOT_FOUND', code: 404 }
if (sprint.status !== 'FAILED')
return { ok: false as const, error: 'SPRINT_NOT_FAILED', code: 400 }
// Sprint terug naar ACTIVE
await tx.sprint.update({
where: { id: sprint_id },
data: { status: 'OPEN', completed_at: null },
})
// FAILED stories binnen sprint terug naar IN_SPRINT (DONE blijft)
await tx.story.updateMany({
where: { sprint_id, status: 'FAILED' },
data: { status: 'IN_SPRINT' },
})
// PBIs van die stories: FAILED → READY (BLOCKED met rust laten)
const storyPbiIds = (
await tx.story.findMany({
where: { sprint_id },
select: { pbi_id: true },
distinct: ['pbi_id'],
})
).map((s) => s.pbi_id)
await tx.pbi.updateMany({
where: { id: { in: storyPbiIds }, status: 'FAILED' },
data: { status: 'READY' },
})
// FAILED tasks → TO_DO (DONE blijft)
await tx.task.updateMany({
where: { story: { sprint_id }, status: 'FAILED' },
data: { status: 'TO_DO' },
})
return startSprintRunCore(tx, sprint_id, userId)
})
if (result.ok) {
revalidatePath(`/sprints/${sprint_id}`)
}
return result
}
const ResumePausedSprintRunInput = z.object({ sprint_run_id: z.string().min(1) })
interface ResumePausedResultOk {
ok: true
}
type ResumePausedResult = ResumePausedResultOk | ErrorResult
export async function resumePausedSprintRunAction(
input: unknown,
): Promise<ResumePausedResult> {
const session = await getSession()
if (!session.userId) return { ok: false, error: 'Niet ingelogd', code: 403 }
if (session.isDemo)
return { ok: false, error: 'Niet beschikbaar in demo-modus', code: 403 }
const parsed = ResumePausedSprintRunInput.safeParse(input)
if (!parsed.success) return { ok: false, error: 'Validatie mislukt', code: 422 }
const sprint_run_id = parsed.data.sprint_run_id
const userId = session.userId
const result = await prisma.$transaction(async (tx) => {
const run = await tx.sprintRun.findUnique({
where: { id: sprint_run_id },
select: {
id: true,
status: true,
sprint_id: true,
pr_strategy: true,
branch: true,
pause_context: true,
},
})
if (!run) return { ok: false as const, error: 'SPRINT_RUN_NOT_FOUND', code: 404 }
if (run.status !== 'PAUSED')
return { ok: false as const, error: 'SPRINT_RUN_NOT_PAUSED', code: 400 }
const ctx = parsePauseContext(run.pause_context)
if (ctx) {
await tx.claudeQuestion.updateMany({
where: { id: ctx.claude_question_id, status: 'open' },
data: { status: 'closed' },
})
}
// PBI-50: SPRINT_BATCH resume-pad — als de SprintRun hangt aan een
// SPRINT_IMPLEMENTATION-job en er nog onafgemaakte SprintTaskExecution-rows
// zijn (PENDING/RUNNING), maak NIEUWE SprintRun met previous_run_id +
// hergebruikte branch + nieuwe SPRINT_IMPLEMENTATION-job. Oude SprintRun
// gaat naar CANCELLED.
const sprintJob = await tx.claudeJob.findFirst({
where: { sprint_run_id, kind: 'SPRINT_IMPLEMENTATION' },
select: { id: true, product_id: true },
})
if (sprintJob) {
const remaining = await tx.sprintTaskExecution.count({
where: {
sprint_job_id: sprintJob.id,
status: { in: ['PENDING', 'RUNNING'] },
},
})
if (remaining > 0) {
const newRun = await tx.sprintRun.create({
data: {
sprint_id: run.sprint_id,
started_by_id: userId,
status: 'QUEUED',
pr_strategy: run.pr_strategy,
branch: run.branch,
previous_run_id: run.id,
started_at: new Date(),
},
})
const resumeSnapshot = await getJobConfigSnapshot({
kind: 'SPRINT_IMPLEMENTATION',
productId: sprintJob.product_id,
})
await tx.claudeJob.create({
data: {
user_id: userId,
product_id: sprintJob.product_id,
task_id: null,
idea_id: null,
sprint_run_id: newRun.id,
kind: 'SPRINT_IMPLEMENTATION',
status: 'QUEUED',
...resumeSnapshot,
},
})
await tx.sprintRun.update({
where: { id: sprint_run_id },
data: {
status: 'CANCELLED',
pause_context: Prisma.JsonNull,
finished_at: new Date(),
},
})
return { ok: true as const, sprint_id: run.sprint_id, finalStatus: 'QUEUED' as const }
}
}
const activeClaims = await tx.claudeJob.count({
where: { sprint_run_id, status: { in: ['CLAIMED', 'RUNNING'] } },
})
const queuedJobs = await tx.claudeJob.count({
where: { sprint_run_id, status: 'QUEUED' },
})
// PBI-49 P0: een STORY auto-merge MERGE_CONFLICT komt NA dat alle tasks
// al DONE zijn. Terug naar QUEUED zou de SprintRun voor altijd laten
// hangen — geen QUEUED job. Bij volledige scope-completion transitie
// direct naar DONE; de dev heeft het conflict opgelost, de PR is van hen.
let nextStatus: 'RUNNING' | 'QUEUED' | 'DONE'
let finishedAt: Date | undefined
if (activeClaims === 0 && queuedJobs === 0) {
nextStatus = 'DONE'
finishedAt = new Date()
} else if (activeClaims > 0) {
nextStatus = 'RUNNING'
} else {
nextStatus = 'QUEUED'
}
await tx.sprintRun.update({
where: { id: sprint_run_id },
data: {
status: nextStatus,
pause_context: Prisma.JsonNull,
...(finishedAt ? { finished_at: finishedAt } : {}),
},
})
return { ok: true as const, sprint_id: run.sprint_id, finalStatus: nextStatus }
})
if (result.ok && 'sprint_id' in result) {
revalidatePath(`/sprints/${result.sprint_id}`)
return { ok: true }
}
return result
}
interface CancelResultOk {
ok: true
}
type CancelResult = CancelResultOk | ErrorResult
export async function cancelSprintRunAction(input: unknown): Promise<CancelResult> {
const session = await getSession()
if (!session.userId) return { ok: false, error: 'Niet ingelogd', code: 403 }
if (session.isDemo)
return { ok: false, error: 'Niet beschikbaar in demo-modus', code: 403 }
const parsed = CancelSprintRunInput.safeParse(input)
if (!parsed.success) return { ok: false, error: 'Validatie mislukt', code: 422 }
const sprint_run_id = parsed.data.sprint_run_id
const result = await prisma.$transaction(async (tx) => {
const run = await tx.sprintRun.findUnique({ where: { id: sprint_run_id } })
if (!run)
return { ok: false as const, error: 'SPRINT_RUN_NOT_FOUND', code: 404 }
if (!['QUEUED', 'RUNNING', 'PAUSED'].includes(run.status))
return { ok: false as const, error: 'SPRINT_RUN_NOT_CANCELLABLE', code: 400 }
await tx.sprintRun.update({
where: { id: sprint_run_id },
data: { status: 'CANCELLED', finished_at: new Date() },
})
// Cancel openstaande task-jobs binnen deze run.
// Tasks/Stories/PBIs/Sprint blijven hun status — cancel ≠ fail.
await tx.claudeJob.updateMany({
where: {
sprint_run_id,
status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] },
},
data: {
status: 'CANCELLED',
finished_at: new Date(),
},
})
return { ok: true as const, sprint_id: run.sprint_id }
})
if (result.ok && 'sprint_id' in result) {
revalidatePath(`/sprints/${result.sprint_id}`)
return { ok: true }
}
return result
}

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