Compare commits
104 commits
claude/cra
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 615ee2327e | |||
| b2e26805b3 | |||
| 4fceea633f | |||
| a3bf6dbd4e | |||
| 8557cd1676 | |||
| 4010e8c296 | |||
| a71ea6f380 | |||
| 0cf238b52a | |||
| 7aba1b79fc | |||
| 268d935563 | |||
| 4b4e52d11e | |||
| 03eb1432ab | |||
| 92775cfcf4 | |||
| 2fc7bec3ad | |||
| e66a4e979c | |||
| bee15014c8 | |||
| 27728296ff | |||
| a6fcfe685a | |||
| 2c6d356acf | |||
| 98526f9f20 | |||
| 972e415fc9 | |||
| ba37c8e8f2 | |||
| d1df1f6cc4 | |||
| 6a0f18c16c | |||
| 9a3a243ddf | |||
| 4e3df2d425 | |||
| 9fc15f279a | |||
| 0b4cb511d6 | |||
| d32b39f62f | |||
| 9cbbabe469 | |||
| 23cce4f35b | |||
| e32ae45eb7 | |||
| a892ff83ca | |||
| a59c35b9ae | |||
| bb4a71eafa | |||
| 3c8699f8e7 | |||
| 628857873e | |||
| 7212192544 | |||
| 7332420914 | |||
| 4539de1fff | |||
| 1d116c44ff | |||
| ca301b5792 | |||
| 5ae78ff872 | |||
| afafbca855 | |||
| 55781e463a | |||
| 667be61334 | |||
| 73cb61d3a2 | |||
| 410cd7c123 | |||
|
|
c9d4122b3a | ||
|
|
22781365e6 | ||
|
|
a3303a605b | ||
|
|
973ff93d0c | ||
|
|
00af559726 | ||
|
|
3d5c22382c | ||
|
|
2a6386163c | ||
|
|
3d52fe4958 | ||
|
|
8287509c7c | ||
|
|
3ad352c10f | ||
|
|
ea28a62973 | ||
|
|
b6bad83319 | ||
|
|
ff22196714 | ||
|
|
b6249a41c0 | ||
|
|
1de872298d | ||
|
|
7bb252c528 | ||
|
|
d84cdf664f | ||
|
|
b8e22539f6 | ||
|
|
76c2efd27c | ||
|
|
551550791e | ||
|
|
91190a5804 | ||
|
|
2b4b5bf719 | ||
|
|
2bef1a4c20 | ||
|
|
0a842e6841 | ||
|
|
b39c3ec2e1 | ||
|
|
d587be2fb3 | ||
|
|
bf7162a5fc | ||
|
|
852945efa3 | ||
|
|
a1e6ec35e5 | ||
|
|
f8693d126b | ||
|
|
a0e5867857 | ||
|
|
1f8cbacb0a | ||
|
|
a9b53dedf0 | ||
|
|
3b5cee823c | ||
|
|
98ee05d458 | ||
|
|
5df04feb11 | ||
| 0d126695db | |||
|
|
d292e445d9 | ||
|
|
ce43f7720a | ||
|
|
6756450131 | ||
|
|
71319e629d | ||
|
|
35e37dac09 | ||
|
|
3c773421da | ||
|
|
c2633695d2 | ||
|
|
00c5045558 | ||
| 10c52e8b8f | |||
|
|
79005dc777 | ||
|
|
8c63ba377d | ||
|
|
f233dd815e | ||
|
|
eaabec8471 | ||
|
|
8a6b2d2cb3 | ||
|
|
a16988b957 | ||
|
|
f7464db837 | ||
|
|
3842c05ae9 | ||
|
|
a4a7ef9b8b | ||
|
|
4a9db57e94 |
440 changed files with 57834 additions and 5113 deletions
|
|
@ -25,6 +25,12 @@ VAPID_SUBJECT="mailto:admin@example.com"
|
|||
# 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).
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -50,6 +50,7 @@ next-env.d.ts
|
|||
|
||||
# Claude Code local settings
|
||||
.claude/settings.local.json
|
||||
.claude/worktrees/
|
||||
|
||||
# Local plan/scratch files (per-developer, not shared)
|
||||
|
||||
|
|
|
|||
|
|
@ -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/plans/v1-readiness.md`.
|
||||
- v1.0 readiness checklist in `docs/old/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/backlog/index.md](./docs/backlog/index.md).
|
||||
Voor de volledige milestone-historie zie [docs/old/backlog/index.md](./docs/old/backlog/index.md).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
72
CLAUDE.md
72
CLAUDE.md
|
|
@ -3,7 +3,7 @@ title: "CLAUDE.md — Scrum4Me"
|
|||
status: active
|
||||
audience: [ai-agent]
|
||||
language: nl
|
||||
last_updated: 2026-05-03
|
||||
last_updated: 2026-05-11
|
||||
---
|
||||
|
||||
# CLAUDE.md — Scrum4Me
|
||||
|
|
@ -19,29 +19,24 @@ 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/plans/<key>-*.md` | Implementatieplan per milestone |
|
||||
| `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 |
|
||||
|
||||
---
|
||||
|
||||
## Hoe werk vinden
|
||||
|
||||
**Track A — MCP (aanbevolen):**
|
||||
1. Branch aanmaken: `git checkout -b feat/<batch-slug>` — nog **geen** `gh pr create`
|
||||
1. Branch aanmaken: `git checkout -b feat/<batch-slug>` — nog **geen** PR aanmaken
|
||||
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 lint && npm test && npm run build`
|
||||
5. Verifieer: `npm run verify && npm run build` — `verify` = lint + typecheck + test
|
||||
6. Commit per laag: `git add -A && git commit` — **geen** `git push` — zie [docs/runbooks/branch-and-commit.md](./docs/runbooks/branch-and-commit.md)
|
||||
7. Herhaal stap 2–6 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
|
||||
8. Queue leeg → `git push -u origin <branch>` → open de Forgejo compare-URL die na push verschijnt en maak daar de PR aan (zie hardstop "Forge" hieronder)
|
||||
|
||||
Volledige MCP-tool documentatie: [docs/runbooks/mcp-integration.md](./docs/runbooks/mcp-integration.md)
|
||||
|
||||
|
|
@ -51,12 +46,16 @@ Volledige MCP-tool documentatie: [docs/runbooks/mcp-integration.md](./docs/runbo
|
|||
|
||||
- **Styling:** nooit `bg-blue-500`; altijd MD3-tokens (`bg-primary`, `bg-status-done`, …)
|
||||
- **UI:** gebruik `@base-ui/react` met `render`-prop, niet Radix `asChild`
|
||||
- **Forge:** **Forgejo** (`git.jp-visser.nl`) is leidend. `git push` alleen naar `origin` (Forgejo). PRs uitsluitend op Forgejo aanmaken via de compare-URL of `tea` CLI — **nooit** `gh pr create`. GitHub-remote is alleen mirror (push erheen alleen op expliciet verzoek). Reviewen/mergen/sluiten van PRs ook via Forgejo (web-UI of API), niet via `gh`
|
||||
- **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
|
||||
- **Deployment:** `npm run lint && npm test && npm run build` vóór elke PR. Selectieve deploy-controle (labels + path-filter): zie [docs/runbooks/deploy-control.md](./docs/runbooks/deploy-control.md)
|
||||
- **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)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -64,12 +63,13 @@ Volledige MCP-tool documentatie: [docs/runbooks/mcp-integration.md](./docs/runbo
|
|||
|
||||
| Laag | Technologie |
|
||||
|---|---|
|
||||
| Framework | Next.js 16 (App Router) + React 19 |
|
||||
| Framework | Next.js 16.2 (App Router) + React 19.2 — PPR/Cache Components beschikbaar |
|
||||
| Taal | TypeScript strict |
|
||||
| Styling | Tailwind CSS + shadcn/ui + MD3 via `app/styles/theme.css` |
|
||||
| Styling | Tailwind CSS v4 + shadcn/ui + MD3 via `app/styles/theme.css` |
|
||||
| State | Zustand + dnd-kit |
|
||||
| DB | Prisma v7 + PostgreSQL (Neon) |
|
||||
| DB | Prisma v7.8 + PostgreSQL (Neon) |
|
||||
| Auth | iron-session + bcryptjs |
|
||||
| Test | Vitest (`__tests__/`, config in `vitest.config.ts`) |
|
||||
| Utilities | Zod, Sonner, Sharp, Vercel Analytics |
|
||||
|
||||
---
|
||||
|
|
@ -82,12 +82,20 @@ 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` |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -100,7 +108,18 @@ SESSION_SECRET="" # min 32 chars
|
|||
CRON_SECRET="" # Bearer-secret /api/cron/*
|
||||
```
|
||||
|
||||
Volledig schema: `lib/env.ts`. Canonieke lijst: `.env.example`.
|
||||
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)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -113,7 +132,24 @@ PBI (niet: Feature/Epic) · Story (niet: Ticket) · Sprint Goal (niet: Objective
|
|||
## Verificatie
|
||||
|
||||
```bash
|
||||
npm run lint && npm test && npm run build
|
||||
npm run verify && npm run build # verify = lint + typecheck + test
|
||||
```
|
||||
|
||||
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.
|
||||
|
|
|
|||
37
README.md
37
README.md
|
|
@ -123,16 +123,12 @@ Vul daarna `DATABASE_URL` en `SESSION_SECRET` in. `DIRECT_URL` is optioneel loka
|
|||
npx prisma db push
|
||||
```
|
||||
|
||||
4. Genereer Prisma Client en de ERD:
|
||||
4. Genereer Prisma Client:
|
||||
|
||||
```bash
|
||||
npm run db:erd
|
||||
npx prisma generate
|
||||
```
|
||||
|
||||
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
|
||||
|
|
@ -166,19 +162,9 @@ 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).
|
||||
|
||||
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`.
|
||||
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 app draait standaard op `http://localhost:3000`.
|
||||
|
||||
|
|
@ -189,7 +175,6 @@ 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
|
||||
|
|
@ -262,13 +247,20 @@ 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` | Volgende story uit de actieve sprint |
|
||||
| `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/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 implementatieplan bijwerken |
|
||||
| `POST` | `/api/todos` | Todo aanmaken binnen een productcontext |
|
||||
| `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`).
|
||||
|
||||
### Security-regels
|
||||
|
||||
|
|
@ -295,5 +287,4 @@ 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)
|
||||
|
|
|
|||
103
__tests__/actions/active-sprint-action.test.ts
Normal file
103
__tests__/actions/active-sprint-action.test.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
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()
|
||||
})
|
||||
})
|
||||
|
|
@ -8,13 +8,24 @@ const {
|
|||
mockGetSession,
|
||||
mockFindFirstJob,
|
||||
mockUpdateJob,
|
||||
mockUpdateManyJob,
|
||||
mockUpdateManySprintTaskExecution,
|
||||
mockTransaction,
|
||||
mockExecuteRaw,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetSession: vi.fn(),
|
||||
mockFindFirstJob: vi.fn(),
|
||||
mockUpdateJob: vi.fn(),
|
||||
mockExecuteRaw: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
} = vi.hoisted(() => {
|
||||
const mockUpdateManyJob = vi.fn()
|
||||
const mockUpdateManySprintTaskExecution = vi.fn()
|
||||
const mockTransaction = vi.fn()
|
||||
return {
|
||||
mockGetSession: vi.fn(),
|
||||
mockFindFirstJob: vi.fn(),
|
||||
mockUpdateJob: vi.fn(),
|
||||
mockUpdateManyJob,
|
||||
mockUpdateManySprintTaskExecution,
|
||||
mockTransaction,
|
||||
mockExecuteRaw: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
||||
vi.mock('@/lib/auth', () => ({ getSession: mockGetSession }))
|
||||
|
|
@ -23,7 +34,12 @@ vi.mock('@/lib/prisma', () => ({
|
|||
claudeJob: {
|
||||
findFirst: mockFindFirstJob,
|
||||
update: mockUpdateJob,
|
||||
updateMany: mockUpdateManyJob,
|
||||
},
|
||||
sprintTaskExecution: {
|
||||
updateMany: mockUpdateManySprintTaskExecution,
|
||||
},
|
||||
$transaction: mockTransaction,
|
||||
$executeRaw: mockExecuteRaw,
|
||||
},
|
||||
}))
|
||||
|
|
@ -32,6 +48,7 @@ import {
|
|||
enqueueClaudeJobAction,
|
||||
enqueueAllTodoJobsAction,
|
||||
cancelClaudeJobAction,
|
||||
restartClaudeJobAction,
|
||||
} from '@/actions/claude-jobs'
|
||||
|
||||
const SESSION_USER = { userId: 'user-1', isDemo: false }
|
||||
|
|
@ -39,6 +56,12 @@ const SESSION_USER = { userId: 'user-1', isDemo: false }
|
|||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockExecuteRaw.mockResolvedValue(undefined)
|
||||
mockTransaction.mockImplementation(async (fn: (tx: unknown) => Promise<unknown>) =>
|
||||
fn({
|
||||
claudeJob: { updateMany: mockUpdateManyJob },
|
||||
sprintTaskExecution: { updateMany: mockUpdateManySprintTaskExecution },
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
describe('enqueueClaudeJobAction (deprecated)', () => {
|
||||
|
|
@ -104,3 +127,115 @@ describe('cancelClaudeJobAction', () => {
|
|||
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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
290
__tests__/actions/commit-sprint-membership.test.ts
Normal file
290
__tests__/actions/commit-sprint-membership.test.ts
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
300
__tests__/actions/create-sprint-with-selection.test.ts
Normal file
300
__tests__/actions/create-sprint-with-selection.test.ts
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -47,6 +47,10 @@ vi.mock('@/lib/prisma', () => ({
|
|||
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),
|
||||
|
|
@ -61,6 +65,7 @@ import {
|
|||
deleteIdeaAction,
|
||||
updateGrillMdAction,
|
||||
updatePlanMdAction,
|
||||
uploadPlanMdAction,
|
||||
downloadIdeaMdAction,
|
||||
startGrillJobAction,
|
||||
startMakePlanJobAction,
|
||||
|
|
@ -247,6 +252,97 @@ 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',
|
||||
|
|
@ -424,7 +520,7 @@ body
|
|||
.mockResolvedValueOnce({ id: 't-B1' })
|
||||
})
|
||||
|
||||
it('happy: creates PBI + 2 stories + 3 tasks, links idea, returns ids', async () => {
|
||||
it('happy: creates PBI + 2 stories + 3 tasks, links idea, returns ids; sort_order = parseCodeNumber(code)', async () => {
|
||||
const r = await materializeIdeaPlanAction('idea-1')
|
||||
expect(r).toMatchObject({
|
||||
success: true,
|
||||
|
|
@ -438,6 +534,15 @@ 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 () => {
|
||||
|
|
|
|||
524
__tests__/actions/product-docs.test.ts
Normal file
524
__tests__/actions/product-docs.test.ts
Normal file
|
|
@ -0,0 +1,524 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const { mockSession } = vi.hoisted(() => ({
|
||||
mockSession: { userId: 'user-1', isDemo: false } as {
|
||||
userId: string | undefined
|
||||
isDemo: boolean
|
||||
},
|
||||
}))
|
||||
|
||||
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/prisma', () => ({
|
||||
prisma: {
|
||||
product: { findFirst: vi.fn(), update: vi.fn() },
|
||||
productDoc: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
productDocLog: { create: vi.fn() },
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { _resetRateLimit } from '@/lib/rate-limit'
|
||||
import {
|
||||
createProductDocAction,
|
||||
deleteProductDocAction,
|
||||
listProductDocsAction,
|
||||
toggleProductDocFolderAction,
|
||||
updateProductDocAction,
|
||||
} from '@/actions/product-docs'
|
||||
|
||||
const VALID_PRODUCT_ID = 'cmohrysyj0000rd17clnjy4tc'
|
||||
const VALID_DOC_MD = `---
|
||||
title: "Deploy stappen"
|
||||
status: draft
|
||||
---
|
||||
|
||||
# Body
|
||||
|
||||
stappen
|
||||
`
|
||||
|
||||
function setSession(s: Partial<typeof mockSession>) {
|
||||
Object.assign(mockSession, s)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setSession({ userId: 'user-1', isDemo: false })
|
||||
_resetRateLimit()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createProductDocAction
|
||||
|
||||
describe('createProductDocAction', () => {
|
||||
const baseInput = {
|
||||
product_id: VALID_PRODUCT_ID,
|
||||
folder: 'runbooks' as const,
|
||||
slug: 'deploy',
|
||||
content_md: VALID_DOC_MD,
|
||||
}
|
||||
|
||||
it('returnt 401 als niet-ingelogd', async () => {
|
||||
setSession({ userId: undefined })
|
||||
const r = await createProductDocAction(baseInput)
|
||||
expect(r).toEqual({ error: 'Niet ingelogd', code: 401 })
|
||||
})
|
||||
|
||||
it('returnt 403 voor demo-user', async () => {
|
||||
setSession({ isDemo: true })
|
||||
const r = await createProductDocAction(baseInput)
|
||||
expect(r).toEqual({ error: 'Niet beschikbaar in demo-modus', code: 403 })
|
||||
})
|
||||
|
||||
it('returnt 422 bij invalide product_id (zod-fail)', async () => {
|
||||
const r = await createProductDocAction({ ...baseInput, product_id: 'not-a-cuid' })
|
||||
expect('code' in r && r.code).toBe(422)
|
||||
})
|
||||
|
||||
it('returnt 422 als content_md geen frontmatter heeft (P2-validation)', async () => {
|
||||
const r = await createProductDocAction({ ...baseInput, content_md: '# alleen body' })
|
||||
expect('code' in r && r.code).toBe(422)
|
||||
expect((prisma.productDoc.create as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returnt 404 als product niet toegankelijk', async () => {
|
||||
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null)
|
||||
const r = await createProductDocAction(baseInput)
|
||||
expect(r).toEqual({ error: 'Product niet gevonden', code: 404 })
|
||||
})
|
||||
|
||||
it('returnt 422 als folder is uitgeschakeld', async () => {
|
||||
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
id: VALID_PRODUCT_ID,
|
||||
user_id: 'user-1',
|
||||
enabled_doc_folders: ['ADR'],
|
||||
})
|
||||
const r = await createProductDocAction(baseInput)
|
||||
expect('code' in r && r.code).toBe(422)
|
||||
expect('error' in r && r.error).toMatch(/staat uit/i)
|
||||
})
|
||||
|
||||
it('schrijft title/status uit frontmatter naar de kolommen (P2-create)', async () => {
|
||||
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
id: VALID_PRODUCT_ID,
|
||||
user_id: 'user-1',
|
||||
enabled_doc_folders: ['RUNBOOKS', 'ADR'],
|
||||
})
|
||||
;(prisma.$transaction as ReturnType<typeof vi.fn>).mockImplementationOnce(
|
||||
async (cb: (tx: typeof prisma) => Promise<unknown>) => {
|
||||
const tx = {
|
||||
productDoc: {
|
||||
create: vi.fn().mockResolvedValue({
|
||||
id: 'doc-1',
|
||||
folder: 'RUNBOOKS',
|
||||
slug: 'deploy',
|
||||
}),
|
||||
},
|
||||
productDocLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
}
|
||||
const result = await cb(tx as unknown as typeof prisma)
|
||||
;(prisma.productDoc.create as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
tx.productDoc.create,
|
||||
)
|
||||
;(prisma.productDocLog.create as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
tx.productDocLog.create,
|
||||
)
|
||||
// Bewaar de tx-mocks zodat de test ze kan inspecteren
|
||||
;(prisma.productDoc.create as ReturnType<typeof vi.fn> & { calls: unknown[] })
|
||||
.calls = tx.productDoc.create.mock.calls
|
||||
return result
|
||||
},
|
||||
)
|
||||
|
||||
const r = await createProductDocAction({
|
||||
...baseInput,
|
||||
content_md: `---\ntitle: "Custom Title"\nstatus: active\n---\n\nbody`,
|
||||
})
|
||||
expect('success' in r && r.success).toBe(true)
|
||||
|
||||
const txCalls = (prisma.productDoc.create as ReturnType<typeof vi.fn> & {
|
||||
calls: unknown[]
|
||||
}).calls
|
||||
expect(txCalls.length).toBeGreaterThan(0)
|
||||
const createArg = (txCalls[0] as [{ data: { title: string; status: string } }])[0]
|
||||
expect(createArg.data.title).toBe('Custom Title')
|
||||
expect(createArg.data.status).toBe('active')
|
||||
})
|
||||
|
||||
it('overschrijft user-supplied last_updated met today (P2-last_updated)', async () => {
|
||||
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
id: VALID_PRODUCT_ID,
|
||||
user_id: 'user-1',
|
||||
enabled_doc_folders: ['RUNBOOKS'],
|
||||
})
|
||||
let capturedCreateArg: { data: { content_md: string } } | null = null
|
||||
;(prisma.$transaction as ReturnType<typeof vi.fn>).mockImplementationOnce(
|
||||
async (cb: (tx: typeof prisma) => Promise<unknown>) => {
|
||||
const tx = {
|
||||
productDoc: {
|
||||
create: vi
|
||||
.fn()
|
||||
.mockImplementation(async (arg: { data: { content_md: string } }) => {
|
||||
capturedCreateArg = arg
|
||||
return { id: 'doc-1', folder: 'RUNBOOKS', slug: 'deploy' }
|
||||
}),
|
||||
},
|
||||
productDocLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
}
|
||||
return cb(tx as unknown as typeof prisma)
|
||||
},
|
||||
)
|
||||
|
||||
const stale = `---\ntitle: "X"\nstatus: draft\nlast_updated: 2020-01-01\n---\n\nbody`
|
||||
const r = await createProductDocAction({ ...baseInput, content_md: stale })
|
||||
expect('success' in r && r.success).toBe(true)
|
||||
|
||||
expect(capturedCreateArg).not.toBeNull()
|
||||
const savedMd = capturedCreateArg!.data.content_md
|
||||
expect(savedMd).not.toMatch(/2020-01-01/)
|
||||
expect(savedMd).toMatch(/last_updated:\s*['"]?\d{4}-\d{2}-\d{2}/)
|
||||
})
|
||||
|
||||
it('returnt 422 bij slug-conflict (P2002)', async () => {
|
||||
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
id: VALID_PRODUCT_ID,
|
||||
user_id: 'user-1',
|
||||
enabled_doc_folders: ['RUNBOOKS'],
|
||||
})
|
||||
;(prisma.$transaction as ReturnType<typeof vi.fn>).mockRejectedValueOnce({
|
||||
code: 'P2002',
|
||||
})
|
||||
|
||||
const r = await createProductDocAction(baseInput)
|
||||
expect('code' in r && r.code).toBe(422)
|
||||
expect('error' in r && r.error).toMatch(/bestaat al/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// updateProductDocAction
|
||||
|
||||
describe('updateProductDocAction', () => {
|
||||
const NEW_MD = `---
|
||||
title: "Updated"
|
||||
status: active
|
||||
---
|
||||
|
||||
new body
|
||||
`
|
||||
|
||||
it('returnt 401 als niet-ingelogd', async () => {
|
||||
setSession({ userId: undefined })
|
||||
const r = await updateProductDocAction('doc-1', NEW_MD)
|
||||
expect(r).toEqual({ error: 'Niet ingelogd', code: 401 })
|
||||
})
|
||||
|
||||
it('returnt 403 voor demo-user', async () => {
|
||||
setSession({ isDemo: true })
|
||||
const r = await updateProductDocAction('doc-1', NEW_MD)
|
||||
expect(r).toEqual({ error: 'Niet beschikbaar in demo-modus', code: 403 })
|
||||
})
|
||||
|
||||
it('returnt 404 als doc niet toegankelijk', async () => {
|
||||
;(prisma.productDoc.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null)
|
||||
const r = await updateProductDocAction('doc-1', NEW_MD)
|
||||
expect(r).toEqual({ error: 'Doc niet gevonden', code: 404 })
|
||||
})
|
||||
|
||||
it('returnt 422 bij broken frontmatter (zonder DB-write)', async () => {
|
||||
;(prisma.productDoc.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
id: 'doc-1',
|
||||
product_id: 'p',
|
||||
folder: 'RUNBOOKS',
|
||||
slug: 'deploy',
|
||||
status: 'draft',
|
||||
})
|
||||
const r = await updateProductDocAction('doc-1', '# alleen body')
|
||||
expect('code' in r && r.code).toBe(422)
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sync titel/status + logt UPDATED met prev/new-status', async () => {
|
||||
;(prisma.productDoc.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
id: 'doc-1',
|
||||
product_id: 'p',
|
||||
folder: 'RUNBOOKS',
|
||||
slug: 'deploy',
|
||||
status: 'draft',
|
||||
})
|
||||
;(prisma.$transaction as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
|
||||
undefined,
|
||||
undefined,
|
||||
])
|
||||
|
||||
const r = await updateProductDocAction('doc-1', NEW_MD)
|
||||
expect('success' in r && r.success).toBe(true)
|
||||
|
||||
expect(prisma.productDoc.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'doc-1' },
|
||||
data: expect.objectContaining({
|
||||
title: 'Updated',
|
||||
status: 'active',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(prisma.productDocLog.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
type: 'UPDATED',
|
||||
metadata: expect.objectContaining({
|
||||
prev_status: 'draft',
|
||||
new_status: 'active',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// deleteProductDocAction — P1-review-fix coverage
|
||||
|
||||
describe('deleteProductDocAction', () => {
|
||||
it('returnt 401 als niet-ingelogd', async () => {
|
||||
setSession({ userId: undefined })
|
||||
const r = await deleteProductDocAction('doc-1')
|
||||
expect(r).toEqual({ error: 'Niet ingelogd', code: 401 })
|
||||
})
|
||||
|
||||
it('returnt 403 voor demo-user', async () => {
|
||||
setSession({ isDemo: true })
|
||||
const r = await deleteProductDocAction('doc-1')
|
||||
expect(r).toEqual({ error: 'Niet beschikbaar in demo-modus', code: 403 })
|
||||
})
|
||||
|
||||
it('returnt 404 als doc niet toegankelijk', async () => {
|
||||
;(prisma.productDoc.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null)
|
||||
const r = await deleteProductDocAction('doc-1')
|
||||
expect(r).toEqual({ error: 'Doc niet gevonden', code: 404 })
|
||||
})
|
||||
|
||||
it('P1: log heeft doc_id:null + metadata met folder/slug/title (geen FK-race)', async () => {
|
||||
;(prisma.productDoc.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
id: 'doc-1',
|
||||
product_id: 'product-1',
|
||||
folder: 'RUNBOOKS',
|
||||
slug: 'deploy',
|
||||
title: 'Deploy stappen',
|
||||
})
|
||||
;(prisma.$transaction as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
|
||||
undefined,
|
||||
undefined,
|
||||
])
|
||||
|
||||
const r = await deleteProductDocAction('doc-1')
|
||||
expect('success' in r && r.success).toBe(true)
|
||||
|
||||
// De $transaction krijgt een array met [log, delete] in die volgorde.
|
||||
const txArg = (prisma.$transaction as ReturnType<typeof vi.fn>).mock.calls[0][0]
|
||||
expect(Array.isArray(txArg)).toBe(true)
|
||||
|
||||
// De productDocLog.create call moet doc_id:null hebben + DELETED + metadata
|
||||
expect(prisma.productDocLog.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
product_id: 'product-1',
|
||||
doc_id: null, // P1-fix
|
||||
type: 'DELETED',
|
||||
metadata: expect.objectContaining({
|
||||
folder: 'runbooks',
|
||||
slug: 'deploy',
|
||||
title: 'Deploy stappen',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
// En de delete moet aangeroepen zijn op het juiste id
|
||||
expect(prisma.productDoc.delete).toHaveBeenCalledWith({ where: { id: 'doc-1' } })
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// toggleProductDocFolderAction — owner-only check
|
||||
|
||||
describe('toggleProductDocFolderAction', () => {
|
||||
const baseInput = {
|
||||
product_id: VALID_PRODUCT_ID,
|
||||
folder: 'api' as const,
|
||||
enabled: false,
|
||||
}
|
||||
|
||||
it('returnt 401 als niet-ingelogd', async () => {
|
||||
setSession({ userId: undefined })
|
||||
const r = await toggleProductDocFolderAction(baseInput)
|
||||
expect(r).toEqual({ error: 'Niet ingelogd', code: 401 })
|
||||
})
|
||||
|
||||
it('returnt 403 voor demo-user', async () => {
|
||||
setSession({ isDemo: true })
|
||||
const r = await toggleProductDocFolderAction(baseInput)
|
||||
expect(r).toEqual({ error: 'Niet beschikbaar in demo-modus', code: 403 })
|
||||
})
|
||||
|
||||
it('returnt 404 als de user geen owner is (ook niet als ProductMember)', async () => {
|
||||
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null)
|
||||
const r = await toggleProductDocFolderAction(baseInput)
|
||||
expect(r).toEqual({ error: 'Product niet gevonden', code: 404 })
|
||||
// Check dat de scope owner-only is: where bevat user_id (geen OR met members)
|
||||
const call = (prisma.product.findFirst as ReturnType<typeof vi.fn>).mock.calls[0][0]
|
||||
expect(call.where.user_id).toBe('user-1')
|
||||
})
|
||||
|
||||
it('idempotent: target-staat == huidige staat → success zonder DB-write', async () => {
|
||||
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
id: VALID_PRODUCT_ID,
|
||||
enabled_doc_folders: ['ADR', 'API'],
|
||||
})
|
||||
|
||||
// enabled:false maar API zit al uit (niet in array) → no-op
|
||||
const r = await toggleProductDocFolderAction({ ...baseInput, folder: 'manual', enabled: false })
|
||||
expect('success' in r && r.success).toBe(true)
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disable folder: update + FOLDER_DISABLED-log', async () => {
|
||||
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
id: VALID_PRODUCT_ID,
|
||||
enabled_doc_folders: ['ADR', 'API'],
|
||||
})
|
||||
;(prisma.$transaction as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
|
||||
undefined,
|
||||
undefined,
|
||||
])
|
||||
|
||||
const r = await toggleProductDocFolderAction({
|
||||
product_id: VALID_PRODUCT_ID,
|
||||
folder: 'api',
|
||||
enabled: false,
|
||||
})
|
||||
expect('success' in r && r.success).toBe(true)
|
||||
|
||||
expect(prisma.product.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: VALID_PRODUCT_ID },
|
||||
data: { enabled_doc_folders: ['ADR'] },
|
||||
}),
|
||||
)
|
||||
expect(prisma.productDocLog.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
type: 'FOLDER_DISABLED',
|
||||
doc_id: null,
|
||||
metadata: { folder: 'api' },
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('enable folder: update + FOLDER_ENABLED-log', async () => {
|
||||
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
id: VALID_PRODUCT_ID,
|
||||
enabled_doc_folders: ['ADR'],
|
||||
})
|
||||
;(prisma.$transaction as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
|
||||
undefined,
|
||||
undefined,
|
||||
])
|
||||
|
||||
const r = await toggleProductDocFolderAction({
|
||||
product_id: VALID_PRODUCT_ID,
|
||||
folder: 'api',
|
||||
enabled: true,
|
||||
})
|
||||
expect('success' in r && r.success).toBe(true)
|
||||
expect(prisma.productDocLog.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ type: 'FOLDER_ENABLED' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// listProductDocsAction — read-only; demo MAG lezen
|
||||
|
||||
describe('listProductDocsAction', () => {
|
||||
it('returnt 401 als niet-ingelogd', async () => {
|
||||
setSession({ userId: undefined })
|
||||
const r = await listProductDocsAction({ product_id: VALID_PRODUCT_ID })
|
||||
expect(r).toEqual({ error: 'Niet ingelogd', code: 401 })
|
||||
})
|
||||
|
||||
it('demo MAG lezen (geen 403)', async () => {
|
||||
setSession({ isDemo: true })
|
||||
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
id: VALID_PRODUCT_ID,
|
||||
user_id: 'other',
|
||||
enabled_doc_folders: [],
|
||||
})
|
||||
;(prisma.productDoc.findMany as ReturnType<typeof vi.fn>).mockResolvedValueOnce([])
|
||||
const r = await listProductDocsAction({ product_id: VALID_PRODUCT_ID })
|
||||
expect('success' in r && r.success).toBe(true)
|
||||
})
|
||||
|
||||
it('returnt 404 als product niet toegankelijk', async () => {
|
||||
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null)
|
||||
const r = await listProductDocsAction({ product_id: VALID_PRODUCT_ID })
|
||||
expect(r).toEqual({ error: 'Product niet gevonden', code: 404 })
|
||||
})
|
||||
|
||||
it('returnt gemapte items zonder content_md', async () => {
|
||||
;(prisma.product.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
id: VALID_PRODUCT_ID,
|
||||
user_id: 'user-1',
|
||||
enabled_doc_folders: ['RUNBOOKS'],
|
||||
})
|
||||
;(prisma.productDoc.findMany as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'd1',
|
||||
folder: 'RUNBOOKS',
|
||||
slug: 'deploy',
|
||||
title: 'Deploy',
|
||||
status: 'active',
|
||||
updated_at: new Date('2026-05-16'),
|
||||
},
|
||||
])
|
||||
const r = await listProductDocsAction({ product_id: VALID_PRODUCT_ID, folder: 'runbooks' })
|
||||
expect('success' in r && r.success).toBe(true)
|
||||
if ('data' in r && r.data) {
|
||||
expect(r.data).toHaveLength(1)
|
||||
expect(r.data[0]).toMatchObject({
|
||||
id: 'd1',
|
||||
folder: 'runbooks', // lowercase mapping
|
||||
slug: 'deploy',
|
||||
title: 'Deploy',
|
||||
status: 'active',
|
||||
})
|
||||
}
|
||||
|
||||
// Check dat folder-filter in de query zit
|
||||
const call = (prisma.productDoc.findMany as ReturnType<typeof vi.fn>).mock.calls[0][0]
|
||||
expect(call.where.folder).toBe('RUNBOOKS')
|
||||
// En dat content_md niet geselecteerd is
|
||||
expect(call.select.content_md).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -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({}) }))
|
||||
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 }),
|
||||
}))
|
||||
|
|
@ -20,6 +20,11 @@ vi.mock('@/lib/prisma', () => ({
|
|||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ settings: {} }),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
$executeRaw: vi.fn().mockResolvedValue(1),
|
||||
},
|
||||
}))
|
||||
|
||||
|
|
|
|||
167
__tests__/actions/sprint-draft.test.ts
Normal file
167
__tests__/actions/sprint-draft.test.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
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' })
|
||||
})
|
||||
})
|
||||
|
|
@ -30,6 +30,7 @@ vi.mock('@/lib/prisma', () => ({
|
|||
},
|
||||
task: {
|
||||
updateMany: vi.fn(),
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
claudeQuestion: {
|
||||
findMany: vi.fn(),
|
||||
|
|
@ -38,6 +39,9 @@ vi.mock('@/lib/prisma', () => ({
|
|||
create: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
product: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
|
|
|||
148
__tests__/actions/update-sprint.test.ts
Normal file
148
__tests__/actions/update-sprint.test.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
82
__tests__/actions/user-settings.test.ts
Normal file
82
__tests__/actions/user-settings.test.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
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()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const { mockGetSession } = vi.hoisted(() => ({ mockGetSession: vi.fn() }))
|
||||
|
||||
vi.mock('@/lib/auth', () => ({ getSession: mockGetSession }))
|
||||
vi.mock('@/lib/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')
|
||||
})
|
||||
})
|
||||
120
__tests__/api/cross-sprint-blocks.test.ts
Normal file
120
__tests__/api/cross-sprint-blocks.test.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 priority then sort_order', async () => {
|
||||
it('queries story ordered by sort_order only', 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: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||
orderBy: [{ sort_order: 'asc' }],
|
||||
})
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,111 +0,0 @@
|
|||
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 } })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -54,7 +54,6 @@ 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'
|
||||
|
||||
|
|
@ -276,56 +275,6 @@ 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', () => {
|
||||
|
|
|
|||
121
__tests__/api/sprint-membership-summary.test.ts
Normal file
121
__tests__/api/sprint-membership-summary.test.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
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 },
|
||||
})
|
||||
})
|
||||
})
|
||||
106
__tests__/app/api/jobs/job-by-id-route.test.ts
Normal file
106
__tests__/app/api/jobs/job-by-id-route.test.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,9 +1,21 @@
|
|||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useSelectionStore } from '@/stores/selection-store'
|
||||
|
||||
vi.mock('@/actions/user-settings', () => ({
|
||||
updateUserSettingsAction: vi.fn().mockResolvedValue({ success: true, settings: {} }),
|
||||
}))
|
||||
|
||||
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
||||
import { BacklogSplitPane } from '@/components/backlog/backlog-split-pane'
|
||||
|
||||
function setSelection(pbiId: string | null, storyId: string | null) {
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.context.activePbiId = pbiId
|
||||
s.context.activeStoryId = storyId
|
||||
})
|
||||
}
|
||||
|
||||
const PANES = [
|
||||
<div key="a">PBI pane</div>,
|
||||
<div key="b">Stories pane</div>,
|
||||
|
|
@ -22,7 +34,7 @@ function renderPane() {
|
|||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useSelectionStore.setState({ selectedPbiId: null, selectedStoryId: null })
|
||||
setSelection(null, null)
|
||||
// Force mobile viewport
|
||||
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 })
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
|
|
@ -37,7 +49,7 @@ describe('BacklogSplitPane auto-switch', () => {
|
|||
|
||||
it('auto-switches to tab 1 when PBI is selected', () => {
|
||||
const { rerender } = renderPane()
|
||||
useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: null })
|
||||
setSelection('pbi-1', null)
|
||||
rerender(
|
||||
<BacklogSplitPane
|
||||
panes={PANES}
|
||||
|
|
@ -52,7 +64,7 @@ describe('BacklogSplitPane auto-switch', () => {
|
|||
|
||||
it('auto-switches to tab 2 when story is selected', () => {
|
||||
const { rerender } = renderPane()
|
||||
useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: 'story-1' })
|
||||
setSelection('pbi-1', 'story-1')
|
||||
rerender(
|
||||
<BacklogSplitPane
|
||||
panes={PANES}
|
||||
|
|
@ -67,11 +79,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)
|
||||
useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: 'story-1' })
|
||||
setSelection('pbi-1', 'story-1')
|
||||
const { rerender } = renderPane()
|
||||
|
||||
// Cascade-reset: new PBI → story clears
|
||||
useSelectionStore.setState({ selectedPbiId: 'pbi-2', selectedStoryId: null })
|
||||
setSelection('pbi-2', null)
|
||||
rerender(
|
||||
<BacklogSplitPane
|
||||
panes={PANES}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { useSelectionStore } from '@/stores/selection-store'
|
||||
import { useBacklogStore } from '@/stores/backlog-store'
|
||||
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
||||
import type {
|
||||
BacklogStory,
|
||||
BacklogTask,
|
||||
} from '@/stores/product-workspace/types'
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn()
|
||||
|
|
@ -22,15 +25,16 @@ 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/tasks', () => ({ reorderTasksAction: vi.fn().mockResolvedValue({ success: true }) }))
|
||||
vi.mock('@/actions/user-settings', () => ({
|
||||
updateUserSettingsAction: vi.fn().mockResolvedValue({ success: true, settings: {} }),
|
||||
}))
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
|
||||
|
||||
// Mock dnd-kit
|
||||
// Mock dnd-kit (still needed for PBI panel which supports drag-and-drop)
|
||||
vi.mock('@dnd-kit/core', () => ({
|
||||
DndContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
PointerSensor: class {},
|
||||
|
|
@ -61,19 +65,40 @@ const PBI_ID = 'pbi-1'
|
|||
const ALT_PBI_ID = 'pbi-2'
|
||||
const STORY_ID = 'story-1'
|
||||
|
||||
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 STORIES: BacklogStory[] = [
|
||||
{ id: STORY_ID, code: 'ST-1', title: 'Eerste story', description: null, acceptance_criteria: null, priority: 2, sort_order: 1, status: 'OPEN', pbi_id: PBI_ID, sprint_id: null, created_at: new Date() },
|
||||
]
|
||||
const TASKS = [
|
||||
{ id: 'task-1', title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_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() },
|
||||
]
|
||||
|
||||
function resetStores() {
|
||||
useSelectionStore.setState({ selectedPbiId: null, selectedStoryId: null })
|
||||
useBacklogStore.setState({
|
||||
pbis: [],
|
||||
storiesByPbi: { [PBI_ID]: STORIES },
|
||||
tasksByStory: { [STORY_ID]: TASKS },
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -89,42 +114,40 @@ describe('Backlog 3-pane integration', () => {
|
|||
})
|
||||
|
||||
it('StoryPanel shows stories when PBI is selected', () => {
|
||||
useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: null })
|
||||
selectPbi(PBI_ID)
|
||||
render(<StoryPanel productId={PRODUCT_ID} isDemo={false} />)
|
||||
expect(screen.getByText('Eerste story')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('clicking a story dispatches selectStory to the store', () => {
|
||||
useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: null })
|
||||
it('clicking a story dispatches setActiveStory to the workspace-store', () => {
|
||||
selectPbi(PBI_ID)
|
||||
render(<StoryPanel productId={PRODUCT_ID} isDemo={false} />)
|
||||
fireEvent.click(screen.getByText('Eerste story'))
|
||||
expect(useSelectionStore.getState().selectedStoryId).toBe(STORY_ID)
|
||||
expect(useProductWorkspaceStore.getState().context.activeStoryId).toBe(STORY_ID)
|
||||
})
|
||||
|
||||
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('cascade-reset: selecting different PBI clears activeStoryId', () => {
|
||||
selectStory(PBI_ID, STORY_ID)
|
||||
useProductWorkspaceStore.getState().setActivePbi(ALT_PBI_ID)
|
||||
expect(useProductWorkspaceStore.getState().context.activeStoryId).toBeNull()
|
||||
})
|
||||
|
||||
it('TaskPanel shows tasks after story is selected', () => {
|
||||
useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: STORY_ID })
|
||||
selectStory(PBI_ID, STORY_ID)
|
||||
render(<TaskPanel productId={PRODUCT_ID} isDemo={false} closePath={`/products/${PRODUCT_ID}`} />)
|
||||
expect(screen.getByText('Eerste taak')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('TaskPanel shows empty state after cascade-reset', () => {
|
||||
useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: STORY_ID })
|
||||
selectStory(PBI_ID, STORY_ID)
|
||||
render(<TaskPanel productId={PRODUCT_ID} isDemo={false} closePath={`/products/${PRODUCT_ID}`} />)
|
||||
// Reset via selectPbi
|
||||
useSelectionStore.getState().selectPbi(ALT_PBI_ID)
|
||||
// Re-render reflects new store state
|
||||
useProductWorkspaceStore.getState().setActivePbi(ALT_PBI_ID)
|
||||
render(<TaskPanel productId={PRODUCT_ID} isDemo={false} closePath={`/products/${PRODUCT_ID}`} />)
|
||||
expect(screen.getAllByText('Selecteer een story om de taken te bekijken.').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('selected story card has isSelected highlight class applied', () => {
|
||||
useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: STORY_ID })
|
||||
selectStory(PBI_ID, STORY_ID)
|
||||
const { container } = render(<StoryPanel productId={PRODUCT_ID} isDemo={false} />)
|
||||
// bg-primary-container is applied when isSelected
|
||||
const selected = container.querySelector('.bg-primary-container')
|
||||
|
|
|
|||
57
__tests__/components/backlog/new-sprint-trigger.test.tsx
Normal file
57
__tests__/components/backlog/new-sprint-trigger.test.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// @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()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,44 +1,40 @@
|
|||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { useSelectionStore } from '@/stores/selection-store'
|
||||
import { useBacklogStore } from '@/stores/backlog-store'
|
||||
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
||||
import type { BacklogTask } from '@/stores/product-workspace/types'
|
||||
|
||||
function resetWorkspace() {
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.context.activeProduct = null
|
||||
s.context.activePbiId = null
|
||||
s.context.activeStoryId = null
|
||||
s.context.activeTaskId = null
|
||||
s.entities.pbisById = {}
|
||||
s.entities.storiesById = {}
|
||||
s.entities.tasksById = {}
|
||||
s.relations.pbiIds = []
|
||||
s.relations.storyIdsByPbi = {}
|
||||
s.relations.taskIdsByStory = {}
|
||||
})
|
||||
}
|
||||
|
||||
function setActiveStoryAndTasks(storyId: string | null, tasks: BacklogTask[] = []) {
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.context.activeStoryId = storyId
|
||||
if (storyId) {
|
||||
s.relations.taskIdsByStory[storyId] = tasks.map((t) => t.id)
|
||||
for (const task of tasks) s.entities.tasksById[task.id] = task
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mockPush }) }))
|
||||
|
||||
// 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'
|
||||
|
|
@ -46,8 +42,8 @@ const STORY_ID = 'story-1'
|
|||
const CLOSE_PATH = `/products/${PRODUCT_ID}`
|
||||
|
||||
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() },
|
||||
{ id: 'task-2', title: 'Tweede taak', description: null, priority: 3, status: 'IN_PROGRESS', sort_order: 2, story_id: STORY_ID, created_at: new Date() },
|
||||
{ id: 'task-1', code: null, title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_ID, created_at: new Date() },
|
||||
{ id: 'task-2', code: null, title: 'Tweede taak', description: null, priority: 3, status: 'IN_PROGRESS', sort_order: 2, story_id: STORY_ID, created_at: new Date() },
|
||||
]
|
||||
|
||||
function renderPanel(isDemo = false) {
|
||||
|
|
@ -57,8 +53,7 @@ function renderPanel(isDemo = false) {
|
|||
describe('TaskPanel', () => {
|
||||
beforeEach(() => {
|
||||
mockPush.mockClear()
|
||||
useSelectionStore.setState({ selectedStoryId: null, selectedPbiId: null })
|
||||
useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: {} })
|
||||
resetWorkspace()
|
||||
})
|
||||
|
||||
it('shows empty state when no story is selected', () => {
|
||||
|
|
@ -67,40 +62,35 @@ describe('TaskPanel', () => {
|
|||
})
|
||||
|
||||
it('shows empty state with action when story selected but no tasks', () => {
|
||||
useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null })
|
||||
useBacklogStore.setState({ tasksByStory: { [STORY_ID]: [] } })
|
||||
setActiveStoryAndTasks(STORY_ID, [])
|
||||
renderPanel()
|
||||
expect(screen.getByText('Nog geen taken voor deze story.')).toBeTruthy()
|
||||
expect(screen.getAllByText('+ Nieuwe taak').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('renders task cards when tasks are present', () => {
|
||||
useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null })
|
||||
useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } })
|
||||
setActiveStoryAndTasks(STORY_ID, TASKS)
|
||||
renderPanel()
|
||||
expect(screen.getByText('Eerste taak')).toBeTruthy()
|
||||
expect(screen.getByText('Tweede taak')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders status badges on task cards', () => {
|
||||
useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null })
|
||||
useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } })
|
||||
setActiveStoryAndTasks(STORY_ID, TASKS)
|
||||
renderPanel()
|
||||
expect(screen.getByText('To Do')).toBeTruthy()
|
||||
expect(screen.getByText('Bezig')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('task cards are rendered inside a grid container', () => {
|
||||
useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null })
|
||||
useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } })
|
||||
setActiveStoryAndTasks(STORY_ID, TASKS)
|
||||
const { container } = renderPanel()
|
||||
const grid = container.querySelector('.grid')
|
||||
expect(grid).toBeTruthy()
|
||||
})
|
||||
|
||||
it('clicking + button calls router.push with newTask params', () => {
|
||||
useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null })
|
||||
useBacklogStore.setState({ tasksByStory: { [STORY_ID]: [] } })
|
||||
setActiveStoryAndTasks(STORY_ID, [])
|
||||
renderPanel()
|
||||
const buttons = screen.getAllByText('+ Nieuwe taak')
|
||||
fireEvent.click(buttons[0])
|
||||
|
|
@ -108,29 +98,18 @@ describe('TaskPanel', () => {
|
|||
})
|
||||
|
||||
it('clicking task card calls router.push with editTask param', () => {
|
||||
useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null })
|
||||
useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } })
|
||||
setActiveStoryAndTasks(STORY_ID, TASKS)
|
||||
renderPanel()
|
||||
fireEvent.click(screen.getByText('Eerste taak'))
|
||||
expect(mockPush).toHaveBeenCalledWith(`${CLOSE_PATH}?editTask=task-1`)
|
||||
})
|
||||
|
||||
it('+ button is disabled in demo mode', () => {
|
||||
useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null })
|
||||
useBacklogStore.setState({ tasksByStory: { [STORY_ID]: [] } })
|
||||
setActiveStoryAndTasks(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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
|
||||
const { pushMock } = vi.hoisted(() => ({ pushMock: vi.fn() }))
|
||||
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: pushMock, refresh: vi.fn() }) }))
|
||||
vi.mock('@/actions/products', () => ({ restoreProductAction: vi.fn() }))
|
||||
vi.mock('@/actions/active-product', () => ({ setActiveProductAction: vi.fn() }))
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||
vi.mock('@/components/dialogs/product-dialog', () => ({
|
||||
ProductDialog: ({ open }: { open: boolean }) => (open ? <div role="dialog">ProductDialog</div> : null),
|
||||
}))
|
||||
|
||||
import { ProductList } from '@/components/dashboard/product-list'
|
||||
|
||||
const PRODUCT = {
|
||||
id: 'p1',
|
||||
name: 'Mijn Product',
|
||||
code: 'MP',
|
||||
description: 'Een product',
|
||||
repo_url: 'https://github.com/foo/bar',
|
||||
definition_of_done: 'klaar als het werkt',
|
||||
auto_pr: false,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
pushMock.mockClear()
|
||||
})
|
||||
|
||||
describe('ProductList — edit-icoon (todo cmoq3ox51)', () => {
|
||||
it('rendert pencil-icoon (Bewerk product) op active card, geen tekstknop "Bewerken"', () => {
|
||||
render(<ProductList products={[PRODUCT]} isDemo={false} activeProductId="p1" />)
|
||||
expect(screen.getByLabelText('Bewerk product')).toBeTruthy()
|
||||
// Oude tekstknop is weg
|
||||
expect(screen.queryByText('Bewerken')).toBeNull()
|
||||
})
|
||||
|
||||
it('opent ProductDialog op klik (en stopt propagation zodat card-click niet navigeert)', () => {
|
||||
render(<ProductList products={[PRODUCT]} isDemo={false} activeProductId="p1" />)
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
fireEvent.click(screen.getByLabelText('Bewerk product'))
|
||||
expect(screen.getByRole('dialog')).toBeTruthy()
|
||||
expect(pushMock).not.toHaveBeenCalled() // card-navigation niet getriggerd
|
||||
})
|
||||
|
||||
it('demo-user: knop is disabled', () => {
|
||||
render(<ProductList products={[PRODUCT]} isDemo={true} activeProductId="p1" />)
|
||||
const btn = screen.getByLabelText('Bewerk product') as HTMLButtonElement
|
||||
expect(btn.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('toont geen edit-icoon bij gearchiveerde producten', () => {
|
||||
render(<ProductList products={[PRODUCT]} isDemo={false} showArchived={true} activeProductId={null} />)
|
||||
expect(screen.queryByLabelText('Bewerk product')).toBeNull()
|
||||
})
|
||||
})
|
||||
72
__tests__/components/dashboard/product-row-actions.test.tsx
Normal file
72
__tests__/components/dashboard/product-row-actions.test.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/actions/products', () => ({
|
||||
archiveProductAction: vi.fn().mockResolvedValue({ success: true }),
|
||||
restoreProductAction: vi.fn().mockResolvedValue({ success: true }),
|
||||
deleteProductAction: vi.fn().mockResolvedValue({ success: true }),
|
||||
}))
|
||||
|
||||
vi.mock('@/actions/active-product', () => ({
|
||||
setActiveProductAction: vi.fn().mockResolvedValue({ success: true }),
|
||||
}))
|
||||
|
||||
import { ProductRowActions } from '@/components/dashboard/product-row-actions'
|
||||
|
||||
const baseProps = {
|
||||
productId: 'p-1',
|
||||
productName: 'Scrum4Me',
|
||||
isActive: false,
|
||||
isArchived: false,
|
||||
isDemo: false,
|
||||
}
|
||||
|
||||
describe('ProductRowActions', () => {
|
||||
it('toont Activeer-knop voor inactief, niet-archived product', () => {
|
||||
render(<ProductRowActions {...baseProps} />)
|
||||
expect(screen.queryByText(/activeer/i)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('toont "Actief"-badge voor active product (geen Activeer-knop)', () => {
|
||||
render(<ProductRowActions {...baseProps} isActive={true} />)
|
||||
expect(screen.queryByText('Actief')).not.toBeNull()
|
||||
expect(screen.queryByText(/^activeer$/i)).toBeNull()
|
||||
})
|
||||
|
||||
it('verbergt Activeer-knop én badge bij archived product', () => {
|
||||
render(<ProductRowActions {...baseProps} isArchived={true} />)
|
||||
expect(screen.queryByText(/^activeer$/i)).toBeNull()
|
||||
expect(screen.queryByText('Actief')).toBeNull()
|
||||
})
|
||||
|
||||
it('rendert Docs-knop met aria-label', () => {
|
||||
render(<ProductRowActions {...baseProps} />)
|
||||
expect(screen.queryByLabelText('Docs')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('rendert Open-backlog-knop met aria-label', () => {
|
||||
render(<ProductRowActions {...baseProps} />)
|
||||
expect(screen.queryByLabelText('Open backlog')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('Meer-acties-knop is disabled in demo-modus', () => {
|
||||
render(<ProductRowActions {...baseProps} isDemo={true} />)
|
||||
const more = screen.getByLabelText('Meer acties') as HTMLButtonElement
|
||||
expect(more.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('Meer-acties-knop is enabled voor reguliere user', () => {
|
||||
render(<ProductRowActions {...baseProps} />)
|
||||
const more = screen.getByLabelText('Meer acties') as HTMLButtonElement
|
||||
expect(more.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
37
__tests__/components/dashboard/products-empty-state.test.tsx
Normal file
37
__tests__/components/dashboard/products-empty-state.test.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/dialogs/product-dialog', () => ({
|
||||
ProductDialog: () => null,
|
||||
}))
|
||||
|
||||
import { ProductsEmptyState } from '@/components/dashboard/products-empty-state'
|
||||
|
||||
describe('ProductsEmptyState', () => {
|
||||
it('toont de empty-tekst', () => {
|
||||
render(<ProductsEmptyState isDemo={false} />)
|
||||
expect(screen.queryByText(/nog geen producten aangemaakt/i)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('toont NewProductButton voor reguliere user', () => {
|
||||
render(<ProductsEmptyState isDemo={false} />)
|
||||
expect(screen.queryByText(/nieuw product/i)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('toont GEEN NewProductButton in demo-modus', () => {
|
||||
render(<ProductsEmptyState isDemo={true} />)
|
||||
expect(screen.queryByText(/nieuw product/i)).toBeNull()
|
||||
})
|
||||
|
||||
it('rendert in een gestylde container', () => {
|
||||
const { container } = render(<ProductsEmptyState isDemo={false} />)
|
||||
const root = container.firstChild as HTMLElement
|
||||
expect(root.className).toContain('rounded-xl')
|
||||
expect(root.className).toContain('border')
|
||||
})
|
||||
})
|
||||
104
__tests__/components/dialogs/answer-modal.test.tsx
Normal file
104
__tests__/components/dialogs/answer-modal.test.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
// @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()
|
||||
})
|
||||
})
|
||||
44
__tests__/components/idea-timeline-merge.test.ts
Normal file
44
__tests__/components/idea-timeline-merge.test.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { mergeTimelineItems } from '@/components/ideas/idea-timeline'
|
||||
|
||||
describe('mergeTimelineItems', () => {
|
||||
it('sorteert reverse-chronologisch: nieuwste entry staat eerst', () => {
|
||||
const logs = [
|
||||
{ id: 'l1', type: 'NOTE', content: 'oud', metadata: null, created_at: '2024-01-01T10:00:00.000Z' },
|
||||
]
|
||||
const questions = [
|
||||
{
|
||||
id: 'q1',
|
||||
question: 'Vraag?',
|
||||
options: null,
|
||||
status: 'open' as const,
|
||||
answer: null,
|
||||
created_at: '2024-01-03T12:00:00.000Z',
|
||||
expires_at: '2024-01-10T12:00:00.000Z',
|
||||
},
|
||||
]
|
||||
const userQuestions = [
|
||||
{
|
||||
id: 'uq1',
|
||||
question: 'Mijn vraag',
|
||||
answer: null,
|
||||
status: 'pending' as const,
|
||||
created_at: '2024-01-02T08:00:00.000Z',
|
||||
},
|
||||
]
|
||||
|
||||
const result = mergeTimelineItems(logs, questions, userQuestions)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0].created_at).toBe('2024-01-03T12:00:00.000Z')
|
||||
expect(result[0].kind).toBe('question')
|
||||
expect(result[1].created_at).toBe('2024-01-02T08:00:00.000Z')
|
||||
expect(result[1].kind).toBe('user_question')
|
||||
expect(result[2].created_at).toBe('2024-01-01T10:00:00.000Z')
|
||||
expect(result[2].kind).toBe('log')
|
||||
})
|
||||
|
||||
it('geeft lege lijst terug bij geen input', () => {
|
||||
expect(mergeTimelineItems([], [], [])).toEqual([])
|
||||
})
|
||||
})
|
||||
277
__tests__/components/ideas/idea-list.test.tsx
Normal file
277
__tests__/components/ideas/idea-list.test.tsx
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
// @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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
85
__tests__/components/jobs/job-card.test.tsx
Normal file
85
__tests__/components/jobs/job-card.test.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
// @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()
|
||||
})
|
||||
})
|
||||
78
__tests__/components/jobs/job-detail-pane.test.tsx
Normal file
78
__tests__/components/jobs/job-detail-pane.test.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
// @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()
|
||||
})
|
||||
})
|
||||
263
__tests__/components/shared/markdown-doc-editor.test.tsx
Normal file
263
__tests__/components/shared/markdown-doc-editor.test.tsx
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { toast } from 'sonner'
|
||||
import { MarkdownDocEditor } from '@/components/shared/markdown-doc-editor'
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('MarkdownDocEditor — rendering + dirty-state', () => {
|
||||
it('rendert textarea met initialValue', () => {
|
||||
render(
|
||||
<MarkdownDocEditor
|
||||
storageKey="test-1"
|
||||
initialValue="hello"
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
|
||||
expect(textarea.value).toBe('hello')
|
||||
})
|
||||
|
||||
it('save-knop is disabled wanneer niet dirty', () => {
|
||||
render(
|
||||
<MarkdownDocEditor
|
||||
storageKey="test-2"
|
||||
initialValue="hello"
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
const saveBtn = screen.getByRole('button', { name: /opslaan/i })
|
||||
expect((saveBtn as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('save-knop is enabled na wijziging', () => {
|
||||
render(
|
||||
<MarkdownDocEditor
|
||||
storageKey="test-3"
|
||||
initialValue="hello"
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
fireEvent.change(textarea, { target: { value: 'hello world' } })
|
||||
const saveBtn = screen.getByRole('button', { name: /opslaan/i })
|
||||
expect((saveBtn as HTMLButtonElement).disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MarkdownDocEditor — localStorage draft', () => {
|
||||
it('persisteert draft naar localStorage on change', () => {
|
||||
render(
|
||||
<MarkdownDocEditor
|
||||
storageKey="test-draft-1"
|
||||
initialValue="hello"
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
fireEvent.change(screen.getByRole('textbox'), {
|
||||
target: { value: 'changed' },
|
||||
})
|
||||
expect(window.localStorage.getItem('test-draft-1')).toBe('changed')
|
||||
})
|
||||
|
||||
it('verwijdert draft als waarde terug op initialValue staat', () => {
|
||||
window.localStorage.setItem('test-draft-2', 'staleDraft')
|
||||
render(
|
||||
<MarkdownDocEditor
|
||||
storageKey="test-draft-2"
|
||||
initialValue="hello"
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
// Restore from draft → toast.info wordt aangeroepen
|
||||
// Reset naar initialValue
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'hello' } })
|
||||
expect(window.localStorage.getItem('test-draft-2')).toBeNull()
|
||||
})
|
||||
|
||||
it('restored draft uit localStorage bij mount + toast.info', () => {
|
||||
window.localStorage.setItem('test-restore', 'restored content')
|
||||
render(
|
||||
<MarkdownDocEditor
|
||||
storageKey="test-restore"
|
||||
initialValue="original"
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
|
||||
expect(textarea.value).toBe('restored content')
|
||||
expect(toast.info).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('MarkdownDocEditor — save flow', () => {
|
||||
it('Cmd+S triggert onSave', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue({ success: true })
|
||||
const onCancel = vi.fn()
|
||||
render(
|
||||
<MarkdownDocEditor
|
||||
storageKey="test-save-1"
|
||||
initialValue="hello"
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
fireEvent.change(textarea, { target: { value: 'changed' } })
|
||||
fireEvent.keyDown(textarea, { key: 's', metaKey: true })
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith('changed'))
|
||||
})
|
||||
|
||||
it('Ctrl+S triggert ook onSave', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue({ success: true })
|
||||
render(
|
||||
<MarkdownDocEditor
|
||||
storageKey="test-save-2"
|
||||
initialValue="hello"
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'changed' } })
|
||||
fireEvent.keyDown(screen.getByRole('textbox'), { key: 's', ctrlKey: true })
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('na success: localStorage clear + onSaved + onCancel + toast.success', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue({ success: true })
|
||||
const onSaved = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
render(
|
||||
<MarkdownDocEditor
|
||||
storageKey="test-save-3"
|
||||
initialValue="hello"
|
||||
onSave={onSave}
|
||||
onSaved={onSaved}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'new' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /opslaan/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSaved).toHaveBeenCalled()
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
expect(window.localStorage.getItem('test-save-3')).toBeNull()
|
||||
expect(toast.success).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('na error: toast.error + submitErrors renderen', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue({
|
||||
error: 'Server-fout',
|
||||
code: 422,
|
||||
details: [{ line: 5, message: 'bad yaml' }],
|
||||
})
|
||||
render(
|
||||
<MarkdownDocEditor
|
||||
storageKey="test-error"
|
||||
initialValue="hello"
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'new' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /opslaan/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('Server-fout')
|
||||
expect(screen.queryByText(/Regel 5/i)).not.toBeNull()
|
||||
expect(screen.queryByText(/bad yaml/i)).not.toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('MarkdownDocEditor — validation', () => {
|
||||
it('valide-errors blokkeren submit (save-knop disabled)', () => {
|
||||
const validate = vi.fn().mockReturnValue([{ message: 'yaml fout' }])
|
||||
render(
|
||||
<MarkdownDocEditor
|
||||
storageKey="test-val-1"
|
||||
initialValue="hello"
|
||||
validate={validate}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'changed' } })
|
||||
const saveBtn = screen.getByRole('button', { name: /opslaan/i })
|
||||
expect((saveBtn as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('valide-errors worden gerendered in error-box', () => {
|
||||
const validate = vi.fn().mockReturnValue([
|
||||
{ line: 3, message: 'yaml fout', hint: 'check de indenting' },
|
||||
])
|
||||
render(
|
||||
<MarkdownDocEditor
|
||||
storageKey="test-val-2"
|
||||
initialValue="hello"
|
||||
validate={validate}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
validationErrorsHeader="YAML-frontmatter fouten"
|
||||
/>,
|
||||
)
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'changed' } })
|
||||
expect(screen.queryByText(/YAML-frontmatter fouten/i)).not.toBeNull()
|
||||
expect(screen.queryByText(/Regel 3/i)).not.toBeNull()
|
||||
expect(screen.queryByText(/yaml fout/i)).not.toBeNull()
|
||||
expect(screen.queryByText(/check de indenting/i)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('validate wordt niet aangeroepen als waarde nog op initialValue staat', () => {
|
||||
const validate = vi.fn().mockReturnValue([])
|
||||
render(
|
||||
<MarkdownDocEditor
|
||||
storageKey="test-val-3"
|
||||
initialValue="hello"
|
||||
validate={validate}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(validate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('MarkdownDocEditor — cancel', () => {
|
||||
it('Annuleer-knop roept onCancel', () => {
|
||||
const onCancel = vi.fn()
|
||||
render(
|
||||
<MarkdownDocEditor
|
||||
storageKey="test-cancel"
|
||||
initialValue="hello"
|
||||
onSave={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: /annuleer/i }))
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
179
__tests__/components/shared/nav-bar.test.tsx
Normal file
179
__tests__/components/shared/nav-bar.test.tsx
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
// @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')
|
||||
})
|
||||
})
|
||||
96
__tests__/components/shared/sort-header.test.tsx
Normal file
96
__tests__/components/shared/sort-header.test.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
|
||||
import { SortHeader } from '@/components/shared/sort-header'
|
||||
|
||||
type Cols = 'name' | 'date'
|
||||
|
||||
describe('SortHeader', () => {
|
||||
it('rendert label en is een button', () => {
|
||||
render(
|
||||
<SortHeader<Cols>
|
||||
col="name"
|
||||
label="Naam"
|
||||
sortKey="date"
|
||||
sortDir="asc"
|
||||
onSort={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByText('Naam')).not.toBeNull()
|
||||
expect(screen.getByRole('button')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('roept onSort aan met juiste col bij klik', () => {
|
||||
const onSort = vi.fn()
|
||||
render(
|
||||
<SortHeader<Cols>
|
||||
col="name"
|
||||
label="Naam"
|
||||
sortKey="date"
|
||||
sortDir="asc"
|
||||
onSort={onSort}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(onSort).toHaveBeenCalledWith('name')
|
||||
})
|
||||
|
||||
it('actieve kolom heeft text-foreground class', () => {
|
||||
render(
|
||||
<SortHeader<Cols>
|
||||
col="name"
|
||||
label="Naam"
|
||||
sortKey="name"
|
||||
sortDir="asc"
|
||||
onSort={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button.className).toContain('text-foreground')
|
||||
expect(button.className).not.toContain('text-muted-foreground')
|
||||
})
|
||||
|
||||
it('niet-actieve kolom heeft text-muted-foreground class', () => {
|
||||
render(
|
||||
<SortHeader<Cols>
|
||||
col="name"
|
||||
label="Naam"
|
||||
sortKey="date"
|
||||
sortDir="asc"
|
||||
onSort={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button.className).toContain('text-muted-foreground')
|
||||
})
|
||||
|
||||
it('accepteert custom className', () => {
|
||||
render(
|
||||
<SortHeader<Cols>
|
||||
col="name"
|
||||
label="Naam"
|
||||
sortKey="date"
|
||||
sortDir="asc"
|
||||
onSort={vi.fn()}
|
||||
className="ml-4 custom-class"
|
||||
/>,
|
||||
)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button.className).toContain('custom-class')
|
||||
expect(button.className).toContain('ml-4')
|
||||
})
|
||||
|
||||
it('rendert een svg-icoon naast het label', () => {
|
||||
const { container } = render(
|
||||
<SortHeader<Cols>
|
||||
col="name"
|
||||
label="Naam"
|
||||
sortKey="name"
|
||||
sortDir="asc"
|
||||
onSort={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(container.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
174
__tests__/components/shared/sprint-switcher.test.tsx
Normal file
174
__tests__/components/shared/sprint-switcher.test.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
// @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()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,28 +1,35 @@
|
|||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { SplitPane } from '@/components/split-pane/split-pane'
|
||||
|
||||
// Helper to set a cookie
|
||||
function setCookie(key: string, value: string) {
|
||||
Object.defineProperty(document, 'cookie', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: `sp:${key}=${encodeURIComponent(value)}`,
|
||||
vi.mock('@/actions/user-settings', () => ({
|
||||
updateUserSettingsAction: vi.fn().mockResolvedValue({ success: true, settings: {} }),
|
||||
}))
|
||||
|
||||
import { SplitPane } from '@/components/split-pane/split-pane'
|
||||
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||
|
||||
function seedPositions(key: string, positions: number[]) {
|
||||
useUserSettingsStore.setState((s) => {
|
||||
s.entities.settings = {
|
||||
layout: {
|
||||
splitPanePositions: { [key]: positions },
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function clearCookies() {
|
||||
Object.defineProperty(document, 'cookie', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: '',
|
||||
function resetStore() {
|
||||
useUserSettingsStore.setState((s) => {
|
||||
s.entities.settings = {}
|
||||
s.context.hydrated = false
|
||||
s.context.isDemo = false
|
||||
})
|
||||
}
|
||||
|
||||
describe('SplitPane', () => {
|
||||
beforeEach(() => {
|
||||
clearCookies()
|
||||
resetStore()
|
||||
// Default: desktop viewport
|
||||
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1440 })
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
|
|
@ -64,9 +71,8 @@ describe('SplitPane', () => {
|
|||
expect(dividers).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('restores splits from cookie on mount', () => {
|
||||
const stored = JSON.stringify([40, 60])
|
||||
setCookie('test-restore', stored)
|
||||
it('restores splits from user-settings store on mount', () => {
|
||||
seedPositions('test-restore', [40, 60])
|
||||
|
||||
const { container } = render(
|
||||
<SplitPane
|
||||
|
|
@ -81,8 +87,9 @@ describe('SplitPane', () => {
|
|||
expect(paneDiv).toBeTruthy()
|
||||
})
|
||||
|
||||
it('falls back to defaultSplit when cookie is invalid', () => {
|
||||
setCookie('test-invalid', 'not-valid-json')
|
||||
it('falls back to defaultSplit when persisted positions are invalid', () => {
|
||||
// Wrong number of values for a 2-pane layout
|
||||
seedPositions('test-invalid', [10, 30, 60])
|
||||
|
||||
const { container } = render(
|
||||
<SplitPane
|
||||
|
|
|
|||
119
__tests__/components/sprint/sprint-task-dialog-mount.test.tsx
Normal file
119
__tests__/components/sprint/sprint-task-dialog-mount.test.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
// @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()
|
||||
})
|
||||
})
|
||||
})
|
||||
147
__tests__/hooks/use-jobs-realtime.test.tsx
Normal file
147
__tests__/hooks/use-jobs-realtime.test.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
// @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)
|
||||
})
|
||||
})
|
||||
190
__tests__/lib/active-sprint.test.ts
Normal file
190
__tests__/lib/active-sprint.test.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
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 })
|
||||
})
|
||||
})
|
||||
25
__tests__/lib/code.test.ts
Normal file
25
__tests__/lib/code.test.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
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)
|
||||
})
|
||||
})
|
||||
23
__tests__/lib/debug.test.ts
Normal file
23
__tests__/lib/debug.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
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')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -62,6 +62,41 @@ 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:
|
||||
|
|
|
|||
|
|
@ -128,4 +128,21 @@ 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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
101
__tests__/lib/job-config.test.ts
Normal file
101
__tests__/lib/job-config.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
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')
|
||||
})
|
||||
})
|
||||
57
__tests__/lib/jobs-time-filter.test.ts
Normal file
57
__tests__/lib/jobs-time-filter.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
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)
|
||||
})
|
||||
})
|
||||
108
__tests__/lib/product-doc-frontmatter.test.ts
Normal file
108
__tests__/lib/product-doc-frontmatter.test.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { parseProductDocMd } from '@/lib/product-doc-parser'
|
||||
import {
|
||||
setProductDocFrontmatterFields,
|
||||
todayIsoDate,
|
||||
} from '@/lib/product-doc-frontmatter'
|
||||
|
||||
const baseMd = `---
|
||||
title: "Deploy"
|
||||
status: draft
|
||||
audience: maintainer
|
||||
last_updated: 2020-01-01
|
||||
---
|
||||
|
||||
# Body
|
||||
|
||||
inhoud
|
||||
`
|
||||
|
||||
describe('setProductDocFrontmatterFields — P2-coverage', () => {
|
||||
it('vervangt bestaand last_updated', () => {
|
||||
const out = setProductDocFrontmatterFields(baseMd, {
|
||||
last_updated: '2026-05-16',
|
||||
})
|
||||
const parsed = parseProductDocMd(out)
|
||||
expect(parsed.ok).toBe(true)
|
||||
if (!parsed.ok) return
|
||||
expect(parsed.frontmatter.last_updated).toBe('2026-05-16')
|
||||
})
|
||||
|
||||
it('voegt last_updated toe als afwezig', () => {
|
||||
const md = `---
|
||||
title: "Deploy"
|
||||
status: draft
|
||||
---
|
||||
|
||||
body
|
||||
`
|
||||
const out = setProductDocFrontmatterFields(md, {
|
||||
last_updated: '2026-05-16',
|
||||
})
|
||||
const parsed = parseProductDocMd(out)
|
||||
expect(parsed.ok).toBe(true)
|
||||
if (!parsed.ok) return
|
||||
expect(parsed.frontmatter.last_updated).toBe('2026-05-16')
|
||||
})
|
||||
|
||||
it('behoudt overige frontmatter-velden', () => {
|
||||
const out = setProductDocFrontmatterFields(baseMd, {
|
||||
last_updated: '2026-05-16',
|
||||
})
|
||||
const parsed = parseProductDocMd(out)
|
||||
expect(parsed.ok).toBe(true)
|
||||
if (!parsed.ok) return
|
||||
expect(parsed.frontmatter.title).toBe('Deploy')
|
||||
expect(parsed.frontmatter.status).toBe('draft')
|
||||
expect(parsed.frontmatter.audience).toBe('maintainer')
|
||||
})
|
||||
|
||||
it('behoudt body-inhoud onveranderd', () => {
|
||||
const out = setProductDocFrontmatterFields(baseMd, {
|
||||
last_updated: '2026-05-16',
|
||||
})
|
||||
expect(out).toContain('# Body')
|
||||
expect(out).toContain('inhoud')
|
||||
})
|
||||
|
||||
it('kan meerdere velden tegelijk patchen', () => {
|
||||
const out = setProductDocFrontmatterFields(baseMd, {
|
||||
last_updated: '2026-05-16',
|
||||
status: 'active',
|
||||
})
|
||||
const parsed = parseProductDocMd(out)
|
||||
expect(parsed.ok).toBe(true)
|
||||
if (!parsed.ok) return
|
||||
expect(parsed.frontmatter.status).toBe('active')
|
||||
expect(parsed.frontmatter.last_updated).toBe('2026-05-16')
|
||||
})
|
||||
|
||||
it('throwed bij ontbrekende frontmatter', () => {
|
||||
expect(() =>
|
||||
setProductDocFrontmatterFields('# alleen body', { last_updated: 'x' }),
|
||||
).toThrow(/yaml-frontmatter/i)
|
||||
})
|
||||
|
||||
it('throwed bij broken yaml', () => {
|
||||
const broken = `---
|
||||
title: "open quote
|
||||
status: draft
|
||||
---
|
||||
|
||||
body`
|
||||
expect(() =>
|
||||
setProductDocFrontmatterFields(broken, { last_updated: 'x' }),
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('todayIsoDate', () => {
|
||||
it('returnt yyyy-mm-dd format', () => {
|
||||
expect(todayIsoDate()).toMatch(/^\d{4}-\d{2}-\d{2}$/)
|
||||
})
|
||||
|
||||
it('respecteert de meegegeven Date', () => {
|
||||
expect(todayIsoDate(new Date('2026-05-16T12:34:56Z'))).toBe('2026-05-16')
|
||||
})
|
||||
})
|
||||
141
__tests__/lib/product-doc-parser.test.ts
Normal file
141
__tests__/lib/product-doc-parser.test.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { parseProductDocMd } from '@/lib/product-doc-parser'
|
||||
|
||||
const minimalValid = `---
|
||||
title: "Deploy stappen"
|
||||
status: draft
|
||||
---
|
||||
|
||||
# Body
|
||||
|
||||
stappen hier
|
||||
`
|
||||
|
||||
describe('parseProductDocMd — succes', () => {
|
||||
it('parseert minimaal valide doc', () => {
|
||||
const r = parseProductDocMd(minimalValid)
|
||||
expect(r.ok).toBe(true)
|
||||
if (!r.ok) return
|
||||
expect(r.frontmatter.title).toBe('Deploy stappen')
|
||||
expect(r.frontmatter.status).toBe('draft')
|
||||
expect(r.body.startsWith('# Body')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepteert optionele velden (audience, applies_to, last_updated)', () => {
|
||||
const md = `---
|
||||
title: "Doc"
|
||||
status: active
|
||||
audience: [maintainer, contributor]
|
||||
applies_to: PBI-96
|
||||
last_updated: 2026-05-16
|
||||
---
|
||||
|
||||
body
|
||||
`
|
||||
const r = parseProductDocMd(md)
|
||||
expect(r.ok).toBe(true)
|
||||
if (!r.ok) return
|
||||
expect(r.frontmatter.audience).toEqual(['maintainer', 'contributor'])
|
||||
expect(r.frontmatter.applies_to).toBe('PBI-96')
|
||||
expect(r.frontmatter.last_updated).toBe('2026-05-16')
|
||||
})
|
||||
|
||||
it('accepteert audience als single string', () => {
|
||||
const md = `---
|
||||
title: "Doc"
|
||||
status: draft
|
||||
audience: maintainer
|
||||
---
|
||||
|
||||
body`
|
||||
const r = parseProductDocMd(md)
|
||||
expect(r.ok).toBe(true)
|
||||
if (!r.ok) return
|
||||
expect(r.frontmatter.audience).toBe('maintainer')
|
||||
})
|
||||
|
||||
it('trimt leading whitespace van body', () => {
|
||||
const md = `---
|
||||
title: "x"
|
||||
status: draft
|
||||
---
|
||||
|
||||
|
||||
body
|
||||
`
|
||||
const r = parseProductDocMd(md)
|
||||
expect(r.ok).toBe(true)
|
||||
if (!r.ok) return
|
||||
expect(r.body.startsWith('body')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseProductDocMd — fouten', () => {
|
||||
it('weigert doc zonder frontmatter (regel 1 error)', () => {
|
||||
const r = parseProductDocMd('# alleen body')
|
||||
expect(r.ok).toBe(false)
|
||||
if (r.ok) return
|
||||
expect(r.errors[0].line).toBe(1)
|
||||
expect(r.errors[0].message).toMatch(/yaml-frontmatter/i)
|
||||
})
|
||||
|
||||
it('weigert doc zonder afsluitende `---`', () => {
|
||||
const md = `---
|
||||
title: "x"
|
||||
status: draft
|
||||
|
||||
body
|
||||
`
|
||||
const r = parseProductDocMd(md)
|
||||
expect(r.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('weigert frontmatter zonder title', () => {
|
||||
const md = `---
|
||||
status: draft
|
||||
---
|
||||
|
||||
body`
|
||||
const r = parseProductDocMd(md)
|
||||
expect(r.ok).toBe(false)
|
||||
if (r.ok) return
|
||||
expect(r.errors.some((e) => e.message.includes('title'))).toBe(true)
|
||||
})
|
||||
|
||||
it('weigert frontmatter zonder status', () => {
|
||||
const md = `---
|
||||
title: "x"
|
||||
---
|
||||
|
||||
body`
|
||||
const r = parseProductDocMd(md)
|
||||
expect(r.ok).toBe(false)
|
||||
if (r.ok) return
|
||||
expect(r.errors.some((e) => e.message.includes('status'))).toBe(true)
|
||||
})
|
||||
|
||||
it('weigert status buiten enum-set', () => {
|
||||
const md = `---
|
||||
title: "x"
|
||||
status: wip
|
||||
---
|
||||
|
||||
body`
|
||||
const r = parseProductDocMd(md)
|
||||
expect(r.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('geeft line-info bij bad yaml', () => {
|
||||
const md = `---
|
||||
title: "x
|
||||
status: draft
|
||||
---
|
||||
|
||||
body`
|
||||
const r = parseProductDocMd(md)
|
||||
expect(r.ok).toBe(false)
|
||||
if (r.ok) return
|
||||
expect(r.errors[0].line).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
111
__tests__/lib/product-doc-slug.test.ts
Normal file
111
__tests__/lib/product-doc-slug.test.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import {
|
||||
nextAdrPrefix,
|
||||
parseAdrNumber,
|
||||
slugify,
|
||||
suggestAdrSlug,
|
||||
suggestSlug,
|
||||
} from '@/lib/product-doc-slug'
|
||||
|
||||
describe('slugify', () => {
|
||||
it('maakt simpele titels lowercase met koppeltekens', () => {
|
||||
expect(slugify('Deploy stappen')).toBe('deploy-stappen')
|
||||
expect(slugify('Hello, World!')).toBe('hello-world')
|
||||
})
|
||||
|
||||
it('stript diakritieken', () => {
|
||||
expect(slugify('Café écrasé')).toBe('cafe-ecrase')
|
||||
expect(slugify('Ångström')).toBe('angstrom')
|
||||
})
|
||||
|
||||
it('verwijdert leading/trailing dashes', () => {
|
||||
expect(slugify(' --- hello --- ')).toBe('hello')
|
||||
})
|
||||
|
||||
it('capt lengte op 80 tekens', () => {
|
||||
const long = 'a'.repeat(100)
|
||||
expect(slugify(long).length).toBe(80)
|
||||
})
|
||||
|
||||
it('geeft lege string voor lege/whitespace-only input', () => {
|
||||
expect(slugify('')).toBe('')
|
||||
expect(slugify(' ')).toBe('')
|
||||
expect(slugify('!@#$%')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('suggestSlug', () => {
|
||||
it('returnt base-slug zonder collision', () => {
|
||||
expect(suggestSlug('Deploy', [])).toBe('deploy')
|
||||
})
|
||||
|
||||
it('voegt -2 suffix toe bij eerste collision', () => {
|
||||
expect(suggestSlug('Deploy', ['deploy'])).toBe('deploy-2')
|
||||
})
|
||||
|
||||
it('telt door bij meerdere collisions', () => {
|
||||
expect(suggestSlug('Deploy', ['deploy', 'deploy-2', 'deploy-3'])).toBe('deploy-4')
|
||||
})
|
||||
|
||||
it('geeft lege string voor lege titel', () => {
|
||||
expect(suggestSlug('', ['x'])).toBe('')
|
||||
})
|
||||
|
||||
it('respecteert max-len bij toevoegen suffix', () => {
|
||||
const long80 = 'a'.repeat(80)
|
||||
const result = suggestSlug(long80, [long80])
|
||||
expect(result.length).toBeLessThanOrEqual(80)
|
||||
expect(result.endsWith('-2')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nextAdrPrefix', () => {
|
||||
it('geeft 0001 als er nog geen ADRs zijn', () => {
|
||||
expect(nextAdrPrefix(null)).toBe('0001')
|
||||
})
|
||||
|
||||
it('telt door op currentMax', () => {
|
||||
expect(nextAdrPrefix(0)).toBe('0001')
|
||||
expect(nextAdrPrefix(41)).toBe('0042')
|
||||
expect(nextAdrPrefix(999)).toBe('1000')
|
||||
})
|
||||
|
||||
it('pad altijd tot minimaal 4 cijfers', () => {
|
||||
expect(nextAdrPrefix(null)).toMatch(/^\d{4}$/)
|
||||
expect(nextAdrPrefix(8)).toBe('0009')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseAdrNumber', () => {
|
||||
it('parseert geldig NNNN-prefix', () => {
|
||||
expect(parseAdrNumber('0001-context')).toBe(1)
|
||||
expect(parseAdrNumber('0042-some-slug')).toBe(42)
|
||||
})
|
||||
|
||||
it('returns null voor slugs zonder geldig prefix', () => {
|
||||
expect(parseAdrNumber('context')).toBeNull()
|
||||
expect(parseAdrNumber('abc-context')).toBeNull()
|
||||
expect(parseAdrNumber('1-context')).toBeNull()
|
||||
expect(parseAdrNumber('12345-context')).toBeNull() // 5 cijfers
|
||||
})
|
||||
})
|
||||
|
||||
describe('suggestAdrSlug', () => {
|
||||
it('bouwt NNNN-{slug} format', () => {
|
||||
expect(suggestAdrSlug('Use base-ui not Radix', null)).toBe('0001-use-base-ui-not-radix')
|
||||
expect(suggestAdrSlug('Use base-ui not Radix', 41)).toBe('0042-use-base-ui-not-radix')
|
||||
})
|
||||
|
||||
it('geeft alleen prefix bij lege titel', () => {
|
||||
expect(suggestAdrSlug('', null)).toBe('0001')
|
||||
expect(suggestAdrSlug(' ', 5)).toBe('0006')
|
||||
})
|
||||
|
||||
it('respecteert max-len van 80 tekens', () => {
|
||||
const longTitle = 'x'.repeat(100)
|
||||
const slug = suggestAdrSlug(longTitle, null)
|
||||
expect(slug.length).toBeLessThanOrEqual(80)
|
||||
expect(slug.startsWith('0001-')).toBe(true)
|
||||
})
|
||||
})
|
||||
56
__tests__/lib/product-switch-path.test.ts
Normal file
56
__tests__/lib/product-switch-path.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
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',
|
||||
)
|
||||
})
|
||||
})
|
||||
160
__tests__/lib/schemas/product-doc.test.ts
Normal file
160
__tests__/lib/schemas/product-doc.test.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import {
|
||||
PRODUCT_DOC_FOLDERS,
|
||||
PRODUCT_DOC_STATUSES,
|
||||
productDocCreateSchema,
|
||||
productDocFolderToggleSchema,
|
||||
productDocFrontmatterSchema,
|
||||
productDocSlugSchema,
|
||||
productDocUpdateSchema,
|
||||
} from '@/lib/schemas/product-doc'
|
||||
|
||||
const validProductId = 'cmohrysyj0000rd17clnjy4tc'
|
||||
|
||||
describe('productDocSlugSchema', () => {
|
||||
it('accepteert geldige slugs', () => {
|
||||
expect(productDocSlugSchema.safeParse('deploy').success).toBe(true)
|
||||
expect(productDocSlugSchema.safeParse('0001-context-decision').success).toBe(true)
|
||||
expect(productDocSlugSchema.safeParse('a').success).toBe(true)
|
||||
})
|
||||
|
||||
it('weigert hoofdletters, spaties en speciale tekens', () => {
|
||||
expect(productDocSlugSchema.safeParse('Deploy').success).toBe(false)
|
||||
expect(productDocSlugSchema.safeParse('deploy stappen').success).toBe(false)
|
||||
expect(productDocSlugSchema.safeParse('deploy/stappen').success).toBe(false)
|
||||
})
|
||||
|
||||
it('weigert slug die met streepje begint', () => {
|
||||
expect(productDocSlugSchema.safeParse('-deploy').success).toBe(false)
|
||||
})
|
||||
|
||||
it('weigert slug > 80 tekens', () => {
|
||||
expect(productDocSlugSchema.safeParse('a'.repeat(81)).success).toBe(false)
|
||||
expect(productDocSlugSchema.safeParse('a'.repeat(80)).success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('productDocFrontmatterSchema', () => {
|
||||
it('accepteert minimaal valide frontmatter', () => {
|
||||
const r = productDocFrontmatterSchema.safeParse({ title: 'Deploy', status: 'draft' })
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
|
||||
it('weigert ontbrekende title of status', () => {
|
||||
expect(
|
||||
productDocFrontmatterSchema.safeParse({ status: 'draft' }).success,
|
||||
).toBe(false)
|
||||
expect(
|
||||
productDocFrontmatterSchema.safeParse({ title: 'Deploy' }).success,
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('weigert status die niet in de enum zit', () => {
|
||||
expect(
|
||||
productDocFrontmatterSchema.safeParse({ title: 'D', status: 'wip' }).success,
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('accepteert audience als string of array', () => {
|
||||
expect(
|
||||
productDocFrontmatterSchema.safeParse({
|
||||
title: 'D',
|
||||
status: 'draft',
|
||||
audience: 'maintainer',
|
||||
}).success,
|
||||
).toBe(true)
|
||||
expect(
|
||||
productDocFrontmatterSchema.safeParse({
|
||||
title: 'D',
|
||||
status: 'draft',
|
||||
audience: ['maintainer', 'contributor'],
|
||||
}).success,
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('weigert oversized title', () => {
|
||||
expect(
|
||||
productDocFrontmatterSchema.safeParse({
|
||||
title: 'x'.repeat(201),
|
||||
status: 'draft',
|
||||
}).success,
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('productDocCreateSchema', () => {
|
||||
const base = {
|
||||
product_id: validProductId,
|
||||
folder: 'runbooks' as const,
|
||||
slug: 'deploy',
|
||||
content_md: '---\ntitle: "Deploy"\nstatus: draft\n---\n\nbody',
|
||||
}
|
||||
|
||||
it('accepteert geldige input', () => {
|
||||
expect(productDocCreateSchema.safeParse(base).success).toBe(true)
|
||||
})
|
||||
|
||||
it('weigert ongeldige folder', () => {
|
||||
expect(
|
||||
productDocCreateSchema.safeParse({ ...base, folder: 'wiki' }).success,
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('weigert ongeldige product_id (geen cuid)', () => {
|
||||
expect(
|
||||
productDocCreateSchema.safeParse({ ...base, product_id: 'not-a-cuid' }).success,
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('weigert leeg of te lang content_md', () => {
|
||||
expect(productDocCreateSchema.safeParse({ ...base, content_md: '' }).success).toBe(false)
|
||||
expect(
|
||||
productDocCreateSchema.safeParse({ ...base, content_md: 'x'.repeat(100_001) }).success,
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('productDocUpdateSchema', () => {
|
||||
it('accepteert valide content_md', () => {
|
||||
expect(
|
||||
productDocUpdateSchema.safeParse({ content_md: '---\ntitle: "x"\nstatus: draft\n---\n\nbody' })
|
||||
.success,
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('weigert leeg content_md', () => {
|
||||
expect(productDocUpdateSchema.safeParse({ content_md: '' }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('productDocFolderToggleSchema', () => {
|
||||
it('accepteert valide toggle-input', () => {
|
||||
expect(
|
||||
productDocFolderToggleSchema.safeParse({
|
||||
product_id: validProductId,
|
||||
folder: 'api',
|
||||
enabled: false,
|
||||
}).success,
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('weigert ontbrekende enabled-vlag', () => {
|
||||
expect(
|
||||
productDocFolderToggleSchema.safeParse({
|
||||
product_id: validProductId,
|
||||
folder: 'api',
|
||||
}).success,
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PRODUCT_DOC_FOLDERS + STATUSES', () => {
|
||||
it('bevat exact 8 folders', () => {
|
||||
expect(PRODUCT_DOC_FOLDERS).toHaveLength(8)
|
||||
})
|
||||
|
||||
it('bevat exact 4 statussen', () => {
|
||||
expect(PRODUCT_DOC_STATUSES).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
275
__tests__/lib/sprint-conflicts.test.ts
Normal file
275
__tests__/lib/sprint-conflicts.test.ts
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
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)
|
||||
})
|
||||
})
|
||||
147
__tests__/lib/user-settings-migration.test.ts
Normal file
147
__tests__/lib/user-settings-migration.test.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
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')
|
||||
})
|
||||
})
|
||||
209
__tests__/lib/user-settings.test.ts
Normal file
209
__tests__/lib/user-settings.test.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
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)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
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)
|
||||
})
|
||||
})
|
||||
69
__tests__/realtime/use-workspace-resync.test.tsx
Normal file
69
__tests__/realtime/use-workspace-resync.test.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// @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()
|
||||
})
|
||||
})
|
||||
212
__tests__/review-plan-job.test.ts
Normal file
212
__tests__/review-plan-job.test.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
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'
|
||||
})
|
||||
})
|
||||
})
|
||||
117
__tests__/stores/product-workspace/restore.test.ts
Normal file
117
__tests__/stores/product-workspace/restore.test.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
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({})
|
||||
})
|
||||
})
|
||||
83
__tests__/stores/product-workspace/screen-state.test.ts
Normal file
83
__tests__/stores/product-workspace/screen-state.test.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
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 })
|
||||
})
|
||||
})
|
||||
341
__tests__/stores/product-workspace/sprint-membership.test.ts
Normal file
341
__tests__/stores/product-workspace/sprint-membership.test.ts
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
// @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
|
||||
}
|
||||
})
|
||||
})
|
||||
890
__tests__/stores/product-workspace/store.test.ts
Normal file
890
__tests__/stores/product-workspace/store.test.ts
Normal file
|
|
@ -0,0 +1,890 @@
|
|||
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'])
|
||||
})
|
||||
})
|
||||
131
__tests__/stores/solo-workspace/store.test.ts
Normal file
131
__tests__/stores/solo-workspace/store.test.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
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')
|
||||
})
|
||||
})
|
||||
119
__tests__/stores/sprint-workspace/restore.test.ts
Normal file
119
__tests__/stores/sprint-workspace/restore.test.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
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({})
|
||||
})
|
||||
})
|
||||
875
__tests__/stores/sprint-workspace/store.test.ts
Normal file
875
__tests__/stores/sprint-workspace/store.test.ts
Normal file
|
|
@ -0,0 +1,875 @@
|
|||
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')
|
||||
})
|
||||
})
|
||||
240
__tests__/stores/user-settings.test.ts
Normal file
240
__tests__/stores/user-settings.test.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
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)
|
||||
})
|
||||
})
|
||||
|
|
@ -7,7 +7,11 @@ import { z } from 'zod'
|
|||
import { prisma } from '@/lib/prisma'
|
||||
import { SessionData, sessionOptions } from '@/lib/session'
|
||||
import { productAccessFilter } from '@/lib/product-access'
|
||||
import { setActiveSprintCookie } from '@/lib/active-sprint'
|
||||
import {
|
||||
clearActiveSprintInSettings,
|
||||
setActiveSelectionInSettings,
|
||||
setActiveSprintInSettings,
|
||||
} from '@/lib/active-sprint'
|
||||
|
||||
async function getSession() {
|
||||
return getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||
|
|
@ -18,6 +22,10 @@ const setSchema = z.object({
|
|||
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' }
|
||||
|
|
@ -36,7 +44,121 @@ export async function setActiveSprintAction(productId: string, sprintId: string)
|
|||
})
|
||||
if (!sprint) return { error: 'Sprint niet gevonden of niet toegankelijk' }
|
||||
|
||||
await setActiveSprintCookie(parsed.data.productId, parsed.data.sprintId)
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { type ClaudeJobStatus } from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { ACTIVE_JOB_STATUSES, jobStatusToApi } from '@/lib/job-status'
|
||||
|
|
@ -15,6 +16,9 @@ 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
|
||||
|
|
@ -109,3 +113,76 @@ 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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ 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'
|
||||
|
|
@ -20,6 +21,7 @@ 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'
|
||||
|
||||
|
|
@ -310,6 +312,73 @@ 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.
|
||||
|
||||
|
|
@ -340,6 +409,7 @@ export async function downloadIdeaMdAction(
|
|||
|
||||
const GRILL_TRIGGERABLE_FROM: IdeaStatus[] = ['DRAFT', 'GRILLED', 'GRILL_FAILED', 'PLAN_READY', 'PLANNED']
|
||||
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)
|
||||
|
|
@ -349,6 +419,10 @@ 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,
|
||||
|
|
@ -413,6 +487,8 @@ 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({
|
||||
|
|
@ -422,6 +498,7 @@ async function startIdeaJob(
|
|||
idea_id: id,
|
||||
kind,
|
||||
status: 'QUEUED',
|
||||
...ideaSnapshot,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
|
@ -476,12 +553,15 @@ 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.
|
||||
// plan_md was (re-plan-cancel), anders GRILLED. Bij review-plan: terug naar
|
||||
// PLAN_READY (review kan altijd opnieuw gestart worden).
|
||||
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 }
|
||||
}
|
||||
|
|
@ -641,16 +721,17 @@ 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: `ST-${String(nextStoryN++).padStart(3, '0')}`,
|
||||
code: storyCode,
|
||||
title: s.title,
|
||||
description: s.description ?? null,
|
||||
acceptance_criteria: s.acceptance_criteria ?? null,
|
||||
priority: s.priority,
|
||||
sort_order: si + 1, // sequential within PBI
|
||||
sort_order: parseCodeNumber(storyCode),
|
||||
status: 'OPEN',
|
||||
},
|
||||
select: { id: true },
|
||||
|
|
@ -659,16 +740,21 @@ 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: `T-${nextTaskN++}`,
|
||||
code: taskCode,
|
||||
title: t.title,
|
||||
description: t.description ?? null,
|
||||
implementation_plan: t.implementation_plan ?? null,
|
||||
priority: t.priority,
|
||||
sort_order: ti + 1,
|
||||
// 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),
|
||||
status: 'TO_DO',
|
||||
verify_required: t.verify_required ?? 'ALIGNED_OR_PARTIAL',
|
||||
verify_only: t.verify_only ?? false,
|
||||
|
|
|
|||
|
|
@ -2,146 +2,10 @@
|
|||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import type { ClaudeJobKind, ClaudeJobStatus, VerifyResult } from '@prisma/client'
|
||||
import { JOB_INCLUDE, mapJob, buildPriceMap } from '@/lib/jobs-mapper'
|
||||
import type { RawJob, JobWithRelations, PriceRow } from '@/lib/jobs-mapper'
|
||||
|
||||
export type JobWithRelations = {
|
||||
id: string
|
||||
kind: ClaudeJobKind
|
||||
status: ClaudeJobStatus
|
||||
taskCode: string | null
|
||||
taskTitle: string | null
|
||||
ideaCode: string | null
|
||||
ideaTitle: string | null
|
||||
sprintGoal: string | null
|
||||
sprintCode: string | null
|
||||
productName: string
|
||||
modelId: string | null
|
||||
inputTokens: number | null
|
||||
outputTokens: number | null
|
||||
cacheReadTokens: number | null
|
||||
cacheWriteTokens: number | null
|
||||
costUsd: number | null
|
||||
branch: string | null
|
||||
prUrl: string | null
|
||||
error: string | null
|
||||
summary: string | null
|
||||
description: string | null
|
||||
verifyResult: VerifyResult | null
|
||||
startedAt: Date | null
|
||||
finishedAt: Date | null
|
||||
createdAt: Date
|
||||
sprintRunId: string | null
|
||||
}
|
||||
|
||||
const JOB_INCLUDE = {
|
||||
task: { select: { code: true, title: true, description: true, implementation_plan: true } },
|
||||
idea: { select: { code: true, title: true, description: true, grill_md: true, plan_md: true } },
|
||||
product: { select: { name: true } },
|
||||
sprint_run: { include: { sprint: { select: { sprint_goal: true, code: true } } } },
|
||||
} as const
|
||||
|
||||
type RawJob = {
|
||||
id: string
|
||||
kind: ClaudeJobKind
|
||||
status: ClaudeJobStatus
|
||||
model_id: string | null
|
||||
input_tokens: number | null
|
||||
output_tokens: number | null
|
||||
cache_read_tokens: number | null
|
||||
cache_write_tokens: number | null
|
||||
branch: string | null
|
||||
pr_url: string | null
|
||||
error: string | null
|
||||
summary: string | null
|
||||
verify_result: VerifyResult | null
|
||||
started_at: Date | null
|
||||
finished_at: Date | null
|
||||
created_at: Date
|
||||
sprint_run_id: string | null
|
||||
task: {
|
||||
code: string | null
|
||||
title: string
|
||||
description: string | null
|
||||
implementation_plan: string | null
|
||||
} | null
|
||||
idea: {
|
||||
code: string | null
|
||||
title: string
|
||||
description: string | null
|
||||
grill_md: string | null
|
||||
plan_md: string | null
|
||||
} | null
|
||||
product: { name: string }
|
||||
sprint_run: { sprint: { sprint_goal: string; code: string } } | null
|
||||
}
|
||||
|
||||
type PriceRow = {
|
||||
model_id: string
|
||||
input_price_per_1m: { toString: () => string }
|
||||
output_price_per_1m: { toString: () => string }
|
||||
cache_read_price_per_1m: { toString: () => string }
|
||||
cache_write_price_per_1m: { toString: () => string }
|
||||
}
|
||||
|
||||
function pickDescription(j: RawJob): string | null {
|
||||
switch (j.kind) {
|
||||
case 'TASK_IMPLEMENTATION':
|
||||
return j.task?.implementation_plan ?? j.task?.description ?? null
|
||||
case 'IDEA_GRILL':
|
||||
return j.idea?.grill_md ?? j.idea?.description ?? null
|
||||
case 'IDEA_MAKE_PLAN':
|
||||
return j.idea?.plan_md ?? j.idea?.description ?? null
|
||||
case 'PLAN_CHAT':
|
||||
return j.idea?.description ?? null
|
||||
case 'SPRINT_IMPLEMENTATION':
|
||||
return null
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function computeCost(j: RawJob, priceMap: Map<string, PriceRow>): number | null {
|
||||
if (!j.model_id) return null
|
||||
const p = priceMap.get(j.model_id)
|
||||
if (!p || j.input_tokens == null) return null
|
||||
return (
|
||||
((j.input_tokens ?? 0) * Number(p.input_price_per_1m.toString())) / 1_000_000 +
|
||||
((j.output_tokens ?? 0) * Number(p.output_price_per_1m.toString())) / 1_000_000 +
|
||||
((j.cache_read_tokens ?? 0) * Number(p.cache_read_price_per_1m.toString())) / 1_000_000 +
|
||||
((j.cache_write_tokens ?? 0) * Number(p.cache_write_price_per_1m.toString())) / 1_000_000
|
||||
)
|
||||
}
|
||||
|
||||
function mapJob(j: RawJob, priceMap: Map<string, PriceRow>): JobWithRelations {
|
||||
return {
|
||||
id: j.id,
|
||||
kind: j.kind,
|
||||
status: j.status,
|
||||
taskCode: j.task?.code ?? null,
|
||||
taskTitle: j.task?.title ?? null,
|
||||
ideaCode: j.idea?.code ?? null,
|
||||
ideaTitle: j.idea?.title ?? null,
|
||||
sprintGoal: j.sprint_run?.sprint.sprint_goal ?? null,
|
||||
sprintCode: j.sprint_run?.sprint.code ?? null,
|
||||
productName: j.product.name,
|
||||
modelId: j.model_id,
|
||||
inputTokens: j.input_tokens,
|
||||
outputTokens: j.output_tokens,
|
||||
cacheReadTokens: j.cache_read_tokens,
|
||||
cacheWriteTokens: j.cache_write_tokens,
|
||||
costUsd: computeCost(j, priceMap),
|
||||
branch: j.branch,
|
||||
prUrl: j.pr_url,
|
||||
error: j.error,
|
||||
summary: j.summary,
|
||||
description: pickDescription(j),
|
||||
verifyResult: j.verify_result,
|
||||
startedAt: j.started_at,
|
||||
finishedAt: j.finished_at,
|
||||
createdAt: j.created_at,
|
||||
sprintRunId: j.sprint_run_id,
|
||||
}
|
||||
}
|
||||
export type { JobWithRelations } from '@/lib/jobs-mapper'
|
||||
|
||||
export async function fetchJobsPageData(): Promise<{ activeJobs: JobWithRelations[]; doneJobs: JobWithRelations[] } | null> {
|
||||
const session = await getSession()
|
||||
|
|
@ -162,10 +26,10 @@ export async function fetchJobsPageData(): Promise<{ activeJobs: JobWithRelation
|
|||
prisma.modelPrice.findMany(),
|
||||
])
|
||||
|
||||
const priceMap = new Map<string, PriceRow>(prices.map((p) => [p.model_id, p as unknown as PriceRow]))
|
||||
const priceMap = buildPriceMap(prices as unknown as PriceRow[])
|
||||
|
||||
return {
|
||||
activeJobs: active.map((j) => mapJob(j as RawJob, priceMap)),
|
||||
doneJobs: done.map((j) => mapJob(j as RawJob, priceMap)),
|
||||
activeJobs: active.map((j) => mapJob(j as unknown as RawJob, priceMap)),
|
||||
doneJobs: done.map((j) => mapJob(j as unknown as RawJob, priceMap)),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
427
actions/product-docs.ts
Normal file
427
actions/product-docs.ts
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
'use server'
|
||||
|
||||
// Server-actions voor de ProductDoc-entity (PBI-96). Volgt
|
||||
// docs/patterns/server-action.md: auth → demo-guard → rate-limit → zod →
|
||||
// scope-check → frontmatter-parse → tx-write+log → revalidatePath. Pattern
|
||||
// gespiegeld uit actions/ideas.ts (markdown-edit flow, regels 232-313).
|
||||
//
|
||||
// Belangrijke review-fixes (zie
|
||||
// docs/recommendations/product-docs-storage-system-review-2026-05-16.md):
|
||||
// - P1 (delete-audit): log met doc_id:null vóór delete in $transaction
|
||||
// — geen FK-race, geen interactieve tx nodig (in T-1063).
|
||||
// - P2 (frontmatter-sync): title/status uit parsed.frontmatter worden
|
||||
// naar de gerepliceerde kolommen geschreven; last_updated wordt
|
||||
// server-side genormaliseerd via setProductDocFrontmatterFields.
|
||||
//
|
||||
// Plan: docs/plans/PBI-96-product-docs.md §B.2.
|
||||
|
||||
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 { enforceUserRateLimit } from '@/lib/rate-limit'
|
||||
import { productAccessFilter } from '@/lib/product-access'
|
||||
import {
|
||||
folderApiToDbOrThrow,
|
||||
loadAccessibleProduct,
|
||||
} from '@/lib/product-docs-server'
|
||||
import { productDocFolderToApi } from '@/lib/product-doc-folder'
|
||||
import {
|
||||
productDocCreateSchema,
|
||||
productDocFolderToggleSchema,
|
||||
productDocUpdateSchema,
|
||||
type ProductDocCreateInput,
|
||||
type ProductDocFolderToggleInput,
|
||||
} from '@/lib/schemas/product-doc'
|
||||
import { parseProductDocMd } from '@/lib/product-doc-parser'
|
||||
import {
|
||||
setProductDocFrontmatterFields,
|
||||
todayIsoDate,
|
||||
} from '@/lib/product-doc-frontmatter'
|
||||
|
||||
async function getSession() {
|
||||
return getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||
}
|
||||
|
||||
type ActionResult<T = void> =
|
||||
| { success: true; data?: T }
|
||||
| { error: string; code?: number; details?: unknown }
|
||||
|
||||
function isPrismaUniqueConstraintError(err: unknown): boolean {
|
||||
return (
|
||||
err != null &&
|
||||
typeof err === 'object' &&
|
||||
'code' in err &&
|
||||
(err as { code: string }).code === 'P2002'
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CREATE
|
||||
|
||||
export async function createProductDocAction(
|
||||
input: ProductDocCreateInput,
|
||||
): Promise<ActionResult<{ id: string; folder: string; slug: string }>> {
|
||||
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('create-product-doc', session.userId)
|
||||
if (limited) return limited
|
||||
|
||||
const parsedInput = productDocCreateSchema.safeParse(input)
|
||||
if (!parsedInput.success) {
|
||||
return {
|
||||
error: 'Validatie mislukt',
|
||||
code: 422,
|
||||
details: parsedInput.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
// P2: parse + valideer frontmatter (422 met line-info bij fout)
|
||||
const parsedMd = parseProductDocMd(parsedInput.data.content_md)
|
||||
if (!parsedMd.ok) {
|
||||
return {
|
||||
error: 'content_md is niet parseerbaar',
|
||||
code: 422,
|
||||
details: parsedMd.errors,
|
||||
}
|
||||
}
|
||||
|
||||
const userId = session.userId
|
||||
const product = await loadAccessibleProduct(
|
||||
parsedInput.data.product_id,
|
||||
userId,
|
||||
)
|
||||
if (!product) return { error: 'Product niet gevonden', code: 404 }
|
||||
|
||||
const folderDb = folderApiToDbOrThrow(parsedInput.data.folder)
|
||||
if (!product.enabled_doc_folders.includes(folderDb)) {
|
||||
return {
|
||||
error: `Folder '${parsedInput.data.folder}' staat uit voor dit product`,
|
||||
code: 422,
|
||||
}
|
||||
}
|
||||
|
||||
// P2: normaliseer last_updated server-side in het opgeslagen content_md
|
||||
const normalized = setProductDocFrontmatterFields(
|
||||
parsedInput.data.content_md,
|
||||
{ last_updated: todayIsoDate() },
|
||||
)
|
||||
|
||||
try {
|
||||
const created = await prisma.$transaction(async (tx) => {
|
||||
const doc = await tx.productDoc.create({
|
||||
data: {
|
||||
product_id: product.id,
|
||||
folder: folderDb,
|
||||
slug: parsedInput.data.slug,
|
||||
title: parsedMd.frontmatter.title, // P2: sync uit frontmatter
|
||||
status: parsedMd.frontmatter.status, // P2: sync uit frontmatter
|
||||
content_md: normalized,
|
||||
created_by: userId,
|
||||
},
|
||||
select: { id: true, folder: true, slug: true },
|
||||
})
|
||||
|
||||
await tx.productDocLog.create({
|
||||
data: {
|
||||
product_id: product.id,
|
||||
doc_id: doc.id,
|
||||
actor_user_id: userId,
|
||||
type: 'CREATED',
|
||||
metadata: {
|
||||
folder: productDocFolderToApi(folderDb),
|
||||
slug: parsedInput.data.slug,
|
||||
title: parsedMd.frontmatter.title,
|
||||
length: normalized.length,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return doc
|
||||
})
|
||||
|
||||
const folderApi = productDocFolderToApi(created.folder)
|
||||
revalidatePath(`/products/${product.id}/docs`)
|
||||
revalidatePath(`/products/${product.id}/docs/${folderApi}`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { id: created.id, folder: folderApi, slug: created.slug },
|
||||
}
|
||||
} catch (err) {
|
||||
if (isPrismaUniqueConstraintError(err)) {
|
||||
return {
|
||||
error: `Slug '${parsedInput.data.slug}' bestaat al in folder '${parsedInput.data.folder}'`,
|
||||
code: 422,
|
||||
}
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UPDATE
|
||||
|
||||
export async function updateProductDocAction(
|
||||
id: string,
|
||||
contentMd: 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('edit-product-doc', session.userId)
|
||||
if (limited) return limited
|
||||
|
||||
const parsedInput = productDocUpdateSchema.safeParse({ content_md: contentMd })
|
||||
if (!parsedInput.success) {
|
||||
return {
|
||||
error: 'Validatie mislukt',
|
||||
code: 422,
|
||||
details: parsedInput.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const userId = session.userId
|
||||
const existing = await prisma.productDoc.findFirst({
|
||||
where: { id, product: productAccessFilter(userId) },
|
||||
select: {
|
||||
id: true,
|
||||
product_id: true,
|
||||
folder: true,
|
||||
slug: true,
|
||||
status: true,
|
||||
},
|
||||
})
|
||||
if (!existing) return { error: 'Doc niet gevonden', code: 404 }
|
||||
|
||||
const parsedMd = parseProductDocMd(parsedInput.data.content_md)
|
||||
if (!parsedMd.ok) {
|
||||
return {
|
||||
error: 'content_md is niet parseerbaar',
|
||||
code: 422,
|
||||
details: parsedMd.errors,
|
||||
}
|
||||
}
|
||||
|
||||
// P2: normaliseer last_updated server-side
|
||||
const normalized = setProductDocFrontmatterFields(
|
||||
parsedInput.data.content_md,
|
||||
{ last_updated: todayIsoDate() },
|
||||
)
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.productDoc.update({
|
||||
where: { id },
|
||||
data: {
|
||||
title: parsedMd.frontmatter.title, // P2: sync uit frontmatter
|
||||
status: parsedMd.frontmatter.status, // P2: sync uit frontmatter
|
||||
content_md: normalized,
|
||||
},
|
||||
}),
|
||||
prisma.productDocLog.create({
|
||||
data: {
|
||||
product_id: existing.product_id,
|
||||
doc_id: id,
|
||||
actor_user_id: userId,
|
||||
type: 'UPDATED',
|
||||
metadata: {
|
||||
length: normalized.length,
|
||||
prev_status: existing.status,
|
||||
new_status: parsedMd.frontmatter.status,
|
||||
},
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const folderApi = productDocFolderToApi(existing.folder)
|
||||
revalidatePath(`/products/${existing.product_id}/docs`)
|
||||
revalidatePath(`/products/${existing.product_id}/docs/${folderApi}`)
|
||||
revalidatePath(
|
||||
`/products/${existing.product_id}/docs/${folderApi}/${existing.slug}`,
|
||||
)
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE — verwerkt P1-review-fix (delete-audit FK-race)
|
||||
//
|
||||
// Probleem zoals omschreven in
|
||||
// docs/recommendations/product-docs-storage-system-review-2026-05-16.md (P1):
|
||||
// als de log na de delete met `doc_id:<oldId>` wordt aangemaakt, faalt de
|
||||
// foreign key. Met SetNull-cascade zou de FK 'genezen', maar dat vereist
|
||||
// een interactieve transaction met juiste volgorde.
|
||||
//
|
||||
// Fix: schrijf log met `doc_id: null` VANAF HET BEGIN. Geen FK-race, geen
|
||||
// interactieve tx nodig. Metadata bewaart `folder/slug/title` voor
|
||||
// traceability — de relatie is wel verloren maar de informatie niet.
|
||||
|
||||
export async function deleteProductDocAction(id: 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 userId = session.userId
|
||||
const existing = await prisma.productDoc.findFirst({
|
||||
where: { id, product: productAccessFilter(userId) },
|
||||
select: {
|
||||
id: true,
|
||||
product_id: true,
|
||||
folder: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
},
|
||||
})
|
||||
if (!existing) return { error: 'Doc niet gevonden', code: 404 }
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.productDocLog.create({
|
||||
data: {
|
||||
product_id: existing.product_id,
|
||||
doc_id: null, // P1-fix: null vanaf het begin
|
||||
actor_user_id: userId,
|
||||
type: 'DELETED',
|
||||
metadata: {
|
||||
folder: productDocFolderToApi(existing.folder),
|
||||
slug: existing.slug,
|
||||
title: existing.title,
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.productDoc.delete({ where: { id } }),
|
||||
])
|
||||
|
||||
const folderApi = productDocFolderToApi(existing.folder)
|
||||
revalidatePath(`/products/${existing.product_id}/docs`)
|
||||
revalidatePath(`/products/${existing.product_id}/docs/${folderApi}`)
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FOLDER TOGGLE — owner-only (NIET productAccessFilter, folder-config is
|
||||
// een product-setting, niet een doc-mutation). ProductMember kan dus geen
|
||||
// folders aan/uit zetten.
|
||||
//
|
||||
// Idempotent: als de target-staat al de huidige staat is, doet de action
|
||||
// niets en returnt success — geen log-rij, geen revalidate.
|
||||
|
||||
export async function toggleProductDocFolderAction(
|
||||
input: ProductDocFolderToggleInput,
|
||||
): 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 = productDocFolderToggleSchema.safeParse(input)
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
error: 'Validatie mislukt',
|
||||
code: 422,
|
||||
details: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const userId = session.userId
|
||||
// Owner-only — NIET productAccessFilter
|
||||
const product = await prisma.product.findFirst({
|
||||
where: { id: parsed.data.product_id, user_id: userId },
|
||||
select: { id: true, enabled_doc_folders: true },
|
||||
})
|
||||
if (!product) return { error: 'Product niet gevonden', code: 404 }
|
||||
|
||||
const folderDb = folderApiToDbOrThrow(parsed.data.folder)
|
||||
const isEnabledNow = product.enabled_doc_folders.includes(folderDb)
|
||||
|
||||
if (parsed.data.enabled === isEnabledNow) {
|
||||
return { success: true } // idempotent
|
||||
}
|
||||
|
||||
const next = parsed.data.enabled
|
||||
? Array.from(new Set([...product.enabled_doc_folders, folderDb]))
|
||||
: product.enabled_doc_folders.filter((f) => f !== folderDb)
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.product.update({
|
||||
where: { id: product.id },
|
||||
data: { enabled_doc_folders: next },
|
||||
}),
|
||||
prisma.productDocLog.create({
|
||||
data: {
|
||||
product_id: product.id,
|
||||
doc_id: null,
|
||||
actor_user_id: userId,
|
||||
type: parsed.data.enabled ? 'FOLDER_ENABLED' : 'FOLDER_DISABLED',
|
||||
metadata: { folder: parsed.data.folder },
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
revalidatePath(`/products/${product.id}/docs`)
|
||||
revalidatePath(`/products/${product.id}/docs/settings`)
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LIST — read-only. Demo MAG lezen (zie plan §B.4). Geen rate-limit.
|
||||
|
||||
export interface ProductDocListItem {
|
||||
id: string
|
||||
folder: string
|
||||
slug: string
|
||||
title: string
|
||||
status: string
|
||||
updated_at: Date
|
||||
}
|
||||
|
||||
export async function listProductDocsAction(input: {
|
||||
product_id: string
|
||||
folder?: string
|
||||
}): Promise<ActionResult<ProductDocListItem[]>> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd', code: 401 }
|
||||
|
||||
const userId = session.userId
|
||||
const product = await loadAccessibleProduct(input.product_id, userId)
|
||||
if (!product) return { error: 'Product niet gevonden', code: 404 }
|
||||
|
||||
const folderDb = input.folder ? folderApiToDbOrThrow(input.folder) : undefined
|
||||
|
||||
const docs = await prisma.productDoc.findMany({
|
||||
where: {
|
||||
product_id: product.id,
|
||||
...(folderDb ? { folder: folderDb } : {}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
folder: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
status: true,
|
||||
updated_at: true,
|
||||
},
|
||||
orderBy: [{ folder: 'asc' }, { slug: 'asc' }],
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: docs.map((d) => ({
|
||||
id: d.id,
|
||||
folder: productDocFolderToApi(d.folder),
|
||||
slug: d.slug,
|
||||
title: d.title,
|
||||
status: d.status,
|
||||
updated_at: d.updated_at,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
|
@ -306,6 +306,33 @@ export async function restoreProductAction(id: string) {
|
|||
return { success: true }
|
||||
}
|
||||
|
||||
// PBI-98 / T-1089: owner-only product-delete vanuit de dashboard-tabel.
|
||||
// Cascade-delete wordt afgehandeld door Prisma (onDelete: Cascade op PBI,
|
||||
// Story, Task, ClaudeJob, ProductDoc etc.). Bestaande active_product_id
|
||||
// referenties worden eerst genulleerd.
|
||||
export async function deleteProductAction(id: string) {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
||||
|
||||
const product = await prisma.product.findFirst({
|
||||
where: { id, user_id: session.userId },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!product) return { error: 'Product niet gevonden' }
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.user.updateMany({
|
||||
where: { active_product_id: id },
|
||||
data: { active_product_id: null },
|
||||
}),
|
||||
prisma.product.delete({ where: { id } }),
|
||||
])
|
||||
|
||||
revalidatePath('/dashboard')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function addProductMemberAction(_prevState: unknown, formData: FormData) {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||
|
|
|
|||
121
actions/sprint-draft.ts
Normal file
121
actions/sprint-draft.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
'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 }
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ 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)
|
||||
|
|
@ -84,10 +85,10 @@ async function startSprintRunCore(
|
|||
// 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: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||
orderBy: [{ sort_order: 'asc' }],
|
||||
},
|
||||
},
|
||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||
orderBy: [{ sort_order: 'asc' }],
|
||||
})
|
||||
|
||||
const blockers: PreFlightBlocker[] = []
|
||||
|
|
@ -166,7 +167,6 @@ async function startSprintRunCore(
|
|||
(a, b) =>
|
||||
a.pbi.priority - b.pbi.priority ||
|
||||
a.pbi.sort_order - b.pbi.sort_order ||
|
||||
a.priority - b.priority ||
|
||||
a.sort_order - b.sort_order,
|
||||
)
|
||||
.flatMap((s) => s.tasks)
|
||||
|
|
@ -176,6 +176,10 @@ async function startSprintRunCore(
|
|||
// 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,
|
||||
|
|
@ -185,13 +189,20 @@ async function startSprintRunCore(
|
|||
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.
|
||||
// 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,
|
||||
|
|
@ -200,6 +211,7 @@ async function startSprintRunCore(
|
|||
sprint_run_id: sprintRun.id,
|
||||
kind: 'TASK_IMPLEMENTATION',
|
||||
status: 'QUEUED',
|
||||
...taskSnapshot,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -360,6 +372,10 @@ export async function resumePausedSprintRunAction(
|
|||
started_at: new Date(),
|
||||
},
|
||||
})
|
||||
const resumeSnapshot = await getJobConfigSnapshot({
|
||||
kind: 'SPRINT_IMPLEMENTATION',
|
||||
productId: sprintJob.product_id,
|
||||
})
|
||||
await tx.claudeJob.create({
|
||||
data: {
|
||||
user_id: userId,
|
||||
|
|
@ -369,6 +385,7 @@ export async function resumePausedSprintRunAction(
|
|||
sprint_run_id: newRun.id,
|
||||
kind: 'SPRINT_IMPLEMENTATION',
|
||||
status: 'QUEUED',
|
||||
...resumeSnapshot,
|
||||
},
|
||||
})
|
||||
await tx.sprintRun.update({
|
||||
|
|
|
|||
|
|
@ -14,9 +14,359 @@ import {
|
|||
import { enforceUserRateLimit } from '@/lib/rate-limit'
|
||||
import { propagateStatusUpwards } from '@/lib/tasks-status-update'
|
||||
import { createWithCodeRetry, generateNextSprintCode } from '@/lib/code-server'
|
||||
import { setActiveSprintCookie } from '@/lib/active-sprint'
|
||||
import { setActiveSprintInSettings } from '@/lib/active-sprint'
|
||||
import { partitionByEligibility } from '@/lib/sprint-conflicts'
|
||||
import { z } from 'zod'
|
||||
|
||||
const StoryOverrideSchema = z.object({
|
||||
add: z.array(z.string()),
|
||||
remove: z.array(z.string()),
|
||||
})
|
||||
|
||||
const createSprintWithSelectionSchema = z.object({
|
||||
productId: z.string().min(1),
|
||||
metadata: z.object({
|
||||
goal: z.string().min(1).max(2000),
|
||||
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(), StoryOverrideSchema).default({}),
|
||||
})
|
||||
|
||||
export type CreateSprintWithSelectionInput = z.infer<
|
||||
typeof createSprintWithSelectionSchema
|
||||
>
|
||||
|
||||
type SprintCreateConflicts = {
|
||||
notEligible: { storyId: string; reason: 'DONE' | 'IN_OTHER_SPRINT' }[]
|
||||
crossSprint: { storyId: string; sprintId: string; sprintName: string }[]
|
||||
}
|
||||
|
||||
export type CreateSprintWithSelectionResult =
|
||||
| {
|
||||
success: true
|
||||
sprintId: string
|
||||
affectedStoryIds: string[]
|
||||
affectedPbiIds: string[]
|
||||
affectedTaskIds: string[]
|
||||
conflicts: SprintCreateConflicts
|
||||
}
|
||||
| { error: string; code: number }
|
||||
|
||||
const updateSprintSchema = z.object({
|
||||
sprintId: z.string().min(1),
|
||||
fields: z
|
||||
.object({
|
||||
goal: z.string().min(1).max(2000).optional(),
|
||||
startAt: z.string().date().nullable().optional(),
|
||||
endAt: z.string().date().nullable().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => Object.keys(data).length > 0,
|
||||
'Minstens één veld vereist',
|
||||
),
|
||||
})
|
||||
|
||||
export type UpdateSprintInput = z.infer<typeof updateSprintSchema>
|
||||
|
||||
export type UpdateSprintResult =
|
||||
| { success: true; sprintId: string }
|
||||
| { error: string; code: number }
|
||||
|
||||
export async function updateSprintAction(
|
||||
input: UpdateSprintInput,
|
||||
): Promise<UpdateSprintResult> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd', code: 403 }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
|
||||
|
||||
const parsed = updateSprintSchema.safeParse(input)
|
||||
if (!parsed.success) return { error: 'Validatie mislukt', code: 422 }
|
||||
|
||||
const sprint = await prisma.sprint.findFirst({
|
||||
where: {
|
||||
id: parsed.data.sprintId,
|
||||
product: productAccessFilter(session.userId),
|
||||
},
|
||||
select: { id: true, product_id: true },
|
||||
})
|
||||
if (!sprint) return { error: 'Sprint niet gevonden', code: 403 }
|
||||
|
||||
const data: { sprint_goal?: string; start_date?: Date | null; end_date?: Date | null } = {}
|
||||
if (parsed.data.fields.goal !== undefined) {
|
||||
data.sprint_goal = parsed.data.fields.goal
|
||||
}
|
||||
if (parsed.data.fields.startAt !== undefined) {
|
||||
data.start_date = parseDate(parsed.data.fields.startAt)
|
||||
}
|
||||
if (parsed.data.fields.endAt !== undefined) {
|
||||
data.end_date = parseDate(parsed.data.fields.endAt)
|
||||
}
|
||||
|
||||
await prisma.sprint.update({
|
||||
where: { id: parsed.data.sprintId },
|
||||
data,
|
||||
})
|
||||
revalidatePath(`/products/${sprint.product_id}`, 'layout')
|
||||
|
||||
return { success: true, sprintId: parsed.data.sprintId }
|
||||
}
|
||||
|
||||
const commitSprintMembershipSchema = z.object({
|
||||
activeSprintId: z.string().min(1),
|
||||
adds: z.array(z.string()),
|
||||
removes: z.array(z.string()),
|
||||
})
|
||||
|
||||
export type CommitSprintMembershipInput = z.infer<
|
||||
typeof commitSprintMembershipSchema
|
||||
>
|
||||
|
||||
type CommitConflicts = {
|
||||
notEligible: { storyId: string; reason: 'DONE' | 'IN_OTHER_SPRINT' }[]
|
||||
alreadyRemoved: string[]
|
||||
}
|
||||
|
||||
export type CommitSprintMembershipResult =
|
||||
| {
|
||||
success: true
|
||||
affectedStoryIds: string[]
|
||||
affectedPbiIds: string[]
|
||||
affectedTaskIds: string[]
|
||||
conflicts: CommitConflicts
|
||||
}
|
||||
| { error: string; code: number }
|
||||
|
||||
export async function commitSprintMembershipAction(
|
||||
input: CommitSprintMembershipInput,
|
||||
): Promise<CommitSprintMembershipResult> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd', code: 403 }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
|
||||
|
||||
const parsed = commitSprintMembershipSchema.safeParse(input)
|
||||
if (!parsed.success) return { error: 'Validatie mislukt', code: 422 }
|
||||
|
||||
// Sprint moet bestaan en bereikbaar zijn via product-access.
|
||||
const sprint = await prisma.sprint.findFirst({
|
||||
where: {
|
||||
id: parsed.data.activeSprintId,
|
||||
product: productAccessFilter(session.userId),
|
||||
},
|
||||
select: { id: true, product_id: true },
|
||||
})
|
||||
if (!sprint) {
|
||||
return { error: 'Sprint niet gevonden of niet toegankelijk', code: 403 }
|
||||
}
|
||||
|
||||
// Filter adds via eligibility (sprint_id IS NULL en niet DONE; andere OPEN
|
||||
// sprint → conflicts.notEligible + crossSprint).
|
||||
const addPartition = await partitionByEligibility(
|
||||
prisma,
|
||||
parsed.data.adds,
|
||||
parsed.data.activeSprintId,
|
||||
)
|
||||
const eligibleAdds = addPartition.eligible
|
||||
const notEligibleAdds = addPartition.notEligible
|
||||
|
||||
// Race-safety voor removes: alleen stories die feitelijk in de actieve
|
||||
// sprint zitten worden verwijderd.
|
||||
const removeRows =
|
||||
parsed.data.removes.length > 0
|
||||
? await prisma.story.findMany({
|
||||
where: {
|
||||
id: { in: parsed.data.removes },
|
||||
sprint_id: parsed.data.activeSprintId,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
: []
|
||||
const validRemoves = removeRows.map((r) => r.id)
|
||||
const validRemoveSet = new Set(validRemoves)
|
||||
const alreadyRemoved = parsed.data.removes.filter(
|
||||
(id) => !validRemoveSet.has(id),
|
||||
)
|
||||
|
||||
if (eligibleAdds.length === 0 && validRemoves.length === 0) {
|
||||
// Geen werk te doen — geef toch een success-shape terug zodat de client
|
||||
// pending buffer kan resetten + conflicts kan tonen.
|
||||
return {
|
||||
success: true,
|
||||
affectedStoryIds: [],
|
||||
affectedPbiIds: [],
|
||||
affectedTaskIds: [],
|
||||
conflicts: { notEligible: notEligibleAdds, alreadyRemoved },
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (eligibleAdds.length > 0) {
|
||||
await tx.story.updateMany({
|
||||
where: { id: { in: eligibleAdds } },
|
||||
data: { sprint_id: parsed.data.activeSprintId, status: 'IN_SPRINT' },
|
||||
})
|
||||
await tx.task.updateMany({
|
||||
where: { story_id: { in: eligibleAdds } },
|
||||
data: { sprint_id: parsed.data.activeSprintId },
|
||||
})
|
||||
}
|
||||
if (validRemoves.length > 0) {
|
||||
await tx.story.updateMany({
|
||||
where: { id: { in: validRemoves } },
|
||||
data: { sprint_id: null, status: 'OPEN' },
|
||||
})
|
||||
await tx.task.updateMany({
|
||||
where: { story_id: { in: validRemoves } },
|
||||
data: { sprint_id: null },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const affectedStoryIds = [...eligibleAdds, ...validRemoves]
|
||||
const affectedStories =
|
||||
affectedStoryIds.length > 0
|
||||
? await prisma.story.findMany({
|
||||
where: { id: { in: affectedStoryIds } },
|
||||
select: { pbi_id: true },
|
||||
})
|
||||
: []
|
||||
const affectedPbiIds = Array.from(
|
||||
new Set(affectedStories.map((s) => s.pbi_id)),
|
||||
)
|
||||
const affectedTasks =
|
||||
affectedStoryIds.length > 0
|
||||
? await prisma.task.findMany({
|
||||
where: { story_id: { in: affectedStoryIds } },
|
||||
select: { id: true },
|
||||
})
|
||||
: []
|
||||
const affectedTaskIds = affectedTasks.map((t) => t.id)
|
||||
|
||||
revalidatePath(`/products/${sprint.product_id}`, 'layout')
|
||||
|
||||
return {
|
||||
success: true,
|
||||
affectedStoryIds,
|
||||
affectedPbiIds,
|
||||
affectedTaskIds,
|
||||
conflicts: { notEligible: notEligibleAdds, alreadyRemoved },
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSprintWithSelectionAction(
|
||||
input: CreateSprintWithSelectionInput,
|
||||
): Promise<CreateSprintWithSelectionResult> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd', code: 403 }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
|
||||
|
||||
const limited = enforceUserRateLimit('create-sprint', session.userId)
|
||||
if (limited) return { error: limited.error, code: limited.code }
|
||||
|
||||
const parsed = createSprintWithSelectionSchema.safeParse(input)
|
||||
if (!parsed.success) return { error: 'Validatie mislukt', code: 422 }
|
||||
|
||||
const product = await getAccessibleProduct(parsed.data.productId, session.userId)
|
||||
if (!product) return { error: 'Product niet gevonden', code: 403 }
|
||||
|
||||
// Resolveer intent + per-PBI overrides naar concrete story-IDs.
|
||||
const allPbiAllIds = Object.entries(parsed.data.pbiIntent)
|
||||
.filter(([, intent]) => intent === 'all')
|
||||
.map(([pbiId]) => pbiId)
|
||||
|
||||
// Stap 1: alle child-stories voor PBI's met intent='all'.
|
||||
let candidate: string[] = []
|
||||
if (allPbiAllIds.length > 0) {
|
||||
const rows = await prisma.story.findMany({
|
||||
where: { pbi_id: { in: allPbiAllIds }, product_id: parsed.data.productId },
|
||||
select: { id: true, pbi_id: true },
|
||||
})
|
||||
const removedSet = new Set<string>()
|
||||
for (const [pbiId, override] of Object.entries(parsed.data.storyOverrides)) {
|
||||
for (const id of override.remove) removedSet.add(`${pbiId}:${id}`)
|
||||
}
|
||||
candidate = rows
|
||||
.filter((row) => !removedSet.has(`${row.pbi_id}:${row.id}`))
|
||||
.map((row) => row.id)
|
||||
}
|
||||
|
||||
// Stap 2: storyOverrides.add — werkt voor zowel intent='none' als 'all' (extra
|
||||
// toevoegingen). Dedupliceren met candidates uit stap 1.
|
||||
const candidateSet = new Set(candidate)
|
||||
for (const override of Object.values(parsed.data.storyOverrides)) {
|
||||
for (const id of override.add) candidateSet.add(id)
|
||||
}
|
||||
const candidateIds = Array.from(candidateSet)
|
||||
|
||||
// Eligibility-filter (incl. cross-sprint guard).
|
||||
const partition = await partitionByEligibility(prisma, candidateIds)
|
||||
|
||||
if (partition.eligible.length === 0) {
|
||||
return {
|
||||
error: 'Geen eligible stories voor deze sprint',
|
||||
code: 422,
|
||||
}
|
||||
}
|
||||
|
||||
const sprint = await createWithCodeRetry(
|
||||
() => generateNextSprintCode(parsed.data.productId),
|
||||
(code) =>
|
||||
prisma.$transaction(async (tx) => {
|
||||
const created = await tx.sprint.create({
|
||||
data: {
|
||||
product_id: parsed.data.productId,
|
||||
code,
|
||||
sprint_goal: parsed.data.metadata.goal,
|
||||
status: 'OPEN',
|
||||
start_date: parseDate(parsed.data.metadata.startAt),
|
||||
end_date: parseDate(parsed.data.metadata.endAt),
|
||||
},
|
||||
})
|
||||
await tx.story.updateMany({
|
||||
where: { id: { in: partition.eligible } },
|
||||
data: { sprint_id: created.id, status: 'IN_SPRINT' },
|
||||
})
|
||||
await tx.task.updateMany({
|
||||
where: { story_id: { in: partition.eligible } },
|
||||
data: { sprint_id: created.id },
|
||||
})
|
||||
return created
|
||||
}),
|
||||
)
|
||||
|
||||
// Snapshot affected pbi/task IDs voor client-store patches.
|
||||
const affectedStories = await prisma.story.findMany({
|
||||
where: { id: { in: partition.eligible } },
|
||||
select: { pbi_id: true },
|
||||
})
|
||||
const affectedPbiIds = Array.from(new Set(affectedStories.map((s) => s.pbi_id)))
|
||||
const affectedTasks = await prisma.task.findMany({
|
||||
where: { story_id: { in: partition.eligible } },
|
||||
select: { id: true },
|
||||
})
|
||||
const affectedTaskIds = affectedTasks.map((t) => t.id)
|
||||
|
||||
await setActiveSprintInSettings(
|
||||
session.userId,
|
||||
parsed.data.productId,
|
||||
sprint.id,
|
||||
)
|
||||
revalidatePath(`/products/${parsed.data.productId}`, 'layout')
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sprintId: sprint.id,
|
||||
affectedStoryIds: partition.eligible,
|
||||
affectedPbiIds,
|
||||
affectedTaskIds,
|
||||
conflicts: {
|
||||
notEligible: partition.notEligible,
|
||||
crossSprint: partition.crossSprint,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function getSession() {
|
||||
return getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||
}
|
||||
|
|
@ -40,6 +390,7 @@ export async function createSprintAction(_prevState: unknown, formData: FormData
|
|||
sprint_goal: formData.get('sprint_goal'),
|
||||
start_date: formData.get('start_date'),
|
||||
end_date: formData.get('end_date'),
|
||||
pbi_id: formData.get('pbi_id'),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
|
|
@ -52,10 +403,10 @@ export async function createSprintAction(_prevState: unknown, formData: FormData
|
|||
const product = await getAccessibleProduct(parsed.data.productId, session.userId)
|
||||
if (!product) return { error: 'Product niet gevonden', code: 403 }
|
||||
|
||||
const existing = await prisma.sprint.findFirst({
|
||||
where: { product_id: parsed.data.productId, status: 'OPEN' },
|
||||
})
|
||||
if (existing) return { error: 'Er is al een actieve Sprint voor dit product', sprintId: existing.id, code: 422 }
|
||||
// PBI-79 / ST-1342: multi-OPEN sprints toegestaan. Bestaande OPEN sprints
|
||||
// op hetzelfde product zijn geen reden meer om aanmaak te blokkeren —
|
||||
// cross-sprint-conflicts worden per-story afgevangen in de membership-
|
||||
// commit-flow.
|
||||
|
||||
const sprint = await createWithCodeRetry(
|
||||
() => generateNextSprintCode(parsed.data.productId),
|
||||
|
|
@ -72,7 +423,37 @@ export async function createSprintAction(_prevState: unknown, formData: FormData
|
|||
}),
|
||||
)
|
||||
|
||||
revalidatePath(`/products/${parsed.data.productId}`)
|
||||
if (parsed.data.pbi_id) {
|
||||
const pbi = await prisma.pbi.findFirst({
|
||||
where: { id: parsed.data.pbi_id, product_id: parsed.data.productId },
|
||||
select: { id: true },
|
||||
})
|
||||
if (pbi) {
|
||||
const stories = await prisma.story.findMany({
|
||||
where: { pbi_id: pbi.id, sprint_id: null },
|
||||
orderBy: [{ sort_order: 'asc' }],
|
||||
select: { id: true },
|
||||
})
|
||||
if (stories.length > 0) {
|
||||
const storyIds = stories.map(s => s.id)
|
||||
await prisma.$transaction([
|
||||
...stories.map((s, i) =>
|
||||
prisma.story.update({
|
||||
where: { id: s.id },
|
||||
data: { sprint_id: sprint.id, status: 'IN_SPRINT' },
|
||||
}),
|
||||
),
|
||||
prisma.task.updateMany({
|
||||
where: { story_id: { in: storyIds }, sprint_id: null },
|
||||
data: { sprint_id: sprint.id },
|
||||
}),
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await setActiveSprintInSettings(session.userId, parsed.data.productId, sprint.id)
|
||||
revalidatePath(`/products/${parsed.data.productId}`, 'layout')
|
||||
return { success: true, sprintId: sprint.id }
|
||||
}
|
||||
|
||||
|
|
@ -150,14 +531,9 @@ export async function addStoryToSprintAction(sprintId: string, storyId: string)
|
|||
if (!story) return { error: 'Story niet gevonden' }
|
||||
if (story.product_id !== sprint.product_id) return { error: 'Story hoort niet bij deze Sprint' }
|
||||
|
||||
const last = await prisma.story.findFirst({
|
||||
where: { sprint_id: sprintId },
|
||||
orderBy: { sort_order: 'desc' },
|
||||
})
|
||||
|
||||
await prisma.story.update({
|
||||
where: { id: storyId },
|
||||
data: { sprint_id: sprintId, status: 'IN_SPRINT', sort_order: (last?.sort_order ?? 0) + 1.0 },
|
||||
data: { sprint_id: sprintId, status: 'IN_SPRINT' },
|
||||
})
|
||||
|
||||
revalidatePath(`/products/${sprint.product_id}/sprint`)
|
||||
|
|
@ -186,32 +562,6 @@ export async function removeStoryFromSprintAction(storyId: string) {
|
|||
return { success: true }
|
||||
}
|
||||
|
||||
export async function reorderSprintStoriesAction(sprintId: string, orderedIds: string[]) {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
||||
if (hasDuplicateIds(orderedIds)) return { error: 'Ongeldige Sprint Backlog-volgorde' }
|
||||
|
||||
const sprint = await prisma.sprint.findFirst({
|
||||
where: { id: sprintId, product: productAccessFilter(session.userId) },
|
||||
})
|
||||
if (!sprint) return { error: 'Sprint niet gevonden' }
|
||||
|
||||
const stories = await prisma.story.findMany({
|
||||
where: { id: { in: orderedIds }, sprint_id: sprintId, product_id: sprint.product_id },
|
||||
select: { id: true },
|
||||
})
|
||||
if (stories.length !== orderedIds.length) return { error: 'Ongeldige Sprint Backlog-volgorde' }
|
||||
|
||||
await prisma.$transaction(
|
||||
orderedIds.map((id, i) =>
|
||||
prisma.story.update({ where: { id }, data: { sort_order: i + 1.0 } })
|
||||
)
|
||||
)
|
||||
|
||||
revalidatePath(`/products/${sprint.product_id}/sprint`)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function completeSprintAction(
|
||||
sprintId: string,
|
||||
|
|
@ -378,7 +728,7 @@ export async function createSprintWithPbisAction(input: {
|
|||
}),
|
||||
)
|
||||
|
||||
await setActiveSprintCookie(parsed.data.productId, sprint.id)
|
||||
await setActiveSprintInSettings(session.userId, parsed.data.productId, sprint.id)
|
||||
revalidatePath(`/products/${parsed.data.productId}`, 'layout')
|
||||
return { success: true, sprintId: sprint.id }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { prisma } from '@/lib/prisma'
|
|||
import { SessionData, sessionOptions } from '@/lib/session'
|
||||
import { getAccessibleProduct, productAccessFilter } from '@/lib/product-access'
|
||||
import { requireProductWriter } from '@/lib/auth'
|
||||
import { isValidCode, normalizeCode } from '@/lib/code'
|
||||
import { isValidCode, normalizeCode, parseCodeNumber } from '@/lib/code'
|
||||
import { createWithCodeRetry, generateNextStoryCode } from '@/lib/code-server'
|
||||
import { createStorySchema, updateStorySchema } from '@/lib/schemas/story'
|
||||
import { enforceUserRateLimit } from '@/lib/rate-limit'
|
||||
|
|
@ -78,12 +78,6 @@ export async function createStoryAction(_prevState: unknown, formData: FormData)
|
|||
}
|
||||
}
|
||||
|
||||
const last = await prisma.story.findFirst({
|
||||
where: { pbi_id: parsed.data.pbiId, priority: parsed.data.priority },
|
||||
orderBy: { sort_order: 'desc' },
|
||||
})
|
||||
const sort_order = (last?.sort_order ?? 0) + 1.0
|
||||
|
||||
const insert = (code: string) =>
|
||||
prisma.story.create({
|
||||
data: {
|
||||
|
|
@ -94,7 +88,7 @@ export async function createStoryAction(_prevState: unknown, formData: FormData)
|
|||
description: parsed.data.description ?? null,
|
||||
acceptance_criteria: parsed.data.acceptance_criteria ?? null,
|
||||
priority: parsed.data.priority,
|
||||
sort_order,
|
||||
sort_order: parseCodeNumber(code),
|
||||
status: 'OPEN',
|
||||
},
|
||||
})
|
||||
|
|
@ -167,7 +161,7 @@ export async function updateStoryAction(_prevState: unknown, formData: FormData)
|
|||
await prisma.story.update({
|
||||
where: { id: parsed.data.id },
|
||||
data: {
|
||||
...(code ? { code } : {}),
|
||||
...(code ? { code, sort_order: parseCodeNumber(code) } : {}),
|
||||
title: parsed.data.title,
|
||||
description: parsed.data.description ?? null,
|
||||
acceptance_criteria: parsed.data.acceptance_criteria ?? null,
|
||||
|
|
@ -363,43 +357,3 @@ export async function claimAllUnassignedInActiveSprintAction(productId: string)
|
|||
return { success: true, count: result.count }
|
||||
}
|
||||
|
||||
export async function reorderStoriesAction(
|
||||
pbiId: string,
|
||||
productId: string,
|
||||
orderedIds: string[],
|
||||
newPriority?: number
|
||||
) {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
||||
if (hasDuplicateIds(orderedIds)) return { error: 'Ongeldige story-volgorde' }
|
||||
if (newPriority !== undefined && (!Number.isInteger(newPriority) || newPriority < 1 || newPriority > 4)) {
|
||||
return { error: 'Ongeldige prioriteit' }
|
||||
}
|
||||
|
||||
const pbi = await prisma.pbi.findFirst({
|
||||
where: { id: pbiId, product: productAccessFilter(session.userId) },
|
||||
})
|
||||
if (!pbi) return { error: 'PBI niet gevonden' }
|
||||
|
||||
const stories = await prisma.story.findMany({
|
||||
where: { id: { in: orderedIds }, pbi_id: pbiId, product_id: pbi.product_id },
|
||||
select: { id: true },
|
||||
})
|
||||
if (stories.length !== orderedIds.length) return { error: 'Ongeldige story-volgorde' }
|
||||
|
||||
await prisma.$transaction(
|
||||
orderedIds.map((id, i) =>
|
||||
prisma.story.update({
|
||||
where: { id },
|
||||
data: {
|
||||
sort_order: i + 1.0,
|
||||
...(newPriority !== undefined ? { priority: newPriority } : {}),
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
revalidatePath(`/products/${pbi.product_id}`)
|
||||
return { success: true }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { productAccessFilter } from '@/lib/product-access'
|
|||
import { requireProductWriter } from '@/lib/auth'
|
||||
import { taskSchema as sharedTaskSchema, type TaskInput } from '@/lib/schemas/task'
|
||||
import { propagateStatusUpwards } from '@/lib/tasks-status-update'
|
||||
import { normalizeCode } from '@/lib/code'
|
||||
import { normalizeCode, parseCodeNumber } from '@/lib/code'
|
||||
import { createWithCodeRetry, generateNextTaskCode, isCodeUniqueConflict } from '@/lib/code-server'
|
||||
import { enforceUserRateLimit } from '@/lib/rate-limit'
|
||||
|
||||
|
|
@ -80,6 +80,7 @@ export async function saveTask(
|
|||
description: description ?? null,
|
||||
implementation_plan: implementation_plan ?? null,
|
||||
priority,
|
||||
...(inputCode ? { code: inputCode, sort_order: parseCodeNumber(inputCode) } : {}),
|
||||
},
|
||||
select: { id: true, title: true, status: true },
|
||||
})
|
||||
|
|
@ -106,15 +107,8 @@ export async function saveTask(
|
|||
})
|
||||
if (!story) return { ok: false, code: 403, error: 'forbidden' }
|
||||
|
||||
const last = await prisma.task.findFirst({
|
||||
where: { story_id: context.storyId },
|
||||
orderBy: { sort_order: 'desc' },
|
||||
select: { sort_order: true },
|
||||
})
|
||||
|
||||
const productId = story.product_id
|
||||
const sprintId = story.sprint_id ?? null
|
||||
const sortOrder = (last?.sort_order ?? 0) + 1.0
|
||||
const storyId = context.storyId
|
||||
|
||||
const task = await createWithCodeRetry(
|
||||
|
|
@ -130,7 +124,7 @@ export async function saveTask(
|
|||
description: description ?? null,
|
||||
implementation_plan: implementation_plan ?? null,
|
||||
priority,
|
||||
sort_order: sortOrder,
|
||||
sort_order: parseCodeNumber(code),
|
||||
status: 'TO_DO',
|
||||
},
|
||||
select: { id: true, title: true, status: true },
|
||||
|
|
@ -207,11 +201,6 @@ export async function createTaskAction(_prevState: unknown, formData: FormData)
|
|||
})
|
||||
if (!story) return { error: 'Story niet gevonden' }
|
||||
|
||||
const last = await prisma.task.findFirst({
|
||||
where: { story_id: storyId },
|
||||
orderBy: { sort_order: 'desc' },
|
||||
})
|
||||
|
||||
const productId = story.product_id
|
||||
const task = await createWithCodeRetry(
|
||||
() => generateNextTaskCode(productId),
|
||||
|
|
@ -225,7 +214,7 @@ export async function createTaskAction(_prevState: unknown, formData: FormData)
|
|||
title: parsed.data.title,
|
||||
description: parsed.data.description ?? null,
|
||||
priority: parsed.data.priority,
|
||||
sort_order: (last?.sort_order ?? 0) + 1.0,
|
||||
sort_order: parseCodeNumber(code),
|
||||
status: 'TO_DO',
|
||||
},
|
||||
}),
|
||||
|
|
@ -333,22 +322,3 @@ export async function updateTaskPlanAction(taskId: string, productId: string, im
|
|||
return { success: true }
|
||||
}
|
||||
|
||||
export async function reorderTasksAction(storyId: string, orderedIds: string[]) {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
||||
|
||||
const story = await prisma.story.findFirst({
|
||||
where: { id: storyId, product: productAccessFilter(session.userId) },
|
||||
})
|
||||
if (!story) return { error: 'Story niet gevonden' }
|
||||
|
||||
await prisma.$transaction(
|
||||
orderedIds.map((id, i) =>
|
||||
prisma.task.update({ where: { id }, data: { sort_order: i + 1.0 } })
|
||||
)
|
||||
)
|
||||
|
||||
revalidatePath(`/products/${story.product_id}/sprint/planning`)
|
||||
return { success: true }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { prisma } from '@/lib/prisma'
|
|||
import { SessionData, sessionOptions } from '@/lib/session'
|
||||
import { enforceUserRateLimit } from '@/lib/rate-limit'
|
||||
import { ACTIVE_JOB_STATUSES } from '@/lib/job-status'
|
||||
import { getJobConfigSnapshot } from '@/lib/job-config-snapshot'
|
||||
|
||||
async function getSession() {
|
||||
return getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||
|
|
@ -56,6 +57,8 @@ export async function createUserQuestionAction(
|
|||
})
|
||||
if (existing) return { error: 'Er loopt al een actieve PLAN_CHAT voor dit idee', code: 409 }
|
||||
|
||||
const snapshot = await getJobConfigSnapshot({ kind: 'PLAN_CHAT', productId: idea.product_id })
|
||||
|
||||
const [uq, job] = await prisma.$transaction([
|
||||
prisma.userQuestion.create({
|
||||
data: {
|
||||
|
|
@ -71,6 +74,7 @@ export async function createUserQuestionAction(
|
|||
idea_id: parsed.data.ideaId,
|
||||
kind: 'PLAN_CHAT',
|
||||
status: 'QUEUED',
|
||||
...snapshot,
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
|
|
|||
62
actions/user-settings.ts
Normal file
62
actions/user-settings.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
'use server'
|
||||
|
||||
import { cookies } from 'next/headers'
|
||||
import { getIronSession } from 'iron-session'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { SessionData, sessionOptions } from '@/lib/session'
|
||||
import {
|
||||
UserSettingsSchema,
|
||||
mergeSettings,
|
||||
parseUserSettings,
|
||||
type UserSettings,
|
||||
} from '@/lib/user-settings'
|
||||
|
||||
async function getSession() {
|
||||
return getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||
}
|
||||
|
||||
export type UpdateUserSettingsResult =
|
||||
| { success: true; settings: UserSettings }
|
||||
| { error: string; code: 401 | 403 | 422; fieldErrors?: Record<string, string[]> }
|
||||
|
||||
export async function updateUserSettingsAction(
|
||||
patch: Partial<UserSettings>,
|
||||
): Promise<UpdateUserSettingsResult> {
|
||||
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 = UserSettingsSchema.partial().safeParse(patch)
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
error: 'Ongeldige settings-patch',
|
||||
code: 422,
|
||||
fieldErrors: parsed.error.flatten().fieldErrors as Record<string, string[]>,
|
||||
}
|
||||
}
|
||||
|
||||
const merged = await prisma.$transaction(async (tx) => {
|
||||
const user = await tx.user.findUnique({
|
||||
where: { id: session.userId! },
|
||||
select: { settings: true },
|
||||
})
|
||||
const current = parseUserSettings(user?.settings)
|
||||
const next = mergeSettings(current, parsed.data)
|
||||
await tx.user.update({
|
||||
where: { id: session.userId! },
|
||||
data: { settings: next as unknown as Prisma.InputJsonValue },
|
||||
})
|
||||
return next
|
||||
})
|
||||
|
||||
await prisma.$executeRaw`
|
||||
SELECT pg_notify('scrum4me_changes', ${JSON.stringify({
|
||||
kind: 'user_settings',
|
||||
userId: session.userId,
|
||||
patch: parsed.data,
|
||||
})}::text)
|
||||
`
|
||||
|
||||
return { success: true, settings: merged }
|
||||
}
|
||||
|
|
@ -21,6 +21,10 @@ export default async function AdminJobsPage() {
|
|||
output_tokens: true,
|
||||
cache_read_tokens: true,
|
||||
cache_write_tokens: true,
|
||||
actual_thinking_tokens: true,
|
||||
requested_model: true,
|
||||
requested_thinking_budget: true,
|
||||
requested_permission_mode: true,
|
||||
user: { select: { username: true } },
|
||||
product: { select: { name: true } },
|
||||
},
|
||||
|
|
@ -36,7 +40,8 @@ export default async function AdminJobsPage() {
|
|||
(job.input_tokens ?? 0) * Number(p.input_price_per_1m) / 1_000_000 +
|
||||
(job.output_tokens ?? 0) * Number(p.output_price_per_1m) / 1_000_000 +
|
||||
(job.cache_read_tokens ?? 0) * Number(p.cache_read_price_per_1m) / 1_000_000 +
|
||||
(job.cache_write_tokens ?? 0) * Number(p.cache_write_price_per_1m) / 1_000_000
|
||||
(job.cache_write_tokens ?? 0) * Number(p.cache_write_price_per_1m) / 1_000_000 +
|
||||
(job.actual_thinking_tokens ?? 0) * Number(p.input_price_per_1m) / 1_000_000
|
||||
return { ...job, cost_usd: cost }
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto w-full animate-pulse">
|
||||
<div className="h-6 w-32 bg-border rounded mb-6" />
|
||||
<div className="p-6 max-w-4xl mx-auto w-full">
|
||||
<Skeleton className="h-6 w-32 mb-6" />
|
||||
<div className="grid gap-3">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="h-20 bg-border/50 rounded-xl" />
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-20 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,56 +1,63 @@
|
|||
import { cookies } from 'next/headers'
|
||||
import { getIronSession } from 'iron-session'
|
||||
|
||||
import { SessionData, sessionOptions } from '@/lib/session'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { productAccessFilter } from '@/lib/product-access'
|
||||
import Link from 'next/link'
|
||||
import { ProductList } from '@/components/dashboard/product-list'
|
||||
import { NewProductButton } from '@/components/dashboard/new-product-button'
|
||||
import { ProductsTable } from '@/components/dashboard/products-table'
|
||||
import { ProductsTableToolbar } from '@/components/dashboard/products-table-toolbar'
|
||||
import type { ProductsTableRow } from '@/components/dashboard/products-table'
|
||||
|
||||
interface Props {
|
||||
searchParams: Promise<{ archived?: string }>
|
||||
}
|
||||
|
||||
export default async function DashboardPage({ searchParams }: Props) {
|
||||
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||
const { archived } = await searchParams
|
||||
const showArchived = archived === '1'
|
||||
export default async function DashboardPage() {
|
||||
const session = await getIronSession<SessionData>(
|
||||
await cookies(),
|
||||
sessionOptions,
|
||||
)
|
||||
|
||||
// Tabel-filter (archived) leeft client-side in useUserSettingsStore;
|
||||
// server haalt alle toegankelijke producten + #PBI's in één query
|
||||
// (geen N+1) en geeft alles door aan ProductsTable.
|
||||
const [products, user] = await Promise.all([
|
||||
prisma.product.findMany({
|
||||
where: { archived: showArchived, ...productAccessFilter(session.userId) },
|
||||
orderBy: { created_at: 'desc' },
|
||||
where: productAccessFilter(session.userId ?? ''),
|
||||
include: { _count: { select: { pbis: true } } },
|
||||
orderBy: { updated_at: 'desc' },
|
||||
}),
|
||||
session.userId
|
||||
? prisma.user.findUnique({ where: { id: session.userId }, select: { active_product_id: true } })
|
||||
? prisma.user.findUnique({
|
||||
where: { id: session.userId },
|
||||
select: { active_product_id: true },
|
||||
})
|
||||
: null,
|
||||
])
|
||||
|
||||
const rows: ProductsTableRow[] = products.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
code: p.code,
|
||||
description: p.description,
|
||||
repo_url: p.repo_url,
|
||||
definition_of_done: p.definition_of_done,
|
||||
auto_pr: p.auto_pr,
|
||||
archived: p.archived,
|
||||
pbiCount: p._count.pbis,
|
||||
updated_at: p.updated_at,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto w-full">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-medium text-foreground">
|
||||
{showArchived ? 'Gearchiveerde producten' : 'Mijn Producten'}
|
||||
</h1>
|
||||
{showArchived ? (
|
||||
<Link href="/dashboard" className="text-xs text-primary hover:underline">
|
||||
← Actief
|
||||
</Link>
|
||||
) : (
|
||||
<Link href="/dashboard?archived=1" className="text-xs text-muted-foreground hover:text-foreground">
|
||||
Toon gearchiveerd
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{!session.isDemo && !showArchived && <NewProductButton />}
|
||||
<div className="p-6 max-w-6xl mx-auto w-full space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h1 className="text-xl font-medium text-foreground">Producten</h1>
|
||||
{!session.isDemo && <NewProductButton />}
|
||||
</div>
|
||||
|
||||
<ProductList
|
||||
products={products}
|
||||
isDemo={session.isDemo ?? false}
|
||||
showArchived={showArchived}
|
||||
<ProductsTableToolbar />
|
||||
|
||||
<ProductsTable
|
||||
products={rows}
|
||||
activeProductId={user?.active_product_id ?? null}
|
||||
isDemo={session.isDemo ?? false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { productAccessFilter } from '@/lib/product-access'
|
|||
import { ideaToDto } from '@/lib/idea-dto'
|
||||
import { IdeaDetailLayout } from '@/components/ideas/idea-detail-layout'
|
||||
import { loadIdeaSyncData } from './sync-tab-server'
|
||||
import type { ReviewLog } from '@/components/ideas/review-log-viewer'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
|
|
@ -26,10 +27,25 @@ export default async function IdeaDetailPage({ params, searchParams }: PageProps
|
|||
// M12: strikt user_id-only — 404 (niet 403) voor andere users (anti-enum).
|
||||
const idea = await prisma.idea.findFirst({
|
||||
where: { id, user_id: session.userId },
|
||||
include: {
|
||||
select: {
|
||||
id: true,
|
||||
user_id: true,
|
||||
product_id: true,
|
||||
code: true,
|
||||
title: true,
|
||||
description: true,
|
||||
status: true,
|
||||
pbi_id: true,
|
||||
archived: true,
|
||||
grill_md: true,
|
||||
plan_md: true,
|
||||
plan_review_log: true,
|
||||
reviewed_at: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
product: { select: { id: true, name: true, repo_url: true } },
|
||||
pbi: { select: { id: true, code: true, title: true } },
|
||||
secondary_products: { include: { product: { select: { id: true, name: true } } } },
|
||||
secondary_products: { select: { id: true, product_id: true, product: { select: { id: true, name: true } } } },
|
||||
},
|
||||
})
|
||||
if (!idea) notFound()
|
||||
|
|
@ -73,7 +89,7 @@ export default async function IdeaDetailPage({ params, searchParams }: PageProps
|
|||
|
||||
const userQuestionsRaw = await prisma.userQuestion.findMany({
|
||||
where: { idea_id: id },
|
||||
orderBy: { created_at: 'asc' },
|
||||
orderBy: { created_at: 'desc' },
|
||||
take: 100,
|
||||
select: { id: true, question: true, answer: true, status: true, created_at: true },
|
||||
})
|
||||
|
|
@ -91,6 +107,7 @@ export default async function IdeaDetailPage({ params, searchParams }: PageProps
|
|||
idea={ideaToDto(idea)}
|
||||
grill_md={idea.grill_md}
|
||||
plan_md={idea.plan_md}
|
||||
plan_review_log={(idea.plan_review_log as ReviewLog | null) ?? null}
|
||||
products={products}
|
||||
logs={logs.map((l) => ({
|
||||
id: l.id,
|
||||
|
|
|
|||
|
|
@ -32,6 +32,16 @@ export default async function IdeasPage() {
|
|||
select: { id: true, name: true, repo_url: true },
|
||||
})
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.userId },
|
||||
select: { active_product_id: true },
|
||||
})
|
||||
|
||||
const activeProductId =
|
||||
user?.active_product_id && products.some((p) => p.id === user.active_product_id)
|
||||
? user.active_product_id
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto w-full">
|
||||
<header className="mb-6 flex items-baseline justify-between">
|
||||
|
|
@ -45,6 +55,7 @@ export default async function IdeasPage() {
|
|||
ideas={ideas.map((i) => ideaToDto(i))}
|
||||
products={products}
|
||||
isDemo={session.isDemo ?? false}
|
||||
activeProductId={activeProductId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
272
app/(app)/insights/components/cost-analysis.tsx
Normal file
272
app/(app)/insights/components/cost-analysis.tsx
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
'use client'
|
||||
|
||||
import { useTransition } from 'react'
|
||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type {
|
||||
Period,
|
||||
CostKpi,
|
||||
CostByDayRow,
|
||||
CostByModelRow,
|
||||
CostByKindRow,
|
||||
CacheEfficiency,
|
||||
} from '@/lib/insights/cost-analysis'
|
||||
|
||||
interface Props {
|
||||
period: Period
|
||||
kpi: CostKpi
|
||||
byDay: CostByDayRow[]
|
||||
byModel: CostByModelRow[]
|
||||
byKind: CostByKindRow[]
|
||||
cache: CacheEfficiency
|
||||
}
|
||||
|
||||
const PERIOD_LABELS: Record<Period, string> = {
|
||||
'7d': 'Laatste 7 dagen',
|
||||
'30d': 'Laatste 30 dagen',
|
||||
'90d': 'Laatste 90 dagen',
|
||||
mtd: 'Deze maand',
|
||||
}
|
||||
|
||||
const KIND_LABELS: Record<string, string> = {
|
||||
TASK_IMPLEMENTATION: 'Task impl.',
|
||||
IDEA_GRILL: 'Idea grill',
|
||||
IDEA_MAKE_PLAN: 'Idea plan',
|
||||
PLAN_CHAT: 'Plan chat',
|
||||
SPRINT_IMPLEMENTATION: 'Sprint impl.',
|
||||
}
|
||||
|
||||
function fmtUsd(n: number, decimals = 2): string {
|
||||
return '$' + n.toFixed(decimals)
|
||||
}
|
||||
|
||||
function shortenModel(modelId: string): string {
|
||||
return modelId.replace(/^claude-/, '')
|
||||
}
|
||||
|
||||
export function CostAnalysisCard({ period, kpi, byDay, byModel, byKind, cache }: Props) {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
function handlePeriodChange(value: string | null) {
|
||||
if (value === null) return
|
||||
startTransition(() => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.set('period', value)
|
||||
router.replace(`${pathname}?${params.toString()}`)
|
||||
})
|
||||
}
|
||||
|
||||
const periodSelector = (
|
||||
<Select value={period} onValueChange={handlePeriodChange}>
|
||||
<SelectTrigger className="w-44" disabled={isPending}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(PERIOD_LABELS) as Period[]).map(p => (
|
||||
<SelectItem key={p} value={p}>
|
||||
{PERIOD_LABELS[p]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
|
||||
if (kpi.jobCount === 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<p className="text-muted-foreground text-sm">Geen jobs in deze periode.</p>
|
||||
{periodSelector}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const cacheData = [
|
||||
{ name: 'Cache', value: cache.cacheReadTokens },
|
||||
{ name: 'Uncached input', value: cache.uncachedInputTokens },
|
||||
]
|
||||
const cacheColors = ['var(--status-done)', 'var(--muted-foreground)']
|
||||
|
||||
const modelData = byModel.map(m => ({ ...m, label: shortenModel(m.modelId) }))
|
||||
const kindData = byKind.map(k => ({ ...k, label: KIND_LABELS[k.kind] ?? k.kind }))
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* KPI strip + period selector */}
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="flex gap-6 flex-wrap">
|
||||
<div>
|
||||
<div className="text-2xl font-semibold text-foreground">{fmtUsd(kpi.totalCostUsd)}</div>
|
||||
<div className="text-xs text-muted-foreground">Totaal kosten</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-semibold text-foreground">{fmtUsd(kpi.avgPerDayUsd)}</div>
|
||||
<div className="text-xs text-muted-foreground">Gem. per dag</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-semibold text-status-done">
|
||||
{fmtUsd(kpi.cacheSavingsUsd)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Cache-besparing</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-semibold text-foreground">
|
||||
{kpi.topModelId ? fmtUsd(kpi.topModelCostUsd) : '—'}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Top model{kpi.topModelId ? `: ${shortenModel(kpi.topModelId)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{periodSelector}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Daily cost */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-muted-foreground">Kosten per dag</div>
|
||||
{byDay.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">Geen data</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={byDay}>
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
tick={{ fontSize: 11 }}
|
||||
stroke="var(--muted-foreground)"
|
||||
tickFormatter={v => (v as string).slice(5)}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11 }}
|
||||
stroke="var(--muted-foreground)"
|
||||
tickFormatter={v => fmtUsd(v as number, 2)}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={value => [fmtUsd(Number(value), 4), 'Kosten']}
|
||||
/>
|
||||
<Bar dataKey="costUsd" fill="var(--chart-1)" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Per model */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-muted-foreground">Kosten per model</div>
|
||||
{modelData.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">Geen data</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={modelData} layout="vertical">
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fontSize: 11 }}
|
||||
stroke="var(--muted-foreground)"
|
||||
tickFormatter={v => fmtUsd(v as number, 2)}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 11 }}
|
||||
stroke="var(--muted-foreground)"
|
||||
width={120}
|
||||
/>
|
||||
<Tooltip formatter={value => [fmtUsd(Number(value), 4), 'Kosten']} />
|
||||
<Bar dataKey="costUsd" fill="var(--chart-2)" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Per kind */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-muted-foreground">Kosten per job-kind</div>
|
||||
{kindData.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">Geen data</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={kindData} layout="vertical">
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fontSize: 11 }}
|
||||
stroke="var(--muted-foreground)"
|
||||
tickFormatter={v => fmtUsd(v as number, 2)}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 11 }}
|
||||
stroke="var(--muted-foreground)"
|
||||
width={120}
|
||||
/>
|
||||
<Tooltip formatter={value => [fmtUsd(Number(value), 4), 'Kosten']} />
|
||||
<Bar dataKey="costUsd" fill="var(--chart-3)" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cache efficiency */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-muted-foreground">Cache efficiency</div>
|
||||
{cache.cacheReadTokens + cache.uncachedInputTokens === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">Geen data</p>
|
||||
) : (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={cacheData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
innerRadius={40}
|
||||
outerRadius={70}
|
||||
>
|
||||
{cacheData.map((_, i) => (
|
||||
<Cell key={i} fill={cacheColors[i]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value, name) => [
|
||||
Number(value).toLocaleString() + ' tokens',
|
||||
String(name),
|
||||
]}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<p className="text-sm text-foreground text-center">
|
||||
<span className="font-semibold">{(cache.cacheHitRatio * 100).toFixed(1)}%</span>{' '}
|
||||
cache hit ·{' '}
|
||||
<span className="text-status-done font-semibold">
|
||||
{fmtUsd(cache.savingsUsd)}
|
||||
</span>{' '}
|
||||
bespaard
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -8,6 +8,14 @@ import { getSprintStatusBreakdown } from '@/lib/insights/sprint-status'
|
|||
import { getVerifyResultStats, getAlignmentTrend } from '@/lib/insights/verify-stats'
|
||||
import { getJobsPerDay } from '@/lib/insights/agent-throughput'
|
||||
import { getTokenStats } from '@/lib/insights/token-stats'
|
||||
import {
|
||||
getCostKpi,
|
||||
getCostByDay,
|
||||
getCostByModel,
|
||||
getCostByKind,
|
||||
getCacheEfficiency,
|
||||
type Period,
|
||||
} from '@/lib/insights/cost-analysis'
|
||||
import { getVelocity } from '@/lib/insights/velocity'
|
||||
import { getBacklogHealth } from '@/lib/insights/backlog-health'
|
||||
import { SprintInfoStrip } from './components/sprint-info-strip'
|
||||
|
|
@ -16,6 +24,7 @@ import { SprintStatusDonut } from './components/sprint-status-donut'
|
|||
import { PlanQualityCard } from './components/plan-quality'
|
||||
import { AlignmentTrend } from './components/alignment-trend'
|
||||
import { AgentThroughputCard } from './components/agent-throughput'
|
||||
import { CostAnalysisCard } from './components/cost-analysis'
|
||||
import { TokenUsageCard } from './components/token-usage'
|
||||
import { VelocityChart } from './components/velocity-chart'
|
||||
import { BacklogHealthCard } from './components/backlog-health'
|
||||
|
|
@ -24,7 +33,13 @@ const DAY_MS = 86_400_000
|
|||
const ASSUMED_SPRINT_DAYS = 14
|
||||
|
||||
interface InsightsPageProps {
|
||||
searchParams: Promise<{ product?: string }>
|
||||
searchParams: Promise<{ product?: string; period?: string }>
|
||||
}
|
||||
|
||||
const VALID_PERIODS = ['7d', '30d', '90d', 'mtd'] as const
|
||||
|
||||
function parsePeriod(raw: string | undefined): Period {
|
||||
return (VALID_PERIODS as readonly string[]).includes(raw ?? '') ? (raw as Period) : '30d'
|
||||
}
|
||||
|
||||
function MissingDatesNotice({ productId, productName }: { productId: string; productName: string }) {
|
||||
|
|
@ -41,7 +56,8 @@ function MissingDatesNotice({ productId, productName }: { productId: string; pro
|
|||
export default async function InsightsPage({ searchParams }: InsightsPageProps) {
|
||||
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||
const userId = session.userId!
|
||||
const { product: filterProductId } = await searchParams
|
||||
const { product: filterProductId, period: rawPeriod } = await searchParams
|
||||
const period = parsePeriod(rawPeriod)
|
||||
|
||||
const [
|
||||
burndownSprints,
|
||||
|
|
@ -53,6 +69,11 @@ export default async function InsightsPage({ searchParams }: InsightsPageProps)
|
|||
jobsPerDay,
|
||||
velocity,
|
||||
backlogHealth,
|
||||
costKpi,
|
||||
costByDay,
|
||||
costByModel,
|
||||
costByKind,
|
||||
cacheEff,
|
||||
] = await Promise.all([
|
||||
getBurndownData(userId),
|
||||
getSprintStatusBreakdown(userId),
|
||||
|
|
@ -77,6 +98,11 @@ export default async function InsightsPage({ searchParams }: InsightsPageProps)
|
|||
getJobsPerDay(userId, 14, filterProductId),
|
||||
getVelocity(userId, 5),
|
||||
getBacklogHealth(userId),
|
||||
getCostKpi(userId, period),
|
||||
getCostByDay(userId, period),
|
||||
getCostByModel(userId, period),
|
||||
getCostByKind(userId, period),
|
||||
getCacheEfficiency(userId, period),
|
||||
])
|
||||
|
||||
const activeSprintId = activeSprints.find(s => s.product.id === filterProductId)?.id ?? ''
|
||||
|
|
@ -134,6 +160,19 @@ export default async function InsightsPage({ searchParams }: InsightsPageProps)
|
|||
)}
|
||||
</section>
|
||||
|
||||
{/* Cost analyse */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-medium text-foreground">Cost analyse</h2>
|
||||
<CostAnalysisCard
|
||||
period={period}
|
||||
kpi={costKpi}
|
||||
byDay={costByDay}
|
||||
byModel={costByModel}
|
||||
byKind={costByKind}
|
||||
cache={cacheEff}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Plan-quality */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-medium text-foreground">Plan-quality</h2>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { redirect } from 'next/navigation'
|
|||
import { getSession } from '@/lib/auth'
|
||||
import { fetchJobsPageData } from '@/actions/jobs-page'
|
||||
import JobsBoard from '@/components/jobs/jobs-board'
|
||||
import JobsTimeFilter from '@/components/jobs/jobs-time-filter'
|
||||
|
||||
export const metadata = { title: 'Jobs — Scrum4Me' }
|
||||
|
||||
|
|
@ -14,11 +15,12 @@ export default async function JobsPage() {
|
|||
|
||||
return (
|
||||
<main className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="px-6 py-4 border-b shrink-0 flex items-center gap-3">
|
||||
<div className="px-6 py-4 border-b shrink-0 flex items-center justify-between gap-3">
|
||||
<h1 className="text-lg font-semibold">Jobs</h1>
|
||||
<JobsTimeFilter />
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<JobsBoard initialActiveJobs={data.activeJobs} initialDoneJobs={data.doneJobs} />
|
||||
<JobsBoard initialActiveJobs={data.activeJobs} initialDoneJobs={data.doneJobs} isDemo={session.isDemo ?? false} />
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import { MinWidthBanner } from '@/components/shared/min-width-banner'
|
|||
import { StatusBar } from '@/components/shared/status-bar'
|
||||
import { SoloRealtimeBridge } from '@/components/solo/realtime-bridge'
|
||||
import { NotificationsBridge } from '@/components/notifications/notifications-bridge'
|
||||
import { UserSettingsBridge } from '@/components/shared/user-settings-bridge'
|
||||
import { parseUserSettings } from '@/lib/user-settings'
|
||||
import { AlertToast } from '@/components/shared/alert-toast'
|
||||
import { Suspense } from 'react'
|
||||
|
||||
|
|
@ -17,7 +19,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
|||
const [user, userRoles, accessibleProducts] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: session.userId },
|
||||
select: { username: true, email: true, active_product_id: true, min_quota_pct: true },
|
||||
select: { username: true, email: true, active_product_id: true, min_quota_pct: true, settings: true },
|
||||
}),
|
||||
prisma.userRole.findMany({
|
||||
where: { user_id: session.userId },
|
||||
|
|
@ -37,7 +39,6 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
|||
|
||||
// Resolve active product — clear stale reference if archived or inaccessible
|
||||
let activeProduct: { id: string; name: string } | null = null
|
||||
let activeSprintId: string | null = null
|
||||
let hasActiveSprint = false
|
||||
if (user.active_product_id) {
|
||||
const product = await prisma.product.findFirst({
|
||||
|
|
@ -46,8 +47,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
|||
})
|
||||
if (product) {
|
||||
activeProduct = product
|
||||
const resolved = await resolveActiveSprint(product.id)
|
||||
activeSprintId = resolved?.id ?? null
|
||||
const resolved = await resolveActiveSprint(product.id, session.userId)
|
||||
hasActiveSprint = !!resolved
|
||||
} else {
|
||||
await prisma.user.update({
|
||||
|
|
@ -72,7 +72,6 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
|||
activeProduct={activeProduct}
|
||||
products={accessibleProducts}
|
||||
hasActiveSprint={hasActiveSprint}
|
||||
activeSprintId={activeSprintId}
|
||||
minQuotaPct={user.min_quota_pct}
|
||||
/>
|
||||
<MinWidthBanner />
|
||||
|
|
@ -82,6 +81,10 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
|||
<StatusBar />
|
||||
<SoloRealtimeBridge productId={activeProduct?.id ?? null} />
|
||||
<NotificationsBridge userId={session.userId} />
|
||||
<UserSettingsBridge
|
||||
initial={parseUserSettings(user.settings)}
|
||||
isDemo={session.isDemo ?? false}
|
||||
/>
|
||||
<Suspense>
|
||||
<AlertToast />
|
||||
</Suspense>
|
||||
|
|
|
|||
159
app/(app)/products/[id]/docs/[folder]/[slug]/page.tsx
Normal file
159
app/(app)/products/[id]/docs/[folder]/[slug]/page.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
// Product Doc detail-page (PBI-96 / T-1069). Server-component.
|
||||
//
|
||||
// Loadt + scope-checked de doc, parseert frontmatter, en switcht tussen
|
||||
// viewer en editor via `?edit=1`. Edit-knop verborgen bij disabled folder
|
||||
// (zie plan §C.4 + T-1071). Delete-knop blijft altijd zichtbaar (voor
|
||||
// cleanup van docs in disabled folder); wel DemoTooltip-wrapped.
|
||||
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft, Pencil } from 'lucide-react'
|
||||
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { productAccessFilter } from '@/lib/product-access'
|
||||
import { productDocFolderFromApi, productDocFolderToApi } from '@/lib/product-doc-folder'
|
||||
import { parseProductDocMd } from '@/lib/product-doc-parser'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FOLDER_LABELS } from '@/components/product-docs/product-docs-index'
|
||||
import { ProductDocViewer } from '@/components/product-docs/product-doc-viewer'
|
||||
import { ProductDocEditor } from '@/components/product-docs/product-doc-editor'
|
||||
import { DeleteProductDocButton } from '@/components/product-docs/delete-product-doc-button'
|
||||
import { DisabledFolderBanner } from '@/components/product-docs/disabled-folder-banner'
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string; folder: string; slug: string }>
|
||||
searchParams: Promise<{ edit?: string }>
|
||||
}
|
||||
|
||||
export default async function ProductDocDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: Props) {
|
||||
const { id, folder: folderApiParam, slug } = await params
|
||||
const { edit } = await searchParams
|
||||
const session = await getSession()
|
||||
if (!session.userId) redirect('/login')
|
||||
|
||||
const folderDb = productDocFolderFromApi(folderApiParam)
|
||||
if (!folderDb) notFound()
|
||||
|
||||
const folderApi = productDocFolderToApi(folderDb)
|
||||
const docsUrl = `/products/${id}/docs/${folderApi}`
|
||||
const docUrl = `${docsUrl}/${slug}`
|
||||
|
||||
// Scope: doc moet onder een toegankelijk product hangen
|
||||
const doc = await prisma.productDoc.findFirst({
|
||||
where: {
|
||||
product_id: id,
|
||||
folder: folderDb,
|
||||
slug,
|
||||
product: productAccessFilter(session.userId),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
content_md: true,
|
||||
status: true,
|
||||
updated_at: true,
|
||||
product: { select: { enabled_doc_folders: true } },
|
||||
},
|
||||
})
|
||||
if (!doc) notFound()
|
||||
|
||||
const isFolderEnabled = doc.product.enabled_doc_folders.includes(folderDb)
|
||||
const isDemo = session.isDemo ?? false
|
||||
const isEditMode = edit === '1'
|
||||
|
||||
const parsed = parseProductDocMd(doc.content_md)
|
||||
const label = FOLDER_LABELS[folderDb]
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto space-y-4">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Link
|
||||
href={`/products/${id}/docs`}
|
||||
className="hover:text-foreground"
|
||||
>
|
||||
Documentatie
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<Link href={docsUrl} className="hover:text-foreground">
|
||||
{label.title}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-foreground font-mono">{slug}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Link
|
||||
href={docsUrl}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-3" />
|
||||
Terug naar {label.title}
|
||||
</Link>
|
||||
{!isEditMode && (
|
||||
<div className="flex items-center gap-2">
|
||||
{isFolderEnabled && !isDemo && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
nativeButton={false}
|
||||
render={<Link href={`${docUrl}?edit=1`} />}
|
||||
>
|
||||
<Pencil className="size-3.5 mr-1" />
|
||||
Bewerken
|
||||
</Button>
|
||||
)}
|
||||
<DeleteProductDocButton
|
||||
docId={doc.id}
|
||||
docTitle={parsed.ok ? parsed.frontmatter.title : slug}
|
||||
redirectHref={docsUrl}
|
||||
isDemo={isDemo}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isFolderEnabled && <DisabledFolderBanner productId={id} />}
|
||||
|
||||
{isEditMode ? (
|
||||
<ProductDocEditor
|
||||
docId={doc.id}
|
||||
initialValue={doc.content_md}
|
||||
cancelHref={docUrl}
|
||||
/>
|
||||
) : parsed.ok ? (
|
||||
<ProductDocViewer
|
||||
title={parsed.frontmatter.title}
|
||||
status={parsed.frontmatter.status}
|
||||
body={parsed.body}
|
||||
audience={parsed.frontmatter.audience}
|
||||
applies_to={parsed.frontmatter.applies_to}
|
||||
lastUpdated={parsed.frontmatter.last_updated}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border border-status-blocked/30 bg-status-blocked/10 p-4 text-sm">
|
||||
<p className="font-medium text-status-blocked mb-2">
|
||||
Frontmatter is niet parseerbaar
|
||||
</p>
|
||||
<ul className="text-xs space-y-1 text-status-blocked">
|
||||
{parsed.errors.map((err, i) => (
|
||||
<li key={i}>
|
||||
{err.line ? `Regel ${err.line}: ` : ''}
|
||||
{err.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-xs text-muted-foreground mt-3">
|
||||
Klik ‘Bewerken’ om de fout te herstellen, of bekijk
|
||||
de raw inhoud hieronder.
|
||||
</p>
|
||||
<pre className="mt-3 rounded bg-surface-container p-3 overflow-x-auto text-xs">
|
||||
{doc.content_md}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
100
app/(app)/products/[id]/docs/[folder]/page.tsx
Normal file
100
app/(app)/products/[id]/docs/[folder]/page.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// Folder-listing page (PBI-96 / T-1068). Server-component.
|
||||
//
|
||||
// Valideert de folder-segment tegen ProductDocFolder-enum (404 anders).
|
||||
// Toont een tabel met alle docs in deze folder (sortering [slug ASC]).
|
||||
// "Nieuwe doc" knop wordt in T-1070 functioneel via een dialog; voor nu
|
||||
// is het een link naar `?new=1`. Disabled-folder banner komt in T-1071.
|
||||
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getAccessibleProduct } from '@/lib/product-access'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { productDocFolderFromApi } from '@/lib/product-doc-folder'
|
||||
import { FOLDER_LABELS } from '@/components/product-docs/product-docs-index'
|
||||
import {
|
||||
ProductDocsFolderList,
|
||||
type ProductDocListRow,
|
||||
} from '@/components/product-docs/product-docs-folder-list'
|
||||
import { NewProductDocDialog } from '@/components/product-docs/new-product-doc-dialog'
|
||||
import { DisabledFolderBanner } from '@/components/product-docs/disabled-folder-banner'
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string; folder: string }>
|
||||
}
|
||||
|
||||
export default async function ProductDocsFolderPage({ params }: Props) {
|
||||
const { id, folder: folderApiParam } = await params
|
||||
const session = await getSession()
|
||||
if (!session.userId) redirect('/login')
|
||||
|
||||
const folderDb = productDocFolderFromApi(folderApiParam)
|
||||
if (!folderDb) notFound()
|
||||
|
||||
const product = await getAccessibleProduct(id, session.userId)
|
||||
if (!product) notFound()
|
||||
|
||||
const docs = await prisma.productDoc.findMany({
|
||||
where: { product_id: id, folder: folderDb },
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
status: true,
|
||||
updated_at: true,
|
||||
},
|
||||
orderBy: [{ slug: 'asc' }],
|
||||
})
|
||||
|
||||
const rows: ProductDocListRow[] = docs.map((d) => ({
|
||||
id: d.id,
|
||||
slug: d.slug,
|
||||
title: d.title,
|
||||
status: d.status,
|
||||
updated_at: d.updated_at,
|
||||
}))
|
||||
|
||||
const label = FOLDER_LABELS[folderDb]
|
||||
const folderApi = folderApiParam.toLowerCase()
|
||||
const isFolderEnabled = product.enabled_doc_folders.includes(folderDb)
|
||||
const isDemo = session.isDemo ?? false
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Link
|
||||
href={`/products/${id}/docs`}
|
||||
className="inline-flex items-center gap-1 hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-3" />
|
||||
Documentatie
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-foreground font-medium">{label.title}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{label.title}</h1>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{label.description}
|
||||
</p>
|
||||
</div>
|
||||
{isFolderEnabled && (
|
||||
<NewProductDocDialog
|
||||
productId={id}
|
||||
enabledFolders={product.enabled_doc_folders}
|
||||
initialFolder={folderApi}
|
||||
isDemo={isDemo}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isFolderEnabled && <DisabledFolderBanner productId={id} />}
|
||||
|
||||
<ProductDocsFolderList productId={id} folderApi={folderApi} docs={rows} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
76
app/(app)/products/[id]/docs/page.tsx
Normal file
76
app/(app)/products/[id]/docs/page.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// Product Docs INDEX-pagina (PBI-96 / T-1067). Server-component.
|
||||
//
|
||||
// Loadt het product + de meest-recente 3 docs per enabled folder, en
|
||||
// rendert de grid via ProductDocsIndex. Disabled folders worden NIET
|
||||
// getoond op de INDEX maar blijven via directe URL bereikbaar (zie
|
||||
// plan §C.4 / T-1071 voor de banner-flow).
|
||||
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getAccessibleProduct } from '@/lib/product-access'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
ProductDocsIndex,
|
||||
} from '@/components/product-docs/product-docs-index'
|
||||
import type { ProductDocCardItem } from '@/components/product-docs/product-docs-folder-card'
|
||||
import type { ProductDocFolder } from '@prisma/client'
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
export default async function ProductDocsIndexPage({ params }: Props) {
|
||||
const { id } = await params
|
||||
const session = await getSession()
|
||||
if (!session.userId) redirect('/login')
|
||||
|
||||
const product = await getAccessibleProduct(id, session.userId)
|
||||
if (!product) notFound()
|
||||
|
||||
const enabledFolders = product.enabled_doc_folders
|
||||
|
||||
// Eén findMany, in-memory groeperen + slicen tot 3 per folder. Voorkomt
|
||||
// 8 separate queries; voor doc-aantallen tot ~100 is dit verwaarloosbaar.
|
||||
const recentDocs =
|
||||
enabledFolders.length === 0
|
||||
? []
|
||||
: await prisma.productDoc.findMany({
|
||||
where: { product_id: id, folder: { in: enabledFolders } },
|
||||
select: {
|
||||
id: true,
|
||||
folder: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
status: true,
|
||||
updated_at: true,
|
||||
},
|
||||
orderBy: [{ updated_at: 'desc' }],
|
||||
})
|
||||
|
||||
const docsByFolder: Partial<Record<ProductDocFolder, ProductDocCardItem[]>> = {}
|
||||
const totalByFolder: Partial<Record<ProductDocFolder, number>> = {}
|
||||
for (const doc of recentDocs) {
|
||||
totalByFolder[doc.folder] = (totalByFolder[doc.folder] ?? 0) + 1
|
||||
const bucket = docsByFolder[doc.folder] ?? []
|
||||
if (bucket.length < 3) {
|
||||
bucket.push({
|
||||
id: doc.id,
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
status: doc.status,
|
||||
updated_at: doc.updated_at,
|
||||
})
|
||||
docsByFolder[doc.folder] = bucket
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ProductDocsIndex
|
||||
productId={id}
|
||||
enabledFolders={enabledFolders}
|
||||
docsByFolder={docsByFolder}
|
||||
totalByFolder={totalByFolder}
|
||||
/>
|
||||
)
|
||||
}
|
||||
56
app/(app)/products/[id]/docs/settings/page.tsx
Normal file
56
app/(app)/products/[id]/docs/settings/page.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// Product Docs folder-settings page (PBI-96 / T-1072). Server-component.
|
||||
// Owner-only voor schrijven; ProductMember ziet read-only checkboxes.
|
||||
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getAccessibleProduct } from '@/lib/product-access'
|
||||
import { ProductDocFolderToggle } from '@/components/product-docs/product-doc-folder-toggle'
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
export default async function ProductDocsSettingsPage({ params }: Props) {
|
||||
const { id } = await params
|
||||
const session = await getSession()
|
||||
if (!session.userId) redirect('/login')
|
||||
|
||||
const product = await getAccessibleProduct(id, session.userId)
|
||||
if (!product) notFound()
|
||||
|
||||
const isOwner = product.user_id === session.userId
|
||||
const isDemo = session.isDemo ?? false
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-2xl mx-auto space-y-4">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Link
|
||||
href={`/products/${id}/docs`}
|
||||
className="inline-flex items-center gap-1 hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-3" />
|
||||
Documentatie
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-foreground font-medium">Folder-instellingen</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Folder-instellingen</h1>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Bepaal welke documentatie-folders zichtbaar zijn voor dit product.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ProductDocFolderToggle
|
||||
productId={id}
|
||||
initialEnabledFolders={product.enabled_doc_folders}
|
||||
isOwner={isOwner}
|
||||
isDemo={isDemo}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,34 +1 @@
|
|||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex flex-col h-full animate-pulse">
|
||||
{/* Header skeleton */}
|
||||
<div className="px-4 py-3 border-b border-border bg-surface-container-low shrink-0 flex items-center justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-4 w-32 bg-border rounded" />
|
||||
<div className="h-3 w-48 bg-border/60 rounded" />
|
||||
</div>
|
||||
<div className="h-7 w-24 bg-border rounded" />
|
||||
</div>
|
||||
|
||||
{/* Split pane skeleton */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left */}
|
||||
<div className="w-2/5 border-r border-border p-4 space-y-3">
|
||||
<div className="h-4 w-24 bg-border rounded" />
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<div key={i} className="h-8 bg-border/50 rounded" />
|
||||
))}
|
||||
</div>
|
||||
{/* Right */}
|
||||
<div className="flex-1 p-4 space-y-3">
|
||||
<div className="h-4 w-16 bg-border rounded" />
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="w-28 h-24 bg-border/50 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export { default } from '@/components/loading/backlog-page-skeleton'
|
||||
|
|
|
|||
|
|
@ -4,20 +4,25 @@ import { getSession } from '@/lib/auth'
|
|||
import { getAccessibleProduct } from '@/lib/product-access'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { pbiStatusToApi } from '@/lib/task-status'
|
||||
import { getSprintSwitcherData } from '@/lib/sprint-switcher-data'
|
||||
import { BacklogSplitPane } from '@/components/backlog/backlog-split-pane'
|
||||
import { PbiList } from '@/components/backlog/pbi-list'
|
||||
import { StoryPanel } from '@/components/backlog/story-panel'
|
||||
import type { Story } from '@/components/backlog/story-panel'
|
||||
import { TaskPanel } from '@/components/backlog/task-panel'
|
||||
import { BacklogHydrationWrapper } from '@/components/backlog/backlog-hydration-wrapper'
|
||||
import { UrlTaskSync } from '@/components/backlog/url-task-sync'
|
||||
import { TaskDialog } from '@/app/_components/tasks/task-dialog'
|
||||
import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader'
|
||||
import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton'
|
||||
import { StartSprintButton } from '@/components/sprint/start-sprint-button'
|
||||
import { SprintSwitcher } from '@/components/sprint/sprint-switcher'
|
||||
import { NewSprintTrigger } from '@/components/backlog/new-sprint-trigger'
|
||||
import { SprintDraftBanner } from '@/components/backlog/sprint-draft-banner'
|
||||
import { SprintDraftLeaveGuard } from '@/components/backlog/sprint-draft-leave-guard'
|
||||
import { SaveSprintButton } from '@/components/backlog/save-sprint-button'
|
||||
import { ActiveSelectionHydrator } from '@/components/backlog/active-selection-hydrator'
|
||||
import { ActivateProductButton } from '@/components/shared/activate-product-button'
|
||||
import { EditProductButton } from '@/components/products/edit-product-button'
|
||||
import { resolveActiveSprint } from '@/lib/active-sprint'
|
||||
import { SprintSwitcher } from '@/components/shared/sprint-switcher'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface Props {
|
||||
|
|
@ -35,15 +40,13 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
|
|||
const product = await getAccessibleProduct(id, session.userId)
|
||||
if (!product) notFound()
|
||||
|
||||
const [allSprints, user, activeSprint] = await Promise.all([
|
||||
prisma.sprint.findMany({
|
||||
where: { product_id: id },
|
||||
orderBy: { created_at: 'desc' },
|
||||
select: { id: true, code: true },
|
||||
}),
|
||||
const [user, switcherData] = await Promise.all([
|
||||
prisma.user.findUnique({ where: { id: session.userId! }, select: { active_product_id: true } }),
|
||||
resolveActiveSprint(id),
|
||||
getSprintSwitcherData(id, { userId: session.userId }),
|
||||
])
|
||||
const { sprintItems, buildingSprintIds, activeSprintItem } = switcherData
|
||||
const hasOpenSprint = sprintItems.some(s => s.status === 'open')
|
||||
const isActiveProduct = user?.active_product_id === id
|
||||
|
||||
const pbis = await prisma.pbi.findMany({
|
||||
where: { product_id: id },
|
||||
|
|
@ -53,7 +56,7 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
|
|||
const [stories, tasks] = await Promise.all([
|
||||
prisma.story.findMany({
|
||||
where: { product_id: id },
|
||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||
orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }],
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
|
|
@ -61,8 +64,10 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
|
|||
description: true,
|
||||
acceptance_criteria: true,
|
||||
priority: true,
|
||||
sort_order: true,
|
||||
status: true,
|
||||
pbi_id: true,
|
||||
sprint_id: true,
|
||||
created_at: true,
|
||||
},
|
||||
}),
|
||||
|
|
@ -70,6 +75,7 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
|
|||
where: { story: { pbi: { product_id: id } } },
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
title: true,
|
||||
description: true,
|
||||
priority: true,
|
||||
|
|
@ -78,11 +84,11 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
|
|||
story_id: true,
|
||||
created_at: true,
|
||||
},
|
||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||
orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }],
|
||||
}),
|
||||
])
|
||||
|
||||
// Group stories by PBI id
|
||||
// Group stories by PBI id (status uit DB blijft UPPER_SNAKE in dit hydratie-pad)
|
||||
const storiesByPbi: Record<string, Story[]> = {}
|
||||
for (const story of stories) {
|
||||
if (!storiesByPbi[story.pbi_id]) storiesByPbi[story.pbi_id] = []
|
||||
|
|
@ -100,21 +106,37 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
|
|||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Product header — actions only; product-naam zit al in NavBar */}
|
||||
<div className="px-4 py-3 border-b border-border bg-surface-container-low shrink-0 flex items-center justify-end">
|
||||
<div className="flex items-center gap-3">
|
||||
{user?.active_product_id !== id && (
|
||||
<ActivateProductButton productId={id} isDemo={isDemo} redirectTo={`/products/${id}`} />
|
||||
)}
|
||||
{allSprints.length > 0 && (
|
||||
{/* Product header — sprint-switcher gecentreerd, actions rechts */}
|
||||
<div className="px-4 py-3 border-b border-border bg-surface-container-low shrink-0 flex items-center gap-3">
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center justify-center">
|
||||
{isActiveProduct && (
|
||||
<SprintSwitcher
|
||||
productId={id}
|
||||
sprints={allSprints}
|
||||
activeSprintId={activeSprint?.id ?? null}
|
||||
sprints={sprintItems}
|
||||
activeSprint={activeSprintItem}
|
||||
buildingSprintIds={buildingSprintIds}
|
||||
/>
|
||||
)}
|
||||
{!isDemo && !activeSprint && (
|
||||
<StartSprintButton productId={id} />
|
||||
</div>
|
||||
<div className="flex-1 flex items-center gap-3 justify-end">
|
||||
{!isActiveProduct && (
|
||||
<ActivateProductButton productId={id} isDemo={isDemo} redirectTo={`/products/${id}`} />
|
||||
)}
|
||||
{hasOpenSprint && (
|
||||
<Link href={`/products/${id}/sprint`} className="text-xs text-primary hover:underline font-medium">
|
||||
Sprint actief →
|
||||
</Link>
|
||||
)}
|
||||
{activeSprintItem && !isDemo && (
|
||||
<SaveSprintButton activeSprintId={activeSprintItem.id} />
|
||||
)}
|
||||
{!isDemo && (
|
||||
<NewSprintTrigger
|
||||
productId={id}
|
||||
isDemo={isDemo}
|
||||
isActiveProduct={isActiveProduct}
|
||||
/>
|
||||
)}
|
||||
{!isDemo && product.user_id === session.userId && (
|
||||
<EditProductButton
|
||||
|
|
@ -129,6 +151,12 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
<Link
|
||||
href={`/products/${id}/docs`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Docs
|
||||
</Link>
|
||||
<Link
|
||||
href={`/products/${id}/settings`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
|
|
@ -138,16 +166,23 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sprint definition banner (state A′) + beforeunload-guard */}
|
||||
<SprintDraftBanner productId={id} />
|
||||
<SprintDraftLeaveGuard productId={id} />
|
||||
|
||||
{/* Split pane */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<BacklogHydrationWrapper
|
||||
productId={id}
|
||||
productName={product.name}
|
||||
initialData={{
|
||||
pbis: pbis.map((p) => ({ id: p.id, code: p.code, title: p.title, priority: p.priority, description: p.description, created_at: p.created_at, status: pbiStatusToApi(p.status) })),
|
||||
pbis: pbis.map((p) => ({ id: p.id, code: p.code, title: p.title, priority: p.priority, sort_order: p.sort_order, description: p.description, created_at: p.created_at, status: pbiStatusToApi(p.status) })),
|
||||
storiesByPbi,
|
||||
tasksByStory,
|
||||
}}
|
||||
>
|
||||
<UrlTaskSync />
|
||||
<ActiveSelectionHydrator productId={id} />
|
||||
<BacklogSplitPane
|
||||
cookieKey={`backlog-${id}`}
|
||||
defaultSplit={[20, 45, 35]}
|
||||
|
|
@ -157,11 +192,13 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
|
|||
key="pbi"
|
||||
productId={id}
|
||||
isDemo={isDemo}
|
||||
activeSprintId={activeSprintItem?.id ?? null}
|
||||
/>,
|
||||
<StoryPanel
|
||||
key="story"
|
||||
productId={id}
|
||||
isDemo={isDemo}
|
||||
activeSprintId={activeSprintItem?.id ?? null}
|
||||
/>,
|
||||
<TaskPanel
|
||||
key="tasks"
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { notFound, redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getAccessibleProduct } from '@/lib/product-access'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getSprintSwitcherData } from '@/lib/sprint-switcher-data'
|
||||
import { getSoloWorkspaceSnapshot } from '@/lib/solo-workspace-server'
|
||||
import { SoloBoard } from '@/components/solo/solo-board'
|
||||
import { SoloHydrationWrapper } from '@/components/solo/solo-hydration-wrapper'
|
||||
import { NoActiveSprint } from '@/components/solo/no-active-sprint'
|
||||
import type { SoloTask } from '@/components/solo/solo-board'
|
||||
import type { UnassignedStory } from '@/components/solo/unassigned-stories-sheet'
|
||||
import { SprintSwitcher } from '@/components/shared/sprint-switcher'
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>
|
||||
|
|
@ -19,101 +20,45 @@ export default async function SoloProductPage({ params }: Props) {
|
|||
const product = await getAccessibleProduct(id, session.userId)
|
||||
if (!product) notFound()
|
||||
|
||||
const sprint = await prisma.sprint.findFirst({
|
||||
where: { product_id: id, status: 'OPEN' },
|
||||
const initialData = await getSoloWorkspaceSnapshot(id, session.userId)
|
||||
const switcherData = await getSprintSwitcherData(id, {
|
||||
activeSprintId: initialData?.sprint.id ?? null,
|
||||
})
|
||||
|
||||
if (!sprint) {
|
||||
const switcherBar = (
|
||||
<div className="px-4 py-3 border-b border-border bg-surface-container-low shrink-0 flex items-center justify-center">
|
||||
<SprintSwitcher
|
||||
productId={id}
|
||||
sprints={switcherData.sprintItems}
|
||||
activeSprint={switcherData.activeSprintItem}
|
||||
buildingSprintIds={switcherData.buildingSprintIds}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!initialData) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{switcherBar}
|
||||
<NoActiveSprint productId={id} productName={product.name} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const [rawTasks, rawUnassigned] = await Promise.all([
|
||||
prisma.task.findMany({
|
||||
where: {
|
||||
story: {
|
||||
sprint_id: sprint.id,
|
||||
assignee_id: session.userId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
story: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
title: true,
|
||||
tasks: { select: { id: true }, orderBy: { sort_order: 'asc' } },
|
||||
pbi: { select: { code: true, title: true, description: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ story: { pbi: { priority: 'asc' } } },
|
||||
{ story: { pbi: { sort_order: 'asc' } } },
|
||||
{ story: { sort_order: 'asc' } },
|
||||
{ priority: 'asc' },
|
||||
{ sort_order: 'asc' },
|
||||
],
|
||||
}),
|
||||
prisma.story.findMany({
|
||||
where: { sprint_id: sprint.id, assignee_id: null },
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
title: true,
|
||||
tasks: {
|
||||
select: { id: true, title: true, description: true, priority: true, status: true },
|
||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||
},
|
||||
},
|
||||
orderBy: { sort_order: 'asc' },
|
||||
}),
|
||||
])
|
||||
|
||||
const tasks: SoloTask[] = rawTasks.map(t => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
description: t.description,
|
||||
implementation_plan: t.implementation_plan,
|
||||
priority: t.priority,
|
||||
sort_order: t.sort_order,
|
||||
status: t.status as SoloTask['status'],
|
||||
verify_only: t.verify_only,
|
||||
verify_required: t.verify_required as SoloTask['verify_required'],
|
||||
story_id: t.story.id,
|
||||
story_code: t.story.code,
|
||||
story_title: t.story.title,
|
||||
task_code: t.code,
|
||||
pbi_code: t.story.pbi?.code ?? null,
|
||||
pbi_title: t.story.pbi?.title ?? null,
|
||||
pbi_description: t.story.pbi?.description ?? null,
|
||||
}))
|
||||
|
||||
const unassignedStories: UnassignedStory[] = rawUnassigned.map(s => ({
|
||||
id: s.id,
|
||||
code: s.code,
|
||||
title: s.title,
|
||||
tasks: s.tasks.map(t => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
description: t.description,
|
||||
priority: t.priority,
|
||||
status: t.status,
|
||||
})),
|
||||
}))
|
||||
|
||||
return (
|
||||
<SoloBoard
|
||||
productId={id}
|
||||
sprintGoal={sprint.sprint_goal}
|
||||
tasks={tasks}
|
||||
unassignedStories={unassignedStories}
|
||||
isDemo={session.isDemo ?? false}
|
||||
currentUserId={session.userId}
|
||||
repoUrl={product.repo_url}
|
||||
/>
|
||||
<div className="flex flex-col h-full">
|
||||
{switcherBar}
|
||||
<div className="flex-1 min-h-0">
|
||||
<SoloHydrationWrapper initialData={initialData}>
|
||||
<SoloBoard
|
||||
key={initialData.sprint.id}
|
||||
productId={id}
|
||||
sprintGoal={initialData.sprint.sprint_goal}
|
||||
isDemo={session.isDemo ?? false}
|
||||
repoUrl={product.repo_url}
|
||||
/>
|
||||
</SoloHydrationWrapper>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,34 +1 @@
|
|||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex flex-col h-full animate-pulse">
|
||||
{/* Header skeleton */}
|
||||
<div className="px-4 py-3 border-b border-border bg-surface-container-low shrink-0 flex items-center justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-4 w-32 bg-border rounded" />
|
||||
<div className="h-3 w-48 bg-border/60 rounded" />
|
||||
</div>
|
||||
<div className="h-7 w-24 bg-border rounded" />
|
||||
</div>
|
||||
|
||||
{/* Split pane skeleton */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left */}
|
||||
<div className="w-2/5 border-r border-border p-4 space-y-3">
|
||||
<div className="h-4 w-24 bg-border rounded" />
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<div key={i} className="h-8 bg-border/50 rounded" />
|
||||
))}
|
||||
</div>
|
||||
{/* Right */}
|
||||
<div className="flex-1 p-4 space-y-3">
|
||||
<div className="h-4 w-16 bg-border rounded" />
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="w-28 h-24 bg-border/50 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export { default } from '@/components/loading/backlog-page-skeleton'
|
||||
|
|
|
|||
|
|
@ -1,18 +1,23 @@
|
|||
import { Suspense } from 'react'
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getAccessibleProduct } from '@/lib/product-access'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { pbiStatusToApi } from '@/lib/task-status'
|
||||
import { SprintBoardClient } from '@/components/sprint/sprint-board-client'
|
||||
import {
|
||||
SprintHydrationWrapper,
|
||||
type SprintHydrationData,
|
||||
} from '@/components/sprint/sprint-hydration-wrapper'
|
||||
import { SprintTaskDialogMount } from '@/components/sprint/sprint-task-dialog-mount'
|
||||
import { SprintUrlTaskSync } from '@/components/sprint/sprint-url-task-sync'
|
||||
import { SyncActiveSprintCookie } from '@/components/sprint/sync-active-sprint-cookie'
|
||||
import { getSprintSwitcherData } from '@/lib/sprint-switcher-data'
|
||||
import { SprintHeader } from '@/components/sprint/sprint-header'
|
||||
import { SprintRunControls } from '@/components/sprint/sprint-run-controls'
|
||||
import { parsePauseContext } from '@/lib/pause-context'
|
||||
import type { SprintStory, PbiWithStories, ProductMember } from '@/components/sprint/sprint-backlog'
|
||||
import type { Task } from '@/components/sprint/task-list'
|
||||
import type { SprintWorkspaceTask } from '@/stores/sprint-workspace/types'
|
||||
import { TaskDialog } from '@/app/_components/tasks/task-dialog'
|
||||
import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader'
|
||||
import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface Props {
|
||||
|
|
@ -20,13 +25,12 @@ interface Props {
|
|||
searchParams: Promise<{
|
||||
newTask?: string
|
||||
storyId?: string
|
||||
editTask?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export default async function SprintBoardPage({ params, searchParams }: Props) {
|
||||
const { id, sprintId } = await params
|
||||
const { newTask, storyId: storyIdParam, editTask } = await searchParams
|
||||
const { newTask, storyId: storyIdParam } = await searchParams
|
||||
|
||||
const session = await getSession()
|
||||
if (!session.userId) redirect('/login')
|
||||
|
|
@ -47,6 +51,8 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
|
|||
})
|
||||
if (!sprint) notFound()
|
||||
|
||||
const switcherData = await getSprintSwitcherData(id, { activeSprintId: sprint.id })
|
||||
|
||||
const activeSprintRun = await prisma.sprintRun.findFirst({
|
||||
where: {
|
||||
sprint_id: sprint.id,
|
||||
|
|
@ -66,7 +72,7 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
|
|||
where: { sprint_id: sprint.id },
|
||||
orderBy: { sort_order: 'asc' },
|
||||
include: {
|
||||
tasks: { orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }] },
|
||||
tasks: { orderBy: [{ sort_order: 'asc' }] },
|
||||
assignee: { select: { id: true, username: true } },
|
||||
},
|
||||
}),
|
||||
|
|
@ -89,8 +95,10 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
|
|||
description: s.description,
|
||||
acceptance_criteria: s.acceptance_criteria,
|
||||
pbi_id: s.pbi_id,
|
||||
sprint_id: s.sprint_id,
|
||||
created_at: s.created_at,
|
||||
priority: s.priority,
|
||||
sort_order: s.sort_order,
|
||||
status: s.status,
|
||||
taskCount: s.tasks.length,
|
||||
doneCount: s.tasks.filter(t => t.status === 'DONE').length,
|
||||
|
|
@ -98,17 +106,19 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
|
|||
assignee_username: s.assignee?.username ?? null,
|
||||
}))
|
||||
|
||||
const tasksByStory: Record<string, Task[]> = {}
|
||||
const tasksByStoryWorkspace: Record<string, SprintWorkspaceTask[]> = {}
|
||||
for (const story of sprintStories) {
|
||||
tasksByStory[story.id] = story.tasks.map(t => ({
|
||||
tasksByStoryWorkspace[story.id] = story.tasks.map(t => ({
|
||||
id: t.id,
|
||||
code: t.code,
|
||||
title: t.title,
|
||||
description: t.description,
|
||||
priority: t.priority,
|
||||
sort_order: t.sort_order,
|
||||
status: t.status,
|
||||
story_id: t.story_id,
|
||||
sprint_id: t.sprint_id,
|
||||
created_at: t.created_at,
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -118,7 +128,7 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
|
|||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||
include: {
|
||||
stories: {
|
||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||
orderBy: [{ sort_order: 'asc' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
@ -139,8 +149,10 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
|
|||
description: s.description,
|
||||
acceptance_criteria: s.acceptance_criteria,
|
||||
pbi_id: s.pbi_id,
|
||||
sprint_id: s.sprint_id,
|
||||
created_at: s.created_at,
|
||||
priority: s.priority,
|
||||
sort_order: s.sort_order,
|
||||
status: s.status,
|
||||
taskCount: 0,
|
||||
doneCount: 0,
|
||||
|
|
@ -149,18 +161,37 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
|
|||
})),
|
||||
}))
|
||||
|
||||
const sprintStoryIdList = sprintStories.map(s => s.id)
|
||||
const isDemo = session.isDemo ?? false
|
||||
const closePath = `/products/${id}/sprint/${sprint.id}`
|
||||
|
||||
const hydrationData: SprintHydrationData = {
|
||||
sprint: {
|
||||
id: sprint.id,
|
||||
product_id: id,
|
||||
code: sprint.code,
|
||||
sprint_goal: sprint.sprint_goal,
|
||||
status: sprint.status as 'OPEN' | 'CLOSED',
|
||||
start_date: sprint.start_date ? sprint.start_date.toISOString().slice(0, 10) : null,
|
||||
end_date: sprint.end_date ? sprint.end_date.toISOString().slice(0, 10) : null,
|
||||
created_at: new Date(),
|
||||
completed_at: null,
|
||||
},
|
||||
stories: sprintStoryItems,
|
||||
tasksByStory: tasksByStoryWorkspace,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div id="wrapper2" className="flex flex-col h-full">
|
||||
<SyncActiveSprintCookie productId={id} sprintId={sprint.id} />
|
||||
<SprintHeader
|
||||
productId={id}
|
||||
productName={product.name}
|
||||
sprint={sprint}
|
||||
isDemo={isDemo}
|
||||
sprintStories={sprintStoryItems}
|
||||
switcherSprints={switcherData.sprintItems}
|
||||
switcherActiveSprint={switcherData.activeSprintItem}
|
||||
switcherBuildingSprintIds={switcherData.buildingSprintIds}
|
||||
/>
|
||||
|
||||
<div className="border-b border-border bg-surface-container-low px-4 py-2 shrink-0">
|
||||
|
|
@ -176,17 +207,23 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
|
|||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<SprintBoardClient
|
||||
<SprintHydrationWrapper
|
||||
initialData={hydrationData}
|
||||
productId={id}
|
||||
sprintId={sprint.id}
|
||||
stories={sprintStoryItems}
|
||||
pbisWithStories={pbisWithStories}
|
||||
sprintStoryIdList={sprintStoryIdList}
|
||||
tasksByStory={tasksByStory}
|
||||
isDemo={isDemo}
|
||||
currentUserId={session.userId}
|
||||
members={members}
|
||||
/>
|
||||
productName={product.name}
|
||||
>
|
||||
<SprintBoardClient
|
||||
key={sprint.id}
|
||||
productId={id}
|
||||
sprintId={sprint.id}
|
||||
pbisWithStories={pbisWithStories}
|
||||
isDemo={isDemo}
|
||||
currentUserId={session.userId}
|
||||
members={members}
|
||||
/>
|
||||
<SprintTaskDialogMount productId={id} isDemo={isDemo} />
|
||||
<SprintUrlTaskSync />
|
||||
</SprintHydrationWrapper>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border px-4 py-2 bg-surface-container-low shrink-0">
|
||||
|
|
@ -203,18 +240,6 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
|
|||
isDemo={isDemo}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editTask && !newTask && (
|
||||
<Suspense fallback={<TaskDialogSkeleton />}>
|
||||
<EditTaskLoader
|
||||
taskId={editTask}
|
||||
userId={session.userId}
|
||||
productId={id}
|
||||
closePath={closePath}
|
||||
isDemo={isDemo}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,34 +1 @@
|
|||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex flex-col h-full animate-pulse">
|
||||
{/* Header skeleton */}
|
||||
<div className="px-4 py-3 border-b border-border bg-surface-container-low shrink-0 flex items-center justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-4 w-32 bg-border rounded" />
|
||||
<div className="h-3 w-48 bg-border/60 rounded" />
|
||||
</div>
|
||||
<div className="h-7 w-24 bg-border rounded" />
|
||||
</div>
|
||||
|
||||
{/* Split pane skeleton */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left */}
|
||||
<div className="w-2/5 border-r border-border p-4 space-y-3">
|
||||
<div className="h-4 w-24 bg-border rounded" />
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<div key={i} className="h-8 bg-border/50 rounded" />
|
||||
))}
|
||||
</div>
|
||||
{/* Right */}
|
||||
<div className="flex-1 p-4 space-y-3">
|
||||
<div className="h-4 w-16 bg-border rounded" />
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="w-28 h-24 bg-border/50 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export { default } from '@/components/loading/backlog-page-skeleton'
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue