import { describe, it, expect, vi, beforeEach } from 'vitest' vi.mock('@/lib/prisma', () => ({ prisma: { product: { findFirst: vi.fn() }, idea: { findFirst: vi.fn(), findMany: vi.fn(), create: vi.fn(), update: vi.fn(), }, ideaLog: { findMany: vi.fn() }, $transaction: vi.fn(), }, })) vi.mock('@/lib/api-auth', () => ({ authenticateApiRequest: vi.fn(), })) vi.mock('@/lib/idea-code-server', () => ({ nextIdeaCode: vi.fn().mockResolvedValue('IDEA-001'), })) import { prisma } from '@/lib/prisma' import { authenticateApiRequest } from '@/lib/api-auth' import { GET as getIdeas, POST as postIdea } from '@/app/api/ideas/route' import { GET as getIdea, PATCH as patchIdea } from '@/app/api/ideas/[id]/route' type M = { product: { findFirst: ReturnType } idea: { findFirst: ReturnType; findMany: ReturnType; create: ReturnType; update: ReturnType } ideaLog: { findMany: ReturnType } $transaction: ReturnType } const m = prisma as unknown as M const mockAuth = authenticateApiRequest as ReturnType const NOW = new Date('2026-05-04T19:00:00Z') const IDEA_ROW = { id: 'idea-1', user_id: 'user-1', code: 'IDEA-001', title: 'Plant-watering reminder', description: null, status: 'DRAFT' as const, product_id: null, product: null, pbi: null, pbi_id: null, archived: false, grill_md: null, plan_md: null, created_at: NOW, updated_at: NOW, } function makeRequest(method: 'GET' | 'POST' | 'PATCH', url: string, body?: unknown): Request { return new Request(`http://localhost${url}`, { method, headers: { Authorization: 'Bearer test-token', 'Content-Type': 'application/json', }, body: body !== undefined ? JSON.stringify(body) : undefined, }) } beforeEach(() => { vi.clearAllMocks() mockAuth.mockResolvedValue({ userId: 'user-1', isDemo: false }) m.$transaction.mockImplementation(async (arg: unknown) => { if (typeof arg === 'function') return (arg as (tx: unknown) => unknown)(m) return arg }) }) describe('GET /api/ideas', () => { it('returns user ideas (DTO shape)', async () => { m.idea.findMany.mockResolvedValueOnce([IDEA_ROW]) const res = await getIdeas(makeRequest('GET', '/api/ideas')) expect(res.status).toBe(200) const body = await res.json() expect(body.ideas).toHaveLength(1) expect(body.ideas[0]).toMatchObject({ id: 'idea-1', code: 'IDEA-001', status: 'draft', has_grill_md: false, }) }) it('rejects unauthenticated', async () => { mockAuth.mockResolvedValueOnce({ error: 'Unauthorized', status: 401 }) const res = await getIdeas(makeRequest('GET', '/api/ideas')) expect(res.status).toBe(401) }) it('filters by archived=false param', async () => { m.idea.findMany.mockResolvedValueOnce([]) await getIdeas(makeRequest('GET', '/api/ideas?archived=false')) expect(m.idea.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ archived: false, user_id: 'user-1' }), }), ) }) }) describe('POST /api/ideas', () => { it('creates idea and returns 201', async () => { m.idea.create.mockResolvedValueOnce(IDEA_ROW) const res = await postIdea(makeRequest('POST', '/api/ideas', { title: 'Plant-watering reminder' })) expect(res.status).toBe(201) const body = await res.json() expect(body.idea).toMatchObject({ id: 'idea-1', code: 'IDEA-001', status: 'draft' }) }) it('rejects demo with 403', async () => { mockAuth.mockResolvedValueOnce({ userId: 'demo-1', isDemo: true }) const res = await postIdea(makeRequest('POST', '/api/ideas', { title: 'x' })) expect(res.status).toBe(403) }) it('rejects empty title with 422', async () => { const res = await postIdea(makeRequest('POST', '/api/ideas', { title: '' })) expect(res.status).toBe(422) }) it('rejects malformed JSON with 400', async () => { const req = new Request('http://localhost/api/ideas', { method: 'POST', headers: { Authorization: 'Bearer test', 'Content-Type': 'application/json' }, body: 'not-json', }) const res = await postIdea(req) expect(res.status).toBe(400) }) it('returns 404 when product_id refers to a foreign product', async () => { m.product.findFirst.mockResolvedValueOnce(null) const res = await postIdea( makeRequest('POST', '/api/ideas', { title: 'x', product_id: 'cmohrysyj0000rd17clnjy4tc', }), ) expect(res.status).toBe(404) }) }) describe('GET /api/ideas/[id]', () => { it('returns idea + logs', async () => { m.idea.findFirst.mockResolvedValueOnce(IDEA_ROW) m.ideaLog.findMany.mockResolvedValueOnce([ { id: 'l-1', type: 'NOTE', content: 'x', metadata: null, created_at: NOW }, ]) const ctx = { params: Promise.resolve({ id: 'idea-1' }) } const res = await getIdea(makeRequest('GET', '/api/ideas/idea-1'), ctx) expect(res.status).toBe(200) const body = await res.json() expect(body.idea).toMatchObject({ id: 'idea-1' }) expect(body.logs).toHaveLength(1) }) it('returns 404 (not 403) for foreign user — anti-enumeration', async () => { m.idea.findFirst.mockResolvedValueOnce(null) const ctx = { params: Promise.resolve({ id: 'idea-1' }) } const res = await getIdea(makeRequest('GET', '/api/ideas/idea-1'), ctx) expect(res.status).toBe(404) }) }) describe('PATCH /api/ideas/[id]', () => { const ctx = { params: Promise.resolve({ id: 'idea-1' }) } it('updates editable idea', async () => { m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', status: 'DRAFT' }) m.idea.update.mockResolvedValueOnce({ ...IDEA_ROW, title: 'Updated' }) const res = await patchIdea(makeRequest('PATCH', '/api/ideas/idea-1', { title: 'Updated' }), ctx) expect(res.status).toBe(200) }) it('blocks demo with 403', async () => { mockAuth.mockResolvedValueOnce({ userId: 'demo-1', isDemo: true }) const res = await patchIdea(makeRequest('PATCH', '/api/ideas/idea-1', { title: 'x' }), ctx) expect(res.status).toBe(403) }) it('blocks update on PLANNED with 422', async () => { m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', status: 'PLANNED' }) const res = await patchIdea(makeRequest('PATCH', '/api/ideas/idea-1', { title: 'x' }), ctx) expect(res.status).toBe(422) }) })