Files
accounted/app/api/notifications/bookkeeping-digest/cron/__tests__/route.test.ts
T
MattssonandClaude Fable 5 6dfaa45061 feat(notifications): opt-in daily "nytt att bokföra" email digest (#2078)
* feat(notifications): opt-in daily "nytt att bokföra" email digest

Users asked for an email when new work arrives: bank transactions that
synced overnight and documents that landed in the inbox. Adds a daily
05:45 UTC cron (after the 05:00 bank sync) that emails opted-in users a
per-company summary with counts only, no amounts (data-minimization
stance of the kvittens/skattekonto mails).

- notification_settings.email_digest_enabled, NOT NULL DEFAULT false:
  strictly opt-in via a new toggle in the notification settings panel
- notification_log type 'bookkeeping_digest' with the claim-then-send
  partial unique index pattern: one mail per user per company per day
- counts unbooked transactions and unprocessed inbox items created in
  the last 24h; empty digests are never sent
- brand-aware sender + link base via lib/email/brand-sender
- docker crontabs regenerated from vercel.json

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

* fix(notifications): digest review findings in one pass

Skeptic + bot findings on PR #2078, resolved together:

- Count queries now match the canonical worklist anchors: ignored
  transactions excluded; inbox items already booked directly or matched
  to a transaction (created_journal_entry_id / matched_transaction_id)
  no longer counted (skeptic: spurious digests).
- Memberships sweep and member-email lookups chunk .in() id lists at 150
  ids to stay under proxy URL limits (HTTP 414 at ~350 opted-in users).
- Recoverable claim lifecycle (CodeRabbit): claim inserts as 'pending',
  flips to 'sent' only after the provider accepted the mail; a stale
  pending claim is atomically taken over by a later run, so a worker
  death mid-send no longer swallows the day's digest. New migration
  20260831110000 admits 'pending' to the delivery_status CHECK.
- Company name sanitized against CRLF header injection before the mail
  subject (compliance swarm ASVS V1.2.5), with test.
- RoPA entry for the new processing activity in .compliance/ropa.yaml
  (compliance swarm GDPR Art. 30).

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

* fix(types): admit 'pending' to NotificationLog delivery_status union

Matches migration 20260831110000; surfaced by fix re-verification.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 14:15:31 +02:00

81 lines
2.4 KiB
TypeScript

/**
* The cron shell around the digest: authorization and summary aggregation.
* The digest logic itself is covered by
* lib/notifications/__tests__/bookkeeping-digest.test.ts.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
vi.mock('@/lib/auth/cron', () => ({
verifyCronSecret: vi.fn(() => null),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: vi.fn(() => ({})),
}))
const mockRunDigest = vi.fn()
vi.mock('@/lib/notifications/bookkeeping-digest', () => ({
runBookkeepingDigest: (...args: unknown[]) => mockRunDigest(...args),
}))
import { verifyCronSecret } from '@/lib/auth/cron'
import { createServiceClient } from '@/lib/supabase/server'
import { GET } from '../route'
function request(): Request {
return new Request('https://app.testbrand.example/api/notifications/bookkeeping-digest/cron')
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(verifyCronSecret).mockReturnValue(null)
})
describe('GET /api/notifications/bookkeeping-digest/cron', () => {
it('rejects an unauthorized caller without touching the database', async () => {
vi.mocked(verifyCronSecret).mockReturnValueOnce(
NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
)
const response = await GET(request())
expect(response.status).toBe(401)
expect(vi.mocked(createServiceClient)).not.toHaveBeenCalled()
expect(mockRunDigest).not.toHaveBeenCalled()
})
it('runs the digest and returns its summary', async () => {
mockRunDigest.mockResolvedValueOnce({
optedInUsers: 2,
companiesConsidered: 1,
sent: 2,
skippedEmpty: 0,
skippedDuplicate: 0,
failed: 0,
})
const response = await GET(request())
const body = await response.json()
expect(response.status).toBe(200)
expect(body).toMatchObject({ success: true, sent: 2, optedInUsers: 2 })
expect(mockRunDigest).toHaveBeenCalledTimes(1)
expect(mockRunDigest.mock.calls[0][1]).toBeInstanceOf(Date)
})
it('maps a thrown digest failure to the canonical error envelope', async () => {
mockRunDigest.mockRejectedValueOnce(new Error('boom'))
const response = await GET(request())
expect(response.status).toBeGreaterThanOrEqual(500)
const body = await response.json()
expect(body.error).toBeDefined()
})
})