* feat(skatteverket): repair notification recipients + make the agent the SKV notification surface The company_members -> profiles!inner(email) PostgREST embed has no FK to traverse (company_members.user_id references auth.users), so it 400'd and silently killed all four notification emails since they shipped. Recipient lookup is now a shared two-step helper (lib/notifications/member-email): kvittens confirmations, skattekonto drift alerts (tax-contact routing preserved via the plural variant) and backup alerts deliver again. The connection-expired email is deleted instead of fixed: with SKV's 65-minute personal sessions it was one mail per connect (see DECISIONS.md); the event and needs_reconsent flagging stay. For MCP-first users the agent is the notification surface, so: - SKATTEVERKET_NOT_CONNECTED copy is now agent-directive: session expiry is normal (~1h by SKV design), only a person can reconnect with BankID, do not retry until they confirm. Inline strings (declaration-status, read routes, v1 pitfalls, accounted-api skill) aligned. - gnubok_get_agent_briefing gains an optional skatteverket_connection block (status/source/connected_at + directive message on needs_reconsent), emitted only when a connection or verified system grant exists, so agents warn the user at session start instead of failing mid-task. Payload bench ceiling bumped 59.95K -> 60.15K for the outputSchema contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skatteverket): drift email resolves recipients via service client; review fixes The skeptic pass refuted the drift-email repair: skattekonto.drift_detected is emitted only by the nightly cron, and the extension registry builds each event handler a fresh ctx from the anonymous cookie client (or none at all on cookieless requests), so RLS returned zero company_members rows and the two-step lookup still resolved no recipient. The handler now builds its own service-role client, the same documented pattern as the retired connection-expired handler; drift tests exercise the handler without ctx, matching the cron reality. CodeRabbit findings: resolveMemberEmails pages both queries through fetchAllRows with stable ordering (PostgREST caps unpaged reads at 1000 rows); the v1 vat-declarations pitfall and regenerated accounted-api docs now name both auth paths (member BankID connection or verified ombud grant); the briefing's system-before-user priority carries a cross-reference to resolveReadAuth explaining why it is not reused. member-email.ts JSDoc states the service-role-client requirement (profiles RLS is own-row-only). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
195 lines
5.9 KiB
TypeScript
195 lines
5.9 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
const sendEmail = vi.fn()
|
|
const isConfigured = vi.fn()
|
|
|
|
vi.mock('@/lib/email/service', () => ({
|
|
getEmailService: () => ({ sendEmail, isConfigured }),
|
|
}))
|
|
|
|
import {
|
|
shouldSendBackupAlert,
|
|
sendBackupFailureAlert,
|
|
ALERT_FAILURE_THRESHOLD,
|
|
ALERT_THROTTLE_MS,
|
|
} from '../backup-alert'
|
|
|
|
const NOW = new Date('2026-07-12T04:00:00.000Z')
|
|
|
|
describe('shouldSendBackupAlert', () => {
|
|
it('alerts immediately on needs_reauth regardless of failure count', () => {
|
|
expect(
|
|
shouldSendBackupAlert({
|
|
kind: 'needs_reauth',
|
|
consecutiveFailures: 1,
|
|
lastAlertAt: null,
|
|
now: NOW,
|
|
})
|
|
).toBe(true)
|
|
})
|
|
|
|
it('requires the failure threshold for repeated_failures', () => {
|
|
expect(
|
|
shouldSendBackupAlert({
|
|
kind: 'repeated_failures',
|
|
consecutiveFailures: ALERT_FAILURE_THRESHOLD - 1,
|
|
lastAlertAt: null,
|
|
now: NOW,
|
|
})
|
|
).toBe(false)
|
|
expect(
|
|
shouldSendBackupAlert({
|
|
kind: 'repeated_failures',
|
|
consecutiveFailures: ALERT_FAILURE_THRESHOLD,
|
|
lastAlertAt: null,
|
|
now: NOW,
|
|
})
|
|
).toBe(true)
|
|
})
|
|
|
|
it('throttles both kinds against last_alert_at', () => {
|
|
const recent = new Date(NOW.getTime() - ALERT_THROTTLE_MS + 60_000).toISOString()
|
|
expect(
|
|
shouldSendBackupAlert({
|
|
kind: 'needs_reauth',
|
|
consecutiveFailures: 0,
|
|
lastAlertAt: recent,
|
|
now: NOW,
|
|
})
|
|
).toBe(false)
|
|
expect(
|
|
shouldSendBackupAlert({
|
|
kind: 'repeated_failures',
|
|
consecutiveFailures: 10,
|
|
lastAlertAt: recent,
|
|
now: NOW,
|
|
})
|
|
).toBe(false)
|
|
|
|
const stale = new Date(NOW.getTime() - ALERT_THROTTLE_MS - 60_000).toISOString()
|
|
expect(
|
|
shouldSendBackupAlert({
|
|
kind: 'needs_reauth',
|
|
consecutiveFailures: 0,
|
|
lastAlertAt: stale,
|
|
now: NOW,
|
|
})
|
|
).toBe(true)
|
|
})
|
|
})
|
|
|
|
/**
|
|
* Supabase stub: the two-step recipient lookup (company_members membership
|
|
* check, then profiles email), and company_settings resolves a company name.
|
|
*/
|
|
function makeSupabase(options: { member?: unknown; companyName?: string | null } = {}) {
|
|
const from = vi.fn().mockImplementation((table: string) => {
|
|
const chain: any = {
|
|
select: vi.fn().mockReturnThis(),
|
|
eq: vi.fn().mockReturnThis(),
|
|
maybeSingle: vi.fn().mockImplementation(() => {
|
|
if (table === 'company_members') {
|
|
return Promise.resolve({
|
|
data: options.member !== undefined ? options.member : { user_id: 'u-1' },
|
|
error: null,
|
|
})
|
|
}
|
|
if (table === 'profiles') {
|
|
return Promise.resolve({ data: { email: 'emil@example.com' }, error: null })
|
|
}
|
|
return Promise.resolve({
|
|
data: { company_name: options.companyName ?? 'Testbolag AB' },
|
|
error: null,
|
|
})
|
|
}),
|
|
}
|
|
return chain
|
|
})
|
|
return { from } as any
|
|
}
|
|
|
|
const baseInput = {
|
|
companyId: 'c-1',
|
|
userId: 'u-1',
|
|
consecutiveFailures: 3,
|
|
errorMessage: 'Drive quota exceeded',
|
|
origin: 'https://app.test',
|
|
} as const
|
|
|
|
describe('sendBackupFailureAlert', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
isConfigured.mockReturnValue(true)
|
|
sendEmail.mockResolvedValue({ success: true })
|
|
})
|
|
|
|
it('does nothing when the email service is not configured', async () => {
|
|
isConfigured.mockReturnValue(false)
|
|
const result = await sendBackupFailureAlert(makeSupabase(), {
|
|
...baseInput,
|
|
kind: 'repeated_failures',
|
|
})
|
|
expect(result).toEqual({ sent: false, reason: 'email_not_configured' })
|
|
expect(sendEmail).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not email a user who is no longer a company member', async () => {
|
|
const result = await sendBackupFailureAlert(makeSupabase({ member: null }), {
|
|
...baseInput,
|
|
kind: 'repeated_failures',
|
|
})
|
|
expect(result).toEqual({ sent: false, reason: 'no_recipient' })
|
|
expect(sendEmail).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('sends a repeated-failures email with count, error and link', async () => {
|
|
const result = await sendBackupFailureAlert(makeSupabase(), {
|
|
...baseInput,
|
|
kind: 'repeated_failures',
|
|
})
|
|
expect(result).toEqual({ sent: true })
|
|
expect(sendEmail).toHaveBeenCalledTimes(1)
|
|
const options = sendEmail.mock.calls[0][0]
|
|
expect(options.to).toBe('emil@example.com')
|
|
expect(options.subject).toContain('misslyckas')
|
|
expect(options.text).toContain('3 nätter i rad')
|
|
expect(options.text).toContain('Drive quota exceeded')
|
|
expect(options.text).toContain('https://app.test/import#cloud-backup')
|
|
expect(options.html).toContain('Testbolag AB')
|
|
})
|
|
|
|
it('sends a needs_reauth email pointing at the reconnect flow', async () => {
|
|
const result = await sendBackupFailureAlert(makeSupabase(), {
|
|
...baseInput,
|
|
kind: 'needs_reauth',
|
|
errorMessage: null,
|
|
})
|
|
expect(result).toEqual({ sent: true })
|
|
const options = sendEmail.mock.calls[0][0]
|
|
expect(options.subject).toContain('pausad')
|
|
expect(options.text).toContain('Koppla om Google Drive')
|
|
expect(options.text).toContain('https://app.test/import#cloud-backup')
|
|
})
|
|
|
|
it('reports send failures without throwing', async () => {
|
|
sendEmail.mockResolvedValue({ success: false, error: 'smtp down' })
|
|
const result = await sendBackupFailureAlert(makeSupabase(), {
|
|
...baseInput,
|
|
kind: 'repeated_failures',
|
|
})
|
|
expect(result).toEqual({ sent: false, reason: 'send_failed' })
|
|
})
|
|
|
|
it('escapes HTML in the error message', async () => {
|
|
await sendBackupFailureAlert(makeSupabase(), {
|
|
...baseInput,
|
|
kind: 'repeated_failures',
|
|
errorMessage: '<script>alert(1)</script>',
|
|
})
|
|
const options = sendEmail.mock.calls[0][0]
|
|
expect(options.html).not.toContain('<script>')
|
|
expect(options.html).toContain('<script>')
|
|
})
|
|
})
|