actions: idea-job triggers + cancel (M12 T-497)
actions/ideas.ts:
- startGrillJobAction(id) — DRAFT/GRILLED/GRILL_FAILED/PLAN_READY → GRILLING;
validates product+repo_url, idempotency check (active job 409),
worker-count check (15s freshness), atomic $transaction creates ClaudeJob
+ flips idea.status + IdeaLog{JOB_EVENT}, manual pg_notify
- startMakePlanJobAction(id) — GRILLED/PLAN_FAILED/PLAN_READY → PLANNING;
same shape via shared startIdeaJob helper
- cancelIdeaJobAction(id) — finds active QUEUED|CLAIMED|RUNNING job for idea,
reverts status: grill→DRAFT/GRILLED based on grill_md presence;
plan→GRILLED/PLAN_READY based on plan_md presence
Tests: 31 cases incl. happy path, demo-403, no-product/no-repo-422,
no-worker-422, idempotency-409, status-mismatch-422, cancel revert paths,
404 no-active-job.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5f410d3b10
commit
33cbb6c2f4
2 changed files with 348 additions and 2 deletions
|
|
@ -24,7 +24,16 @@ vi.mock('@/lib/prisma', () => ({
|
|||
delete: vi.fn(),
|
||||
},
|
||||
ideaLog: { create: vi.fn() },
|
||||
claudeJob: {
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
claudeWorker: {
|
||||
count: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
$executeRaw: vi.fn().mockResolvedValue(0),
|
||||
},
|
||||
}))
|
||||
|
||||
|
|
@ -37,9 +46,19 @@ import {
|
|||
updateGrillMdAction,
|
||||
updatePlanMdAction,
|
||||
downloadIdeaMdAction,
|
||||
startGrillJobAction,
|
||||
startMakePlanJobAction,
|
||||
cancelIdeaJobAction,
|
||||
} 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> }; $transaction: ReturnType<typeof vi.fn> }
|
||||
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> }
|
||||
$transaction: ReturnType<typeof vi.fn>
|
||||
$executeRaw: ReturnType<typeof vi.fn>
|
||||
}
|
||||
const m = prisma as unknown as MockIdea
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -207,6 +226,138 @@ body
|
|||
})
|
||||
})
|
||||
|
||||
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('downloadIdeaMdAction', () => {
|
||||
it('returns grill_md when present', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue