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(), }, $transaction: vi.fn(), $executeRaw: vi.fn().mockResolvedValue(0), }, })) import { prisma } from '@/lib/prisma' import { createIdeaAction, updateIdeaAction, archiveIdeaAction, deleteIdeaAction, updateGrillMdAction, updatePlanMdAction, downloadIdeaMdAction, startGrillJobAction, startMakePlanJobAction, cancelIdeaJobAction, materializeIdeaPlanAction, relinkIdeaPlanAction, } from '@/actions/ideas' type MockIdea = { idea: { create: ReturnType; findFirst: ReturnType; update: ReturnType; delete: ReturnType } ideaLog: { create: ReturnType } claudeJob: { findFirst: ReturnType; create: ReturnType; update: ReturnType } claudeWorker: { count: ReturnType } pbi: { findFirst: ReturnType; findMany: ReturnType; findUnique: ReturnType; create: ReturnType; delete: ReturnType } story: { findMany: ReturnType; create: ReturnType } task: { findMany: ReturnType; create: ReturnType; count: ReturnType } $transaction: ReturnType $executeRaw: ReturnType } 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('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', 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('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 }) }) })