Scrum4Me/__tests__/api/cron-expire-questions.test.ts
Madhura68 eeed5d7506 feat(ST-1107): add Vercel cron expire-questions (+ M10 pairing cleanup)
POST /api/cron/expire-questions:
- Auth via Authorization: Bearer ${CRON_SECRET} (Vercel injecteert dit
  automatisch wanneer de env-var op de project-omgeving staat); 401 als secret
  niet matcht of niet is gezet (faal-veilig — geen open endpoint in dev)
- updateMany op claude_questions WHERE status='open' AND expires_at<now →
  'expired'
- Bonus: zelfde route ruimt M10 login_pairings op (status='pending' AND
  expires_at<now → 'cancelled'). Eén cron-job is goedkoper qua Vercel-budget
  en houdt cleanup-strategie centraal — opvolg-actie uit M10 dat geparkeerd was.

Config:
- vercel.json: crons-entry { path: '/api/cron/expire-questions', schedule: '0 */6 * * *' } (4x/dag)
- lib/env.ts: CRON_SECRET als optional in Zod-schema
- .env.example: documentatie + openssl rand-tip

Tests __tests__/api/cron-expire-questions.test.ts (4 cases):
- 401 zonder Authorization-header
- 401 met verkeerde secret
- 401 als CRON_SECRET niet is gezet (faal-veilig)
- 200 met juiste secret: response { expired_questions, expired_pairings, ran_at }
  + beide updateMany WHERE/data correct

Quality gates: lint 0 errors, tsc clean, vitest 151/151.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 01:51:48 +02:00

77 lines
2.7 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/lib/prisma', () => ({
prisma: {
claudeQuestion: { updateMany: vi.fn() },
loginPairing: { updateMany: vi.fn() },
},
}))
import { prisma } from '@/lib/prisma'
import { POST } from '@/app/api/cron/expire-questions/route'
const mockPrisma = prisma as unknown as {
claudeQuestion: { updateMany: ReturnType<typeof vi.fn> }
loginPairing: { updateMany: ReturnType<typeof vi.fn> }
}
const SECRET = 'test-cron-secret-abc123'
function makeReq(headers: Record<string, string> = {}): Request {
return new Request('http://localhost:3000/api/cron/expire-questions', {
method: 'POST',
headers,
})
}
beforeEach(() => {
vi.clearAllMocks()
process.env.CRON_SECRET = SECRET
mockPrisma.claudeQuestion.updateMany.mockResolvedValue({ count: 0 })
mockPrisma.loginPairing.updateMany.mockResolvedValue({ count: 0 })
})
describe('POST /api/cron/expire-questions', () => {
it('401 zonder Authorization-header', async () => {
const res = await POST(makeReq())
expect(res.status).toBe(401)
expect(mockPrisma.claudeQuestion.updateMany).not.toHaveBeenCalled()
expect(mockPrisma.loginPairing.updateMany).not.toHaveBeenCalled()
})
it('401 met verkeerde secret', async () => {
const res = await POST(makeReq({ authorization: 'Bearer wrong-secret' }))
expect(res.status).toBe(401)
})
it('401 als CRON_SECRET niet is gezet (faal-veilig)', async () => {
delete process.env.CRON_SECRET
const res = await POST(makeReq({ authorization: 'Bearer ' + SECRET }))
expect(res.status).toBe(401)
})
it('200 met juiste secret + beide updateMany aangeroepen', async () => {
mockPrisma.claudeQuestion.updateMany.mockResolvedValue({ count: 3 })
mockPrisma.loginPairing.updateMany.mockResolvedValue({ count: 1 })
const res = await POST(makeReq({ authorization: 'Bearer ' + SECRET }))
expect(res.status).toBe(200)
const body = await res.json()
expect(body).toMatchObject({
expired_questions: 3,
expired_pairings: 1,
})
expect(body.ran_at).toMatch(/^\d{4}-\d{2}-\d{2}T/)
// Question-update: status='open' AND expires_at < now → 'expired'
const qArg = mockPrisma.claudeQuestion.updateMany.mock.calls[0][0]
expect(qArg.where).toMatchObject({ status: 'open' })
expect(qArg.where.expires_at).toMatchObject({ lt: expect.any(Date) })
expect(qArg.data).toEqual({ status: 'expired' })
// Pairing-update: status='pending' AND expires_at < now → 'cancelled'
const pArg = mockPrisma.loginPairing.updateMany.mock.calls[0][0]
expect(pArg.where).toMatchObject({ status: 'pending' })
expect(pArg.data).toEqual({ status: 'cancelled' })
})
})