Files
accounted/app/api/invoices/reminders/cron/__tests__/route.test.ts
T
MattssonandClaude Fable 5 e92b5a59b2 fix(reminders): settings UI discloses that automatic sending is disabled (#2033)
* fix(reminders): settings UI discloses that automatic sending is disabled

The invoice reminder cron has answered 503 since May 2026 (PR #583), so
no automatic reminders are sent, but the settings UI still let users
configure reminder day levels as if sending worked.

Introduce REMINDERS_SENDING_ENABLED (lib/invoices/reminders-enabled.ts)
as the single shared flag read by both sides: the cron route uses it as
its 503 gate (with the original sending pipeline restored behind it, so
re-enabling later is one flag flip), and the invoice settings form shows
an attn notice while the flag is off. Schedule settings stay editable;
notice strings added to both sv and en locales.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98

* docs(reminders): correct the re-enable contract after skeptic review

The flag docblock, route docblock, and test header claimed flipping
REMINDERS_SENDING_ENABLED alone resumes sending. False on hosted: the
route has had no vercel.json cron entry since PR #559 and the crontab
ratchet pins it in INTENTIONALLY_UNSCHEDULED, and POST requires the cron
secret so no dashboard can trigger it. Rewrite the claims into the real
re-enable checklist and record the pre-flip prerequisites surfaced by
review: invoice_reminders lacks a unique (invoice_id, reminder_level)
constraint and the fee entry is booked before the reminder row, so a run
dying mid-batch double-books the fee; the backlog would get highest-level
reminders first. Comments and a test name only; no runtime change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 21:43:19 +02:00

147 lines
4.5 KiB
TypeScript

/**
* Tests for the invoice reminder cron gate: automatic reminder sending has
* been deliberately disabled since May 2026 (PR #583). The route and the
* invoice settings UI both read REMINDERS_SENDING_ENABLED, so these tests
* pin the contract: while the flag is off the route answers 503 and never
* touches the reminder processor; when the flag is flipped on, the route
* runs the original sending pipeline. Scheduling the route again (it has
* had no vercel.json cron entry since PR #559) and the pre-flip idempotency
* work are separate steps; see lib/invoices/reminders-enabled.ts.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const h = vi.hoisted(() => ({
logInfo: vi.fn(),
logError: vi.fn(),
processOverdueReminders: vi.fn(),
isConfigured: vi.fn(),
}))
vi.mock('@/lib/api/with-cron-context', () => ({
withCronContext:
(_name: string, handler: (req: Request, ctx: unknown) => Promise<Response>) =>
(req: Request) =>
handler(req, {
log: { info: h.logInfo, error: h.logError, warn: vi.fn() },
requestId: 'req_test',
}),
}))
vi.mock('@/lib/invoices/reminder-processor', () => ({
processOverdueReminders: h.processOverdueReminders,
}))
vi.mock('@/lib/email/service', () => ({
getEmailService: () => ({ isConfigured: h.isConfigured }),
}))
import { GET, POST } from '../route'
import { REMINDERS_SENDING_ENABLED } from '@/lib/invoices/reminders-enabled'
function cronRequest(): Request {
return new Request('http://localhost:3000/api/invoices/reminders/cron')
}
describe('REMINDERS_SENDING_ENABLED flag', () => {
it('is off: re-enabling automatic reminder sending is a deliberate founder decision', () => {
expect(REMINDERS_SENDING_ENABLED).toBe(false)
})
})
describe('GET /api/invoices/reminders/cron with sending disabled', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns 503 and never invokes the reminder processor', async () => {
const res = await GET(cronRequest())
const body = await res.json()
expect(res.status).toBe(503)
expect(body).toEqual({ disabled: true })
expect(h.processOverdueReminders).not.toHaveBeenCalled()
expect(h.isConfigured).not.toHaveBeenCalled()
expect(h.logInfo).toHaveBeenCalledWith(
'invoice reminders feature is disabled; skipping run'
)
})
it('exposes POST as the same gated handler (manual dashboard trigger)', async () => {
expect(POST).toBe(GET)
const res = await POST(cronRequest())
expect(res.status).toBe(503)
expect(h.processOverdueReminders).not.toHaveBeenCalled()
})
})
describe('GET /api/invoices/reminders/cron with sending enabled (flag flipped)', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
async function importRouteWithFlagOn() {
vi.doMock('@/lib/invoices/reminders-enabled', () => ({
REMINDERS_SENDING_ENABLED: true,
}))
return import('../route')
}
it('runs the reminder pipeline and reports the summary', async () => {
h.isConfigured.mockReturnValue(true)
h.processOverdueReminders.mockResolvedValue({
processed: 2,
sent: 1,
failed: 1,
results: [
{
invoiceId: 'inv-1',
invoiceNumber: 'F-1001',
customerEmail: 'kund@testbrand.example',
reminderLevel: 1,
success: true,
},
{
invoiceId: 'inv-2',
invoiceNumber: 'F-1002',
customerEmail: 'kund2@testbrand.example',
reminderLevel: 2,
success: false,
error: 'bounce',
},
],
})
const { GET: gatedGet } = await importRouteWithFlagOn()
const res = await gatedGet(cronRequest())
const body = await res.json()
expect(res.status).toBe(200)
expect(h.processOverdueReminders).toHaveBeenCalledTimes(1)
expect(body).toEqual({
success: true,
processed: 2,
sent: 1,
failed: 1,
results: [
{ invoiceNumber: 'F-1001', reminderLevel: 1, success: true },
{ invoiceNumber: 'F-1002', reminderLevel: 2, success: false, error: 'bounce' },
],
})
})
it('still refuses to run when the email service is not configured', async () => {
h.isConfigured.mockReturnValue(false)
const { GET: gatedGet } = await importRouteWithFlagOn()
const res = await gatedGet(cronRequest())
expect(res.status).toBeGreaterThanOrEqual(400)
expect(h.processOverdueReminders).not.toHaveBeenCalled()
expect(h.logError).toHaveBeenCalledWith(
'email service not configured; skipping reminder run'
)
})
})