feat(ideas): upload-plan knop — short-circuit van Make-Plan AI-flow (#197)
Voegt een 'Upload plan' knop toe in idea-row-actions (verschijnt in zowel list als idea-detail). Klik → file picker → kies .md → server-side parse + opslaan; idea-status springt naar PLAN_READY. Vandaaruit de bestaande 'Maak PBI' knop voor materialize. Server (uploadPlanMdAction): - Toegestaan vanuit DRAFT, GRILLED, PLAN_FAILED, PLAN_READY - DRAFT → skip-grill: status gaat direct naar PLAN_READY - PLAN_READY overschrijft het bestaande plan (consistent met updatePlanMdAction, geen confirmation) - Geblokkeerd in GRILLING/PLANNING (job loopt), PLANNED (al gematerialiseerd) - Parse-failure → 422 + details (NIET opslaan, zodat een onparseerbaar plan nooit in de DB belandt) - Empty / >100k chars → 422 - Schrijft IdeaLog NOTE met from_status + length - Rate-limit + demo-guard + ownership-check via loadOwnedIdea (zelfde patroon als updatePlanMdAction) UI (idea-row-actions.tsx): - Hidden <input type=file accept=".md,.markdown,text/markdown,text/plain"> - FileReader → text → action - Toast bij success + router.refresh() - Blocked-tooltip in andere statussen Tests: 10 nieuwe in __tests__/actions/ideas-crud.test.ts dekkend voor: happy paths (DRAFT/GRILLED/PLAN_READY-overwrite/PLAN_FAILED), blocks (PLANNED/GRILLING), validation (empty/oversized/parse-fail), 404. Full suite groen: 849/849. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
551550791e
commit
76c2efd27c
3 changed files with 224 additions and 1 deletions
|
|
@ -65,6 +65,7 @@ import {
|
|||
deleteIdeaAction,
|
||||
updateGrillMdAction,
|
||||
updatePlanMdAction,
|
||||
uploadPlanMdAction,
|
||||
downloadIdeaMdAction,
|
||||
startGrillJobAction,
|
||||
startMakePlanJobAction,
|
||||
|
|
@ -251,6 +252,97 @@ body
|
|||
})
|
||||
})
|
||||
|
||||
describe('uploadPlanMdAction', () => {
|
||||
const VALID_PLAN = `---
|
||||
pbi:
|
||||
title: Uploaded
|
||||
priority: 2
|
||||
stories:
|
||||
- title: S1
|
||||
priority: 2
|
||||
tasks:
|
||||
- title: T1
|
||||
priority: 2
|
||||
---
|
||||
|
||||
body
|
||||
`
|
||||
|
||||
it('happy: uploads from DRAFT — skips grill, sets PLAN_READY', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce({ status: 'DRAFT' })
|
||||
const r = await uploadPlanMdAction('idea-1', VALID_PLAN)
|
||||
expect(r).toEqual({ success: true })
|
||||
expect(m.$transaction).toHaveBeenCalled()
|
||||
const txnArg = m.$transaction.mock.calls.at(-1)?.[0] as unknown[] | undefined
|
||||
expect(txnArg).toBeDefined()
|
||||
// The first call in the transaction is the update — confirm status=PLAN_READY.
|
||||
expect(m.idea.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ plan_md: VALID_PLAN, status: 'PLAN_READY' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('happy: uploads from GRILLED', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce({ status: 'GRILLED' })
|
||||
const r = await uploadPlanMdAction('idea-1', VALID_PLAN)
|
||||
expect(r).toEqual({ success: true })
|
||||
})
|
||||
|
||||
it('happy: overwrites existing plan from PLAN_READY', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce({ status: 'PLAN_READY' })
|
||||
const r = await uploadPlanMdAction('idea-1', VALID_PLAN)
|
||||
expect(r).toEqual({ success: true })
|
||||
})
|
||||
|
||||
it('happy: uploads from PLAN_FAILED (retry)', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce({ status: 'PLAN_FAILED' })
|
||||
const r = await uploadPlanMdAction('idea-1', VALID_PLAN)
|
||||
expect(r).toEqual({ success: true })
|
||||
})
|
||||
|
||||
it('rejects from PLANNED (already materialized)', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce({ status: 'PLANNED' })
|
||||
const r = await uploadPlanMdAction('idea-1', VALID_PLAN)
|
||||
expect(r).toMatchObject({ code: 422 })
|
||||
expect(m.$transaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects from GRILLING (job running)', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce({ status: 'GRILLING' })
|
||||
const r = await uploadPlanMdAction('idea-1', VALID_PLAN)
|
||||
expect(r).toMatchObject({ code: 422 })
|
||||
})
|
||||
|
||||
it('rejects empty markdown', async () => {
|
||||
const r = await uploadPlanMdAction('idea-1', ' \n ')
|
||||
expect(r).toMatchObject({ code: 422 })
|
||||
// Should fail before touching DB
|
||||
expect(m.idea.findFirst).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects oversized markdown', async () => {
|
||||
const huge = 'a'.repeat(100_001)
|
||||
const r = await uploadPlanMdAction('idea-1', huge)
|
||||
expect(r).toMatchObject({ code: 422 })
|
||||
expect(m.idea.findFirst).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects invalid yaml (parse-fail 422 with details)', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce({ status: 'DRAFT' })
|
||||
const r = await uploadPlanMdAction('idea-1', '# no frontmatter')
|
||||
expect(r).toMatchObject({ code: 422 })
|
||||
expect((r as { details?: unknown }).details).toBeDefined()
|
||||
expect(m.$transaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 404 when idea not found', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce(null)
|
||||
const r = await uploadPlanMdAction('nope', VALID_PLAN)
|
||||
expect(r).toMatchObject({ code: 404 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('startGrillJobAction', () => {
|
||||
const idea = {
|
||||
id: 'idea-1',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue