diff --git a/__tests__/actions/ideas-crud.test.ts b/__tests__/actions/ideas-crud.test.ts index 525c56f..6c038fc 100644 --- a/__tests__/actions/ideas-crud.test.ts +++ b/__tests__/actions/ideas-crud.test.ts @@ -35,7 +35,9 @@ vi.mock('@/lib/prisma', () => ({ pbi: { findFirst: vi.fn(), findMany: vi.fn(), + findUnique: vi.fn(), create: vi.fn(), + delete: vi.fn(), }, story: { findMany: vi.fn(), @@ -44,6 +46,7 @@ vi.mock('@/lib/prisma', () => ({ task: { findMany: vi.fn(), create: vi.fn(), + count: vi.fn(), }, $transaction: vi.fn(), $executeRaw: vi.fn().mockResolvedValue(0), @@ -71,9 +74,9 @@ type MockIdea = { ideaLog: { create: ReturnType } claudeJob: { findFirst: ReturnType; create: ReturnType; update: ReturnType } claudeWorker: { count: ReturnType } - pbi: { findFirst: ReturnType; findMany: ReturnType; create: ReturnType } + pbi: { findFirst: ReturnType; findMany: ReturnType; findUnique: ReturnType; create: ReturnType; delete: ReturnType } story: { findMany: ReturnType; create: ReturnType } - task: { findMany: ReturnType; create: ReturnType } + task: { findMany: ReturnType; create: ReturnType; count: ReturnType } $transaction: ReturnType $executeRaw: ReturnType } @@ -476,6 +479,69 @@ body }) }) +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({