* 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>
717 lines
23 KiB
TypeScript
717 lines
23 KiB
TypeScript
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 })
|
|
})
|
|
})
|