actions: ideas CRUD + grill_md/plan_md edit + download (M12 T-496)
actions/ideas.ts (strikt user_id-only, geen productAccessFilter):
- createIdeaAction(input) — atomic nextIdeaCode + idea.create in $transaction
- updateIdeaAction(id, input) — guards on isIdeaEditable
- archiveIdeaAction / unarchiveIdeaAction
- deleteIdeaAction — refuses when pbi_id linked
- updateGrillMdAction — only in GRILLED|PLAN_READY; logs IdeaLog{NOTE}
- updatePlanMdAction — only in PLAN_READY; runs parsePlanMd; 422 with details on fail
- downloadIdeaMdAction — read-only, demo allowed
Added rate-limit configs: create-idea, edit-idea-md, start-idea-job,
materialize-idea.
Tests: 19 cases covering auth (401), demo (403), zod (422), status guards
(422), 404 cross-user-scope, plan-md parse-fail with details.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4d2e4b0b4b
commit
5f410d3b10
3 changed files with 537 additions and 0 deletions
244
__tests__/actions/ideas-crud.test.ts
Normal file
244
__tests__/actions/ideas-crud.test.ts
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
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() },
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
createIdeaAction,
|
||||
updateIdeaAction,
|
||||
archiveIdeaAction,
|
||||
deleteIdeaAction,
|
||||
updateGrillMdAction,
|
||||
updatePlanMdAction,
|
||||
downloadIdeaMdAction,
|
||||
} 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> }
|
||||
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('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 })
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue