* 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>
147 lines
4.5 KiB
TypeScript
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'
|
|
)
|
|
})
|
|
})
|