Compare commits
1 commit
main
...
feat/m8-re
| Author | SHA1 | Date | |
|---|---|---|---|
| ad68f2bfa6 |
706 changed files with 8361 additions and 90706 deletions
12
.claude/settings.local.json
Normal file
12
.claude/settings.local.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npx tsc *)",
|
||||||
|
"Bash(git add *)",
|
||||||
|
"Bash(git commit *)",
|
||||||
|
"Bash(git push *)",
|
||||||
|
"Bash(npx eslint *)",
|
||||||
|
"Bash(npm run *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
33
.env.example
33
.env.example
|
|
@ -8,36 +8,3 @@ SESSION_SECRET="replace-with-at-least-32-characters"
|
||||||
|
|
||||||
# Optional; Vercel and Node set this automatically in deployed environments.
|
# Optional; Vercel and Node set this automatically in deployed environments.
|
||||||
NODE_ENV="development"
|
NODE_ENV="development"
|
||||||
|
|
||||||
# M11 (ST-1107) — shared secret between Vercel cron-trigger and the
|
|
||||||
# /api/cron/expire-questions handler. Required in production; optional in
|
|
||||||
# local dev (the route returns 401 if the Authorization header doesn't match).
|
|
||||||
# Generate with: openssl rand -base64 32
|
|
||||||
CRON_SECRET=""
|
|
||||||
|
|
||||||
# PBI-55 — Web Push (VAPID). All optional; app starts without these.
|
|
||||||
# Generate keys with: npx web-push generate-vapid-keys
|
|
||||||
NEXT_PUBLIC_VAPID_PUBLIC_KEY=""
|
|
||||||
VAPID_PRIVATE_KEY=""
|
|
||||||
# Must start with mailto: e.g. mailto:admin@example.com
|
|
||||||
VAPID_SUBJECT="mailto:admin@example.com"
|
|
||||||
# Shared secret for POST /api/internal/push/send — min 32 chars
|
|
||||||
# Generate with: openssl rand -base64 32
|
|
||||||
INTERNAL_PUSH_SECRET=""
|
|
||||||
|
|
||||||
# PBI-66 — Anthropic API key voor `npm run db:sync-model-prices`.
|
|
||||||
# Optional. Alleen nodig om wekelijks de model_prices tabel te synchroniseren.
|
|
||||||
# Genereer op https://console.anthropic.com/ → API Keys.
|
|
||||||
# /v1/models is een gratis metadata-call (geen tokens, geen credit nodig).
|
|
||||||
ANTHROPIC_API_KEY=""
|
|
||||||
|
|
||||||
# v1-readiness item 2 — Sentry error monitoring.
|
|
||||||
# Optional. Without DSN, the SDK is a no-op (no network, no overhead).
|
|
||||||
# Get a DSN at https://sentry.io → Project → Settings → Client Keys (DSN).
|
|
||||||
NEXT_PUBLIC_SENTRY_DSN=""
|
|
||||||
|
|
||||||
# Required ONLY if you want source-map upload during build (production deploy).
|
|
||||||
# In Vercel: project settings → Environment Variables → add as encrypted.
|
|
||||||
SENTRY_ORG=""
|
|
||||||
SENTRY_PROJECT=""
|
|
||||||
SENTRY_AUTH_TOKEN=""
|
|
||||||
|
|
|
||||||
110
.github/workflows/ci.yml
vendored
110
.github/workflows/ci.yml
vendored
|
|
@ -5,23 +5,11 @@ on:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
target:
|
|
||||||
type: choice
|
|
||||||
description: Deploy target
|
|
||||||
options: [preview, production]
|
|
||||||
default: preview
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
ci:
|
||||||
name: Lint, Typecheck, Test & Build
|
name: Lint, Typecheck, Test & Build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event_name != 'workflow_dispatch'
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
|
@ -51,9 +39,6 @@ jobs:
|
||||||
- name: Test
|
- name: Test
|
||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
- name: Check doc links
|
|
||||||
run: npm run docs:check-links
|
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
env:
|
env:
|
||||||
|
|
@ -61,52 +46,11 @@ jobs:
|
||||||
DIRECT_URL: ${{ secrets.DIRECT_URL }}
|
DIRECT_URL: ${{ secrets.DIRECT_URL }}
|
||||||
SESSION_SECRET: ${{ secrets.SESSION_SECRET }}
|
SESSION_SECRET: ${{ secrets.SESSION_SECRET }}
|
||||||
|
|
||||||
changes:
|
|
||||||
name: Detect deploy-relevant changes
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: ci
|
|
||||||
# Alleen relevant voor auto-deploy jobs; skip wanneer auto-deploy uit staat.
|
|
||||||
if: vars.AUTO_DEPLOY_ENABLED == 'true' && github.event_name != 'workflow_dispatch'
|
|
||||||
outputs:
|
|
||||||
code: ${{ steps.filter.outputs.code }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
- uses: dorny/paths-filter@v3
|
|
||||||
id: filter
|
|
||||||
with:
|
|
||||||
filters: |
|
|
||||||
code:
|
|
||||||
- 'app/**'
|
|
||||||
- 'components/**'
|
|
||||||
- 'lib/**'
|
|
||||||
- 'actions/**'
|
|
||||||
- 'stores/**'
|
|
||||||
- 'prisma/**'
|
|
||||||
- 'public/**'
|
|
||||||
- 'package.json'
|
|
||||||
- 'package-lock.json'
|
|
||||||
- 'next.config.ts'
|
|
||||||
- 'tsconfig.json'
|
|
||||||
- 'vercel.json'
|
|
||||||
- 'proxy.ts'
|
|
||||||
- 'middleware.ts'
|
|
||||||
- '.github/workflows/**'
|
|
||||||
|
|
||||||
deploy-preview:
|
deploy-preview:
|
||||||
name: Deploy Preview (PR)
|
name: Deploy Preview (PR)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [ci, changes]
|
needs: ci
|
||||||
# Auto-deploy is uit. Gebruik "Run workflow" (workflow_dispatch) op de
|
if: github.event_name == 'pull_request'
|
||||||
# Actions-pagina voor handmatige deploys. Zet repo-variable
|
|
||||||
# AUTO_DEPLOY_ENABLED=true in Settings → Secrets and variables → Actions
|
|
||||||
# om PR-preview-deploys weer in te schakelen.
|
|
||||||
if: |
|
|
||||||
vars.AUTO_DEPLOY_ENABLED == 'true'
|
|
||||||
&& github.event_name == 'pull_request' && (
|
|
||||||
(needs.changes.outputs.code == 'true'
|
|
||||||
&& !contains(github.event.pull_request.labels.*.name, 'skip-deploy'))
|
|
||||||
|| contains(github.event.pull_request.labels.*.name, 'force-deploy')
|
|
||||||
)
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
|
@ -133,15 +77,8 @@ jobs:
|
||||||
deploy-production:
|
deploy-production:
|
||||||
name: Deploy Production (main)
|
name: Deploy Production (main)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [ci, changes]
|
needs: ci
|
||||||
# Auto-deploy is uit. Gebruik "Run workflow" (workflow_dispatch) →
|
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||||
# target=production voor handmatige productie-deploys. Zet repo-variable
|
|
||||||
# AUTO_DEPLOY_ENABLED=true om push-naar-main weer auto te deployen.
|
|
||||||
if: |
|
|
||||||
vars.AUTO_DEPLOY_ENABLED == 'true'
|
|
||||||
&& github.ref == 'refs/heads/main'
|
|
||||||
&& github.event_name == 'push'
|
|
||||||
&& needs.changes.outputs.code == 'true'
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
|
@ -170,42 +107,3 @@ jobs:
|
||||||
env:
|
env:
|
||||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||||
|
|
||||||
deploy-manual:
|
|
||||||
name: Deploy Manual (workflow_dispatch)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: github.event_name == 'workflow_dispatch'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v5
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v5
|
|
||||||
with:
|
|
||||||
node-version: '24'
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Install Vercel CLI
|
|
||||||
run: npm install -g vercel@latest
|
|
||||||
|
|
||||||
- name: Run database migrations (production only)
|
|
||||||
if: inputs.target == 'production'
|
|
||||||
run: npx prisma migrate deploy
|
|
||||||
env:
|
|
||||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
|
||||||
DIRECT_URL: ${{ secrets.DIRECT_URL }}
|
|
||||||
|
|
||||||
- name: Deploy
|
|
||||||
run: |
|
|
||||||
if [ "${{ inputs.target }}" = "production" ]; then
|
|
||||||
vercel deploy --prod --token=${{ secrets.VERCEL_TOKEN }}
|
|
||||||
else
|
|
||||||
vercel deploy --token=${{ secrets.VERCEL_TOKEN }}
|
|
||||||
fi
|
|
||||||
env:
|
|
||||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
|
||||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
|
||||||
|
|
|
||||||
41
.github/workflows/neon-backup.yml
vendored
41
.github/workflows/neon-backup.yml
vendored
|
|
@ -1,41 +0,0 @@
|
||||||
name: Daily Neon Database Backup
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: "0 2 * * *"
|
|
||||||
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
backup:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Install PostgreSQL 17 client
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y curl ca-certificates gnupg
|
|
||||||
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo gpg --dearmor -o /usr/share/keyrings/postgresql.gpg
|
|
||||||
echo "deb [signed-by=/usr/share/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt noble-pgdg main" | sudo tee /etc/apt/sources.list.d/pgdg.list
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y postgresql-client-17
|
|
||||||
pg_dump --version
|
|
||||||
|
|
||||||
- name: Create backup
|
|
||||||
env:
|
|
||||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
|
||||||
run: |
|
|
||||||
mkdir -p backups
|
|
||||||
DATE=$(date +"%Y-%m-%d_%H-%M-%S")
|
|
||||||
/usr/lib/postgresql/17/bin/pg_dump "$DATABASE_URL" \
|
|
||||||
--format=custom \
|
|
||||||
--no-owner \
|
|
||||||
--no-privileges \
|
|
||||||
--file="backups/neon-backup-$DATE.dump"
|
|
||||||
|
|
||||||
- name: Upload backup artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: neon-database-backup
|
|
||||||
path: backups/*.dump
|
|
||||||
retention-days: 30
|
|
||||||
19
.gitignore
vendored
19
.gitignore
vendored
|
|
@ -50,9 +50,9 @@ next-env.d.ts
|
||||||
|
|
||||||
# Claude Code local settings
|
# Claude Code local settings
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
.claude/worktrees/
|
|
||||||
|
|
||||||
# Local plan/scratch files (per-developer, not shared)
|
# Local plan/scratch files (per-developer, not shared)
|
||||||
|
.Plans/
|
||||||
|
|
||||||
# Editor
|
# Editor
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
@ -60,20 +60,3 @@ next-env.d.ts
|
||||||
#Screenshots (lokale bron-bestanden negeren, maar /public/screenshots wordt wel gecommit)
|
#Screenshots (lokale bron-bestanden negeren, maar /public/screenshots wordt wel gecommit)
|
||||||
screenshots/
|
screenshots/
|
||||||
!public/screenshots/
|
!public/screenshots/
|
||||||
|
|
||||||
# Testomgeving
|
|
||||||
jp.sh
|
|
||||||
|
|
||||||
# MCP config (bevat credentials)
|
|
||||||
.mcp.json
|
|
||||||
|
|
||||||
# Codex local config
|
|
||||||
.codex/
|
|
||||||
|
|
||||||
# Lokale scratch-bestanden
|
|
||||||
Brainstro
|
|
||||||
/graphify-out
|
|
||||||
|
|
||||||
# Personal Obsidian authoring layer (vault config + sidecar files prefixed `_`)
|
|
||||||
.obsidian/
|
|
||||||
_*.md
|
|
||||||
|
|
@ -1,6 +1 @@
|
||||||
npx lint-staged
|
npx lint-staged
|
||||||
|
|
||||||
if git diff --cached --name-only | grep -q '^docs/.*\.md$'; then
|
|
||||||
npm run docs:index
|
|
||||||
git add docs/INDEX.md
|
|
||||||
fi
|
|
||||||
|
|
|
||||||
49
AGENTS.md
49
AGENTS.md
|
|
@ -1,23 +1,38 @@
|
||||||
---
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
title: "AGENTS.md — Scrum4Me agent rules"
|
# This is NOT the Next.js you know
|
||||||
status: active
|
|
||||||
audience: [ai-agent]
|
|
||||||
language: en
|
|
||||||
last_updated: 2026-05-03
|
|
||||||
---
|
|
||||||
|
|
||||||
# Agent Instructions — Scrum4Me
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||||
|
<!-- END:nextjs-agent-rules -->
|
||||||
|
|
||||||
This file is a redirect stub. All agent instructions live in **[CLAUDE.md](./CLAUDE.md)**.
|
# Scrum4Me Codex Rules
|
||||||
|
|
||||||
For Claude Code specifically, CLAUDE.md is loaded automatically. Start there.
|
Read `CLAUDE.md` and the relevant files in `docs/` before changing behavior. The same product and security rules apply to Codex work.
|
||||||
|
|
||||||
## Branch & PR-flow (quick reference)
|
## Access Control
|
||||||
|
|
||||||
| Moment | Actie | Verbod |
|
- Product-scoped access is owner-or-member: use `productAccessFilter(userId)` from `lib/product-access.ts`.
|
||||||
|---|---|---|
|
- Use owner-only `user_id` checks only for actions that truly require ownership, such as product archiving and team management.
|
||||||
| Start run | `git checkout -b feat/<batch-slug>` | `gh pr create` |
|
- Never trust client-provided IDs by themselves. For reorder, promotion, completion, or bulk updates, fetch the records with both `id in (...)` and the parent scope (`product_id`, `pbi_id`, `sprint_id`, or `story_id`) before writing.
|
||||||
| Na elke taak | `git add -A && git commit -m "<type>(ST-XXX): <title>"` | `git push` |
|
- Reject duplicate IDs in ordered lists or decision payloads.
|
||||||
| Queue leeg | `git push -u origin <branch>` + `gh pr create` | — |
|
- Derive denormalized fields from database parents, for example `pbi.product_id`, not from form data or JSON bodies.
|
||||||
|
- Demo users and demo API tokens must receive 403 on write operations.
|
||||||
|
|
||||||
Full details: [docs/runbooks/branch-and-commit.md § Agent-batch flow](./docs/runbooks/branch-and-commit.md)
|
## Documentation Sync
|
||||||
|
|
||||||
|
When changing behavior, API responses, dependencies, environment variables, deployment behavior, or analytics, update the matching docs in the same change:
|
||||||
|
|
||||||
|
- `README.md` for setup, dependencies, deployment, and API overview.
|
||||||
|
- `docs/scrum4me-functional-spec.md` for user-facing/API requirements.
|
||||||
|
- `docs/scrum4me-architecture.md` for stack, access model, data model, env vars, and deployment.
|
||||||
|
- `docs/patterns/` when a reusable implementation rule changes.
|
||||||
|
- `CLAUDE.md` and this file when an agent instruction would have prevented the issue.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Before handing work back, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
|
||||||
106
CHANGELOG.md
106
CHANGELOG.md
|
|
@ -1,106 +0,0 @@
|
||||||
# Changelog
|
|
||||||
|
|
||||||
All notable changes to **Scrum4Me** are documented in this file.
|
|
||||||
|
|
||||||
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [Unreleased]
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [1.0.0] — 2026-05-04
|
|
||||||
|
|
||||||
**Eerste stabiele release** — MVP volgens functional spec is af, getest en in
|
|
||||||
productie. Geen breaking changes ten opzichte van 0.9.0; deze tag markeert de
|
|
||||||
launch-ready state na de v1-readiness-checklist (Now + Before-launch items).
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Rate-limiting: `enforceUserRateLimit(scope, userId)` helper toegepast op alle
|
|
||||||
high-value mutation paths — PBI/Story/Task/Todo/Sprint/Product/Token create,
|
|
||||||
Claude job enqueue, answerQuestion, story-log POST, avatar upload.
|
|
||||||
([#86](https://github.com/madhura68/Scrum4Me/pull/86))
|
|
||||||
- Sentry error-monitoring scaffolding (`@sentry/nextjs`) met no-op fallback
|
|
||||||
zonder DSN. Activeer via `NEXT_PUBLIC_SENTRY_DSN` in Vercel env-vars.
|
|
||||||
([#85](https://github.com/madhura68/Scrum4Me/pull/85))
|
|
||||||
- `CHANGELOG.md` (Keep a Changelog formaat) + `docs/runbooks/v1-smoke-test.md`
|
|
||||||
— 11-secties pre-launch verificatie. ([#89](https://github.com/madhura68/Scrum4Me/pull/89))
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- A11y Lighthouse score op `/products/[id]` van 86 → ≥95: `aria-selected` →
|
|
||||||
`aria-pressed` op PBI-cards (correct ARIA role-attribute pairing); tap-targets
|
|
||||||
≥28×28 px op hover-icon-buttons. ([#88](https://github.com/madhura68/Scrum4Me/pull/88))
|
|
||||||
- A11y form-label associaties (`htmlFor` + `id`) op happy-path dialogen
|
|
||||||
(Story/Task + Promote-PBI/Story); auth-pages krijgen `<main>` landmark.
|
|
||||||
([#87](https://github.com/madhura68/Scrum4Me/pull/87))
|
|
||||||
- README: test-count 69 → 445, env-vars-tabel uitgebreid met `CRON_SECRET` en
|
|
||||||
Sentry-vars. ([#89](https://github.com/madhura68/Scrum4Me/pull/89))
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Demo-policy: drie mutation-paden zonder `isDemo`-check gedicht
|
|
||||||
(`toggleTodoAction`, `archiveCompletedTodosAction`, `leaveProductAction`).
|
|
||||||
([#89](https://github.com/madhura68/Scrum4Me/pull/89))
|
|
||||||
|
|
||||||
### Security
|
|
||||||
- Vier debug-routes (`/debug-env`, `/debug-realtime`, `/api/debug/*`) krijgen
|
|
||||||
een NODE_ENV-guard → 404 in productie. ([#89](https://github.com/madhura68/Scrum4Me/pull/89))
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [0.9.0] — 2026-05-04
|
|
||||||
|
|
||||||
[GitHub Release](https://github.com/madhura68/Scrum4Me/releases/tag/v0.9.0)
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **PBI-11: Mobile-shell met landscape-lock** ([#81](https://github.com/madhura68/Scrum4Me/pull/81)):
|
|
||||||
- Aparte route group `app/(mobile)/m/{settings,pair,products}/...` met eigen
|
|
||||||
layout (zonder NavBar/StatusBar/MinWidthBanner)
|
|
||||||
- `LandscapeGuard` (rotate-overlay in portrait), `MobileTabBar` (3 lucide-iconen)
|
|
||||||
- PWA-manifest met `"orientation": "landscape"`
|
|
||||||
- UA-redirect bij login: telefoons (`Mobi`-substring) → `/m/products/[active]/solo`,
|
|
||||||
tablets en desktop → `/dashboard`
|
|
||||||
- Gedeelde `lib/auth-guard.ts` `requireSession()` helper, hergebruikt door beide layouts
|
|
||||||
- Mobile-fullscreen voor entity-dialogen via gedeelde `entityDialogContentClasses`
|
|
||||||
- Sprint Product-Backlog kolom: filter-popover (prioriteit + status) en
|
|
||||||
edit-iconen op PBI/story/task-rijen. ([#79](https://github.com/madhura68/Scrum4Me/pull/79))
|
|
||||||
- Edit-icoon op product-card in dashboard (consistent met PBI/story/task-pattern).
|
|
||||||
([#83](https://github.com/madhura68/Scrum4Me/pull/83))
|
|
||||||
- v1.0 readiness checklist in `docs/old/plans/v1-readiness.md`.
|
|
||||||
([#82](https://github.com/madhura68/Scrum4Me/pull/82))
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Refactor `app/(app)/layout.tsx` om gedeelde `requireSession()` te gebruiken
|
|
||||||
(gedrag onveranderd). ([#81](https://github.com/madhura68/Scrum4Me/pull/81))
|
|
||||||
- `/m/pair` filesystem-verhuisd uit `(app)/` naar `(mobile)/` — URL onveranderd.
|
|
||||||
([#81](https://github.com/madhura68/Scrum4Me/pull/81))
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [0.4.0] — eerder
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- M9 — Actief Product Backlog: persistente actieve PB-keuze, gesplitste
|
|
||||||
navigatie, disabled-states bij geen actief product
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [0.3.1] — eerder
|
|
||||||
|
|
||||||
Initiële stabilisatie-release.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Pre-0.3.x
|
|
||||||
|
|
||||||
Foundation-werk (M0 t/m M8) is niet retroactief in dit changelog opgenomen.
|
|
||||||
Voor de volledige milestone-historie zie [docs/old/backlog/index.md](./docs/old/backlog/index.md).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
[Unreleased]: https://github.com/madhura68/Scrum4Me/compare/v1.0.0...HEAD
|
|
||||||
[1.0.0]: https://github.com/madhura68/Scrum4Me/releases/tag/v1.0.0
|
|
||||||
[0.9.0]: https://github.com/madhura68/Scrum4Me/releases/tag/v0.9.0
|
|
||||||
[0.4.0]: https://github.com/madhura68/Scrum4Me/commit/615f0c8
|
|
||||||
[0.3.1]: https://github.com/madhura68/Scrum4Me/commit/ecc05dd
|
|
||||||
289
CLAUDE.md
289
CLAUDE.md
|
|
@ -1,154 +1,229 @@
|
||||||
---
|
|
||||||
title: "CLAUDE.md — Scrum4Me"
|
|
||||||
status: active
|
|
||||||
audience: [ai-agent]
|
|
||||||
language: nl
|
|
||||||
last_updated: 2026-05-11
|
|
||||||
---
|
|
||||||
|
|
||||||
# CLAUDE.md — Scrum4Me
|
# CLAUDE.md — Scrum4Me
|
||||||
|
|
||||||
Desktop-first Scrum-app voor solo developers en kleine teams. Hiërarchie: product → PBI → story → taak. Zie [README.md](./README.md) voor setup.
|
Dit is het centrale instructiedocument voor Claude Code. Lees dit volledig voordat je iets bouwt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Orientatie
|
## Wat is Scrum4Me?
|
||||||
|
|
||||||
| Bestand | Waarvoor |
|
Een desktop-first fullstack webapplicatie voor solo developers en kleine Scrum Teams die meerdere softwareprojecten parallel beheren. De app organiseert werk hiërarchisch (product → PBI → story → taak), biedt gesplitste planningsschermen met drag-and-drop, en integreert met Claude Code via een REST API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Specificatiedocumenten
|
||||||
|
|
||||||
|
Lees het relevante document voordat je aan een feature begint. Nooit gokken over requirements.
|
||||||
|
|
||||||
|
| Document | Gebruik voor |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `docs/INDEX.md` | Gegenereerde index van alle docs — begin hier |
|
| `docs/scrum4me-functional-spec.md` | Acceptatiecriteria, randgevallen, user flows |
|
||||||
| `docs/specs/functional.md` | Acceptatiecriteria, user flows |
|
| `docs/scrum4me-architecture.md` | Stack, datamodel, Prisma schema, Zustand stores |
|
||||||
| `docs/architecture.md` | Breadcrumb → 6 topische arch-bestanden |
|
| `docs/scrum4me-backlog.md` | Welke task bouwen, volgorde, "done when"-criteria |
|
||||||
| `docs/api/rest-contract.md` | REST API contract voor Claude Code |
|
| `docs/scrum4me-personas.md` | Lars (primair), Dina, Remi — gebruik bij UI-beslissingen |
|
||||||
| `docs/design/styling.md` | **Lees vóór elk component** — MD3-tokens, shadcn |
|
| `docs/scrum4me-product-backlog.md` | Historische domein-backlog (referentie); seed wordt sinds ST-004 gegenereerd uit `scrum4me-backlog.md` via `prisma/seed-data/parse-backlog.ts` |
|
||||||
| `docs/adr/` | Architecture Decision Records — tech-keuzes (base-ui vs Radix, sort-order, demo-policy, …) |
|
| `docs/API.md` | REST-API contract voor Claude Code — endpoints, status-enums, foutcodes, voorbeeld-curls |
|
||||||
| `docs/architecture/` | 6 topische architecture-bestanden (data-model, auth, sprint-execution, …) — uitwerking van `docs/architecture.md` |
|
| `docs/scrum4me-styling.md` | **Lees dit voor elk component** — MD3-kleuren, shadcn patronen |
|
||||||
| `docs/runbooks/plan-to-pbi-flow.md` | **Na goedgekeurd plan** — PBI/Story/Task aanmaken via MCP, zónder direct uitvoeren |
|
| `docs/agent-instruction-audit.md` | Waarom de agent-instructies zijn aangescherpt; checklist voor toekomstige wijzigingen |
|
||||||
|
| [`madhura68/scrum4me-mcp`](https://github.com/madhura68/scrum4me-mcp) | MCP-server repo: native tools voor Claude Code, schema-sync via git submodule |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Hoe werk vinden
|
## Waar te beginnen
|
||||||
|
|
||||||
1. Branch aanmaken: `git checkout -b feat/<batch-slug>` — nog **geen** `gh pr create`
|
Volg de backlog strikt op volgorde. Start bij **ST-001**. Sla geen milestone over.
|
||||||
2. `mcp__scrum4me__get_claude_context` → pak de next story
|
|
||||||
3. Voer taken uit in `sort_order`; update status per taak
|
|
||||||
4. Lees het relevante patroon en styling vóór je begint
|
|
||||||
5. Verifieer: `npm run verify && npm run build` — `verify` = lint + typecheck + test
|
|
||||||
6. Commit per laag: `git add -A && git commit` — **geen** `git push` — zie [docs/runbooks/branch-and-commit.md](./docs/runbooks/branch-and-commit.md)
|
|
||||||
7. Herhaal stap 2–6 per story; branch blijft dezelfde
|
|
||||||
8. Queue leeg → `git push -u origin <branch>` + `gh pr create`
|
|
||||||
|
|
||||||
Volledige MCP-tool documentatie: [docs/runbooks/mcp-integration.md](./docs/runbooks/mcp-integration.md)
|
```
|
||||||
|
M0 (ST-001–008) → M1 (ST-101–110) → M2 (ST-201–210)
|
||||||
|
→ M3 (ST-301–312) → M4 (ST-401–410) → M5 (ST-501–506)
|
||||||
|
→ M6 (ST-601–612)
|
||||||
|
```
|
||||||
|
|
||||||
|
Werken aan een task kan via twee tracks. Track A heeft de voorkeur als je in Claude Code zit; Track B is voor Codex of omgevingen zonder MCP.
|
||||||
|
|
||||||
|
### Track A — via Claude Code MCP (aanbevolen)
|
||||||
|
|
||||||
|
1. Roep `mcp__scrum4me__implement_next_story` aan met `product_id` (gebruik `mcp__scrum4me__list_products` als je het id niet weet)
|
||||||
|
2. De prompt orkestreert: `get_claude_context` → `log_implementation` → per task `update_task_status(in_progress)` → bouw → `update_task_status(done)` → `log_test_result` → `log_commit`
|
||||||
|
3. Bouw de tasks in volgorde van `sort_order`; lees per task de relevante pattern-doc en styling
|
||||||
|
4. Verifieer: `npm run lint && npm test && npm run build`
|
||||||
|
5. Commit per laag (zie Commit Strategy)
|
||||||
|
|
||||||
|
### Track B — manueel (Codex of zonder MCP)
|
||||||
|
|
||||||
|
1. Lees de task in `scrum4me-backlog.md`
|
||||||
|
2. Zoek de bijbehorende feature-spec in `scrum4me-functional-spec.md`
|
||||||
|
3. Lees het relevante patroon in `docs/patterns/` en styling in `docs/scrum4me-styling.md` als dat van toepassing is
|
||||||
|
4. Bouw — test — verifieer de "Done when"-criteria
|
||||||
|
5. Vraag of de code correct is
|
||||||
|
6. Commit (zie Commit Strategy hieronder)
|
||||||
|
7. Vraag of de volgende taak gedaan moet worden
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Hardstop regels
|
## Tech stack
|
||||||
|
|
||||||
- **Styling:** nooit `bg-blue-500`; altijd MD3-tokens (`bg-primary`, `bg-status-done`, …)
|
```
|
||||||
- **UI:** gebruik `@base-ui/react` met `render`-prop, niet Radix `asChild`
|
Next.js 16 (App Router) + React 19
|
||||||
- **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)
|
TypeScript strict
|
||||||
- **Demo:** drie lagen — proxy.ts + server action + UI disabled knop
|
Tailwind CSS + shadcn/ui
|
||||||
- **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/*`
|
MD3 kleurensysteem via app/styles/theme.css
|
||||||
- **Enum:** DB UPPER_SNAKE ↔ API lowercase — uitsluitend via `lib/task-status.ts`
|
Zustand (client state)
|
||||||
- **Foutcodes:** 400 = parse-fout, 422 = Zod-validatie, 403 = demo-token
|
dnd-kit (drag-and-drop)
|
||||||
- **Server/client grens:** `*-server.ts` bevat DB/node-only; nooit importeren in client component
|
Prisma v7 + PostgreSQL (Neon)
|
||||||
- **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)
|
iron-session (auth cookies)
|
||||||
- **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)
|
bcryptjs + Zod + Sonner
|
||||||
- **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)
|
Sharp (avatarverwerking)
|
||||||
|
Vercel Analytics (@vercel/analytics/next)
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **Stylingregel:** Gebruik **nooit** `bg-blue-500` of willekeurige Tailwind-kleuren.
|
||||||
|
> Gebruik altijd semantische MD3-tokens: `bg-primary`, `bg-status-done`, `bg-priority-critical`.
|
||||||
|
> Zie `scrum4me-styling.md` voor alle patronen.
|
||||||
|
|
||||||
|
> ⚠️ **Next.js-versie:** Lees `node_modules/next/dist/docs/` bij twijfel — API's kunnen afwijken van trainingsdata.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Stack
|
## Implementatiepatronen
|
||||||
|
|
||||||
| Laag | Technologie |
|
Lees het relevante patroon vóór je begint. Nooit uit het hoofd schrijven.
|
||||||
|---|---|
|
|
||||||
| Framework | Next.js 16.2 (App Router) + React 19.2 — PPR/Cache Components beschikbaar |
|
|
||||||
| Taal | TypeScript strict |
|
|
||||||
| Styling | Tailwind CSS v4 + shadcn/ui + MD3 via `app/styles/theme.css` |
|
|
||||||
| State | Zustand + dnd-kit |
|
|
||||||
| DB | Prisma v7.8 + PostgreSQL (Neon) |
|
|
||||||
| Auth | iron-session + bcryptjs |
|
|
||||||
| Test | Vitest (`__tests__/`, config in `vitest.config.ts`) |
|
|
||||||
| Utilities | Zod, Sonner, Sharp, Vercel Analytics |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Patterns quickref
|
|
||||||
|
|
||||||
| Patroon | Bestand |
|
| Patroon | Bestand |
|
||||||
|---|---|
|
|---|---|
|
||||||
| iron-session | `docs/patterns/iron-session.md` |
|
| iron-session (auth cookies) | `docs/patterns/iron-session.md` |
|
||||||
| Prisma singleton | `docs/patterns/prisma-client.md` |
|
| Prisma Client singleton | `docs/patterns/prisma-client.md` |
|
||||||
| Server Action (auth + Zod) | `docs/patterns/server-action.md` |
|
| Server Action (met auth + Zod) | `docs/patterns/server-action.md` |
|
||||||
| Route Handler (REST) | `docs/patterns/route-handler.md` |
|
| Route Handler (REST API) | `docs/patterns/route-handler.md` |
|
||||||
| Workspace-store + realtime (PBI-74) | `docs/patterns/workspace-store.md` |
|
| Zustand optimistische update + rollback | `docs/patterns/zustand-optimistic.md` |
|
||||||
| Zustand optimistic update | `docs/patterns/zustand-optimistic.md` |
|
| Float sort_order drag-and-drop | `docs/patterns/sort-order.md` |
|
||||||
| Float sort_order / drag-and-drop | `docs/patterns/sort-order.md` |
|
| Middleware (route protection) | `docs/patterns/middleware.md` |
|
||||||
| Proxy / route protection | `docs/patterns/proxy.md` |
|
| Status-enum mapping (DB ↔ API) | `lib/task-status.ts` |
|
||||||
| QR-pairing | `docs/patterns/qr-login.md` |
|
| Client/server module-boundary | `*-server.ts` bevat DB-calls of node-only deps; `*.ts` is pure (client-safe). Nooit `import { ... } from '@/lib/foo-server'` in een client-component, anders krijg je `Module not found: 'dns'`/`'pg'`-style runtime fouten |
|
||||||
| 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` |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Env vars
|
## Env vars
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DATABASE_URL="" # postgresql://...
|
DATABASE_URL="" # postgresql://...
|
||||||
DIRECT_URL="" # pooler-bypass voor LISTEN/NOTIFY
|
DIRECT_URL="" # alleen bij Neon/cloud
|
||||||
SESSION_SECRET="" # min 32 chars
|
SESSION_SECRET="" # openssl rand -base64 32
|
||||||
CRON_SECRET="" # Bearer-secret /api/cron/*
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Volledig schema: `lib/env.ts`. Canonieke lijst: `.env.example` — bevat ook web-push (`VAPID_*`, `INTERNAL_PUSH_SECRET`), Sentry (`SENTRY_*`) en optioneel `ANTHROPIC_API_KEY`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## MCP & cron
|
## Conventies
|
||||||
|
|
||||||
- **MCP-server (extern):** standalone Node-proces in `~/Development/scrum4me-mcp/` — Prisma-schema gesynced via `sync-schema.sh`. 30+ tools (`get_claude_context`, `wait_for_job`, `update_task_status`, …)
|
- **Branches:** `feat/ST-001-scaffolding`
|
||||||
- **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
|
- **Server Actions:** altijd in `actions/[domein].ts`, nooit inline in page.tsx
|
||||||
- **Cron (vercel.json):**
|
- **Validatie:** altijd Zod, nooit handmatige checks
|
||||||
- `/api/cron/expire-questions` — dagelijks 04:00 UTC
|
- **Toegangsmodel:** product-scoped resources gebruiken `productAccessFilter(userId)` tenzij het expliciet een eigenaarsactie is
|
||||||
- `/api/cron/cleanup-agent-artifacts` — dagelijks 03:00 UTC
|
- **Bulk-ID's:** reorder- en beslissingsacties valideren dat alle meegegeven IDs binnen dezelfde parent-scope vallen voordat er geschreven wordt
|
||||||
- **Realtime:** SSE op `/api/realtime/*`, gevoed door PostgreSQL `LISTEN`/`NOTIFY` op kanaal `scrum4me_changes` (vereist `DIRECT_URL` voor pooler-bypass)
|
- **Foreign keys:** denormalized keys zoals `story.product_id` worden afgeleid uit de database-parent (`pbi.product_id`), nooit uit client-input
|
||||||
|
- **Demo-check:** elke Server Action controleert `session.isDemo` vóór schrijven
|
||||||
|
- **Foutberichten:** Nederlands voor eindgebruikers — comments in code: Engels
|
||||||
|
- **Dependencies:** elke geïmporteerde runtime package staat direct in `dependencies`, niet alleen transitief in `package-lock.json`
|
||||||
|
- **Docs-sync:** elke gedrags-, dependency-, API- of deploymentwijziging werkt README, relevante docs en patterns bij in dezelfde change
|
||||||
|
- **Entity codes:** gebruik product/PBI/story-codes in commit-titles wanneer aanwezig (`feat(ST-356.2): ...`); branchnaam blijft `feat/ST-XXX-slug`
|
||||||
|
- **Status-enums op API:** lowercase (`todo|in_progress|review|done`, `open|in_sprint|done`); DB houdt UPPER_SNAKE; conversie uitsluitend via `lib/task-status.ts`-mappers — nooit ad-hoc `.toLowerCase()` elders
|
||||||
|
- **Foutcodes API:** `400` alleen voor malformed JSON-body (parse-fout via `request.json()`); `422` voor zod-validatie en well-formed-maar-niet-acceptabel; `403` voor demo-tokens. Documenteer per endpoint in `docs/API.md`
|
||||||
|
- **Tests volgen contract:** bij een API-contract-wijziging (status, foutcode, response-shape) MOET in dezelfde commit ook `__tests__/api/` mee — een test die rood gaat omdat de oude waarde wordt verwacht is een onvolledige wijziging, niet een "kapotte test"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commit Strategy (STRICT)
|
||||||
|
|
||||||
|
> **Core rule: één commit = één verantwoordelijkheid**
|
||||||
|
|
||||||
|
### Nooit doen
|
||||||
|
|
||||||
|
- Database + API + UI in één commit mengen
|
||||||
|
- Feature + documentatie combineren
|
||||||
|
- Grote "alles gewijzigd" commits
|
||||||
|
- Vage berichten zoals "update stuff"
|
||||||
|
|
||||||
|
### Verplichte structuur
|
||||||
|
|
||||||
|
Splits werk op in logische lagen:
|
||||||
|
|
||||||
|
1. Database / Prisma
|
||||||
|
2. API / server actions
|
||||||
|
3. UI / components
|
||||||
|
4. Config / infra
|
||||||
|
5. Documentatie
|
||||||
|
|
||||||
|
### Commit-formaat
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(ST-XXX): korte beschrijving
|
||||||
|
fix(ST-XXX): korte beschrijving
|
||||||
|
chore(ST-XXX): korte beschrijving
|
||||||
|
docs(ST-XXX): korte beschrijving
|
||||||
|
```
|
||||||
|
|
||||||
|
### Voorbeeld (verplicht patroon)
|
||||||
|
|
||||||
|
In plaats van:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
feat: add profile system
|
||||||
|
```
|
||||||
|
|
||||||
|
Splits altijd op in:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
feat(ST-XXX): add user profile fields to Prisma schema
|
||||||
|
feat(ST-XXX): add avatar upload endpoint
|
||||||
|
feat(ST-XXX): add profile editor component
|
||||||
|
chore(ST-XXX): configure sharp for avatar processing
|
||||||
|
docs(ST-XXX): document profile feature
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Scrum-terminologie
|
## Scrum-terminologie
|
||||||
|
|
||||||
PBI (niet: Feature/Epic) · Story (niet: Ticket) · Sprint Goal (niet: Objective)
|
| Correct | Niet gebruiken |
|
||||||
|
|---|---|
|
||||||
|
| Product Backlog Item (PBI) | Feature, Epic, Issue |
|
||||||
|
| Story | User Story, Ticket |
|
||||||
|
| Sprint Goal | Sprint Objective |
|
||||||
|
| Scrum Team | Team |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Verificatie
|
## MCP-integratie
|
||||||
|
|
||||||
```bash
|
Scrum4Me heeft een eigen MCP-server in repo [`madhura68/scrum4me-mcp`](https://github.com/madhura68/scrum4me-mcp) die de REST-API als native tools voor Claude Code aanbiedt. Schema's worden gedeeld via een git submodule (`vendor/scrum4me`), niet gedupliceerd.
|
||||||
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).
|
### Tools beschikbaar in Claude Code
|
||||||
|
|
||||||
### Scripts
|
- `mcp__scrum4me__health` — service + DB ping
|
||||||
|
- `mcp__scrum4me__list_products` — producten waar de tokengebruiker toegang tot heeft
|
||||||
|
- `mcp__scrum4me__get_claude_context` — bundled product / actieve sprint / next story (met tasks) / open todos
|
||||||
|
- `mcp__scrum4me__update_task_status`, `mcp__scrum4me__update_task_plan`
|
||||||
|
- `mcp__scrum4me__log_implementation`, `mcp__scrum4me__log_test_result`, `mcp__scrum4me__log_commit`
|
||||||
|
- `mcp__scrum4me__create_todo`
|
||||||
|
|
||||||
| Commando | Doel |
|
### Prompt
|
||||||
|---|---|
|
|
||||||
| `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.
|
- `implement_next_story` (arg: `product_id`) — end-to-end workflow
|
||||||
|
|
||||||
|
### Schema-drift bewaking
|
||||||
|
|
||||||
|
Wekelijks (maandag 08:00 Amsterdam) draait de remote agent `trig_015FFUnxjz9WMuhhWNGBQKFD` die `vendor/scrum4me` syncet en `prisma:generate` + `tsc --noEmit` uitvoert in scrum4me-mcp. Als die agent drift rapporteert, hoort dat **vóór** een Scrum4Me-PR met schema-wijziging gemerged kan worden — anders breekt de MCP-server stilletjes op runtime.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definition of Done (MVP)
|
||||||
|
|
||||||
|
M7 (MCP-server) is post-MVP en heeft eigen acceptatie in `docs/scrum4me-backlog.md`.
|
||||||
|
|
||||||
|
- [ ] Alle 62 tasks (ST-001 t/m ST-612) afgerond
|
||||||
|
- [ ] Volledige Lars-flow zonder fouten (ST-612)
|
||||||
|
- [ ] Alle 7 API-endpoints werken via curl
|
||||||
|
- [ ] Demo-gebruiker heeft geen schrijfrechten
|
||||||
|
- [ ] App opzetbaar via README zonder extra hulp
|
||||||
|
- [ ] CI/CD actief — falende build blokkeert merge
|
||||||
|
- [ ] Beveiligingsreview API geslaagd (cross-user toegang onmogelijk)
|
||||||
|
- [ ] Documentatie is bijgewerkt voor gewijzigde API's, dependencies, deployment en agent-instructies
|
||||||
|
|
|
||||||
63
README.md
63
README.md
|
|
@ -47,13 +47,6 @@ Scrum4Me biedt een lichtgewicht, web-based oplossing voor het beheren van sprint
|
||||||
- Vercel hosting
|
- Vercel hosting
|
||||||
- GitHub Actions / CI-CD
|
- GitHub Actions / CI-CD
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
- [CHANGELOG.md](CHANGELOG.md) — release-historie (Keep a Changelog)
|
|
||||||
- [docs/INDEX.md](docs/INDEX.md) — generated index of all docs (front-matter driven)
|
|
||||||
- [docs/glossary.md](docs/glossary.md) — domain terms (PBI, Story, MCP-job, etc.)
|
|
||||||
- [CLAUDE.md](CLAUDE.md) / [AGENTS.md](AGENTS.md) — agent instructions
|
|
||||||
|
|
||||||
## Architectuur (kort)
|
## Architectuur (kort)
|
||||||
|
|
||||||
- Frontend en backend via Next.js App Router
|
- Frontend en backend via Next.js App Router
|
||||||
|
|
@ -123,12 +116,16 @@ Vul daarna `DATABASE_URL` en `SESSION_SECRET` in. `DIRECT_URL` is optioneel loka
|
||||||
npx prisma db push
|
npx prisma db push
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Genereer Prisma Client:
|
4. Genereer Prisma Client en de ERD:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx prisma generate
|
npm run db:erd
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Deze command voert lokaal `prisma generate` uit. Daardoor worden zowel de Prisma Client als `docs/erd.svg` opnieuw opgebouwd.
|
||||||
|
|
||||||
|
In CI en deployment wordt bewust alleen de Prisma Client gegenereerd met `prisma generate --generator client`. Het ERD-diagram gebruikt Mermaid/Puppeteer en wordt daarom niet in GitHub Actions of Vercel gegenereerd.
|
||||||
|
|
||||||
5. Seed testdata indien nodig:
|
5. Seed testdata indien nodig:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -149,7 +146,7 @@ npm run dev
|
||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
Verwacht: alle 445 tests slagen, 0 failures.
|
Verwacht: alle 69 tests slagen, 0 failures.
|
||||||
|
|
||||||
**API curl-tests (vereist lopende dev server + API token):**
|
**API curl-tests (vereist lopende dev server + API token):**
|
||||||
|
|
||||||
|
|
@ -158,13 +155,23 @@ Verwacht: alle 445 tests slagen, 0 failures.
|
||||||
bash scripts/test-api.sh
|
bash scripts/test-api.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
De curl-tests dekken alle 7 API-endpoints: auth (401), demo-blokkering (403), inputvalidatie (400) en happy paths. Zie `docs/qa/api-test-plan.md` voor het volledige testplan.
|
De curl-tests dekken alle 7 API-endpoints: auth (401), demo-blokkering (403), inputvalidatie (400) en happy paths. Zie `docs/scrum4me-test-plan.md` voor het volledige testplan.
|
||||||
|
|
||||||
## Database
|
## Database
|
||||||
|
|
||||||
Het schema staat in `prisma/schema.prisma`; uitgebreide documentatie in [`docs/architecture/data-model.md`](./docs/architecture/data-model.md).
|

|
||||||
|
|
||||||
Gebruik `npx prisma db push` om schema-wijzigingen naar de database te synchroniseren. `npx prisma generate` (of `prisma generate --generator client` in CI) genereert de Prisma Client.
|
De databasevisualisatie wordt lokaal gegenereerd uit `prisma/schema.prisma` via `prisma-erd-generator`.
|
||||||
|
|
||||||
|
Handmatige generatie:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:erd
|
||||||
|
```
|
||||||
|
|
||||||
|
Tijdens lokale development draait `npm run dev` naast Next.js ook `npm run db:erd:watch`. Bij wijzigingen in `prisma/schema.prisma` wordt `docs/erd.svg` automatisch opnieuw gegenereerd.
|
||||||
|
|
||||||
|
Gebruik `npx prisma db push` alleen om het schema naar de database te synchroniseren. Gebruik `npm run db:erd` om lokaal Prisma Client en de ERD te genereren. Gebruik in CI uitsluitend `npx prisma generate --generator client`.
|
||||||
|
|
||||||
De app draait standaard op `http://localhost:3000`.
|
De app draait standaard op `http://localhost:3000`.
|
||||||
|
|
||||||
|
|
@ -175,6 +182,7 @@ npm run dev # lokale development server
|
||||||
npm run lint # ESLint
|
npm run lint # ESLint
|
||||||
npm test # Vitest test suite
|
npm test # Vitest test suite
|
||||||
npm run build # productiebuild zoals Vercel die verwacht
|
npm run build # productiebuild zoals Vercel die verwacht
|
||||||
|
npm run db:erd # Prisma Client + docs/erd.svg genereren
|
||||||
```
|
```
|
||||||
|
|
||||||
### Environment variables
|
### Environment variables
|
||||||
|
|
@ -184,15 +192,8 @@ Zie [.env.example](.env.example).
|
||||||
| Variabele | Verplicht | Doel |
|
| Variabele | Verplicht | Doel |
|
||||||
|---|---:|---|
|
|---|---:|---|
|
||||||
| `DATABASE_URL` | Ja | PostgreSQL connection string voor Prisma |
|
| `DATABASE_URL` | Ja | PostgreSQL connection string voor Prisma |
|
||||||
| `DIRECT_URL` | Nee | Directe Neon connection string voor migraties (Prisma `directUrl`) |
|
| `DIRECT_URL` | Nee | Directe Neon connection string voor migraties |
|
||||||
| `SESSION_SECRET` | Ja | Minimaal 32 tekens; gebruikt door iron-session |
|
| `SESSION_SECRET` | Ja | Minimaal 32 tekens; gebruikt door iron-session |
|
||||||
| `CRON_SECRET` | Productie | Bearer-secret voor `/api/cron/*` routes — required als crons aan staan |
|
|
||||||
| `NEXT_PUBLIC_VAPID_PUBLIC_KEY` | Nee | VAPID public key voor Web Push — genereer met `npx web-push generate-vapid-keys` |
|
|
||||||
| `VAPID_PRIVATE_KEY` | Nee | VAPID private key voor Web Push |
|
|
||||||
| `VAPID_SUBJECT` | Nee | Contact URI voor Web Push (bijv. `mailto:admin@example.com`) |
|
|
||||||
| `INTERNAL_PUSH_SECRET` | Nee | Bearer-secret voor `/api/internal/push/*` routes (min 32 tekens) |
|
|
||||||
| `NEXT_PUBLIC_SENTRY_DSN` | Nee | Sentry DSN — zonder is de SDK een no-op |
|
|
||||||
| `SENTRY_ORG` / `SENTRY_PROJECT` / `SENTRY_AUTH_TOKEN` | Nee | Source-map upload tijdens build |
|
|
||||||
| `NODE_ENV` | Nee | Wordt door Node/Vercel gezet |
|
| `NODE_ENV` | Nee | Wordt door Node/Vercel gezet |
|
||||||
|
|
||||||
Vercel Analytics gebruikt geen project-specifieke environment variabele in deze app; de component staat in `app/layout.tsx`.
|
Vercel Analytics gebruikt geen project-specifieke environment variabele in deze app; de component staat in `app/layout.tsx`.
|
||||||
|
|
@ -247,20 +248,13 @@ Authorization: Bearer <token>
|
||||||
|
|
||||||
| Methode | Endpoint | Doel |
|
| 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` | Actieve producten waarvoor de tokengebruiker eigenaar of teamlid is |
|
||||||
| `GET` | `/api/products/:id/next-story` | Hoogst geprioriteerde open story uit de actieve sprint |
|
| `GET` | `/api/products/:id/next-story` | Volgende 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 |
|
| `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 |
|
| `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 |
|
| `POST` | `/api/stories/:id/log` | Implementatieplan, testresultaat of commit vastleggen |
|
||||||
| `PATCH` | `/api/tasks/:id` | Taakstatus of `implementation_plan` bijwerken |
|
| `PATCH` | `/api/tasks/:id` | Taakstatus of implementatieplan bijwerken |
|
||||||
| `GET / POST` | `/api/ideas` · `GET / PATCH /api/ideas/:id` | Idea CRUD (M12 — vervangt voormalige `/api/todos`) |
|
| `POST` | `/api/todos` | Todo aanmaken binnen een productcontext |
|
||||||
| `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
|
### Security-regels
|
||||||
|
|
||||||
|
|
@ -285,6 +279,7 @@ De productieomgeving is gericht op Vercel + Neon.
|
||||||
|
|
||||||
### Documentatie
|
### Documentatie
|
||||||
|
|
||||||
- [Functionele specificatie](docs/specs/functional.md)
|
- [Functionele specificatie](docs/scrum4me-functional-spec.md)
|
||||||
- [Technische architectuur](docs/architecture.md)
|
- [Technische architectuur](docs/scrum4me-architecture.md)
|
||||||
- [Agent-instructie audit](docs/decisions/agent-instructions-history.md)
|
- [Backlog](docs/scrum4me-backlog.md)
|
||||||
|
- [Agent-instructie audit](docs/agent-instruction-audit.md)
|
||||||
|
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
|
||||||
vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) }))
|
|
||||||
vi.mock('iron-session', () => ({
|
|
||||||
getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/session', () => ({
|
|
||||||
sessionOptions: { cookieName: 'test', password: 'test' },
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/product-access', () => ({
|
|
||||||
productAccessFilter: vi.fn().mockReturnValue({}),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: {
|
|
||||||
product: { findFirst: vi.fn() },
|
|
||||||
user: { update: vi.fn() },
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
import { getIronSession } from 'iron-session'
|
|
||||||
import { setActiveProductAction, clearActiveProductAction } from '@/actions/active-product'
|
|
||||||
|
|
||||||
const mockPrisma = prisma as unknown as {
|
|
||||||
product: { findFirst: ReturnType<typeof vi.fn> }
|
|
||||||
user: { update: ReturnType<typeof vi.fn> }
|
|
||||||
}
|
|
||||||
const mockGetIronSession = getIronSession as ReturnType<typeof vi.fn>
|
|
||||||
|
|
||||||
const PRODUCT = { id: 'product-1', name: 'Test Product', archived: false }
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockGetIronSession.mockResolvedValue({ userId: 'user-1', isDemo: false })
|
|
||||||
mockPrisma.product.findFirst.mockResolvedValue(PRODUCT)
|
|
||||||
mockPrisma.user.update.mockResolvedValue({})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('setActiveProductAction', () => {
|
|
||||||
it('sets active_product_id for authenticated user', async () => {
|
|
||||||
const result = await setActiveProductAction('product-1')
|
|
||||||
expect(result).toEqual({ success: true, productId: 'product-1' })
|
|
||||||
expect(mockPrisma.user.update).toHaveBeenCalledWith({
|
|
||||||
where: { id: 'user-1' },
|
|
||||||
data: { active_product_id: 'product-1' },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns error when not logged in', async () => {
|
|
||||||
mockGetIronSession.mockResolvedValue({ userId: undefined, isDemo: false })
|
|
||||||
const result = await setActiveProductAction('product-1')
|
|
||||||
expect(result).toEqual({ error: 'Niet ingelogd' })
|
|
||||||
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns error for demo user', async () => {
|
|
||||||
mockGetIronSession.mockResolvedValue({ userId: 'user-1', isDemo: true })
|
|
||||||
const result = await setActiveProductAction('product-1')
|
|
||||||
expect(result).toEqual({ error: 'Niet beschikbaar in demo-modus' })
|
|
||||||
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns error when product is archived or inaccessible', async () => {
|
|
||||||
mockPrisma.product.findFirst.mockResolvedValue(null)
|
|
||||||
const result = await setActiveProductAction('product-1')
|
|
||||||
expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' })
|
|
||||||
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns error for empty product id', async () => {
|
|
||||||
const result = await setActiveProductAction('')
|
|
||||||
expect(result).toEqual({ error: 'Ongeldig product-id' })
|
|
||||||
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('clearActiveProductAction', () => {
|
|
||||||
it('clears active_product_id for authenticated user', async () => {
|
|
||||||
const result = await clearActiveProductAction()
|
|
||||||
expect(result).toEqual({ success: true })
|
|
||||||
expect(mockPrisma.user.update).toHaveBeenCalledWith({
|
|
||||||
where: { id: 'user-1' },
|
|
||||||
data: { active_product_id: null },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns error when not logged in', async () => {
|
|
||||||
mockGetIronSession.mockResolvedValue({ userId: undefined, isDemo: false })
|
|
||||||
const result = await clearActiveProductAction()
|
|
||||||
expect(result).toEqual({ error: 'Niet ingelogd' })
|
|
||||||
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns error for demo user', async () => {
|
|
||||||
mockGetIronSession.mockResolvedValue({ userId: 'user-1', isDemo: true })
|
|
||||||
const result = await clearActiveProductAction()
|
|
||||||
expect(result).toEqual({ error: 'Niet beschikbaar in demo-modus' })
|
|
||||||
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
|
||||||
vi.mock('next/headers', () => ({
|
|
||||||
cookies: vi.fn().mockResolvedValue({
|
|
||||||
set: vi.fn(),
|
|
||||||
get: vi.fn(),
|
|
||||||
delete: vi.fn(),
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
vi.mock('iron-session', () => ({
|
|
||||||
getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/session', () => ({
|
|
||||||
sessionOptions: { cookieName: 'test', password: 'test' },
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/product-access', () => ({
|
|
||||||
productAccessFilter: vi.fn().mockReturnValue({}),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: {
|
|
||||||
sprint: { findFirst: vi.fn() },
|
|
||||||
product: { findFirst: vi.fn() },
|
|
||||||
user: {
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
update: vi.fn().mockResolvedValue({}),
|
|
||||||
},
|
|
||||||
$executeRaw: vi.fn().mockResolvedValue(1),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
import { clearActiveSprintAction } from '@/actions/active-sprint'
|
|
||||||
|
|
||||||
const mockPrisma = prisma as unknown as {
|
|
||||||
product: { findFirst: ReturnType<typeof vi.fn> }
|
|
||||||
user: {
|
|
||||||
findUnique: ReturnType<typeof vi.fn>
|
|
||||||
update: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('clearActiveSprintAction', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('writes null instead of deleting the key', async () => {
|
|
||||||
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
|
|
||||||
mockPrisma.user.findUnique.mockResolvedValueOnce({
|
|
||||||
settings: { layout: { activeSprints: { p1: 'sprint-1', p2: 'sprint-2' } } },
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await clearActiveSprintAction('p1')
|
|
||||||
|
|
||||||
expect(result).toEqual({ success: true })
|
|
||||||
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
|
|
||||||
data: { settings: { layout?: { activeSprints?: Record<string, string | null> } } }
|
|
||||||
}
|
|
||||||
expect(updateArg.data.settings.layout?.activeSprints).toEqual({
|
|
||||||
p1: null,
|
|
||||||
p2: 'sprint-2',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('preserves other product keys when clearing one', async () => {
|
|
||||||
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
|
|
||||||
mockPrisma.user.findUnique.mockResolvedValueOnce({
|
|
||||||
settings: {
|
|
||||||
layout: {
|
|
||||||
activeSprints: { p1: 'sprint-1', p2: 'sprint-2', p3: null },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await clearActiveSprintAction('p1')
|
|
||||||
|
|
||||||
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
|
|
||||||
data: { settings: { layout?: { activeSprints?: Record<string, string | null> } } }
|
|
||||||
}
|
|
||||||
expect(updateArg.data.settings.layout?.activeSprints).toEqual({
|
|
||||||
p1: null,
|
|
||||||
p2: 'sprint-2',
|
|
||||||
p3: null,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects when product is not accessible', async () => {
|
|
||||||
mockPrisma.product.findFirst.mockResolvedValueOnce(null)
|
|
||||||
|
|
||||||
const result = await clearActiveSprintAction('p1')
|
|
||||||
|
|
||||||
expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' })
|
|
||||||
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects invalid productId', async () => {
|
|
||||||
const result = await clearActiveSprintAction('')
|
|
||||||
|
|
||||||
expect(result).toEqual({ error: 'Ongeldig product-id' })
|
|
||||||
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
const {
|
|
||||||
redirectMock,
|
|
||||||
verifyUserMock,
|
|
||||||
headerGetMock,
|
|
||||||
sessionSaveMock,
|
|
||||||
requireSessionMock,
|
|
||||||
prismaUserUpdateMock,
|
|
||||||
prismaUserRoleFindFirstMock,
|
|
||||||
} = vi.hoisted(() => ({
|
|
||||||
redirectMock: vi.fn((path: string) => { throw new Error(`REDIRECT:${path}`) }),
|
|
||||||
verifyUserMock: vi.fn(),
|
|
||||||
headerGetMock: vi.fn(),
|
|
||||||
sessionSaveMock: vi.fn(),
|
|
||||||
requireSessionMock: vi.fn(),
|
|
||||||
prismaUserUpdateMock: vi.fn(),
|
|
||||||
prismaUserRoleFindFirstMock: vi.fn().mockResolvedValue(null),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('next/navigation', () => ({ redirect: redirectMock }))
|
|
||||||
vi.mock('next/headers', () => ({
|
|
||||||
cookies: vi.fn().mockResolvedValue({}),
|
|
||||||
headers: vi.fn().mockResolvedValue({ get: headerGetMock }),
|
|
||||||
}))
|
|
||||||
vi.mock('iron-session', () => ({
|
|
||||||
getIronSession: vi.fn().mockResolvedValue({
|
|
||||||
userId: '',
|
|
||||||
isDemo: false,
|
|
||||||
save: sessionSaveMock,
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/session', () => ({ sessionOptions: { cookieName: 't', password: 't' } }))
|
|
||||||
vi.mock('@/lib/auth', () => ({
|
|
||||||
verifyUser: verifyUserMock,
|
|
||||||
registerUser: vi.fn(),
|
|
||||||
hashPassword: vi.fn().mockResolvedValue('hashed'),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/auth-guard', () => ({ requireSession: requireSessionMock }))
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: {
|
|
||||||
user: { update: prismaUserUpdateMock },
|
|
||||||
userRole: { findFirst: prismaUserRoleFindFirstMock },
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/rate-limit', () => ({ checkRateLimit: vi.fn().mockReturnValue(true) }))
|
|
||||||
|
|
||||||
import { loginAction, resetPasswordAction } from '@/actions/auth'
|
|
||||||
|
|
||||||
const IPHONE_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) Mobile/15E148 Safari/604.1'
|
|
||||||
const IPAD_UA = 'Mozilla/5.0 (iPad; CPU OS 17_4 like Mac OS X) Safari/604.1'
|
|
||||||
const DESKTOP_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) Chrome/124.0.0.0 Safari/537.36'
|
|
||||||
|
|
||||||
function fd(username: string, password: string) {
|
|
||||||
const f = new FormData()
|
|
||||||
f.set('username', username)
|
|
||||||
f.set('password', password)
|
|
||||||
return f
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
redirectMock.mockClear()
|
|
||||||
verifyUserMock.mockReset()
|
|
||||||
headerGetMock.mockReset()
|
|
||||||
sessionSaveMock.mockReset()
|
|
||||||
requireSessionMock.mockReset()
|
|
||||||
prismaUserUpdateMock.mockReset()
|
|
||||||
prismaUserRoleFindFirstMock.mockResolvedValue(null)
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('loginAction UA-redirect', () => {
|
|
||||||
it('phone-UA + actief product → /m/products/[id]/solo', async () => {
|
|
||||||
verifyUserMock.mockResolvedValue({ id: 'u1', is_demo: false, active_product_id: 'p1' })
|
|
||||||
headerGetMock.mockReturnValue(IPHONE_UA)
|
|
||||||
await expect(loginAction(undefined, fd('alice', 'secret123'))).rejects.toThrow('REDIRECT:/m/products/p1/solo')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('phone-UA zonder actief product → /m/settings', async () => {
|
|
||||||
verifyUserMock.mockResolvedValue({ id: 'u1', is_demo: false, active_product_id: null })
|
|
||||||
headerGetMock.mockReturnValue(IPHONE_UA)
|
|
||||||
await expect(loginAction(undefined, fd('alice', 'secret123'))).rejects.toThrow('REDIRECT:/m/settings')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('tablet-UA (iPad) → /dashboard', async () => {
|
|
||||||
verifyUserMock.mockResolvedValue({ id: 'u1', is_demo: false, active_product_id: 'p1' })
|
|
||||||
headerGetMock.mockReturnValue(IPAD_UA)
|
|
||||||
await expect(loginAction(undefined, fd('alice', 'secret123'))).rejects.toThrow('REDIRECT:/dashboard')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('desktop-UA → /dashboard', async () => {
|
|
||||||
verifyUserMock.mockResolvedValue({ id: 'u1', is_demo: false, active_product_id: 'p1' })
|
|
||||||
headerGetMock.mockReturnValue(DESKTOP_UA)
|
|
||||||
await expect(loginAction(undefined, fd('alice', 'secret123'))).rejects.toThrow('REDIRECT:/dashboard')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('geen UA-header → /dashboard', async () => {
|
|
||||||
verifyUserMock.mockResolvedValue({ id: 'u1', is_demo: false, active_product_id: 'p1' })
|
|
||||||
headerGetMock.mockReturnValue(null)
|
|
||||||
await expect(loginAction(undefined, fd('alice', 'secret123'))).rejects.toThrow('REDIRECT:/dashboard')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('demo-user op phone volgt dezelfde routing', async () => {
|
|
||||||
verifyUserMock.mockResolvedValue({ id: 'demo', is_demo: true, active_product_id: 'p1' })
|
|
||||||
headerGetMock.mockReturnValue(IPHONE_UA)
|
|
||||||
await expect(loginAction(undefined, fd('demo', 'demo123pw'))).rejects.toThrow('REDIRECT:/m/products/p1/solo')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('resetPasswordAction', () => {
|
|
||||||
function fdReset(password: string, confirm: string) {
|
|
||||||
const f = new FormData()
|
|
||||||
f.set('password', password)
|
|
||||||
f.set('confirm', confirm)
|
|
||||||
return f
|
|
||||||
}
|
|
||||||
|
|
||||||
it('redirect /dashboard na succesvolle reset', async () => {
|
|
||||||
requireSessionMock.mockResolvedValue({ userId: 'u1' })
|
|
||||||
prismaUserUpdateMock.mockResolvedValue({})
|
|
||||||
await expect(resetPasswordAction(undefined, fdReset('nieuwpass1', 'nieuwpass1'))).rejects.toThrow('REDIRECT:/dashboard')
|
|
||||||
expect(prismaUserUpdateMock).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
where: { id: 'u1' },
|
|
||||||
data: expect.objectContaining({ password_hash: 'hashed', must_reset_password: false }),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('fout als wachtwoorden niet overeenkomen', async () => {
|
|
||||||
requireSessionMock.mockResolvedValue({ userId: 'u1' })
|
|
||||||
const result = await resetPasswordAction(undefined, fdReset('nieuwpass1', 'anderpass1'))
|
|
||||||
expect(result).toMatchObject({ error: expect.objectContaining({ confirm: expect.any(Array) }) })
|
|
||||||
expect(prismaUserUpdateMock).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('fout als wachtwoord te kort is', async () => {
|
|
||||||
requireSessionMock.mockResolvedValue({ userId: 'u1' })
|
|
||||||
const result = await resetPasswordAction(undefined, fdReset('kort', 'kort'))
|
|
||||||
expect(result).toMatchObject({ error: expect.objectContaining({ password: expect.any(Array) }) })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
/**
|
|
||||||
* Per-task batch enqueue is gedeprecateerd ten gunste van startSprintRunAction
|
|
||||||
* (zie actions/sprint-runs.ts). De functies blijven exporteerbaar als stub voor
|
|
||||||
* backwards-compat met UI-componenten die in F4 worden vervangen.
|
|
||||||
*/
|
|
||||||
import { describe, it, expect, vi } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
|
||||||
vi.mock('@/lib/auth', () => ({ getSession: vi.fn() }))
|
|
||||||
vi.mock('@/lib/prisma', () => ({ prisma: {} }))
|
|
||||||
|
|
||||||
import {
|
|
||||||
previewEnqueueAllAction,
|
|
||||||
enqueueClaudeJobsBatchAction,
|
|
||||||
} from '@/actions/claude-jobs'
|
|
||||||
|
|
||||||
describe('previewEnqueueAllAction (deprecated)', () => {
|
|
||||||
it('retourneert een deprecation-error', async () => {
|
|
||||||
const result = await previewEnqueueAllAction('prod-1')
|
|
||||||
expect(result).toMatchObject({ error: expect.stringContaining('vervangen') })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('enqueueClaudeJobsBatchAction (deprecated)', () => {
|
|
||||||
it('retourneert een deprecation-error', async () => {
|
|
||||||
const result = await enqueueClaudeJobsBatchAction('prod-1', ['t1', 't2'])
|
|
||||||
expect(result).toMatchObject({ error: expect.stringContaining('Start Sprint') })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,241 +0,0 @@
|
||||||
/**
|
|
||||||
* Per-task enqueue-acties zijn gedeprecateerd. cancelClaudeJobAction blijft
|
|
||||||
* actief — gebruikt voor het annuleren van losse jobs (bv. idea-jobs).
|
|
||||||
*/
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
const {
|
|
||||||
mockGetSession,
|
|
||||||
mockFindFirstJob,
|
|
||||||
mockUpdateJob,
|
|
||||||
mockUpdateManyJob,
|
|
||||||
mockUpdateManySprintTaskExecution,
|
|
||||||
mockTransaction,
|
|
||||||
mockExecuteRaw,
|
|
||||||
} = vi.hoisted(() => {
|
|
||||||
const mockUpdateManyJob = vi.fn()
|
|
||||||
const mockUpdateManySprintTaskExecution = vi.fn()
|
|
||||||
const mockTransaction = vi.fn()
|
|
||||||
return {
|
|
||||||
mockGetSession: vi.fn(),
|
|
||||||
mockFindFirstJob: vi.fn(),
|
|
||||||
mockUpdateJob: vi.fn(),
|
|
||||||
mockUpdateManyJob,
|
|
||||||
mockUpdateManySprintTaskExecution,
|
|
||||||
mockTransaction,
|
|
||||||
mockExecuteRaw: vi.fn().mockResolvedValue(undefined),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
|
||||||
vi.mock('@/lib/auth', () => ({ getSession: mockGetSession }))
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: {
|
|
||||||
claudeJob: {
|
|
||||||
findFirst: mockFindFirstJob,
|
|
||||||
update: mockUpdateJob,
|
|
||||||
updateMany: mockUpdateManyJob,
|
|
||||||
},
|
|
||||||
sprintTaskExecution: {
|
|
||||||
updateMany: mockUpdateManySprintTaskExecution,
|
|
||||||
},
|
|
||||||
$transaction: mockTransaction,
|
|
||||||
$executeRaw: mockExecuteRaw,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
import {
|
|
||||||
enqueueClaudeJobAction,
|
|
||||||
enqueueAllTodoJobsAction,
|
|
||||||
cancelClaudeJobAction,
|
|
||||||
restartClaudeJobAction,
|
|
||||||
} from '@/actions/claude-jobs'
|
|
||||||
|
|
||||||
const SESSION_USER = { userId: 'user-1', isDemo: false }
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockExecuteRaw.mockResolvedValue(undefined)
|
|
||||||
mockTransaction.mockImplementation(async (fn: (tx: unknown) => Promise<unknown>) =>
|
|
||||||
fn({
|
|
||||||
claudeJob: { updateMany: mockUpdateManyJob },
|
|
||||||
sprintTaskExecution: { updateMany: mockUpdateManySprintTaskExecution },
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('enqueueClaudeJobAction (deprecated)', () => {
|
|
||||||
it('retourneert een deprecation-error', async () => {
|
|
||||||
const result = await enqueueClaudeJobAction('task-1')
|
|
||||||
expect(result).toMatchObject({ error: expect.stringContaining('Start Sprint') })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('enqueueAllTodoJobsAction (deprecated)', () => {
|
|
||||||
it('retourneert een deprecation-error', async () => {
|
|
||||||
const result = await enqueueAllTodoJobsAction('prod-1')
|
|
||||||
expect(result).toMatchObject({ error: expect.stringContaining('Start Sprint') })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('cancelClaudeJobAction', () => {
|
|
||||||
it('cancelt een actieve job', async () => {
|
|
||||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
||||||
mockFindFirstJob.mockResolvedValue({
|
|
||||||
id: 'job-1',
|
|
||||||
status: 'QUEUED',
|
|
||||||
task_id: 'task-1',
|
|
||||||
product_id: 'prod-1',
|
|
||||||
})
|
|
||||||
mockUpdateJob.mockResolvedValue(undefined)
|
|
||||||
|
|
||||||
const result = await cancelClaudeJobAction('job-1')
|
|
||||||
|
|
||||||
expect(result).toEqual({ success: true })
|
|
||||||
expect(mockUpdateJob).toHaveBeenCalledWith({
|
|
||||||
where: { id: 'job-1' },
|
|
||||||
data: expect.objectContaining({ status: 'CANCELLED' }),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('weigert demo-sessie', async () => {
|
|
||||||
mockGetSession.mockResolvedValue({ userId: 'demo', isDemo: true })
|
|
||||||
|
|
||||||
const result = await cancelClaudeJobAction('job-1')
|
|
||||||
expect(result).toMatchObject({ error: expect.stringContaining('demo') })
|
|
||||||
expect(mockUpdateJob).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('retourneert error als job niet gevonden', async () => {
|
|
||||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
||||||
mockFindFirstJob.mockResolvedValue(null)
|
|
||||||
|
|
||||||
const result = await cancelClaudeJobAction('nonexistent')
|
|
||||||
expect(result).toMatchObject({ error: expect.stringContaining('niet gevonden') })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('weigert wanneer job niet meer actief is', async () => {
|
|
||||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
||||||
mockFindFirstJob.mockResolvedValue({
|
|
||||||
id: 'job-1',
|
|
||||||
status: 'DONE',
|
|
||||||
task_id: 'task-1',
|
|
||||||
product_id: 'prod-1',
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await cancelClaudeJobAction('job-1')
|
|
||||||
expect(result).toMatchObject({ error: expect.stringContaining('actieve') })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('restartClaudeJobAction', () => {
|
|
||||||
const FAILED_JOB = {
|
|
||||||
id: 'job-1',
|
|
||||||
status: 'FAILED',
|
|
||||||
kind: 'TASK_IMPLEMENTATION',
|
|
||||||
task_id: 'task-1',
|
|
||||||
idea_id: null,
|
|
||||||
sprint_run_id: null,
|
|
||||||
product_id: 'prod-1',
|
|
||||||
}
|
|
||||||
|
|
||||||
it('reset een FAILED job naar QUEUED (happy path)', async () => {
|
|
||||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
||||||
mockFindFirstJob.mockResolvedValue(FAILED_JOB)
|
|
||||||
mockUpdateManyJob.mockResolvedValue({ count: 1 })
|
|
||||||
|
|
||||||
const result = await restartClaudeJobAction('job-1')
|
|
||||||
|
|
||||||
expect(result).toEqual({ success: true })
|
|
||||||
expect(mockUpdateManyJob).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
where: expect.objectContaining({ id: 'job-1', status: { in: ['FAILED', 'CANCELLED', 'SKIPPED'] } }),
|
|
||||||
data: expect.objectContaining({ status: 'QUEUED' }),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
expect(mockExecuteRaw).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reset een CANCELLED job naar QUEUED', async () => {
|
|
||||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
||||||
mockFindFirstJob.mockResolvedValue({ ...FAILED_JOB, status: 'CANCELLED' })
|
|
||||||
mockUpdateManyJob.mockResolvedValue({ count: 1 })
|
|
||||||
|
|
||||||
const result = await restartClaudeJobAction('job-1')
|
|
||||||
expect(result).toEqual({ success: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reset een SKIPPED job naar QUEUED', async () => {
|
|
||||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
||||||
mockFindFirstJob.mockResolvedValue({ ...FAILED_JOB, status: 'SKIPPED' })
|
|
||||||
mockUpdateManyJob.mockResolvedValue({ count: 1 })
|
|
||||||
|
|
||||||
const result = await restartClaudeJobAction('job-1')
|
|
||||||
expect(result).toEqual({ success: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('weigert demo-sessie', async () => {
|
|
||||||
mockGetSession.mockResolvedValue({ userId: 'demo', isDemo: true })
|
|
||||||
|
|
||||||
const result = await restartClaudeJobAction('job-1')
|
|
||||||
expect(result).toMatchObject({ error: expect.stringContaining('demo') })
|
|
||||||
expect(mockUpdateManyJob).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('retourneert error als job niet gevonden', async () => {
|
|
||||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
||||||
mockFindFirstJob.mockResolvedValue(null)
|
|
||||||
|
|
||||||
const result = await restartClaudeJobAction('job-1')
|
|
||||||
expect(result).toMatchObject({ error: expect.stringContaining('niet gevonden') })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('weigert wanneer job een niet-restartbare status heeft', async () => {
|
|
||||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
||||||
mockFindFirstJob.mockResolvedValue({ ...FAILED_JOB, status: 'DONE' })
|
|
||||||
|
|
||||||
const result = await restartClaudeJobAction('job-1')
|
|
||||||
expect(result).toMatchObject({ error: expect.stringContaining('mislukte') })
|
|
||||||
expect(mockUpdateManyJob).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('retourneert error bij race-conditie (updateMany count === 0)', async () => {
|
|
||||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
||||||
mockFindFirstJob.mockResolvedValue(FAILED_JOB)
|
|
||||||
mockUpdateManyJob.mockResolvedValue({ count: 0 })
|
|
||||||
|
|
||||||
const result = await restartClaudeJobAction('job-1')
|
|
||||||
expect(result).toMatchObject({ error: expect.stringContaining('gewijzigd') })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reset ook SprintTaskExecution-rows bij SPRINT_IMPLEMENTATION', async () => {
|
|
||||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
||||||
mockFindFirstJob.mockResolvedValue({
|
|
||||||
...FAILED_JOB,
|
|
||||||
kind: 'SPRINT_IMPLEMENTATION',
|
|
||||||
sprint_run_id: 'run-1',
|
|
||||||
})
|
|
||||||
mockUpdateManyJob.mockResolvedValue({ count: 1 })
|
|
||||||
mockUpdateManySprintTaskExecution.mockResolvedValue({ count: 3 })
|
|
||||||
|
|
||||||
const result = await restartClaudeJobAction('job-1')
|
|
||||||
|
|
||||||
expect(result).toEqual({ success: true })
|
|
||||||
expect(mockUpdateManySprintTaskExecution).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
where: { sprint_job_id: 'job-1' },
|
|
||||||
data: expect.objectContaining({ status: 'PENDING' }),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reset geen SprintTaskExecution-rows bij TASK_IMPLEMENTATION', async () => {
|
|
||||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
||||||
mockFindFirstJob.mockResolvedValue(FAILED_JOB)
|
|
||||||
mockUpdateManyJob.mockResolvedValue({ count: 1 })
|
|
||||||
|
|
||||||
await restartClaudeJobAction('job-1')
|
|
||||||
|
|
||||||
expect(mockUpdateManySprintTaskExecution).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,290 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
|
||||||
vi.mock('next/headers', () => ({
|
|
||||||
cookies: vi.fn().mockResolvedValue({
|
|
||||||
set: vi.fn(),
|
|
||||||
get: vi.fn(),
|
|
||||||
delete: vi.fn(),
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
vi.mock('iron-session', () => ({
|
|
||||||
getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/session', () => ({
|
|
||||||
sessionOptions: { cookieName: 'test', password: 'test' },
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/product-access', () => ({
|
|
||||||
productAccessFilter: vi.fn().mockReturnValue({}),
|
|
||||||
getAccessibleProduct: vi.fn().mockResolvedValue({ id: 'product-1' }),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/rate-limit', () => ({
|
|
||||||
enforceUserRateLimit: vi.fn().mockReturnValue(null),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/code-server', () => ({
|
|
||||||
createWithCodeRetry: vi.fn(),
|
|
||||||
generateNextSprintCode: vi.fn(),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/active-sprint', () => ({
|
|
||||||
setActiveSprintInSettings: vi.fn().mockResolvedValue(undefined),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/prisma', () => {
|
|
||||||
const txClient = {
|
|
||||||
sprint: { create: vi.fn() },
|
|
||||||
story: { updateMany: vi.fn() },
|
|
||||||
task: { updateMany: vi.fn() },
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
prisma: {
|
|
||||||
sprint: { findFirst: vi.fn() },
|
|
||||||
story: {
|
|
||||||
findMany: vi.fn(),
|
|
||||||
updateMany: vi.fn(),
|
|
||||||
},
|
|
||||||
task: {
|
|
||||||
findMany: vi.fn(),
|
|
||||||
updateMany: vi.fn(),
|
|
||||||
},
|
|
||||||
$transaction: vi.fn(async (fn: (tx: typeof txClient) => unknown) => fn(txClient)),
|
|
||||||
__txClient: txClient,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
import { commitSprintMembershipAction } from '@/actions/sprints'
|
|
||||||
|
|
||||||
type Mocked = {
|
|
||||||
sprint: { findFirst: ReturnType<typeof vi.fn> }
|
|
||||||
story: {
|
|
||||||
findMany: ReturnType<typeof vi.fn>
|
|
||||||
updateMany: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
task: {
|
|
||||||
findMany: ReturnType<typeof vi.fn>
|
|
||||||
updateMany: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
$transaction: ReturnType<typeof vi.fn>
|
|
||||||
__txClient: {
|
|
||||||
sprint: { create: ReturnType<typeof vi.fn> }
|
|
||||||
story: { updateMany: ReturnType<typeof vi.fn> }
|
|
||||||
task: { updateMany: ReturnType<typeof vi.fn> }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const mockPrisma = prisma as unknown as Mocked
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockPrisma.sprint.findFirst.mockReset().mockResolvedValue({
|
|
||||||
id: 'sprint-active',
|
|
||||||
product_id: 'product-1',
|
|
||||||
})
|
|
||||||
mockPrisma.story.findMany.mockReset()
|
|
||||||
mockPrisma.story.updateMany.mockReset()
|
|
||||||
mockPrisma.task.findMany.mockReset()
|
|
||||||
mockPrisma.task.updateMany.mockReset()
|
|
||||||
mockPrisma.$transaction.mockImplementation(
|
|
||||||
async (fn: (tx: typeof mockPrisma.__txClient) => unknown) =>
|
|
||||||
fn(mockPrisma.__txClient),
|
|
||||||
)
|
|
||||||
mockPrisma.__txClient.story.updateMany.mockReset().mockResolvedValue({ count: 0 })
|
|
||||||
mockPrisma.__txClient.task.updateMany.mockReset().mockResolvedValue({ count: 0 })
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('commitSprintMembershipAction', () => {
|
|
||||||
it('happy path: eligible adds + valid removes → transactie commits', async () => {
|
|
||||||
// adds-partition: alle eligible (sprint_id=null + niet DONE)
|
|
||||||
mockPrisma.story.findMany
|
|
||||||
// partition lookup
|
|
||||||
.mockResolvedValueOnce([
|
|
||||||
{ id: 's-add-1', sprint_id: null, status: 'OPEN', sprint: null },
|
|
||||||
])
|
|
||||||
// removes-filter (sprint_id == activeSprintId)
|
|
||||||
.mockResolvedValueOnce([{ id: 's-rem-1' }])
|
|
||||||
// affectedStories
|
|
||||||
.mockResolvedValueOnce([
|
|
||||||
{ pbi_id: 'pbiA' },
|
|
||||||
{ pbi_id: 'pbiB' },
|
|
||||||
])
|
|
||||||
mockPrisma.task.findMany.mockResolvedValueOnce([{ id: 't1' }])
|
|
||||||
|
|
||||||
const result = await commitSprintMembershipAction({
|
|
||||||
activeSprintId: 'sprint-active',
|
|
||||||
adds: ['s-add-1'],
|
|
||||||
removes: ['s-rem-1'],
|
|
||||||
})
|
|
||||||
|
|
||||||
expect('success' in result).toBe(true)
|
|
||||||
if ('success' in result) {
|
|
||||||
expect(result.affectedStoryIds.sort()).toEqual(['s-add-1', 's-rem-1'])
|
|
||||||
expect(result.affectedPbiIds.sort()).toEqual(['pbiA', 'pbiB'])
|
|
||||||
expect(result.affectedTaskIds).toEqual(['t1'])
|
|
||||||
expect(result.conflicts.notEligible).toEqual([])
|
|
||||||
expect(result.conflicts.alreadyRemoved).toEqual([])
|
|
||||||
}
|
|
||||||
expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1)
|
|
||||||
expect(mockPrisma.__txClient.story.updateMany).toHaveBeenCalledTimes(2)
|
|
||||||
expect(mockPrisma.__txClient.task.updateMany).toHaveBeenCalledTimes(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('add met status=DONE → conflicts.notEligible, story niet ge-update', async () => {
|
|
||||||
mockPrisma.story.findMany
|
|
||||||
.mockResolvedValueOnce([
|
|
||||||
{ id: 's-done', sprint_id: null, status: 'DONE', sprint: null },
|
|
||||||
])
|
|
||||||
// removes-filter (geen removes)
|
|
||||||
.mockResolvedValueOnce([])
|
|
||||||
|
|
||||||
const result = await commitSprintMembershipAction({
|
|
||||||
activeSprintId: 'sprint-active',
|
|
||||||
adds: ['s-done'],
|
|
||||||
removes: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
expect('success' in result).toBe(true)
|
|
||||||
if ('success' in result) {
|
|
||||||
expect(result.affectedStoryIds).toEqual([])
|
|
||||||
expect(result.conflicts.notEligible).toEqual([
|
|
||||||
{ storyId: 's-done', reason: 'DONE' },
|
|
||||||
])
|
|
||||||
}
|
|
||||||
// Geen transaction omdat er niets te commiten valt.
|
|
||||||
expect(mockPrisma.$transaction).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('add met sprint_id in andere OPEN sprint → conflicts.notEligible IN_OTHER_SPRINT', async () => {
|
|
||||||
mockPrisma.story.findMany
|
|
||||||
.mockResolvedValueOnce([
|
|
||||||
{
|
|
||||||
id: 's-elsewhere',
|
|
||||||
sprint_id: 'sprint-other',
|
|
||||||
status: 'IN_SPRINT',
|
|
||||||
sprint: { id: 'sprint-other', code: 'SP-O', status: 'OPEN' },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
.mockResolvedValueOnce([])
|
|
||||||
|
|
||||||
const result = await commitSprintMembershipAction({
|
|
||||||
activeSprintId: 'sprint-active',
|
|
||||||
adds: ['s-elsewhere'],
|
|
||||||
removes: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
if ('success' in result) {
|
|
||||||
expect(result.conflicts.notEligible).toEqual([
|
|
||||||
{ storyId: 's-elsewhere', reason: 'IN_OTHER_SPRINT' },
|
|
||||||
])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('remove voor story die niet in actieve sprint zit → conflicts.alreadyRemoved', async () => {
|
|
||||||
mockPrisma.story.findMany
|
|
||||||
// adds-partition (geen adds)
|
|
||||||
.mockResolvedValueOnce([])
|
|
||||||
// removes-filter — race scenario: story zit niet meer in active sprint
|
|
||||||
.mockResolvedValueOnce([])
|
|
||||||
|
|
||||||
const result = await commitSprintMembershipAction({
|
|
||||||
activeSprintId: 'sprint-active',
|
|
||||||
adds: [],
|
|
||||||
removes: ['s-was-removed'],
|
|
||||||
})
|
|
||||||
|
|
||||||
if ('success' in result) {
|
|
||||||
expect(result.affectedStoryIds).toEqual([])
|
|
||||||
expect(result.conflicts.alreadyRemoved).toEqual(['s-was-removed'])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('transactie: story.status=IN_SPRINT bij add, =OPEN bij remove', async () => {
|
|
||||||
mockPrisma.story.findMany
|
|
||||||
.mockResolvedValueOnce([
|
|
||||||
{ id: 's-add', sprint_id: null, status: 'OPEN', sprint: null },
|
|
||||||
])
|
|
||||||
.mockResolvedValueOnce([{ id: 's-rem' }])
|
|
||||||
.mockResolvedValueOnce([{ pbi_id: 'pbiA' }])
|
|
||||||
mockPrisma.task.findMany.mockResolvedValueOnce([])
|
|
||||||
|
|
||||||
await commitSprintMembershipAction({
|
|
||||||
activeSprintId: 'sprint-active',
|
|
||||||
adds: ['s-add'],
|
|
||||||
removes: ['s-rem'],
|
|
||||||
})
|
|
||||||
|
|
||||||
const calls = mockPrisma.__txClient.story.updateMany.mock.calls
|
|
||||||
// Add: status=IN_SPRINT + sprint_id=sprint-active
|
|
||||||
expect(calls[0][0].data).toEqual({
|
|
||||||
sprint_id: 'sprint-active',
|
|
||||||
status: 'IN_SPRINT',
|
|
||||||
})
|
|
||||||
// Remove: status=OPEN + sprint_id=null
|
|
||||||
expect(calls[1][0].data).toEqual({ sprint_id: null, status: 'OPEN' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('task.sprint_id wordt in dezelfde transactie ge-update', async () => {
|
|
||||||
mockPrisma.story.findMany
|
|
||||||
.mockResolvedValueOnce([
|
|
||||||
{ id: 's-add', sprint_id: null, status: 'OPEN', sprint: null },
|
|
||||||
])
|
|
||||||
.mockResolvedValueOnce([])
|
|
||||||
.mockResolvedValueOnce([{ pbi_id: 'pbiA' }])
|
|
||||||
mockPrisma.task.findMany.mockResolvedValueOnce([])
|
|
||||||
|
|
||||||
await commitSprintMembershipAction({
|
|
||||||
activeSprintId: 'sprint-active',
|
|
||||||
adds: ['s-add'],
|
|
||||||
removes: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(mockPrisma.__txClient.task.updateMany).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
where: { story_id: { in: ['s-add'] } },
|
|
||||||
data: { sprint_id: 'sprint-active' },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('return: affectedStoryIds + affectedPbiIds + affectedTaskIds + conflicts', async () => {
|
|
||||||
mockPrisma.story.findMany
|
|
||||||
.mockResolvedValueOnce([
|
|
||||||
{ id: 's-add', sprint_id: null, status: 'OPEN', sprint: null },
|
|
||||||
])
|
|
||||||
.mockResolvedValueOnce([{ id: 's-rem' }])
|
|
||||||
.mockResolvedValueOnce([
|
|
||||||
{ pbi_id: 'pbiA' },
|
|
||||||
{ pbi_id: 'pbiB' },
|
|
||||||
])
|
|
||||||
mockPrisma.task.findMany.mockResolvedValueOnce([
|
|
||||||
{ id: 't1' },
|
|
||||||
{ id: 't2' },
|
|
||||||
])
|
|
||||||
|
|
||||||
const result = await commitSprintMembershipAction({
|
|
||||||
activeSprintId: 'sprint-active',
|
|
||||||
adds: ['s-add'],
|
|
||||||
removes: ['s-rem'],
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
success: true,
|
|
||||||
affectedStoryIds: expect.arrayContaining(['s-add', 's-rem']),
|
|
||||||
affectedPbiIds: expect.arrayContaining(['pbiA', 'pbiB']),
|
|
||||||
affectedTaskIds: expect.arrayContaining(['t1', 't2']),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects when sprint is not accessible', async () => {
|
|
||||||
mockPrisma.sprint.findFirst.mockResolvedValue(null)
|
|
||||||
|
|
||||||
const result = await commitSprintMembershipAction({
|
|
||||||
activeSprintId: 'sprint-active',
|
|
||||||
adds: [],
|
|
||||||
removes: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
expect('error' in result).toBe(true)
|
|
||||||
if ('error' in result) {
|
|
||||||
expect(result.code).toBe(403)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,300 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
|
||||||
vi.mock('next/headers', () => ({
|
|
||||||
cookies: vi.fn().mockResolvedValue({
|
|
||||||
set: vi.fn(),
|
|
||||||
get: vi.fn(),
|
|
||||||
delete: vi.fn(),
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
vi.mock('iron-session', () => ({
|
|
||||||
getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/session', () => ({
|
|
||||||
sessionOptions: { cookieName: 'test', password: 'test' },
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/product-access', () => ({
|
|
||||||
productAccessFilter: vi.fn().mockReturnValue({}),
|
|
||||||
getAccessibleProduct: vi.fn().mockResolvedValue({
|
|
||||||
id: 'product-1',
|
|
||||||
user_id: 'user-1',
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/rate-limit', () => ({
|
|
||||||
enforceUserRateLimit: vi.fn().mockReturnValue(null),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/code-server', () => ({
|
|
||||||
createWithCodeRetry: vi.fn(async (_gen, fn) => fn('SP-1')),
|
|
||||||
generateNextSprintCode: vi.fn().mockResolvedValue('SP-1'),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/active-sprint', () => ({
|
|
||||||
setActiveSprintInSettings: vi.fn().mockResolvedValue(undefined),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/prisma', () => {
|
|
||||||
const txClient = {
|
|
||||||
sprint: { create: vi.fn() },
|
|
||||||
story: { updateMany: vi.fn() },
|
|
||||||
task: { updateMany: vi.fn() },
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
prisma: {
|
|
||||||
sprint: {
|
|
||||||
create: vi.fn(),
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
story: {
|
|
||||||
findMany: vi.fn(),
|
|
||||||
updateMany: vi.fn(),
|
|
||||||
},
|
|
||||||
task: {
|
|
||||||
findMany: vi.fn(),
|
|
||||||
updateMany: vi.fn(),
|
|
||||||
},
|
|
||||||
pbi: { findMany: vi.fn() },
|
|
||||||
user: {
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
$transaction: vi.fn(async (fn: (tx: typeof txClient) => unknown) => fn(txClient)),
|
|
||||||
__txClient: txClient,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
import {
|
|
||||||
createSprintWithSelectionAction,
|
|
||||||
type CreateSprintWithSelectionInput,
|
|
||||||
} from '@/actions/sprints'
|
|
||||||
|
|
||||||
type Mocked = {
|
|
||||||
sprint: {
|
|
||||||
create: ReturnType<typeof vi.fn>
|
|
||||||
findFirst: ReturnType<typeof vi.fn>
|
|
||||||
update: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
story: {
|
|
||||||
findMany: ReturnType<typeof vi.fn>
|
|
||||||
updateMany: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
task: {
|
|
||||||
findMany: ReturnType<typeof vi.fn>
|
|
||||||
updateMany: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
$transaction: ReturnType<typeof vi.fn>
|
|
||||||
__txClient: {
|
|
||||||
sprint: { create: ReturnType<typeof vi.fn> }
|
|
||||||
story: { updateMany: ReturnType<typeof vi.fn> }
|
|
||||||
task: { updateMany: ReturnType<typeof vi.fn> }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const mockPrisma = prisma as unknown as Mocked
|
|
||||||
|
|
||||||
function baseInput(
|
|
||||||
overrides: Partial<CreateSprintWithSelectionInput> = {},
|
|
||||||
): CreateSprintWithSelectionInput {
|
|
||||||
return {
|
|
||||||
productId: 'product-1',
|
|
||||||
metadata: { goal: 'Sprint 1' },
|
|
||||||
pbiIntent: {},
|
|
||||||
storyOverrides: {},
|
|
||||||
...overrides,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockPrisma.sprint.create.mockReset()
|
|
||||||
mockPrisma.story.findMany.mockReset()
|
|
||||||
mockPrisma.story.updateMany.mockReset()
|
|
||||||
mockPrisma.task.findMany.mockReset()
|
|
||||||
mockPrisma.task.updateMany.mockReset()
|
|
||||||
mockPrisma.$transaction.mockImplementation(
|
|
||||||
async (fn: (tx: typeof mockPrisma.__txClient) => unknown) =>
|
|
||||||
fn(mockPrisma.__txClient),
|
|
||||||
)
|
|
||||||
mockPrisma.__txClient.sprint.create
|
|
||||||
.mockReset()
|
|
||||||
.mockResolvedValue({ id: 'sprint-1', code: 'SP-1' })
|
|
||||||
mockPrisma.__txClient.story.updateMany
|
|
||||||
.mockReset()
|
|
||||||
.mockResolvedValue({ count: 0 })
|
|
||||||
mockPrisma.__txClient.task.updateMany
|
|
||||||
.mockReset()
|
|
||||||
.mockResolvedValue({ count: 0 })
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('createSprintWithSelectionAction', () => {
|
|
||||||
it('resolves intent=all naar alle child-stories en weert overrides.remove', async () => {
|
|
||||||
// Stap 1: stories voor PBI-A (intent=all). Plus eligibility-fetch.
|
|
||||||
mockPrisma.story.findMany
|
|
||||||
// resolve step (only for pbis with intent='all')
|
|
||||||
.mockResolvedValueOnce([
|
|
||||||
{ id: 's1', pbi_id: 'pbiA' },
|
|
||||||
{ id: 's2', pbi_id: 'pbiA' },
|
|
||||||
{ id: 's3', pbi_id: 'pbiA' },
|
|
||||||
])
|
|
||||||
// partitionByEligibility — alle eligible
|
|
||||||
.mockResolvedValueOnce([
|
|
||||||
{ id: 's1', sprint_id: null, status: 'OPEN', sprint: null },
|
|
||||||
{ id: 's3', sprint_id: null, status: 'OPEN', sprint: null },
|
|
||||||
])
|
|
||||||
// affectedStories
|
|
||||||
.mockResolvedValueOnce([
|
|
||||||
{ pbi_id: 'pbiA' },
|
|
||||||
{ pbi_id: 'pbiA' },
|
|
||||||
])
|
|
||||||
mockPrisma.task.findMany.mockResolvedValueOnce([{ id: 't1' }])
|
|
||||||
|
|
||||||
const result = await createSprintWithSelectionAction(
|
|
||||||
baseInput({
|
|
||||||
pbiIntent: { pbiA: 'all' },
|
|
||||||
storyOverrides: { pbiA: { add: [], remove: ['s2'] } },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect('success' in result).toBe(true)
|
|
||||||
if ('success' in result) {
|
|
||||||
expect(result.affectedStoryIds).toEqual(['s1', 's3'])
|
|
||||||
expect(result.conflicts.notEligible).toEqual([])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('voegt storyOverrides.add toe over PBI heen (zelfs intent=none)', async () => {
|
|
||||||
// Geen PBI met intent=all → stap 1 wordt niet uitgevoerd.
|
|
||||||
mockPrisma.story.findMany
|
|
||||||
// partition
|
|
||||||
.mockResolvedValueOnce([
|
|
||||||
{ id: 's10', sprint_id: null, status: 'OPEN', sprint: null },
|
|
||||||
])
|
|
||||||
// affectedStories
|
|
||||||
.mockResolvedValueOnce([{ pbi_id: 'pbiB' }])
|
|
||||||
mockPrisma.task.findMany.mockResolvedValueOnce([])
|
|
||||||
|
|
||||||
const result = await createSprintWithSelectionAction(
|
|
||||||
baseInput({
|
|
||||||
pbiIntent: { pbiB: 'none' },
|
|
||||||
storyOverrides: { pbiB: { add: ['s10'], remove: [] } },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect('success' in result).toBe(true)
|
|
||||||
if ('success' in result) {
|
|
||||||
expect(result.affectedStoryIds).toEqual(['s10'])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('eligibility-filter classificeert DONE en cross-sprint stories', async () => {
|
|
||||||
mockPrisma.story.findMany
|
|
||||||
// resolve
|
|
||||||
.mockResolvedValueOnce([
|
|
||||||
{ id: 's1', pbi_id: 'pbiA' },
|
|
||||||
{ id: 's2', pbi_id: 'pbiA' },
|
|
||||||
{ id: 's3', pbi_id: 'pbiA' },
|
|
||||||
])
|
|
||||||
// partition: s1=DONE, s2=eligible, s3=in andere OPEN sprint
|
|
||||||
.mockResolvedValueOnce([
|
|
||||||
{ id: 's1', sprint_id: null, status: 'DONE', sprint: null },
|
|
||||||
{ id: 's2', sprint_id: null, status: 'OPEN', sprint: null },
|
|
||||||
{
|
|
||||||
id: 's3',
|
|
||||||
sprint_id: 'sprint-other',
|
|
||||||
status: 'IN_SPRINT',
|
|
||||||
sprint: { id: 'sprint-other', code: 'SP-O', status: 'OPEN' },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
// affectedStories
|
|
||||||
.mockResolvedValueOnce([{ pbi_id: 'pbiA' }])
|
|
||||||
mockPrisma.task.findMany.mockResolvedValueOnce([])
|
|
||||||
|
|
||||||
const result = await createSprintWithSelectionAction(
|
|
||||||
baseInput({ pbiIntent: { pbiA: 'all' } }),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect('success' in result).toBe(true)
|
|
||||||
if ('success' in result) {
|
|
||||||
expect(result.affectedStoryIds).toEqual(['s2'])
|
|
||||||
expect(result.conflicts.notEligible.map((n) => n.storyId).sort()).toEqual(
|
|
||||||
['s1', 's3'],
|
|
||||||
)
|
|
||||||
expect(result.conflicts.crossSprint.map((c) => c.storyId)).toEqual(['s3'])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('zet story.status=IN_SPRINT en task.sprint_id mee in dezelfde transactie', async () => {
|
|
||||||
mockPrisma.story.findMany
|
|
||||||
.mockResolvedValueOnce([{ id: 's1', pbi_id: 'pbiA' }])
|
|
||||||
.mockResolvedValueOnce([
|
|
||||||
{ id: 's1', sprint_id: null, status: 'OPEN', sprint: null },
|
|
||||||
])
|
|
||||||
.mockResolvedValueOnce([{ pbi_id: 'pbiA' }])
|
|
||||||
mockPrisma.task.findMany.mockResolvedValueOnce([{ id: 't1' }])
|
|
||||||
|
|
||||||
await createSprintWithSelectionAction(
|
|
||||||
baseInput({ pbiIntent: { pbiA: 'all' } }),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1)
|
|
||||||
expect(mockPrisma.__txClient.story.updateMany).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
data: expect.objectContaining({
|
|
||||||
sprint_id: 'sprint-1',
|
|
||||||
status: 'IN_SPRINT',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
expect(mockPrisma.__txClient.task.updateMany).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
data: { sprint_id: 'sprint-1' },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returnt affectedStoryIds + affectedPbiIds + affectedTaskIds', async () => {
|
|
||||||
mockPrisma.story.findMany
|
|
||||||
.mockResolvedValueOnce([
|
|
||||||
{ id: 's1', pbi_id: 'pbiA' },
|
|
||||||
{ id: 's2', pbi_id: 'pbiB' },
|
|
||||||
])
|
|
||||||
.mockResolvedValueOnce([
|
|
||||||
{ id: 's1', sprint_id: null, status: 'OPEN', sprint: null },
|
|
||||||
{ id: 's2', sprint_id: null, status: 'OPEN', sprint: null },
|
|
||||||
])
|
|
||||||
.mockResolvedValueOnce([{ pbi_id: 'pbiA' }, { pbi_id: 'pbiB' }])
|
|
||||||
mockPrisma.task.findMany.mockResolvedValueOnce([
|
|
||||||
{ id: 't1' },
|
|
||||||
{ id: 't2' },
|
|
||||||
])
|
|
||||||
|
|
||||||
const result = await createSprintWithSelectionAction(
|
|
||||||
baseInput({ pbiIntent: { pbiA: 'all', pbiB: 'all' } }),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect('success' in result).toBe(true)
|
|
||||||
if ('success' in result) {
|
|
||||||
expect(result.affectedStoryIds.sort()).toEqual(['s1', 's2'])
|
|
||||||
expect(result.affectedPbiIds.sort()).toEqual(['pbiA', 'pbiB'])
|
|
||||||
expect(result.affectedTaskIds.sort()).toEqual(['t1', 't2'])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returnt error wanneer geen eligible stories overblijven', async () => {
|
|
||||||
mockPrisma.story.findMany
|
|
||||||
.mockResolvedValueOnce([{ id: 's1', pbi_id: 'pbiA' }])
|
|
||||||
// s1 is DONE → notEligible
|
|
||||||
.mockResolvedValueOnce([
|
|
||||||
{ id: 's1', sprint_id: null, status: 'DONE', sprint: null },
|
|
||||||
])
|
|
||||||
|
|
||||||
const result = await createSprintWithSelectionAction(
|
|
||||||
baseInput({ pbiIntent: { pbiA: 'all' } }),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect('error' in result).toBe(true)
|
|
||||||
if ('error' in result) {
|
|
||||||
expect(result.code).toBe(422)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,717 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
const { mockSession } = vi.hoisted(() => ({
|
|
||||||
mockSession: { userId: 'user-1', isDemo: false },
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
|
||||||
vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) }))
|
|
||||||
vi.mock('iron-session', () => ({
|
|
||||||
getIronSession: vi.fn().mockImplementation(async () => mockSession),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/session', () => ({
|
|
||||||
sessionOptions: { cookieName: 'test', password: 'test-password-32-chars-minimum-len' },
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/idea-code-server', () => ({
|
|
||||||
nextIdeaCode: vi.fn().mockResolvedValue('IDEA-001'),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: {
|
|
||||||
idea: {
|
|
||||||
create: vi.fn(),
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
delete: vi.fn(),
|
|
||||||
},
|
|
||||||
ideaLog: { create: vi.fn() },
|
|
||||||
claudeJob: {
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
create: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
claudeWorker: {
|
|
||||||
count: vi.fn(),
|
|
||||||
},
|
|
||||||
pbi: {
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
findMany: vi.fn(),
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
create: vi.fn(),
|
|
||||||
delete: vi.fn(),
|
|
||||||
},
|
|
||||||
story: {
|
|
||||||
findMany: vi.fn(),
|
|
||||||
create: vi.fn(),
|
|
||||||
},
|
|
||||||
task: {
|
|
||||||
findMany: vi.fn(),
|
|
||||||
create: vi.fn(),
|
|
||||||
count: vi.fn(),
|
|
||||||
findUnique: vi.fn().mockResolvedValue(null),
|
|
||||||
},
|
|
||||||
product: {
|
|
||||||
findUnique: vi.fn().mockResolvedValue(null),
|
|
||||||
},
|
|
||||||
$transaction: vi.fn(),
|
|
||||||
$executeRaw: vi.fn().mockResolvedValue(0),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
import {
|
|
||||||
createIdeaAction,
|
|
||||||
updateIdeaAction,
|
|
||||||
archiveIdeaAction,
|
|
||||||
deleteIdeaAction,
|
|
||||||
updateGrillMdAction,
|
|
||||||
updatePlanMdAction,
|
|
||||||
uploadPlanMdAction,
|
|
||||||
downloadIdeaMdAction,
|
|
||||||
startGrillJobAction,
|
|
||||||
startMakePlanJobAction,
|
|
||||||
cancelIdeaJobAction,
|
|
||||||
materializeIdeaPlanAction,
|
|
||||||
relinkIdeaPlanAction,
|
|
||||||
} from '@/actions/ideas'
|
|
||||||
|
|
||||||
type MockIdea = {
|
|
||||||
idea: { create: ReturnType<typeof vi.fn>; findFirst: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> }
|
|
||||||
ideaLog: { create: ReturnType<typeof vi.fn> }
|
|
||||||
claudeJob: { findFirst: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
|
|
||||||
claudeWorker: { count: ReturnType<typeof vi.fn> }
|
|
||||||
pbi: { findFirst: ReturnType<typeof vi.fn>; findMany: ReturnType<typeof vi.fn>; findUnique: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> }
|
|
||||||
story: { findMany: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn> }
|
|
||||||
task: { findMany: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn>; count: ReturnType<typeof vi.fn> }
|
|
||||||
$transaction: ReturnType<typeof vi.fn>
|
|
||||||
$executeRaw: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
const m = prisma as unknown as MockIdea
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockSession.userId = 'user-1'
|
|
||||||
mockSession.isDemo = false
|
|
||||||
// Default: $transaction passes its callback through with our mocked prisma
|
|
||||||
m.$transaction.mockImplementation(async (arg: unknown) => {
|
|
||||||
if (typeof arg === 'function') {
|
|
||||||
return (arg as (tx: unknown) => unknown)(m)
|
|
||||||
}
|
|
||||||
return arg
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('createIdeaAction', () => {
|
|
||||||
it('happy path: creates DRAFT idea with auto-generated code', async () => {
|
|
||||||
m.idea.create.mockResolvedValueOnce({ id: 'idea-1', code: 'IDEA-001' })
|
|
||||||
|
|
||||||
const r = await createIdeaAction({ title: 'Plant-watering reminder' })
|
|
||||||
expect(r).toEqual({ success: true, data: { id: 'idea-1', code: 'IDEA-001' } })
|
|
||||||
expect(m.idea.create).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
data: expect.objectContaining({
|
|
||||||
user_id: 'user-1',
|
|
||||||
code: 'IDEA-001',
|
|
||||||
title: 'Plant-watering reminder',
|
|
||||||
status: 'DRAFT',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects unauthenticated', async () => {
|
|
||||||
mockSession.userId = ''
|
|
||||||
const r = await createIdeaAction({ title: 'x' })
|
|
||||||
expect(r).toMatchObject({ error: expect.stringMatching(/ingelogd/), code: 401 })
|
|
||||||
expect(m.idea.create).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects demo-user', async () => {
|
|
||||||
mockSession.isDemo = true
|
|
||||||
const r = await createIdeaAction({ title: 'x' })
|
|
||||||
expect(r).toMatchObject({ error: expect.stringMatching(/demo/), code: 403 })
|
|
||||||
expect(m.idea.create).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects invalid title (zod 422)', async () => {
|
|
||||||
const r = await createIdeaAction({ title: ' ' })
|
|
||||||
expect(r).toMatchObject({ code: 422 })
|
|
||||||
expect(m.idea.create).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('updateIdeaAction', () => {
|
|
||||||
it('happy: updates editable idea (DRAFT)', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', status: 'DRAFT' })
|
|
||||||
m.idea.update.mockResolvedValueOnce({})
|
|
||||||
|
|
||||||
const r = await updateIdeaAction('idea-1', { title: 'Updated' })
|
|
||||||
expect(r).toEqual({ success: true })
|
|
||||||
expect(m.idea.update).toHaveBeenCalledWith({
|
|
||||||
where: { id: 'idea-1' },
|
|
||||||
data: { title: 'Updated' },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('blocks update on PLANNED (status-mismatch 422)', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', status: 'PLANNED' })
|
|
||||||
const r = await updateIdeaAction('idea-1', { title: 'x' })
|
|
||||||
expect(r).toMatchObject({ code: 422 })
|
|
||||||
expect(m.idea.update).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('blocks update during GRILLING', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', status: 'GRILLING' })
|
|
||||||
const r = await updateIdeaAction('idea-1', { title: 'x' })
|
|
||||||
expect(r).toMatchObject({ code: 422 })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns 404 when idea belongs to another user', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce(null)
|
|
||||||
const r = await updateIdeaAction('idea-1', { title: 'x' })
|
|
||||||
expect(r).toMatchObject({ code: 404 })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('deleteIdeaAction', () => {
|
|
||||||
it('happy: deletes idea without pbi', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', pbi_id: null })
|
|
||||||
const r = await deleteIdeaAction('idea-1')
|
|
||||||
expect(r).toEqual({ success: true })
|
|
||||||
expect(m.idea.delete).toHaveBeenCalledWith({ where: { id: 'idea-1' } })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('blocks deletion when PBI is linked', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', pbi_id: 'pbi-1' })
|
|
||||||
const r = await deleteIdeaAction('idea-1')
|
|
||||||
expect(r).toMatchObject({ code: 422 })
|
|
||||||
expect(m.idea.delete).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('archiveIdeaAction', () => {
|
|
||||||
it('archives owned idea', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1' })
|
|
||||||
const r = await archiveIdeaAction('idea-1')
|
|
||||||
expect(r).toEqual({ success: true })
|
|
||||||
expect(m.idea.update).toHaveBeenCalledWith({
|
|
||||||
where: { id: 'idea-1' },
|
|
||||||
data: { archived: true },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('updateGrillMdAction', () => {
|
|
||||||
it('happy: updates grill_md in GRILLED', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce({ status: 'GRILLED' })
|
|
||||||
const r = await updateGrillMdAction('idea-1', '# Updated grill')
|
|
||||||
expect(r).toEqual({ success: true })
|
|
||||||
expect(m.$transaction).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('blocks in DRAFT', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce({ status: 'DRAFT' })
|
|
||||||
const r = await updateGrillMdAction('idea-1', 'x')
|
|
||||||
expect(r).toMatchObject({ code: 422 })
|
|
||||||
expect(m.$transaction).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('updatePlanMdAction', () => {
|
|
||||||
const VALID_PLAN = `---
|
|
||||||
pbi:
|
|
||||||
title: Test
|
|
||||||
priority: 2
|
|
||||||
stories:
|
|
||||||
- title: S1
|
|
||||||
priority: 2
|
|
||||||
tasks:
|
|
||||||
- title: T1
|
|
||||||
priority: 2
|
|
||||||
---
|
|
||||||
|
|
||||||
body
|
|
||||||
`
|
|
||||||
|
|
||||||
it('happy: updates plan_md in PLAN_READY with valid yaml', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce({ status: 'PLAN_READY' })
|
|
||||||
const r = await updatePlanMdAction('idea-1', VALID_PLAN)
|
|
||||||
expect(r).toEqual({ success: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects invalid yaml (parse-fail 422 with details)', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce({ status: 'PLAN_READY' })
|
|
||||||
const r = await updatePlanMdAction('idea-1', '# no frontmatter')
|
|
||||||
expect(r).toMatchObject({ code: 422 })
|
|
||||||
expect((r as { details?: unknown }).details).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('blocks in PLANNED', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce({ status: 'PLANNED' })
|
|
||||||
const r = await updatePlanMdAction('idea-1', VALID_PLAN)
|
|
||||||
expect(r).toMatchObject({ code: 422 })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('uploadPlanMdAction', () => {
|
|
||||||
const VALID_PLAN = `---
|
|
||||||
pbi:
|
|
||||||
title: Uploaded
|
|
||||||
priority: 2
|
|
||||||
stories:
|
|
||||||
- title: S1
|
|
||||||
priority: 2
|
|
||||||
tasks:
|
|
||||||
- title: T1
|
|
||||||
priority: 2
|
|
||||||
---
|
|
||||||
|
|
||||||
body
|
|
||||||
`
|
|
||||||
|
|
||||||
it('happy: uploads from DRAFT — skips grill, sets PLAN_READY', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce({ status: 'DRAFT' })
|
|
||||||
const r = await uploadPlanMdAction('idea-1', VALID_PLAN)
|
|
||||||
expect(r).toEqual({ success: true })
|
|
||||||
expect(m.$transaction).toHaveBeenCalled()
|
|
||||||
const txnArg = m.$transaction.mock.calls.at(-1)?.[0] as unknown[] | undefined
|
|
||||||
expect(txnArg).toBeDefined()
|
|
||||||
// The first call in the transaction is the update — confirm status=PLAN_READY.
|
|
||||||
expect(m.idea.update).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
data: expect.objectContaining({ plan_md: VALID_PLAN, status: 'PLAN_READY' }),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('happy: uploads from GRILLED', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce({ status: 'GRILLED' })
|
|
||||||
const r = await uploadPlanMdAction('idea-1', VALID_PLAN)
|
|
||||||
expect(r).toEqual({ success: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('happy: overwrites existing plan from PLAN_READY', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce({ status: 'PLAN_READY' })
|
|
||||||
const r = await uploadPlanMdAction('idea-1', VALID_PLAN)
|
|
||||||
expect(r).toEqual({ success: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('happy: uploads from PLAN_FAILED (retry)', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce({ status: 'PLAN_FAILED' })
|
|
||||||
const r = await uploadPlanMdAction('idea-1', VALID_PLAN)
|
|
||||||
expect(r).toEqual({ success: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects from PLANNED (already materialized)', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce({ status: 'PLANNED' })
|
|
||||||
const r = await uploadPlanMdAction('idea-1', VALID_PLAN)
|
|
||||||
expect(r).toMatchObject({ code: 422 })
|
|
||||||
expect(m.$transaction).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects from GRILLING (job running)', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce({ status: 'GRILLING' })
|
|
||||||
const r = await uploadPlanMdAction('idea-1', VALID_PLAN)
|
|
||||||
expect(r).toMatchObject({ code: 422 })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects empty markdown', async () => {
|
|
||||||
const r = await uploadPlanMdAction('idea-1', ' \n ')
|
|
||||||
expect(r).toMatchObject({ code: 422 })
|
|
||||||
// Should fail before touching DB
|
|
||||||
expect(m.idea.findFirst).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects oversized markdown', async () => {
|
|
||||||
const huge = 'a'.repeat(100_001)
|
|
||||||
const r = await uploadPlanMdAction('idea-1', huge)
|
|
||||||
expect(r).toMatchObject({ code: 422 })
|
|
||||||
expect(m.idea.findFirst).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects invalid yaml (parse-fail 422 with details)', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce({ status: 'DRAFT' })
|
|
||||||
const r = await uploadPlanMdAction('idea-1', '# no frontmatter')
|
|
||||||
expect(r).toMatchObject({ code: 422 })
|
|
||||||
expect((r as { details?: unknown }).details).toBeDefined()
|
|
||||||
expect(m.$transaction).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns 404 when idea not found', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce(null)
|
|
||||||
const r = await uploadPlanMdAction('nope', VALID_PLAN)
|
|
||||||
expect(r).toMatchObject({ code: 404 })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('startGrillJobAction', () => {
|
|
||||||
const idea = {
|
|
||||||
id: 'idea-1',
|
|
||||||
status: 'DRAFT',
|
|
||||||
product_id: 'prod-1',
|
|
||||||
product: { id: 'prod-1', repo_url: 'https://github.com/x/y' },
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
m.idea.findFirst.mockResolvedValue(idea)
|
|
||||||
m.claudeJob.findFirst.mockResolvedValue(null)
|
|
||||||
m.claudeWorker.count.mockResolvedValue(1)
|
|
||||||
m.claudeJob.create.mockResolvedValue({ id: 'job-1' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('happy path: creates IDEA_GRILL job, flips status to GRILLING', async () => {
|
|
||||||
const r = await startGrillJobAction('idea-1')
|
|
||||||
expect(r).toMatchObject({ success: true, data: { job_id: 'job-1' } })
|
|
||||||
expect(m.$executeRaw).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('blocks demo-user', async () => {
|
|
||||||
mockSession.isDemo = true
|
|
||||||
const r = await startGrillJobAction('idea-1')
|
|
||||||
expect(r).toMatchObject({ code: 403 })
|
|
||||||
expect(m.claudeJob.create).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('blocks when product has no repo_url', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce({
|
|
||||||
...idea,
|
|
||||||
product: { id: 'prod-1', repo_url: null },
|
|
||||||
})
|
|
||||||
const r = await startGrillJobAction('idea-1')
|
|
||||||
expect(r).toMatchObject({ code: 422, error: expect.stringMatching(/repo_url/i) })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('blocks when no idea is unlinked', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce({ ...idea, product_id: null, product: null })
|
|
||||||
const r = await startGrillJobAction('idea-1')
|
|
||||||
expect(r).toMatchObject({ code: 422 })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('blocks when no worker is active', async () => {
|
|
||||||
m.claudeWorker.count.mockResolvedValueOnce(0)
|
|
||||||
const r = await startGrillJobAction('idea-1')
|
|
||||||
expect(r).toMatchObject({ code: 422, error: expect.stringMatching(/worker/i) })
|
|
||||||
expect(m.claudeJob.create).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('blocks when an active job already exists (409)', async () => {
|
|
||||||
m.claudeJob.findFirst.mockResolvedValueOnce({ id: 'existing-job' })
|
|
||||||
const r = await startGrillJobAction('idea-1')
|
|
||||||
expect(r).toMatchObject({ code: 409 })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('blocks invalid status (PLANNING)', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce({ ...idea, status: 'PLANNING' })
|
|
||||||
const r = await startGrillJobAction('idea-1')
|
|
||||||
expect(r).toMatchObject({ code: 422 })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('startMakePlanJobAction', () => {
|
|
||||||
const idea = {
|
|
||||||
id: 'idea-1',
|
|
||||||
status: 'GRILLED',
|
|
||||||
product_id: 'prod-1',
|
|
||||||
product: { id: 'prod-1', repo_url: 'https://github.com/x/y' },
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
m.idea.findFirst.mockResolvedValue(idea)
|
|
||||||
m.claudeJob.findFirst.mockResolvedValue(null)
|
|
||||||
m.claudeWorker.count.mockResolvedValue(1)
|
|
||||||
m.claudeJob.create.mockResolvedValue({ id: 'job-2' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('happy: GRILLED → PLANNING', async () => {
|
|
||||||
const r = await startMakePlanJobAction('idea-1')
|
|
||||||
expect(r).toMatchObject({ success: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('blocks from DRAFT (must grill first)', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce({ ...idea, status: 'DRAFT' })
|
|
||||||
const r = await startMakePlanJobAction('idea-1')
|
|
||||||
expect(r).toMatchObject({ code: 422 })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('cancelIdeaJobAction', () => {
|
|
||||||
it('grill cancel without prior grill_md → DRAFT', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce({
|
|
||||||
id: 'idea-1',
|
|
||||||
status: 'GRILLING',
|
|
||||||
grill_md: null,
|
|
||||||
plan_md: null,
|
|
||||||
})
|
|
||||||
m.claudeJob.findFirst.mockResolvedValueOnce({ id: 'job-1', kind: 'IDEA_GRILL' })
|
|
||||||
|
|
||||||
const r = await cancelIdeaJobAction('idea-1')
|
|
||||||
expect(r).toEqual({ success: true })
|
|
||||||
// Verify $transaction was called with 3 ops (job-update, idea-update, log)
|
|
||||||
expect(m.$transaction).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('grill re-grill cancel with prior grill_md → GRILLED', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce({
|
|
||||||
id: 'idea-1',
|
|
||||||
status: 'GRILLING',
|
|
||||||
grill_md: '# old grill',
|
|
||||||
plan_md: null,
|
|
||||||
})
|
|
||||||
m.claudeJob.findFirst.mockResolvedValueOnce({ id: 'job-1', kind: 'IDEA_GRILL' })
|
|
||||||
|
|
||||||
const r = await cancelIdeaJobAction('idea-1')
|
|
||||||
expect(r).toEqual({ success: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns 404 when no active job', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce({
|
|
||||||
id: 'idea-1',
|
|
||||||
status: 'GRILLED',
|
|
||||||
grill_md: null,
|
|
||||||
plan_md: null,
|
|
||||||
})
|
|
||||||
m.claudeJob.findFirst.mockResolvedValueOnce(null)
|
|
||||||
const r = await cancelIdeaJobAction('idea-1')
|
|
||||||
expect(r).toMatchObject({ code: 404 })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('materializeIdeaPlanAction', () => {
|
|
||||||
const VALID_PLAN = `---
|
|
||||||
pbi:
|
|
||||||
title: New PBI
|
|
||||||
priority: 2
|
|
||||||
stories:
|
|
||||||
- title: Story A
|
|
||||||
priority: 2
|
|
||||||
tasks:
|
|
||||||
- title: Task A1
|
|
||||||
priority: 2
|
|
||||||
implementation_plan: "1. Doe X"
|
|
||||||
- title: Task A2
|
|
||||||
priority: 2
|
|
||||||
- title: Story B
|
|
||||||
priority: 3
|
|
||||||
tasks:
|
|
||||||
- title: Task B1
|
|
||||||
priority: 3
|
|
||||||
---
|
|
||||||
|
|
||||||
body
|
|
||||||
`
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
m.idea.findFirst.mockResolvedValue({
|
|
||||||
id: 'idea-1',
|
|
||||||
status: 'PLAN_READY',
|
|
||||||
product_id: 'prod-1',
|
|
||||||
plan_md: VALID_PLAN,
|
|
||||||
})
|
|
||||||
m.pbi.findMany.mockResolvedValue([])
|
|
||||||
m.story.findMany.mockResolvedValue([])
|
|
||||||
m.task.findMany.mockResolvedValue([])
|
|
||||||
m.pbi.findFirst.mockResolvedValue(null)
|
|
||||||
m.pbi.create.mockResolvedValue({ id: 'pbi-1', code: 'PBI-1' })
|
|
||||||
m.story.create
|
|
||||||
.mockResolvedValueOnce({ id: 's-A' })
|
|
||||||
.mockResolvedValueOnce({ id: 's-B' })
|
|
||||||
m.task.create
|
|
||||||
.mockResolvedValueOnce({ id: 't-A1' })
|
|
||||||
.mockResolvedValueOnce({ id: 't-A2' })
|
|
||||||
.mockResolvedValueOnce({ id: 't-B1' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('happy: creates PBI + 2 stories + 3 tasks, links idea, returns ids; sort_order = parseCodeNumber(code)', async () => {
|
|
||||||
const r = await materializeIdeaPlanAction('idea-1')
|
|
||||||
expect(r).toMatchObject({
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
pbi_id: 'pbi-1',
|
|
||||||
pbi_code: 'PBI-1',
|
|
||||||
story_ids: ['s-A', 's-B'],
|
|
||||||
task_ids: ['t-A1', 't-A2', 't-B1'],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(m.pbi.create).toHaveBeenCalledTimes(1)
|
|
||||||
expect(m.story.create).toHaveBeenCalledTimes(2)
|
|
||||||
expect(m.task.create).toHaveBeenCalledTimes(3)
|
|
||||||
|
|
||||||
// story sort_order = parseCodeNumber(auto-code): ST-001→1, ST-002→2
|
|
||||||
expect(m.story.create.mock.calls[0][0].data.sort_order).toBe(1)
|
|
||||||
expect(m.story.create.mock.calls[1][0].data.sort_order).toBe(2)
|
|
||||||
|
|
||||||
// task sort_order = parseCodeNumber(auto-code): T-1→1, T-2→2, T-3→3
|
|
||||||
expect(m.task.create.mock.calls[0][0].data.sort_order).toBe(1)
|
|
||||||
expect(m.task.create.mock.calls[1][0].data.sort_order).toBe(2)
|
|
||||||
expect(m.task.create.mock.calls[2][0].data.sort_order).toBe(3)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('blocks when not PLAN_READY (e.g. GRILLED)', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce({
|
|
||||||
id: 'idea-1',
|
|
||||||
status: 'GRILLED',
|
|
||||||
product_id: 'prod-1',
|
|
||||||
plan_md: VALID_PLAN,
|
|
||||||
})
|
|
||||||
const r = await materializeIdeaPlanAction('idea-1')
|
|
||||||
expect(r).toMatchObject({ code: 422 })
|
|
||||||
expect(m.pbi.create).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns 422 with details on parse-fail', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce({
|
|
||||||
id: 'idea-1',
|
|
||||||
status: 'PLAN_READY',
|
|
||||||
product_id: 'prod-1',
|
|
||||||
plan_md: '# no frontmatter',
|
|
||||||
})
|
|
||||||
const r = await materializeIdeaPlanAction('idea-1')
|
|
||||||
expect(r).toMatchObject({ code: 422 })
|
|
||||||
expect((r as { details?: unknown }).details).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('blocks demo-user', async () => {
|
|
||||||
mockSession.isDemo = true
|
|
||||||
const r = await materializeIdeaPlanAction('idea-1')
|
|
||||||
expect(r).toMatchObject({ code: 403 })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns 409 on P2002 race', async () => {
|
|
||||||
m.$transaction.mockImplementationOnce(async () => {
|
|
||||||
throw new Error('Unique constraint failed (P2002)')
|
|
||||||
})
|
|
||||||
const r = await materializeIdeaPlanAction('idea-1')
|
|
||||||
expect(r).toMatchObject({ code: 409 })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('materializeIdeaPlanAction — existing PBI pre-check', () => {
|
|
||||||
const VALID_PLAN = `---
|
|
||||||
pbi:
|
|
||||||
title: New PBI
|
|
||||||
priority: 2
|
|
||||||
stories:
|
|
||||||
- title: Story A
|
|
||||||
priority: 2
|
|
||||||
tasks:
|
|
||||||
- title: Task A1
|
|
||||||
priority: 2
|
|
||||||
---
|
|
||||||
|
|
||||||
body
|
|
||||||
`
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
// Use a distinct userId to avoid sharing the rate-limit bucket with the
|
|
||||||
// materializeIdeaPlanAction describe block above.
|
|
||||||
mockSession.userId = 'user-precheck'
|
|
||||||
m.idea.findFirst.mockResolvedValue({
|
|
||||||
id: 'idea-1',
|
|
||||||
status: 'PLAN_READY',
|
|
||||||
product_id: 'prod-1',
|
|
||||||
plan_md: VALID_PLAN,
|
|
||||||
pbi_id: 'old-pbi',
|
|
||||||
})
|
|
||||||
m.pbi.findMany.mockResolvedValue([])
|
|
||||||
m.story.findMany.mockResolvedValue([])
|
|
||||||
m.task.findMany.mockResolvedValue([])
|
|
||||||
m.pbi.findFirst.mockResolvedValue(null)
|
|
||||||
m.pbi.findUnique.mockResolvedValue({ code: 'PBI-X' })
|
|
||||||
m.pbi.create.mockResolvedValue({ id: 'pbi-new', code: 'PBI-2' })
|
|
||||||
m.pbi.delete.mockResolvedValue({})
|
|
||||||
m.story.create.mockResolvedValue({ id: 's-1' })
|
|
||||||
m.task.create.mockResolvedValue({ id: 't-1' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('auto-vervang: deletes old PBI in transaction when no tasks executed', async () => {
|
|
||||||
m.task.count.mockResolvedValueOnce(0)
|
|
||||||
const r = await materializeIdeaPlanAction('idea-1')
|
|
||||||
expect(r).toMatchObject({ success: true, data: { pbi_id: 'pbi-new' } })
|
|
||||||
expect(m.pbi.delete).toHaveBeenCalledWith({ where: { id: 'old-pbi' } })
|
|
||||||
expect(m.pbi.create).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('conflict-409: returns PBI_HAS_ACTIVE_TASKS when executed tasks exist', async () => {
|
|
||||||
m.task.count.mockResolvedValueOnce(1)
|
|
||||||
const r = await materializeIdeaPlanAction('idea-1')
|
|
||||||
expect(r).toMatchObject({ code: 409, error: 'PBI_HAS_ACTIVE_TASKS:PBI-X' })
|
|
||||||
expect(m.pbi.create).not.toHaveBeenCalled()
|
|
||||||
expect(m.pbi.delete).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('alongside: skips old PBI delete and creates new PBI when allowAlongside=true', async () => {
|
|
||||||
m.task.count.mockResolvedValueOnce(1)
|
|
||||||
const r = await materializeIdeaPlanAction('idea-1', { allowAlongside: true })
|
|
||||||
expect(r).toMatchObject({ success: true, data: { pbi_id: 'pbi-new' } })
|
|
||||||
expect(m.pbi.delete).not.toHaveBeenCalled()
|
|
||||||
expect(m.pbi.create).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('relinkIdeaPlanAction', () => {
|
|
||||||
it('happy: PLANNED with pbi_id=null → PLAN_READY', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce({
|
|
||||||
id: 'idea-1',
|
|
||||||
status: 'PLANNED',
|
|
||||||
pbi_id: null,
|
|
||||||
})
|
|
||||||
const r = await relinkIdeaPlanAction('idea-1')
|
|
||||||
expect(r).toEqual({ success: true })
|
|
||||||
expect(m.$transaction).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('blocks when pbi still linked', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce({
|
|
||||||
id: 'idea-1',
|
|
||||||
status: 'PLANNED',
|
|
||||||
pbi_id: 'pbi-1',
|
|
||||||
})
|
|
||||||
const r = await relinkIdeaPlanAction('idea-1')
|
|
||||||
expect(r).toMatchObject({ code: 422 })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('blocks when not PLANNED', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce({
|
|
||||||
id: 'idea-1',
|
|
||||||
status: 'PLAN_READY',
|
|
||||||
pbi_id: null,
|
|
||||||
})
|
|
||||||
const r = await relinkIdeaPlanAction('idea-1')
|
|
||||||
expect(r).toMatchObject({ code: 422 })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('downloadIdeaMdAction', () => {
|
|
||||||
it('returns grill_md when present', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce({
|
|
||||||
code: 'IDEA-001',
|
|
||||||
grill_md: '# Idee\nscope',
|
|
||||||
plan_md: null,
|
|
||||||
})
|
|
||||||
const r = await downloadIdeaMdAction('idea-1', 'grill')
|
|
||||||
expect(r).toMatchObject({
|
|
||||||
success: true,
|
|
||||||
data: { filename: 'IDEA-001-grill.md', markdown: '# Idee\nscope' },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('404 when md not yet generated', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce({
|
|
||||||
code: 'IDEA-001',
|
|
||||||
grill_md: null,
|
|
||||||
plan_md: null,
|
|
||||||
})
|
|
||||||
const r = await downloadIdeaMdAction('idea-1', 'plan')
|
|
||||||
expect(r).toMatchObject({ code: 404 })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('demo MAY download (read-only operation)', async () => {
|
|
||||||
mockSession.isDemo = true
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce({
|
|
||||||
code: 'IDEA-001',
|
|
||||||
grill_md: 'x',
|
|
||||||
plan_md: null,
|
|
||||||
})
|
|
||||||
const r = await downloadIdeaMdAction('idea-1', 'grill')
|
|
||||||
expect(r).toMatchObject({ success: true })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,170 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
const { mockGetSession } = vi.hoisted(() => ({
|
|
||||||
mockGetSession: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/lib/auth', () => ({
|
|
||||||
getSession: mockGetSession,
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: {
|
|
||||||
loginPairing: {
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
user: {
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
import { hashToken } from '@/lib/auth/pairing'
|
|
||||||
import {
|
|
||||||
getPairingForApproval,
|
|
||||||
approvePairing,
|
|
||||||
cancelPairing,
|
|
||||||
} from '@/actions/pairing'
|
|
||||||
|
|
||||||
const mockPrisma = prisma as unknown as {
|
|
||||||
loginPairing: {
|
|
||||||
findUnique: ReturnType<typeof vi.fn>
|
|
||||||
update: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
user: { findUnique: ReturnType<typeof vi.fn> }
|
|
||||||
}
|
|
||||||
|
|
||||||
const VALID_PAIRING_ID = 'cmohmk0a' + 'g008bs417mzik8x9w'.padEnd(17, 'a').slice(0, 17)
|
|
||||||
const VALID_SECRET = 'A'.repeat(43) // ≥40 chars voor Zod min(40)
|
|
||||||
const SESSION_USER = { userId: 'user-1', isDemo: false }
|
|
||||||
const SESSION_DEMO = { userId: 'demo-1', isDemo: true }
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockPrisma.user.findUnique.mockResolvedValue({ username: 'lars' })
|
|
||||||
})
|
|
||||||
|
|
||||||
function pendingPairing(secret = VALID_SECRET) {
|
|
||||||
return {
|
|
||||||
status: 'pending' as const,
|
|
||||||
expires_at: new Date(Date.now() + 60_000),
|
|
||||||
secret_hash: hashToken(secret),
|
|
||||||
desktop_ua: 'TestUA/1.0',
|
|
||||||
desktop_ip: '198.51.100.1',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('actions/pairing', () => {
|
|
||||||
describe('getPairingForApproval', () => {
|
|
||||||
it('ok-pad: pending + correct secret → desktop-info + username', async () => {
|
|
||||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
||||||
mockPrisma.loginPairing.findUnique.mockResolvedValue(pendingPairing())
|
|
||||||
|
|
||||||
const res = await getPairingForApproval(VALID_PAIRING_ID, VALID_SECRET)
|
|
||||||
expect(res).toEqual({
|
|
||||||
ok: true,
|
|
||||||
desktop_ua: 'TestUA/1.0',
|
|
||||||
desktop_ip: '198.51.100.1',
|
|
||||||
username: 'lars',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('faalt zonder sessie', async () => {
|
|
||||||
mockGetSession.mockResolvedValue({ userId: undefined, isDemo: false })
|
|
||||||
const res = await getPairingForApproval(VALID_PAIRING_ID, VALID_SECRET)
|
|
||||||
expect(res).toEqual({ ok: false, error: 'Niet ingelogd' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('faalt op al-approved pairing', async () => {
|
|
||||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
||||||
mockPrisma.loginPairing.findUnique.mockResolvedValue({
|
|
||||||
...pendingPairing(),
|
|
||||||
status: 'approved',
|
|
||||||
})
|
|
||||||
const res = await getPairingForApproval(VALID_PAIRING_ID, VALID_SECRET)
|
|
||||||
expect(res).toEqual({ ok: false, error: 'Pairing al afgehandeld' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('faalt op verlopen pairing', async () => {
|
|
||||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
||||||
mockPrisma.loginPairing.findUnique.mockResolvedValue({
|
|
||||||
...pendingPairing(),
|
|
||||||
expires_at: new Date(Date.now() - 1000),
|
|
||||||
})
|
|
||||||
const res = await getPairingForApproval(VALID_PAIRING_ID, VALID_SECRET)
|
|
||||||
expect(res).toEqual({ ok: false, error: 'Pairing verlopen' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('faalt op verkeerd secret', async () => {
|
|
||||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
||||||
mockPrisma.loginPairing.findUnique.mockResolvedValue(pendingPairing('echt'))
|
|
||||||
const res = await getPairingForApproval(VALID_PAIRING_ID, VALID_SECRET)
|
|
||||||
expect(res).toEqual({ ok: false, error: 'Ongeldig pairing-geheim' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('faalt op ongeldige cuid (Zod)', async () => {
|
|
||||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
||||||
const res = await getPairingForApproval('niet-cuid', VALID_SECRET)
|
|
||||||
expect(res).toEqual({ ok: false, error: 'Ongeldige invoer' })
|
|
||||||
expect(mockPrisma.loginPairing.findUnique).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('approvePairing', () => {
|
|
||||||
it('happy-pad: status pending→approved, user_id gezet, expires_at +5min', async () => {
|
|
||||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
||||||
mockPrisma.loginPairing.findUnique.mockResolvedValue(pendingPairing())
|
|
||||||
mockPrisma.loginPairing.update.mockResolvedValue({})
|
|
||||||
|
|
||||||
const res = await approvePairing(VALID_PAIRING_ID, VALID_SECRET)
|
|
||||||
expect(res).toEqual({ ok: true })
|
|
||||||
expect(mockPrisma.loginPairing.update).toHaveBeenCalledTimes(1)
|
|
||||||
const arg = mockPrisma.loginPairing.update.mock.calls[0][0]
|
|
||||||
expect(arg.where).toEqual({ id: VALID_PAIRING_ID })
|
|
||||||
expect(arg.data.status).toBe('approved')
|
|
||||||
expect(arg.data.user_id).toBe('user-1')
|
|
||||||
const dt = new Date(arg.data.expires_at).getTime() - Date.now()
|
|
||||||
expect(dt).toBeGreaterThan(295_000)
|
|
||||||
expect(dt).toBeLessThan(305_000)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('demo-user wordt geblokkeerd, geen DB-write', async () => {
|
|
||||||
mockGetSession.mockResolvedValue(SESSION_DEMO)
|
|
||||||
const res = await approvePairing(VALID_PAIRING_ID, VALID_SECRET)
|
|
||||||
expect(res).toEqual({ ok: false, error: 'Niet beschikbaar in demo-modus' })
|
|
||||||
expect(mockPrisma.loginPairing.findUnique).not.toHaveBeenCalled()
|
|
||||||
expect(mockPrisma.loginPairing.update).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('faalt op verkeerd secret zonder DB-write', async () => {
|
|
||||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
||||||
mockPrisma.loginPairing.findUnique.mockResolvedValue(pendingPairing('echt'))
|
|
||||||
const res = await approvePairing(VALID_PAIRING_ID, VALID_SECRET)
|
|
||||||
expect(res).toEqual({ ok: false, error: 'Ongeldig pairing-geheim' })
|
|
||||||
expect(mockPrisma.loginPairing.update).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('cancelPairing', () => {
|
|
||||||
it('happy-pad: status pending→cancelled', async () => {
|
|
||||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
||||||
mockPrisma.loginPairing.findUnique.mockResolvedValue(pendingPairing())
|
|
||||||
mockPrisma.loginPairing.update.mockResolvedValue({})
|
|
||||||
|
|
||||||
const res = await cancelPairing(VALID_PAIRING_ID, VALID_SECRET)
|
|
||||||
expect(res).toEqual({ ok: true })
|
|
||||||
const arg = mockPrisma.loginPairing.update.mock.calls[0][0]
|
|
||||||
expect(arg.data.status).toBe('cancelled')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('demo-user wordt geblokkeerd, geen DB-write', async () => {
|
|
||||||
mockGetSession.mockResolvedValue(SESSION_DEMO)
|
|
||||||
const res = await cancelPairing(VALID_PAIRING_ID, VALID_SECRET)
|
|
||||||
expect(res).toEqual({ ok: false, error: 'Niet beschikbaar in demo-modus' })
|
|
||||||
expect(mockPrisma.loginPairing.findUnique).not.toHaveBeenCalled()
|
|
||||||
expect(mockPrisma.loginPairing.update).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
const {
|
|
||||||
mockGetSession,
|
|
||||||
mockFindFirstProduct,
|
|
||||||
mockCreateProduct,
|
|
||||||
mockUpdateProduct,
|
|
||||||
mockCreateMember,
|
|
||||||
mockExecuteRaw,
|
|
||||||
mockTransaction,
|
|
||||||
} = vi.hoisted(() => ({
|
|
||||||
mockGetSession: vi.fn(),
|
|
||||||
mockFindFirstProduct: vi.fn(),
|
|
||||||
mockCreateProduct: vi.fn(),
|
|
||||||
mockUpdateProduct: vi.fn(),
|
|
||||||
mockCreateMember: vi.fn(),
|
|
||||||
mockExecuteRaw: vi.fn().mockResolvedValue(undefined),
|
|
||||||
mockTransaction: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
|
||||||
vi.mock('next/navigation', () => ({ redirect: vi.fn() }))
|
|
||||||
vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) }))
|
|
||||||
vi.mock('iron-session', () => ({
|
|
||||||
getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/session', () => ({
|
|
||||||
sessionOptions: { cookieName: 'test', password: 'test' },
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/auth', () => ({ getSession: mockGetSession }))
|
|
||||||
vi.mock('@/lib/product-access', () => ({
|
|
||||||
productAccessFilter: vi.fn().mockReturnValue({ OR: [{ user_id: 'user-1' }] }),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: {
|
|
||||||
product: { findFirst: mockFindFirstProduct, create: mockCreateProduct, update: mockUpdateProduct },
|
|
||||||
productMember: { create: mockCreateMember },
|
|
||||||
$executeRaw: mockExecuteRaw,
|
|
||||||
$transaction: mockTransaction,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { createProductAction, updateProductAction } from '@/actions/products'
|
|
||||||
import { getIronSession } from 'iron-session'
|
|
||||||
|
|
||||||
const mockSession = getIronSession as ReturnType<typeof vi.fn>
|
|
||||||
|
|
||||||
const SESSION_USER = { userId: 'user-1', isDemo: false }
|
|
||||||
const SESSION_DEMO = { userId: 'demo-1', isDemo: true }
|
|
||||||
const PRODUCT_ID = 'product-1'
|
|
||||||
|
|
||||||
const VALID_DATA = {
|
|
||||||
name: 'Test Product',
|
|
||||||
code: 'TP',
|
|
||||||
description: 'Een product',
|
|
||||||
repo_url: 'https://github.com/org/repo',
|
|
||||||
definition_of_done: 'Alles groen',
|
|
||||||
auto_pr: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockExecuteRaw.mockResolvedValue(undefined)
|
|
||||||
mockSession.mockResolvedValue(SESSION_USER)
|
|
||||||
})
|
|
||||||
|
|
||||||
// =============================================================
|
|
||||||
// createProductAction
|
|
||||||
// =============================================================
|
|
||||||
describe('createProductAction', () => {
|
|
||||||
it('happy path: maakt product + member aan en retourneert productId', async () => {
|
|
||||||
mockFindFirstProduct.mockResolvedValue(null) // geen dubbele code
|
|
||||||
mockTransaction.mockImplementation(async (fn: (tx: unknown) => Promise<unknown>) => {
|
|
||||||
return fn({
|
|
||||||
product: {
|
|
||||||
create: vi.fn().mockResolvedValue({ id: PRODUCT_ID }),
|
|
||||||
},
|
|
||||||
productMember: {
|
|
||||||
create: vi.fn().mockResolvedValue({}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await createProductAction(VALID_DATA)
|
|
||||||
|
|
||||||
expect(result).toEqual({ success: true, productId: PRODUCT_ID })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('demo-user → error', async () => {
|
|
||||||
mockSession.mockResolvedValue(SESSION_DEMO)
|
|
||||||
|
|
||||||
const result = await createProductAction(VALID_DATA)
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ error: expect.stringContaining('demo') })
|
|
||||||
expect(mockTransaction).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('ongeldige repo_url (niet github) → validatiefout', async () => {
|
|
||||||
const result = await createProductAction({ ...VALID_DATA, repo_url: 'https://gitlab.com/org/repo' })
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ error: expect.any(String) })
|
|
||||||
expect(mockTransaction).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('dubbele code → error', async () => {
|
|
||||||
mockFindFirstProduct.mockResolvedValue({ id: 'other-product' })
|
|
||||||
|
|
||||||
const result = await createProductAction(VALID_DATA)
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
code: 422,
|
|
||||||
fieldErrors: { code: expect.arrayContaining([expect.stringContaining('gebruik')]) },
|
|
||||||
})
|
|
||||||
expect(mockTransaction).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('naam ontbreekt → validatiefout', async () => {
|
|
||||||
const result = await createProductAction({ ...VALID_DATA, name: '' })
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ error: expect.any(String) })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// =============================================================
|
|
||||||
// updateProductAction
|
|
||||||
// =============================================================
|
|
||||||
describe('updateProductAction', () => {
|
|
||||||
it('happy path: werkt product bij en stuurt pg_notify', async () => {
|
|
||||||
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
|
|
||||||
mockUpdateProduct.mockResolvedValue({ id: PRODUCT_ID })
|
|
||||||
|
|
||||||
const result = await updateProductAction(PRODUCT_ID, VALID_DATA)
|
|
||||||
|
|
||||||
expect(result).toEqual({ success: true })
|
|
||||||
expect(mockUpdateProduct).toHaveBeenCalled()
|
|
||||||
expect(mockExecuteRaw).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('demo-user → error', async () => {
|
|
||||||
mockSession.mockResolvedValue(SESSION_DEMO)
|
|
||||||
|
|
||||||
const result = await updateProductAction(PRODUCT_ID, VALID_DATA)
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ error: expect.stringContaining('demo') })
|
|
||||||
expect(mockUpdateProduct).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('geen toegang tot product → error', async () => {
|
|
||||||
mockFindFirstProduct.mockResolvedValue(null)
|
|
||||||
|
|
||||||
const result = await updateProductAction(PRODUCT_ID, VALID_DATA)
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ error: expect.stringContaining('toegang') })
|
|
||||||
expect(mockUpdateProduct).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('ongeldige repo_url → validatiefout', async () => {
|
|
||||||
const result = await updateProductAction(PRODUCT_ID, { ...VALID_DATA, repo_url: 'https://bitbucket.org/x' })
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ error: expect.any(String) })
|
|
||||||
expect(mockUpdateProduct).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
const { mockGetSession } = vi.hoisted(() => ({
|
|
||||||
mockGetSession: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/lib/auth', () => ({
|
|
||||||
getSession: mockGetSession,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const { mockUpsert, mockDeleteMany } = vi.hoisted(() => ({
|
|
||||||
mockUpsert: vi.fn(),
|
|
||||||
mockDeleteMany: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: {
|
|
||||||
pushSubscription: {
|
|
||||||
upsert: mockUpsert,
|
|
||||||
deleteMany: mockDeleteMany,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { subscribeToPushAction, unsubscribeFromPushAction } from '@/actions/push'
|
|
||||||
|
|
||||||
const VALID_INPUT = {
|
|
||||||
endpoint: 'https://push.example.com/subscription/abc123',
|
|
||||||
keys: { p256dh: 'aBcDeFgH', auth: 'xYzAbC' },
|
|
||||||
}
|
|
||||||
|
|
||||||
const SESSION_USER = { userId: 'user-1', isDemo: false }
|
|
||||||
const SESSION_DEMO = { userId: 'demo-1', isDemo: true }
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockUpsert.mockResolvedValue({})
|
|
||||||
mockDeleteMany.mockResolvedValue({ count: 1 })
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('subscribeToPushAction', () => {
|
|
||||||
it('upserts subscription for authenticated user', async () => {
|
|
||||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
||||||
await subscribeToPushAction(VALID_INPUT)
|
|
||||||
expect(mockUpsert).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
where: { endpoint: VALID_INPUT.endpoint },
|
|
||||||
create: expect.objectContaining({ user_id: 'user-1', endpoint: VALID_INPUT.endpoint }),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('is idempotent — calling twice upserts twice without error', async () => {
|
|
||||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
||||||
await subscribeToPushAction(VALID_INPUT)
|
|
||||||
await subscribeToPushAction(VALID_INPUT)
|
|
||||||
expect(mockUpsert).toHaveBeenCalledTimes(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns without writing for demo user', async () => {
|
|
||||||
mockGetSession.mockResolvedValue(SESSION_DEMO)
|
|
||||||
await subscribeToPushAction(VALID_INPUT)
|
|
||||||
expect(mockUpsert).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns without writing when not authenticated', async () => {
|
|
||||||
mockGetSession.mockResolvedValue({})
|
|
||||||
await subscribeToPushAction(VALID_INPUT)
|
|
||||||
expect(mockUpsert).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns without writing for invalid input', async () => {
|
|
||||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
||||||
// @ts-expect-error intentionally invalid
|
|
||||||
await subscribeToPushAction({ endpoint: 'not-a-url', keys: {} })
|
|
||||||
expect(mockUpsert).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('unsubscribeFromPushAction', () => {
|
|
||||||
it('deletes subscription scoped to user_id', async () => {
|
|
||||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
||||||
await unsubscribeFromPushAction({ endpoint: VALID_INPUT.endpoint })
|
|
||||||
expect(mockDeleteMany).toHaveBeenCalledWith({
|
|
||||||
where: { endpoint: VALID_INPUT.endpoint, user_id: 'user-1' },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not touch subscriptions of other users', async () => {
|
|
||||||
mockGetSession.mockResolvedValue({ userId: 'other-user', isDemo: false })
|
|
||||||
await unsubscribeFromPushAction({ endpoint: VALID_INPUT.endpoint })
|
|
||||||
expect(mockDeleteMany).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ where: expect.objectContaining({ user_id: 'other-user' }) })
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns without writing when not authenticated', async () => {
|
|
||||||
mockGetSession.mockResolvedValue({})
|
|
||||||
await unsubscribeFromPushAction({ endpoint: VALID_INPUT.endpoint })
|
|
||||||
expect(mockDeleteMany).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
const { mockGetSession } = vi.hoisted(() => ({
|
|
||||||
mockGetSession: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
|
||||||
|
|
||||||
vi.mock('@/lib/auth', () => ({
|
|
||||||
getSession: mockGetSession,
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: {
|
|
||||||
claudeQuestion: {
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
updateMany: vi.fn(),
|
|
||||||
},
|
|
||||||
product: {
|
|
||||||
findFirst: vi.fn().mockResolvedValue({ id: 'product-1' }),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { revalidatePath } from 'next/cache'
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
import { answerQuestion } from '@/actions/questions'
|
|
||||||
|
|
||||||
const mockPrisma = prisma as unknown as {
|
|
||||||
claudeQuestion: {
|
|
||||||
findFirst: ReturnType<typeof vi.fn>
|
|
||||||
updateMany: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const mockRevalidate = revalidatePath as ReturnType<typeof vi.fn>
|
|
||||||
|
|
||||||
const VALID_ID = 'cmohrz0jra1aaaaaaaaaaaaaa'
|
|
||||||
const VALID_ANSWER = 'Antwoord van de gebruiker'
|
|
||||||
|
|
||||||
const SESSION_USER = { userId: 'user-1', isDemo: false }
|
|
||||||
const SESSION_DEMO = { userId: 'demo-1', isDemo: true }
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('actions/questions — answerQuestion', () => {
|
|
||||||
it('happy: status pending→answered, revalidatePath geroepen', async () => {
|
|
||||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
||||||
mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({
|
|
||||||
id: VALID_ID,
|
|
||||||
story_id: 'story-1',
|
|
||||||
idea_id: null,
|
|
||||||
product_id: 'product-1',
|
|
||||||
idea: null,
|
|
||||||
})
|
|
||||||
mockPrisma.claudeQuestion.updateMany.mockResolvedValueOnce({ count: 1 })
|
|
||||||
|
|
||||||
const res = await answerQuestion(VALID_ID, VALID_ANSWER)
|
|
||||||
expect(res).toEqual({ ok: true })
|
|
||||||
|
|
||||||
const updateArg = mockPrisma.claudeQuestion.updateMany.mock.calls[0][0]
|
|
||||||
expect(updateArg.where).toMatchObject({
|
|
||||||
id: VALID_ID,
|
|
||||||
status: 'open',
|
|
||||||
})
|
|
||||||
expect(updateArg.where.expires_at).toMatchObject({ gt: expect.any(Date) })
|
|
||||||
expect(updateArg.data).toMatchObject({
|
|
||||||
status: 'answered',
|
|
||||||
answer: VALID_ANSWER,
|
|
||||||
answered_by: 'user-1',
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(mockRevalidate).toHaveBeenCalledWith('/', 'layout')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('demo-user wordt geblokkeerd, geen DB-call', async () => {
|
|
||||||
mockGetSession.mockResolvedValue(SESSION_DEMO)
|
|
||||||
const res = await answerQuestion(VALID_ID, VALID_ANSWER)
|
|
||||||
expect(res).toEqual({ ok: false, error: 'Niet beschikbaar in demo-modus' })
|
|
||||||
expect(mockPrisma.claudeQuestion.findFirst).not.toHaveBeenCalled()
|
|
||||||
expect(mockPrisma.claudeQuestion.updateMany).not.toHaveBeenCalled()
|
|
||||||
expect(mockRevalidate).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('user zonder product-access: error, geen update', async () => {
|
|
||||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
||||||
mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce(null)
|
|
||||||
|
|
||||||
const res = await answerQuestion(VALID_ID, VALID_ANSWER)
|
|
||||||
expect(res).toEqual({ ok: false, error: 'Vraag niet gevonden of geen toegang' })
|
|
||||||
expect(mockPrisma.claudeQuestion.updateMany).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('al-answered: race-error met begrijpelijke melding', async () => {
|
|
||||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
||||||
mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({
|
|
||||||
id: VALID_ID,
|
|
||||||
story_id: 'story-1',
|
|
||||||
idea_id: null,
|
|
||||||
product_id: 'product-1',
|
|
||||||
idea: null,
|
|
||||||
})
|
|
||||||
mockPrisma.claudeQuestion.updateMany.mockResolvedValueOnce({ count: 0 })
|
|
||||||
mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({
|
|
||||||
status: 'answered',
|
|
||||||
expires_at: new Date(Date.now() + 60_000),
|
|
||||||
})
|
|
||||||
|
|
||||||
const res = await answerQuestion(VALID_ID, VALID_ANSWER)
|
|
||||||
expect(res).toEqual({ ok: false, error: 'Vraag is al answered' })
|
|
||||||
expect(mockRevalidate).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('verlopen: updateMany count=0, nog open status maar voorbij expiry', async () => {
|
|
||||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
||||||
mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({
|
|
||||||
id: VALID_ID,
|
|
||||||
story_id: 'story-1',
|
|
||||||
idea_id: null,
|
|
||||||
product_id: 'product-1',
|
|
||||||
idea: null,
|
|
||||||
})
|
|
||||||
mockPrisma.claudeQuestion.updateMany.mockResolvedValueOnce({ count: 0 })
|
|
||||||
mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({
|
|
||||||
status: 'open',
|
|
||||||
expires_at: new Date(Date.now() - 60_000),
|
|
||||||
})
|
|
||||||
|
|
||||||
const res = await answerQuestion(VALID_ID, VALID_ANSWER)
|
|
||||||
expect(res).toEqual({ ok: false, error: 'Vraag is verlopen' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('lege answer: Zod-validatie faalt', async () => {
|
|
||||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
||||||
const res = await answerQuestion(VALID_ID, '')
|
|
||||||
expect(res.ok).toBe(false)
|
|
||||||
if (!res.ok) {
|
|
||||||
expect(res.error.toLowerCase()).toMatch(/string|character|leeg|empty|small/i)
|
|
||||||
}
|
|
||||||
expect(mockPrisma.claudeQuestion.findFirst).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
const { mockUserUpdate, mockGetIronSession } = vi.hoisted(() => ({
|
|
||||||
mockUserUpdate: vi.fn(),
|
|
||||||
mockGetIronSession: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
|
||||||
vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) }))
|
|
||||||
vi.mock('iron-session', () => ({ getIronSession: mockGetIronSession }))
|
|
||||||
vi.mock('@/lib/session', () => ({ sessionOptions: { cookieName: 'test', password: 'test' } }))
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: { user: { update: mockUserUpdate } },
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { updateMinQuotaPctAction } from '@/actions/settings'
|
|
||||||
|
|
||||||
const SESSION_USER = { userId: 'user-1', isDemo: false }
|
|
||||||
const SESSION_DEMO = { userId: 'demo-1', isDemo: true }
|
|
||||||
const SESSION_UNAUTH = { userId: undefined, isDemo: false }
|
|
||||||
|
|
||||||
describe('updateMinQuotaPctAction', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockUserUpdate.mockResolvedValue({})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns error when not authenticated', async () => {
|
|
||||||
mockGetIronSession.mockResolvedValue(SESSION_UNAUTH)
|
|
||||||
const result = await updateMinQuotaPctAction(20)
|
|
||||||
expect(result).toMatchObject({ error: expect.any(String) })
|
|
||||||
expect(mockUserUpdate).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns 403 error for demo session', async () => {
|
|
||||||
mockGetIronSession.mockResolvedValue(SESSION_DEMO)
|
|
||||||
const result = await updateMinQuotaPctAction(20)
|
|
||||||
expect(result).toMatchObject({ status: 403 })
|
|
||||||
expect(mockUserUpdate).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns 422 error when value is 0 (below min)', async () => {
|
|
||||||
mockGetIronSession.mockResolvedValue(SESSION_USER)
|
|
||||||
const result = await updateMinQuotaPctAction(0)
|
|
||||||
expect(result).toMatchObject({ status: 422 })
|
|
||||||
expect(mockUserUpdate).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns 422 error when value is 101 (above max)', async () => {
|
|
||||||
mockGetIronSession.mockResolvedValue(SESSION_USER)
|
|
||||||
const result = await updateMinQuotaPctAction(101)
|
|
||||||
expect(result).toMatchObject({ status: 422 })
|
|
||||||
expect(mockUserUpdate).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('saves valid value and returns success', async () => {
|
|
||||||
mockGetIronSession.mockResolvedValue(SESSION_USER)
|
|
||||||
const result = await updateMinQuotaPctAction(35)
|
|
||||||
expect(result).toEqual({ success: true })
|
|
||||||
expect(mockUserUpdate).toHaveBeenCalledWith({
|
|
||||||
where: { id: 'user-1' },
|
|
||||||
data: { min_quota_pct: 35 },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('accepts boundary values 1 and 100', async () => {
|
|
||||||
mockGetIronSession.mockResolvedValue(SESSION_USER)
|
|
||||||
await updateMinQuotaPctAction(1)
|
|
||||||
await updateMinQuotaPctAction(100)
|
|
||||||
expect(mockUserUpdate).toHaveBeenCalledTimes(2)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
|
||||||
vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({ set: vi.fn(), get: vi.fn(), delete: vi.fn() }) }))
|
|
||||||
vi.mock('iron-session', () => ({
|
|
||||||
getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/session', () => ({
|
|
||||||
sessionOptions: { cookieName: 'test', password: 'test' },
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/product-access', () => ({
|
|
||||||
productAccessFilter: vi.fn().mockReturnValue({}),
|
|
||||||
getAccessibleProduct: vi.fn().mockResolvedValue({ id: 'product-1', user_id: 'user-1' }),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: {
|
|
||||||
sprint: {
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
findMany: vi.fn(),
|
|
||||||
create: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
user: {
|
|
||||||
findUnique: vi.fn().mockResolvedValue({ settings: {} }),
|
|
||||||
update: vi.fn().mockResolvedValue({}),
|
|
||||||
},
|
|
||||||
$executeRaw: vi.fn().mockResolvedValue(1),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
import { createSprintAction, updateSprintDatesAction } from '@/actions/sprints'
|
|
||||||
|
|
||||||
const mockSprint = prisma as unknown as { sprint: { findFirst: ReturnType<typeof vi.fn>; findMany: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } }
|
|
||||||
|
|
||||||
function makeFormData(data: Record<string, string | null>) {
|
|
||||||
const fd = new FormData()
|
|
||||||
for (const [k, v] of Object.entries(data)) {
|
|
||||||
if (v !== null) fd.append(k, v)
|
|
||||||
}
|
|
||||||
return fd
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('createSprintAction — date validation', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockSprint.sprint.findFirst.mockResolvedValue(null)
|
|
||||||
mockSprint.sprint.findMany.mockResolvedValue([])
|
|
||||||
mockSprint.sprint.create.mockResolvedValue({ id: 'sprint-1' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('accepts valid start_date + end_date', async () => {
|
|
||||||
const fd = makeFormData({ productId: 'product-1', sprint_goal: 'Doel', start_date: '2026-05-01', end_date: '2026-05-14' })
|
|
||||||
const result = await createSprintAction(undefined, fd)
|
|
||||||
expect(result.success).toBe(true)
|
|
||||||
expect(mockSprint.sprint.create).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ data: expect.objectContaining({ start_date: new Date('2026-05-01'), end_date: new Date('2026-05-14') }) })
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects end_date before start_date', async () => {
|
|
||||||
const fd = makeFormData({ productId: 'product-1', sprint_goal: 'Doel', start_date: '2026-05-14', end_date: '2026-05-01' })
|
|
||||||
const result = await createSprintAction(undefined, fd) as { code?: number; fieldErrors?: Record<string, string[]> }
|
|
||||||
expect(result.code).toBe(422)
|
|
||||||
expect(result.fieldErrors?.end_date?.[0]).toContain('Einddatum')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('accepts no dates (both optional)', async () => {
|
|
||||||
const fd = makeFormData({ productId: 'product-1', sprint_goal: 'Doel', start_date: '', end_date: '' })
|
|
||||||
const result = await createSprintAction(undefined, fd)
|
|
||||||
expect(result.success).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('updateSprintDatesAction — date validation', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockSprint.sprint.findFirst.mockResolvedValue({ id: 'sprint-1', product_id: 'product-1' })
|
|
||||||
mockSprint.sprint.update.mockResolvedValue({})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('saves valid dates', async () => {
|
|
||||||
const fd = makeFormData({ id: 'sprint-1', start_date: '2026-05-01', end_date: '2026-05-14' })
|
|
||||||
const result = await updateSprintDatesAction(undefined, fd)
|
|
||||||
expect(result.success).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects end_date before start_date', async () => {
|
|
||||||
const fd = makeFormData({ id: 'sprint-1', start_date: '2026-05-10', end_date: '2026-05-05' })
|
|
||||||
const result = await updateSprintDatesAction(undefined, fd) as { code?: number; fieldErrors?: Record<string, string[]> }
|
|
||||||
expect(result.code).toBe(422)
|
|
||||||
expect(result.fieldErrors?.end_date?.[0]).toContain('Einddatum')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('blocks demo users', async () => {
|
|
||||||
const { getIronSession } = await import('iron-session')
|
|
||||||
vi.mocked(getIronSession).mockResolvedValueOnce({ userId: 'user-1', isDemo: true } as never)
|
|
||||||
const fd = makeFormData({ id: 'sprint-1', start_date: '', end_date: '' })
|
|
||||||
const result = await updateSprintDatesAction(undefined, fd)
|
|
||||||
expect(result.error).toBe('Niet beschikbaar in demo-modus')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
|
||||||
vi.mock('next/headers', () => ({
|
|
||||||
cookies: vi.fn().mockResolvedValue({
|
|
||||||
set: vi.fn(),
|
|
||||||
get: vi.fn(),
|
|
||||||
delete: vi.fn(),
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
vi.mock('iron-session', () => ({
|
|
||||||
getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/session', () => ({
|
|
||||||
sessionOptions: { cookieName: 'test', password: 'test' },
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/product-access', () => ({
|
|
||||||
productAccessFilter: vi.fn().mockReturnValue({}),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: {
|
|
||||||
product: { findFirst: vi.fn() },
|
|
||||||
user: {
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
update: vi.fn().mockResolvedValue({}),
|
|
||||||
},
|
|
||||||
$executeRaw: vi.fn().mockResolvedValue(1),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
import {
|
|
||||||
clearPendingSprintDraftAction,
|
|
||||||
setPendingSprintDraftAction,
|
|
||||||
} from '@/actions/sprint-draft'
|
|
||||||
import type { PendingSprintDraft, UserSettings } from '@/lib/user-settings'
|
|
||||||
|
|
||||||
const mockPrisma = prisma as unknown as {
|
|
||||||
product: { findFirst: ReturnType<typeof vi.fn> }
|
|
||||||
user: {
|
|
||||||
findUnique: ReturnType<typeof vi.fn>
|
|
||||||
update: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const validDraft: PendingSprintDraft = {
|
|
||||||
goal: 'Sprint 1',
|
|
||||||
pbiIntent: { pbiA: 'all' },
|
|
||||||
storyOverrides: { pbiA: { add: [], remove: ['story-1'] } },
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('setPendingSprintDraftAction', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockPrisma.product.findFirst.mockReset()
|
|
||||||
mockPrisma.user.findUnique.mockReset()
|
|
||||||
mockPrisma.user.update.mockReset().mockResolvedValue({})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('persists draft for accessible product', async () => {
|
|
||||||
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
|
|
||||||
mockPrisma.user.findUnique.mockResolvedValueOnce({ settings: {} })
|
|
||||||
|
|
||||||
const result = await setPendingSprintDraftAction('p1', validDraft)
|
|
||||||
|
|
||||||
expect(result).toEqual({ success: true })
|
|
||||||
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
|
|
||||||
data: { settings: UserSettings }
|
|
||||||
}
|
|
||||||
expect(updateArg.data.settings.workflow?.pendingSprintDraft?.p1).toMatchObject({
|
|
||||||
goal: 'Sprint 1',
|
|
||||||
pbiIntent: { pbiA: 'all' },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('preserves drafts for other products', async () => {
|
|
||||||
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
|
|
||||||
mockPrisma.user.findUnique.mockResolvedValueOnce({
|
|
||||||
settings: {
|
|
||||||
workflow: {
|
|
||||||
pendingSprintDraft: {
|
|
||||||
p2: { goal: 'P2 draft', pbiIntent: {}, storyOverrides: {} },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await setPendingSprintDraftAction('p1', validDraft)
|
|
||||||
|
|
||||||
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
|
|
||||||
data: { settings: UserSettings }
|
|
||||||
}
|
|
||||||
const drafts = updateArg.data.settings.workflow?.pendingSprintDraft
|
|
||||||
expect(Object.keys(drafts ?? {})).toEqual(expect.arrayContaining(['p1', 'p2']))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects invalid draft (empty goal)', async () => {
|
|
||||||
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
|
|
||||||
|
|
||||||
const result = await setPendingSprintDraftAction('p1', {
|
|
||||||
...validDraft,
|
|
||||||
goal: '',
|
|
||||||
} as PendingSprintDraft)
|
|
||||||
|
|
||||||
expect(result).toHaveProperty('error')
|
|
||||||
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects when product not accessible', async () => {
|
|
||||||
mockPrisma.product.findFirst.mockResolvedValueOnce(null)
|
|
||||||
|
|
||||||
const result = await setPendingSprintDraftAction('p1', validDraft)
|
|
||||||
|
|
||||||
expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' })
|
|
||||||
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('clearPendingSprintDraftAction', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockPrisma.product.findFirst.mockReset()
|
|
||||||
mockPrisma.user.findUnique.mockReset()
|
|
||||||
mockPrisma.user.update.mockReset().mockResolvedValue({})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('removes draft key for product', async () => {
|
|
||||||
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
|
|
||||||
mockPrisma.user.findUnique.mockResolvedValueOnce({
|
|
||||||
settings: {
|
|
||||||
workflow: {
|
|
||||||
pendingSprintDraft: {
|
|
||||||
p1: { goal: 'gone', pbiIntent: {}, storyOverrides: {} },
|
|
||||||
p2: { goal: 'keep', pbiIntent: {}, storyOverrides: {} },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await clearPendingSprintDraftAction('p1')
|
|
||||||
|
|
||||||
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
|
|
||||||
data: { settings: UserSettings }
|
|
||||||
}
|
|
||||||
expect(updateArg.data.settings.workflow?.pendingSprintDraft).toEqual({
|
|
||||||
p2: { goal: 'keep', pbiIntent: {}, storyOverrides: {} },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('is a no-op when there is no draft for the product', async () => {
|
|
||||||
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
|
|
||||||
mockPrisma.user.findUnique.mockResolvedValueOnce({ settings: {} })
|
|
||||||
|
|
||||||
const result = await clearPendingSprintDraftAction('p1')
|
|
||||||
|
|
||||||
expect(result).toEqual({ success: true })
|
|
||||||
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects when product not accessible', async () => {
|
|
||||||
mockPrisma.product.findFirst.mockResolvedValueOnce(null)
|
|
||||||
|
|
||||||
const result = await clearPendingSprintDraftAction('p1')
|
|
||||||
|
|
||||||
expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,407 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
|
||||||
vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) }))
|
|
||||||
vi.mock('iron-session', () => ({
|
|
||||||
getIronSession: vi.fn(),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/session', () => ({
|
|
||||||
sessionOptions: { cookieName: 'test', password: 'test' },
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: {
|
|
||||||
sprint: {
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
sprintRun: {
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
create: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
story: {
|
|
||||||
findMany: vi.fn(),
|
|
||||||
updateMany: vi.fn(),
|
|
||||||
},
|
|
||||||
pbi: {
|
|
||||||
updateMany: vi.fn(),
|
|
||||||
},
|
|
||||||
task: {
|
|
||||||
updateMany: vi.fn(),
|
|
||||||
findUnique: vi.fn().mockResolvedValue(null),
|
|
||||||
},
|
|
||||||
claudeQuestion: {
|
|
||||||
findMany: vi.fn(),
|
|
||||||
},
|
|
||||||
claudeJob: {
|
|
||||||
create: vi.fn(),
|
|
||||||
updateMany: vi.fn(),
|
|
||||||
},
|
|
||||||
product: {
|
|
||||||
findUnique: vi.fn().mockResolvedValue(null),
|
|
||||||
},
|
|
||||||
$transaction: vi.fn(),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
import { getIronSession } from 'iron-session'
|
|
||||||
import {
|
|
||||||
startSprintRunAction,
|
|
||||||
resumeSprintAction,
|
|
||||||
cancelSprintRunAction,
|
|
||||||
} from '@/actions/sprint-runs'
|
|
||||||
|
|
||||||
const mockSession = getIronSession as ReturnType<typeof vi.fn>
|
|
||||||
|
|
||||||
type Mocked = {
|
|
||||||
sprint: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
|
|
||||||
sprintRun: {
|
|
||||||
findFirst: ReturnType<typeof vi.fn>
|
|
||||||
findUnique: ReturnType<typeof vi.fn>
|
|
||||||
create: ReturnType<typeof vi.fn>
|
|
||||||
update: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
story: {
|
|
||||||
findMany: ReturnType<typeof vi.fn>
|
|
||||||
updateMany: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
pbi: { updateMany: ReturnType<typeof vi.fn> }
|
|
||||||
task: { updateMany: ReturnType<typeof vi.fn> }
|
|
||||||
claudeQuestion: { findMany: ReturnType<typeof vi.fn> }
|
|
||||||
claudeJob: {
|
|
||||||
create: ReturnType<typeof vi.fn>
|
|
||||||
updateMany: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
$transaction: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
const mockPrisma = prisma as unknown as Mocked
|
|
||||||
|
|
||||||
const SPRINT_OK = {
|
|
||||||
id: 'sprint-1',
|
|
||||||
status: 'OPEN',
|
|
||||||
product_id: 'prod-1',
|
|
||||||
product: { id: 'prod-1', pr_strategy: 'SPRINT' },
|
|
||||||
}
|
|
||||||
|
|
||||||
const STORY_OK = {
|
|
||||||
id: 'story-1',
|
|
||||||
pbi_id: 'pbi-1',
|
|
||||||
priority: 1,
|
|
||||||
sort_order: 1,
|
|
||||||
pbi: {
|
|
||||||
id: 'pbi-1',
|
|
||||||
code: 'PBI-1',
|
|
||||||
title: 'PBI',
|
|
||||||
status: 'READY',
|
|
||||||
priority: 1,
|
|
||||||
sort_order: 1,
|
|
||||||
},
|
|
||||||
tasks: [
|
|
||||||
{ id: 'task-1', code: 'T-1', title: 'T1', priority: 1, sort_order: 1, implementation_plan: 'plan' },
|
|
||||||
{ id: 'task-2', code: 'T-2', title: 'T2', priority: 1, sort_order: 2, implementation_plan: 'plan' },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockSession.mockResolvedValue({ userId: 'user-1', isDemo: false })
|
|
||||||
mockPrisma.$transaction.mockImplementation(
|
|
||||||
async (run: (tx: typeof prisma) => Promise<unknown>) => run(prisma),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('startSprintRunAction — happy path', () => {
|
|
||||||
it('maakt SprintRun + 2 ClaudeJobs voor 2 TO_DO tasks', async () => {
|
|
||||||
mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_OK)
|
|
||||||
mockPrisma.sprintRun.findFirst.mockResolvedValue(null)
|
|
||||||
mockPrisma.story.findMany.mockResolvedValue([STORY_OK])
|
|
||||||
mockPrisma.claudeQuestion.findMany.mockResolvedValue([])
|
|
||||||
mockPrisma.sprintRun.create.mockResolvedValue({ id: 'run-1' })
|
|
||||||
mockPrisma.claudeJob.create.mockResolvedValue({ id: 'job-x' })
|
|
||||||
|
|
||||||
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
|
|
||||||
|
|
||||||
expect(result).toEqual({ ok: true, sprint_run_id: 'run-1', jobs_count: 2 })
|
|
||||||
expect(mockPrisma.sprintRun.create).toHaveBeenCalledWith({
|
|
||||||
data: expect.objectContaining({
|
|
||||||
sprint_id: 'sprint-1',
|
|
||||||
started_by_id: 'user-1',
|
|
||||||
status: 'QUEUED',
|
|
||||||
pr_strategy: 'SPRINT',
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
expect(mockPrisma.claudeJob.create).toHaveBeenCalledTimes(2)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('startSprintRunAction — pre-flight blockers', () => {
|
|
||||||
it('blokkeert wanneer task geen implementation_plan heeft', async () => {
|
|
||||||
mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_OK)
|
|
||||||
mockPrisma.sprintRun.findFirst.mockResolvedValue(null)
|
|
||||||
mockPrisma.story.findMany.mockResolvedValue([
|
|
||||||
{
|
|
||||||
...STORY_OK,
|
|
||||||
tasks: [
|
|
||||||
{ id: 'task-1', code: 'T-1', title: 'T1', priority: 1, sort_order: 1, implementation_plan: null },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
mockPrisma.claudeQuestion.findMany.mockResolvedValue([])
|
|
||||||
|
|
||||||
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ ok: false, error: 'PRE_FLIGHT_BLOCKED' })
|
|
||||||
if (result.ok === false && 'blockers' in result) {
|
|
||||||
expect(result.blockers).toContainEqual({
|
|
||||||
type: 'task_no_plan',
|
|
||||||
id: 'task-1',
|
|
||||||
label: 'T-1: T1',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
expect(mockPrisma.sprintRun.create).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('blokkeert wanneer er een open ClaudeQuestion in scope is', async () => {
|
|
||||||
mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_OK)
|
|
||||||
mockPrisma.sprintRun.findFirst.mockResolvedValue(null)
|
|
||||||
mockPrisma.story.findMany.mockResolvedValue([STORY_OK])
|
|
||||||
mockPrisma.claudeQuestion.findMany.mockResolvedValue([
|
|
||||||
{ id: 'q-1', question: 'Welke route?' },
|
|
||||||
])
|
|
||||||
|
|
||||||
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ ok: false, error: 'PRE_FLIGHT_BLOCKED' })
|
|
||||||
if (result.ok === false && 'blockers' in result) {
|
|
||||||
expect(result.blockers).toContainEqual({
|
|
||||||
type: 'open_question',
|
|
||||||
id: 'q-1',
|
|
||||||
label: 'Welke route?',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('blokkeert wanneer een PBI BLOCKED of FAILED is', async () => {
|
|
||||||
mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_OK)
|
|
||||||
mockPrisma.sprintRun.findFirst.mockResolvedValue(null)
|
|
||||||
mockPrisma.story.findMany.mockResolvedValue([
|
|
||||||
{ ...STORY_OK, pbi: { ...STORY_OK.pbi, status: 'BLOCKED' } },
|
|
||||||
])
|
|
||||||
mockPrisma.claudeQuestion.findMany.mockResolvedValue([])
|
|
||||||
|
|
||||||
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ ok: false, error: 'PRE_FLIGHT_BLOCKED' })
|
|
||||||
if (result.ok === false && 'blockers' in result) {
|
|
||||||
expect(result.blockers).toContainEqual({
|
|
||||||
type: 'pbi_blocked',
|
|
||||||
id: 'pbi-1',
|
|
||||||
label: 'PBI-1: PBI',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('startSprintRunAction — SPRINT_BATCH', () => {
|
|
||||||
const SPRINT_BATCH = {
|
|
||||||
...SPRINT_OK,
|
|
||||||
product: {
|
|
||||||
id: 'prod-1',
|
|
||||||
pr_strategy: 'SPRINT_BATCH',
|
|
||||||
repo_url: 'https://github.com/example/main',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
it('blokkeert task met afwijkende repo_url', async () => {
|
|
||||||
mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_BATCH)
|
|
||||||
mockPrisma.sprintRun.findFirst.mockResolvedValue(null)
|
|
||||||
mockPrisma.story.findMany.mockResolvedValue([
|
|
||||||
{
|
|
||||||
...STORY_OK,
|
|
||||||
tasks: [
|
|
||||||
{
|
|
||||||
id: 'task-1',
|
|
||||||
code: 'T-1',
|
|
||||||
title: 'In main repo',
|
|
||||||
priority: 1,
|
|
||||||
sort_order: 1,
|
|
||||||
implementation_plan: 'plan',
|
|
||||||
repo_url: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'task-2',
|
|
||||||
code: 'T-2',
|
|
||||||
title: 'Cross-repo',
|
|
||||||
priority: 1,
|
|
||||||
sort_order: 2,
|
|
||||||
implementation_plan: 'plan',
|
|
||||||
repo_url: 'https://github.com/example/other',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
mockPrisma.claudeQuestion.findMany.mockResolvedValue([])
|
|
||||||
|
|
||||||
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ ok: false, error: 'PRE_FLIGHT_BLOCKED' })
|
|
||||||
if (result.ok === false && 'blockers' in result) {
|
|
||||||
expect(result.blockers).toContainEqual({
|
|
||||||
type: 'task_cross_repo',
|
|
||||||
id: 'task-2',
|
|
||||||
label: 'T-2: Cross-repo',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
expect(mockPrisma.sprintRun.create).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('staat tasks toe wanneer repo_url leeg is of gelijk aan product.repo_url', async () => {
|
|
||||||
mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_BATCH)
|
|
||||||
mockPrisma.sprintRun.findFirst.mockResolvedValue(null)
|
|
||||||
mockPrisma.story.findMany.mockResolvedValue([
|
|
||||||
{
|
|
||||||
...STORY_OK,
|
|
||||||
tasks: [
|
|
||||||
{
|
|
||||||
id: 'task-1',
|
|
||||||
code: 'T-1',
|
|
||||||
title: 'No override',
|
|
||||||
priority: 1,
|
|
||||||
sort_order: 1,
|
|
||||||
implementation_plan: 'plan',
|
|
||||||
repo_url: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'task-2',
|
|
||||||
code: 'T-2',
|
|
||||||
title: 'Same repo',
|
|
||||||
priority: 1,
|
|
||||||
sort_order: 2,
|
|
||||||
implementation_plan: 'plan',
|
|
||||||
repo_url: 'https://github.com/example/main',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
mockPrisma.claudeQuestion.findMany.mockResolvedValue([])
|
|
||||||
mockPrisma.sprintRun.create.mockResolvedValue({ id: 'run-batch' })
|
|
||||||
mockPrisma.claudeJob.create.mockResolvedValue({ id: 'job-sprint' })
|
|
||||||
|
|
||||||
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ ok: true, sprint_run_id: 'run-batch' })
|
|
||||||
// Eén SPRINT_IMPLEMENTATION-job, niet per-task
|
|
||||||
expect(mockPrisma.claudeJob.create).toHaveBeenCalledTimes(1)
|
|
||||||
expect(mockPrisma.claudeJob.create).toHaveBeenCalledWith({
|
|
||||||
data: expect.objectContaining({
|
|
||||||
kind: 'SPRINT_IMPLEMENTATION',
|
|
||||||
sprint_run_id: 'run-batch',
|
|
||||||
product_id: 'prod-1',
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('startSprintRunAction — guards', () => {
|
|
||||||
it('weigert wanneer Sprint niet ACTIVE is', async () => {
|
|
||||||
mockPrisma.sprint.findUnique.mockResolvedValue({ ...SPRINT_OK, status: 'CLOSED' })
|
|
||||||
|
|
||||||
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
|
|
||||||
expect(result).toMatchObject({ ok: false, error: 'SPRINT_NOT_ACTIVE' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('weigert wanneer er al een actieve SprintRun is', async () => {
|
|
||||||
mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_OK)
|
|
||||||
mockPrisma.sprintRun.findFirst.mockResolvedValue({ id: 'run-existing', status: 'RUNNING' })
|
|
||||||
|
|
||||||
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
|
|
||||||
expect(result).toMatchObject({ ok: false, error: 'SPRINT_RUN_ALREADY_ACTIVE' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('weigert demo-sessie', async () => {
|
|
||||||
mockSession.mockResolvedValue({ userId: 'demo', isDemo: true })
|
|
||||||
|
|
||||||
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
|
|
||||||
expect(result).toMatchObject({ ok: false, code: 403 })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('resumeSprintAction', () => {
|
|
||||||
it('zet sprint en cascade-statuses terug en maakt nieuwe SprintRun', async () => {
|
|
||||||
// Eerste findUnique (resume) ziet de sprint nog op FAILED;
|
|
||||||
// de tweede call (binnen startSprintRunCore na de update) ziet ACTIVE.
|
|
||||||
mockPrisma.sprint.findUnique
|
|
||||||
.mockResolvedValueOnce({ ...SPRINT_OK, status: 'FAILED' })
|
|
||||||
.mockResolvedValue(SPRINT_OK)
|
|
||||||
mockPrisma.sprintRun.findFirst.mockResolvedValue(null)
|
|
||||||
mockPrisma.story.findMany.mockImplementation(async (args: { select?: { pbi_id?: boolean } }) => {
|
|
||||||
if (args.select?.pbi_id) return [{ pbi_id: 'pbi-1' }]
|
|
||||||
return [STORY_OK]
|
|
||||||
})
|
|
||||||
mockPrisma.claudeQuestion.findMany.mockResolvedValue([])
|
|
||||||
mockPrisma.sprintRun.create.mockResolvedValue({ id: 'run-2' })
|
|
||||||
mockPrisma.claudeJob.create.mockResolvedValue({ id: 'job-x' })
|
|
||||||
|
|
||||||
const result = await resumeSprintAction({ sprint_id: 'sprint-1' })
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ ok: true, sprint_run_id: 'run-2' })
|
|
||||||
expect(mockPrisma.sprint.update).toHaveBeenCalledWith({
|
|
||||||
where: { id: 'sprint-1' },
|
|
||||||
data: { status: 'OPEN', completed_at: null },
|
|
||||||
})
|
|
||||||
expect(mockPrisma.story.updateMany).toHaveBeenCalledWith({
|
|
||||||
where: { sprint_id: 'sprint-1', status: 'FAILED' },
|
|
||||||
data: { status: 'IN_SPRINT' },
|
|
||||||
})
|
|
||||||
expect(mockPrisma.task.updateMany).toHaveBeenCalledWith({
|
|
||||||
where: { story: { sprint_id: 'sprint-1' }, status: 'FAILED' },
|
|
||||||
data: { status: 'TO_DO' },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('weigert als sprint niet FAILED is', async () => {
|
|
||||||
mockPrisma.sprint.findUnique.mockResolvedValue({ ...SPRINT_OK, status: 'OPEN' })
|
|
||||||
|
|
||||||
const result = await resumeSprintAction({ sprint_id: 'sprint-1' })
|
|
||||||
expect(result).toMatchObject({ ok: false, error: 'SPRINT_NOT_FAILED' })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('cancelSprintRunAction', () => {
|
|
||||||
it('zet SprintRun op CANCELLED en cancelt openstaande jobs', async () => {
|
|
||||||
mockPrisma.sprintRun.findUnique.mockResolvedValue({
|
|
||||||
id: 'run-1',
|
|
||||||
status: 'RUNNING',
|
|
||||||
sprint_id: 'sprint-1',
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await cancelSprintRunAction({ sprint_run_id: 'run-1' })
|
|
||||||
|
|
||||||
expect(result).toEqual({ ok: true })
|
|
||||||
expect(mockPrisma.sprintRun.update).toHaveBeenCalledWith({
|
|
||||||
where: { id: 'run-1' },
|
|
||||||
data: expect.objectContaining({ status: 'CANCELLED' }),
|
|
||||||
})
|
|
||||||
expect(mockPrisma.claudeJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({
|
|
||||||
where: expect.objectContaining({
|
|
||||||
sprint_run_id: 'run-1',
|
|
||||||
status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] },
|
|
||||||
}),
|
|
||||||
data: expect.objectContaining({ status: 'CANCELLED' }),
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('weigert wanneer SprintRun al DONE is', async () => {
|
|
||||||
mockPrisma.sprintRun.findUnique.mockResolvedValue({
|
|
||||||
id: 'run-1',
|
|
||||||
status: 'DONE',
|
|
||||||
sprint_id: 'sprint-1',
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await cancelSprintRunAction({ sprint_run_id: 'run-1' })
|
|
||||||
expect(result).toMatchObject({ ok: false, error: 'SPRINT_RUN_NOT_CANCELLABLE' })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,238 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
|
||||||
vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) }))
|
|
||||||
vi.mock('iron-session', () => ({
|
|
||||||
getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/session', () => ({
|
|
||||||
sessionOptions: { cookieName: 'test', password: 'test' },
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/product-access', () => ({
|
|
||||||
productAccessFilter: vi.fn().mockReturnValue({}),
|
|
||||||
getAccessibleProduct: vi.fn().mockResolvedValue({ id: 'product-1' }),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: {
|
|
||||||
sprint: {
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
story: {
|
|
||||||
findMany: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
pbi: {
|
|
||||||
findMany: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
$transaction: vi.fn().mockResolvedValue([]),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
import { completeSprintAction } from '@/actions/sprints'
|
|
||||||
|
|
||||||
const mockPrisma = prisma as unknown as {
|
|
||||||
sprint: {
|
|
||||||
findFirst: ReturnType<typeof vi.fn>
|
|
||||||
update: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
story: {
|
|
||||||
findMany: ReturnType<typeof vi.fn>
|
|
||||||
update: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
pbi: {
|
|
||||||
findMany: ReturnType<typeof vi.fn>
|
|
||||||
update: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
$transaction: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
|
|
||||||
const SPRINT = { id: 'sprint-1', product_id: 'product-1', status: 'OPEN' }
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockPrisma.sprint.findFirst.mockResolvedValue(SPRINT)
|
|
||||||
mockPrisma.$transaction.mockResolvedValue([])
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('completeSprintAction — PBI auto-DONE cascade', () => {
|
|
||||||
it('marks PBI DONE when all its stories are decided DONE', async () => {
|
|
||||||
mockPrisma.story.findMany.mockResolvedValue([
|
|
||||||
{ id: 'story-a', pbi_id: 'pbi-1' },
|
|
||||||
{ id: 'story-b', pbi_id: 'pbi-1' },
|
|
||||||
])
|
|
||||||
mockPrisma.pbi.findMany.mockResolvedValue([
|
|
||||||
{
|
|
||||||
id: 'pbi-1',
|
|
||||||
stories: [
|
|
||||||
{ id: 'story-a', status: 'IN_SPRINT' },
|
|
||||||
{ id: 'story-b', status: 'IN_SPRINT' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
const result = await completeSprintAction('sprint-1', {
|
|
||||||
'story-a': 'DONE',
|
|
||||||
'story-b': 'DONE',
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result).toEqual({ success: true })
|
|
||||||
expect(mockPrisma.pbi.update).toHaveBeenCalledTimes(1)
|
|
||||||
expect(mockPrisma.pbi.update).toHaveBeenCalledWith({
|
|
||||||
where: { id: 'pbi-1' },
|
|
||||||
data: { status: 'DONE' },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not mark PBI DONE when a story is decided OPEN', async () => {
|
|
||||||
mockPrisma.story.findMany.mockResolvedValue([
|
|
||||||
{ id: 'story-a', pbi_id: 'pbi-1' },
|
|
||||||
{ id: 'story-b', pbi_id: 'pbi-1' },
|
|
||||||
])
|
|
||||||
mockPrisma.pbi.findMany.mockResolvedValue([
|
|
||||||
{
|
|
||||||
id: 'pbi-1',
|
|
||||||
stories: [
|
|
||||||
{ id: 'story-a', status: 'IN_SPRINT' },
|
|
||||||
{ id: 'story-b', status: 'IN_SPRINT' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
const result = await completeSprintAction('sprint-1', {
|
|
||||||
'story-a': 'DONE',
|
|
||||||
'story-b': 'OPEN',
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result).toEqual({ success: true })
|
|
||||||
expect(mockPrisma.pbi.update).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not mark PBI DONE when it has stories outside this sprint that are not DONE', async () => {
|
|
||||||
mockPrisma.story.findMany.mockResolvedValue([
|
|
||||||
{ id: 'story-a', pbi_id: 'pbi-1' },
|
|
||||||
])
|
|
||||||
mockPrisma.pbi.findMany.mockResolvedValue([
|
|
||||||
{
|
|
||||||
id: 'pbi-1',
|
|
||||||
stories: [
|
|
||||||
{ id: 'story-a', status: 'IN_SPRINT' },
|
|
||||||
{ id: 'story-b', status: 'OPEN' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
const result = await completeSprintAction('sprint-1', {
|
|
||||||
'story-a': 'DONE',
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result).toEqual({ success: true })
|
|
||||||
expect(mockPrisma.pbi.update).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('marks PBI DONE when its in-sprint stories are DONE and out-of-sprint stories are already DONE', async () => {
|
|
||||||
mockPrisma.story.findMany.mockResolvedValue([
|
|
||||||
{ id: 'story-a', pbi_id: 'pbi-1' },
|
|
||||||
])
|
|
||||||
mockPrisma.pbi.findMany.mockResolvedValue([
|
|
||||||
{
|
|
||||||
id: 'pbi-1',
|
|
||||||
stories: [
|
|
||||||
{ id: 'story-a', status: 'IN_SPRINT' },
|
|
||||||
{ id: 'story-b', status: 'DONE' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
const result = await completeSprintAction('sprint-1', {
|
|
||||||
'story-a': 'DONE',
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result).toEqual({ success: true })
|
|
||||||
expect(mockPrisma.pbi.update).toHaveBeenCalledWith({
|
|
||||||
where: { id: 'pbi-1' },
|
|
||||||
data: { status: 'DONE' },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('skips PBIs that are already DONE (promote-only)', async () => {
|
|
||||||
mockPrisma.story.findMany.mockResolvedValue([
|
|
||||||
{ id: 'story-a', pbi_id: 'pbi-1' },
|
|
||||||
])
|
|
||||||
// pbi.findMany filters via { status: { not: 'DONE' } } in the action,
|
|
||||||
// so an already-DONE PBI just doesn't appear in candidatePbis.
|
|
||||||
mockPrisma.pbi.findMany.mockResolvedValue([])
|
|
||||||
|
|
||||||
const result = await completeSprintAction('sprint-1', {
|
|
||||||
'story-a': 'DONE',
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result).toEqual({ success: true })
|
|
||||||
expect(mockPrisma.pbi.update).not.toHaveBeenCalled()
|
|
||||||
expect(mockPrisma.pbi.findMany).toHaveBeenCalledWith({
|
|
||||||
where: { id: { in: ['pbi-1'] }, status: { not: 'DONE' } },
|
|
||||||
select: { id: true, stories: { select: { id: true, status: true } } },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('cascades across multiple PBIs in one sprint close', async () => {
|
|
||||||
mockPrisma.story.findMany.mockResolvedValue([
|
|
||||||
{ id: 'story-a', pbi_id: 'pbi-1' },
|
|
||||||
{ id: 'story-b', pbi_id: 'pbi-2' },
|
|
||||||
])
|
|
||||||
mockPrisma.pbi.findMany.mockResolvedValue([
|
|
||||||
{ id: 'pbi-1', stories: [{ id: 'story-a', status: 'IN_SPRINT' }] },
|
|
||||||
{ id: 'pbi-2', stories: [{ id: 'story-b', status: 'IN_SPRINT' }] },
|
|
||||||
])
|
|
||||||
|
|
||||||
const result = await completeSprintAction('sprint-1', {
|
|
||||||
'story-a': 'DONE',
|
|
||||||
'story-b': 'DONE',
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result).toEqual({ success: true })
|
|
||||||
expect(mockPrisma.pbi.update).toHaveBeenCalledTimes(2)
|
|
||||||
expect(mockPrisma.pbi.update).toHaveBeenCalledWith({
|
|
||||||
where: { id: 'pbi-1' },
|
|
||||||
data: { status: 'DONE' },
|
|
||||||
})
|
|
||||||
expect(mockPrisma.pbi.update).toHaveBeenCalledWith({
|
|
||||||
where: { id: 'pbi-2' },
|
|
||||||
data: { status: 'DONE' },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not include 0-story PBIs in cascade', async () => {
|
|
||||||
mockPrisma.story.findMany.mockResolvedValue([
|
|
||||||
{ id: 'story-a', pbi_id: 'pbi-1' },
|
|
||||||
])
|
|
||||||
mockPrisma.pbi.findMany.mockResolvedValue([
|
|
||||||
{ id: 'pbi-1', stories: [] },
|
|
||||||
])
|
|
||||||
|
|
||||||
const result = await completeSprintAction('sprint-1', {
|
|
||||||
'story-a': 'DONE',
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result).toEqual({ success: true })
|
|
||||||
expect(mockPrisma.pbi.update).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('blocks sprint close for demo users', async () => {
|
|
||||||
const { getIronSession } = await import('iron-session')
|
|
||||||
;(getIronSession as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
||||||
userId: 'user-demo',
|
|
||||||
isDemo: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await completeSprintAction('sprint-1', {
|
|
||||||
'story-a': 'DONE',
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result).toEqual({ error: 'Niet beschikbaar in demo-modus' })
|
|
||||||
expect(mockPrisma.$transaction).not.toHaveBeenCalled()
|
|
||||||
expect(mockPrisma.pbi.update).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -50,7 +50,7 @@ const mockRequireProductWriter = requireProductWriter as ReturnType<typeof vi.fn
|
||||||
const mockGetIronSession = getIronSession as ReturnType<typeof vi.fn>
|
const mockGetIronSession = getIronSession as ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
const STORY = { id: 'story-1', product_id: 'product-1', assignee_id: null }
|
const STORY = { id: 'story-1', product_id: 'product-1', assignee_id: null }
|
||||||
const SPRINT = { id: 'sprint-1', product_id: 'product-1', status: 'OPEN' }
|
const SPRINT = { id: 'sprint-1', product_id: 'product-1', status: 'ACTIVE' }
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
|
|
||||||
|
|
@ -1,268 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
|
||||||
vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) }))
|
|
||||||
vi.mock('iron-session', () => ({
|
|
||||||
getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/session', () => ({
|
|
||||||
sessionOptions: { cookieName: 'test', password: 'test' },
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/product-access', () => ({
|
|
||||||
productAccessFilter: vi.fn().mockReturnValue({}),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: {
|
|
||||||
task: {
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
create: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
delete: vi.fn(),
|
|
||||||
findMany: vi.fn(),
|
|
||||||
},
|
|
||||||
story: {
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
findUniqueOrThrow: vi.fn(),
|
|
||||||
findMany: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
pbi: {
|
|
||||||
findUniqueOrThrow: vi.fn(),
|
|
||||||
findMany: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
sprint: {
|
|
||||||
findUniqueOrThrow: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
claudeJob: {
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
updateMany: vi.fn(),
|
|
||||||
},
|
|
||||||
sprintRun: {
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
$transaction: vi.fn(),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
import { getIronSession } from 'iron-session'
|
|
||||||
import { saveTask, deleteTask } from '@/actions/tasks'
|
|
||||||
|
|
||||||
const mockPrisma = prisma as unknown as {
|
|
||||||
task: {
|
|
||||||
findFirst: ReturnType<typeof vi.fn>
|
|
||||||
create: ReturnType<typeof vi.fn>
|
|
||||||
update: ReturnType<typeof vi.fn>
|
|
||||||
delete: ReturnType<typeof vi.fn>
|
|
||||||
findMany: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
story: {
|
|
||||||
findFirst: ReturnType<typeof vi.fn>
|
|
||||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
|
||||||
findMany: ReturnType<typeof vi.fn>
|
|
||||||
update: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
pbi: {
|
|
||||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
|
||||||
findMany: ReturnType<typeof vi.fn>
|
|
||||||
update: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
sprint: {
|
|
||||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
|
||||||
update: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
claudeJob: {
|
|
||||||
findFirst: ReturnType<typeof vi.fn>
|
|
||||||
updateMany: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
sprintRun: {
|
|
||||||
findUnique: ReturnType<typeof vi.fn>
|
|
||||||
update: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
$transaction: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
const mockSession = getIronSession as ReturnType<typeof vi.fn>
|
|
||||||
|
|
||||||
const VALID_INPUT = {
|
|
||||||
title: 'Test taak',
|
|
||||||
description: 'Beschrijving',
|
|
||||||
implementation_plan: 'Plan',
|
|
||||||
priority: 3,
|
|
||||||
}
|
|
||||||
|
|
||||||
const TASK = {
|
|
||||||
id: 'task-1',
|
|
||||||
title: 'Test taak',
|
|
||||||
status: 'TO_DO',
|
|
||||||
}
|
|
||||||
|
|
||||||
const STORY = { sprint_id: 'sprint-1' }
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockSession.mockResolvedValue({ userId: 'user-1', isDemo: false })
|
|
||||||
// Pass-through transaction so saveTask's $transaction wrapper executes its callback inline.
|
|
||||||
mockPrisma.$transaction.mockImplementation(async (run: (tx: typeof prisma) => Promise<unknown>) => {
|
|
||||||
return run(prisma)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// ─── saveTask ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('saveTask — demo-readonly (laag 2)', () => {
|
|
||||||
it('blokkeert demo-sessie', async () => {
|
|
||||||
mockSession.mockResolvedValue({ userId: 'user-1', isDemo: true })
|
|
||||||
const result = await saveTask(VALID_INPUT, { productId: 'p-1' })
|
|
||||||
expect(result).toEqual({ ok: false, code: 403, error: 'demo_readonly' })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('saveTask — unauthenticated', () => {
|
|
||||||
it('blokkeert niet-ingelogde gebruiker', async () => {
|
|
||||||
mockSession.mockResolvedValue({ userId: undefined, isDemo: false })
|
|
||||||
const result = await saveTask(VALID_INPUT, { productId: 'p-1' })
|
|
||||||
expect(result).toEqual({ ok: false, code: 403, error: 'forbidden' })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('saveTask — validatie', () => {
|
|
||||||
it('retourneert 422 bij lege titel', async () => {
|
|
||||||
const result = await saveTask({ ...VALID_INPUT, title: '' }, { productId: 'p-1', storyId: 's-1' })
|
|
||||||
expect(result).toMatchObject({ ok: false, code: 422, error: 'validation' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('retourneert 422 bij te lange titel (>120 tekens)', async () => {
|
|
||||||
const result = await saveTask(
|
|
||||||
{ ...VALID_INPUT, title: 'a'.repeat(121) },
|
|
||||||
{ productId: 'p-1', storyId: 's-1' },
|
|
||||||
)
|
|
||||||
expect(result).toMatchObject({ ok: false, code: 422, error: 'validation' })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('saveTask — edit (cross-tenant scope)', () => {
|
|
||||||
it('retourneert forbidden als task buiten scope valt', async () => {
|
|
||||||
mockPrisma.task.findFirst.mockResolvedValue(null) // out-of-scope
|
|
||||||
const result = await saveTask(VALID_INPUT, { taskId: 'task-1', productId: 'p-1' })
|
|
||||||
expect(result).toEqual({ ok: false, code: 403, error: 'forbidden' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('update slaagt voor een geautoriseerde task', async () => {
|
|
||||||
mockPrisma.task.findFirst.mockResolvedValue(TASK)
|
|
||||||
mockPrisma.task.update.mockResolvedValue(TASK)
|
|
||||||
const result = await saveTask(VALID_INPUT, { taskId: 'task-1', productId: 'p-1' })
|
|
||||||
expect(result).toMatchObject({ ok: true })
|
|
||||||
// scope-filter is toegepast: findFirst bevat `story.product`
|
|
||||||
expect(mockPrisma.task.findFirst).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
where: expect.objectContaining({ id: 'task-1', story: expect.anything() }),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('saveTask — edit met status-promotie', () => {
|
|
||||||
it('promotes story naar DONE wanneer status flip naar DONE alle siblings DONE maakt', async () => {
|
|
||||||
mockPrisma.task.findFirst.mockResolvedValue({ id: 'task-1', status: 'IN_PROGRESS' })
|
|
||||||
mockPrisma.task.update.mockResolvedValue({
|
|
||||||
id: 'task-1',
|
|
||||||
title: 'Test taak',
|
|
||||||
status: 'IN_PROGRESS',
|
|
||||||
story_id: 'story-1',
|
|
||||||
implementation_plan: null,
|
|
||||||
})
|
|
||||||
// Wanneer de helper draait, gebruikt-ie tx.task.update voor de status-flip.
|
|
||||||
// Dezelfde mock vangt beide updates op; tweede return-value voor de status-update.
|
|
||||||
mockPrisma.task.update.mockResolvedValueOnce({
|
|
||||||
id: 'task-1',
|
|
||||||
title: 'Test taak',
|
|
||||||
status: 'IN_PROGRESS',
|
|
||||||
story_id: 'story-1',
|
|
||||||
implementation_plan: null,
|
|
||||||
}).mockResolvedValueOnce({
|
|
||||||
id: 'task-1',
|
|
||||||
title: 'Test taak',
|
|
||||||
status: 'DONE',
|
|
||||||
story_id: 'story-1',
|
|
||||||
implementation_plan: null,
|
|
||||||
})
|
|
||||||
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }, { status: 'DONE' }])
|
|
||||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
|
||||||
id: 'story-1',
|
|
||||||
status: 'IN_SPRINT',
|
|
||||||
pbi_id: 'pbi-1',
|
|
||||||
sprint_id: null,
|
|
||||||
})
|
|
||||||
mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }])
|
|
||||||
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
|
|
||||||
|
|
||||||
const result = await saveTask(
|
|
||||||
{ ...VALID_INPUT, status: 'DONE' },
|
|
||||||
{ taskId: 'task-1', productId: 'p-1' },
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ ok: true })
|
|
||||||
expect(mockPrisma.story.update).toHaveBeenCalledWith({
|
|
||||||
where: { id: 'story-1' },
|
|
||||||
data: { status: 'DONE' },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('saveTask — create (cross-tenant scope)', () => {
|
|
||||||
it('retourneert forbidden als story buiten scope valt', async () => {
|
|
||||||
mockPrisma.story.findFirst.mockResolvedValue(null)
|
|
||||||
const result = await saveTask(VALID_INPUT, { storyId: 's-1', productId: 'p-1' })
|
|
||||||
expect(result).toEqual({ ok: false, code: 403, error: 'forbidden' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('aanmaken slaagt voor een geautoriseerde story', async () => {
|
|
||||||
mockPrisma.story.findFirst.mockResolvedValue(STORY)
|
|
||||||
mockPrisma.task.findFirst.mockResolvedValue(null) // geen vorige taak
|
|
||||||
mockPrisma.task.create.mockResolvedValue(TASK)
|
|
||||||
const result = await saveTask(VALID_INPUT, { storyId: 's-1', productId: 'p-1' })
|
|
||||||
expect(result).toMatchObject({ ok: true })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// ─── deleteTask ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('deleteTask — demo-readonly (laag 2)', () => {
|
|
||||||
it('blokkeert demo-sessie', async () => {
|
|
||||||
mockSession.mockResolvedValue({ userId: 'user-1', isDemo: true })
|
|
||||||
const result = await deleteTask('task-1', { productId: 'p-1' })
|
|
||||||
expect(result).toEqual({ ok: false, code: 403, error: 'demo_readonly' })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('deleteTask — unauthenticated', () => {
|
|
||||||
it('blokkeert niet-ingelogde gebruiker', async () => {
|
|
||||||
mockSession.mockResolvedValue({ userId: undefined, isDemo: false })
|
|
||||||
const result = await deleteTask('task-1', { productId: 'p-1' })
|
|
||||||
expect(result).toEqual({ ok: false, code: 403, error: 'forbidden' })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('deleteTask — cross-tenant scope', () => {
|
|
||||||
it('retourneert forbidden als task buiten scope valt', async () => {
|
|
||||||
mockPrisma.task.findFirst.mockResolvedValue(null)
|
|
||||||
const result = await deleteTask('task-1', { productId: 'p-1' })
|
|
||||||
expect(result).toEqual({ ok: false, code: 403, error: 'forbidden' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('verwijderen slaagt voor een geautoriseerde task', async () => {
|
|
||||||
mockPrisma.task.findFirst.mockResolvedValue(TASK)
|
|
||||||
mockPrisma.task.delete.mockResolvedValue(TASK)
|
|
||||||
const result = await deleteTask('task-1', { productId: 'p-1' })
|
|
||||||
expect(result).toEqual({ ok: true })
|
|
||||||
// scope-filter toegepast
|
|
||||||
expect(mockPrisma.task.findFirst).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
where: expect.objectContaining({ id: 'task-1', story: expect.anything() }),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,148 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
|
||||||
vi.mock('next/headers', () => ({
|
|
||||||
cookies: vi.fn().mockResolvedValue({
|
|
||||||
set: vi.fn(),
|
|
||||||
get: vi.fn(),
|
|
||||||
delete: vi.fn(),
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
vi.mock('iron-session', () => ({
|
|
||||||
getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/session', () => ({
|
|
||||||
sessionOptions: { cookieName: 'test', password: 'test' },
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/product-access', () => ({
|
|
||||||
productAccessFilter: vi.fn().mockReturnValue({}),
|
|
||||||
getAccessibleProduct: vi.fn().mockResolvedValue({ id: 'product-1' }),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/rate-limit', () => ({
|
|
||||||
enforceUserRateLimit: vi.fn().mockReturnValue(null),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/code-server', () => ({
|
|
||||||
createWithCodeRetry: vi.fn(),
|
|
||||||
generateNextSprintCode: vi.fn(),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/active-sprint', () => ({
|
|
||||||
setActiveSprintInSettings: vi.fn().mockResolvedValue(undefined),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: {
|
|
||||||
sprint: {
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
story: {
|
|
||||||
findMany: vi.fn(),
|
|
||||||
updateMany: vi.fn(),
|
|
||||||
},
|
|
||||||
task: {
|
|
||||||
findMany: vi.fn(),
|
|
||||||
updateMany: vi.fn(),
|
|
||||||
},
|
|
||||||
$transaction: vi.fn(),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
import { updateSprintAction } from '@/actions/sprints'
|
|
||||||
|
|
||||||
type Mocked = {
|
|
||||||
sprint: {
|
|
||||||
findFirst: ReturnType<typeof vi.fn>
|
|
||||||
update: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const mockPrisma = prisma as unknown as Mocked
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockPrisma.sprint.findFirst.mockReset().mockResolvedValue({
|
|
||||||
id: 'sprint-1',
|
|
||||||
product_id: 'product-1',
|
|
||||||
})
|
|
||||||
mockPrisma.sprint.update.mockReset().mockResolvedValue({})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('updateSprintAction', () => {
|
|
||||||
it('updates sprint_goal alone', async () => {
|
|
||||||
const result = await updateSprintAction({
|
|
||||||
sprintId: 'sprint-1',
|
|
||||||
fields: { goal: 'Nieuw doel' },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect('success' in result).toBe(true)
|
|
||||||
expect(mockPrisma.sprint.update).toHaveBeenCalledWith({
|
|
||||||
where: { id: 'sprint-1' },
|
|
||||||
data: { sprint_goal: 'Nieuw doel' },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('updates dates only', async () => {
|
|
||||||
await updateSprintAction({
|
|
||||||
sprintId: 'sprint-1',
|
|
||||||
fields: { startAt: '2026-06-01', endAt: '2026-06-14' },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(mockPrisma.sprint.update).toHaveBeenCalledWith({
|
|
||||||
where: { id: 'sprint-1' },
|
|
||||||
data: {
|
|
||||||
start_date: new Date('2026-06-01'),
|
|
||||||
end_date: new Date('2026-06-14'),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('accepts null to clear a date', async () => {
|
|
||||||
await updateSprintAction({
|
|
||||||
sprintId: 'sprint-1',
|
|
||||||
fields: { startAt: null },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(mockPrisma.sprint.update).toHaveBeenCalledWith({
|
|
||||||
where: { id: 'sprint-1' },
|
|
||||||
data: { start_date: null },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects when sprint not accessible', async () => {
|
|
||||||
mockPrisma.sprint.findFirst.mockResolvedValue(null)
|
|
||||||
|
|
||||||
const result = await updateSprintAction({
|
|
||||||
sprintId: 'sprint-1',
|
|
||||||
fields: { goal: 'x' },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect('error' in result).toBe(true)
|
|
||||||
if ('error' in result) {
|
|
||||||
expect(result.code).toBe(403)
|
|
||||||
}
|
|
||||||
expect(mockPrisma.sprint.update).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects empty goal', async () => {
|
|
||||||
const result = await updateSprintAction({
|
|
||||||
sprintId: 'sprint-1',
|
|
||||||
fields: { goal: '' },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect('error' in result).toBe(true)
|
|
||||||
expect(mockPrisma.sprint.update).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects when no fields are supplied', async () => {
|
|
||||||
const result = await updateSprintAction({
|
|
||||||
sprintId: 'sprint-1',
|
|
||||||
fields: {},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Schema-refine should reject; OR action treats empty data as no-op success.
|
|
||||||
// Current implementation: refine forces minstens één veld → 422 error.
|
|
||||||
expect('error' in result).toBe(true)
|
|
||||||
if ('error' in result) {
|
|
||||||
expect(result.code).toBe(422)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) }))
|
|
||||||
vi.mock('iron-session', () => ({
|
|
||||||
getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/session', () => ({
|
|
||||||
sessionOptions: { cookieName: 'test', password: 'test' },
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: {
|
|
||||||
user: { findUnique: vi.fn() },
|
|
||||||
$transaction: vi.fn(async (fn: (tx: unknown) => Promise<unknown>) => {
|
|
||||||
return fn({
|
|
||||||
user: {
|
|
||||||
findUnique: vi.fn().mockResolvedValue({ settings: {} }),
|
|
||||||
update: vi.fn().mockResolvedValue({}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
$executeRaw: vi.fn().mockResolvedValue(1),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
import { getIronSession } from 'iron-session'
|
|
||||||
import { updateUserSettingsAction } from '@/actions/user-settings'
|
|
||||||
|
|
||||||
const mockPrisma = prisma as unknown as {
|
|
||||||
user: { findUnique: ReturnType<typeof vi.fn> }
|
|
||||||
$transaction: ReturnType<typeof vi.fn>
|
|
||||||
$executeRaw: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
const mockGetIronSession = getIronSession as ReturnType<typeof vi.fn>
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockGetIronSession.mockResolvedValue({ userId: 'user-1', isDemo: false })
|
|
||||||
mockPrisma.$executeRaw.mockResolvedValue(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('updateUserSettingsAction', () => {
|
|
||||||
it('returns 401 when not logged in', async () => {
|
|
||||||
mockGetIronSession.mockResolvedValue({ userId: undefined, isDemo: false })
|
|
||||||
const result = await updateUserSettingsAction({})
|
|
||||||
expect(result).toEqual({ error: 'Niet ingelogd', code: 401 })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns 403 for demo accounts', async () => {
|
|
||||||
mockGetIronSession.mockResolvedValue({ userId: 'user-1', isDemo: true })
|
|
||||||
const result = await updateUserSettingsAction({})
|
|
||||||
expect('error' in result && result.code).toBe(403)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns 422 when patch is invalid', async () => {
|
|
||||||
const result = await updateUserSettingsAction({
|
|
||||||
views: { sprintBacklog: { filterStatus: 'NONSENSE' } },
|
|
||||||
} as never)
|
|
||||||
expect('error' in result && result.code).toBe(422)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('merges with current settings and emits notify on success', async () => {
|
|
||||||
const existingFindUnique = vi.fn().mockResolvedValue({
|
|
||||||
settings: { views: { sprintBacklog: { sort: 'code' } } },
|
|
||||||
})
|
|
||||||
const update = vi.fn().mockResolvedValue({})
|
|
||||||
mockPrisma.$transaction.mockImplementationOnce(async (fn: (tx: unknown) => Promise<unknown>) => {
|
|
||||||
return fn({ user: { findUnique: existingFindUnique, update } })
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await updateUserSettingsAction({
|
|
||||||
views: { sprintBacklog: { sortDir: 'desc' } },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect('success' in result && result.success).toBe(true)
|
|
||||||
expect(update).toHaveBeenCalledWith({
|
|
||||||
where: { id: 'user-1' },
|
|
||||||
data: { settings: { views: { sprintBacklog: { sort: 'code', sortDir: 'desc' } } } },
|
|
||||||
})
|
|
||||||
expect(mockPrisma.$executeRaw).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: {
|
|
||||||
claudeJob: { deleteMany: vi.fn() },
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
import { POST } from '@/app/api/cron/cleanup-agent-artifacts/route'
|
|
||||||
|
|
||||||
const mockPrisma = prisma as unknown as {
|
|
||||||
claudeJob: { deleteMany: ReturnType<typeof vi.fn> }
|
|
||||||
}
|
|
||||||
|
|
||||||
const SECRET = 'test-cron-secret-abc123'
|
|
||||||
|
|
||||||
function makeReq(headers: Record<string, string> = {}): Request {
|
|
||||||
return new Request('http://localhost:3000/api/cron/cleanup-agent-artifacts', {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
process.env.CRON_SECRET = SECRET
|
|
||||||
mockPrisma.claudeJob.deleteMany.mockResolvedValue({ count: 0 })
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('POST /api/cron/cleanup-agent-artifacts', () => {
|
|
||||||
it('401 zonder Authorization-header', async () => {
|
|
||||||
const res = await POST(makeReq())
|
|
||||||
expect(res.status).toBe(401)
|
|
||||||
expect(mockPrisma.claudeJob.deleteMany).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('401 met verkeerde secret', async () => {
|
|
||||||
const res = await POST(makeReq({ authorization: 'Bearer wrong-secret' }))
|
|
||||||
expect(res.status).toBe(401)
|
|
||||||
expect(mockPrisma.claudeJob.deleteMany).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('200 met juiste secret + deleteMany aangeroepen voor FAILED/CANCELLED/SKIPPED ouder dan 7 dagen', async () => {
|
|
||||||
mockPrisma.claudeJob.deleteMany.mockResolvedValue({ count: 5 })
|
|
||||||
|
|
||||||
const res = await POST(makeReq({ authorization: 'Bearer ' + SECRET }))
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = await res.json()
|
|
||||||
expect(body.deleted).toBe(5)
|
|
||||||
expect(body.ran_at).toMatch(/^\d{4}-\d{2}-\d{2}T/)
|
|
||||||
|
|
||||||
const arg = mockPrisma.claudeJob.deleteMany.mock.calls[0][0]
|
|
||||||
expect(arg.where.status).toEqual({ in: ['FAILED', 'CANCELLED', 'SKIPPED'] })
|
|
||||||
expect(arg.where.finished_at.lt).toBeInstanceOf(Date)
|
|
||||||
|
|
||||||
// cutoff should be approximately 7 days ago
|
|
||||||
const cutoff = arg.where.finished_at.lt as Date
|
|
||||||
const diffMs = Date.now() - cutoff.getTime()
|
|
||||||
const diffDays = diffMs / (1000 * 60 * 60 * 24)
|
|
||||||
expect(diffDays).toBeCloseTo(7, 0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: {
|
|
||||||
claudeQuestion: { updateMany: vi.fn() },
|
|
||||||
loginPairing: { updateMany: vi.fn() },
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
import { POST } from '@/app/api/cron/expire-questions/route'
|
|
||||||
|
|
||||||
const mockPrisma = prisma as unknown as {
|
|
||||||
claudeQuestion: { updateMany: ReturnType<typeof vi.fn> }
|
|
||||||
loginPairing: { updateMany: ReturnType<typeof vi.fn> }
|
|
||||||
}
|
|
||||||
|
|
||||||
const SECRET = 'test-cron-secret-abc123'
|
|
||||||
|
|
||||||
function makeReq(headers: Record<string, string> = {}): Request {
|
|
||||||
return new Request('http://localhost:3000/api/cron/expire-questions', {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
process.env.CRON_SECRET = SECRET
|
|
||||||
mockPrisma.claudeQuestion.updateMany.mockResolvedValue({ count: 0 })
|
|
||||||
mockPrisma.loginPairing.updateMany.mockResolvedValue({ count: 0 })
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('POST /api/cron/expire-questions', () => {
|
|
||||||
it('401 zonder Authorization-header', async () => {
|
|
||||||
const res = await POST(makeReq())
|
|
||||||
expect(res.status).toBe(401)
|
|
||||||
expect(mockPrisma.claudeQuestion.updateMany).not.toHaveBeenCalled()
|
|
||||||
expect(mockPrisma.loginPairing.updateMany).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('401 met verkeerde secret', async () => {
|
|
||||||
const res = await POST(makeReq({ authorization: 'Bearer wrong-secret' }))
|
|
||||||
expect(res.status).toBe(401)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('401 als CRON_SECRET niet is gezet (faal-veilig)', async () => {
|
|
||||||
delete process.env.CRON_SECRET
|
|
||||||
const res = await POST(makeReq({ authorization: 'Bearer ' + SECRET }))
|
|
||||||
expect(res.status).toBe(401)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('200 met juiste secret + beide updateMany aangeroepen', async () => {
|
|
||||||
mockPrisma.claudeQuestion.updateMany.mockResolvedValue({ count: 3 })
|
|
||||||
mockPrisma.loginPairing.updateMany.mockResolvedValue({ count: 1 })
|
|
||||||
|
|
||||||
const res = await POST(makeReq({ authorization: 'Bearer ' + SECRET }))
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = await res.json()
|
|
||||||
expect(body).toMatchObject({
|
|
||||||
expired_questions: 3,
|
|
||||||
expired_pairings: 1,
|
|
||||||
})
|
|
||||||
expect(body.ran_at).toMatch(/^\d{4}-\d{2}-\d{2}T/)
|
|
||||||
|
|
||||||
// Question-update: status='open' AND expires_at < now → 'expired'
|
|
||||||
const qArg = mockPrisma.claudeQuestion.updateMany.mock.calls[0][0]
|
|
||||||
expect(qArg.where).toMatchObject({ status: 'open' })
|
|
||||||
expect(qArg.where.expires_at).toMatchObject({ lt: expect.any(Date) })
|
|
||||||
expect(qArg.data).toEqual({ status: 'expired' })
|
|
||||||
|
|
||||||
// Pairing-update: status='pending' AND expires_at < now → 'cancelled'
|
|
||||||
const pArg = mockPrisma.loginPairing.updateMany.mock.calls[0][0]
|
|
||||||
expect(pArg.where).toMatchObject({ status: 'pending' })
|
|
||||||
expect(pArg.data).toEqual({ status: 'cancelled' })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: {
|
|
||||||
product: { findFirst: vi.fn() },
|
|
||||||
story: { findMany: vi.fn() },
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/lib/api-auth', () => ({
|
|
||||||
authenticateApiRequest: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/lib/product-access', () => ({
|
|
||||||
productAccessFilter: vi.fn().mockReturnValue({}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
import { authenticateApiRequest } from '@/lib/api-auth'
|
|
||||||
import { GET } from '@/app/api/products/[id]/cross-sprint-blocks/route'
|
|
||||||
|
|
||||||
const mockPrisma = prisma as unknown as {
|
|
||||||
product: { findFirst: ReturnType<typeof vi.fn> }
|
|
||||||
story: { findMany: ReturnType<typeof vi.fn> }
|
|
||||||
}
|
|
||||||
const mockAuth = authenticateApiRequest as unknown as ReturnType<typeof vi.fn>
|
|
||||||
|
|
||||||
function makeRequest(url: string) {
|
|
||||||
return new Request(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('GET /api/products/[id]/cross-sprint-blocks', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockPrisma.product.findFirst.mockReset()
|
|
||||||
mockPrisma.story.findMany.mockReset()
|
|
||||||
mockAuth.mockReset().mockResolvedValue({ userId: 'user-1' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns blocking sprint info per story for happy path', async () => {
|
|
||||||
mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' })
|
|
||||||
mockPrisma.story.findMany.mockResolvedValue([
|
|
||||||
{
|
|
||||||
id: 'story-1',
|
|
||||||
sprint: { id: 'sprint-x', code: 'SP-X' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'story-2',
|
|
||||||
sprint: { id: 'sprint-y', code: 'SP-Y' },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
const req = makeRequest(
|
|
||||||
'http://localhost/api/products/p1/cross-sprint-blocks?excludeSprintId=sp-1&pbiIds=pbiA',
|
|
||||||
)
|
|
||||||
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = await res.json()
|
|
||||||
expect(body).toEqual({
|
|
||||||
'story-1': { sprintId: 'sprint-x', sprintName: 'SP-X' },
|
|
||||||
'story-2': { sprintId: 'sprint-y', sprintName: 'SP-Y' },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects when pbiIds is missing', async () => {
|
|
||||||
const req = makeRequest(
|
|
||||||
'http://localhost/api/products/p1/cross-sprint-blocks?excludeSprintId=sp-1',
|
|
||||||
)
|
|
||||||
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
|
||||||
expect(res.status).toBe(400)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects when pbiIds is empty', async () => {
|
|
||||||
const req = makeRequest(
|
|
||||||
'http://localhost/api/products/p1/cross-sprint-blocks?pbiIds=',
|
|
||||||
)
|
|
||||||
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
|
||||||
expect(res.status).toBe(400)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns 404 when product is not accessible', async () => {
|
|
||||||
mockPrisma.product.findFirst.mockResolvedValue(null)
|
|
||||||
const req = makeRequest(
|
|
||||||
'http://localhost/api/products/p1/cross-sprint-blocks?pbiIds=pbiA',
|
|
||||||
)
|
|
||||||
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
|
||||||
expect(res.status).toBe(404)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns auth error when authenticate fails', async () => {
|
|
||||||
mockAuth.mockResolvedValue({ error: 'Niet ingelogd', status: 401 })
|
|
||||||
const req = makeRequest(
|
|
||||||
'http://localhost/api/products/p1/cross-sprint-blocks?pbiIds=pbiA',
|
|
||||||
)
|
|
||||||
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
|
||||||
expect(res.status).toBe(401)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('passes NOT excludeSprintId to prisma when provided', async () => {
|
|
||||||
mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' })
|
|
||||||
mockPrisma.story.findMany.mockResolvedValue([])
|
|
||||||
|
|
||||||
const req = makeRequest(
|
|
||||||
'http://localhost/api/products/p1/cross-sprint-blocks?excludeSprintId=sp-active&pbiIds=pbiA',
|
|
||||||
)
|
|
||||||
await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
|
||||||
|
|
||||||
const callArg = mockPrisma.story.findMany.mock.calls[0][0] as {
|
|
||||||
where: Record<string, unknown>
|
|
||||||
}
|
|
||||||
expect(callArg.where).toMatchObject({
|
|
||||||
pbi_id: { in: ['pbiA'] },
|
|
||||||
product_id: 'p1',
|
|
||||||
sprint_id: { not: null },
|
|
||||||
NOT: { sprint_id: 'sp-active' },
|
|
||||||
sprint: { status: 'OPEN' },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,194 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: {
|
|
||||||
product: { findFirst: vi.fn() },
|
|
||||||
idea: {
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
findMany: vi.fn(),
|
|
||||||
create: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
ideaLog: { findMany: vi.fn() },
|
|
||||||
$transaction: vi.fn(),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/api-auth', () => ({
|
|
||||||
authenticateApiRequest: vi.fn(),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/idea-code-server', () => ({
|
|
||||||
nextIdeaCode: vi.fn().mockResolvedValue('IDEA-001'),
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
import { authenticateApiRequest } from '@/lib/api-auth'
|
|
||||||
import { GET as getIdeas, POST as postIdea } from '@/app/api/ideas/route'
|
|
||||||
import { GET as getIdea, PATCH as patchIdea } from '@/app/api/ideas/[id]/route'
|
|
||||||
|
|
||||||
type M = {
|
|
||||||
product: { findFirst: ReturnType<typeof vi.fn> }
|
|
||||||
idea: { findFirst: ReturnType<typeof vi.fn>; findMany: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
|
|
||||||
ideaLog: { findMany: ReturnType<typeof vi.fn> }
|
|
||||||
$transaction: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
const m = prisma as unknown as M
|
|
||||||
const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn>
|
|
||||||
|
|
||||||
const NOW = new Date('2026-05-04T19:00:00Z')
|
|
||||||
|
|
||||||
const IDEA_ROW = {
|
|
||||||
id: 'idea-1',
|
|
||||||
user_id: 'user-1',
|
|
||||||
code: 'IDEA-001',
|
|
||||||
title: 'Plant-watering reminder',
|
|
||||||
description: null,
|
|
||||||
status: 'DRAFT' as const,
|
|
||||||
product_id: null,
|
|
||||||
product: null,
|
|
||||||
pbi: null,
|
|
||||||
pbi_id: null,
|
|
||||||
archived: false,
|
|
||||||
grill_md: null,
|
|
||||||
plan_md: null,
|
|
||||||
created_at: NOW,
|
|
||||||
updated_at: NOW,
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeRequest(method: 'GET' | 'POST' | 'PATCH', url: string, body?: unknown): Request {
|
|
||||||
return new Request(`http://localhost${url}`, {
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
Authorization: 'Bearer test-token',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockAuth.mockResolvedValue({ userId: 'user-1', isDemo: false })
|
|
||||||
m.$transaction.mockImplementation(async (arg: unknown) => {
|
|
||||||
if (typeof arg === 'function') return (arg as (tx: unknown) => unknown)(m)
|
|
||||||
return arg
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('GET /api/ideas', () => {
|
|
||||||
it('returns user ideas (DTO shape)', async () => {
|
|
||||||
m.idea.findMany.mockResolvedValueOnce([IDEA_ROW])
|
|
||||||
const res = await getIdeas(makeRequest('GET', '/api/ideas'))
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = await res.json()
|
|
||||||
expect(body.ideas).toHaveLength(1)
|
|
||||||
expect(body.ideas[0]).toMatchObject({
|
|
||||||
id: 'idea-1',
|
|
||||||
code: 'IDEA-001',
|
|
||||||
status: 'draft',
|
|
||||||
has_grill_md: false,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects unauthenticated', async () => {
|
|
||||||
mockAuth.mockResolvedValueOnce({ error: 'Unauthorized', status: 401 })
|
|
||||||
const res = await getIdeas(makeRequest('GET', '/api/ideas'))
|
|
||||||
expect(res.status).toBe(401)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('filters by archived=false param', async () => {
|
|
||||||
m.idea.findMany.mockResolvedValueOnce([])
|
|
||||||
await getIdeas(makeRequest('GET', '/api/ideas?archived=false'))
|
|
||||||
expect(m.idea.findMany).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
where: expect.objectContaining({ archived: false, user_id: 'user-1' }),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('POST /api/ideas', () => {
|
|
||||||
it('creates idea and returns 201', async () => {
|
|
||||||
m.idea.create.mockResolvedValueOnce(IDEA_ROW)
|
|
||||||
const res = await postIdea(makeRequest('POST', '/api/ideas', { title: 'Plant-watering reminder' }))
|
|
||||||
expect(res.status).toBe(201)
|
|
||||||
const body = await res.json()
|
|
||||||
expect(body.idea).toMatchObject({ id: 'idea-1', code: 'IDEA-001', status: 'draft' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects demo with 403', async () => {
|
|
||||||
mockAuth.mockResolvedValueOnce({ userId: 'demo-1', isDemo: true })
|
|
||||||
const res = await postIdea(makeRequest('POST', '/api/ideas', { title: 'x' }))
|
|
||||||
expect(res.status).toBe(403)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects empty title with 422', async () => {
|
|
||||||
const res = await postIdea(makeRequest('POST', '/api/ideas', { title: '' }))
|
|
||||||
expect(res.status).toBe(422)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects malformed JSON with 400', async () => {
|
|
||||||
const req = new Request('http://localhost/api/ideas', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { Authorization: 'Bearer test', 'Content-Type': 'application/json' },
|
|
||||||
body: 'not-json',
|
|
||||||
})
|
|
||||||
const res = await postIdea(req)
|
|
||||||
expect(res.status).toBe(400)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns 404 when product_id refers to a foreign product', async () => {
|
|
||||||
m.product.findFirst.mockResolvedValueOnce(null)
|
|
||||||
const res = await postIdea(
|
|
||||||
makeRequest('POST', '/api/ideas', {
|
|
||||||
title: 'x',
|
|
||||||
product_id: 'cmohrysyj0000rd17clnjy4tc',
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
expect(res.status).toBe(404)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('GET /api/ideas/[id]', () => {
|
|
||||||
it('returns idea + logs', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce(IDEA_ROW)
|
|
||||||
m.ideaLog.findMany.mockResolvedValueOnce([
|
|
||||||
{ id: 'l-1', type: 'NOTE', content: 'x', metadata: null, created_at: NOW },
|
|
||||||
])
|
|
||||||
const ctx = { params: Promise.resolve({ id: 'idea-1' }) }
|
|
||||||
const res = await getIdea(makeRequest('GET', '/api/ideas/idea-1'), ctx)
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = await res.json()
|
|
||||||
expect(body.idea).toMatchObject({ id: 'idea-1' })
|
|
||||||
expect(body.logs).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns 404 (not 403) for foreign user — anti-enumeration', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce(null)
|
|
||||||
const ctx = { params: Promise.resolve({ id: 'idea-1' }) }
|
|
||||||
const res = await getIdea(makeRequest('GET', '/api/ideas/idea-1'), ctx)
|
|
||||||
expect(res.status).toBe(404)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('PATCH /api/ideas/[id]', () => {
|
|
||||||
const ctx = { params: Promise.resolve({ id: 'idea-1' }) }
|
|
||||||
|
|
||||||
it('updates editable idea', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', status: 'DRAFT' })
|
|
||||||
m.idea.update.mockResolvedValueOnce({ ...IDEA_ROW, title: 'Updated' })
|
|
||||||
const res = await patchIdea(makeRequest('PATCH', '/api/ideas/idea-1', { title: 'Updated' }), ctx)
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('blocks demo with 403', async () => {
|
|
||||||
mockAuth.mockResolvedValueOnce({ userId: 'demo-1', isDemo: true })
|
|
||||||
const res = await patchIdea(makeRequest('PATCH', '/api/ideas/idea-1', { title: 'x' }), ctx)
|
|
||||||
expect(res.status).toBe(403)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('blocks update on PLANNED with 422', async () => {
|
|
||||||
m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', status: 'PLANNED' })
|
|
||||||
const res = await patchIdea(makeRequest('PATCH', '/api/ideas/idea-1', { title: 'x' }), ctx)
|
|
||||||
expect(res.status).toBe(422)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -25,7 +25,7 @@ const mockPrisma = prisma as unknown as {
|
||||||
}
|
}
|
||||||
const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn>
|
const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
const SPRINT = { id: 'sprint-1', product_id: 'prod-1', status: 'OPEN' }
|
const SPRINT = { id: 'sprint-1', product_id: 'prod-1', status: 'ACTIVE' }
|
||||||
const STORY = {
|
const STORY = {
|
||||||
id: 'story-1',
|
id: 'story-1',
|
||||||
title: 'Account aanmaken',
|
title: 'Account aanmaken',
|
||||||
|
|
@ -95,7 +95,7 @@ describe('GET /api/products/:id/next-story', () => {
|
||||||
expect(data.tasks[0]).toMatchObject({ id: 'task-1', status: 'todo' })
|
expect(data.tasks[0]).toMatchObject({ id: 'task-1', status: 'todo' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('queries story ordered by sort_order only', async () => {
|
it('queries story ordered by priority then sort_order', async () => {
|
||||||
mockPrisma.sprint.findFirst.mockResolvedValue(SPRINT)
|
mockPrisma.sprint.findFirst.mockResolvedValue(SPRINT)
|
||||||
mockPrisma.story.findFirst.mockResolvedValue(STORY)
|
mockPrisma.story.findFirst.mockResolvedValue(STORY)
|
||||||
|
|
||||||
|
|
@ -103,7 +103,7 @@ describe('GET /api/products/:id/next-story', () => {
|
||||||
|
|
||||||
expect(mockPrisma.story.findFirst).toHaveBeenCalledWith(
|
expect(mockPrisma.story.findFirst).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
orderBy: [{ sort_order: 'asc' }],
|
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
const { mockGetSession } = vi.hoisted(() => ({ mockGetSession: vi.fn() }))
|
|
||||||
|
|
||||||
vi.mock('@/lib/auth', () => ({
|
|
||||||
getSession: mockGetSession,
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: {
|
|
||||||
product: { findMany: vi.fn() },
|
|
||||||
claudeQuestion: { findMany: vi.fn() },
|
|
||||||
idea: { findMany: vi.fn().mockResolvedValue([]) },
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/lib/product-access', () => ({
|
|
||||||
productAccessFilter: vi.fn().mockReturnValue({}),
|
|
||||||
getAccessibleProduct: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
import type { NextRequest } from 'next/server'
|
|
||||||
import { GET } from '@/app/api/realtime/notifications/route'
|
|
||||||
|
|
||||||
const mockPrisma = prisma as unknown as {
|
|
||||||
product: { findMany: ReturnType<typeof vi.fn> }
|
|
||||||
claudeQuestion: { findMany: ReturnType<typeof vi.fn> }
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeReq(): NextRequest {
|
|
||||||
// Minimaal NextRequest-shape voor de auth-pad — we komen niet bij de
|
|
||||||
// pg-stream-setup omdat de auth-fail vóór dat punt gebeurt.
|
|
||||||
return { signal: new AbortController().signal } as unknown as NextRequest
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
import { productAccessFilter } from '@/lib/product-access'
|
|
||||||
|
|
||||||
const mockProductAccessFilter = productAccessFilter as ReturnType<typeof vi.fn>
|
|
||||||
|
|
||||||
describe('GET /api/realtime/notifications', () => {
|
|
||||||
it('401 zonder iron-session cookie, geen DB-call', async () => {
|
|
||||||
mockGetSession.mockResolvedValue({ userId: undefined, isDemo: false })
|
|
||||||
const res = await GET(makeReq())
|
|
||||||
expect(res.status).toBe(401)
|
|
||||||
expect(mockPrisma.product.findMany).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('access-isolation: productAccessFilter wordt met de juiste userId aangeroepen', async () => {
|
|
||||||
// ST-1106: cross-product-isolatie zit in de productAccessFilter-Set die we
|
|
||||||
// bij connect opbouwen. We mocken de filter zodat product.findMany wel
|
|
||||||
// aangeroepen wordt maar geen producten retourneert; daarna stoppen we
|
|
||||||
// vóór de pg-stream-setup (DIRECT_URL ontbreekt → 500).
|
|
||||||
mockGetSession.mockResolvedValue({ userId: 'user-A', isDemo: false })
|
|
||||||
mockProductAccessFilter.mockReturnValue({ user_id: 'user-A' })
|
|
||||||
mockPrisma.product.findMany.mockResolvedValue([])
|
|
||||||
|
|
||||||
// We laten de stream zelf falen door DIRECT_URL/DATABASE_URL niet te zetten.
|
|
||||||
const before = { ...process.env }
|
|
||||||
delete process.env.DIRECT_URL
|
|
||||||
delete process.env.DATABASE_URL
|
|
||||||
try {
|
|
||||||
const res = await GET(makeReq())
|
|
||||||
expect(res.status).toBe(500)
|
|
||||||
} finally {
|
|
||||||
process.env.DIRECT_URL = before.DIRECT_URL
|
|
||||||
process.env.DATABASE_URL = before.DATABASE_URL
|
|
||||||
}
|
|
||||||
|
|
||||||
// De filter is met de echte userId aangeroepen (cross-user lekt niet)
|
|
||||||
expect(mockProductAccessFilter).toHaveBeenCalledWith('user-A')
|
|
||||||
expect(mockPrisma.product.findMany).toHaveBeenCalledWith({
|
|
||||||
where: { archived: false, user_id: 'user-A' },
|
|
||||||
select: { id: true },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Solo-route filter (entity='question' uitgesloten) is een 1-regel-fix in
|
|
||||||
// app/api/realtime/solo/route.ts. Visueel reviewbaar in de diff; full-stream-
|
|
||||||
// regressie wordt handmatig gedekt in ST-1108-acceptatie.
|
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
const { mockReadPairCookie, mockClearPairCookie, mockSession, mockGetIronSession } = vi.hoisted(
|
|
||||||
() => ({
|
|
||||||
mockReadPairCookie: vi.fn(),
|
|
||||||
mockClearPairCookie: vi.fn(),
|
|
||||||
mockSession: { userId: '', isDemo: false, paired: false, pairedExpiresAt: 0, save: vi.fn() },
|
|
||||||
mockGetIronSession: vi.fn(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
vi.mock('@/lib/auth/pair-cookie', () => ({
|
|
||||||
readPairCookie: mockReadPairCookie,
|
|
||||||
clearPairCookie: mockClearPairCookie,
|
|
||||||
setPairCookie: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('iron-session', () => ({
|
|
||||||
getIronSession: mockGetIronSession,
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('next/headers', () => ({
|
|
||||||
cookies: vi.fn().mockResolvedValue({}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: {
|
|
||||||
loginPairing: {
|
|
||||||
updateMany: vi.fn(),
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
import { hashToken } from '@/lib/auth/pairing'
|
|
||||||
import { POST } from '@/app/api/auth/pair/claim/route'
|
|
||||||
|
|
||||||
const mockPrisma = prisma as unknown as {
|
|
||||||
loginPairing: {
|
|
||||||
updateMany: ReturnType<typeof vi.fn>
|
|
||||||
findFirst: ReturnType<typeof vi.fn>
|
|
||||||
findUnique: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const COOKIE_TOKEN = 'desktop-token-abc'
|
|
||||||
const COOKIE_HASH = hashToken(COOKIE_TOKEN)
|
|
||||||
const PAIRING_ID = 'cmohmk0qpair006c001'
|
|
||||||
|
|
||||||
function makePost(body: unknown): Request {
|
|
||||||
return new Request('http://localhost:3000/api/auth/pair/claim', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: typeof body === 'string' ? body : JSON.stringify(body),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
// Reset session-mock voor elke test
|
|
||||||
mockSession.userId = ''
|
|
||||||
mockSession.isDemo = false
|
|
||||||
mockSession.paired = false
|
|
||||||
mockSession.pairedExpiresAt = 0
|
|
||||||
mockSession.save = vi.fn().mockResolvedValue(undefined)
|
|
||||||
mockGetIronSession.mockResolvedValue(mockSession)
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('POST /api/auth/pair/claim', () => {
|
|
||||||
it('200: schrijft iron-session, clear s4m_pair, retourneert {ok:true}', async () => {
|
|
||||||
mockReadPairCookie.mockResolvedValue(COOKIE_TOKEN)
|
|
||||||
mockPrisma.loginPairing.updateMany.mockResolvedValue({ count: 1 })
|
|
||||||
mockPrisma.loginPairing.findUnique.mockResolvedValue({
|
|
||||||
user_id: 'user-42',
|
|
||||||
user: { is_demo: false },
|
|
||||||
})
|
|
||||||
|
|
||||||
const res = await POST(makePost({ pairingId: PAIRING_ID }))
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
expect(await res.json()).toEqual({ ok: true })
|
|
||||||
|
|
||||||
// Atomic update aangeroepen met juiste WHERE
|
|
||||||
expect(mockPrisma.loginPairing.updateMany).toHaveBeenCalledTimes(1)
|
|
||||||
const where = mockPrisma.loginPairing.updateMany.mock.calls[0][0].where
|
|
||||||
expect(where).toMatchObject({
|
|
||||||
id: PAIRING_ID,
|
|
||||||
status: 'approved',
|
|
||||||
desktop_token_hash: COOKIE_HASH,
|
|
||||||
})
|
|
||||||
expect(where.expires_at).toMatchObject({ gt: expect.any(Date) })
|
|
||||||
|
|
||||||
// Iron-session payload
|
|
||||||
expect(mockSession.userId).toBe('user-42')
|
|
||||||
expect(mockSession.isDemo).toBe(false)
|
|
||||||
expect(mockSession.paired).toBe(true)
|
|
||||||
const dt = mockSession.pairedExpiresAt - Date.now()
|
|
||||||
expect(dt).toBeGreaterThan(8 * 60 * 60 * 1000 - 5_000)
|
|
||||||
expect(dt).toBeLessThan(8 * 60 * 60 * 1000 + 5_000)
|
|
||||||
expect(mockSession.save).toHaveBeenCalledTimes(1)
|
|
||||||
|
|
||||||
expect(mockClearPairCookie).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('demo-user: claim geblokkeerd met 403 (ST-1110.4)', async () => {
|
|
||||||
mockReadPairCookie.mockResolvedValue(COOKIE_TOKEN)
|
|
||||||
mockPrisma.loginPairing.updateMany.mockResolvedValue({ count: 1 })
|
|
||||||
mockPrisma.loginPairing.findUnique.mockResolvedValue({
|
|
||||||
user_id: 'demo-1',
|
|
||||||
user: { is_demo: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
const res = await POST(makePost({ pairingId: PAIRING_ID }))
|
|
||||||
expect(res.status).toBe(403)
|
|
||||||
const body = await res.json()
|
|
||||||
expect(body.error).toMatch(/demo-modus/i)
|
|
||||||
expect(mockClearPairCookie).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('401 zonder s4m_pair-cookie', async () => {
|
|
||||||
mockReadPairCookie.mockResolvedValue(null)
|
|
||||||
const res = await POST(makePost({ pairingId: PAIRING_ID }))
|
|
||||||
expect(res.status).toBe(401)
|
|
||||||
expect(mockPrisma.loginPairing.updateMany).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('400 zonder body', async () => {
|
|
||||||
mockReadPairCookie.mockResolvedValue(COOKIE_TOKEN)
|
|
||||||
const res = await POST(makePost('not-json'))
|
|
||||||
expect(res.status).toBe(400)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('400 zonder pairingId', async () => {
|
|
||||||
mockReadPairCookie.mockResolvedValue(COOKIE_TOKEN)
|
|
||||||
const res = await POST(makePost({}))
|
|
||||||
expect(res.status).toBe(400)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('410 op tweede claim — pairing al consumed', async () => {
|
|
||||||
mockReadPairCookie.mockResolvedValue(COOKIE_TOKEN)
|
|
||||||
mockPrisma.loginPairing.updateMany.mockResolvedValue({ count: 0 })
|
|
||||||
mockPrisma.loginPairing.findFirst.mockResolvedValue({ status: 'consumed' })
|
|
||||||
|
|
||||||
const res = await POST(makePost({ pairingId: PAIRING_ID }))
|
|
||||||
expect(res.status).toBe(410)
|
|
||||||
expect(mockClearPairCookie).toHaveBeenCalledTimes(1)
|
|
||||||
expect(mockSession.save).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('401 op cookie/hash-mismatch (pairing bestaat niet voor deze cookie)', async () => {
|
|
||||||
mockReadPairCookie.mockResolvedValue(COOKIE_TOKEN)
|
|
||||||
mockPrisma.loginPairing.updateMany.mockResolvedValue({ count: 0 })
|
|
||||||
mockPrisma.loginPairing.findFirst.mockResolvedValue(null)
|
|
||||||
|
|
||||||
const res = await POST(makePost({ pairingId: PAIRING_ID }))
|
|
||||||
expect(res.status).toBe(401)
|
|
||||||
expect(mockClearPairCookie).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
const { cookieJar, mockGetIronSession } = vi.hoisted(() => ({
|
|
||||||
cookieJar: { set: vi.fn(), get: vi.fn(), delete: vi.fn() },
|
|
||||||
mockGetIronSession: vi.fn().mockResolvedValue({ isDemo: false }),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('iron-session', () => ({
|
|
||||||
getIronSession: mockGetIronSession,
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: {
|
|
||||||
loginPairing: {
|
|
||||||
create: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('next/headers', () => ({
|
|
||||||
cookies: vi.fn().mockResolvedValue(cookieJar),
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
import { POST } from '@/app/api/auth/pair/start/route'
|
|
||||||
|
|
||||||
const mockPrisma = prisma as unknown as {
|
|
||||||
loginPairing: { create: ReturnType<typeof vi.fn> }
|
|
||||||
}
|
|
||||||
|
|
||||||
function makePost(opts: { ip?: string; ua?: string } = {}): Request {
|
|
||||||
const headers = new Headers()
|
|
||||||
if (opts.ip !== undefined) headers.set('x-forwarded-for', opts.ip)
|
|
||||||
if (opts.ua !== undefined) headers.set('user-agent', opts.ua)
|
|
||||||
return new Request('http://localhost:3000/api/auth/pair/start', {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockPrisma.loginPairing.create.mockResolvedValue({
|
|
||||||
id: 'pair-1',
|
|
||||||
expires_at: new Date('2026-04-27T20:30:00Z'),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('POST /api/auth/pair/start', () => {
|
|
||||||
it('200 met body { pairingId, mobileSecret, expiresAt, qrUrl met fragment }', async () => {
|
|
||||||
const res = await POST(makePost({ ip: '198.51.100.7', ua: 'TestUA/1.0' }))
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = await res.json()
|
|
||||||
expect(body.pairingId).toBe('pair-1')
|
|
||||||
expect(body.mobileSecret).toMatch(/^[A-Za-z0-9_-]{43}$/)
|
|
||||||
expect(body.qrUrl).toBe(
|
|
||||||
`http://localhost:3000/m/pair#id=pair-1&s=${body.mobileSecret}`,
|
|
||||||
)
|
|
||||||
expect(body.expiresAt).toBe('2026-04-27T20:30:00.000Z')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('slaat alleen sha256-hashes op — geen plaintext mobileSecret of desktopToken', async () => {
|
|
||||||
const res = await POST(makePost({ ip: '198.51.100.8' }))
|
|
||||||
const body = await res.json()
|
|
||||||
const arg = mockPrisma.loginPairing.create.mock.calls[0][0].data
|
|
||||||
expect(arg.secret_hash).toMatch(/^[a-f0-9]{64}$/)
|
|
||||||
expect(arg.desktop_token_hash).toMatch(/^[a-f0-9]{64}$/)
|
|
||||||
expect(arg.secret_hash).not.toBe(body.mobileSecret)
|
|
||||||
expect(arg.status).toBe('pending')
|
|
||||||
// expires_at ~5 min in toekomst
|
|
||||||
const dt = new Date(arg.expires_at).getTime() - Date.now()
|
|
||||||
expect(dt).toBeGreaterThan(295_000)
|
|
||||||
expect(dt).toBeLessThan(305_000)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('zet HttpOnly Path-scoped s4m_pair cookie met Max-Age 120', async () => {
|
|
||||||
await POST(makePost({ ip: '198.51.100.9' }))
|
|
||||||
expect(cookieJar.set).toHaveBeenCalledTimes(1)
|
|
||||||
const [name, value, opts] = cookieJar.set.mock.calls[0]
|
|
||||||
expect(name).toBe('s4m_pair')
|
|
||||||
expect(value).toMatch(/^[A-Za-z0-9_-]{43}$/) // desktopToken
|
|
||||||
expect(opts).toMatchObject({
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: 'lax',
|
|
||||||
path: '/api/auth/pair',
|
|
||||||
maxAge: 300,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('slaat user-agent en IP op (afgekapt)', async () => {
|
|
||||||
const longUa = 'A'.repeat(500)
|
|
||||||
await POST(makePost({ ip: '198.51.100.10', ua: longUa }))
|
|
||||||
const arg = mockPrisma.loginPairing.create.mock.calls[0][0].data
|
|
||||||
expect(arg.desktop_ua).toBe('A'.repeat(255))
|
|
||||||
expect(arg.desktop_ip).toBe('198.51.100.10')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('desktop_ip = null als x-forwarded-for ontbreekt', async () => {
|
|
||||||
await POST(makePost({}))
|
|
||||||
const arg = mockPrisma.loginPairing.create.mock.calls[0][0].data
|
|
||||||
expect(arg.desktop_ip).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('11e POST binnen window levert 429', async () => {
|
|
||||||
const ip = '198.51.100.99' // unieke IP zodat andere tests niet in de weg zitten
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
const ok = await POST(makePost({ ip }))
|
|
||||||
expect(ok.status).toBe(200)
|
|
||||||
}
|
|
||||||
const blocked = await POST(makePost({ ip }))
|
|
||||||
expect(blocked.status).toBe(429)
|
|
||||||
const body = await blocked.json()
|
|
||||||
expect(body.error).toMatch(/Te veel pogingen/i)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
// Mock the cookie helper directly — easier than mocking next/headers + reasoning
|
|
||||||
// over the cookies() async wrapper. The actual cookie wiring is tested in
|
|
||||||
// pair-start.test.ts.
|
|
||||||
const { mockReadPairCookie } = vi.hoisted(() => ({
|
|
||||||
mockReadPairCookie: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/lib/auth/pair-cookie', () => ({
|
|
||||||
readPairCookie: mockReadPairCookie,
|
|
||||||
setPairCookie: vi.fn(),
|
|
||||||
clearPairCookie: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: {
|
|
||||||
loginPairing: {
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
import { hashToken } from '@/lib/auth/pairing'
|
|
||||||
import type { NextRequest } from 'next/server'
|
|
||||||
import { GET } from '@/app/api/auth/pair/stream/[pairingId]/route'
|
|
||||||
|
|
||||||
const mockPrisma = prisma as unknown as {
|
|
||||||
loginPairing: { findUnique: ReturnType<typeof vi.fn> }
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeReq(): NextRequest {
|
|
||||||
// Minimaal NextRequest-shape voor de auth-paden — we komen niet aan signal/url toe
|
|
||||||
// omdat de auth-checks vóór de stream-setup falen.
|
|
||||||
return { signal: new AbortController().signal } as unknown as NextRequest
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = (id: string) =>
|
|
||||||
({ params: Promise.resolve({ pairingId: id }) }) as { params: Promise<{ pairingId: string }> }
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('GET /api/auth/pair/stream/[pairingId]', () => {
|
|
||||||
it('401 zonder s4m_pair-cookie', async () => {
|
|
||||||
mockReadPairCookie.mockResolvedValue(null)
|
|
||||||
const res = await GET(makeReq(), params('pair-x'))
|
|
||||||
expect(res.status).toBe(401)
|
|
||||||
const body = await res.json()
|
|
||||||
expect(body.error).toMatch(/cookie/i)
|
|
||||||
expect(mockPrisma.loginPairing.findUnique).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('404 als pairing onbekend is', async () => {
|
|
||||||
mockReadPairCookie.mockResolvedValue('whatever-token')
|
|
||||||
mockPrisma.loginPairing.findUnique.mockResolvedValue(null)
|
|
||||||
const res = await GET(makeReq(), params('pair-onbekend'))
|
|
||||||
expect(res.status).toBe(404)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('410 als pairing verlopen is', async () => {
|
|
||||||
mockReadPairCookie.mockResolvedValue('correct-token')
|
|
||||||
mockPrisma.loginPairing.findUnique.mockResolvedValue({
|
|
||||||
desktop_token_hash: hashToken('correct-token'),
|
|
||||||
status: 'pending',
|
|
||||||
expires_at: new Date(Date.now() - 1000),
|
|
||||||
})
|
|
||||||
const res = await GET(makeReq(), params('pair-verlopen'))
|
|
||||||
expect(res.status).toBe(410)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('401 als cookie hashed naar andere desktop_token_hash', async () => {
|
|
||||||
mockReadPairCookie.mockResolvedValue('verkeerd-token')
|
|
||||||
mockPrisma.loginPairing.findUnique.mockResolvedValue({
|
|
||||||
desktop_token_hash: hashToken('echt-token'),
|
|
||||||
status: 'pending',
|
|
||||||
expires_at: new Date(Date.now() + 60_000),
|
|
||||||
})
|
|
||||||
const res = await GET(makeReq(), params('pair-mismatch'))
|
|
||||||
expect(res.status).toBe(401)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('server-only', () => ({}))
|
|
||||||
|
|
||||||
const { mockSendPushToUser } = vi.hoisted(() => ({
|
|
||||||
mockSendPushToUser: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/lib/push-server', () => ({
|
|
||||||
sendPushToUser: mockSendPushToUser,
|
|
||||||
enabled: true,
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.hoisted(() => {
|
|
||||||
process.env.INTERNAL_PUSH_SECRET = 'a-valid-secret-that-is-at-least-32-chars'
|
|
||||||
})
|
|
||||||
|
|
||||||
import { POST } from '@/app/api/internal/push/send/route'
|
|
||||||
|
|
||||||
const VALID_BODY = {
|
|
||||||
userId: 'user-1',
|
|
||||||
payload: { title: 'Hello', body: 'World', url: '/dashboard' },
|
|
||||||
}
|
|
||||||
const SECRET = 'a-valid-secret-that-is-at-least-32-chars'
|
|
||||||
|
|
||||||
function makeRequest(body: unknown, bearer?: string) {
|
|
||||||
return new Request('http://localhost/api/internal/push/send', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(bearer !== undefined ? { Authorization: bearer } : {}),
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockSendPushToUser.mockResolvedValue(undefined)
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('POST /api/internal/push/send', () => {
|
|
||||||
it('returns 401 without authorization header', async () => {
|
|
||||||
const res = await POST(makeRequest(VALID_BODY))
|
|
||||||
expect(res.status).toBe(401)
|
|
||||||
expect(mockSendPushToUser).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns 401 with wrong bearer secret', async () => {
|
|
||||||
const res = await POST(makeRequest(VALID_BODY, 'Bearer wrong-secret'))
|
|
||||||
expect(res.status).toBe(401)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns 422 with invalid body', async () => {
|
|
||||||
const res = await POST(makeRequest({ userId: '', payload: {} }, `Bearer ${SECRET}`))
|
|
||||||
expect(res.status).toBe(422)
|
|
||||||
expect(mockSendPushToUser).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns 204 and calls sendPushToUser on success', async () => {
|
|
||||||
const res = await POST(makeRequest(VALID_BODY, `Bearer ${SECRET}`))
|
|
||||||
expect(res.status).toBe(204)
|
|
||||||
expect(mockSendPushToUser).toHaveBeenCalledWith('user-1', VALID_BODY.payload)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns 400 for invalid JSON', async () => {
|
|
||||||
const req = new Request('http://localhost/api/internal/push/send', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { Authorization: `Bearer ${SECRET}`, 'Content-Type': 'application/json' },
|
|
||||||
body: 'not-json',
|
|
||||||
})
|
|
||||||
const res = await POST(req)
|
|
||||||
expect(res.status).toBe(400)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
111
__tests__/api/reorder.test.ts
Normal file
111
__tests__/api/reorder.test.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('@/lib/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
story: {
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
},
|
||||||
|
task: {
|
||||||
|
update: vi.fn(),
|
||||||
|
},
|
||||||
|
$transaction: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/api-auth', () => ({
|
||||||
|
authenticateApiRequest: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { authenticateApiRequest } from '@/lib/api-auth'
|
||||||
|
import { PATCH as patchReorder } from '@/app/api/stories/[id]/tasks/reorder/route'
|
||||||
|
|
||||||
|
const mockPrisma = prisma as unknown as {
|
||||||
|
story: { findFirst: ReturnType<typeof vi.fn> }
|
||||||
|
task: { update: ReturnType<typeof vi.fn> }
|
||||||
|
$transaction: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
function makeStory(taskIds: string[]) {
|
||||||
|
return {
|
||||||
|
id: 'story-1',
|
||||||
|
product_id: 'prod-1',
|
||||||
|
tasks: taskIds.map(id => ({ id })),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRequest(body: unknown, storyId = 'story-1'): [Request, { params: Promise<{ id: string }> }] {
|
||||||
|
return [
|
||||||
|
new Request(`http://localhost/api/stories/${storyId}/tasks/reorder`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Authorization: 'Bearer test-token', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
{ params: Promise.resolve({ id: storyId }) },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PATCH /api/stories/:id/tasks/reorder', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockAuth.mockResolvedValue({ userId: 'user-1', isDemo: false })
|
||||||
|
mockPrisma.$transaction.mockResolvedValue([])
|
||||||
|
mockPrisma.task.update.mockResolvedValue({ id: 'task-1', sort_order: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
// TC-RO-06 — body validation fires before story lookup
|
||||||
|
it('returns 422 when task_ids is an empty array', async () => {
|
||||||
|
const res = await patchReorder(...makeRequest({ task_ids: [] }))
|
||||||
|
expect(res.status).toBe(422)
|
||||||
|
expect(mockPrisma.story.findFirst).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
// TC-RO-07
|
||||||
|
it('returns 422 when task_ids is not an array', async () => {
|
||||||
|
const res = await patchReorder(...makeRequest({ task_ids: 'task-1' }))
|
||||||
|
expect(res.status).toBe(422)
|
||||||
|
expect(mockPrisma.story.findFirst).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 422 when task_ids is missing entirely', async () => {
|
||||||
|
const res = await patchReorder(...makeRequest({}))
|
||||||
|
expect(res.status).toBe(422)
|
||||||
|
})
|
||||||
|
|
||||||
|
// TC-RO-08
|
||||||
|
it('returns 422 when task_ids contains an ID not belonging to the story', async () => {
|
||||||
|
mockPrisma.story.findFirst.mockResolvedValue(makeStory(['task-1', 'task-2']))
|
||||||
|
|
||||||
|
const res = await patchReorder(...makeRequest({ task_ids: ['task-1', 'task-from-other-story'] }))
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
expect(res.status).toBe(422)
|
||||||
|
expect(data.error).toContain('task-from-other-story')
|
||||||
|
})
|
||||||
|
|
||||||
|
// TC-RO-09
|
||||||
|
it('reorders tasks and returns 200 with success: true', async () => {
|
||||||
|
mockPrisma.story.findFirst.mockResolvedValue(makeStory(['task-1', 'task-2', 'task-3']))
|
||||||
|
|
||||||
|
const res = await patchReorder(...makeRequest({ task_ids: ['task-3', 'task-1', 'task-2'] }))
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(data).toEqual({ success: true })
|
||||||
|
expect(mockPrisma.$transaction).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates each task with its new sort_order index', async () => {
|
||||||
|
mockPrisma.story.findFirst.mockResolvedValue(makeStory(['task-1', 'task-2']))
|
||||||
|
|
||||||
|
await patchReorder(...makeRequest({ task_ids: ['task-2', 'task-1'] }))
|
||||||
|
|
||||||
|
expect(mockPrisma.task.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ where: { id: 'task-2' }, data: { sort_order: 1 } })
|
||||||
|
)
|
||||||
|
expect(mockPrisma.task.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ where: { id: 'task-1' }, data: { sort_order: 2 } })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -8,32 +8,13 @@ vi.mock('@/lib/prisma', () => ({
|
||||||
},
|
},
|
||||||
sprint: {
|
sprint: {
|
||||||
findFirst: vi.fn(),
|
findFirst: vi.fn(),
|
||||||
findUniqueOrThrow: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
},
|
||||||
story: {
|
story: {
|
||||||
findFirst: vi.fn(),
|
findFirst: vi.fn(),
|
||||||
findUniqueOrThrow: vi.fn(),
|
|
||||||
findMany: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
},
|
||||||
task: {
|
task: {
|
||||||
findFirst: vi.fn(),
|
findFirst: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
findMany: vi.fn(),
|
|
||||||
},
|
|
||||||
pbi: {
|
|
||||||
findUniqueOrThrow: vi.fn(),
|
|
||||||
findMany: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
claudeJob: {
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
updateMany: vi.fn(),
|
|
||||||
},
|
|
||||||
sprintRun: {
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
},
|
||||||
storyLog: {
|
storyLog: {
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
|
|
@ -54,40 +35,16 @@ import { authenticateApiRequest } from '@/lib/api-auth'
|
||||||
import { GET as getProducts } from '@/app/api/products/route'
|
import { GET as getProducts } from '@/app/api/products/route'
|
||||||
import { GET as getNextStory } from '@/app/api/products/[id]/next-story/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 { 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 { POST as postStoryLog } from '@/app/api/stories/[id]/log/route'
|
||||||
import { PATCH as patchTask } from '@/app/api/tasks/[id]/route'
|
import { PATCH as patchTask } from '@/app/api/tasks/[id]/route'
|
||||||
|
import { POST as postTodo } from '@/app/api/todos/route'
|
||||||
|
|
||||||
const mockPrisma = prisma as unknown as {
|
const mockPrisma = prisma as unknown as {
|
||||||
product: { findMany: ReturnType<typeof vi.fn>; findFirst: ReturnType<typeof vi.fn> }
|
product: { findMany: ReturnType<typeof vi.fn>; findFirst: ReturnType<typeof vi.fn> }
|
||||||
sprint: {
|
sprint: { findFirst: ReturnType<typeof vi.fn> }
|
||||||
findFirst: ReturnType<typeof vi.fn>
|
story: { findFirst: ReturnType<typeof vi.fn> }
|
||||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
task: { findFirst: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
|
||||||
update: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
story: {
|
|
||||||
findFirst: ReturnType<typeof vi.fn>
|
|
||||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
|
||||||
findMany: ReturnType<typeof vi.fn>
|
|
||||||
update: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
task: {
|
|
||||||
findFirst: ReturnType<typeof vi.fn>
|
|
||||||
update: ReturnType<typeof vi.fn>
|
|
||||||
findMany: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
pbi: {
|
|
||||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
|
||||||
findMany: ReturnType<typeof vi.fn>
|
|
||||||
update: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
claudeJob: {
|
|
||||||
findFirst: ReturnType<typeof vi.fn>
|
|
||||||
updateMany: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
sprintRun: {
|
|
||||||
findUnique: ReturnType<typeof vi.fn>
|
|
||||||
update: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
storyLog: { create: ReturnType<typeof vi.fn> }
|
storyLog: { create: ReturnType<typeof vi.fn> }
|
||||||
todo: { create: ReturnType<typeof vi.fn> }
|
todo: { create: ReturnType<typeof vi.fn> }
|
||||||
$transaction: ReturnType<typeof vi.fn>
|
$transaction: ReturnType<typeof vi.fn>
|
||||||
|
|
@ -128,11 +85,6 @@ function routeCtx(id: string) {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
// Pass-through transaction so callers can `prisma.$transaction(async tx => ...)` in routes.
|
|
||||||
mockPrisma.$transaction.mockImplementation(async (run: unknown) => {
|
|
||||||
if (typeof run === 'function') return (run as (tx: typeof prisma) => Promise<unknown>)(prisma)
|
|
||||||
return run
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// ─── GET /api/products ────────────────────────────────────────────────────────
|
// ─── GET /api/products ────────────────────────────────────────────────────────
|
||||||
|
|
@ -196,7 +148,7 @@ describe('GET /api/products/:id/next-story', () => {
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
where: expect.objectContaining({
|
where: expect.objectContaining({
|
||||||
product_id: 'prod-other',
|
product_id: 'prod-other',
|
||||||
status: 'OPEN',
|
status: 'ACTIVE',
|
||||||
product: expect.objectContaining({
|
product: expect.objectContaining({
|
||||||
OR: expect.arrayContaining([{ user_id: 'user-1' }]),
|
OR: expect.arrayContaining([{ user_id: 'user-1' }]),
|
||||||
}),
|
}),
|
||||||
|
|
@ -275,6 +227,56 @@ describe('GET /api/sprints/:id/tasks', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ─── PATCH /api/stories/:id/tasks/reorder ────────────────────────────────────
|
||||||
|
|
||||||
|
describe('PATCH /api/stories/:id/tasks/reorder', () => {
|
||||||
|
const VALID_BODY = { task_ids: ['task-x'] }
|
||||||
|
|
||||||
|
// TC-RO-01
|
||||||
|
it('returns 401 when no valid token provided', async () => {
|
||||||
|
mockAuth.mockResolvedValue(UNAUTHORIZED)
|
||||||
|
const res = await patchReorder(
|
||||||
|
makePatch('http://localhost/api/stories/story-1/tasks/reorder', VALID_BODY),
|
||||||
|
routeCtx('story-1')
|
||||||
|
)
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
// TC-RO-03
|
||||||
|
it('returns 403 for demo users', async () => {
|
||||||
|
mockAuth.mockResolvedValue(DEMO_AUTH)
|
||||||
|
const res = await patchReorder(
|
||||||
|
makePatch('http://localhost/api/stories/story-1/tasks/reorder', VALID_BODY),
|
||||||
|
routeCtx('story-1')
|
||||||
|
)
|
||||||
|
expect(res.status).toBe(403)
|
||||||
|
const data = await res.json()
|
||||||
|
expect(data.error).toBe('Niet beschikbaar in demo-modus')
|
||||||
|
})
|
||||||
|
|
||||||
|
// TC-RO-04 / TC-RO-05
|
||||||
|
it('returns 404 when story is not accessible to the authenticated user', async () => {
|
||||||
|
mockAuth.mockResolvedValue(USER_2_AUTH)
|
||||||
|
mockPrisma.story.findFirst.mockResolvedValue(null)
|
||||||
|
|
||||||
|
const res = await patchReorder(
|
||||||
|
makePatch('http://localhost/api/stories/story-1/tasks/reorder', VALID_BODY),
|
||||||
|
routeCtx('story-1')
|
||||||
|
)
|
||||||
|
expect(res.status).toBe(404)
|
||||||
|
expect(mockPrisma.story.findFirst).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
id: 'story-1',
|
||||||
|
product: expect.objectContaining({
|
||||||
|
OR: expect.arrayContaining([{ user_id: 'user-2' }]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// ─── POST /api/stories/:id/log ────────────────────────────────────────────────
|
// ─── POST /api/stories/:id/log ────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('POST /api/stories/:id/log', () => {
|
describe('POST /api/stories/:id/log', () => {
|
||||||
|
|
@ -384,22 +386,7 @@ describe('PATCH /api/tasks/:id', () => {
|
||||||
id: 'task-1',
|
id: 'task-1',
|
||||||
story: { product: { user_id: 'user-1' } },
|
story: { product: { user_id: 'user-1' } },
|
||||||
})
|
})
|
||||||
mockPrisma.task.update.mockResolvedValue({
|
mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'DONE' })
|
||||||
id: 'task-1',
|
|
||||||
title: 'Task',
|
|
||||||
status: 'DONE',
|
|
||||||
story_id: 'story-1',
|
|
||||||
implementation_plan: null,
|
|
||||||
})
|
|
||||||
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }])
|
|
||||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
|
||||||
id: 'story-1',
|
|
||||||
status: 'DONE',
|
|
||||||
pbi_id: 'pbi-1',
|
|
||||||
sprint_id: null,
|
|
||||||
})
|
|
||||||
mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }])
|
|
||||||
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'DONE' })
|
|
||||||
|
|
||||||
const res = await patchTask(
|
const res = await patchTask(
|
||||||
makePatch('http://localhost/api/tasks/task-1', { status: 'done' }),
|
makePatch('http://localhost/api/tasks/task-1', { status: 'done' }),
|
||||||
|
|
@ -408,3 +395,46 @@ describe('PATCH /api/tasks/:id', () => {
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ─── POST /api/todos ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('POST /api/todos', () => {
|
||||||
|
// product_id is required by the Zod schema (z.string().min(1))
|
||||||
|
const VALID_BODY = { title: 'Test todo', product_id: 'prod-1' }
|
||||||
|
|
||||||
|
// TC-TD-01
|
||||||
|
it('returns 401 when no valid token provided', async () => {
|
||||||
|
mockAuth.mockResolvedValue(UNAUTHORIZED)
|
||||||
|
const res = await postTodo(makePost('http://localhost/api/todos', VALID_BODY))
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
// TC-TD-03
|
||||||
|
it('returns 403 for demo users', async () => {
|
||||||
|
mockAuth.mockResolvedValue(DEMO_AUTH)
|
||||||
|
const res = await postTodo(makePost('http://localhost/api/todos', VALID_BODY))
|
||||||
|
expect(res.status).toBe(403)
|
||||||
|
const data = await res.json()
|
||||||
|
expect(data.error).toBe('Niet beschikbaar in demo-modus')
|
||||||
|
})
|
||||||
|
|
||||||
|
// TC-TD-08
|
||||||
|
it('returns 404 when product_id belongs to another user', async () => {
|
||||||
|
mockAuth.mockResolvedValue(USER_2_AUTH)
|
||||||
|
mockPrisma.product.findFirst.mockResolvedValue(null)
|
||||||
|
|
||||||
|
const res = await postTodo(
|
||||||
|
makePost('http://localhost/api/todos', { title: 'Todo', product_id: 'prod-owned-by-user-1' })
|
||||||
|
)
|
||||||
|
expect(res.status).toBe(404)
|
||||||
|
// Verify it queries by user_id, not productAccessFilter
|
||||||
|
expect(mockPrisma.product.findFirst).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
id: 'prod-owned-by-user-1',
|
||||||
|
user_id: 'user-2',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: {
|
|
||||||
product: { findFirst: vi.fn() },
|
|
||||||
story: { groupBy: vi.fn() },
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/lib/api-auth', () => ({
|
|
||||||
authenticateApiRequest: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/lib/product-access', () => ({
|
|
||||||
productAccessFilter: vi.fn().mockReturnValue({}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
import { authenticateApiRequest } from '@/lib/api-auth'
|
|
||||||
import { GET } from '@/app/api/products/[id]/sprint-membership-summary/route'
|
|
||||||
|
|
||||||
const mockPrisma = prisma as unknown as {
|
|
||||||
product: { findFirst: ReturnType<typeof vi.fn> }
|
|
||||||
story: { groupBy: ReturnType<typeof vi.fn> }
|
|
||||||
}
|
|
||||||
const mockAuth = authenticateApiRequest as unknown as ReturnType<typeof vi.fn>
|
|
||||||
|
|
||||||
function makeRequest(url: string) {
|
|
||||||
return new Request(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('GET /api/products/[id]/sprint-membership-summary', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockPrisma.product.findFirst.mockReset()
|
|
||||||
mockPrisma.story.groupBy.mockReset()
|
|
||||||
mockAuth.mockReset().mockResolvedValue({ userId: 'user-1' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns counts per PBI for happy path', async () => {
|
|
||||||
mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' })
|
|
||||||
mockPrisma.story.groupBy
|
|
||||||
.mockResolvedValueOnce([
|
|
||||||
{ pbi_id: 'pbiA', _count: { _all: 5 } },
|
|
||||||
{ pbi_id: 'pbiB', _count: { _all: 3 } },
|
|
||||||
])
|
|
||||||
.mockResolvedValueOnce([{ pbi_id: 'pbiA', _count: { _all: 2 } }])
|
|
||||||
|
|
||||||
const req = makeRequest(
|
|
||||||
'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA,pbiB',
|
|
||||||
)
|
|
||||||
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = await res.json()
|
|
||||||
expect(body).toEqual({
|
|
||||||
pbiA: { total: 5, inSprint: 2 },
|
|
||||||
pbiB: { total: 3, inSprint: 0 },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects when pbiIds is missing', async () => {
|
|
||||||
const req = makeRequest(
|
|
||||||
'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1',
|
|
||||||
)
|
|
||||||
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
|
||||||
expect(res.status).toBe(400)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects when pbiIds is empty', async () => {
|
|
||||||
const req = makeRequest(
|
|
||||||
'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=',
|
|
||||||
)
|
|
||||||
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
|
||||||
expect(res.status).toBe(400)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects when sprintId is missing', async () => {
|
|
||||||
const req = makeRequest(
|
|
||||||
'http://localhost/api/products/p1/sprint-membership-summary?pbiIds=pbiA',
|
|
||||||
)
|
|
||||||
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
|
||||||
expect(res.status).toBe(400)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns 404 when product is not accessible', async () => {
|
|
||||||
mockPrisma.product.findFirst.mockResolvedValue(null)
|
|
||||||
const req = makeRequest(
|
|
||||||
'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA',
|
|
||||||
)
|
|
||||||
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
|
||||||
expect(res.status).toBe(404)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns auth error when authenticate fails', async () => {
|
|
||||||
mockAuth.mockResolvedValue({ error: 'Niet ingelogd', status: 401 })
|
|
||||||
const req = makeRequest(
|
|
||||||
'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA',
|
|
||||||
)
|
|
||||||
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
|
||||||
expect(res.status).toBe(401)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns zero counts for PBIs without stories', async () => {
|
|
||||||
mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' })
|
|
||||||
mockPrisma.story.groupBy
|
|
||||||
.mockResolvedValueOnce([])
|
|
||||||
.mockResolvedValueOnce([])
|
|
||||||
|
|
||||||
const req = makeRequest(
|
|
||||||
'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA,pbiB',
|
|
||||||
)
|
|
||||||
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
|
||||||
|
|
||||||
const body = await res.json()
|
|
||||||
expect(body).toEqual({
|
|
||||||
pbiA: { total: 0, inSprint: 0 },
|
|
||||||
pbiB: { total: 0, inSprint: 0 },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -25,7 +25,7 @@ const mockPrisma = prisma as unknown as {
|
||||||
}
|
}
|
||||||
const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn>
|
const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
const SPRINT = { id: 'sprint-1', product_id: 'prod-1', status: 'OPEN' }
|
const SPRINT = { id: 'sprint-1', product_id: 'prod-1', status: 'ACTIVE' }
|
||||||
|
|
||||||
function makeTask(n: number) {
|
function makeTask(n: number) {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ describe('POST /api/stories/:id/log', () => {
|
||||||
const res = await postStoryLog(
|
const res = await postStoryLog(
|
||||||
...makeRequest({ type: 'TEST_RESULT', content: 'Test gefaald.', status: 'FAILED' })
|
...makeRequest({ type: 'TEST_RESULT', content: 'Test gefaald.', status: 'FAILED' })
|
||||||
)
|
)
|
||||||
await res.json()
|
const data = await res.json()
|
||||||
|
|
||||||
expect(res.status).toBe(201)
|
expect(res.status).toBe(201)
|
||||||
expect(mockPrisma.storyLog.create).toHaveBeenCalledWith(
|
expect(mockPrisma.storyLog.create).toHaveBeenCalledWith(
|
||||||
|
|
|
||||||
|
|
@ -5,31 +5,7 @@ vi.mock('@/lib/prisma', () => ({
|
||||||
task: {
|
task: {
|
||||||
findFirst: vi.fn(),
|
findFirst: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
findMany: vi.fn(),
|
|
||||||
},
|
},
|
||||||
story: {
|
|
||||||
findUniqueOrThrow: vi.fn(),
|
|
||||||
findMany: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
pbi: {
|
|
||||||
findUniqueOrThrow: vi.fn(),
|
|
||||||
findMany: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
sprint: {
|
|
||||||
findUniqueOrThrow: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
claudeJob: {
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
updateMany: vi.fn(),
|
|
||||||
},
|
|
||||||
sprintRun: {
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
$transaction: vi.fn(),
|
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
@ -42,34 +18,7 @@ import { authenticateApiRequest } from '@/lib/api-auth'
|
||||||
import { PATCH as patchTask } from '@/app/api/tasks/[id]/route'
|
import { PATCH as patchTask } from '@/app/api/tasks/[id]/route'
|
||||||
|
|
||||||
const mockPrisma = prisma as unknown as {
|
const mockPrisma = prisma as unknown as {
|
||||||
task: {
|
task: { findFirst: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
|
||||||
findFirst: ReturnType<typeof vi.fn>
|
|
||||||
update: ReturnType<typeof vi.fn>
|
|
||||||
findMany: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
story: {
|
|
||||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
|
||||||
findMany: ReturnType<typeof vi.fn>
|
|
||||||
update: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
pbi: {
|
|
||||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
|
||||||
findMany: ReturnType<typeof vi.fn>
|
|
||||||
update: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
sprint: {
|
|
||||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
|
||||||
update: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
claudeJob: {
|
|
||||||
findFirst: ReturnType<typeof vi.fn>
|
|
||||||
updateMany: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
sprintRun: {
|
|
||||||
findUnique: ReturnType<typeof vi.fn>
|
|
||||||
update: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
$transaction: ReturnType<typeof vi.fn>
|
|
||||||
}
|
}
|
||||||
const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn>
|
const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
|
@ -106,22 +55,6 @@ describe('PATCH /api/tasks/:id', () => {
|
||||||
id: 'task-1',
|
id: 'task-1',
|
||||||
status: 'DONE',
|
status: 'DONE',
|
||||||
implementation_plan: null,
|
implementation_plan: null,
|
||||||
title: 'Task',
|
|
||||||
story_id: 'story-1',
|
|
||||||
})
|
|
||||||
// Default sibling state: only this task, already DONE → no story-promotion
|
|
||||||
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }])
|
|
||||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
|
||||||
id: 'story-1',
|
|
||||||
status: 'DONE',
|
|
||||||
pbi_id: 'pbi-1',
|
|
||||||
sprint_id: null,
|
|
||||||
})
|
|
||||||
mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }])
|
|
||||||
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'DONE' })
|
|
||||||
// Pass-through for $transaction so tests behave as if Prisma ran the run-fn directly.
|
|
||||||
mockPrisma.$transaction.mockImplementation(async (run: (tx: typeof prisma) => Promise<unknown>) => {
|
|
||||||
return run(prisma)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -178,28 +111,17 @@ describe('PATCH /api/tasks/:id', () => {
|
||||||
// TC-T-10
|
// TC-T-10
|
||||||
it('updates both status and implementation_plan and returns 200', async () => {
|
it('updates both status and implementation_plan and returns 200', async () => {
|
||||||
const plan = 'Full plan here.'
|
const plan = 'Full plan here.'
|
||||||
// First update writes the implementation_plan; second is the helper's status write.
|
mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'DONE', implementation_plan: plan })
|
||||||
mockPrisma.task.update
|
|
||||||
.mockResolvedValueOnce({ id: 'task-1', status: 'TO_DO', implementation_plan: plan })
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
id: 'task-1',
|
|
||||||
title: 'Task',
|
|
||||||
status: 'DONE',
|
|
||||||
story_id: 'story-1',
|
|
||||||
implementation_plan: plan,
|
|
||||||
})
|
|
||||||
|
|
||||||
const res = await patchTask(...makeRequest({ status: 'done', implementation_plan: plan }))
|
const res = await patchTask(...makeRequest({ status: 'done', implementation_plan: plan }))
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(data).toMatchObject({ status: 'done', implementation_plan: plan })
|
expect(data).toMatchObject({ status: 'done', implementation_plan: plan })
|
||||||
// implementation_plan written via direct update; status written via helper update.
|
|
||||||
expect(mockPrisma.task.update).toHaveBeenCalledWith(
|
expect(mockPrisma.task.update).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ data: { implementation_plan: plan } }),
|
expect.objectContaining({
|
||||||
)
|
data: { status: 'DONE', implementation_plan: plan },
|
||||||
expect(mockPrisma.task.update).toHaveBeenCalledWith(
|
})
|
||||||
expect.objectContaining({ data: { status: 'DONE' } }),
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -224,61 +146,6 @@ describe('PATCH /api/tasks/:id', () => {
|
||||||
expect(reviewRes.status).toBe(422)
|
expect(reviewRes.status).toBe(422)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('promotes story to DONE when last sibling task transitions to DONE', async () => {
|
|
||||||
mockPrisma.task.update.mockResolvedValue({
|
|
||||||
id: 'task-1',
|
|
||||||
status: 'DONE',
|
|
||||||
implementation_plan: null,
|
|
||||||
title: 'Task',
|
|
||||||
story_id: 'story-1',
|
|
||||||
})
|
|
||||||
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }, { status: 'DONE' }])
|
|
||||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
|
||||||
id: 'story-1',
|
|
||||||
status: 'IN_SPRINT',
|
|
||||||
pbi_id: 'pbi-1',
|
|
||||||
sprint_id: null,
|
|
||||||
})
|
|
||||||
mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }])
|
|
||||||
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
|
|
||||||
|
|
||||||
const res = await patchTask(...makeRequest({ status: 'done' }))
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
expect(mockPrisma.story.update).toHaveBeenCalledWith({
|
|
||||||
where: { id: 'story-1' },
|
|
||||||
data: { status: 'DONE' },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// TC-T-12
|
|
||||||
it('updates verify_only alone and returns 200 with verify_only in response', async () => {
|
|
||||||
mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'TO_DO', implementation_plan: null, verify_only: true })
|
|
||||||
|
|
||||||
const res = await patchTask(...makeRequest({ verify_only: true }))
|
|
||||||
const data = await res.json()
|
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
expect(data.verify_only).toBe(true)
|
|
||||||
expect(mockPrisma.task.update).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ data: { verify_only: true } }),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('combines verify_only and implementation_plan into one update call', async () => {
|
|
||||||
const plan = 'Verify only: check test results.'
|
|
||||||
mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'TO_DO', implementation_plan: plan, verify_only: true })
|
|
||||||
|
|
||||||
const res = await patchTask(...makeRequest({ implementation_plan: plan, verify_only: true }))
|
|
||||||
const data = await res.json()
|
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
expect(data).toMatchObject({ implementation_plan: plan, verify_only: true })
|
|
||||||
expect(mockPrisma.task.update).toHaveBeenCalledTimes(1)
|
|
||||||
expect(mockPrisma.task.update).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ data: { implementation_plan: plan, verify_only: true } }),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns 400 for malformed JSON', async () => {
|
it('returns 400 for malformed JSON', async () => {
|
||||||
const req = new Request('http://localhost/api/tasks/task-1', {
|
const req = new Request('http://localhost/api/tasks/task-1', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
|
|
||||||
109
__tests__/api/todos.test.ts
Normal file
109
__tests__/api/todos.test.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('@/lib/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
product: {
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
},
|
||||||
|
todo: {
|
||||||
|
create: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/api-auth', () => ({
|
||||||
|
authenticateApiRequest: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { authenticateApiRequest } from '@/lib/api-auth'
|
||||||
|
import { POST as postTodo } from '@/app/api/todos/route'
|
||||||
|
|
||||||
|
const mockPrisma = prisma as unknown as {
|
||||||
|
product: { findFirst: ReturnType<typeof vi.fn> }
|
||||||
|
todo: { create: ReturnType<typeof vi.fn> }
|
||||||
|
}
|
||||||
|
const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
const PRODUCT = { id: 'prod-1', name: 'DevPlanner', archived: false, user_id: 'user-1' }
|
||||||
|
const TODO_RESULT = { id: 'todo-1', title: 'Test todo', created_at: new Date('2026-04-30T10:00:00Z') }
|
||||||
|
|
||||||
|
function makeRequest(body: unknown): Request {
|
||||||
|
return new Request('http://localhost/api/todos', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: 'Bearer test-token', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /api/todos', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockAuth.mockResolvedValue({ userId: 'user-1', isDemo: false })
|
||||||
|
mockPrisma.product.findFirst.mockResolvedValue(PRODUCT)
|
||||||
|
mockPrisma.todo.create.mockResolvedValue(TODO_RESULT)
|
||||||
|
})
|
||||||
|
|
||||||
|
// TC-TD-04
|
||||||
|
it('returns 422 when title is missing', async () => {
|
||||||
|
const res = await postTodo(makeRequest({ product_id: 'prod-1' }))
|
||||||
|
expect(res.status).toBe(422)
|
||||||
|
})
|
||||||
|
|
||||||
|
// TC-TD-05
|
||||||
|
it('returns 422 when title is empty string', async () => {
|
||||||
|
const res = await postTodo(makeRequest({ title: '', product_id: 'prod-1' }))
|
||||||
|
expect(res.status).toBe(422)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 422 when product_id is missing', async () => {
|
||||||
|
// product_id is required by the Zod schema (z.string().min(1))
|
||||||
|
const res = await postTodo(makeRequest({ title: 'My todo' }))
|
||||||
|
expect(res.status).toBe(422)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 422 when product_id is empty string', async () => {
|
||||||
|
const res = await postTodo(makeRequest({ title: 'My todo', product_id: '' }))
|
||||||
|
expect(res.status).toBe(422)
|
||||||
|
})
|
||||||
|
|
||||||
|
// TC-TD-07
|
||||||
|
it('creates todo with valid product_id and returns 201', async () => {
|
||||||
|
const res = await postTodo(makeRequest({ title: 'Test todo', product_id: 'prod-1' }))
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
expect(res.status).toBe(201)
|
||||||
|
expect(data).toMatchObject({ id: 'todo-1', title: 'Test todo' })
|
||||||
|
expect(data).toHaveProperty('created_at')
|
||||||
|
expect(mockPrisma.todo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
user_id: 'user-1',
|
||||||
|
product_id: 'prod-1',
|
||||||
|
title: 'Test todo',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('queries product by user_id (not productAccessFilter) to enforce ownership', async () => {
|
||||||
|
await postTodo(makeRequest({ title: 'Test todo', product_id: 'prod-1' }))
|
||||||
|
|
||||||
|
expect(mockPrisma.product.findFirst).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
id: 'prod-1',
|
||||||
|
user_id: 'user-1',
|
||||||
|
archived: false,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 404 when product does not exist or is archived', async () => {
|
||||||
|
mockPrisma.product.findFirst.mockResolvedValue(null)
|
||||||
|
|
||||||
|
const res = await postTodo(makeRequest({ title: 'My todo', product_id: 'nonexistent' }))
|
||||||
|
expect(res.status).toBe(404)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
const { mockGetSession, mockFindFirstJob, mockFindManyPrice } = vi.hoisted(() => ({
|
|
||||||
mockGetSession: vi.fn(),
|
|
||||||
mockFindFirstJob: vi.fn(),
|
|
||||||
mockFindManyPrice: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/lib/auth', () => ({ getSession: mockGetSession }))
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: {
|
|
||||||
claudeJob: { findFirst: mockFindFirstJob },
|
|
||||||
modelPrice: { findMany: mockFindManyPrice },
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { GET } from '@/app/api/jobs/[id]/route'
|
|
||||||
|
|
||||||
function makeParams(id = 'job-1'): { params: Promise<{ id: string }> } {
|
|
||||||
return { params: Promise.resolve({ id }) }
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeRequest(id = 'job-1'): Request {
|
|
||||||
return new Request(`http://localhost/api/jobs/${id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const RAW_JOB = {
|
|
||||||
id: 'job-1',
|
|
||||||
kind: 'TASK_IMPLEMENTATION' as const,
|
|
||||||
status: 'DONE' as const,
|
|
||||||
model_id: 'claude-sonnet-4-6',
|
|
||||||
input_tokens: 100,
|
|
||||||
output_tokens: 50,
|
|
||||||
cache_read_tokens: 0,
|
|
||||||
cache_write_tokens: 0,
|
|
||||||
branch: 'feat/test',
|
|
||||||
pr_url: null,
|
|
||||||
error: null,
|
|
||||||
summary: 'Done',
|
|
||||||
verify_result: 'ALIGNED' as const,
|
|
||||||
started_at: new Date('2026-01-01T10:00:00Z'),
|
|
||||||
finished_at: new Date('2026-01-01T10:05:00Z'),
|
|
||||||
created_at: new Date('2026-01-01T09:59:00Z'),
|
|
||||||
sprint_run_id: null,
|
|
||||||
task: {
|
|
||||||
code: 'T-42',
|
|
||||||
title: 'Some task',
|
|
||||||
description: null,
|
|
||||||
implementation_plan: 'Do the thing',
|
|
||||||
story: { code: 'S-10', pbi: { code: 'PBI-5' } },
|
|
||||||
},
|
|
||||||
idea: null,
|
|
||||||
product: { name: 'Scrum4Me', code: 'SCR' },
|
|
||||||
sprint_run: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('GET /api/jobs/:id', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockGetSession.mockResolvedValue({ userId: 'user-1' })
|
|
||||||
mockFindFirstJob.mockResolvedValue(RAW_JOB)
|
|
||||||
mockFindManyPrice.mockResolvedValue([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns 401 when not logged in', async () => {
|
|
||||||
mockGetSession.mockResolvedValue({ userId: undefined })
|
|
||||||
const res = await GET(makeRequest() as never, makeParams())
|
|
||||||
expect(res.status).toBe(401)
|
|
||||||
const body = await res.json()
|
|
||||||
expect(body.error).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns 404 when job not found', async () => {
|
|
||||||
mockFindFirstJob.mockResolvedValue(null)
|
|
||||||
const res = await GET(makeRequest() as never, makeParams())
|
|
||||||
expect(res.status).toBe(404)
|
|
||||||
const body = await res.json()
|
|
||||||
expect(body.error).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('queries with user_id filter to prevent cross-user access', async () => {
|
|
||||||
await GET(makeRequest() as never, makeParams())
|
|
||||||
expect(mockFindFirstJob).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
where: { id: 'job-1', user_id: 'user-1' },
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns 200 with mapped job shape including breadcrumb codes', async () => {
|
|
||||||
const res = await GET(makeRequest() as never, makeParams())
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = await res.json()
|
|
||||||
expect(body).toMatchObject({
|
|
||||||
id: 'job-1',
|
|
||||||
kind: 'TASK_IMPLEMENTATION',
|
|
||||||
status: 'DONE',
|
|
||||||
taskCode: 'T-42',
|
|
||||||
taskTitle: 'Some task',
|
|
||||||
productCode: 'SCR',
|
|
||||||
storyCode: 'S-10',
|
|
||||||
pbiCode: 'PBI-5',
|
|
||||||
branch: 'feat/test',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
// Lichte regressie-tests voor de mobile backlog-page. Server-component render
|
|
||||||
// vereist te veel mocking; we asserten op statische source-eigenschappen die
|
|
||||||
// kritisch zijn voor de mobile-shell (cookie-key gescheiden, /m/-paden).
|
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { readFileSync } from 'node:fs'
|
|
||||||
import { resolve } from 'node:path'
|
|
||||||
|
|
||||||
const PAGE = resolve(process.cwd(), 'app/(mobile)/m/products/[id]/page.tsx')
|
|
||||||
const src = readFileSync(PAGE, 'utf-8')
|
|
||||||
|
|
||||||
describe('mobile backlog page (ST-1137)', () => {
|
|
||||||
it('gebruikt gescheiden cookie-key (backlog-{id}-mobile)', () => {
|
|
||||||
// Beslissing C: tab-mode-gebruikers vervuilen desktop-split niet.
|
|
||||||
expect(src).toMatch(/cookieKey=\{`backlog-\$\{id\}-mobile`\}/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('closePath en TaskDialog redirect blijven onder /m/products/', () => {
|
|
||||||
expect(src).toContain('const closePath = `/m/products/${id}`')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('hergebruikt BacklogHydrationWrapper + BacklogSplitPane (geen content-componenten dupliceren)', () => {
|
|
||||||
expect(src).toContain('BacklogHydrationWrapper')
|
|
||||||
expect(src).toContain('BacklogSplitPane')
|
|
||||||
expect(src).toContain('PbiList')
|
|
||||||
expect(src).toContain('StoryPanel')
|
|
||||||
expect(src).toContain('TaskPanel')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('auth via requireSession() (gedeelde guard)', () => {
|
|
||||||
expect(src).toContain("from '@/lib/auth-guard'")
|
|
||||||
expect(src).toContain('requireSession()')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rendert TaskDialog op ?newTask en EditTaskLoader op ?editTask', () => {
|
|
||||||
expect(src).toContain('{newTask &&')
|
|
||||||
expect(src).toContain('{editTask && !newTask &&')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
// ST-1138: regressie-vangnet voor mobile solo-page (server component).
|
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { readFileSync } from 'node:fs'
|
|
||||||
import { resolve } from 'node:path'
|
|
||||||
|
|
||||||
const PAGE = resolve(process.cwd(), 'app/(mobile)/m/products/[id]/solo/page.tsx')
|
|
||||||
const TASK_DETAIL = resolve(process.cwd(), 'components/solo/task-detail-dialog.tsx')
|
|
||||||
|
|
||||||
describe('mobile solo page (ST-1138)', () => {
|
|
||||||
const src = readFileSync(PAGE, 'utf-8')
|
|
||||||
|
|
||||||
it('hergebruikt SoloBoard zonder content-aanpassingen', () => {
|
|
||||||
expect(src).toContain('SoloBoard')
|
|
||||||
expect(src).toContain("from '@/components/solo/solo-board'")
|
|
||||||
})
|
|
||||||
|
|
||||||
it('auth via gedeelde requireSession()', () => {
|
|
||||||
expect(src).toContain("from '@/lib/auth-guard'")
|
|
||||||
expect(src).toContain('requireSession()')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('geeft NoActiveSprint terug als geen actieve sprint (zelfde gedrag als desktop)', () => {
|
|
||||||
expect(src).toContain('NoActiveSprint')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('TaskDetailDialog erft mobile-fullscreen (ST-1138 T-332 verify-only)', () => {
|
|
||||||
// Beslissing A: TaskDetailDialog gebruikt entityDialogContentClasses; mobile-classes
|
|
||||||
// komen automatisch door uit T-317. Dit test bewijst de wiring blijft staan.
|
|
||||||
const src = readFileSync(TASK_DETAIL, 'utf-8')
|
|
||||||
|
|
||||||
it('rendert DialogContent met entityDialogContentClasses (geen eigen className-override)', () => {
|
|
||||||
expect(src).toContain('className={entityDialogContentClasses}')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
// @vitest-environment jsdom
|
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
||||||
import { render, screen } from '@testing-library/react'
|
|
||||||
|
|
||||||
vi.mock('@/actions/user-settings', () => ({
|
|
||||||
updateUserSettingsAction: vi.fn().mockResolvedValue({ success: true, settings: {} }),
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
|
||||||
import { BacklogSplitPane } from '@/components/backlog/backlog-split-pane'
|
|
||||||
|
|
||||||
function setSelection(pbiId: string | null, storyId: string | null) {
|
|
||||||
useProductWorkspaceStore.setState((s) => {
|
|
||||||
s.context.activePbiId = pbiId
|
|
||||||
s.context.activeStoryId = storyId
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const PANES = [
|
|
||||||
<div key="a">PBI pane</div>,
|
|
||||||
<div key="b">Stories pane</div>,
|
|
||||||
<div key="c">Tasks pane</div>,
|
|
||||||
]
|
|
||||||
|
|
||||||
function renderPane() {
|
|
||||||
return render(
|
|
||||||
<BacklogSplitPane
|
|
||||||
panes={PANES}
|
|
||||||
defaultSplit={[33, 33, 34]}
|
|
||||||
cookieKey="test-backlog"
|
|
||||||
tabLabels={["PBI's", 'Stories', 'Taken']}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
setSelection(null, null)
|
|
||||||
// Force mobile viewport
|
|
||||||
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 })
|
|
||||||
window.dispatchEvent(new Event('resize'))
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('BacklogSplitPane auto-switch', () => {
|
|
||||||
it('starts on tab 0 with no selection', () => {
|
|
||||||
renderPane()
|
|
||||||
expect(screen.getByText('PBI pane')).toBeTruthy()
|
|
||||||
expect(screen.queryByText('Stories pane')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('auto-switches to tab 1 when PBI is selected', () => {
|
|
||||||
const { rerender } = renderPane()
|
|
||||||
setSelection('pbi-1', null)
|
|
||||||
rerender(
|
|
||||||
<BacklogSplitPane
|
|
||||||
panes={PANES}
|
|
||||||
defaultSplit={[33, 33, 34]}
|
|
||||||
cookieKey="test-backlog"
|
|
||||||
tabLabels={["PBI's", 'Stories', 'Taken']}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
expect(screen.getByText('Stories pane')).toBeTruthy()
|
|
||||||
expect(screen.queryByText('PBI pane')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('auto-switches to tab 2 when story is selected', () => {
|
|
||||||
const { rerender } = renderPane()
|
|
||||||
setSelection('pbi-1', 'story-1')
|
|
||||||
rerender(
|
|
||||||
<BacklogSplitPane
|
|
||||||
panes={PANES}
|
|
||||||
defaultSplit={[33, 33, 34]}
|
|
||||||
cookieKey="test-backlog"
|
|
||||||
tabLabels={["PBI's", 'Stories', 'Taken']}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
expect(screen.getByText('Tasks pane')).toBeTruthy()
|
|
||||||
expect(screen.queryByText('PBI pane')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('switches to tab 1 on cascade-reset (story cleared when new PBI selected)', () => {
|
|
||||||
// Start with story selected (tab 2)
|
|
||||||
setSelection('pbi-1', 'story-1')
|
|
||||||
const { rerender } = renderPane()
|
|
||||||
|
|
||||||
// Cascade-reset: new PBI → story clears
|
|
||||||
setSelection('pbi-2', null)
|
|
||||||
rerender(
|
|
||||||
<BacklogSplitPane
|
|
||||||
panes={PANES}
|
|
||||||
defaultSplit={[33, 33, 34]}
|
|
||||||
cookieKey="test-backlog"
|
|
||||||
tabLabels={["PBI's", 'Stories', 'Taken']}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
expect(screen.getByText('Stories pane')).toBeTruthy()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
// @vitest-environment jsdom
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
import { render, screen, fireEvent } from '@testing-library/react'
|
|
||||||
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
|
||||||
import type {
|
|
||||||
BacklogStory,
|
|
||||||
BacklogTask,
|
|
||||||
} from '@/stores/product-workspace/types'
|
|
||||||
|
|
||||||
// Mock next/navigation
|
|
||||||
const mockPush = vi.fn()
|
|
||||||
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mockPush }) }))
|
|
||||||
|
|
||||||
// localStorage mock for StoryPanel sort mode persistence
|
|
||||||
const localStorageMock = (() => {
|
|
||||||
let store: Record<string, string> = {}
|
|
||||||
return {
|
|
||||||
getItem: (k: string) => store[k] ?? null,
|
|
||||||
setItem: (k: string, v: string) => { store[k] = v },
|
|
||||||
removeItem: (k: string) => { delete store[k] },
|
|
||||||
clear: () => { store = {} },
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
|
|
||||||
|
|
||||||
// Mock server actions
|
|
||||||
vi.mock('@/actions/stories', () => ({
|
|
||||||
reorderPbisAction: vi.fn().mockResolvedValue({ success: true }),
|
|
||||||
updatePbiPriorityAction: vi.fn().mockResolvedValue({ success: true }),
|
|
||||||
}))
|
|
||||||
vi.mock('@/actions/pbis', () => ({ deletePbiAction: vi.fn().mockResolvedValue({ success: true }) }))
|
|
||||||
vi.mock('@/actions/user-settings', () => ({
|
|
||||||
updateUserSettingsAction: vi.fn().mockResolvedValue({ success: true, settings: {} }),
|
|
||||||
}))
|
|
||||||
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
|
|
||||||
|
|
||||||
// Mock dnd-kit (still needed for PBI panel which supports drag-and-drop)
|
|
||||||
vi.mock('@dnd-kit/core', () => ({
|
|
||||||
DndContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
||||||
PointerSensor: class {},
|
|
||||||
KeyboardSensor: class {},
|
|
||||||
useSensor: vi.fn(),
|
|
||||||
useSensors: vi.fn(() => []),
|
|
||||||
closestCenter: vi.fn(),
|
|
||||||
DragOverlay: () => null,
|
|
||||||
}))
|
|
||||||
vi.mock('@dnd-kit/sortable', () => ({
|
|
||||||
SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
||||||
useSortable: () => ({
|
|
||||||
attributes: {}, listeners: {}, setNodeRef: vi.fn(),
|
|
||||||
transform: null, transition: undefined, isDragging: false,
|
|
||||||
}),
|
|
||||||
verticalListSortingStrategy: {},
|
|
||||||
rectSortingStrategy: {},
|
|
||||||
sortableKeyboardCoordinates: {},
|
|
||||||
arrayMove: (arr: unknown[]) => arr,
|
|
||||||
}))
|
|
||||||
vi.mock('@dnd-kit/utilities', () => ({ CSS: { Transform: { toString: () => '' } } }))
|
|
||||||
|
|
||||||
import { StoryPanel } from '@/components/backlog/story-panel'
|
|
||||||
import { TaskPanel } from '@/components/backlog/task-panel'
|
|
||||||
|
|
||||||
const PRODUCT_ID = 'prod-1'
|
|
||||||
const PBI_ID = 'pbi-1'
|
|
||||||
const ALT_PBI_ID = 'pbi-2'
|
|
||||||
const STORY_ID = 'story-1'
|
|
||||||
|
|
||||||
const STORIES: BacklogStory[] = [
|
|
||||||
{ id: STORY_ID, code: 'ST-1', title: 'Eerste story', description: null, acceptance_criteria: null, priority: 2, sort_order: 1, status: 'OPEN', pbi_id: PBI_ID, sprint_id: null, created_at: new Date() },
|
|
||||||
]
|
|
||||||
const TASKS: BacklogTask[] = [
|
|
||||||
{ id: 'task-1', code: null, title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_ID, created_at: new Date() },
|
|
||||||
]
|
|
||||||
|
|
||||||
function resetStores() {
|
|
||||||
useProductWorkspaceStore.setState((s) => {
|
|
||||||
s.context.activeProduct = null
|
|
||||||
s.context.activePbiId = null
|
|
||||||
s.context.activeStoryId = null
|
|
||||||
s.context.activeTaskId = null
|
|
||||||
s.entities.pbisById = {}
|
|
||||||
s.entities.storiesById = Object.fromEntries(STORIES.map((st) => [st.id, st]))
|
|
||||||
s.entities.tasksById = Object.fromEntries(TASKS.map((t) => [t.id, t]))
|
|
||||||
s.relations.pbiIds = []
|
|
||||||
s.relations.storyIdsByPbi = { [PBI_ID]: STORIES.map((st) => st.id) }
|
|
||||||
s.relations.taskIdsByStory = { [STORY_ID]: TASKS.map((t) => t.id) }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectPbi(pbiId: string | null) {
|
|
||||||
useProductWorkspaceStore.setState((s) => {
|
|
||||||
s.context.activePbiId = pbiId
|
|
||||||
s.context.activeStoryId = null
|
|
||||||
s.context.activeTaskId = null
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectStory(pbiId: string | null, storyId: string | null) {
|
|
||||||
useProductWorkspaceStore.setState((s) => {
|
|
||||||
s.context.activePbiId = pbiId
|
|
||||||
s.context.activeStoryId = storyId
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Backlog 3-pane integration', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockPush.mockClear()
|
|
||||||
resetStores()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('StoryPanel shows empty state when no PBI selected', () => {
|
|
||||||
render(<StoryPanel productId={PRODUCT_ID} isDemo={false} />)
|
|
||||||
expect(screen.getByText('Selecteer een PBI om de stories te bekijken.')).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('StoryPanel shows stories when PBI is selected', () => {
|
|
||||||
selectPbi(PBI_ID)
|
|
||||||
render(<StoryPanel productId={PRODUCT_ID} isDemo={false} />)
|
|
||||||
expect(screen.getByText('Eerste story')).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('clicking a story dispatches setActiveStory to the workspace-store', () => {
|
|
||||||
selectPbi(PBI_ID)
|
|
||||||
render(<StoryPanel productId={PRODUCT_ID} isDemo={false} />)
|
|
||||||
fireEvent.click(screen.getByText('Eerste story'))
|
|
||||||
expect(useProductWorkspaceStore.getState().context.activeStoryId).toBe(STORY_ID)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('cascade-reset: selecting different PBI clears activeStoryId', () => {
|
|
||||||
selectStory(PBI_ID, STORY_ID)
|
|
||||||
useProductWorkspaceStore.getState().setActivePbi(ALT_PBI_ID)
|
|
||||||
expect(useProductWorkspaceStore.getState().context.activeStoryId).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('TaskPanel shows tasks after story is selected', () => {
|
|
||||||
selectStory(PBI_ID, STORY_ID)
|
|
||||||
render(<TaskPanel productId={PRODUCT_ID} isDemo={false} closePath={`/products/${PRODUCT_ID}`} />)
|
|
||||||
expect(screen.getByText('Eerste taak')).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('TaskPanel shows empty state after cascade-reset', () => {
|
|
||||||
selectStory(PBI_ID, STORY_ID)
|
|
||||||
render(<TaskPanel productId={PRODUCT_ID} isDemo={false} closePath={`/products/${PRODUCT_ID}`} />)
|
|
||||||
useProductWorkspaceStore.getState().setActivePbi(ALT_PBI_ID)
|
|
||||||
render(<TaskPanel productId={PRODUCT_ID} isDemo={false} closePath={`/products/${PRODUCT_ID}`} />)
|
|
||||||
expect(screen.getAllByText('Selecteer een story om de taken te bekijken.').length).toBeGreaterThan(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('selected story card has isSelected highlight class applied', () => {
|
|
||||||
selectStory(PBI_ID, STORY_ID)
|
|
||||||
const { container } = render(<StoryPanel productId={PRODUCT_ID} isDemo={false} />)
|
|
||||||
// bg-primary-container is applied when isSelected
|
|
||||||
const selected = container.querySelector('.bg-primary-container')
|
|
||||||
expect(selected).toBeTruthy()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
// @vitest-environment jsdom
|
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
||||||
import { render, screen } from '@testing-library/react'
|
|
||||||
import '@testing-library/jest-dom'
|
|
||||||
import type { ReactNode } from 'react'
|
|
||||||
|
|
||||||
const workflowMock: {
|
|
||||||
value: { pendingSprintDraft?: Record<string, unknown> } | undefined
|
|
||||||
} = { value: undefined }
|
|
||||||
|
|
||||||
vi.mock('@/stores/user-settings/store', () => ({
|
|
||||||
useUserSettingsStore: (
|
|
||||||
selector: (s: {
|
|
||||||
entities: {
|
|
||||||
settings: {
|
|
||||||
workflow: { pendingSprintDraft?: Record<string, unknown> } | undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}) => unknown,
|
|
||||||
) => selector({ entities: { settings: { workflow: workflowMock.value } } }),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('./new-sprint-metadata-dialog', () => ({
|
|
||||||
NewSprintMetadataDialog: () => null,
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/components/shared/demo-tooltip', () => ({
|
|
||||||
DemoTooltip: ({ children }: { children: ReactNode }) => children,
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { NewSprintTrigger } from '@/components/backlog/new-sprint-trigger'
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
workflowMock.value = undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('NewSprintTrigger', () => {
|
|
||||||
it('renders the button on an active product without a draft', () => {
|
|
||||||
render(<NewSprintTrigger productId="p1" isDemo={false} isActiveProduct={true} />)
|
|
||||||
expect(screen.getByText('Nieuwe sprint')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders nothing on a non-active product (G6)', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<NewSprintTrigger productId="p1" isDemo={false} isActiveProduct={false} />,
|
|
||||||
)
|
|
||||||
expect(container).toBeEmptyDOMElement()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders nothing when a sprint draft is pending', () => {
|
|
||||||
workflowMock.value = { pendingSprintDraft: { p1: { goal: 'X' } } }
|
|
||||||
const { container } = render(
|
|
||||||
<NewSprintTrigger productId="p1" isDemo={false} isActiveProduct={true} />,
|
|
||||||
)
|
|
||||||
expect(container).toBeEmptyDOMElement()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
// @vitest-environment jsdom
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
import { render, screen, fireEvent } from '@testing-library/react'
|
|
||||||
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
|
||||||
import type { BacklogTask } from '@/stores/product-workspace/types'
|
|
||||||
|
|
||||||
function resetWorkspace() {
|
|
||||||
useProductWorkspaceStore.setState((s) => {
|
|
||||||
s.context.activeProduct = null
|
|
||||||
s.context.activePbiId = null
|
|
||||||
s.context.activeStoryId = null
|
|
||||||
s.context.activeTaskId = null
|
|
||||||
s.entities.pbisById = {}
|
|
||||||
s.entities.storiesById = {}
|
|
||||||
s.entities.tasksById = {}
|
|
||||||
s.relations.pbiIds = []
|
|
||||||
s.relations.storyIdsByPbi = {}
|
|
||||||
s.relations.taskIdsByStory = {}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function setActiveStoryAndTasks(storyId: string | null, tasks: BacklogTask[] = []) {
|
|
||||||
useProductWorkspaceStore.setState((s) => {
|
|
||||||
s.context.activeStoryId = storyId
|
|
||||||
if (storyId) {
|
|
||||||
s.relations.taskIdsByStory[storyId] = tasks.map((t) => t.id)
|
|
||||||
for (const task of tasks) s.entities.tasksById[task.id] = task
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mock next/navigation
|
|
||||||
const mockPush = vi.fn()
|
|
||||||
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mockPush }) }))
|
|
||||||
|
|
||||||
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
|
|
||||||
|
|
||||||
import { TaskPanel } from '@/components/backlog/task-panel'
|
|
||||||
|
|
||||||
const PRODUCT_ID = 'prod-1'
|
|
||||||
const STORY_ID = 'story-1'
|
|
||||||
const CLOSE_PATH = `/products/${PRODUCT_ID}`
|
|
||||||
|
|
||||||
const TASKS = [
|
|
||||||
{ id: 'task-1', code: null, title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_ID, created_at: new Date() },
|
|
||||||
{ id: 'task-2', code: null, title: 'Tweede taak', description: null, priority: 3, status: 'IN_PROGRESS', sort_order: 2, story_id: STORY_ID, created_at: new Date() },
|
|
||||||
]
|
|
||||||
|
|
||||||
function renderPanel(isDemo = false) {
|
|
||||||
return render(<TaskPanel productId={PRODUCT_ID} isDemo={isDemo} closePath={CLOSE_PATH} />)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('TaskPanel', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockPush.mockClear()
|
|
||||||
resetWorkspace()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows empty state when no story is selected', () => {
|
|
||||||
renderPanel()
|
|
||||||
expect(screen.getByText('Selecteer een story om de taken te bekijken.')).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows empty state with action when story selected but no tasks', () => {
|
|
||||||
setActiveStoryAndTasks(STORY_ID, [])
|
|
||||||
renderPanel()
|
|
||||||
expect(screen.getByText('Nog geen taken voor deze story.')).toBeTruthy()
|
|
||||||
expect(screen.getAllByText('+ Nieuwe taak').length).toBeGreaterThanOrEqual(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders task cards when tasks are present', () => {
|
|
||||||
setActiveStoryAndTasks(STORY_ID, TASKS)
|
|
||||||
renderPanel()
|
|
||||||
expect(screen.getByText('Eerste taak')).toBeTruthy()
|
|
||||||
expect(screen.getByText('Tweede taak')).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders status badges on task cards', () => {
|
|
||||||
setActiveStoryAndTasks(STORY_ID, TASKS)
|
|
||||||
renderPanel()
|
|
||||||
expect(screen.getByText('To Do')).toBeTruthy()
|
|
||||||
expect(screen.getByText('Bezig')).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('task cards are rendered inside a grid container', () => {
|
|
||||||
setActiveStoryAndTasks(STORY_ID, TASKS)
|
|
||||||
const { container } = renderPanel()
|
|
||||||
const grid = container.querySelector('.grid')
|
|
||||||
expect(grid).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('clicking + button calls router.push with newTask params', () => {
|
|
||||||
setActiveStoryAndTasks(STORY_ID, [])
|
|
||||||
renderPanel()
|
|
||||||
const buttons = screen.getAllByText('+ Nieuwe taak')
|
|
||||||
fireEvent.click(buttons[0])
|
|
||||||
expect(mockPush).toHaveBeenCalledWith(`${CLOSE_PATH}?newTask=1&storyId=${STORY_ID}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('clicking task card calls router.push with editTask param', () => {
|
|
||||||
setActiveStoryAndTasks(STORY_ID, TASKS)
|
|
||||||
renderPanel()
|
|
||||||
fireEvent.click(screen.getByText('Eerste taak'))
|
|
||||||
expect(mockPush).toHaveBeenCalledWith(`${CLOSE_PATH}?editTask=task-1`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('+ button is disabled in demo mode', () => {
|
|
||||||
setActiveStoryAndTasks(STORY_ID, [])
|
|
||||||
renderPanel(true)
|
|
||||||
const btn = screen.getAllByText('+ Nieuwe taak')[0].closest('button')
|
|
||||||
expect(btn).toBeTruthy()
|
|
||||||
expect((btn as HTMLButtonElement).disabled).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
@ -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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
// @vitest-environment jsdom
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
||||||
import React from 'react'
|
|
||||||
|
|
||||||
vi.mock('@/actions/questions', () => ({
|
|
||||||
answerQuestion: vi.fn(),
|
|
||||||
}))
|
|
||||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
|
||||||
vi.mock('@/stores/notifications-store', () => ({
|
|
||||||
useNotificationsStore: {
|
|
||||||
getState: () => ({ remove: vi.fn() }),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
vi.mock('next/link', () => ({
|
|
||||||
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
|
|
||||||
<a href={href}>{children}</a>
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { AnswerModal } from '@/components/notifications/answer-modal'
|
|
||||||
import { answerQuestion } from '@/actions/questions'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import type { NotificationQuestion } from '@/stores/notifications-store'
|
|
||||||
|
|
||||||
const mockAnswerQuestion = answerQuestion as ReturnType<typeof vi.fn>
|
|
||||||
const mockToast = toast as unknown as {
|
|
||||||
success: ReturnType<typeof vi.fn>
|
|
||||||
error: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
|
|
||||||
const QUESTION: NotificationQuestion = {
|
|
||||||
kind: 'idea',
|
|
||||||
id: 'q-1',
|
|
||||||
product_id: 'prod-1',
|
|
||||||
idea_id: 'idea-1',
|
|
||||||
idea_code: 'IDEA-42',
|
|
||||||
idea_title: 'Mijn Idee',
|
|
||||||
question: 'Wat denk jij?',
|
|
||||||
options: ['Optie A', 'Optie B'],
|
|
||||||
created_at: '2026-01-01T00:00:00Z',
|
|
||||||
expires_at: '2026-12-31T00:00:00Z',
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('AnswerModal — met opties', () => {
|
|
||||||
it('toont optieknoppen, textarea en Verstuur-knop', () => {
|
|
||||||
render(<AnswerModal question={QUESTION} isDemo={false} onClose={vi.fn()} />)
|
|
||||||
expect(screen.getByRole('button', { name: 'Optie A' })).toBeTruthy()
|
|
||||||
expect(screen.getByRole('button', { name: 'Optie B' })).toBeTruthy()
|
|
||||||
expect(screen.getByLabelText(/Antwoord op Claude/)).toBeTruthy()
|
|
||||||
expect(screen.getByRole('button', { name: 'Verstuur' })).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('roept answerQuestion aan met optiewaarde bij klik op optieknop', async () => {
|
|
||||||
mockAnswerQuestion.mockResolvedValue({ ok: true })
|
|
||||||
render(<AnswerModal question={QUESTION} isDemo={false} onClose={vi.fn()} />)
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Optie A' }))
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockAnswerQuestion).toHaveBeenCalledWith('q-1', 'Optie A')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('roept answerQuestion aan met getypte tekst bij klik op Verstuur', async () => {
|
|
||||||
mockAnswerQuestion.mockResolvedValue({ ok: true })
|
|
||||||
render(<AnswerModal question={QUESTION} isDemo={false} onClose={vi.fn()} />)
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText(/Antwoord op Claude/), {
|
|
||||||
target: { value: 'Mijn eigen antwoord' },
|
|
||||||
})
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Verstuur' }))
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockAnswerQuestion).toHaveBeenCalledWith('q-1', 'Mijn eigen antwoord')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Verstuur-knop is disabled zolang het tekstveld leeg is', () => {
|
|
||||||
render(<AnswerModal question={QUESTION} isDemo={false} onClose={vi.fn()} />)
|
|
||||||
expect(screen.getByRole('button', { name: 'Verstuur' })).toHaveProperty('disabled', true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('AnswerModal — demo-modus', () => {
|
|
||||||
it('textarea is disabled en Verstuur is disabled bij isDemo=true', () => {
|
|
||||||
render(<AnswerModal question={QUESTION} isDemo={true} onClose={vi.fn()} />)
|
|
||||||
expect(screen.getByLabelText(/Antwoord op Claude/)).toHaveProperty('disabled', true)
|
|
||||||
expect(screen.getByRole('button', { name: 'Verstuur' })).toHaveProperty('disabled', true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('AnswerModal — geen vraag', () => {
|
|
||||||
it('rendert niets wanneer question null is', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<AnswerModal question={null} isDemo={false} onClose={vi.fn()} />,
|
|
||||||
)
|
|
||||||
expect(container.firstChild).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
// @vitest-environment jsdom
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
||||||
|
|
||||||
vi.mock('@/actions/products', () => ({
|
|
||||||
createProductAction: vi.fn(),
|
|
||||||
updateProductAction: vi.fn(),
|
|
||||||
}))
|
|
||||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
|
||||||
vi.mock('@/stores/products-store', () => ({
|
|
||||||
useProductsStore: vi.fn((selector: (s: { addProduct: () => void; updateProduct: () => void }) => unknown) =>
|
|
||||||
selector({ addProduct: vi.fn(), updateProduct: vi.fn() })
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { ProductDialog } from '@/components/dialogs/product-dialog'
|
|
||||||
import { createProductAction, updateProductAction } from '@/actions/products'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
const mockCreate = createProductAction as ReturnType<typeof vi.fn>
|
|
||||||
const mockUpdate = updateProductAction as ReturnType<typeof vi.fn>
|
|
||||||
const mockToast = toast as unknown as {
|
|
||||||
success: ReturnType<typeof vi.fn>
|
|
||||||
error: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
|
|
||||||
const PRODUCT = {
|
|
||||||
id: 'prod-1',
|
|
||||||
name: 'Mijn Product',
|
|
||||||
code: 'MP',
|
|
||||||
description: 'Een product',
|
|
||||||
repo_url: 'https://github.com/org/repo',
|
|
||||||
definition_of_done: 'Alles groen',
|
|
||||||
auto_pr: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('ProductDialog — create mode', () => {
|
|
||||||
it('rendert met lege velden en "Nieuw product" titel', () => {
|
|
||||||
render(
|
|
||||||
<ProductDialog mode="create" open={true} onOpenChange={vi.fn()} />
|
|
||||||
)
|
|
||||||
expect(screen.getByText('Nieuw product')).toBeTruthy()
|
|
||||||
expect(screen.getByLabelText(/Naam/)).toBeTruthy()
|
|
||||||
expect((screen.getByLabelText(/Naam/) as HTMLInputElement).value).toBe('')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('toont validatiefout als naam leeg is bij submit', async () => {
|
|
||||||
render(
|
|
||||||
<ProductDialog mode="create" open={true} onOpenChange={vi.fn()} />
|
|
||||||
)
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Aanmaken' }))
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Naam is verplicht')).toBeTruthy()
|
|
||||||
})
|
|
||||||
expect(mockCreate).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('roept createProductAction aan bij geldig formulier', async () => {
|
|
||||||
mockCreate.mockResolvedValue({ success: true, productId: 'new-prod' })
|
|
||||||
|
|
||||||
render(
|
|
||||||
<ProductDialog mode="create" open={true} onOpenChange={vi.fn()} />
|
|
||||||
)
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText(/Naam/), { target: { value: 'Nieuw Product' } })
|
|
||||||
fireEvent.submit(document.getElementById('product-form')!)
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockCreate).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ name: 'Nieuw Product' })
|
|
||||||
)
|
|
||||||
})
|
|
||||||
expect(mockToast.success).toHaveBeenCalledWith('Product aangemaakt')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('toont error-toast als createProductAction een error retourneert', async () => {
|
|
||||||
mockCreate.mockResolvedValue({ error: 'Code is al in gebruik' })
|
|
||||||
|
|
||||||
render(
|
|
||||||
<ProductDialog mode="create" open={true} onOpenChange={vi.fn()} />
|
|
||||||
)
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText(/Naam/), { target: { value: 'Test' } })
|
|
||||||
fireEvent.submit(document.getElementById('product-form')!)
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockToast.error).toHaveBeenCalledWith('Code is al in gebruik')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('ProductDialog — edit mode', () => {
|
|
||||||
it('rendert met bestaande waarden vooringevuld', () => {
|
|
||||||
render(
|
|
||||||
<ProductDialog mode="edit" open={true} onOpenChange={vi.fn()} product={PRODUCT} />
|
|
||||||
)
|
|
||||||
expect(screen.getByText('Product bewerken')).toBeTruthy()
|
|
||||||
expect((screen.getByLabelText(/Naam/) as HTMLInputElement).value).toBe('Mijn Product')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('roept updateProductAction aan bij opslaan', async () => {
|
|
||||||
mockUpdate.mockResolvedValue({ success: true })
|
|
||||||
|
|
||||||
render(
|
|
||||||
<ProductDialog mode="edit" open={true} onOpenChange={vi.fn()} product={PRODUCT} />
|
|
||||||
)
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText(/Naam/), { target: { value: 'Gewijzigd Product' } })
|
|
||||||
fireEvent.submit(document.getElementById('product-form')!)
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockUpdate).toHaveBeenCalledWith(
|
|
||||||
PRODUCT.id,
|
|
||||||
expect.objectContaining({ name: 'Gewijzigd Product' })
|
|
||||||
)
|
|
||||||
})
|
|
||||||
expect(mockToast.success).toHaveBeenCalledWith('Product opgeslagen')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('ProductDialog — demo mode', () => {
|
|
||||||
it('submit-knop is disabled in demo-modus', () => {
|
|
||||||
render(
|
|
||||||
<ProductDialog mode="create" open={true} onOpenChange={vi.fn()} isDemo={true} />
|
|
||||||
)
|
|
||||||
const submitBtn = screen.getByRole('button', { name: 'Aanmaken' })
|
|
||||||
expect(submitBtn).toHaveProperty('disabled', true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,277 +0,0 @@
|
||||||
// @vitest-environment jsdom
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
||||||
import '@testing-library/jest-dom'
|
|
||||||
import React from 'react'
|
|
||||||
|
|
||||||
// --- Navigation mock ---
|
|
||||||
vi.mock('next/navigation', () => ({
|
|
||||||
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
|
|
||||||
}))
|
|
||||||
|
|
||||||
// --- Actions mocks ---
|
|
||||||
vi.mock('@/actions/ideas', () => ({
|
|
||||||
createIdeaAction: vi.fn(),
|
|
||||||
archiveIdeaAction: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/actions/user-settings', () => ({
|
|
||||||
updateUserSettingsAction: vi.fn().mockResolvedValue({ success: true, settings: {} }),
|
|
||||||
}))
|
|
||||||
|
|
||||||
// --- Sonner mock ---
|
|
||||||
vi.mock('sonner', () => ({
|
|
||||||
toast: { error: vi.fn(), success: vi.fn() },
|
|
||||||
}))
|
|
||||||
|
|
||||||
// --- IdeaRowActions mock (complex component with many deps) ---
|
|
||||||
vi.mock('@/components/ideas/idea-row-actions', () => ({
|
|
||||||
IdeaRowActions: () => <div data-testid="idea-row-actions" />,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// --- DemoTooltip mock ---
|
|
||||||
vi.mock('@/components/shared/demo-tooltip', () => ({
|
|
||||||
DemoTooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// --- Popover mock — controlled via open prop ---
|
|
||||||
vi.mock('@/components/ui/popover', () => {
|
|
||||||
const PopoverCtx = React.createContext<{
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (v: boolean) => void
|
|
||||||
}>({ open: false, onOpenChange: () => {} })
|
|
||||||
|
|
||||||
return {
|
|
||||||
Popover: ({
|
|
||||||
children,
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode
|
|
||||||
open?: boolean
|
|
||||||
onOpenChange?: (v: boolean) => void
|
|
||||||
}) => (
|
|
||||||
<PopoverCtx.Provider value={{ open: open ?? false, onOpenChange: onOpenChange ?? (() => {}) }}>
|
|
||||||
{children}
|
|
||||||
</PopoverCtx.Provider>
|
|
||||||
),
|
|
||||||
PopoverTrigger: ({ render: renderEl }: { render: React.ReactElement<{ onClick?: (e: React.MouseEvent) => void }> }) => {
|
|
||||||
const { open, onOpenChange } = React.useContext(PopoverCtx)
|
|
||||||
return React.cloneElement(renderEl, {
|
|
||||||
onClick: (e: React.MouseEvent) => {
|
|
||||||
onOpenChange(!open)
|
|
||||||
renderEl.props.onClick?.(e)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
|
||||||
PopoverContent: ({ children }: { children: React.ReactNode }) => {
|
|
||||||
const { open } = React.useContext(PopoverCtx)
|
|
||||||
return open ? <div data-testid="popover-content">{children}</div> : null
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Import after mocks
|
|
||||||
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
|
||||||
import { IdeaList } from '@/components/ideas/idea-list'
|
|
||||||
import { createIdeaAction } from '@/actions/ideas'
|
|
||||||
import type { IdeaDto } from '@/lib/idea-dto'
|
|
||||||
|
|
||||||
const PRODUCTS = [
|
|
||||||
{ id: 'prod-1', name: 'Product A', repo_url: null },
|
|
||||||
// repo_url ingesteld zodat de optietekst gewoon "Product B" is (zonder "(geen repo)" suffix)
|
|
||||||
{ id: 'prod-2', name: 'Product B', repo_url: 'https://github.com/org/prod-b' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// Minimal IdeaDto factory
|
|
||||||
function makeIdea(overrides: Partial<IdeaDto> = {}): IdeaDto {
|
|
||||||
return {
|
|
||||||
id: 'idea-1',
|
|
||||||
code: 'ID-1',
|
|
||||||
title: 'Test Idee',
|
|
||||||
description: null,
|
|
||||||
status: 'draft',
|
|
||||||
product_id: null,
|
|
||||||
product: null,
|
|
||||||
pbi_id: null,
|
|
||||||
pbi: null,
|
|
||||||
secondary_products: [],
|
|
||||||
archived: false,
|
|
||||||
has_grill_md: false,
|
|
||||||
has_plan_md: false,
|
|
||||||
created_at: '2024-01-01T00:00:00.000Z',
|
|
||||||
updated_at: '2024-01-01T00:00:00.000Z',
|
|
||||||
...overrides,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const IDEAS: IdeaDto[] = [
|
|
||||||
makeIdea({ id: 'idea-1', code: 'ID-1', title: 'Idee Concept', status: 'draft' }),
|
|
||||||
makeIdea({ id: 'idea-2', code: 'ID-2', title: 'Idee Gegrilld', status: 'grilled' }),
|
|
||||||
makeIdea({ id: 'idea-3', code: 'ID-3', title: 'Idee Gepland', status: 'planned' }),
|
|
||||||
]
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
useUserSettingsStore.getState().hydrate({}, false)
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('IdeaList — filterpopover', () => {
|
|
||||||
it('toont de "Filters"-knop in de toolbar (geen inline chip-rij)', () => {
|
|
||||||
render(<IdeaList ideas={IDEAS} products={[]} isDemo={false} activeProductId={null} />)
|
|
||||||
|
|
||||||
// Filters-knop aanwezig
|
|
||||||
expect(screen.getByText('Filters')).toBeInTheDocument()
|
|
||||||
|
|
||||||
// Status-labels zoals "Concept" mogen NIET los zichtbaar zijn zonder popover te openen
|
|
||||||
// (anders was de oude inline chip-rij er nog)
|
|
||||||
expect(screen.queryByRole('button', { name: 'Concept' })).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('klik op "Filters" opent de popover en toont 11 statusopties', () => {
|
|
||||||
render(<IdeaList ideas={IDEAS} products={[]} isDemo={false} activeProductId={null} />)
|
|
||||||
|
|
||||||
// Popover nog niet open: content niet zichtbaar
|
|
||||||
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('Filters'))
|
|
||||||
|
|
||||||
// Content verschijnt
|
|
||||||
expect(screen.getByTestId('popover-content')).toBeInTheDocument()
|
|
||||||
|
|
||||||
// 11 statusopties + "Alle" = 12 buttons in de popover
|
|
||||||
// Controleer specifiek de 11 status-labels
|
|
||||||
const statusLabels = [
|
|
||||||
'Concept', 'Grillen', 'Gegrilld', 'Plannen', 'Plan klaar',
|
|
||||||
'Plan beoordelen', 'Gepland', 'Grill mislukt', 'Plan mislukt',
|
|
||||||
'Beoordeling mislukt', 'Plan beoordeeld',
|
|
||||||
]
|
|
||||||
for (const label of statusLabels) {
|
|
||||||
expect(screen.getByRole('button', { name: label })).toBeInTheDocument()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('klik op een statuschip schrijft de status naar de store', () => {
|
|
||||||
render(<IdeaList ideas={IDEAS} products={[]} isDemo={false} activeProductId={null} />)
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('Filters'))
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Concept' }))
|
|
||||||
|
|
||||||
const stored =
|
|
||||||
useUserSettingsStore.getState().entities.settings.views?.ideasList?.filterStatuses
|
|
||||||
expect(stored).toContain('draft')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('gehydrateerde filter toont "Filters (1)" en filtert de tabel', () => {
|
|
||||||
useUserSettingsStore
|
|
||||||
.getState()
|
|
||||||
.hydrate({ views: { ideasList: { filterStatuses: ['draft'] } } }, false)
|
|
||||||
|
|
||||||
render(<IdeaList ideas={IDEAS} products={[]} isDemo={false} activeProductId={null} />)
|
|
||||||
|
|
||||||
// Trigger toont het actieve filteraantal
|
|
||||||
expect(screen.getByText('Filters (1)')).toBeInTheDocument()
|
|
||||||
|
|
||||||
// Alleen het concept-idee is zichtbaar; de andere twee worden weggefilterd
|
|
||||||
expect(screen.getByText('Idee Concept')).toBeInTheDocument()
|
|
||||||
expect(screen.queryByText('Idee Gegrilld')).not.toBeInTheDocument()
|
|
||||||
expect(screen.queryByText('Idee Gepland')).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('"Wis filters" is disabled wanneer geen filter actief is', () => {
|
|
||||||
render(<IdeaList ideas={IDEAS} products={[]} isDemo={false} activeProductId={null} />)
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('Filters'))
|
|
||||||
|
|
||||||
const wisButton = screen.getByRole('button', { name: 'Wis filters' })
|
|
||||||
expect(wisButton).toBeDisabled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('"Wis filters" is enabled en wist de filter wanneer een filter actief is', () => {
|
|
||||||
useUserSettingsStore
|
|
||||||
.getState()
|
|
||||||
.hydrate({ views: { ideasList: { filterStatuses: ['draft'] } } }, false)
|
|
||||||
|
|
||||||
render(<IdeaList ideas={IDEAS} products={[]} isDemo={false} activeProductId={null} />)
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('Filters (1)'))
|
|
||||||
|
|
||||||
const wisButton = screen.getByRole('button', { name: 'Wis filters' })
|
|
||||||
expect(wisButton).not.toBeDisabled()
|
|
||||||
|
|
||||||
fireEvent.click(wisButton)
|
|
||||||
|
|
||||||
const stored =
|
|
||||||
useUserSettingsStore.getState().entities.settings.views?.ideasList?.filterStatuses
|
|
||||||
expect(stored).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('IdeaList — activeProductId voorvullen', () => {
|
|
||||||
// Hulpfunctie: vind een knop op basis van gedeeltelijke tekstinhoud.
|
|
||||||
// getByText() werkt hier betrouwbaarder dan getByRole({name}) voor knoppen
|
|
||||||
// met SVG-icoon omdat de accessible-name-berekening van Base UI knoppen in
|
|
||||||
// jsdom soms afwijkt van wat we verwachten.
|
|
||||||
function clickButton(label: string) {
|
|
||||||
const btn = Array.from(document.querySelectorAll('button')).find(
|
|
||||||
(b) => b.textContent?.trim().includes(label)
|
|
||||||
)
|
|
||||||
if (!btn) throw new Error(`Knop met tekst "${label}" niet gevonden`)
|
|
||||||
fireEvent.click(btn)
|
|
||||||
}
|
|
||||||
|
|
||||||
it('AC1: "Nieuw idee"-select is voorgevuld met het actieve product', async () => {
|
|
||||||
render(
|
|
||||||
<IdeaList ideas={[]} products={PRODUCTS} isDemo={false} activeProductId="prod-2" />
|
|
||||||
)
|
|
||||||
|
|
||||||
clickButton('Nieuw idee')
|
|
||||||
|
|
||||||
// Wacht tot het formulier verschijnt; create-form-select toont "Product B" (waarde 'prod-2').
|
|
||||||
// De toolbar-select toont "Alle producten" (waarde 'all'), zodat displayValue uniek is.
|
|
||||||
const createFormSelect = await waitFor(() => screen.getByDisplayValue('Product B'))
|
|
||||||
expect(createFormSelect).toHaveValue('prod-2')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('AC2: "Nieuw idee"-select staat op leeg wanneer activeProductId null is', async () => {
|
|
||||||
render(
|
|
||||||
<IdeaList ideas={[]} products={PRODUCTS} isDemo={false} activeProductId={null} />
|
|
||||||
)
|
|
||||||
|
|
||||||
clickButton('Nieuw idee')
|
|
||||||
|
|
||||||
// Toolbar-select toont "Alle producten"; create-form-select toont de placeholder (waarde '').
|
|
||||||
const createFormSelect = await waitFor(() =>
|
|
||||||
screen.getByDisplayValue('Geen product (kan later worden gekoppeld)')
|
|
||||||
)
|
|
||||||
expect(createFormSelect).toHaveValue('')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('AC3: "Snel idee" stuurt product_id gelijk aan activeProductId mee', async () => {
|
|
||||||
vi.mocked(createIdeaAction).mockResolvedValue({ data: { code: 'ID-99', id: 'idea-99' } } as never)
|
|
||||||
|
|
||||||
render(
|
|
||||||
<IdeaList ideas={[]} products={PRODUCTS} isDemo={false} activeProductId="prod-2" />
|
|
||||||
)
|
|
||||||
|
|
||||||
// Open "Snel idee"-formulier en wacht tot het verschijnt
|
|
||||||
clickButton('Snel idee')
|
|
||||||
await waitFor(() => screen.getByPlaceholderText('Titel *'))
|
|
||||||
|
|
||||||
// Vul de verplichte titel in
|
|
||||||
fireEvent.change(screen.getByPlaceholderText('Titel *'), {
|
|
||||||
target: { value: 'Mijn snel idee' },
|
|
||||||
})
|
|
||||||
|
|
||||||
// Klik Opslaan — startTransition roept createIdeaAction synchroon aan
|
|
||||||
clickButton('Opslaan')
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(createIdeaAction).toHaveBeenCalledWith({
|
|
||||||
title: 'Mijn snel idee',
|
|
||||||
description: null,
|
|
||||||
product_id: 'prod-2',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
// @vitest-environment jsdom
|
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { render, screen } from '@testing-library/react'
|
|
||||||
import '@testing-library/jest-dom'
|
|
||||||
import JobCard from '@/components/jobs/job-card'
|
|
||||||
|
|
||||||
const BASE_PROPS = {
|
|
||||||
id: 'job-1',
|
|
||||||
kind: 'TASK_IMPLEMENTATION' as const,
|
|
||||||
status: 'RUNNING' as const,
|
|
||||||
productName: 'Scrum4Me',
|
|
||||||
productCode: 'S4M',
|
|
||||||
pbiCode: 'PBI-1',
|
|
||||||
storyCode: 'ST-1',
|
|
||||||
createdAt: new Date('2026-01-01T10:00:00Z'),
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('JobCard breadcrumb', () => {
|
|
||||||
it('TASK-job toont productCode, pbiCode en storyCode in de breadcrumb', () => {
|
|
||||||
render(<JobCard {...BASE_PROPS} />)
|
|
||||||
const breadcrumb = screen.getByText('S4M PBI-1 ST-1')
|
|
||||||
expect(breadcrumb).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('TASK-job zonder productCode valt terug op productName in de breadcrumb', () => {
|
|
||||||
render(<JobCard {...BASE_PROPS} productCode={null} />)
|
|
||||||
expect(screen.getByText('Scrum4Me PBI-1 ST-1')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('TASK-job laat ontbrekende codes weg uit de breadcrumb', () => {
|
|
||||||
render(<JobCard {...BASE_PROPS} pbiCode={null} storyCode={null} />)
|
|
||||||
expect(screen.getByText('S4M')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('GRILL-job toont productCode en ideaCode', () => {
|
|
||||||
render(
|
|
||||||
<JobCard
|
|
||||||
{...BASE_PROPS}
|
|
||||||
kind="IDEA_GRILL"
|
|
||||||
productCode="S4M"
|
|
||||||
ideaCode="IDEA-5"
|
|
||||||
pbiCode={null}
|
|
||||||
storyCode={null}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
expect(screen.getByText('S4M IDEA-5')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('SPRINT-job toont productCode en sprintCode', () => {
|
|
||||||
render(
|
|
||||||
<JobCard
|
|
||||||
{...BASE_PROPS}
|
|
||||||
kind="SPRINT_IMPLEMENTATION"
|
|
||||||
productCode="S4M"
|
|
||||||
sprintCode="SP-3"
|
|
||||||
pbiCode={null}
|
|
||||||
storyCode={null}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
expect(screen.getByText('S4M SP-3')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('JobCard datumweergave', () => {
|
|
||||||
it('toont finishedAt als die beschikbaar is', () => {
|
|
||||||
const finishedAt = new Date('2026-03-15T14:30:00Z')
|
|
||||||
render(<JobCard {...BASE_PROPS} startedAt={new Date('2026-03-10T09:00:00Z')} finishedAt={finishedAt} />)
|
|
||||||
const formatted = finishedAt.toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' })
|
|
||||||
expect(screen.getByText(formatted)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('toont startedAt als finishedAt ontbreekt', () => {
|
|
||||||
const startedAt = new Date('2026-03-10T09:00:00Z')
|
|
||||||
render(<JobCard {...BASE_PROPS} startedAt={startedAt} finishedAt={null} />)
|
|
||||||
const formatted = startedAt.toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' })
|
|
||||||
expect(screen.getByText(formatted)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('toont createdAt als zowel finishedAt als startedAt ontbreken', () => {
|
|
||||||
const createdAt = new Date('2026-01-01T10:00:00Z')
|
|
||||||
render(<JobCard {...BASE_PROPS} createdAt={createdAt} startedAt={null} finishedAt={null} />)
|
|
||||||
const formatted = createdAt.toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' })
|
|
||||||
expect(screen.getByText(formatted)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
// @vitest-environment jsdom
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
import { render, screen, fireEvent } from '@testing-library/react'
|
|
||||||
import '@testing-library/jest-dom'
|
|
||||||
import type { JobWithRelations } from '@/actions/jobs-page'
|
|
||||||
|
|
||||||
vi.mock('@/actions/claude-jobs', () => ({
|
|
||||||
restartClaudeJobAction: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
|
|
||||||
|
|
||||||
import { restartClaudeJobAction } from '@/actions/claude-jobs'
|
|
||||||
import JobDetailPane from '@/components/jobs/job-detail-pane'
|
|
||||||
|
|
||||||
const mockAction = restartClaudeJobAction as ReturnType<typeof vi.fn>
|
|
||||||
|
|
||||||
function makeJob(status: JobWithRelations['status']): JobWithRelations {
|
|
||||||
return {
|
|
||||||
id: 'job-1',
|
|
||||||
kind: 'TASK_IMPLEMENTATION',
|
|
||||||
status,
|
|
||||||
taskCode: 'T-1',
|
|
||||||
taskTitle: 'Test taak',
|
|
||||||
ideaCode: null,
|
|
||||||
ideaTitle: null,
|
|
||||||
sprintGoal: null,
|
|
||||||
sprintCode: null,
|
|
||||||
productName: 'Scrum4Me',
|
|
||||||
productCode: null,
|
|
||||||
storyCode: null,
|
|
||||||
pbiCode: null,
|
|
||||||
modelId: null,
|
|
||||||
inputTokens: null,
|
|
||||||
outputTokens: null,
|
|
||||||
cacheReadTokens: null,
|
|
||||||
cacheWriteTokens: null,
|
|
||||||
costUsd: null,
|
|
||||||
branch: null,
|
|
||||||
prUrl: null,
|
|
||||||
error: null,
|
|
||||||
summary: null,
|
|
||||||
description: null,
|
|
||||||
verifyResult: null,
|
|
||||||
startedAt: null,
|
|
||||||
finishedAt: null,
|
|
||||||
createdAt: new Date('2026-01-01'),
|
|
||||||
sprintRunId: null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockAction.mockResolvedValue({ success: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('JobDetailPane restart button', () => {
|
|
||||||
it('toont de knop voor FAILED-jobs', () => {
|
|
||||||
render(<JobDetailPane job={makeJob('FAILED')} isDemo={false} />)
|
|
||||||
expect(screen.getByRole('button', { name: /opnieuw starten/i })).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('toont de knop niet voor DONE-jobs', () => {
|
|
||||||
render(<JobDetailPane job={makeJob('DONE')} isDemo={false} />)
|
|
||||||
expect(screen.queryByRole('button', { name: /opnieuw starten/i })).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('roept restartClaudeJobAction aan met het juiste id bij klik', () => {
|
|
||||||
render(<JobDetailPane job={makeJob('FAILED')} isDemo={false} />)
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /opnieuw starten/i }))
|
|
||||||
expect(mockAction).toHaveBeenCalledWith('job-1')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('knop is disabled in demo-modus', () => {
|
|
||||||
render(<JobDetailPane job={makeJob('FAILED')} isDemo={true} />)
|
|
||||||
expect(screen.getByRole('button', { name: /opnieuw starten/i })).toBeDisabled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
// @vitest-environment jsdom
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
||||||
import { render, screen, act } from '@testing-library/react'
|
|
||||||
import { LandscapeGuard } from '@/components/mobile/landscape-guard'
|
|
||||||
|
|
||||||
type Listener = (e: MediaQueryListEvent) => void
|
|
||||||
|
|
||||||
function mockMatchMedia(initialPortrait: boolean) {
|
|
||||||
let matches = initialPortrait
|
|
||||||
let listener: Listener | null = null
|
|
||||||
|
|
||||||
const mql = {
|
|
||||||
get matches() { return matches },
|
|
||||||
media: '(orientation: portrait)',
|
|
||||||
onchange: null,
|
|
||||||
addEventListener: (_: string, l: Listener) => { listener = l },
|
|
||||||
removeEventListener: () => { listener = null },
|
|
||||||
addListener: () => {},
|
|
||||||
removeListener: () => {},
|
|
||||||
dispatchEvent: () => false,
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.defineProperty(window, 'matchMedia', {
|
|
||||||
writable: true,
|
|
||||||
configurable: true,
|
|
||||||
value: () => mql,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
setPortrait(p: boolean) {
|
|
||||||
matches = p
|
|
||||||
if (listener) listener({ matches: p } as MediaQueryListEvent)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('LandscapeGuard', () => {
|
|
||||||
beforeEach(() => {})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders children always', () => {
|
|
||||||
mockMatchMedia(false)
|
|
||||||
render(<LandscapeGuard><div>kids</div></LandscapeGuard>)
|
|
||||||
expect(screen.getByText('kids')).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows overlay in portrait', () => {
|
|
||||||
mockMatchMedia(true)
|
|
||||||
render(<LandscapeGuard><div>kids</div></LandscapeGuard>)
|
|
||||||
expect(screen.getByRole('alert').textContent).toContain('Draai je telefoon naar landscape')
|
|
||||||
// children blijven in DOM (geen unmount → SSE-streams blijven leven)
|
|
||||||
expect(screen.getByText('kids')).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('hides overlay in landscape', () => {
|
|
||||||
mockMatchMedia(false)
|
|
||||||
render(<LandscapeGuard><div>kids</div></LandscapeGuard>)
|
|
||||||
expect(screen.queryByRole('alert')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('toggles overlay on orientation change', () => {
|
|
||||||
const ctl = mockMatchMedia(false)
|
|
||||||
render(<LandscapeGuard><div>kids</div></LandscapeGuard>)
|
|
||||||
expect(screen.queryByRole('alert')).toBeNull()
|
|
||||||
act(() => ctl.setPortrait(true))
|
|
||||||
expect(screen.getByRole('alert')).toBeTruthy()
|
|
||||||
act(() => ctl.setPortrait(false))
|
|
||||||
expect(screen.queryByRole('alert')).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
// @vitest-environment jsdom
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
||||||
|
|
||||||
const { logoutMock } = vi.hoisted(() => ({
|
|
||||||
logoutMock: vi.fn().mockResolvedValue(undefined),
|
|
||||||
}))
|
|
||||||
vi.mock('@/actions/auth', () => ({ logoutAction: logoutMock }))
|
|
||||||
|
|
||||||
import { LogoutButton } from '@/components/mobile/logout-button'
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
logoutMock.mockClear()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('LogoutButton', () => {
|
|
||||||
it('toont initieel alleen de Uitloggen-knop, geen dialog', () => {
|
|
||||||
render(<LogoutButton />)
|
|
||||||
expect(screen.getByRole('button', { name: /Uitloggen/ })).toBeTruthy()
|
|
||||||
expect(screen.queryByText(/Weet je zeker/)).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('opent AlertDialog bij klikken op de knop', () => {
|
|
||||||
render(<LogoutButton />)
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /Uitloggen/ }))
|
|
||||||
expect(screen.getByText('Uitloggen?')).toBeTruthy()
|
|
||||||
expect(screen.getByText(/Weet je zeker/)).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('roept logoutAction aan op bevestigen', async () => {
|
|
||||||
const { container } = render(<LogoutButton />)
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /Uitloggen/ }))
|
|
||||||
// Het body-portal wordt buiten container gerenderd; query op document.body.
|
|
||||||
const allButtons = Array.from(document.body.querySelectorAll('button'))
|
|
||||||
const confirmBtn = allButtons.find(b => b.textContent?.trim() === 'Uitloggen' && !container.contains(b)) ?? allButtons[allButtons.length - 1]
|
|
||||||
fireEvent.click(confirmBtn)
|
|
||||||
await waitFor(() => expect(logoutMock).toHaveBeenCalledTimes(1))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('roept logoutAction NIET aan bij annuleren', () => {
|
|
||||||
render(<LogoutButton />)
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /Uitloggen/ }))
|
|
||||||
fireEvent.click(screen.getByText('Annuleren'))
|
|
||||||
expect(logoutMock).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
// @vitest-environment jsdom
|
|
||||||
import { describe, it, expect, vi } from 'vitest'
|
|
||||||
import { render, screen } from '@testing-library/react'
|
|
||||||
import { MobileTabBar } from '@/components/mobile/mobile-tab-bar'
|
|
||||||
|
|
||||||
let pathname = '/m/products/p1'
|
|
||||||
vi.mock('next/navigation', () => ({
|
|
||||||
usePathname: () => pathname,
|
|
||||||
}))
|
|
||||||
|
|
||||||
function setPathname(p: string) { pathname = p }
|
|
||||||
|
|
||||||
describe('MobileTabBar', () => {
|
|
||||||
it('toont 3 tabs als activeProductId aanwezig is', () => {
|
|
||||||
setPathname('/m/products/p1')
|
|
||||||
render(<MobileTabBar activeProductId="p1" />)
|
|
||||||
expect(screen.getByLabelText('Backlog')).toBeTruthy()
|
|
||||||
expect(screen.getByLabelText('Solo')).toBeTruthy()
|
|
||||||
expect(screen.getByLabelText('Settings')).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('toont alleen Settings als activeProductId null is', () => {
|
|
||||||
setPathname('/m/settings')
|
|
||||||
render(<MobileTabBar activeProductId={null} />)
|
|
||||||
expect(screen.queryByLabelText('Backlog')).toBeNull()
|
|
||||||
expect(screen.queryByLabelText('Solo')).toBeNull()
|
|
||||||
expect(screen.getByLabelText('Settings')).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Backlog-tab is aria-current op /m/products/[id]', () => {
|
|
||||||
setPathname('/m/products/p1')
|
|
||||||
render(<MobileTabBar activeProductId="p1" />)
|
|
||||||
expect(screen.getByLabelText('Backlog').getAttribute('aria-current')).toBe('page')
|
|
||||||
expect(screen.getByLabelText('Solo').getAttribute('aria-current')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Solo-tab is aria-current op /m/products/[id]/solo', () => {
|
|
||||||
setPathname('/m/products/p1/solo')
|
|
||||||
render(<MobileTabBar activeProductId="p1" />)
|
|
||||||
expect(screen.getByLabelText('Solo').getAttribute('aria-current')).toBe('page')
|
|
||||||
expect(screen.getByLabelText('Backlog').getAttribute('aria-current')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Settings-tab is aria-current op /m/settings', () => {
|
|
||||||
setPathname('/m/settings')
|
|
||||||
render(<MobileTabBar activeProductId="p1" />)
|
|
||||||
expect(screen.getByLabelText('Settings').getAttribute('aria-current')).toBe('page')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('tap-targets >=44x44 (h-14 = 56px breedtevulling per tab)', () => {
|
|
||||||
setPathname('/m/products/p1')
|
|
||||||
render(<MobileTabBar activeProductId="p1" />)
|
|
||||||
const tab = screen.getByLabelText('Backlog')
|
|
||||||
expect(tab.className).toContain('h-14')
|
|
||||||
expect(tab.className).toContain('flex-1')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
// @vitest-environment jsdom
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
import { render, screen, fireEvent } from '@testing-library/react'
|
|
||||||
import '@testing-library/jest-dom'
|
|
||||||
|
|
||||||
vi.mock('@/actions/products', () => ({
|
|
||||||
updateAutoPrAction: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
|
|
||||||
|
|
||||||
import { updateAutoPrAction } from '@/actions/products'
|
|
||||||
import { AutoPrToggle } from '@/components/products/auto-pr-toggle'
|
|
||||||
|
|
||||||
const mockAction = updateAutoPrAction as ReturnType<typeof vi.fn>
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockAction.mockResolvedValue({ success: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('AutoPrToggle', () => {
|
|
||||||
it('renders in off state with aria-checked=false', () => {
|
|
||||||
render(<AutoPrToggle productId="prod-1" initialValue={false} />)
|
|
||||||
const toggle = screen.getByRole('switch')
|
|
||||||
expect(toggle).toHaveAttribute('aria-checked', 'false')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders in on state with aria-checked=true', () => {
|
|
||||||
render(<AutoPrToggle productId="prod-1" initialValue={true} />)
|
|
||||||
const toggle = screen.getByRole('switch')
|
|
||||||
expect(toggle).toHaveAttribute('aria-checked', 'true')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('calls updateAutoPrAction with true when toggled on', async () => {
|
|
||||||
render(<AutoPrToggle productId="prod-1" initialValue={false} />)
|
|
||||||
fireEvent.click(screen.getByRole('switch'))
|
|
||||||
expect(mockAction).toHaveBeenCalledWith('prod-1', true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('calls updateAutoPrAction with false when toggled off', async () => {
|
|
||||||
render(<AutoPrToggle productId="prod-1" initialValue={true} />)
|
|
||||||
fireEvent.click(screen.getByRole('switch'))
|
|
||||||
expect(mockAction).toHaveBeenCalledWith('prod-1', false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { readFileSync } from 'node:fs'
|
|
||||||
import { resolve } from 'node:path'
|
|
||||||
import { entityDialogContentClasses } from '@/components/shared/entity-dialog-layout'
|
|
||||||
|
|
||||||
describe('entityDialogContentClasses', () => {
|
|
||||||
it('bevat mobile-fullscreen classes (<640px)', () => {
|
|
||||||
const cls = entityDialogContentClasses
|
|
||||||
expect(cls).toContain('max-sm:w-screen')
|
|
||||||
expect(cls).toContain('max-sm:h-screen')
|
|
||||||
expect(cls).toContain('max-sm:max-w-none')
|
|
||||||
expect(cls).toContain('max-sm:rounded-none')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('behoudt desktop-classes (>=640px)', () => {
|
|
||||||
const cls = entityDialogContentClasses
|
|
||||||
expect(cls).toContain('sm:max-w-[90vw]')
|
|
||||||
expect(cls).toContain('sm:max-h-[85vh]')
|
|
||||||
expect(cls).toContain('lg:max-w-[50vw]')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('alle entity-dialogen gebruiken entityDialogContentClasses', () => {
|
|
||||||
// Regressie-vangnet: voorkomt dat een dialog zijn eigen className meegeeft en
|
|
||||||
// daarmee de gedeelde mobile-fullscreen-classes ontwijkt.
|
|
||||||
const files = [
|
|
||||||
'app/_components/tasks/task-dialog.tsx',
|
|
||||||
'components/solo/task-detail-dialog.tsx',
|
|
||||||
'components/backlog/pbi-dialog.tsx',
|
|
||||||
'components/backlog/story-dialog.tsx',
|
|
||||||
]
|
|
||||||
for (const f of files) {
|
|
||||||
it(`${f} importeert + gebruikt entityDialogContentClasses`, () => {
|
|
||||||
const src = readFileSync(resolve(process.cwd(), f), 'utf-8')
|
|
||||||
expect(src).toContain('entityDialogContentClasses')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -1,179 +0,0 @@
|
||||||
// @vitest-environment jsdom
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
import { render, screen, fireEvent } from '@testing-library/react'
|
|
||||||
import '@testing-library/jest-dom'
|
|
||||||
import React from 'react'
|
|
||||||
|
|
||||||
const pushMock = vi.fn()
|
|
||||||
const refreshMock = vi.fn()
|
|
||||||
const pathnameMock = vi.fn(() => '/dashboard')
|
|
||||||
|
|
||||||
vi.mock('next/navigation', () => ({
|
|
||||||
useRouter: () => ({ push: pushMock, refresh: refreshMock }),
|
|
||||||
usePathname: () => pathnameMock(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/actions/active-product', () => ({
|
|
||||||
setActiveProductAction: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('sonner', () => ({
|
|
||||||
toast: { error: vi.fn(), success: vi.fn() },
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/components/ui/dropdown-menu', () => {
|
|
||||||
type Props = React.HTMLAttributes<HTMLDivElement> & {
|
|
||||||
children?: React.ReactNode
|
|
||||||
onClick?: () => void
|
|
||||||
}
|
|
||||||
const PassThrough = ({ children }: Props) => <>{children}</>
|
|
||||||
const Forwarding = ({ children, ...rest }: Props) => <div {...rest}>{children}</div>
|
|
||||||
return {
|
|
||||||
DropdownMenu: PassThrough,
|
|
||||||
DropdownMenuTrigger: Forwarding,
|
|
||||||
DropdownMenuContent: PassThrough,
|
|
||||||
DropdownMenuItem: ({ children, onClick, className }: Props) => (
|
|
||||||
<button type="button" onClick={onClick} className={className} data-testid="dd-item">
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
DropdownMenuSeparator: () => null,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
vi.mock('@/components/ui/tooltip', () => {
|
|
||||||
type Props = { children?: React.ReactNode }
|
|
||||||
const PassThrough = ({ children }: Props) => <>{children}</>
|
|
||||||
return {
|
|
||||||
Tooltip: PassThrough,
|
|
||||||
TooltipContent: PassThrough,
|
|
||||||
TooltipProvider: PassThrough,
|
|
||||||
TooltipTrigger: PassThrough,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
vi.mock('@/components/shared/app-icon', () => ({ AppIcon: () => null }))
|
|
||||||
vi.mock('@/components/shared/user-menu', () => ({ UserMenu: () => null }))
|
|
||||||
vi.mock('@/components/shared/notifications-bell', () => ({ NotificationsBell: () => null }))
|
|
||||||
vi.mock('@/components/solo/nav-status-indicators', () => ({
|
|
||||||
SoloNavStatusIndicators: () => null,
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { setActiveProductAction } from '@/actions/active-product'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { NavBar } from '@/components/shared/nav-bar'
|
|
||||||
|
|
||||||
const actionMock = setActiveProductAction as unknown as ReturnType<typeof vi.fn>
|
|
||||||
const toastSuccess = toast.success as unknown as ReturnType<typeof vi.fn>
|
|
||||||
|
|
||||||
const products = [
|
|
||||||
{ id: 'A', name: 'Alpha' },
|
|
||||||
{ id: 'B', name: 'Beta' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function renderNavBar(overrides: { isDemo?: boolean; activeProductId?: string } = {}) {
|
|
||||||
const isDemo = overrides.isDemo ?? false
|
|
||||||
const activeId = overrides.activeProductId ?? 'A'
|
|
||||||
const activeProduct = products.find(p => p.id === activeId) ?? null
|
|
||||||
return render(
|
|
||||||
<NavBar
|
|
||||||
isDemo={isDemo}
|
|
||||||
roles={[]}
|
|
||||||
userId="u1"
|
|
||||||
username="user"
|
|
||||||
email={null}
|
|
||||||
activeProduct={activeProduct}
|
|
||||||
products={products}
|
|
||||||
hasActiveSprint={false}
|
|
||||||
minQuotaPct={100}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
actionMock.mockResolvedValue({ success: true })
|
|
||||||
pathnameMock.mockReturnValue('/dashboard')
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('NavBar — product switch', () => {
|
|
||||||
it('demo: clicking another product navigates via router.push without calling the action', () => {
|
|
||||||
renderNavBar({ isDemo: true, activeProductId: 'A' })
|
|
||||||
fireEvent.click(screen.getByText('Beta'))
|
|
||||||
expect(pushMock).toHaveBeenCalledWith('/products/B')
|
|
||||||
expect(actionMock).not.toHaveBeenCalled()
|
|
||||||
expect(toastSuccess).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('non-demo: clicking another product calls setActiveProductAction', async () => {
|
|
||||||
renderNavBar({ isDemo: false, activeProductId: 'A' })
|
|
||||||
fireEvent.click(screen.getByText('Beta'))
|
|
||||||
await Promise.resolve()
|
|
||||||
expect(actionMock).toHaveBeenCalledWith('B')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('non-demo: on /products/A navigates to /products/B', async () => {
|
|
||||||
pathnameMock.mockReturnValue('/products/A')
|
|
||||||
renderNavBar({ isDemo: false, activeProductId: 'A' })
|
|
||||||
fireEvent.click(screen.getByText('Beta'))
|
|
||||||
await Promise.resolve()
|
|
||||||
await Promise.resolve()
|
|
||||||
expect(pushMock).toHaveBeenCalledWith('/products/B')
|
|
||||||
expect(toastSuccess).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('non-demo: on /products/A/sprint/SPR1 navigates to /products/B/sprint', async () => {
|
|
||||||
pathnameMock.mockReturnValue('/products/A/sprint/SPR1')
|
|
||||||
renderNavBar({ isDemo: false, activeProductId: 'A' })
|
|
||||||
fireEvent.click(screen.getByText('Beta'))
|
|
||||||
await Promise.resolve()
|
|
||||||
await Promise.resolve()
|
|
||||||
expect(pushMock).toHaveBeenCalledWith('/products/B/sprint')
|
|
||||||
expect(toastSuccess).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('non-demo: on /products/A/solo navigates to /products/B/solo', async () => {
|
|
||||||
pathnameMock.mockReturnValue('/products/A/solo')
|
|
||||||
renderNavBar({ isDemo: false, activeProductId: 'A' })
|
|
||||||
fireEvent.click(screen.getByText('Beta'))
|
|
||||||
await Promise.resolve()
|
|
||||||
await Promise.resolve()
|
|
||||||
expect(pushMock).toHaveBeenCalledWith('/products/B/solo')
|
|
||||||
expect(toastSuccess).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('non-demo: on /dashboard calls router.refresh and not router.push', async () => {
|
|
||||||
pathnameMock.mockReturnValue('/dashboard')
|
|
||||||
renderNavBar({ isDemo: false, activeProductId: 'A' })
|
|
||||||
fireEvent.click(screen.getByText('Beta'))
|
|
||||||
await Promise.resolve()
|
|
||||||
await Promise.resolve()
|
|
||||||
expect(refreshMock).toHaveBeenCalled()
|
|
||||||
expect(pushMock).not.toHaveBeenCalled()
|
|
||||||
expect(toastSuccess).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('NavBar — URL-derived active product (demo only)', () => {
|
|
||||||
it('demo: label and dropdown highlight follow pathname, not the activeProduct prop', () => {
|
|
||||||
pathnameMock.mockReturnValue('/products/B/sprint')
|
|
||||||
const { container } = renderNavBar({ isDemo: true, activeProductId: 'A' })
|
|
||||||
const trigger = container.querySelector('[data-debug-id="nav-bar__product-switcher"]')
|
|
||||||
expect(trigger?.textContent).toContain('Beta')
|
|
||||||
expect(trigger?.textContent).not.toContain('Alpha')
|
|
||||||
const items = screen.getAllByTestId('dd-item')
|
|
||||||
const itemB = items.find(el => el.textContent?.includes('Beta'))
|
|
||||||
expect(itemB?.className).toContain('bg-primary-container')
|
|
||||||
const itemA = items.find(el => el.textContent?.includes('Alpha'))
|
|
||||||
expect(itemA?.className ?? '').not.toContain('bg-primary-container')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('non-demo: pathname does NOT override the activeProduct prop', () => {
|
|
||||||
pathnameMock.mockReturnValue('/products/B/sprint')
|
|
||||||
renderNavBar({ isDemo: false, activeProductId: 'A' })
|
|
||||||
// Label still reflects server-rendered activeProduct (Alpha)
|
|
||||||
const items = screen.getAllByTestId('dd-item')
|
|
||||||
const itemA = items.find(el => el.textContent?.includes('Alpha'))
|
|
||||||
expect(itemA?.className).toContain('bg-primary-container')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,174 +0,0 @@
|
||||||
// @vitest-environment jsdom
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
import { render, screen, fireEvent } from '@testing-library/react'
|
|
||||||
import '@testing-library/jest-dom'
|
|
||||||
import React from 'react'
|
|
||||||
|
|
||||||
const pushMock = vi.fn()
|
|
||||||
const refreshMock = vi.fn()
|
|
||||||
const pathnameMock = vi.fn(() => '/products/p1/sprint')
|
|
||||||
|
|
||||||
vi.mock('next/navigation', () => ({
|
|
||||||
useRouter: () => ({ push: pushMock, refresh: refreshMock }),
|
|
||||||
usePathname: () => pathnameMock(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/actions/active-sprint', () => ({
|
|
||||||
setActiveSprintAction: vi.fn(),
|
|
||||||
switchActiveSprintAction: vi.fn(),
|
|
||||||
clearActiveSprintAction: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('sonner', () => ({
|
|
||||||
toast: { error: vi.fn(), success: vi.fn() },
|
|
||||||
}))
|
|
||||||
|
|
||||||
const isDemoMock = { value: false }
|
|
||||||
const workflowMock: {
|
|
||||||
value:
|
|
||||||
| { pendingSprintDraft?: Record<string, { goal: string } | undefined> }
|
|
||||||
| undefined
|
|
||||||
} = { value: undefined }
|
|
||||||
// Mock-state shape moet alle paden dekken die SprintSwitcher selecteert:
|
|
||||||
// - s.context.isDemo (oude code)
|
|
||||||
// - s.entities.settings.workflow?.pendingSprintDraft?.[productId]?.goal (PBI-79)
|
|
||||||
type MockStoreState = {
|
|
||||||
context: { isDemo: boolean }
|
|
||||||
entities: {
|
|
||||||
settings: {
|
|
||||||
workflow?: {
|
|
||||||
pendingSprintDraft?: Record<string, { goal: string } | undefined>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
vi.mock('@/stores/user-settings/store', () => ({
|
|
||||||
useUserSettingsStore: (selector: (s: MockStoreState) => unknown) =>
|
|
||||||
selector({
|
|
||||||
context: { isDemo: isDemoMock.value },
|
|
||||||
entities: { settings: { workflow: workflowMock.value } },
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/components/ui/dropdown-menu', () => {
|
|
||||||
type Props = { children?: React.ReactNode; onClick?: () => void; className?: string }
|
|
||||||
const PassThrough = ({ children }: Props) => <>{children}</>
|
|
||||||
return {
|
|
||||||
DropdownMenu: PassThrough,
|
|
||||||
DropdownMenuTrigger: PassThrough,
|
|
||||||
DropdownMenuContent: PassThrough,
|
|
||||||
DropdownMenuItem: ({ children, onClick, className }: Props) => (
|
|
||||||
<button type="button" onClick={onClick} className={className}>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
DropdownMenuSeparator: () => null,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
vi.mock('@/components/ui/tooltip', () => {
|
|
||||||
type Props = { children?: React.ReactNode }
|
|
||||||
const PassThrough = ({ children }: Props) => <>{children}</>
|
|
||||||
return {
|
|
||||||
Tooltip: PassThrough,
|
|
||||||
TooltipContent: PassThrough,
|
|
||||||
TooltipProvider: PassThrough,
|
|
||||||
TooltipTrigger: PassThrough,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
import { switchActiveSprintAction } from '@/actions/active-sprint'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { SprintSwitcher } from '@/components/shared/sprint-switcher'
|
|
||||||
|
|
||||||
const actionMock = switchActiveSprintAction as unknown as ReturnType<typeof vi.fn>
|
|
||||||
const toastError = toast.error as unknown as ReturnType<typeof vi.fn>
|
|
||||||
const toastSuccess = toast.success as unknown as ReturnType<typeof vi.fn>
|
|
||||||
|
|
||||||
const sprints = [
|
|
||||||
{ id: 's1', code: 'SP-1', sprint_goal: 'Goal 1', status: 'open' as const },
|
|
||||||
{ id: 's2', code: 'SP-2', sprint_goal: 'Goal 2', status: 'open' as const },
|
|
||||||
]
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
isDemoMock.value = false
|
|
||||||
workflowMock.value = undefined
|
|
||||||
actionMock.mockResolvedValue({ success: true })
|
|
||||||
pathnameMock.mockReturnValue('/products/p1/sprint')
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('SprintSwitcher', () => {
|
|
||||||
it('demo: clicking another sprint navigates via router.push without calling the action', () => {
|
|
||||||
isDemoMock.value = true
|
|
||||||
render(
|
|
||||||
<SprintSwitcher
|
|
||||||
productId="p1"
|
|
||||||
sprints={sprints}
|
|
||||||
activeSprint={sprints[0]}
|
|
||||||
buildingSprintIds={[]}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
fireEvent.click(screen.getByText('Goal 2'))
|
|
||||||
expect(pushMock).toHaveBeenCalledWith('/products/p1/sprint/s2')
|
|
||||||
expect(actionMock).not.toHaveBeenCalled()
|
|
||||||
expect(toastError).not.toHaveBeenCalled()
|
|
||||||
expect(toastSuccess).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('non-demo: clicking another sprint calls setActiveSprintAction', async () => {
|
|
||||||
isDemoMock.value = false
|
|
||||||
render(
|
|
||||||
<SprintSwitcher
|
|
||||||
productId="p1"
|
|
||||||
sprints={sprints}
|
|
||||||
activeSprint={sprints[0]}
|
|
||||||
buildingSprintIds={[]}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
fireEvent.click(screen.getByText('Goal 2'))
|
|
||||||
// Wait microtask for the transition to flush.
|
|
||||||
await Promise.resolve()
|
|
||||||
expect(actionMock).toHaveBeenCalledWith('p1', 's2')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('clicking the already-active sprint does nothing', () => {
|
|
||||||
isDemoMock.value = true
|
|
||||||
render(
|
|
||||||
<SprintSwitcher
|
|
||||||
productId="p1"
|
|
||||||
sprints={sprints}
|
|
||||||
activeSprint={sprints[0]}
|
|
||||||
buildingSprintIds={[]}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
fireEvent.click(screen.getByText('Goal 1'))
|
|
||||||
expect(pushMock).not.toHaveBeenCalled()
|
|
||||||
expect(actionMock).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows the concept-sprint on the trigger when a draft is pending (G5)', () => {
|
|
||||||
workflowMock.value = { pendingSprintDraft: { p1: { goal: 'Test goal' } } }
|
|
||||||
render(
|
|
||||||
<SprintSwitcher
|
|
||||||
productId="p1"
|
|
||||||
sprints={sprints}
|
|
||||||
activeSprint={null}
|
|
||||||
buildingSprintIds={[]}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
expect(screen.getByText('⚙ Concept — Test goal')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows no concept label on the trigger when no draft is pending', () => {
|
|
||||||
render(
|
|
||||||
<SprintSwitcher
|
|
||||||
productId="p1"
|
|
||||||
sprints={sprints}
|
|
||||||
activeSprint={sprints[0]}
|
|
||||||
buildingSprintIds={[]}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
expect(screen.queryByText(/⚙ Concept/)).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
// @vitest-environment jsdom
|
|
||||||
import '@testing-library/jest-dom'
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
import { render, screen, fireEvent } from '@testing-library/react'
|
|
||||||
|
|
||||||
vi.mock('@/components/ui/dialog', () => ({
|
|
||||||
Dialog: ({ open, children }: { open: boolean; onOpenChange?: (v: boolean) => void; children: React.ReactNode }) =>
|
|
||||||
open ? <div data-testid="dialog">{children}</div> : null,
|
|
||||||
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
||||||
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
||||||
DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
|
|
||||||
}))
|
|
||||||
vi.mock('@/components/ui/button', () => ({
|
|
||||||
Button: ({
|
|
||||||
children,
|
|
||||||
onClick,
|
|
||||||
disabled,
|
|
||||||
variant,
|
|
||||||
}: {
|
|
||||||
children?: React.ReactNode
|
|
||||||
onClick?: () => void
|
|
||||||
disabled?: boolean
|
|
||||||
variant?: string
|
|
||||||
}) => (
|
|
||||||
<button onClick={onClick} disabled={disabled} data-variant={variant}>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
vi.mock('@/components/ui/tooltip', () => ({
|
|
||||||
TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
||||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
||||||
TooltipTrigger: ({ render: r, children }: { render?: React.ReactElement; children?: React.ReactNode }) =>
|
|
||||||
r ? <>{r}</> : <>{children}</>,
|
|
||||||
TooltipContent: ({ children }: { children: React.ReactNode }) => (
|
|
||||||
<span data-testid="tooltip-content">{children}</span>
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { BatchEnqueueBlockerDialog } from '@/components/solo/batch-enqueue-blocker-dialog'
|
|
||||||
|
|
||||||
const DEFAULT_PROPS = {
|
|
||||||
open: true,
|
|
||||||
onOpenChange: vi.fn(),
|
|
||||||
prefixCount: 3,
|
|
||||||
blockerReason: 'task-review' as const,
|
|
||||||
blockerLabel: 'Story X — Task Y (in review)',
|
|
||||||
onConfirm: vi.fn(),
|
|
||||||
onCancel: vi.fn(),
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('BatchEnqueueBlockerDialog', () => {
|
|
||||||
it('renders title and blocker info for task-review', () => {
|
|
||||||
render(<BatchEnqueueBlockerDialog {...DEFAULT_PROPS} />)
|
|
||||||
|
|
||||||
expect(screen.getByRole('heading')).toHaveTextContent('Blokkade gedetecteerd')
|
|
||||||
expect(screen.getByText(/Een taak staat op 'review'/)).toBeInTheDocument()
|
|
||||||
expect(screen.getByText(/Story X — Task Y/)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders correct blocker label for pbi-blocked', () => {
|
|
||||||
render(
|
|
||||||
<BatchEnqueueBlockerDialog
|
|
||||||
{...DEFAULT_PROPS}
|
|
||||||
blockerReason="pbi-blocked"
|
|
||||||
blockerLabel="PBI Z — geblokkeerd"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(screen.getByText(/De PBI is geblokkeerd/)).toBeInTheDocument()
|
|
||||||
expect(screen.getByText(/PBI Z/)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('calls onConfirm when primary button is clicked', () => {
|
|
||||||
render(<BatchEnqueueBlockerDialog {...DEFAULT_PROPS} />)
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText(/Stuur 3 taken tot aan blokkade/))
|
|
||||||
|
|
||||||
expect(DEFAULT_PROPS.onConfirm).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('calls onCancel when cancel button is clicked', () => {
|
|
||||||
render(<BatchEnqueueBlockerDialog {...DEFAULT_PROPS} />)
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('Annuleer'))
|
|
||||||
|
|
||||||
expect(DEFAULT_PROPS.onCancel).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('disables confirm button and shows tooltip when prefixCount is 0', () => {
|
|
||||||
render(<BatchEnqueueBlockerDialog {...DEFAULT_PROPS} prefixCount={0} />)
|
|
||||||
|
|
||||||
const confirmBtn = screen.getByText(/Stuur 0/).closest('button')
|
|
||||||
expect(confirmBtn).toBeDisabled()
|
|
||||||
expect(screen.getByTestId('tooltip-content')).toHaveTextContent('Geen taken vóór blokkade')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not render when open is false', () => {
|
|
||||||
render(<BatchEnqueueBlockerDialog {...DEFAULT_PROPS} open={false} />)
|
|
||||||
|
|
||||||
expect(screen.queryByTestId('dialog')).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('uses singular taak when prefixCount is 1', () => {
|
|
||||||
render(<BatchEnqueueBlockerDialog {...DEFAULT_PROPS} prefixCount={1} />)
|
|
||||||
|
|
||||||
expect(screen.getByText(/Stuur 1 taak tot aan blokkade/)).toBeInTheDocument()
|
|
||||||
expect(screen.getByText(/1 taak vóór de blokkade/)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,207 +0,0 @@
|
||||||
// @vitest-environment jsdom
|
|
||||||
import '@testing-library/jest-dom'
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
||||||
|
|
||||||
const { mockPreviewEnqueueAllAction, mockEnqueueClaudeJobsBatchAction } = vi.hoisted(() => ({
|
|
||||||
mockPreviewEnqueueAllAction: vi.fn(),
|
|
||||||
mockEnqueueClaudeJobsBatchAction: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/actions/claude-jobs', () => ({
|
|
||||||
previewEnqueueAllAction: mockPreviewEnqueueAllAction,
|
|
||||||
enqueueClaudeJobsBatchAction: mockEnqueueClaudeJobsBatchAction,
|
|
||||||
cancelClaudeJobAction: vi.fn(),
|
|
||||||
enqueueClaudeJobAction: vi.fn(),
|
|
||||||
}))
|
|
||||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
|
||||||
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } }))
|
|
||||||
vi.mock('@dnd-kit/core', () => ({
|
|
||||||
DndContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
||||||
DragOverlay: () => null,
|
|
||||||
PointerSensor: class {},
|
|
||||||
useSensor: vi.fn(() => ({})),
|
|
||||||
useSensors: vi.fn(() => []),
|
|
||||||
closestCorners: vi.fn(),
|
|
||||||
}))
|
|
||||||
vi.mock('@/components/ui/button', () => ({
|
|
||||||
Button: ({
|
|
||||||
children,
|
|
||||||
onClick,
|
|
||||||
disabled,
|
|
||||||
}: {
|
|
||||||
children?: React.ReactNode
|
|
||||||
onClick?: () => void
|
|
||||||
disabled?: boolean
|
|
||||||
}) => (
|
|
||||||
<button onClick={onClick} disabled={disabled}>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
vi.mock('@/components/ui/dialog', () => ({
|
|
||||||
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
|
||||||
open ? <div data-testid="dialog">{children}</div> : null,
|
|
||||||
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
||||||
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
||||||
DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
|
|
||||||
}))
|
|
||||||
vi.mock('@/components/ui/tooltip', () => ({
|
|
||||||
TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
||||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
||||||
TooltipTrigger: ({ render: r, children }: { render?: React.ReactElement; children?: React.ReactNode }) =>
|
|
||||||
r ? <>{r}</> : <>{children}</>,
|
|
||||||
TooltipContent: () => null,
|
|
||||||
}))
|
|
||||||
vi.mock('@/components/shared/demo-tooltip', () => ({
|
|
||||||
DemoTooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
||||||
}))
|
|
||||||
vi.mock('@/components/split-pane/split-pane', () => ({
|
|
||||||
SplitPane: ({ panes }: { panes: React.ReactNode[] }) => <>{panes}</>,
|
|
||||||
}))
|
|
||||||
vi.mock('@/components/solo/solo-column', () => ({
|
|
||||||
SoloColumn: () => <div data-testid="solo-column" />,
|
|
||||||
}))
|
|
||||||
vi.mock('@/components/solo/solo-task-card', () => ({
|
|
||||||
SoloTaskCardOverlay: () => null,
|
|
||||||
}))
|
|
||||||
vi.mock('@/components/solo/task-detail-dialog', () => ({
|
|
||||||
TaskDetailDialog: () => null,
|
|
||||||
}))
|
|
||||||
vi.mock('@/components/solo/unassigned-stories-sheet', () => ({
|
|
||||||
UnassignedStoriesSheet: () => null,
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/task-status', () => ({
|
|
||||||
taskStatusToApi: (s: string) => s.toLowerCase(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { useSoloStore } from '@/stores/solo-store'
|
|
||||||
import { SoloBoard } from '@/components/solo/solo-board'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
const PRODUCT_ID = 'prod-1'
|
|
||||||
const TODO_TASK = {
|
|
||||||
id: 't1',
|
|
||||||
title: 'Task 1',
|
|
||||||
description: null,
|
|
||||||
implementation_plan: null,
|
|
||||||
priority: 1,
|
|
||||||
sort_order: 1,
|
|
||||||
status: 'TO_DO' as const,
|
|
||||||
verify_only: false,
|
|
||||||
verify_required: 'ALIGNED_OR_PARTIAL' as const,
|
|
||||||
story_id: 'story-1',
|
|
||||||
story_code: 'ST-1',
|
|
||||||
story_title: 'Story 1',
|
|
||||||
task_code: 'ST-1.1',
|
|
||||||
pbi_code: null,
|
|
||||||
pbi_title: null,
|
|
||||||
pbi_description: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_PROPS = {
|
|
||||||
productId: PRODUCT_ID,
|
|
||||||
sprintGoal: 'Sprint goal',
|
|
||||||
tasks: [TODO_TASK],
|
|
||||||
unassignedStories: [],
|
|
||||||
isDemo: false,
|
|
||||||
currentUserId: 'user-1',
|
|
||||||
}
|
|
||||||
|
|
||||||
const PREVIEW_NO_BLOCKER = {
|
|
||||||
tasks: [{ id: 't1', title: 'Task 1', status: 'TO_DO', story_title: 'Story 1', pbi_id: 'pbi-1', pbi_status: 'READY' }],
|
|
||||||
blockerIndex: null,
|
|
||||||
blockerReason: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
const PREVIEW_WITH_BLOCKER = {
|
|
||||||
tasks: [
|
|
||||||
{ id: 't1', title: 'Task 1', status: 'TO_DO', story_title: 'Story 1', pbi_id: 'pbi-1', pbi_status: 'READY' },
|
|
||||||
{ id: 't2', title: 'Task 2', status: 'TO_DO', story_title: 'Story 1', pbi_id: 'pbi-1', pbi_status: 'READY' },
|
|
||||||
{ id: 't3', title: 'Task Review', status: 'REVIEW', story_title: 'Story 1', pbi_id: 'pbi-1', pbi_status: 'READY' },
|
|
||||||
],
|
|
||||||
blockerIndex: 2,
|
|
||||||
blockerReason: 'task-review' as const,
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
useSoloStore.setState({ tasks: {}, claudeJobsByTaskId: {}, connectedWorkers: 1 })
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('SoloBoard — batch-enqueue flow', () => {
|
|
||||||
it('no blocker: calls enqueueClaudeJobsBatchAction with TO_DO task IDs directly', async () => {
|
|
||||||
mockPreviewEnqueueAllAction.mockResolvedValue(PREVIEW_NO_BLOCKER)
|
|
||||||
mockEnqueueClaudeJobsBatchAction.mockResolvedValue({ success: true, count: 1 })
|
|
||||||
|
|
||||||
render(<SoloBoard {...DEFAULT_PROPS} />)
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText(/Start agents/))
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockPreviewEnqueueAllAction).toHaveBeenCalledWith(PRODUCT_ID)
|
|
||||||
expect(mockEnqueueClaudeJobsBatchAction).toHaveBeenCalledWith(PRODUCT_ID, ['t1'])
|
|
||||||
expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('1 agent'))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('blocker: shows dialog when preview returns blockerIndex', async () => {
|
|
||||||
mockPreviewEnqueueAllAction.mockResolvedValue(PREVIEW_WITH_BLOCKER)
|
|
||||||
|
|
||||||
render(<SoloBoard {...DEFAULT_PROPS} />)
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText(/Start agents/))
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId('dialog')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText(/Blokkade gedetecteerd/)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
expect(mockEnqueueClaudeJobsBatchAction).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('blocker dialog confirm: enqueues prefix tasks and closes', async () => {
|
|
||||||
mockPreviewEnqueueAllAction.mockResolvedValue(PREVIEW_WITH_BLOCKER)
|
|
||||||
mockEnqueueClaudeJobsBatchAction.mockResolvedValue({ success: true, count: 2 })
|
|
||||||
|
|
||||||
render(<SoloBoard {...DEFAULT_PROPS} />)
|
|
||||||
fireEvent.click(screen.getByText(/Start agents/))
|
|
||||||
|
|
||||||
await waitFor(() => screen.getByTestId('dialog'))
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText(/Stuur 2 taken tot aan blokkade/))
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockEnqueueClaudeJobsBatchAction).toHaveBeenCalledWith(PRODUCT_ID, ['t1', 't2'])
|
|
||||||
expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('2 agents'))
|
|
||||||
expect(screen.queryByTestId('dialog')).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('blocker dialog cancel: closes dialog without enqueuing', async () => {
|
|
||||||
mockPreviewEnqueueAllAction.mockResolvedValue(PREVIEW_WITH_BLOCKER)
|
|
||||||
|
|
||||||
render(<SoloBoard {...DEFAULT_PROPS} />)
|
|
||||||
fireEvent.click(screen.getByText(/Start agents/))
|
|
||||||
|
|
||||||
await waitFor(() => screen.getByTestId('dialog'))
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('Annuleer'))
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByTestId('dialog')).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
expect(mockEnqueueClaudeJobsBatchAction).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('preview error: shows toast without opening dialog', async () => {
|
|
||||||
mockPreviewEnqueueAllAction.mockResolvedValue({ error: 'Geen toegang' })
|
|
||||||
|
|
||||||
render(<SoloBoard {...DEFAULT_PROPS} />)
|
|
||||||
fireEvent.click(screen.getByText(/Start agents/))
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(toast.error).toHaveBeenCalledWith('Geen toegang')
|
|
||||||
})
|
|
||||||
expect(screen.queryByTestId('dialog')).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
// @vitest-environment jsdom
|
|
||||||
import '@testing-library/jest-dom'
|
|
||||||
import { describe, it, expect, vi } from 'vitest'
|
|
||||||
import { render, screen } from '@testing-library/react'
|
|
||||||
import type { SoloTask } from '@/components/solo/solo-board'
|
|
||||||
|
|
||||||
vi.mock('@/components/ui/tooltip', () => ({
|
|
||||||
TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
||||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
||||||
TooltipTrigger: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
|
||||||
TooltipContent: ({ children }: { children: React.ReactNode }) => <span data-testid="tooltip-content">{children}</span>,
|
|
||||||
}))
|
|
||||||
vi.mock('@dnd-kit/core', () => ({
|
|
||||||
useDraggable: () => ({ attributes: {}, listeners: {}, setNodeRef: vi.fn(), transform: null, isDragging: false }),
|
|
||||||
}))
|
|
||||||
vi.mock('@/stores/solo-store', () => ({
|
|
||||||
useSoloStore: () => null,
|
|
||||||
}))
|
|
||||||
vi.mock('@/components/shared/code-badge', () => ({
|
|
||||||
CodeBadge: ({ code }: { code: string }) => <span data-testid="code-badge">{code}</span>,
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { SoloTaskCard, SoloTaskCardOverlay } from '@/components/solo/solo-task-card'
|
|
||||||
|
|
||||||
function makeSoloTask(overrides: Partial<SoloTask> = {}): SoloTask {
|
|
||||||
return {
|
|
||||||
id: 'task-1',
|
|
||||||
title: 'Taak titel',
|
|
||||||
description: 'Omschrijving van de taak die langer is dan tachtig tekens voor test',
|
|
||||||
implementation_plan: null,
|
|
||||||
priority: 2,
|
|
||||||
sort_order: 0,
|
|
||||||
status: 'TO_DO',
|
|
||||||
verify_only: false,
|
|
||||||
verify_required: 'ALIGNED',
|
|
||||||
story_id: 'story-1',
|
|
||||||
story_code: 'ST-1',
|
|
||||||
story_title: 'Story titel',
|
|
||||||
task_code: 'T-1',
|
|
||||||
pbi_code: 'PBI-1',
|
|
||||||
pbi_title: 'PBI titel',
|
|
||||||
pbi_description: 'PBI omschrijving',
|
|
||||||
...overrides,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('SoloTaskCard', () => {
|
|
||||||
it('toont taaknaam, task_code, pbi_code, story_code, story_title', () => {
|
|
||||||
render(<SoloTaskCard task={makeSoloTask()} isDemo={false} onClick={vi.fn()} />)
|
|
||||||
expect(screen.getAllByText('Taak titel').length).toBeGreaterThan(0)
|
|
||||||
expect(screen.getAllByText('T-1').length).toBeGreaterThan(0)
|
|
||||||
expect(screen.getAllByText('PBI-1').length).toBeGreaterThan(0)
|
|
||||||
expect(screen.getByText('ST-1')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('Story titel')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('verbergt pbi_code badge als pbi_code null is', () => {
|
|
||||||
render(<SoloTaskCard task={makeSoloTask({ pbi_code: null })} isDemo={false} onClick={vi.fn()} />)
|
|
||||||
const badges = screen.queryAllByTestId('code-badge')
|
|
||||||
const codes = badges.map(b => b.textContent)
|
|
||||||
expect(codes).not.toContain('PBI-1')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('verbergt description als description null is', () => {
|
|
||||||
const task = makeSoloTask({ description: null })
|
|
||||||
render(<SoloTaskCard task={task} isDemo={false} onClick={vi.fn()} />)
|
|
||||||
expect(screen.queryByText(/Omschrijving/)).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('toont description als tekst', () => {
|
|
||||||
render(<SoloTaskCard task={makeSoloTask()} isDemo={false} onClick={vi.fn()} />)
|
|
||||||
expect(screen.getAllByText('Omschrijving van de taak die langer is dan tachtig tekens voor test').length).toBeGreaterThan(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('SoloTaskCardOverlay', () => {
|
|
||||||
it('toont taaknaam en codes zonder tooltip-wrappers', () => {
|
|
||||||
render(<SoloTaskCardOverlay task={makeSoloTask()} />)
|
|
||||||
expect(screen.getByText('Taak titel')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('T-1')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('PBI-1')).toBeInTheDocument()
|
|
||||||
expect(screen.queryAllByTestId('tooltip-content')).toHaveLength(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,232 +0,0 @@
|
||||||
// @vitest-environment jsdom
|
|
||||||
import '@testing-library/jest-dom'
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
||||||
import type { SoloTask } from '@/components/solo/solo-board'
|
|
||||||
|
|
||||||
// Mock heavy UI primitives to avoid portal/JSDOM issues
|
|
||||||
vi.mock('@/components/ui/dialog', () => ({
|
|
||||||
Dialog: ({ open, children }: { open: boolean; onOpenChange?: () => void; children: React.ReactNode }) =>
|
|
||||||
open ? <div data-testid="dialog">{children}</div> : null,
|
|
||||||
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
||||||
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
||||||
DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
|
|
||||||
}))
|
|
||||||
vi.mock('@/components/ui/tooltip', () => ({
|
|
||||||
TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
||||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
||||||
TooltipTrigger: ({ render: r, children }: { render?: React.ReactElement; children?: React.ReactNode }) =>
|
|
||||||
r ? <>{r}</> : <>{children}</>,
|
|
||||||
TooltipContent: ({ children }: { children: React.ReactNode }) => <span data-testid="tooltip-content">{children}</span>,
|
|
||||||
}))
|
|
||||||
vi.mock('@/components/ui/textarea', () => ({
|
|
||||||
Textarea: (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => <textarea {...props} />,
|
|
||||||
}))
|
|
||||||
vi.mock('@/components/ui/badge', () => ({
|
|
||||||
Badge: ({ children, className }: { children: React.ReactNode; className?: string }) =>
|
|
||||||
<span className={className}>{children}</span>,
|
|
||||||
}))
|
|
||||||
vi.mock('@/components/ui/button', () => ({
|
|
||||||
Button: ({ children, onClick, disabled }: { children?: React.ReactNode; onClick?: () => void; disabled?: boolean }) =>
|
|
||||||
<button onClick={onClick} disabled={disabled}>{children}</button>,
|
|
||||||
}))
|
|
||||||
vi.mock('@/components/markdown', () => ({
|
|
||||||
Markdown: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
||||||
}))
|
|
||||||
vi.mock('@/components/shared/demo-tooltip', () => ({
|
|
||||||
DemoTooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
||||||
}))
|
|
||||||
vi.mock('next/link', () => ({
|
|
||||||
default: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a>,
|
|
||||||
}))
|
|
||||||
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
|
|
||||||
vi.mock('@/actions/claude-jobs', () => ({
|
|
||||||
enqueueClaudeJobAction: vi.fn(),
|
|
||||||
cancelClaudeJobAction: vi.fn(),
|
|
||||||
}))
|
|
||||||
vi.mock('@/lib/job-status-url', () => ({
|
|
||||||
getBranchUrl: (repoUrl: string, branch: string) => `${repoUrl}/tree/${branch}`,
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { useSoloStore } from '@/stores/solo-store'
|
|
||||||
import { TaskDetailDialog } from '@/components/solo/task-detail-dialog'
|
|
||||||
|
|
||||||
const baseTask: SoloTask = {
|
|
||||||
id: 'task-1',
|
|
||||||
title: 'Test taak',
|
|
||||||
description: null,
|
|
||||||
implementation_plan: null,
|
|
||||||
priority: 2,
|
|
||||||
sort_order: 1,
|
|
||||||
status: 'TO_DO',
|
|
||||||
verify_only: false,
|
|
||||||
verify_required: 'ALIGNED_OR_PARTIAL',
|
|
||||||
story_id: 'story-1',
|
|
||||||
story_code: 'ST-100',
|
|
||||||
story_title: 'Test Story',
|
|
||||||
task_code: 'ST-100.1',
|
|
||||||
pbi_code: null,
|
|
||||||
pbi_title: null,
|
|
||||||
pbi_description: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_PROPS = {
|
|
||||||
productId: 'prod-1',
|
|
||||||
isDemo: false,
|
|
||||||
repoUrl: 'https://github.com/user/repo',
|
|
||||||
onClose: vi.fn(),
|
|
||||||
}
|
|
||||||
|
|
||||||
function jobDone(verify_result?: string) {
|
|
||||||
return {
|
|
||||||
'task-1': {
|
|
||||||
job_id: 'j1',
|
|
||||||
task_id: 'task-1',
|
|
||||||
status: 'done' as const,
|
|
||||||
branch: 'feat/job-abc',
|
|
||||||
pushed_at: '2026-01-01T00:00:00Z',
|
|
||||||
...(verify_result && { verify_result: verify_result as import('@/stores/solo-store').VerifyResultApi }),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('TaskDetailDialog — verify_result display', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
useSoloStore.setState({
|
|
||||||
tasks: { 'task-1': baseTask },
|
|
||||||
claudeJobsByTaskId: {},
|
|
||||||
connectedWorkers: 0,
|
|
||||||
})
|
|
||||||
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows no verify label when job has no verify_result', () => {
|
|
||||||
useSoloStore.setState({ claudeJobsByTaskId: jobDone() })
|
|
||||||
render(<TaskDetailDialog {...DEFAULT_PROPS} task={baseTask} />)
|
|
||||||
expect(screen.queryByText(/Aligned|Gedeeltelijk|Divergent|Geen wijzigingen/)).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows Aligned label with text-status-done class for verify_result=aligned', () => {
|
|
||||||
useSoloStore.setState({ claudeJobsByTaskId: jobDone('aligned') })
|
|
||||||
render(<TaskDetailDialog {...DEFAULT_PROPS} task={baseTask} />)
|
|
||||||
const label = screen.getByText(/Aligned/)
|
|
||||||
expect(label).toHaveClass('text-status-done')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows Gedeeltelijk label with text-warning class for verify_result=partial', () => {
|
|
||||||
useSoloStore.setState({ claudeJobsByTaskId: jobDone('partial') })
|
|
||||||
render(<TaskDetailDialog {...DEFAULT_PROPS} task={baseTask} />)
|
|
||||||
const label = screen.getByText(/Gedeeltelijk/)
|
|
||||||
expect(label).toHaveClass('text-warning')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows Divergent label with text-error class for verify_result=divergent', () => {
|
|
||||||
useSoloStore.setState({ claudeJobsByTaskId: jobDone('divergent') })
|
|
||||||
render(<TaskDetailDialog {...DEFAULT_PROPS} task={baseTask} />)
|
|
||||||
const label = screen.getByText(/Divergent/)
|
|
||||||
expect(label).toHaveClass('text-error')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows Geen wijzigingen label with text-muted-foreground class for verify_result=empty', () => {
|
|
||||||
useSoloStore.setState({ claudeJobsByTaskId: jobDone('empty') })
|
|
||||||
render(<TaskDetailDialog {...DEFAULT_PROPS} task={baseTask} />)
|
|
||||||
const label = screen.getByText(/Geen wijzigingen/)
|
|
||||||
expect(label).toHaveClass('text-muted-foreground')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('TaskDetailDialog — PR link display', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
useSoloStore.setState({
|
|
||||||
tasks: { 'task-1': baseTask },
|
|
||||||
claudeJobsByTaskId: {},
|
|
||||||
connectedWorkers: 0,
|
|
||||||
})
|
|
||||||
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows "Open PR" link when pr_url is set', () => {
|
|
||||||
useSoloStore.setState({
|
|
||||||
claudeJobsByTaskId: {
|
|
||||||
'task-1': {
|
|
||||||
job_id: 'j1',
|
|
||||||
task_id: 'task-1',
|
|
||||||
status: 'done',
|
|
||||||
branch: 'feat/job-abc',
|
|
||||||
pushed_at: '2026-01-01T00:00:00Z',
|
|
||||||
pr_url: 'https://github.com/org/repo/pull/42',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
render(<TaskDetailDialog {...DEFAULT_PROPS} task={baseTask} />)
|
|
||||||
const link = screen.getByRole('link', { name: /Open PR/i })
|
|
||||||
expect(link).toHaveAttribute('href', 'https://github.com/org/repo/pull/42')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows "Open op GitHub" branch link when pushed_at is set but no pr_url', () => {
|
|
||||||
useSoloStore.setState({
|
|
||||||
claudeJobsByTaskId: {
|
|
||||||
'task-1': {
|
|
||||||
job_id: 'j1',
|
|
||||||
task_id: 'task-1',
|
|
||||||
status: 'done',
|
|
||||||
branch: 'feat/job-abc',
|
|
||||||
pushed_at: '2026-01-01T00:00:00Z',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
render(<TaskDetailDialog {...DEFAULT_PROPS} task={baseTask} />)
|
|
||||||
expect(screen.queryByText(/Open PR/)).toBeNull()
|
|
||||||
const link = screen.getByRole('link', { name: /Open op GitHub/i })
|
|
||||||
expect(link).toHaveAttribute('href', expect.stringContaining('feat/job-abc'))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('TaskDetailDialog — verify_only checkbox', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
useSoloStore.setState({
|
|
||||||
tasks: { 'task-1': baseTask },
|
|
||||||
claudeJobsByTaskId: {},
|
|
||||||
connectedWorkers: 0,
|
|
||||||
})
|
|
||||||
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders verify_only checkbox unchecked when task.verify_only is false', () => {
|
|
||||||
render(<TaskDetailDialog {...DEFAULT_PROPS} task={{ ...baseTask, verify_only: false }} />)
|
|
||||||
const checkbox = screen.getByRole('checkbox')
|
|
||||||
expect(checkbox).toHaveAttribute('aria-checked', 'false')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders verify_only checkbox checked when task.verify_only is true', () => {
|
|
||||||
render(<TaskDetailDialog {...DEFAULT_PROPS} task={{ ...baseTask, verify_only: true }} />)
|
|
||||||
const checkbox = screen.getByRole('checkbox')
|
|
||||||
expect(checkbox).toHaveAttribute('aria-checked', 'true')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('calls PATCH with verify_only toggled on click', async () => {
|
|
||||||
render(<TaskDetailDialog {...DEFAULT_PROPS} task={{ ...baseTask, verify_only: false }} />)
|
|
||||||
fireEvent.click(screen.getByRole('checkbox'))
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(global.fetch).toHaveBeenCalledWith(
|
|
||||||
'/api/tasks/task-1',
|
|
||||||
expect.objectContaining({
|
|
||||||
method: 'PATCH',
|
|
||||||
body: JSON.stringify({ verify_only: true }),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reverts optimistic toggle when PATCH fails', async () => {
|
|
||||||
global.fetch = vi.fn().mockResolvedValue({ ok: false })
|
|
||||||
render(<TaskDetailDialog {...DEFAULT_PROPS} task={{ ...baseTask, verify_only: false }} />)
|
|
||||||
fireEvent.click(screen.getByRole('checkbox'))
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'false')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,234 +0,0 @@
|
||||||
// @vitest-environment jsdom
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
||||||
import { render, screen, fireEvent } from '@testing-library/react'
|
|
||||||
|
|
||||||
vi.mock('@/actions/user-settings', () => ({
|
|
||||||
updateUserSettingsAction: vi.fn().mockResolvedValue({ success: true, settings: {} }),
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { SplitPane } from '@/components/split-pane/split-pane'
|
|
||||||
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
|
||||||
|
|
||||||
function seedPositions(key: string, positions: number[]) {
|
|
||||||
useUserSettingsStore.setState((s) => {
|
|
||||||
s.entities.settings = {
|
|
||||||
layout: {
|
|
||||||
splitPanePositions: { [key]: positions },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetStore() {
|
|
||||||
useUserSettingsStore.setState((s) => {
|
|
||||||
s.entities.settings = {}
|
|
||||||
s.context.hydrated = false
|
|
||||||
s.context.isDemo = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('SplitPane', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
resetStore()
|
|
||||||
// Default: desktop viewport
|
|
||||||
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1440 })
|
|
||||||
window.dispatchEvent(new Event('resize'))
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders 2 panes', () => {
|
|
||||||
render(
|
|
||||||
<SplitPane
|
|
||||||
panes={[<div key="a">Pane A</div>, <div key="b">Pane B</div>]}
|
|
||||||
defaultSplit={[30, 70]}
|
|
||||||
cookieKey="test-2pane"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
expect(screen.getByText('Pane A')).toBeTruthy()
|
|
||||||
expect(screen.getByText('Pane B')).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders 3 panes with 2 dividers', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<SplitPane
|
|
||||||
panes={[
|
|
||||||
<div key="a">Left</div>,
|
|
||||||
<div key="b">Middle</div>,
|
|
||||||
<div key="c">Right</div>,
|
|
||||||
]}
|
|
||||||
defaultSplit={[28, 35, 37]}
|
|
||||||
cookieKey="test-3pane"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
expect(screen.getByText('Left')).toBeTruthy()
|
|
||||||
expect(screen.getByText('Middle')).toBeTruthy()
|
|
||||||
expect(screen.getByText('Right')).toBeTruthy()
|
|
||||||
// 2 dividers: cursor-col-resize elements
|
|
||||||
const dividers = container.querySelectorAll('.cursor-col-resize')
|
|
||||||
expect(dividers).toHaveLength(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('restores splits from user-settings store on mount', () => {
|
|
||||||
seedPositions('test-restore', [40, 60])
|
|
||||||
|
|
||||||
const { container } = render(
|
|
||||||
<SplitPane
|
|
||||||
panes={[<div key="a">A</div>, <div key="b">B</div>]}
|
|
||||||
defaultSplit={[20, 80]}
|
|
||||||
cookieKey="test-restore"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
// Left pane should have width 40%, not the default 20%
|
|
||||||
const paneDiv = container.querySelector<HTMLElement>('[style*="40%"]')
|
|
||||||
expect(paneDiv).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('falls back to defaultSplit when persisted positions are invalid', () => {
|
|
||||||
// Wrong number of values for a 2-pane layout
|
|
||||||
seedPositions('test-invalid', [10, 30, 60])
|
|
||||||
|
|
||||||
const { container } = render(
|
|
||||||
<SplitPane
|
|
||||||
panes={[<div key="a">A</div>, <div key="b">B</div>]}
|
|
||||||
defaultSplit={[25, 75]}
|
|
||||||
cookieKey="test-invalid"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
const paneDiv = container.querySelector<HTMLElement>('[style*="25%"]')
|
|
||||||
expect(paneDiv).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders tabs on mobile viewport', () => {
|
|
||||||
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 768 })
|
|
||||||
window.dispatchEvent(new Event('resize'))
|
|
||||||
|
|
||||||
render(
|
|
||||||
<SplitPane
|
|
||||||
panes={[<div key="a">Content A</div>, <div key="b">Content B</div>]}
|
|
||||||
defaultSplit={[50, 50]}
|
|
||||||
cookieKey="test-mobile"
|
|
||||||
tabLabels={['Tab A', 'Tab B']}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(screen.getByText('Tab A')).toBeTruthy()
|
|
||||||
expect(screen.getByText('Tab B')).toBeTruthy()
|
|
||||||
// Only first tab content visible by default
|
|
||||||
expect(screen.getByText('Content A')).toBeTruthy()
|
|
||||||
expect(screen.queryByText('Content B')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('switches tab content on mobile', () => {
|
|
||||||
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 })
|
|
||||||
window.dispatchEvent(new Event('resize'))
|
|
||||||
|
|
||||||
render(
|
|
||||||
<SplitPane
|
|
||||||
panes={[<div key="a">Content A</div>, <div key="b">Content B</div>]}
|
|
||||||
defaultSplit={[50, 50]}
|
|
||||||
cookieKey="test-mobile-switch"
|
|
||||||
tabLabels={['Tab A', 'Tab B']}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
// Click second tab
|
|
||||||
fireEvent.click(screen.getByText('Tab B'))
|
|
||||||
expect(screen.queryByText('Content A')).toBeNull()
|
|
||||||
expect(screen.getByText('Content B')).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('back button not visible on tab 0 in mobile', () => {
|
|
||||||
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 })
|
|
||||||
window.dispatchEvent(new Event('resize'))
|
|
||||||
|
|
||||||
render(
|
|
||||||
<SplitPane
|
|
||||||
panes={[<div key="a">A</div>, <div key="b">B</div>, <div key="c">C</div>]}
|
|
||||||
defaultSplit={[33, 33, 34]}
|
|
||||||
cookieKey="test-back-hidden"
|
|
||||||
tabLabels={['T1', 'T2', 'T3']}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
// On tab 0, no back button
|
|
||||||
expect(screen.queryByLabelText('Terug')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('back button visible on tab > 0 and navigates back', () => {
|
|
||||||
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 })
|
|
||||||
window.dispatchEvent(new Event('resize'))
|
|
||||||
|
|
||||||
render(
|
|
||||||
<SplitPane
|
|
||||||
panes={[<div key="a">A</div>, <div key="b">B</div>, <div key="c">C</div>]}
|
|
||||||
defaultSplit={[33, 33, 34]}
|
|
||||||
cookieKey="test-back-nav"
|
|
||||||
tabLabels={['T1', 'T2', 'T3']}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
// Switch to tab 2
|
|
||||||
fireEvent.click(screen.getByText('T3'))
|
|
||||||
expect(screen.getByText('C')).toBeTruthy()
|
|
||||||
expect(screen.getByLabelText('Terug')).toBeTruthy()
|
|
||||||
|
|
||||||
// Click back → tab 1
|
|
||||||
fireEvent.click(screen.getByLabelText('Terug'))
|
|
||||||
expect(screen.getByText('B')).toBeTruthy()
|
|
||||||
|
|
||||||
// Click back again → tab 0, no back button
|
|
||||||
fireEvent.click(screen.getByLabelText('Terug'))
|
|
||||||
expect(screen.getByText('A')).toBeTruthy()
|
|
||||||
expect(screen.queryByLabelText('Terug')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('controlled activeTab prop switches the active pane', () => {
|
|
||||||
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 })
|
|
||||||
window.dispatchEvent(new Event('resize'))
|
|
||||||
|
|
||||||
const { rerender } = render(
|
|
||||||
<SplitPane
|
|
||||||
panes={[<div key="a">A</div>, <div key="b">B</div>, <div key="c">C</div>]}
|
|
||||||
defaultSplit={[33, 33, 34]}
|
|
||||||
cookieKey="test-controlled"
|
|
||||||
tabLabels={['T1', 'T2', 'T3']}
|
|
||||||
activeTab={0}
|
|
||||||
onActiveTabChange={vi.fn()}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
expect(screen.getByText('A')).toBeTruthy()
|
|
||||||
|
|
||||||
rerender(
|
|
||||||
<SplitPane
|
|
||||||
panes={[<div key="a">A</div>, <div key="b">B</div>, <div key="c">C</div>]}
|
|
||||||
defaultSplit={[33, 33, 34]}
|
|
||||||
cookieKey="test-controlled"
|
|
||||||
tabLabels={['T1', 'T2', 'T3']}
|
|
||||||
activeTab={2}
|
|
||||||
onActiveTabChange={vi.fn()}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
expect(screen.getByText('C')).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not render dividers on mobile', () => {
|
|
||||||
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 })
|
|
||||||
window.dispatchEvent(new Event('resize'))
|
|
||||||
|
|
||||||
const { container } = render(
|
|
||||||
<SplitPane
|
|
||||||
panes={[<div key="a">A</div>, <div key="b">B</div>]}
|
|
||||||
defaultSplit={[50, 50]}
|
|
||||||
cookieKey="test-no-dividers"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
const dividers = container.querySelectorAll('.cursor-col-resize')
|
|
||||||
expect(dividers).toHaveLength(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
// @vitest-environment jsdom
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
||||||
|
|
||||||
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
|
||||||
vi.mock('@/actions/tasks', () => ({
|
|
||||||
saveTask: vi.fn(),
|
|
||||||
deleteTask: vi.fn(),
|
|
||||||
}))
|
|
||||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
|
||||||
|
|
||||||
import { SprintTaskDialogMount } from '@/components/sprint/sprint-task-dialog-mount'
|
|
||||||
import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store'
|
|
||||||
import type { SprintWorkspaceTaskDetail } from '@/stores/sprint-workspace/types'
|
|
||||||
|
|
||||||
const TASK_DETAIL: SprintWorkspaceTaskDetail = {
|
|
||||||
id: 't1',
|
|
||||||
code: 'T-1',
|
|
||||||
title: 'Mijn taak',
|
|
||||||
description: 'Beschrijving',
|
|
||||||
priority: 2,
|
|
||||||
sort_order: 1,
|
|
||||||
status: 'in_progress',
|
|
||||||
story_id: 'story-1',
|
|
||||||
sprint_id: 'sprint-1',
|
|
||||||
created_at: new Date('2026-01-15'),
|
|
||||||
_detail: true,
|
|
||||||
implementation_plan: 'Stap 1\nStap 2',
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetStore() {
|
|
||||||
useSprintWorkspaceStore.setState((s) => {
|
|
||||||
s.context.activeProduct = null
|
|
||||||
s.context.activeSprintId = null
|
|
||||||
s.context.activeStoryId = null
|
|
||||||
s.context.activeTaskId = null
|
|
||||||
s.entities.sprintsById = {}
|
|
||||||
s.entities.storiesById = {}
|
|
||||||
s.entities.tasksById = {}
|
|
||||||
s.relations.sprintIdsByProduct = {}
|
|
||||||
s.relations.storyIdsBySprint = {}
|
|
||||||
s.relations.taskIdsByStory = {}
|
|
||||||
s.loading.loadedProductSprintsIds = {}
|
|
||||||
s.loading.loadingProductId = null
|
|
||||||
s.loading.loadedSprintIds = {}
|
|
||||||
s.loading.loadingSprintId = null
|
|
||||||
s.loading.loadedStoryIds = {}
|
|
||||||
s.loading.loadedTaskIds = {}
|
|
||||||
s.loading.activeRequestId = null
|
|
||||||
s.pendingMutations = {}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
resetStore()
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('SprintTaskDialogMount', () => {
|
|
||||||
it('rendert niets wanneer er geen active task is', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<SprintTaskDialogMount productId="p1" isDemo={false} />,
|
|
||||||
)
|
|
||||||
expect(container.textContent).toBe('')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rendert niets wanneer active task geen _detail heeft', () => {
|
|
||||||
useSprintWorkspaceStore.setState((s) => {
|
|
||||||
s.entities.tasksById['t1'] = {
|
|
||||||
id: 't1',
|
|
||||||
code: 'T-1',
|
|
||||||
title: 'Mijn taak',
|
|
||||||
description: null,
|
|
||||||
priority: 2,
|
|
||||||
sort_order: 1,
|
|
||||||
status: 'todo',
|
|
||||||
story_id: 'story-1',
|
|
||||||
sprint_id: 'sprint-1',
|
|
||||||
created_at: new Date(),
|
|
||||||
}
|
|
||||||
s.context.activeTaskId = 't1'
|
|
||||||
})
|
|
||||||
|
|
||||||
const { container } = render(
|
|
||||||
<SprintTaskDialogMount productId="p1" isDemo={false} />,
|
|
||||||
)
|
|
||||||
expect(container.textContent).toBe('')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rendert TaskDialog met titel "Taak bewerken" wanneer detail aanwezig is', () => {
|
|
||||||
useSprintWorkspaceStore.setState((s) => {
|
|
||||||
s.entities.tasksById['t1'] = TASK_DETAIL
|
|
||||||
s.context.activeTaskId = 't1'
|
|
||||||
})
|
|
||||||
|
|
||||||
render(<SprintTaskDialogMount productId="p1" isDemo={false} />)
|
|
||||||
|
|
||||||
expect(screen.getByText('Taak bewerken')).toBeTruthy()
|
|
||||||
expect((screen.getByLabelText(/Titel/) as HTMLInputElement).value).toBe('Mijn taak')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('clear activeTaskId wanneer Annuleren wordt geklikt', async () => {
|
|
||||||
useSprintWorkspaceStore.setState((s) => {
|
|
||||||
s.entities.tasksById['t1'] = TASK_DETAIL
|
|
||||||
s.context.activeTaskId = 't1'
|
|
||||||
})
|
|
||||||
|
|
||||||
render(<SprintTaskDialogMount productId="p1" isDemo={false} />)
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Annuleren' }))
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(useSprintWorkspaceStore.getState().context.activeTaskId).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
// @vitest-environment jsdom
|
|
||||||
import { describe, it, expect, vi } from 'vitest'
|
|
||||||
import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut'
|
|
||||||
|
|
||||||
function makeEvent(opts: Partial<KeyboardEvent>) {
|
|
||||||
return {
|
|
||||||
metaKey: false,
|
|
||||||
ctrlKey: false,
|
|
||||||
key: '',
|
|
||||||
preventDefault: vi.fn(),
|
|
||||||
...opts,
|
|
||||||
} as unknown as React.KeyboardEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('useDialogSubmitShortcut', () => {
|
|
||||||
it('triggert submit op Cmd+Enter', () => {
|
|
||||||
const submit = vi.fn()
|
|
||||||
const handler = useDialogSubmitShortcut(submit)
|
|
||||||
const e = makeEvent({ metaKey: true, key: 'Enter' })
|
|
||||||
|
|
||||||
handler(e)
|
|
||||||
|
|
||||||
expect(submit).toHaveBeenCalledTimes(1)
|
|
||||||
expect(e.preventDefault).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('triggert submit op Ctrl+Enter', () => {
|
|
||||||
const submit = vi.fn()
|
|
||||||
const handler = useDialogSubmitShortcut(submit)
|
|
||||||
const e = makeEvent({ ctrlKey: true, key: 'Enter' })
|
|
||||||
|
|
||||||
handler(e)
|
|
||||||
|
|
||||||
expect(submit).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('triggert NIET op Enter zonder modifier', () => {
|
|
||||||
const submit = vi.fn()
|
|
||||||
const handler = useDialogSubmitShortcut(submit)
|
|
||||||
const e = makeEvent({ key: 'Enter' })
|
|
||||||
|
|
||||||
handler(e)
|
|
||||||
|
|
||||||
expect(submit).not.toHaveBeenCalled()
|
|
||||||
expect(e.preventDefault).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('triggert NIET op Cmd+andere toets', () => {
|
|
||||||
const submit = vi.fn()
|
|
||||||
const handler = useDialogSubmitShortcut(submit)
|
|
||||||
const e = makeEvent({ metaKey: true, key: 'a' })
|
|
||||||
|
|
||||||
handler(e)
|
|
||||||
|
|
||||||
expect(submit).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
// @vitest-environment jsdom
|
|
||||||
import { describe, it, expect, vi } from 'vitest'
|
|
||||||
import { renderHook, act } from '@testing-library/react'
|
|
||||||
import { useDirtyCloseGuard } from '@/components/shared/use-dirty-close-guard'
|
|
||||||
|
|
||||||
describe('useDirtyCloseGuard', () => {
|
|
||||||
it('sluit direct als form niet dirty is', () => {
|
|
||||||
const onClose = vi.fn()
|
|
||||||
const { result } = renderHook(() => useDirtyCloseGuard(false, onClose))
|
|
||||||
|
|
||||||
act(() => result.current.attemptClose())
|
|
||||||
|
|
||||||
expect(onClose).toHaveBeenCalledTimes(1)
|
|
||||||
expect(result.current.confirmOpen).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('opent confirm als form dirty is', () => {
|
|
||||||
const onClose = vi.fn()
|
|
||||||
const { result } = renderHook(() => useDirtyCloseGuard(true, onClose))
|
|
||||||
|
|
||||||
act(() => result.current.attemptClose())
|
|
||||||
|
|
||||||
expect(onClose).not.toHaveBeenCalled()
|
|
||||||
expect(result.current.confirmOpen).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('confirmDiscard sluit confirm en roept onClose', () => {
|
|
||||||
const onClose = vi.fn()
|
|
||||||
const { result } = renderHook(() => useDirtyCloseGuard(true, onClose))
|
|
||||||
|
|
||||||
act(() => result.current.attemptClose())
|
|
||||||
expect(result.current.confirmOpen).toBe(true)
|
|
||||||
|
|
||||||
act(() => result.current.confirmDiscard())
|
|
||||||
|
|
||||||
expect(onClose).toHaveBeenCalledTimes(1)
|
|
||||||
expect(result.current.confirmOpen).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('setConfirmOpen(false) annuleert zonder onClose te roepen', () => {
|
|
||||||
const onClose = vi.fn()
|
|
||||||
const { result } = renderHook(() => useDirtyCloseGuard(true, onClose))
|
|
||||||
|
|
||||||
act(() => result.current.attemptClose())
|
|
||||||
act(() => result.current.setConfirmOpen(false))
|
|
||||||
|
|
||||||
expect(onClose).not.toHaveBeenCalled()
|
|
||||||
expect(result.current.confirmOpen).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,147 +0,0 @@
|
||||||
// @vitest-environment jsdom
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
import { renderHook, act } from '@testing-library/react'
|
|
||||||
import { useJobsStore } from '@/stores/jobs-store'
|
|
||||||
import useJobsRealtime from '@/hooks/use-jobs-realtime'
|
|
||||||
|
|
||||||
type Listener = (event: { data: string }) => void
|
|
||||||
|
|
||||||
class MockEventSource {
|
|
||||||
static instance: MockEventSource | null = null
|
|
||||||
private listeners: Record<string, Listener[]> = {}
|
|
||||||
onerror: (() => void) | null = null
|
|
||||||
|
|
||||||
constructor(_url: string) {
|
|
||||||
MockEventSource.instance = this
|
|
||||||
}
|
|
||||||
|
|
||||||
addEventListener(type: string, listener: Listener) {
|
|
||||||
if (!this.listeners[type]) this.listeners[type] = []
|
|
||||||
this.listeners[type].push(listener)
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(type: string, data: unknown) {
|
|
||||||
for (const l of this.listeners[type] ?? []) {
|
|
||||||
l({ data: JSON.stringify(data) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fullJob = {
|
|
||||||
id: 'job-unknown-1',
|
|
||||||
kind: 'TASK_IMPLEMENTATION',
|
|
||||||
status: 'RUNNING',
|
|
||||||
taskCode: 'T-1',
|
|
||||||
taskTitle: 'Test',
|
|
||||||
ideaCode: null,
|
|
||||||
ideaTitle: null,
|
|
||||||
sprintGoal: null,
|
|
||||||
sprintCode: null,
|
|
||||||
productName: 'Scrum4Me',
|
|
||||||
productCode: null,
|
|
||||||
storyCode: null,
|
|
||||||
pbiCode: null,
|
|
||||||
modelId: null,
|
|
||||||
inputTokens: null,
|
|
||||||
outputTokens: null,
|
|
||||||
cacheReadTokens: null,
|
|
||||||
cacheWriteTokens: null,
|
|
||||||
costUsd: null,
|
|
||||||
branch: null,
|
|
||||||
prUrl: null,
|
|
||||||
error: null,
|
|
||||||
summary: null,
|
|
||||||
description: null,
|
|
||||||
verifyResult: null,
|
|
||||||
startedAt: null,
|
|
||||||
finishedAt: null,
|
|
||||||
createdAt: new Date('2026-01-01'),
|
|
||||||
sprintRunId: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.stubGlobal('EventSource', MockEventSource)
|
|
||||||
MockEventSource.instance = null
|
|
||||||
|
|
||||||
// Lege store
|
|
||||||
useJobsStore.setState({ activeJobs: [], doneJobs: [], selectedJobId: null })
|
|
||||||
|
|
||||||
// fetch resolveert naar de volledige job
|
|
||||||
vi.stubGlobal(
|
|
||||||
'fetch',
|
|
||||||
vi.fn().mockImplementation(async () => ({
|
|
||||||
ok: true,
|
|
||||||
json: async () => fullJob,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals()
|
|
||||||
vi.restoreAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('useJobsRealtime: fetch-on-unknown', () => {
|
|
||||||
it('haalt onbekende job op via REST bij message-event', async () => {
|
|
||||||
renderHook(() => useJobsRealtime())
|
|
||||||
const es = MockEventSource.instance!
|
|
||||||
|
|
||||||
// Dispatch twee events met hetzelfde onbekende job_id gelijktijdig
|
|
||||||
act(() => {
|
|
||||||
es.dispatch('message', { job_id: 'job-unknown-1', status: 'RUNNING' })
|
|
||||||
es.dispatch('message', { job_id: 'job-unknown-1', status: 'RUNNING' })
|
|
||||||
})
|
|
||||||
|
|
||||||
// Wacht op alle microtasks / fetch-promises
|
|
||||||
await act(async () => {
|
|
||||||
await Promise.resolve()
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledTimes(1)
|
|
||||||
expect(fetch).toHaveBeenCalledWith('/api/jobs/job-unknown-1')
|
|
||||||
|
|
||||||
const { activeJobs } = useJobsStore.getState()
|
|
||||||
expect(activeJobs.some(j => j.id === 'job-unknown-1')).toBe(true)
|
|
||||||
expect(activeJobs.find(j => j.id === 'job-unknown-1')?.taskTitle).toBe('Test')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('gebruikt partial-upsert voor bekende jobs bij message-event', async () => {
|
|
||||||
// Zet een bekende job in de store
|
|
||||||
useJobsStore.setState({
|
|
||||||
activeJobs: [{ ...fullJob, id: 'job-known-1', status: 'QUEUED' } as never],
|
|
||||||
doneJobs: [],
|
|
||||||
selectedJobId: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
renderHook(() => useJobsRealtime())
|
|
||||||
const es = MockEventSource.instance!
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
es.dispatch('message', { job_id: 'job-known-1', status: 'RUNNING', branch: 'feat/x' })
|
|
||||||
})
|
|
||||||
|
|
||||||
await act(async () => { await Promise.resolve() })
|
|
||||||
|
|
||||||
expect(fetch).not.toHaveBeenCalled()
|
|
||||||
const { activeJobs } = useJobsStore.getState()
|
|
||||||
expect(activeJobs.find(j => j.id === 'job-known-1')?.status).toBe('RUNNING')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('haalt onbekende job op via REST bij jobs_initial-event', async () => {
|
|
||||||
renderHook(() => useJobsRealtime())
|
|
||||||
const es = MockEventSource.instance!
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
es.dispatch('jobs_initial', [{ job_id: 'job-unknown-1', status: 'RUNNING' }])
|
|
||||||
})
|
|
||||||
|
|
||||||
await act(async () => { await Promise.resolve() })
|
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledTimes(1)
|
|
||||||
expect(fetch).toHaveBeenCalledWith('/api/jobs/job-unknown-1')
|
|
||||||
const { activeJobs } = useJobsStore.getState()
|
|
||||||
expect(activeJobs.some(j => j.id === 'job-unknown-1')).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,190 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: {
|
|
||||||
sprint: { findFirst: vi.fn() },
|
|
||||||
user: {
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
update: vi.fn().mockResolvedValue({}),
|
|
||||||
},
|
|
||||||
$executeRaw: vi.fn().mockResolvedValue(1),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
import type { UserSettings } from '@/lib/user-settings'
|
|
||||||
import {
|
|
||||||
clearActiveSprintInSettings,
|
|
||||||
readStoredActiveSprintState,
|
|
||||||
resolveActiveSprint,
|
|
||||||
} from '@/lib/active-sprint'
|
|
||||||
|
|
||||||
const mockPrisma = prisma as unknown as {
|
|
||||||
sprint: { findFirst: ReturnType<typeof vi.fn> }
|
|
||||||
user: {
|
|
||||||
findUnique: ReturnType<typeof vi.fn>
|
|
||||||
update: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
$executeRaw: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
|
|
||||||
function withSettings(settings: UserSettings) {
|
|
||||||
mockPrisma.user.findUnique.mockResolvedValueOnce({ settings })
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('readStoredActiveSprintState', () => {
|
|
||||||
it('returns unset when activeSprints map is absent', () => {
|
|
||||||
expect(readStoredActiveSprintState({}, 'p1')).toEqual({ kind: 'unset' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns unset when productId key is absent', () => {
|
|
||||||
const settings: UserSettings = {
|
|
||||||
layout: { activeSprints: { p2: 'sprint-2' } },
|
|
||||||
}
|
|
||||||
expect(readStoredActiveSprintState(settings, 'p1')).toEqual({
|
|
||||||
kind: 'unset',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns cleared when key is present with null value', () => {
|
|
||||||
const settings: UserSettings = {
|
|
||||||
layout: { activeSprints: { p1: null } },
|
|
||||||
}
|
|
||||||
expect(readStoredActiveSprintState(settings, 'p1')).toEqual({
|
|
||||||
kind: 'cleared',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns set when key is present with string value', () => {
|
|
||||||
const settings: UserSettings = {
|
|
||||||
layout: { activeSprints: { p1: 'sprint-1' } },
|
|
||||||
}
|
|
||||||
expect(readStoredActiveSprintState(settings, 'p1')).toEqual({
|
|
||||||
kind: 'set',
|
|
||||||
sprintId: 'sprint-1',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('resolveActiveSprint', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns null without fallback when key is explicitly null (cleared)', async () => {
|
|
||||||
withSettings({ layout: { activeSprints: { p1: null } } })
|
|
||||||
|
|
||||||
const result = await resolveActiveSprint('p1', 'user-1')
|
|
||||||
|
|
||||||
expect(result).toBeNull()
|
|
||||||
expect(mockPrisma.sprint.findFirst).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns the stored sprint when key is set and sprint exists', async () => {
|
|
||||||
withSettings({ layout: { activeSprints: { p1: 'sprint-1' } } })
|
|
||||||
mockPrisma.sprint.findFirst.mockResolvedValueOnce({
|
|
||||||
id: 'sprint-1',
|
|
||||||
code: 'SP-1',
|
|
||||||
status: 'OPEN',
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await resolveActiveSprint('p1', 'user-1')
|
|
||||||
|
|
||||||
expect(result).toEqual({ id: 'sprint-1', code: 'SP-1', status: 'OPEN' })
|
|
||||||
expect(mockPrisma.sprint.findFirst).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('falls back when stored sprint is not found in DB', async () => {
|
|
||||||
withSettings({ layout: { activeSprints: { p1: 'stale-id' } } })
|
|
||||||
mockPrisma.sprint.findFirst
|
|
||||||
.mockResolvedValueOnce(null) // stored lookup misses
|
|
||||||
.mockResolvedValueOnce({ id: 'sprint-open', code: 'SP-O', status: 'OPEN' })
|
|
||||||
|
|
||||||
const result = await resolveActiveSprint('p1', 'user-1')
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
id: 'sprint-open',
|
|
||||||
code: 'SP-O',
|
|
||||||
status: 'OPEN',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('falls back to first OPEN sprint when key is absent', async () => {
|
|
||||||
withSettings({})
|
|
||||||
mockPrisma.sprint.findFirst.mockResolvedValueOnce({
|
|
||||||
id: 'sprint-open',
|
|
||||||
code: 'SP-O',
|
|
||||||
status: 'OPEN',
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await resolveActiveSprint('p1', 'user-1')
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
id: 'sprint-open',
|
|
||||||
code: 'SP-O',
|
|
||||||
status: 'OPEN',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('falls back to recent CLOSED sprint when no OPEN exists', async () => {
|
|
||||||
withSettings({})
|
|
||||||
mockPrisma.sprint.findFirst
|
|
||||||
.mockResolvedValueOnce(null) // no OPEN
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
id: 'sprint-closed',
|
|
||||||
code: 'SP-C',
|
|
||||||
status: 'CLOSED',
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await resolveActiveSprint('p1', 'user-1')
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
id: 'sprint-closed',
|
|
||||||
code: 'SP-C',
|
|
||||||
status: 'CLOSED',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns null when key absent and no sprints exist', async () => {
|
|
||||||
withSettings({})
|
|
||||||
mockPrisma.sprint.findFirst.mockResolvedValue(null)
|
|
||||||
|
|
||||||
const result = await resolveActiveSprint('p1', 'user-1')
|
|
||||||
|
|
||||||
expect(result).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('clearActiveSprintInSettings', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('writes null instead of deleting the key', async () => {
|
|
||||||
withSettings({
|
|
||||||
layout: { activeSprints: { p1: 'sprint-1', p2: 'sprint-2' } },
|
|
||||||
})
|
|
||||||
|
|
||||||
await clearActiveSprintInSettings('user-1', 'p1')
|
|
||||||
|
|
||||||
expect(mockPrisma.user.update).toHaveBeenCalledTimes(1)
|
|
||||||
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
|
|
||||||
data: { settings: UserSettings }
|
|
||||||
}
|
|
||||||
expect(updateArg.data.settings.layout?.activeSprints).toEqual({
|
|
||||||
p1: null,
|
|
||||||
p2: 'sprint-2',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('adds the key with null when previously unset', async () => {
|
|
||||||
withSettings({})
|
|
||||||
|
|
||||||
await clearActiveSprintInSettings('user-1', 'p1')
|
|
||||||
|
|
||||||
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
|
|
||||||
data: { settings: UserSettings }
|
|
||||||
}
|
|
||||||
expect(updateArg.data.settings.layout?.activeSprints).toEqual({ p1: null })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
||||||
|
|
||||||
const getSessionMock = vi.fn()
|
|
||||||
const isPairedSessionExpiredMock = vi.fn()
|
|
||||||
const redirectMock = vi.fn(() => { throw new Error('REDIRECT_CALLED') })
|
|
||||||
const prismaUserRoleFindFirstMock = vi.fn()
|
|
||||||
|
|
||||||
vi.mock('@/lib/auth', () => ({ getSession: getSessionMock }))
|
|
||||||
vi.mock('@/lib/auth/pairing', () => ({ isPairedSessionExpired: isPairedSessionExpiredMock }))
|
|
||||||
vi.mock('next/navigation', () => ({ redirect: redirectMock }))
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: { userRole: { findFirst: prismaUserRoleFindFirstMock } },
|
|
||||||
}))
|
|
||||||
|
|
||||||
describe('requireSession', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
getSessionMock.mockReset()
|
|
||||||
isPairedSessionExpiredMock.mockReset()
|
|
||||||
redirectMock.mockClear()
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.resetModules()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('redirect /login als userId ontbreekt', async () => {
|
|
||||||
getSessionMock.mockResolvedValue({ userId: undefined, destroy: vi.fn() })
|
|
||||||
isPairedSessionExpiredMock.mockReturnValue(false)
|
|
||||||
const { requireSession } = await import('@/lib/auth-guard')
|
|
||||||
await expect(requireSession()).rejects.toThrow('REDIRECT_CALLED')
|
|
||||||
expect(redirectMock).toHaveBeenCalledWith('/login')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('vernietigt + redirect /login als paired-sessie verlopen is', async () => {
|
|
||||||
const destroy = vi.fn().mockResolvedValue(undefined)
|
|
||||||
getSessionMock.mockResolvedValue({ userId: 'u1', destroy })
|
|
||||||
isPairedSessionExpiredMock.mockReturnValue(true)
|
|
||||||
const { requireSession } = await import('@/lib/auth-guard')
|
|
||||||
await expect(requireSession()).rejects.toThrow('REDIRECT_CALLED')
|
|
||||||
expect(destroy).toHaveBeenCalled()
|
|
||||||
expect(redirectMock).toHaveBeenCalledWith('/login')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('geeft sessie terug als alles ok', async () => {
|
|
||||||
const sess = { userId: 'u1', destroy: vi.fn() }
|
|
||||||
getSessionMock.mockResolvedValue(sess)
|
|
||||||
isPairedSessionExpiredMock.mockReturnValue(false)
|
|
||||||
const { requireSession } = await import('@/lib/auth-guard')
|
|
||||||
const result = await requireSession()
|
|
||||||
expect(result).toBe(sess)
|
|
||||||
expect(redirectMock).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
|
|
||||||
import {
|
|
||||||
generateMobileSecret,
|
|
||||||
generateDesktopToken,
|
|
||||||
hashToken,
|
|
||||||
verifyToken,
|
|
||||||
isPairedSessionExpired,
|
|
||||||
} from '@/lib/auth/pairing'
|
|
||||||
|
|
||||||
describe('lib/auth/pairing', () => {
|
|
||||||
describe('generateMobileSecret / generateDesktopToken', () => {
|
|
||||||
it('produceert 43-karakter base64url (32 bytes)', () => {
|
|
||||||
// 32 bytes → ceil(32/3) * 4 = 44 chars zonder padding → 43 chars in base64url (geen '=')
|
|
||||||
expect(generateMobileSecret()).toMatch(/^[A-Za-z0-9_-]{43}$/)
|
|
||||||
expect(generateDesktopToken()).toMatch(/^[A-Za-z0-9_-]{43}$/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('twee opeenvolgende calls leveren verschillende waardes', () => {
|
|
||||||
const a = generateMobileSecret()
|
|
||||||
const b = generateMobileSecret()
|
|
||||||
expect(a).not.toBe(b)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('mobile en desktop generators delen geen state — paren zijn onafhankelijk', () => {
|
|
||||||
const m1 = generateMobileSecret()
|
|
||||||
const d1 = generateDesktopToken()
|
|
||||||
const m2 = generateMobileSecret()
|
|
||||||
const d2 = generateDesktopToken()
|
|
||||||
expect(new Set([m1, d1, m2, d2]).size).toBe(4)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('hashToken', () => {
|
|
||||||
it('is deterministisch — zelfde input → zelfde hash', () => {
|
|
||||||
const t = 'voorbeeld-token'
|
|
||||||
expect(hashToken(t)).toBe(hashToken(t))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('produceert 64-karakter hex (sha256)', () => {
|
|
||||||
expect(hashToken('x')).toMatch(/^[a-f0-9]{64}$/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('verschillende inputs → verschillende hashes', () => {
|
|
||||||
expect(hashToken('a')).not.toBe(hashToken('b'))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('verifyToken', () => {
|
|
||||||
it('true voor geldig (token, hashOf(token))', () => {
|
|
||||||
const token = generateMobileSecret()
|
|
||||||
expect(verifyToken(token, hashToken(token))).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('false voor onjuist token', () => {
|
|
||||||
const realHash = hashToken('echt-token')
|
|
||||||
expect(verifyToken('verkeerd-token', realHash)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('false bij hash met afwijkende lengte', () => {
|
|
||||||
expect(verifyToken('iets', 'abc')).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('false bij lege hash', () => {
|
|
||||||
expect(verifyToken('iets', '')).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('isPairedSessionExpired', () => {
|
|
||||||
it('false als paired niet gezet is (reguliere wachtwoord-sessie)', () => {
|
|
||||||
expect(isPairedSessionExpired({})).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('false als pairedExpiresAt ontbreekt', () => {
|
|
||||||
expect(isPairedSessionExpired({ paired: true })).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('false als de paired-sessie nog niet vervallen is', () => {
|
|
||||||
const future = Date.now() + 60_000
|
|
||||||
expect(isPairedSessionExpired({ paired: true, pairedExpiresAt: future })).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('true als paired én vervaltijd in het verleden ligt', () => {
|
|
||||||
const past = Date.now() - 1_000
|
|
||||||
expect(isPairedSessionExpired({ paired: true, pairedExpiresAt: past })).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import {
|
|
||||||
STATUS_COLORS,
|
|
||||||
PRIORITY_COLORS,
|
|
||||||
VERIFY_COLORS,
|
|
||||||
JOB_STATUS_COLORS,
|
|
||||||
SERIES_COLORS,
|
|
||||||
} from '@/lib/chart-colors'
|
|
||||||
|
|
||||||
describe('chart-colors', () => {
|
|
||||||
it('STATUS_COLORS has all TaskStatus keys and non-empty values', () => {
|
|
||||||
const keys: (keyof typeof STATUS_COLORS)[] = ['TO_DO', 'IN_PROGRESS', 'REVIEW', 'DONE']
|
|
||||||
for (const key of keys) {
|
|
||||||
expect(STATUS_COLORS[key]).toBeTruthy()
|
|
||||||
expect(typeof STATUS_COLORS[key]).toBe('string')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('PRIORITY_COLORS has keys 1-4 with non-empty values', () => {
|
|
||||||
const keys = [1, 2, 3, 4] as const
|
|
||||||
for (const key of keys) {
|
|
||||||
expect(PRIORITY_COLORS[key]).toBeTruthy()
|
|
||||||
expect(typeof PRIORITY_COLORS[key]).toBe('string')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('VERIFY_COLORS has all VerifyResult keys and non-empty values', () => {
|
|
||||||
const keys: (keyof typeof VERIFY_COLORS)[] = ['ALIGNED', 'PARTIAL', 'EMPTY', 'DIVERGENT']
|
|
||||||
for (const key of keys) {
|
|
||||||
expect(VERIFY_COLORS[key]).toBeTruthy()
|
|
||||||
expect(typeof VERIFY_COLORS[key]).toBe('string')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('JOB_STATUS_COLORS has all ClaudeJobStatus keys and non-empty values', () => {
|
|
||||||
const keys: (keyof typeof JOB_STATUS_COLORS)[] = [
|
|
||||||
'queued', 'claimed', 'running', 'done', 'failed', 'cancelled', 'skipped',
|
|
||||||
]
|
|
||||||
for (const key of keys) {
|
|
||||||
expect(JOB_STATUS_COLORS[key]).toBeTruthy()
|
|
||||||
expect(typeof JOB_STATUS_COLORS[key]).toBe('string')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('SERIES_COLORS has 5 non-empty entries', () => {
|
|
||||||
expect(SERIES_COLORS).toHaveLength(5)
|
|
||||||
for (const color of SERIES_COLORS) {
|
|
||||||
expect(color).toBeTruthy()
|
|
||||||
expect(typeof color).toBe('string')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
|
|
||||||
import { parseCodeNumber } from '@/lib/code'
|
|
||||||
|
|
||||||
describe('parseCodeNumber', () => {
|
|
||||||
it('parses a standard story code', () => {
|
|
||||||
expect(parseCodeNumber('ST-001')).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('parses a task code', () => {
|
|
||||||
expect(parseCodeNumber('T-42')).toBe(42)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('parses a large number', () => {
|
|
||||||
expect(parseCodeNumber('ST-1000')).toBe(1000)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns MAX_SAFE_INTEGER for a code with no trailing digits', () => {
|
|
||||||
expect(parseCodeNumber('FOO')).toBe(Number.MAX_SAFE_INTEGER)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns MAX_SAFE_INTEGER for an empty string', () => {
|
|
||||||
expect(parseCodeNumber('')).toBe(Number.MAX_SAFE_INTEGER)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import { describe, it, expect, vi } from 'vitest'
|
|
||||||
|
|
||||||
import { debugProps } from '@/lib/debug'
|
|
||||||
|
|
||||||
describe('debugProps', () => {
|
|
||||||
it('returns data-debug-id attr in dev mode', () => {
|
|
||||||
const result = debugProps('sprint-board', 'SprintBoard', 'components/sprint/sprint-board.tsx')
|
|
||||||
expect(result).toEqual({
|
|
||||||
'data-debug-id': 'sprint-board',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns empty object in production mode', () => {
|
|
||||||
const original = process.env.NODE_ENV
|
|
||||||
try {
|
|
||||||
vi.stubEnv('NODE_ENV', 'production')
|
|
||||||
const result = debugProps('sprint-board', 'SprintBoard', 'components/sprint/sprint-board.tsx')
|
|
||||||
expect(result).toEqual({})
|
|
||||||
} finally {
|
|
||||||
vi.stubEnv('NODE_ENV', original ?? 'test')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
|
|
||||||
import { formatIdeaCode } from '@/lib/idea-code'
|
|
||||||
|
|
||||||
describe('formatIdeaCode', () => {
|
|
||||||
it('pads to 3 digits', () => {
|
|
||||||
expect(formatIdeaCode(1)).toBe('IDEA-001')
|
|
||||||
expect(formatIdeaCode(42)).toBe('IDEA-042')
|
|
||||||
expect(formatIdeaCode(999)).toBe('IDEA-999')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not truncate beyond pad-width', () => {
|
|
||||||
expect(formatIdeaCode(1000)).toBe('IDEA-1000')
|
|
||||||
expect(formatIdeaCode(99999)).toBe('IDEA-99999')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Integration-style concurrency-test op nextIdeaCode is in
|
|
||||||
// __tests__/integration/ tests die de echte DB raken (zie M12 verificatie-stap).
|
|
||||||
// Hier alleen de pure formatter; de increment-logica leunt op Prisma's
|
|
||||||
// row-lock in $transaction die we per-database vertrouwen.
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
|
|
||||||
import { parsePlanMd } from '@/lib/idea-plan-parser'
|
|
||||||
|
|
||||||
const VALID = `---
|
|
||||||
pbi:
|
|
||||||
title: Test PBI
|
|
||||||
priority: 2
|
|
||||||
stories:
|
|
||||||
- title: Eerste flow
|
|
||||||
priority: 2
|
|
||||||
tasks:
|
|
||||||
- title: Setup
|
|
||||||
priority: 2
|
|
||||||
implementation_plan: |
|
|
||||||
1. Doe X
|
|
||||||
2. Doe Y
|
|
||||||
---
|
|
||||||
|
|
||||||
# Overwegingen
|
|
||||||
|
|
||||||
Dit is de body, niet geparsed.
|
|
||||||
`
|
|
||||||
|
|
||||||
describe('parsePlanMd', () => {
|
|
||||||
it('parses a valid plan', () => {
|
|
||||||
const r = parsePlanMd(VALID)
|
|
||||||
expect(r.ok).toBe(true)
|
|
||||||
if (r.ok) {
|
|
||||||
expect(r.plan.pbi.title).toBe('Test PBI')
|
|
||||||
expect(r.plan.stories).toHaveLength(1)
|
|
||||||
expect(r.plan.stories[0].tasks).toHaveLength(1)
|
|
||||||
expect(r.plan.stories[0].tasks[0].implementation_plan).toContain('Doe X')
|
|
||||||
expect(r.body).toContain('# Overwegingen')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects when frontmatter is missing', () => {
|
|
||||||
const r = parsePlanMd('# Just markdown\n\nNo frontmatter here.')
|
|
||||||
expect(r.ok).toBe(false)
|
|
||||||
if (!r.ok) {
|
|
||||||
expect(r.errors[0].line).toBe(1)
|
|
||||||
expect(r.errors[0].message).toMatch(/frontmatter/i)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reports yaml syntax error with line info', () => {
|
|
||||||
const broken = `---
|
|
||||||
pbi:
|
|
||||||
title: Test
|
|
||||||
priority: [unclosed
|
|
||||||
stories:
|
|
||||||
- foo
|
|
||||||
---
|
|
||||||
|
|
||||||
body
|
|
||||||
`
|
|
||||||
const r = parsePlanMd(broken)
|
|
||||||
expect(r.ok).toBe(false)
|
|
||||||
if (!r.ok) {
|
|
||||||
expect(r.errors[0].message.length).toBeGreaterThan(0)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('hints when markdown sneaks into frontmatter', () => {
|
|
||||||
// "1. **...**: [unclosed" triggers a YAMLParseError at the markdown line
|
|
||||||
// (plain-list-with-bold parses as valid YAML without an unclosed flow)
|
|
||||||
const broken = `---
|
|
||||||
pbi:
|
|
||||||
title: Test
|
|
||||||
priority: 2
|
|
||||||
stories:
|
|
||||||
1. **Toggle zichtbaar in productie**: [unclosed
|
|
||||||
---
|
|
||||||
|
|
||||||
body
|
|
||||||
`
|
|
||||||
const r = parsePlanMd(broken)
|
|
||||||
expect(r.ok).toBe(false)
|
|
||||||
if (!r.ok) {
|
|
||||||
expect(r.errors[0].hint).toMatch(/markdown/i)
|
|
||||||
expect(r.errors[0].line).toBeGreaterThan(1)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('omits hint for non-markdown yaml errors', () => {
|
|
||||||
const broken = `---
|
|
||||||
pbi:
|
|
||||||
title: Test
|
|
||||||
priority: [unclosed
|
|
||||||
stories:
|
|
||||||
- foo
|
|
||||||
---
|
|
||||||
`
|
|
||||||
const r = parsePlanMd(broken)
|
|
||||||
expect(r.ok).toBe(false)
|
|
||||||
if (!r.ok) expect(r.errors[0].hint).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reports schema-validation error when pbi-section missing', () => {
|
|
||||||
const noPbi = `---
|
|
||||||
stories:
|
|
||||||
- title: x
|
|
||||||
priority: 2
|
|
||||||
tasks:
|
|
||||||
- title: y
|
|
||||||
priority: 2
|
|
||||||
---
|
|
||||||
|
|
||||||
body
|
|
||||||
`
|
|
||||||
const r = parsePlanMd(noPbi)
|
|
||||||
expect(r.ok).toBe(false)
|
|
||||||
if (!r.ok) {
|
|
||||||
expect(r.errors.some((e) => e.message.includes('pbi'))).toBe(true)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects empty stories array', () => {
|
|
||||||
const noStories = `---
|
|
||||||
pbi:
|
|
||||||
title: x
|
|
||||||
priority: 2
|
|
||||||
stories: []
|
|
||||||
---
|
|
||||||
|
|
||||||
body
|
|
||||||
`
|
|
||||||
const r = parsePlanMd(noStories)
|
|
||||||
expect(r.ok).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles CRLF line endings', () => {
|
|
||||||
const crlf = VALID.replace(/\n/g, '\r\n')
|
|
||||||
const r = parsePlanMd(crlf)
|
|
||||||
expect(r.ok).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,148 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
|
|
||||||
import {
|
|
||||||
ideaCreateSchema,
|
|
||||||
ideaUpdateSchema,
|
|
||||||
ideaPlanMdFrontmatterSchema,
|
|
||||||
} from '@/lib/schemas/idea'
|
|
||||||
|
|
||||||
describe('ideaCreateSchema', () => {
|
|
||||||
it('accepts minimal valid input', () => {
|
|
||||||
const r = ideaCreateSchema.safeParse({ title: 'Plant-watering reminder' })
|
|
||||||
expect(r.success).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('trims and enforces non-empty title', () => {
|
|
||||||
const r = ideaCreateSchema.safeParse({ title: ' ' })
|
|
||||||
expect(r.success).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects oversized title and description', () => {
|
|
||||||
expect(ideaCreateSchema.safeParse({ title: 'x'.repeat(201) }).success).toBe(false)
|
|
||||||
expect(
|
|
||||||
ideaCreateSchema.safeParse({ title: 'ok', description: 'x'.repeat(4001) }).success,
|
|
||||||
).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('accepts cuid-like product_id', () => {
|
|
||||||
const r = ideaCreateSchema.safeParse({
|
|
||||||
title: 'Idee',
|
|
||||||
product_id: 'cmohrysyj0000rd17clnjy4tc',
|
|
||||||
})
|
|
||||||
expect(r.success).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects non-cuid product_id', () => {
|
|
||||||
const r = ideaCreateSchema.safeParse({ title: 'Idee', product_id: 'not-a-cuid' })
|
|
||||||
expect(r.success).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('ideaUpdateSchema', () => {
|
|
||||||
it('allows empty object (no-op update)', () => {
|
|
||||||
expect(ideaUpdateSchema.safeParse({}).success).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('allows partial title update', () => {
|
|
||||||
expect(ideaUpdateSchema.safeParse({ title: 'Updated' }).success).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('ideaPlanMdFrontmatterSchema', () => {
|
|
||||||
const validPlan = {
|
|
||||||
pbi: { title: 'Test PBI', priority: 2 },
|
|
||||||
stories: [
|
|
||||||
{
|
|
||||||
title: 'Eerste flow',
|
|
||||||
priority: 2,
|
|
||||||
tasks: [
|
|
||||||
{ title: 'Setup', priority: 2, implementation_plan: '1. Doe X' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
it('accepts a minimal valid plan', () => {
|
|
||||||
expect(ideaPlanMdFrontmatterSchema.safeParse(validPlan).success).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('requires at least one story', () => {
|
|
||||||
const r = ideaPlanMdFrontmatterSchema.safeParse({ ...validPlan, stories: [] })
|
|
||||||
expect(r.success).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('requires at least one task per story', () => {
|
|
||||||
const r = ideaPlanMdFrontmatterSchema.safeParse({
|
|
||||||
...validPlan,
|
|
||||||
stories: [{ ...validPlan.stories[0], tasks: [] }],
|
|
||||||
})
|
|
||||||
expect(r.success).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('validates priority bounds 1-4', () => {
|
|
||||||
expect(
|
|
||||||
ideaPlanMdFrontmatterSchema.safeParse({
|
|
||||||
...validPlan,
|
|
||||||
pbi: { ...validPlan.pbi, priority: 5 },
|
|
||||||
}).success,
|
|
||||||
).toBe(false)
|
|
||||||
expect(
|
|
||||||
ideaPlanMdFrontmatterSchema.safeParse({
|
|
||||||
...validPlan,
|
|
||||||
pbi: { ...validPlan.pbi, priority: 0 },
|
|
||||||
}).success,
|
|
||||||
).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('accepts optional verify_required + verify_only', () => {
|
|
||||||
const r = ideaPlanMdFrontmatterSchema.safeParse({
|
|
||||||
...validPlan,
|
|
||||||
stories: [
|
|
||||||
{
|
|
||||||
...validPlan.stories[0],
|
|
||||||
tasks: [
|
|
||||||
{
|
|
||||||
title: 'Verify-only task',
|
|
||||||
priority: 2,
|
|
||||||
verify_required: 'ALIGNED_OR_PARTIAL',
|
|
||||||
verify_only: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
expect(r.success).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects invalid verify_required enum', () => {
|
|
||||||
const r = ideaPlanMdFrontmatterSchema.safeParse({
|
|
||||||
...validPlan,
|
|
||||||
stories: [
|
|
||||||
{
|
|
||||||
...validPlan.stories[0],
|
|
||||||
tasks: [
|
|
||||||
{ title: 't', priority: 2, verify_required: 'INVALID' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
expect(r.success).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('accepts plan with task.priority omitted (inherits story-priority via materialize)', () => {
|
|
||||||
const r = ideaPlanMdFrontmatterSchema.safeParse({
|
|
||||||
...validPlan,
|
|
||||||
stories: [
|
|
||||||
{
|
|
||||||
title: 'Story zonder task-priorities',
|
|
||||||
priority: 2,
|
|
||||||
tasks: [
|
|
||||||
{ title: 'Taak 1' }, // geen priority — moet geaccepteerd
|
|
||||||
{ title: 'Taak 2', verify_required: 'ALIGNED' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
expect(r.success).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
|
|
||||||
import {
|
|
||||||
ideaStatusToApi,
|
|
||||||
ideaStatusFromApi,
|
|
||||||
canTransition,
|
|
||||||
isIdeaEditable,
|
|
||||||
isGrillMdEditable,
|
|
||||||
isPlanMdEditable,
|
|
||||||
IDEA_STATUS_API_VALUES,
|
|
||||||
} from '@/lib/idea-status'
|
|
||||||
|
|
||||||
describe('idea-status mappers', () => {
|
|
||||||
it('round-trips every API value', () => {
|
|
||||||
for (const api of IDEA_STATUS_API_VALUES) {
|
|
||||||
const db = ideaStatusFromApi(api)
|
|
||||||
expect(db).not.toBeNull()
|
|
||||||
expect(ideaStatusToApi(db!)).toBe(api)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns null for invalid input', () => {
|
|
||||||
expect(ideaStatusFromApi('NOT_A_STATUS')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('is case-insensitive on the API side', () => {
|
|
||||||
expect(ideaStatusFromApi('PLAN_READY')).toBe('PLAN_READY')
|
|
||||||
expect(ideaStatusFromApi('Plan_Ready')).toBe('PLAN_READY')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('canTransition', () => {
|
|
||||||
it('allows valid forward transitions', () => {
|
|
||||||
expect(canTransition('DRAFT', 'GRILLING')).toBe(true)
|
|
||||||
expect(canTransition('GRILLING', 'GRILLED')).toBe(true)
|
|
||||||
expect(canTransition('GRILLED', 'PLANNING')).toBe(true)
|
|
||||||
expect(canTransition('PLANNING', 'PLAN_READY')).toBe(true)
|
|
||||||
expect(canTransition('PLAN_READY', 'PLANNED')).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('allows re-grill from GRILLED and PLAN_READY-ish states', () => {
|
|
||||||
expect(canTransition('GRILLED', 'GRILLING')).toBe(true)
|
|
||||||
expect(canTransition('PLAN_FAILED', 'PLANNING')).toBe(true)
|
|
||||||
expect(canTransition('PLAN_READY', 'GRILLING')).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('allows fail-side transitions', () => {
|
|
||||||
expect(canTransition('GRILLING', 'GRILL_FAILED')).toBe(true)
|
|
||||||
expect(canTransition('PLANNING', 'PLAN_FAILED')).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('allows recovery from failed states', () => {
|
|
||||||
expect(canTransition('GRILL_FAILED', 'GRILLING')).toBe(true)
|
|
||||||
expect(canTransition('PLAN_FAILED', 'GRILLED')).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('allows PLANNED → PLAN_READY (relink) and PLANNED → GRILLING (re-grill)', () => {
|
|
||||||
expect(canTransition('PLANNED', 'PLAN_READY')).toBe(true)
|
|
||||||
expect(canTransition('PLANNED', 'GRILLING')).toBe(true)
|
|
||||||
expect(canTransition('PLANNED', 'DRAFT')).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('canTransition to GRILLING from all statuses that allow re-grill', () => {
|
|
||||||
// GRILL_TRIGGERABLE_FROM in actions/ideas.ts — alle statussen die re-grill ondersteunen.
|
|
||||||
const regrill = ['DRAFT', 'GRILLED', 'GRILL_FAILED', 'PLAN_READY', 'PLANNED'] as const
|
|
||||||
for (const status of regrill) {
|
|
||||||
expect(canTransition(status, 'GRILLING')).toBe(true)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects invalid jumps', () => {
|
|
||||||
expect(canTransition('DRAFT', 'PLANNED')).toBe(false)
|
|
||||||
expect(canTransition('DRAFT', 'PLAN_READY')).toBe(false)
|
|
||||||
expect(canTransition('GRILLING', 'PLANNED')).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('isIdeaEditable', () => {
|
|
||||||
it('allows edit in non-running, non-PLANNED states', () => {
|
|
||||||
expect(isIdeaEditable('DRAFT')).toBe(true)
|
|
||||||
expect(isIdeaEditable('GRILLED')).toBe(true)
|
|
||||||
expect(isIdeaEditable('GRILL_FAILED')).toBe(true)
|
|
||||||
expect(isIdeaEditable('PLAN_FAILED')).toBe(true)
|
|
||||||
expect(isIdeaEditable('PLAN_READY')).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('blocks edit while a job is running or after PLANNED', () => {
|
|
||||||
expect(isIdeaEditable('GRILLING')).toBe(false)
|
|
||||||
expect(isIdeaEditable('PLANNING')).toBe(false)
|
|
||||||
expect(isIdeaEditable('PLANNED')).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('isGrillMdEditable / isPlanMdEditable', () => {
|
|
||||||
it('grill_md only editable in GRILLED or PLAN_READY', () => {
|
|
||||||
expect(isGrillMdEditable('GRILLED')).toBe(true)
|
|
||||||
expect(isGrillMdEditable('PLAN_READY')).toBe(true)
|
|
||||||
expect(isGrillMdEditable('DRAFT')).toBe(false)
|
|
||||||
expect(isGrillMdEditable('PLANNED')).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('plan_md only editable in PLAN_READY', () => {
|
|
||||||
expect(isPlanMdEditable('PLAN_READY')).toBe(true)
|
|
||||||
expect(isPlanMdEditable('GRILLED')).toBe(false)
|
|
||||||
expect(isPlanMdEditable('PLAN_FAILED')).toBe(false)
|
|
||||||
expect(isPlanMdEditable('PLANNED')).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
const { mockQueryRaw } = vi.hoisted(() => ({ mockQueryRaw: vi.fn() }))
|
|
||||||
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: { $queryRaw: mockQueryRaw },
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { getJobsPerDay } from '@/lib/insights/agent-throughput'
|
|
||||||
|
|
||||||
// Build a date string for N days ago (UTC)
|
|
||||||
function daysAgo(n: number): Date {
|
|
||||||
const d = new Date()
|
|
||||||
d.setUTCDate(d.getUTCDate() - n)
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
function toUTCDate(d: Date): Date {
|
|
||||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()))
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('getJobsPerDay', () => {
|
|
||||||
it('returns a 14-day array zero-filled for missing days', async () => {
|
|
||||||
// Only 3 days have data; the rest should be 0
|
|
||||||
const day0 = toUTCDate(daysAgo(0))
|
|
||||||
const day3 = toUTCDate(daysAgo(3))
|
|
||||||
const day7 = toUTCDate(daysAgo(7))
|
|
||||||
|
|
||||||
const dayRows = [
|
|
||||||
{ day: day0, status: 'done', count: BigInt(2) },
|
|
||||||
{ day: day3, status: 'failed', count: BigInt(1) },
|
|
||||||
{ day: day7, status: 'done', count: BigInt(5) },
|
|
||||||
]
|
|
||||||
|
|
||||||
const kpiRows = [
|
|
||||||
{ today_count: BigInt(2), done_7d: BigInt(7), terminal_7d: BigInt(10), avg_seconds: 120 },
|
|
||||||
]
|
|
||||||
|
|
||||||
mockQueryRaw.mockResolvedValueOnce(dayRows).mockResolvedValueOnce(kpiRows)
|
|
||||||
|
|
||||||
const result = await getJobsPerDay('user-1')
|
|
||||||
|
|
||||||
expect(result.perDay).toHaveLength(14)
|
|
||||||
|
|
||||||
// All days should have zero counts except the three we seeded
|
|
||||||
const nonZero = result.perDay.filter(
|
|
||||||
d => d.done + d.failed + d.queued + d.claimed + d.running + d.cancelled + d.skipped > 0,
|
|
||||||
)
|
|
||||||
expect(nonZero).toHaveLength(3)
|
|
||||||
|
|
||||||
// Today's done count should be 2
|
|
||||||
const today = result.perDay[result.perDay.length - 1]
|
|
||||||
expect(today.done).toBe(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('calculates KPIs correctly', async () => {
|
|
||||||
mockQueryRaw.mockResolvedValueOnce([]).mockResolvedValueOnce([
|
|
||||||
{ today_count: BigInt(3), done_7d: BigInt(7), terminal_7d: BigInt(10), avg_seconds: 90 },
|
|
||||||
])
|
|
||||||
|
|
||||||
const result = await getJobsPerDay('user-1')
|
|
||||||
|
|
||||||
expect(result.kpi.todayCount).toBe(3)
|
|
||||||
expect(result.kpi.successRate7d).toBe(0.7)
|
|
||||||
expect(result.kpi.avgDurationSeconds7d).toBe(90)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns zero successRate and null avgDuration when no terminal jobs', async () => {
|
|
||||||
mockQueryRaw.mockResolvedValueOnce([]).mockResolvedValueOnce([
|
|
||||||
{ today_count: BigInt(0), done_7d: BigInt(0), terminal_7d: BigInt(0), avg_seconds: null },
|
|
||||||
])
|
|
||||||
|
|
||||||
const result = await getJobsPerDay('user-1')
|
|
||||||
|
|
||||||
expect(result.kpi.successRate7d).toBe(0)
|
|
||||||
expect(result.kpi.avgDurationSeconds7d).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
const { mockStoryCount, mockTaskCount, mockTaskFindMany } = vi.hoisted(() => ({
|
|
||||||
mockStoryCount: vi.fn(),
|
|
||||||
mockTaskCount: vi.fn(),
|
|
||||||
mockTaskFindMany: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: {
|
|
||||||
story: { count: mockStoryCount },
|
|
||||||
task: { count: mockTaskCount, findMany: mockTaskFindMany },
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/lib/product-access', () => ({
|
|
||||||
productAccessFilter: () => ({ some: 'filter' }),
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { getBacklogHealth } from '@/lib/insights/backlog-health'
|
|
||||||
|
|
||||||
function makeTask(id: string, daysAgo: number) {
|
|
||||||
const updatedAt = new Date(Date.now() - daysAgo * 86_400_000)
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
title: `Task ${id}`,
|
|
||||||
updated_at: updatedAt,
|
|
||||||
story: {
|
|
||||||
product: { id: 'prod-1', name: 'My Product' },
|
|
||||||
sprint: { sprint_goal: 'Sprint goal' },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('getBacklogHealth', () => {
|
|
||||||
it('returns all zeros when backlog is healthy', async () => {
|
|
||||||
mockStoryCount.mockResolvedValue(0)
|
|
||||||
mockTaskCount.mockResolvedValue(0)
|
|
||||||
mockTaskFindMany.mockResolvedValue([])
|
|
||||||
|
|
||||||
const result = await getBacklogHealth('user-1')
|
|
||||||
|
|
||||||
expect(result.storiesWithoutAc).toBe(0)
|
|
||||||
expect(result.tasksWithoutPlan).toBe(0)
|
|
||||||
expect(result.stuckTasks).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns counts and stuck tasks when everything is flagged', async () => {
|
|
||||||
mockStoryCount.mockResolvedValue(5)
|
|
||||||
mockTaskCount.mockResolvedValue(3)
|
|
||||||
mockTaskFindMany.mockResolvedValue([makeTask('t1', 10), makeTask('t2', 8)])
|
|
||||||
|
|
||||||
const result = await getBacklogHealth('user-1')
|
|
||||||
|
|
||||||
expect(result.storiesWithoutAc).toBe(5)
|
|
||||||
expect(result.tasksWithoutPlan).toBe(3)
|
|
||||||
expect(result.stuckTasks).toHaveLength(2)
|
|
||||||
expect(result.stuckTasks[0].taskId).toBe('t1')
|
|
||||||
expect(result.stuckTasks[0].daysStuck).toBeGreaterThanOrEqual(10)
|
|
||||||
expect(result.stuckTasks[0].productName).toBe('My Product')
|
|
||||||
expect(result.stuckTasks[0].sprintGoal).toBe('Sprint goal')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('mixed: some counters non-zero, one stuck task, no sprint', async () => {
|
|
||||||
mockStoryCount.mockResolvedValue(2)
|
|
||||||
mockTaskCount.mockResolvedValue(0)
|
|
||||||
const task = makeTask('t3', 14)
|
|
||||||
task.story.sprint = null as unknown as { sprint_goal: string }
|
|
||||||
mockTaskFindMany.mockResolvedValue([task])
|
|
||||||
|
|
||||||
const result = await getBacklogHealth('user-1')
|
|
||||||
|
|
||||||
expect(result.storiesWithoutAc).toBe(2)
|
|
||||||
expect(result.tasksWithoutPlan).toBe(0)
|
|
||||||
expect(result.stuckTasks[0].sprintGoal).toBeNull()
|
|
||||||
expect(result.stuckTasks[0].daysStuck).toBeGreaterThanOrEqual(14)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
import { describe, it, expect, vi } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('@/lib/prisma', () => ({ prisma: {} }))
|
|
||||||
|
|
||||||
import { computeBurndownDays } from '@/lib/insights/burndown'
|
|
||||||
|
|
||||||
describe('computeBurndownDays', () => {
|
|
||||||
it('5-day sprint: remaining and ideal match spec', () => {
|
|
||||||
const start = new Date('2024-01-01T00:00:00.000Z')
|
|
||||||
const end = new Date('2024-01-05T00:00:00.000Z')
|
|
||||||
|
|
||||||
const tasks = [
|
|
||||||
{ status: 'DONE', updated_at: new Date('2024-01-02T12:00:00.000Z') },
|
|
||||||
{ status: 'DONE', updated_at: new Date('2024-01-04T12:00:00.000Z') },
|
|
||||||
{ status: 'IN_PROGRESS', updated_at: new Date('2024-01-05T12:00:00.000Z') },
|
|
||||||
]
|
|
||||||
|
|
||||||
const days = computeBurndownDays(tasks, start, end)
|
|
||||||
|
|
||||||
expect(days).toHaveLength(5)
|
|
||||||
expect(days.map(d => d.remaining)).toEqual([3, 2, 2, 1, 1])
|
|
||||||
expect(days.map(d => d.ideal)).toEqual([3, 2.25, 1.5, 0.75, 0])
|
|
||||||
expect(days.map(d => d.day)).toEqual([
|
|
||||||
'2024-01-01',
|
|
||||||
'2024-01-02',
|
|
||||||
'2024-01-03',
|
|
||||||
'2024-01-04',
|
|
||||||
'2024-01-05',
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns empty array when end is before start', () => {
|
|
||||||
const start = new Date('2024-01-05T00:00:00.000Z')
|
|
||||||
const end = new Date('2024-01-01T00:00:00.000Z')
|
|
||||||
expect(computeBurndownDays([], start, end)).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('single-day sprint has ideal = 0', () => {
|
|
||||||
const day = new Date('2024-01-01T00:00:00.000Z')
|
|
||||||
const tasks = [{ status: 'TO_DO', updated_at: new Date('2024-01-01T08:00:00.000Z') }]
|
|
||||||
const days = computeBurndownDays(tasks, day, day)
|
|
||||||
expect(days).toHaveLength(1)
|
|
||||||
expect(days[0].ideal).toBe(0)
|
|
||||||
expect(days[0].remaining).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('all tasks done on first day: remaining drops to 0', () => {
|
|
||||||
const start = new Date('2024-01-01T00:00:00.000Z')
|
|
||||||
const end = new Date('2024-01-03T00:00:00.000Z')
|
|
||||||
const tasks = [
|
|
||||||
{ status: 'DONE', updated_at: new Date('2024-01-01T10:00:00.000Z') },
|
|
||||||
{ status: 'DONE', updated_at: new Date('2024-01-01T11:00:00.000Z') },
|
|
||||||
]
|
|
||||||
const days = computeBurndownDays(tasks, start, end)
|
|
||||||
expect(days.map(d => d.remaining)).toEqual([0, 0, 0])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
const { mockQueryRaw } = vi.hoisted(() => ({ mockQueryRaw: vi.fn() }))
|
|
||||||
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: { $queryRaw: mockQueryRaw },
|
|
||||||
}))
|
|
||||||
|
|
||||||
import {
|
|
||||||
getSprintTokenHistory,
|
|
||||||
getDayTokenData,
|
|
||||||
getPbiTokenAggregates,
|
|
||||||
} from '@/lib/insights/token-history'
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('getSprintTokenHistory', () => {
|
|
||||||
it('returns mapped sprint rows', async () => {
|
|
||||||
mockQueryRaw.mockResolvedValueOnce([
|
|
||||||
{ sprint_id: 'sp-1', sprint_goal: 'Goal A', total_tokens: BigInt(5000), total_cost: 0.1, job_count: BigInt(2) },
|
|
||||||
])
|
|
||||||
const rows = await getSprintTokenHistory('user-1')
|
|
||||||
expect(rows).toHaveLength(1)
|
|
||||||
expect(rows[0].sprintId).toBe('sp-1')
|
|
||||||
expect(rows[0].totalTokens).toBe(5000)
|
|
||||||
expect(rows[0].totalCostUsd).toBe(0.1)
|
|
||||||
expect(rows[0].jobCount).toBe(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns zero cost when total_cost is null', async () => {
|
|
||||||
mockQueryRaw.mockResolvedValueOnce([
|
|
||||||
{ sprint_id: 'sp-2', sprint_goal: 'Goal B', total_tokens: BigInt(0), total_cost: null, job_count: BigInt(0) },
|
|
||||||
])
|
|
||||||
const rows = await getSprintTokenHistory('user-1')
|
|
||||||
expect(rows[0].totalCostUsd).toBe(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('getDayTokenData', () => {
|
|
||||||
it('returns empty array for empty sprintId', async () => {
|
|
||||||
const rows = await getDayTokenData('user-1', '')
|
|
||||||
expect(rows).toHaveLength(0)
|
|
||||||
expect(mockQueryRaw).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('maps day rows with ISO date string', async () => {
|
|
||||||
mockQueryRaw.mockResolvedValueOnce([
|
|
||||||
{ day: new Date('2026-05-01T00:00:00Z'), total_tokens: BigInt(2000), total_cost: 0.05 },
|
|
||||||
])
|
|
||||||
const rows = await getDayTokenData('user-1', 'sprint-1')
|
|
||||||
expect(rows).toHaveLength(1)
|
|
||||||
expect(rows[0].day).toBe('2026-05-01')
|
|
||||||
expect(rows[0].totalTokens).toBe(2000)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('getPbiTokenAggregates', () => {
|
|
||||||
it('returns empty array for empty sprintId', async () => {
|
|
||||||
const rows = await getPbiTokenAggregates('user-1', '')
|
|
||||||
expect(rows).toHaveLength(0)
|
|
||||||
expect(mockQueryRaw).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('maps pbi rows', async () => {
|
|
||||||
mockQueryRaw.mockResolvedValueOnce([
|
|
||||||
{ pbi_id: 'pbi-1', pbi_code: 'M1', pbi_title: 'First PBI', total_tokens: BigInt(3000), total_cost: 0.08 },
|
|
||||||
])
|
|
||||||
const rows = await getPbiTokenAggregates('user-1', 'sprint-1')
|
|
||||||
expect(rows[0].pbiCode).toBe('M1')
|
|
||||||
expect(rows[0].totalTokens).toBe(3000)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
const { mockQueryRaw } = vi.hoisted(() => ({ mockQueryRaw: vi.fn() }))
|
|
||||||
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: { $queryRaw: mockQueryRaw },
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { getTokenStats } from '@/lib/insights/token-stats'
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('getTokenStats', () => {
|
|
||||||
it('returns empty result for empty sprintId', async () => {
|
|
||||||
const result = await getTokenStats('user-1', '')
|
|
||||||
|
|
||||||
expect(result.kpi.totalTokens).toBe(0)
|
|
||||||
expect(result.kpi.totalCostUsd).toBe(0)
|
|
||||||
expect(result.kpi.avgCostPerJob).toBe(0)
|
|
||||||
expect(result.kpi.jobCount).toBe(0)
|
|
||||||
expect(result.jobs).toHaveLength(0)
|
|
||||||
expect(mockQueryRaw).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('maps kpi rows correctly', async () => {
|
|
||||||
const kpiRows = [{ total_tokens: BigInt(10000), total_cost: 0.15, avg_cost: 0.05, job_count: BigInt(3) }]
|
|
||||||
const jobRows: unknown[] = []
|
|
||||||
mockQueryRaw.mockResolvedValueOnce(kpiRows).mockResolvedValueOnce(jobRows)
|
|
||||||
|
|
||||||
const result = await getTokenStats('user-1', 'sprint-1')
|
|
||||||
|
|
||||||
expect(result.kpi.totalTokens).toBe(10000)
|
|
||||||
expect(result.kpi.totalCostUsd).toBe(0.15)
|
|
||||||
expect(result.kpi.avgCostPerJob).toBe(0.05)
|
|
||||||
expect(result.kpi.jobCount).toBe(3)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('maps job rows and handles null token data', async () => {
|
|
||||||
const kpiRows = [{ total_tokens: BigInt(0), total_cost: null, avg_cost: null, job_count: BigInt(0) }]
|
|
||||||
const jobRows = [
|
|
||||||
{
|
|
||||||
job_id: 'job-1',
|
|
||||||
task_title: 'My Task',
|
|
||||||
idea_code: null,
|
|
||||||
model_id: 'claude-sonnet-4-6',
|
|
||||||
input_tokens: null,
|
|
||||||
output_tokens: null,
|
|
||||||
cache_read_tokens: null,
|
|
||||||
cache_write_tokens: null,
|
|
||||||
cost_usd: null,
|
|
||||||
duration_seconds: 42,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
mockQueryRaw.mockResolvedValueOnce(kpiRows).mockResolvedValueOnce(jobRows)
|
|
||||||
|
|
||||||
const result = await getTokenStats('user-1', 'sprint-1')
|
|
||||||
|
|
||||||
expect(result.jobs).toHaveLength(1)
|
|
||||||
const job = result.jobs[0]
|
|
||||||
expect(job.jobId).toBe('job-1')
|
|
||||||
expect(job.taskTitle).toBe('My Task')
|
|
||||||
expect(job.costUsd).toBeNull()
|
|
||||||
expect(job.durationSeconds).toBe(42)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
import { describe, it, expect, vi } from 'vitest'
|
|
||||||
|
|
||||||
const { mockFindMany } = vi.hoisted(() => ({ mockFindMany: vi.fn() }))
|
|
||||||
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: {
|
|
||||||
sprint: { findMany: mockFindMany },
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/lib/product-access', () => ({
|
|
||||||
productAccessFilter: () => ({ some: 'filter' }),
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { getVelocity } from '@/lib/insights/velocity'
|
|
||||||
|
|
||||||
const completedAt = (iso: string) => new Date(iso)
|
|
||||||
|
|
||||||
function makeSprint(id: string, goal: string, productId: string, productName: string, doneCounts: number, completedIso: string) {
|
|
||||||
const tasks = Array.from({ length: doneCounts }, () => ({ status: 'DONE' }))
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
sprint_goal: goal,
|
|
||||||
completed_at: completedAt(completedIso),
|
|
||||||
product: { id: productId, name: productName },
|
|
||||||
tasks,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('getVelocity', () => {
|
|
||||||
it('returns 3 sprints in chronological order with correct done counts', async () => {
|
|
||||||
// DB returns newest-first (orderBy: completed_at desc), getVelocity reverses to oldest-first
|
|
||||||
mockFindMany.mockResolvedValue([
|
|
||||||
makeSprint('s3', 'Sprint C', 'p1', 'Prod A', 3, '2024-03-01T00:00:00.000Z'),
|
|
||||||
makeSprint('s2', 'Sprint B', 'p1', 'Prod A', 5, '2024-02-01T00:00:00.000Z'),
|
|
||||||
makeSprint('s1', 'Sprint A', 'p1', 'Prod A', 2, '2024-01-01T00:00:00.000Z'),
|
|
||||||
])
|
|
||||||
|
|
||||||
const result = await getVelocity('user-1')
|
|
||||||
|
|
||||||
expect(result.sprints).toHaveLength(3)
|
|
||||||
expect(result.sprints.map(s => s.doneCount)).toEqual([2, 5, 3])
|
|
||||||
expect(result.sprints.map(s => s.sprintId)).toEqual(['s1', 's2', 's3'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('deduplicates productNames from sprints', async () => {
|
|
||||||
mockFindMany.mockResolvedValue([
|
|
||||||
makeSprint('s2', 'Sprint B', 'p2', 'Prod B', 1, '2024-02-01T00:00:00.000Z'),
|
|
||||||
makeSprint('s1', 'Sprint A', 'p1', 'Prod A', 2, '2024-01-01T00:00:00.000Z'),
|
|
||||||
])
|
|
||||||
|
|
||||||
const result = await getVelocity('user-1')
|
|
||||||
|
|
||||||
const ids = result.productNames.map(p => p.id)
|
|
||||||
expect(new Set(ids).size).toBe(ids.length)
|
|
||||||
expect(result.productNames).toHaveLength(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns empty sprints and productNames when no completed sprints exist', async () => {
|
|
||||||
mockFindMany.mockResolvedValue([])
|
|
||||||
|
|
||||||
const result = await getVelocity('user-1')
|
|
||||||
|
|
||||||
expect(result.sprints).toEqual([])
|
|
||||||
expect(result.productNames).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('passes sprintsBack as take parameter', async () => {
|
|
||||||
mockFindMany.mockResolvedValue([])
|
|
||||||
|
|
||||||
await getVelocity('user-1', 3)
|
|
||||||
|
|
||||||
expect(mockFindMany).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ take: 3 }),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
const { mockGroupBy, mockFindMany } = vi.hoisted(() => ({
|
|
||||||
mockGroupBy: vi.fn(),
|
|
||||||
mockFindMany: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: {
|
|
||||||
claudeJob: {
|
|
||||||
groupBy: mockGroupBy,
|
|
||||||
findMany: mockFindMany,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { getVerifyResultStats } from '@/lib/insights/verify-stats'
|
|
||||||
|
|
||||||
const USER_ID = 'user-1'
|
|
||||||
|
|
||||||
const makeJob = (id: string, verifyResult: string, daysAgo: number) => {
|
|
||||||
const finishedAt = new Date()
|
|
||||||
finishedAt.setDate(finishedAt.getDate() - daysAgo)
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
finished_at: finishedAt,
|
|
||||||
task: { id: `task-${id}`, title: `Task ${id}` },
|
|
||||||
product: { id: 'prod-1', name: 'Scrum4Me' },
|
|
||||||
verify_result: verifyResult,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('getVerifyResultStats', () => {
|
|
||||||
it('returns counts in ALIGNED→PARTIAL→EMPTY→DIVERGENT order', async () => {
|
|
||||||
mockGroupBy.mockResolvedValue([
|
|
||||||
{ verify_result: 'DIVERGENT', _count: { _all: 2 } },
|
|
||||||
{ verify_result: 'ALIGNED', _count: { _all: 10 } },
|
|
||||||
{ verify_result: 'EMPTY', _count: { _all: 3 } },
|
|
||||||
{ verify_result: 'PARTIAL', _count: { _all: 1 } },
|
|
||||||
])
|
|
||||||
mockFindMany.mockResolvedValue([])
|
|
||||||
|
|
||||||
const stats = await getVerifyResultStats(USER_ID)
|
|
||||||
|
|
||||||
expect(stats.counts.map(c => c.result)).toEqual([
|
|
||||||
'ALIGNED', 'PARTIAL', 'EMPTY', 'DIVERGENT',
|
|
||||||
])
|
|
||||||
expect(stats.counts.map(c => c.count)).toEqual([10, 1, 3, 2])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('omits results with zero count from groupBy', async () => {
|
|
||||||
mockGroupBy.mockResolvedValue([
|
|
||||||
{ verify_result: 'ALIGNED', _count: { _all: 5 } },
|
|
||||||
])
|
|
||||||
mockFindMany.mockResolvedValue([])
|
|
||||||
|
|
||||||
const stats = await getVerifyResultStats(USER_ID)
|
|
||||||
|
|
||||||
expect(stats.counts).toHaveLength(1)
|
|
||||||
expect(stats.counts[0]).toEqual({ result: 'ALIGNED', count: 5 })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('maps topEmpty jobs correctly', async () => {
|
|
||||||
mockGroupBy.mockResolvedValue([])
|
|
||||||
const job = makeJob('j1', 'EMPTY', 2)
|
|
||||||
// First findMany call → topEmpty, second → topDivergent
|
|
||||||
mockFindMany
|
|
||||||
.mockResolvedValueOnce([job])
|
|
||||||
.mockResolvedValueOnce([])
|
|
||||||
|
|
||||||
const stats = await getVerifyResultStats(USER_ID)
|
|
||||||
|
|
||||||
expect(stats.topEmpty).toHaveLength(1)
|
|
||||||
expect(stats.topEmpty[0]).toMatchObject({
|
|
||||||
jobId: 'j1',
|
|
||||||
taskId: 'task-j1',
|
|
||||||
taskTitle: 'Task j1',
|
|
||||||
productId: 'prod-1',
|
|
||||||
productName: 'Scrum4Me',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('topDivergent is ordered most-recent first (from DB order)', async () => {
|
|
||||||
mockGroupBy.mockResolvedValue([])
|
|
||||||
const jobs = [
|
|
||||||
makeJob('jOld', 'DIVERGENT', 10),
|
|
||||||
makeJob('jNew', 'DIVERGENT', 1),
|
|
||||||
]
|
|
||||||
mockFindMany
|
|
||||||
.mockResolvedValueOnce([]) // topEmpty
|
|
||||||
.mockResolvedValueOnce(jobs) // topDivergent (already sorted by Prisma orderBy)
|
|
||||||
|
|
||||||
const stats = await getVerifyResultStats(USER_ID)
|
|
||||||
|
|
||||||
expect(stats.topDivergent.map(j => j.jobId)).toEqual(['jOld', 'jNew'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns empty stats when no jobs found', async () => {
|
|
||||||
mockGroupBy.mockResolvedValue([])
|
|
||||||
mockFindMany.mockResolvedValue([])
|
|
||||||
|
|
||||||
const stats = await getVerifyResultStats(USER_ID)
|
|
||||||
|
|
||||||
expect(stats.counts).toEqual([])
|
|
||||||
expect(stats.topEmpty).toEqual([])
|
|
||||||
expect(stats.topDivergent).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import {
|
|
||||||
getKindDefault,
|
|
||||||
resolveJobConfig,
|
|
||||||
mapBudgetToEffort,
|
|
||||||
} from '@/lib/job-config'
|
|
||||||
|
|
||||||
describe('mapBudgetToEffort', () => {
|
|
||||||
it.each([
|
|
||||||
[0, null],
|
|
||||||
[-1, null],
|
|
||||||
[1, 'medium'],
|
|
||||||
[3000, 'medium'],
|
|
||||||
[6000, 'medium'],
|
|
||||||
[6001, 'high'],
|
|
||||||
[9000, 'high'],
|
|
||||||
[12000, 'high'],
|
|
||||||
[12001, 'xhigh'],
|
|
||||||
[18000, 'xhigh'],
|
|
||||||
[24000, 'xhigh'],
|
|
||||||
[24001, 'max'],
|
|
||||||
[50000, 'max'],
|
|
||||||
[100000, 'max'],
|
|
||||||
])('budget %i → %s', (budget, expected) => {
|
|
||||||
expect(mapBudgetToEffort(budget)).toBe(expected)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('KIND_DEFAULTS.allowed_tools — sync met scrum4me-mcp', () => {
|
|
||||||
it('TASK_IMPLEMENTATION bevat geen claim-tools', () => {
|
|
||||||
const cfg = getKindDefault('TASK_IMPLEMENTATION')
|
|
||||||
expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__wait_for_job')
|
|
||||||
expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__check_queue_empty')
|
|
||||||
expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__get_idea_context')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('TASK_IMPLEMENTATION bevat de essentiële task-tools', () => {
|
|
||||||
const cfg = getKindDefault('TASK_IMPLEMENTATION')
|
|
||||||
expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_task_status')
|
|
||||||
expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_job_status')
|
|
||||||
expect(cfg.allowed_tools).toContain('mcp__scrum4me__verify_task_against_plan')
|
|
||||||
expect(cfg.allowed_tools).toContain('Bash')
|
|
||||||
expect(cfg.allowed_tools).toContain('Edit')
|
|
||||||
expect(cfg.allowed_tools).toContain('Write')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('SPRINT_IMPLEMENTATION bevat sprint-specifieke tools maar GEEN job_heartbeat (runner doet die)', () => {
|
|
||||||
const cfg = getKindDefault('SPRINT_IMPLEMENTATION')
|
|
||||||
expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_task_execution')
|
|
||||||
expect(cfg.allowed_tools).toContain('mcp__scrum4me__verify_sprint_task')
|
|
||||||
expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__job_heartbeat')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('IDEA_GRILL bevat update_idea_grill_md en geen wait_for_job', () => {
|
|
||||||
const cfg = getKindDefault('IDEA_GRILL')
|
|
||||||
expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_idea_grill_md')
|
|
||||||
expect(cfg.allowed_tools).toContain('mcp__scrum4me__log_idea_decision')
|
|
||||||
expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_job_status')
|
|
||||||
expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__wait_for_job')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('IDEA_MAKE_PLAN bevat update_idea_plan_md en geen wait_for_job', () => {
|
|
||||||
const cfg = getKindDefault('IDEA_MAKE_PLAN')
|
|
||||||
expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_idea_plan_md')
|
|
||||||
expect(cfg.allowed_tools).toContain('mcp__scrum4me__log_idea_decision')
|
|
||||||
expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__wait_for_job')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('alle kinds hebben non-null allowed_tools', () => {
|
|
||||||
for (const kind of [
|
|
||||||
'IDEA_GRILL',
|
|
||||||
'IDEA_MAKE_PLAN',
|
|
||||||
'PLAN_CHAT',
|
|
||||||
'TASK_IMPLEMENTATION',
|
|
||||||
'SPRINT_IMPLEMENTATION',
|
|
||||||
]) {
|
|
||||||
const cfg = getKindDefault(kind)
|
|
||||||
expect(cfg.allowed_tools).not.toBeNull()
|
|
||||||
expect(Array.isArray(cfg.allowed_tools)).toBe(true)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('resolveJobConfig — cascade (regression)', () => {
|
|
||||||
it('task.requires_opus overrult product.preferred_model', () => {
|
|
||||||
const cfg = resolveJobConfig(
|
|
||||||
{ kind: 'TASK_IMPLEMENTATION' },
|
|
||||||
{ preferred_model: 'claude-sonnet-4-6' },
|
|
||||||
{ requires_opus: true },
|
|
||||||
)
|
|
||||||
expect(cfg.model).toBe('claude-opus-4-7')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('product.preferred_permission_mode overrult bypassPermissions', () => {
|
|
||||||
const cfg = resolveJobConfig(
|
|
||||||
{ kind: 'TASK_IMPLEMENTATION' },
|
|
||||||
{ preferred_permission_mode: 'acceptEdits' },
|
|
||||||
)
|
|
||||||
expect(cfg.permission_mode).toBe('acceptEdits')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { getBranchUrl } from '@/lib/job-status-url'
|
|
||||||
|
|
||||||
describe('getBranchUrl', () => {
|
|
||||||
it('builds a GitHub tree URL from repo URL and branch', () => {
|
|
||||||
expect(getBranchUrl('https://github.com/owner/repo', 'feat/job-abc12345')).toBe(
|
|
||||||
'https://github.com/owner/repo/tree/feat/job-abc12345',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('strips trailing .git suffix', () => {
|
|
||||||
expect(getBranchUrl('https://github.com/owner/repo.git', 'feat/job-abc')).toBe(
|
|
||||||
'https://github.com/owner/repo/tree/feat/job-abc',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('strips trailing slash', () => {
|
|
||||||
expect(getBranchUrl('https://github.com/owner/repo/', 'feat/job-abc')).toBe(
|
|
||||||
'https://github.com/owner/repo/tree/feat/job-abc',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import {
|
|
||||||
jobStatusToApi,
|
|
||||||
jobStatusFromApi,
|
|
||||||
JOB_STATUS_API_VALUES,
|
|
||||||
ACTIVE_JOB_STATUSES,
|
|
||||||
} from '@/lib/job-status'
|
|
||||||
|
|
||||||
describe('job-status mappers', () => {
|
|
||||||
it('round-trips every API value', () => {
|
|
||||||
for (const api of JOB_STATUS_API_VALUES) {
|
|
||||||
const db = jobStatusFromApi(api)
|
|
||||||
expect(db).not.toBeNull()
|
|
||||||
expect(jobStatusToApi(db!)).toBe(api)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns null for invalid input', () => {
|
|
||||||
expect(jobStatusFromApi('NOT_A_STATUS')).toBeNull()
|
|
||||||
expect(jobStatusFromApi('')).toBeNull()
|
|
||||||
expect(jobStatusFromApi('active')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('is case-insensitive on the API side (accepts both upper and lower)', () => {
|
|
||||||
expect(jobStatusFromApi('running')).toBe('RUNNING')
|
|
||||||
expect(jobStatusFromApi('RUNNING')).toBe('RUNNING')
|
|
||||||
expect(jobStatusFromApi('QUEUED')).toBe('QUEUED')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('maps all 7 DB statuses to API', () => {
|
|
||||||
expect(jobStatusToApi('QUEUED')).toBe('queued')
|
|
||||||
expect(jobStatusToApi('CLAIMED')).toBe('claimed')
|
|
||||||
expect(jobStatusToApi('RUNNING')).toBe('running')
|
|
||||||
expect(jobStatusToApi('DONE')).toBe('done')
|
|
||||||
expect(jobStatusToApi('FAILED')).toBe('failed')
|
|
||||||
expect(jobStatusToApi('CANCELLED')).toBe('cancelled')
|
|
||||||
expect(jobStatusToApi('SKIPPED')).toBe('skipped')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('ACTIVE_JOB_STATUSES contains exactly QUEUED, CLAIMED, RUNNING', () => {
|
|
||||||
expect(ACTIVE_JOB_STATUSES).toEqual(expect.arrayContaining(['QUEUED', 'CLAIMED', 'RUNNING']))
|
|
||||||
expect(ACTIVE_JOB_STATUSES).toHaveLength(3)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
|
|
||||||
import { isWithinTimeWindow } from '@/lib/jobs-time-filter'
|
|
||||||
|
|
||||||
const HOUR_MS = 60 * 60 * 1000
|
|
||||||
|
|
||||||
describe('isWithinTimeWindow', () => {
|
|
||||||
it("returns true for filter='all' regardless of age", () => {
|
|
||||||
const old = new Date(0)
|
|
||||||
expect(isWithinTimeWindow(old, 'all')).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("filter='1h'", () => {
|
|
||||||
const now = Date.now()
|
|
||||||
|
|
||||||
it('returns true for a job created 30 minutes ago', () => {
|
|
||||||
const createdAt = new Date(now - 30 * 60 * 1000)
|
|
||||||
expect(isWithinTimeWindow(createdAt, '1h', now)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns false for a job created 90 minutes ago', () => {
|
|
||||||
const createdAt = new Date(now - 90 * 60 * 1000)
|
|
||||||
expect(isWithinTimeWindow(createdAt, '1h', now)).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("filter='24h'", () => {
|
|
||||||
const now = Date.now()
|
|
||||||
|
|
||||||
it('returns true for a job created 23 hours ago', () => {
|
|
||||||
const createdAt = new Date(now - 23 * HOUR_MS)
|
|
||||||
expect(isWithinTimeWindow(createdAt, '24h', now)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns false for a job created 25 hours ago', () => {
|
|
||||||
const createdAt = new Date(now - 25 * HOUR_MS)
|
|
||||||
expect(isWithinTimeWindow(createdAt, '24h', now)).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('accepts both Date and ISO string for createdAt', () => {
|
|
||||||
const now = Date.now()
|
|
||||||
const recent = new Date(now - 30 * 60 * 1000)
|
|
||||||
|
|
||||||
it('accepts a Date object', () => {
|
|
||||||
expect(isWithinTimeWindow(recent, '1h', now)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('accepts an ISO string', () => {
|
|
||||||
expect(isWithinTimeWindow(recent.toISOString(), '1h', now)).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns true for an invalid date string (fail-open)', () => {
|
|
||||||
expect(isWithinTimeWindow('not-a-date', '1h')).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { resolveProductSwitchTarget } from '@/lib/product-switch-path'
|
|
||||||
|
|
||||||
describe('resolveProductSwitchTarget', () => {
|
|
||||||
it('returns null for non-product pages', () => {
|
|
||||||
expect(resolveProductSwitchTarget('/dashboard', 'new-id')).toBeNull()
|
|
||||||
expect(resolveProductSwitchTarget('/insights', 'new-id')).toBeNull()
|
|
||||||
expect(resolveProductSwitchTarget('/ideas', 'new-id')).toBeNull()
|
|
||||||
expect(resolveProductSwitchTarget('/jobs', 'new-id')).toBeNull()
|
|
||||||
expect(resolveProductSwitchTarget('/', 'new-id')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('maps /products/<old> to /products/<new>', () => {
|
|
||||||
expect(resolveProductSwitchTarget('/products/old-id', 'new-id')).toBe('/products/new-id')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('maps /products/<old>/ to /products/<new>', () => {
|
|
||||||
expect(resolveProductSwitchTarget('/products/old-id/', 'new-id')).toBe('/products/new-id')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('maps /products/<old>/sprint to /products/<new>/sprint', () => {
|
|
||||||
expect(resolveProductSwitchTarget('/products/old-id/sprint', 'new-id')).toBe(
|
|
||||||
'/products/new-id/sprint',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('maps /products/<old>/sprint/<sprintId> to /products/<new>/sprint', () => {
|
|
||||||
expect(resolveProductSwitchTarget('/products/old-id/sprint/abc123', 'new-id')).toBe(
|
|
||||||
'/products/new-id/sprint',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('maps /products/<old>/sprint/.../planning to /products/<new>/sprint', () => {
|
|
||||||
expect(resolveProductSwitchTarget('/products/old-id/sprint/abc123/planning', 'new-id')).toBe(
|
|
||||||
'/products/new-id/sprint',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('maps /products/<old>/solo to /products/<new>/solo', () => {
|
|
||||||
expect(resolveProductSwitchTarget('/products/old-id/solo', 'new-id')).toBe(
|
|
||||||
'/products/new-id/solo',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('falls back to /products/<new> for /products/<old>/settings', () => {
|
|
||||||
expect(resolveProductSwitchTarget('/products/old-id/settings', 'new-id')).toBe(
|
|
||||||
'/products/new-id',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('falls back to /products/<new> for unknown sub-segments', () => {
|
|
||||||
expect(resolveProductSwitchTarget('/products/old-id/unknown/deep', 'new-id')).toBe(
|
|
||||||
'/products/new-id',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import { describe, it, expect, vi } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('@/actions/push', () => ({
|
|
||||||
subscribeToPushAction: vi.fn(),
|
|
||||||
unsubscribeFromPushAction: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { urlBase64ToUint8Array } from '@/lib/push-client'
|
|
||||||
|
|
||||||
describe('urlBase64ToUint8Array', () => {
|
|
||||||
it('converts a base64url-encoded VAPID public key to Uint8Array', () => {
|
|
||||||
// 65-byte uncompressed EC public key encoded as base64url (no padding)
|
|
||||||
const base64url = 'BNMxB-LJm6XvGGiJSsYLdumcYiM7q9s_1aM9i5lI8lVzZ7GYJw1QkQFmrknwFsI4dI-e1iyvUhYHjNpHJKJD3oc'
|
|
||||||
const result = urlBase64ToUint8Array(base64url)
|
|
||||||
expect(result).toBeInstanceOf(Uint8Array)
|
|
||||||
expect(result.length).toBe(65)
|
|
||||||
expect(result[0]).toBe(0x04) // uncompressed EC point prefix
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles base64url with padding', () => {
|
|
||||||
// simple known vector: "hello" = aGVsbG8= in base64
|
|
||||||
const result = urlBase64ToUint8Array('aGVsbG8')
|
|
||||||
expect(result).toBeInstanceOf(Uint8Array)
|
|
||||||
expect(Array.from(result)).toEqual([104, 101, 108, 108, 111]) // "hello"
|
|
||||||
})
|
|
||||||
|
|
||||||
it('converts - and _ characters correctly', () => {
|
|
||||||
// base64url uses - and _ instead of + and /
|
|
||||||
const base64standard = 'AB+/AA=='
|
|
||||||
const base64url = 'AB-_AA'
|
|
||||||
const fromStd = urlBase64ToUint8Array(base64standard)
|
|
||||||
const fromUrl = urlBase64ToUint8Array(base64url)
|
|
||||||
expect(Array.from(fromStd)).toEqual(Array.from(fromUrl))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
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