actions: materializeIdeaPlanAction + relinkIdeaPlanAction (M12 T-498)
actions/ideas.ts:
- materializeIdeaPlanAction(id):
- guard: status===PLAN_READY, plan_md present, product linked, demo-403
- parsePlanMd → 422 with line-info on fail
- Prisma.\$transaction:
- SELECT max(code) for PBI/Story/Task within product
- INSERT PBI with sort_order = lastPbi+1 within priority
- per story: INSERT (sequential ST-NNN), per task: INSERT (T-N)
- UPDATE idea SET pbi_id, status=PLANNED
- INSERT IdeaLog{PLAN_RESULT, metadata}
- returns 409 on P2002 (concurrent-materialize race)
- relinkIdeaPlanAction(id):
- guard: status===PLANNED && pbi_id===null (PBI manually deleted via SetNull FK)
- reverts to PLAN_READY + IdeaLog{NOTE}
Tests: 39 cases total (8 new for materialize + relink): happy creates entities,
status-mismatch-422, parse-fail-422 with details, demo-403, P2002→409,
relink happy + invalid-precondition guards.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
33cbb6c2f4
commit
6fee0394c5
2 changed files with 360 additions and 0 deletions
|
|
@ -32,6 +32,19 @@ vi.mock('@/lib/prisma', () => ({
|
|||
claudeWorker: {
|
||||
count: vi.fn(),
|
||||
},
|
||||
pbi: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
story: {
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
task: {
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
$executeRaw: vi.fn().mockResolvedValue(0),
|
||||
},
|
||||
|
|
@ -49,6 +62,8 @@ import {
|
|||
startGrillJobAction,
|
||||
startMakePlanJobAction,
|
||||
cancelIdeaJobAction,
|
||||
materializeIdeaPlanAction,
|
||||
relinkIdeaPlanAction,
|
||||
} from '@/actions/ideas'
|
||||
|
||||
type MockIdea = {
|
||||
|
|
@ -56,6 +71,9 @@ type MockIdea = {
|
|||
ideaLog: { create: ReturnType<typeof vi.fn> }
|
||||
claudeJob: { findFirst: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
|
||||
claudeWorker: { count: ReturnType<typeof vi.fn> }
|
||||
pbi: { findFirst: ReturnType<typeof vi.fn>; findMany: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn> }
|
||||
story: { findMany: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn> }
|
||||
task: { findMany: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn> }
|
||||
$transaction: ReturnType<typeof vi.fn>
|
||||
$executeRaw: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
|
@ -358,6 +376,139 @@ describe('cancelIdeaJobAction', () => {
|
|||
})
|
||||
})
|
||||
|
||||
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', 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)
|
||||
})
|
||||
|
||||
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('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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue