* feat(code): add parseCodeNumber helper to lib/code.ts
Pure helper that extracts the trailing numeric sequence from a code string
(ST-007 → 7, T-42 → 42). Non-conforming codes fall back to Number.MAX_SAFE_INTEGER
so they sort to the end. Includes 5 unit tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(tasks): add code field to BacklogTask type and all task selects
Adds `code: string | null` to BacklogTask interface and includes it in
all Prisma task.findMany selects (backlog API, stories tasks API, page
hydration routes). Updates coerceTaskPayload and test fixtures to match.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(sort-order): derive story/task sort_order from parseCodeNumber(code)
All create paths (createStoryAction, saveTask, createTaskAction,
materializeIdeaPlanAction) and code-edit paths (updateStoryAction, saveTask
update) now set sort_order = parseCodeNumber(code) instead of last+1.
Removes stale last-record queries from create paths.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(sort-order): decouple sprint membership actions from sort_order
createSprintAction and addStoryToSprintAction no longer write sort_order
when adding stories to a sprint. sort_order is derived from code via
parseCodeNumber, so membership should only set sprint_id + status.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(ordering): remove priority from all story/task orderBy
Story- en taak-ordering is nu puur sort_order asc (created_at als
tiebreaker). PBI-ordering (priority + sort_order) blijft ongewijzigd.
Gewijzigd: backlog/route, pbis/stories/route, claude-context/route,
next-story/route, workspace/route, tasks/route, sprint-runs (query +
in-memory sort), solo-workspace-server, page.tsx (app + mobile + sprint),
store compareStory, actions/sprints story-query, next-story test.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(dnd): remove drag-and-drop reorder for stories and tasks
- Remove reorderStoriesAction, reorderTasksAction, reorderSprintStoriesAction
- Delete REST route app/api/stories/[id]/tasks/reorder/route.ts
- Remove DnD from backlog story-panel and task-panel (flat list)
- Remove reorder-within-sprint branch from sprint-board-client handleDragEnd
- Switch SortableSprintRow to plain SprintRow using useDraggable (membership drag kept)
- Remove all DnD from task-list (status toggle + edit kept)
- Remove story-order/task-order/sprint-story-order/sprint-task-order mutation types and store handlers
- Remove related tests for deleted reorder route; fix sprint store tests
* feat(backlog): toon code-badge op backlog-taakkaarten
Geeft code={task.code} door aan <BacklogCard> in TaskCard (task-panel.tsx).
BacklogCard rendert de CodeBadge al conditionally — alleen de prop ontbrak.
* feat(migration): backfill story/task sort_order from code numeric suffix
One-time Prisma migration that sets sort_order = trailing numeric part
of code for all existing stories and tasks, consistent with
parseCodeNumber (fallback = Number.MAX_SAFE_INTEGER for non-conforming
codes). PBIs are intentionally excluded.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs+tests(sort-order): update for code-binding order on stories/tasks
- Rewrite docs/patterns/sort-order.md: float-insertion PBI only; story/task
sort_order = parseCodeNumber(code), never drag/membership mutated
- Update plan-to-pbi-flow.md: sort_order auto, sprint_id param, priority=label
- Update make-plan.md: priority=label, array order = execution order
- Update rest-contract.md: fix sprint-tasks ordering, remove reorder endpoint
- Add ADR-0011: code is bindende volgordesleutel voor stories/taken
- Regenerate docs/INDEX.md via npm run docs
- Remove reorderStoriesAction/reorderTasksAction mocks from backlog tests
- Remove dnd-kit mocks from task-panel test (panel no longer uses dnd)
- Extend materializeIdeaPlanAction test: assert sort_order=parseCodeNumber(code)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
410 lines
13 KiB
TypeScript
410 lines
13 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
vi.mock('@/lib/prisma', () => ({
|
|
prisma: {
|
|
product: {
|
|
findMany: vi.fn(),
|
|
findFirst: vi.fn(),
|
|
},
|
|
sprint: {
|
|
findFirst: vi.fn(),
|
|
findUniqueOrThrow: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
story: {
|
|
findFirst: vi.fn(),
|
|
findUniqueOrThrow: vi.fn(),
|
|
findMany: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
task: {
|
|
findFirst: vi.fn(),
|
|
update: vi.fn(),
|
|
findMany: vi.fn(),
|
|
},
|
|
pbi: {
|
|
findUniqueOrThrow: vi.fn(),
|
|
findMany: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
claudeJob: {
|
|
findFirst: vi.fn(),
|
|
updateMany: vi.fn(),
|
|
},
|
|
sprintRun: {
|
|
findUnique: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
storyLog: {
|
|
create: vi.fn(),
|
|
},
|
|
todo: {
|
|
create: 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 { GET as getProducts } from '@/app/api/products/route'
|
|
import { GET as getNextStory } from '@/app/api/products/[id]/next-story/route'
|
|
import { GET as getSprintTasks } from '@/app/api/sprints/[id]/tasks/route'
|
|
import { POST as postStoryLog } from '@/app/api/stories/[id]/log/route'
|
|
import { PATCH as patchTask } from '@/app/api/tasks/[id]/route'
|
|
|
|
const mockPrisma = prisma as unknown as {
|
|
product: { findMany: ReturnType<typeof vi.fn>; findFirst: ReturnType<typeof vi.fn> }
|
|
sprint: {
|
|
findFirst: ReturnType<typeof vi.fn>
|
|
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
|
update: ReturnType<typeof vi.fn>
|
|
}
|
|
story: {
|
|
findFirst: ReturnType<typeof vi.fn>
|
|
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
|
findMany: ReturnType<typeof vi.fn>
|
|
update: ReturnType<typeof vi.fn>
|
|
}
|
|
task: {
|
|
findFirst: ReturnType<typeof vi.fn>
|
|
update: ReturnType<typeof vi.fn>
|
|
findMany: ReturnType<typeof vi.fn>
|
|
}
|
|
pbi: {
|
|
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
|
findMany: ReturnType<typeof vi.fn>
|
|
update: ReturnType<typeof vi.fn>
|
|
}
|
|
claudeJob: {
|
|
findFirst: ReturnType<typeof vi.fn>
|
|
updateMany: ReturnType<typeof vi.fn>
|
|
}
|
|
sprintRun: {
|
|
findUnique: ReturnType<typeof vi.fn>
|
|
update: ReturnType<typeof vi.fn>
|
|
}
|
|
storyLog: { create: ReturnType<typeof vi.fn> }
|
|
todo: { create: ReturnType<typeof vi.fn> }
|
|
$transaction: ReturnType<typeof vi.fn>
|
|
}
|
|
const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn>
|
|
|
|
const UNAUTHORIZED = { error: 'Unauthorized', status: 401 }
|
|
const DEMO_AUTH = { userId: 'demo-user', isDemo: true }
|
|
const USER_1_AUTH = { userId: 'user-1', isDemo: false }
|
|
const USER_2_AUTH = { userId: 'user-2', isDemo: false }
|
|
|
|
function makeGet(url: string): Request {
|
|
return new Request(url, {
|
|
method: 'GET',
|
|
headers: { Authorization: 'Bearer test-token' },
|
|
})
|
|
}
|
|
|
|
function makePost(url: string, body: unknown): Request {
|
|
return new Request(url, {
|
|
method: 'POST',
|
|
headers: { Authorization: 'Bearer test-token', 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
})
|
|
}
|
|
|
|
function makePatch(url: string, body: unknown): Request {
|
|
return new Request(url, {
|
|
method: 'PATCH',
|
|
headers: { Authorization: 'Bearer test-token', 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
})
|
|
}
|
|
|
|
function routeCtx(id: string) {
|
|
return { params: Promise.resolve({ id }) }
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
// Pass-through transaction so callers can `prisma.$transaction(async tx => ...)` in routes.
|
|
mockPrisma.$transaction.mockImplementation(async (run: unknown) => {
|
|
if (typeof run === 'function') return (run as (tx: typeof prisma) => Promise<unknown>)(prisma)
|
|
return run
|
|
})
|
|
})
|
|
|
|
// ─── GET /api/products ────────────────────────────────────────────────────────
|
|
|
|
describe('GET /api/products', () => {
|
|
// TC-P-01
|
|
it('returns 401 when no valid token provided', async () => {
|
|
mockAuth.mockResolvedValue(UNAUTHORIZED)
|
|
const res = await getProducts(makeGet('http://localhost/api/products'))
|
|
expect(res.status).toBe(401)
|
|
})
|
|
|
|
// TC-P-08
|
|
it('returns only the authenticated user\'s products (cross-user isolation)', async () => {
|
|
mockAuth.mockResolvedValue(USER_1_AUTH)
|
|
mockPrisma.product.findMany.mockResolvedValue([{ id: 'prod-1', name: 'Product A', repo_url: null }])
|
|
|
|
const res = await getProducts(makeGet('http://localhost/api/products'))
|
|
const data = await res.json()
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(mockPrisma.product.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({
|
|
archived: false,
|
|
OR: expect.arrayContaining([
|
|
{ user_id: 'user-1' },
|
|
{ members: { some: { user_id: 'user-1' } } },
|
|
]),
|
|
}),
|
|
})
|
|
)
|
|
expect(data).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
// ─── GET /api/products/:id/next-story ────────────────────────────────────────
|
|
|
|
describe('GET /api/products/:id/next-story', () => {
|
|
// TC-NS-01
|
|
it('returns 401 when no valid token provided', async () => {
|
|
mockAuth.mockResolvedValue(UNAUTHORIZED)
|
|
const res = await getNextStory(
|
|
makeGet('http://localhost/api/products/prod-1/next-story'),
|
|
routeCtx('prod-1')
|
|
)
|
|
expect(res.status).toBe(401)
|
|
})
|
|
|
|
// TC-NS-03 / TC-NS-07: product not accessible covers both "not found" and cross-user
|
|
it('returns 404 when product is not accessible to the authenticated user', async () => {
|
|
mockAuth.mockResolvedValue(USER_1_AUTH)
|
|
mockPrisma.sprint.findFirst.mockResolvedValue(null)
|
|
|
|
const res = await getNextStory(
|
|
makeGet('http://localhost/api/products/prod-other/next-story'),
|
|
routeCtx('prod-other')
|
|
)
|
|
expect(res.status).toBe(404)
|
|
expect(mockPrisma.sprint.findFirst).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({
|
|
product_id: 'prod-other',
|
|
status: 'OPEN',
|
|
product: expect.objectContaining({
|
|
OR: expect.arrayContaining([{ user_id: 'user-1' }]),
|
|
}),
|
|
}),
|
|
})
|
|
)
|
|
})
|
|
|
|
// TC-NS-07 explicit cross-user
|
|
it('returns 404 for another user\'s product', async () => {
|
|
mockAuth.mockResolvedValue(USER_2_AUTH)
|
|
mockPrisma.sprint.findFirst.mockResolvedValue(null)
|
|
|
|
const res = await getNextStory(
|
|
makeGet('http://localhost/api/products/prod-1/next-story'),
|
|
routeCtx('prod-1')
|
|
)
|
|
expect(res.status).toBe(404)
|
|
expect(mockPrisma.sprint.findFirst).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({
|
|
product: expect.objectContaining({
|
|
OR: expect.arrayContaining([{ user_id: 'user-2' }]),
|
|
}),
|
|
}),
|
|
})
|
|
)
|
|
})
|
|
})
|
|
|
|
// ─── GET /api/sprints/:id/tasks ───────────────────────────────────────────────
|
|
|
|
describe('GET /api/sprints/:id/tasks', () => {
|
|
// TC-ST-01
|
|
it('returns 401 when no valid token provided', async () => {
|
|
mockAuth.mockResolvedValue(UNAUTHORIZED)
|
|
const res = await getSprintTasks(
|
|
makeGet('http://localhost/api/sprints/sprint-1/tasks'),
|
|
routeCtx('sprint-1')
|
|
)
|
|
expect(res.status).toBe(401)
|
|
})
|
|
|
|
// TC-ST-03
|
|
it('returns 404 when sprint is not found', async () => {
|
|
mockAuth.mockResolvedValue(USER_1_AUTH)
|
|
mockPrisma.sprint.findFirst.mockResolvedValue(null)
|
|
|
|
const res = await getSprintTasks(
|
|
makeGet('http://localhost/api/sprints/nonexistent/tasks'),
|
|
routeCtx('nonexistent')
|
|
)
|
|
expect(res.status).toBe(404)
|
|
})
|
|
|
|
// TC-ST-04
|
|
it('returns 404 for another user\'s sprint', async () => {
|
|
mockAuth.mockResolvedValue(USER_2_AUTH)
|
|
mockPrisma.sprint.findFirst.mockResolvedValue(null)
|
|
|
|
const res = await getSprintTasks(
|
|
makeGet('http://localhost/api/sprints/sprint-1/tasks'),
|
|
routeCtx('sprint-1')
|
|
)
|
|
expect(res.status).toBe(404)
|
|
expect(mockPrisma.sprint.findFirst).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({
|
|
id: 'sprint-1',
|
|
product: expect.objectContaining({
|
|
OR: expect.arrayContaining([{ user_id: 'user-2' }]),
|
|
}),
|
|
}),
|
|
})
|
|
)
|
|
})
|
|
})
|
|
|
|
// ─── POST /api/stories/:id/log ────────────────────────────────────────────────
|
|
|
|
describe('POST /api/stories/:id/log', () => {
|
|
const VALID_BODY = { type: 'IMPLEMENTATION_PLAN', content: 'Plan: step 1' }
|
|
|
|
// TC-L-01
|
|
it('returns 401 when no valid token provided', async () => {
|
|
mockAuth.mockResolvedValue(UNAUTHORIZED)
|
|
const res = await postStoryLog(
|
|
makePost('http://localhost/api/stories/story-1/log', VALID_BODY),
|
|
routeCtx('story-1')
|
|
)
|
|
expect(res.status).toBe(401)
|
|
})
|
|
|
|
// TC-L-03
|
|
it('returns 403 for demo users', async () => {
|
|
mockAuth.mockResolvedValue(DEMO_AUTH)
|
|
const res = await postStoryLog(
|
|
makePost('http://localhost/api/stories/story-1/log', 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-L-04 / TC-L-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 postStoryLog(
|
|
makePost('http://localhost/api/stories/story-1/log', 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' }]),
|
|
}),
|
|
}),
|
|
})
|
|
)
|
|
})
|
|
})
|
|
|
|
// ─── PATCH /api/tasks/:id ─────────────────────────────────────────────────────
|
|
|
|
describe('PATCH /api/tasks/:id', () => {
|
|
// TC-T-01
|
|
it('returns 401 when no valid token provided', async () => {
|
|
mockAuth.mockResolvedValue(UNAUTHORIZED)
|
|
const res = await patchTask(
|
|
makePatch('http://localhost/api/tasks/task-1', { status: 'DONE' }),
|
|
routeCtx('task-1')
|
|
)
|
|
expect(res.status).toBe(401)
|
|
})
|
|
|
|
// TC-T-03
|
|
it('returns 403 for demo users', async () => {
|
|
mockAuth.mockResolvedValue(DEMO_AUTH)
|
|
const res = await patchTask(
|
|
makePatch('http://localhost/api/tasks/task-1', { status: 'DONE' }),
|
|
routeCtx('task-1')
|
|
)
|
|
expect(res.status).toBe(403)
|
|
const data = await res.json()
|
|
expect(data.error).toBeTruthy()
|
|
})
|
|
|
|
// TC-T-04
|
|
it('returns 404 when task does not exist', async () => {
|
|
mockAuth.mockResolvedValue(USER_1_AUTH)
|
|
mockPrisma.task.findFirst.mockResolvedValue(null)
|
|
|
|
const res = await patchTask(
|
|
makePatch('http://localhost/api/tasks/nonexistent', { status: 'DONE' }),
|
|
routeCtx('nonexistent')
|
|
)
|
|
expect(res.status).toBe(404)
|
|
})
|
|
|
|
// TC-T-05
|
|
it('returns 403 when task belongs to a different user', async () => {
|
|
mockAuth.mockResolvedValue(USER_2_AUTH)
|
|
mockPrisma.task.findFirst.mockResolvedValue({
|
|
id: 'task-1',
|
|
story: { product: { user_id: 'user-1' } },
|
|
})
|
|
|
|
const res = await patchTask(
|
|
makePatch('http://localhost/api/tasks/task-1', { status: 'DONE' }),
|
|
routeCtx('task-1')
|
|
)
|
|
expect(res.status).toBe(403)
|
|
})
|
|
|
|
// TC-T-08 (happy path, sanity check)
|
|
it('returns 200 when task belongs to the authenticated user', async () => {
|
|
mockAuth.mockResolvedValue(USER_1_AUTH)
|
|
mockPrisma.task.findFirst.mockResolvedValue({
|
|
id: 'task-1',
|
|
story: { product: { user_id: 'user-1' } },
|
|
})
|
|
mockPrisma.task.update.mockResolvedValue({
|
|
id: 'task-1',
|
|
title: 'Task',
|
|
status: 'DONE',
|
|
story_id: 'story-1',
|
|
implementation_plan: null,
|
|
})
|
|
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }])
|
|
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
|
id: 'story-1',
|
|
status: 'DONE',
|
|
pbi_id: 'pbi-1',
|
|
sprint_id: null,
|
|
})
|
|
mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }])
|
|
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'DONE' })
|
|
|
|
const res = await patchTask(
|
|
makePatch('http://localhost/api/tasks/task-1', { status: 'done' }),
|
|
routeCtx('task-1')
|
|
)
|
|
expect(res.status).toBe(200)
|
|
})
|
|
})
|