Files
accounted/extensions/general/enable-banking/__tests__/error-status-retry.test.ts
T
Jakob Wennberg b06d73c23e fix(enable-banking): recover error-state connections, respect PSD2 balance quota, clean error surface (#968)
* fix(enable-banking): recover error-state connections, respect PSD2 balance quota, clean error surface

Three defects from the 2026-07-09 production log triage, all in how the
enable-banking extension handles upstream (Enable Banking / ASPSP) failures:

1. Retry dead-end: a non-session sync failure parked the connection in
   status='error', but POST /sync rejected anything not 'active' with 400,
   so the UI's "Försök igen" button could never succeed and the connection
   stayed stranded until a full re-auth. /sync now accepts 'error' (while
   still rejecting 'expired': a dead consent needs re-authorization), and a
   successful sync restores status='active' and clears error_message.

2. Balance quota burn: every sync (manual or cron) called the BALANCES
   endpoint although PSD2 unattended consents allow only 4 calls/day
   (observed 429 "Consent daily limit 4 is exceeded"), and the retry
   wrapper retried those 429s twice against a daily quota. The sync now
   skips the balance call while the stored balance_updated_at is fresher
   than 12 hours, and authenticatedFetchWithRetry fails fast on a 429
   whose body signals a daily limit.

3. Raw JSON in UI: sync failures persisted the raw English Enable Banking
   error body into bank_connections.error_message, which the settings
   panel renders verbatim. Failures are now mapped to short Swedish user
   messages (shared constants in api-client.ts); the raw body stays in
   server logs only.

Also ratchets the eslint baseline down by 1: the no-explicit-any disable
in the cron route was on the wrong line and never suppressed anything.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(enable-banking): treat future balance timestamps as stale (CodeRabbit)

A future balance_updated_at yielded a negative age that always passed the freshness check, suppressing balance refreshes indefinitely; only 0 <= age < BALANCE_MAX_AGE_MS now counts as fresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 11:04:06 +02:00

184 lines
6.2 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'
import type { ExtensionContext } from '@/lib/extensions/types'
import type { StoredAccount } from '../types'
// Mock the JWT signer so api-client can be imported without real
// ENABLE_BANKING credentials.
vi.mock('../lib/jwt', () => ({
getAuthorizationHeader: () => 'Bearer test-token',
}))
// Mock the sync orchestrator so the /sync handler tests can force success or
// failure without hitting the network.
vi.mock('../lib/sync', () => ({
syncAccountTransactions: vi.fn(),
}))
vi.mock('@/lib/entitlements/has-capability', () => ({
requireCapability: vi.fn().mockResolvedValue(null),
}))
import { SYNC_FAILED_MESSAGE } from '../lib/api-client'
import { enableBankingExtension } from '../index'
import { syncAccountTransactions } from '../lib/sync'
const syncRoute = enableBankingExtension.apiRoutes?.find(
r => r.method === 'POST' && r.path === '/sync'
)
if (!syncRoute) {
throw new Error('POST /sync route not registered on enable-banking extension')
}
const RAW_EB_ERROR =
'Failed to get transactions (400): {"code":400,"message":"Error interacting with ASPSP","detail":null,"error":"ASPSP_ERROR"}'
function makeContext(connection: Record<string, unknown>, updateSpy: Mock): ExtensionContext {
// One universal chainable per from() call (same shape as the other /sync
// handler tests): bank_connections terminates on single(), sie_imports /
// company_members on maybeSingle(), and update() records its payload while
// returning the chain so trailing .eq() calls resolve.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const chain: any = {}
chain.select = vi.fn(() => chain)
chain.eq = vi.fn(() => chain)
chain.gte = vi.fn(() => chain)
chain.limit = vi.fn(() => chain)
chain.order = vi.fn(() => chain)
chain.single = vi.fn().mockResolvedValue({ data: connection, error: null })
chain.maybeSingle = vi.fn().mockResolvedValue({ data: null, error: null })
chain.update = vi.fn((payload: unknown) => {
updateSpy(payload)
return chain
})
const supabase = {
auth: {
getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null }),
},
from: vi.fn(() => chain),
}
return {
userId: 'user-1',
companyId: 'company-1',
extensionId: 'enable-banking',
requestId: 'req_test',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
supabase: supabase as any,
emit: vi.fn().mockResolvedValue(undefined),
settings: { get: vi.fn(), set: vi.fn(), getAll: vi.fn() } as never,
storage: {} as never,
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as never,
services: {} as never,
}
}
function makeRequest(): Request {
return new Request('http://localhost/api/extensions/ext/enable-banking/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connection_id: 'conn-1' }),
})
}
function makeConnection(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: 'conn-1',
company_id: 'company-1',
bank_name: 'SEB',
accounts_data: [{ uid: 'acc-1', currency: 'SEK', enabled: true }] as StoredAccount[],
...overrides,
}
}
describe('POST /sync (enable-banking): retry from error status', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('allows sync from status=error and restores active + clears error_message on success', async () => {
// Regression: a transient ASPSP failure parked the connection in 'error',
// but the old status gate rejected everything but 'active', so the UI's
// "Försök igen" button always got 400 and the connection was stranded
// until a full re-auth.
;(syncAccountTransactions as unknown as Mock).mockResolvedValue({
imported: 0,
duplicates: 0,
errors: 0,
})
const updateSpy = vi.fn()
const ctx = makeContext(
makeConnection({ status: 'error', error_message: RAW_EB_ERROR }),
updateSpy
)
const res = await syncRoute.handler(makeRequest(), ctx)
expect(res.status).toBe(200)
expect(syncAccountTransactions).toHaveBeenCalledTimes(1)
expect(updateSpy).toHaveBeenCalledWith(
expect.objectContaining({
status: 'active',
error_message: null,
last_synced_at: expect.any(String),
})
)
})
it('still rejects expired connections with 400 (re-auth required, not retry)', async () => {
const updateSpy = vi.fn()
const ctx = makeContext(makeConnection({ status: 'expired' }), updateSpy)
const res = await syncRoute.handler(makeRequest(), ctx)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error).toMatch(/not active/i)
expect(syncAccountTransactions).not.toHaveBeenCalled()
expect(updateSpy).not.toHaveBeenCalled()
})
it('does not touch status for an active connection but clears a leftover error_message', async () => {
;(syncAccountTransactions as unknown as Mock).mockResolvedValue({
imported: 0,
duplicates: 0,
errors: 0,
})
const updateSpy = vi.fn()
const ctx = makeContext(
makeConnection({ status: 'active', error_message: RAW_EB_ERROR }),
updateSpy
)
const res = await syncRoute.handler(makeRequest(), ctx)
expect(res.status).toBe(200)
const payload = updateSpy.mock.calls[0][0]
expect(payload).not.toHaveProperty('status')
expect(payload).toMatchObject({ error_message: null })
})
it('maps a non-session failure to the Swedish user message and refreshes the stored error_message', async () => {
// The raw Enable Banking body is an English JSON envelope: it belongs in
// server logs, never in the toast or the settings panel.
;(syncAccountTransactions as unknown as Mock).mockRejectedValue(new Error(RAW_EB_ERROR))
const updateSpy = vi.fn()
const ctx = makeContext(
makeConnection({ status: 'error', error_message: RAW_EB_ERROR }),
updateSpy
)
const res = await syncRoute.handler(makeRequest(), ctx)
expect(res.status).toBe(500)
const body = await res.json()
expect(body.error).toBe(SYNC_FAILED_MESSAGE)
expect(body.error).not.toContain('ASPSP_ERROR')
expect(updateSpy).toHaveBeenCalledWith({ error_message: SYNC_FAILED_MESSAGE })
})
})