fix(enable-banking): reconnect supersedes the old connection and stops duplicate imports (#1728)
* fix(enable-banking): supersede the old connection on bank reconnect and stop renewal duplicates
A renewal performed via the bank list ("Anslut ny bank") created a second
bank_connections row and left the old one parked in 'expired' forever: an
eternal "Åtgärd krävs" card, a red status chip, transactions stranded on the
dead row (so the picker's gap-fill probe read the renewal as a first
connect), and re-imported history for no-IBAN accounts whose provider uids
change on re-authorization.
- New migration: additive superseded_by uuid (FK, ON DELETE SET NULL) +
superseded_at + partial index on bank_connections. Status 'revoked' is
reused for superseded rows (no CHECK change); superseded_by disambiguates
a supersede from a user disconnect. File only: not applied anywhere yet.
- New lib/supersede.ts: after the OAuth callback finalizes, park same-bank
siblings matched by IBAN overlap (an ACTIVE sibling without overlap is
never touched; no-IBAN fallback only for dead siblings when neither side
has IBANs), revoke their EB session only when countLiveSiblings says
nobody shares it, re-point their transactions in id batches, demote
leftover cash_accounts claims (the mirror then promotes them by IBAN),
carry last_synced_at + initial_sync_* onto the survivor, and emit the new
bank_connection.superseded audit event.
- /connect fresh path: 409 { code: 'EXISTING_CONNECTION',
existing_connection_id } when a non-revoked same-bank row exists, unless
the body carries force_new: true (escape hatch for a second login at the
same bank). Runs after the zombie sweep; reconnect-in-place unaffected.
- Dedup scope stability: StoredAccount.dedup_scope pins the external_id
account scope at first ingest (normalized IBAN, else the uid of that
moment), is carried across in-place reconnects and supersedes by IBAN
match, and sync.ts uses dedup_scope ?? IBAN ?? uid (stamping legacy rows
lazily). The external_id FORMAT is untouched.
- AccountPickerDialog gap-fill probe also includes superseded connection
ids so the renewal default never races the transaction re-point.
- Sync toast (BankSyncNowButton) now also reports skipped duplicates
(sv+en strings) so a correctly deduped renewal does not look broken.
Tests: supersede unit tests, /connect 409 + force_new, callback supersede
wiring + dedup-scope carry, sync external_id stability across uid changes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(enable-banking): scope the connect 409 to dead siblings and harden supersede ordering
- POST /connect only 409s when the same-bank sibling is expired/error/
pending_selection: an active row (a second legitimate login at the same
bank) never blocks a fresh connect; force_new bypass kept. The 409 text
now names the bank and points at Fornya samtycke.
- supersede parks the sibling row BEFORE revoking its EB session, and skips
the revoke entirely (logged) when the park update fails, so a failed park
can no longer leave a live-looking row with a dead session.
- callback keeps a survivor account's explicit dedup_scope instead of
letting a carried sibling scope clobber it; carried scopes only apply
when the survivor's scope was derived (IBAN/uid fallback).
- sync-now toast joins its two sentences with '. ' so the imported and
skipped-duplicates messages no longer run together.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
1ded1af8fe
commit
3de5dee553
@@ -41,7 +41,7 @@ interface RecordedChain {
|
||||
function makeChain(result: { data?: unknown; error?: unknown }): RecordedChain {
|
||||
const calls: RecordedCall[] = []
|
||||
const chain: Record<string, unknown> = { _calls: calls }
|
||||
for (const m of ['select', 'eq', 'in', 'is', 'order', 'limit', 'update', 'delete', 'insert']) {
|
||||
for (const m of ['select', 'eq', 'neq', 'in', 'is', 'order', 'limit', 'update', 'delete', 'insert']) {
|
||||
chain[m] = vi.fn((...args: unknown[]) => {
|
||||
calls.push({ method: m, args })
|
||||
return chain
|
||||
@@ -118,6 +118,9 @@ describe('POST /connect never-activated row cleanup', () => {
|
||||
} else if (call === 2) {
|
||||
// The sweep: returns the deleted never-activated rows.
|
||||
chain = makeChain({ data: [{ id: 'stale-1' }, { id: 'old-error' }] })
|
||||
} else if (call === 3) {
|
||||
// Existing-connection guard: nothing established remains post-sweep.
|
||||
chain = makeChain({ data: null })
|
||||
} else {
|
||||
// Insert of the fresh connection row.
|
||||
chain = makeChain({ data: { id: 'new-conn' } })
|
||||
@@ -186,6 +189,9 @@ describe('POST /connect never-activated row cleanup', () => {
|
||||
chain = makeChain({ data: null })
|
||||
} else if (call === 2) {
|
||||
chain = makeChain({ data: [{ id: 'old-error' }] })
|
||||
} else if (call === 3) {
|
||||
// Existing-connection guard: the swept zombie is gone, nothing remains.
|
||||
chain = makeChain({ data: null })
|
||||
} else {
|
||||
chain = makeChain({ data: { id: 'new-conn' } })
|
||||
}
|
||||
@@ -211,14 +217,16 @@ describe('POST /connect auth-method pinning wired into startAuthorization', () =
|
||||
})
|
||||
})
|
||||
|
||||
// Fresh-connect from() sequence: recent-pending check, zombie sweep, insert.
|
||||
// Explicit psu_type in the body skips the companies entity_type lookup.
|
||||
// Fresh-connect from() sequence: recent-pending check, zombie sweep,
|
||||
// existing-connection guard, insert. Explicit psu_type in the body skips
|
||||
// the companies entity_type lookup.
|
||||
function makeFreshConnectContext() {
|
||||
let call = 0
|
||||
return makeContext(() => {
|
||||
call++
|
||||
if (call === 1) return makeChain({ data: null })
|
||||
if (call === 2) return makeChain({ data: [] })
|
||||
if (call === 3) return makeChain({ data: null })
|
||||
return makeChain({ data: { id: 'new-conn' } })
|
||||
})
|
||||
}
|
||||
@@ -340,3 +348,146 @@ describe('POST /connect auth-method pinning wired into startAuthorization', () =
|
||||
expect(args[5]).toBe('BANKID')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /connect existing-connection guard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(requireCapability).mockResolvedValue(null)
|
||||
mockGetPreferredAuthMethod.mockResolvedValue(undefined)
|
||||
mockStartAuthorization.mockResolvedValue({
|
||||
url: 'https://bank.example/auth',
|
||||
authorization_id: 'auth-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 409 EXISTING_CONNECTION when a dead (expired) row for the same bank exists', async () => {
|
||||
const chains: RecordedChain[] = []
|
||||
let call = 0
|
||||
const ctx = makeContext(() => {
|
||||
call++
|
||||
let chain: RecordedChain
|
||||
if (call === 1) {
|
||||
// No live pending attempt.
|
||||
chain = makeChain({ data: null })
|
||||
} else if (call === 2) {
|
||||
// Sweep finds nothing to delete.
|
||||
chain = makeChain({ data: [] })
|
||||
} else {
|
||||
// Guard: an established expired row for this bank survives the sweep.
|
||||
chain = makeChain({ data: { id: 'existing-1', status: 'expired' } })
|
||||
}
|
||||
chains.push(chain)
|
||||
return chain
|
||||
})
|
||||
|
||||
const response = await connectRoute().handler(makeConnectRequest(), ctx)
|
||||
|
||||
expect(response.status).toBe(409)
|
||||
const body = (await response.json()) as {
|
||||
code: string
|
||||
existing_connection_id: string
|
||||
error: string
|
||||
}
|
||||
expect(body.code).toBe('EXISTING_CONNECTION')
|
||||
expect(body.existing_connection_id).toBe('existing-1')
|
||||
// The message names the bank and points at Förnya samtycke.
|
||||
expect(body.error).toContain('Nordea')
|
||||
expect(body.error).toContain('behöver förnyas')
|
||||
expect(body.error).toContain('Förnya samtycke')
|
||||
// The bank flow is never started and no duplicate row is inserted.
|
||||
expect(mockStartAuthorization).not.toHaveBeenCalled()
|
||||
for (const chain of chains) {
|
||||
expect(chain._calls.some((c) => c.method === 'insert')).toBe(false)
|
||||
}
|
||||
// The guard only matches DEAD-BUT-ESTABLISHED rows: an active row (a
|
||||
// legitimate second login at the same bank) and revoked rows never 409.
|
||||
const guard = chains[2]
|
||||
const inCall = guard._calls.find((c) => c.method === 'in')
|
||||
expect(inCall?.args).toEqual(['status', ['expired', 'error', 'pending_selection']])
|
||||
})
|
||||
|
||||
it('never 409s over an ACTIVE same-bank connection: the guard status filter excludes it', async () => {
|
||||
const chains: RecordedChain[] = []
|
||||
let call = 0
|
||||
const ctx = makeContext(() => {
|
||||
call++
|
||||
let chain: RecordedChain
|
||||
if (call === 1) {
|
||||
chain = makeChain({ data: null })
|
||||
} else if (call === 2) {
|
||||
chain = makeChain({ data: [] })
|
||||
} else if (call === 3) {
|
||||
// Guard: only an ACTIVE row exists for this bank; the dead-status
|
||||
// filter matches nothing, so the query returns null.
|
||||
chain = makeChain({ data: null })
|
||||
} else {
|
||||
chain = makeChain({ data: { id: 'second-login' } })
|
||||
}
|
||||
chains.push(chain)
|
||||
return chain
|
||||
})
|
||||
|
||||
const response = await connectRoute().handler(makeConnectRequest(), ctx)
|
||||
|
||||
// The second legitimate login at the same bank goes through.
|
||||
expect(response.status).toBe(200)
|
||||
const body = (await response.json()) as { connection_id: string }
|
||||
expect(body.connection_id).toBe('second-login')
|
||||
expect(mockStartAuthorization).toHaveBeenCalledTimes(1)
|
||||
|
||||
// The guard queried with the dead-status filter (so an active row can
|
||||
// never be returned) rather than neq('status', 'revoked').
|
||||
const guard = chains[2]
|
||||
const inCall = guard._calls.find((c) => c.method === 'in')
|
||||
expect(inCall?.args).toEqual(['status', ['expired', 'error', 'pending_selection']])
|
||||
expect(guard._calls.some((c) => c.method === 'neq')).toBe(false)
|
||||
})
|
||||
|
||||
it('force_new: true bypasses the guard and inserts a fresh row', async () => {
|
||||
const chains: RecordedChain[] = []
|
||||
let call = 0
|
||||
const ctx = makeContext(() => {
|
||||
call++
|
||||
let chain: RecordedChain
|
||||
if (call === 1) {
|
||||
chain = makeChain({ data: null })
|
||||
} else if (call === 2) {
|
||||
chain = makeChain({ data: [] })
|
||||
} else {
|
||||
// With force_new the guard query is skipped entirely: call 3 is the
|
||||
// insert of the fresh connection row.
|
||||
chain = makeChain({ data: { id: 'new-conn' } })
|
||||
}
|
||||
chains.push(chain)
|
||||
return chain
|
||||
})
|
||||
|
||||
const req = new Request('https://test.local/api/extensions/ext/enable-banking/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
aspsp_name: 'Nordea',
|
||||
aspsp_country: 'SE',
|
||||
psu_type: 'business',
|
||||
force_new: true,
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await connectRoute().handler(req, ctx)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const body = (await response.json()) as { connection_id: string }
|
||||
expect(body.connection_id).toBe('new-conn')
|
||||
expect(mockStartAuthorization).toHaveBeenCalledTimes(1)
|
||||
// No guard query ran: nothing filtered on the guard's dead-status set
|
||||
// (the sweep's in() uses ['pending', 'error'] and is expected).
|
||||
for (const chain of chains) {
|
||||
const guardIn = chain._calls.find(
|
||||
(c) =>
|
||||
c.method === 'in' &&
|
||||
JSON.stringify(c.args) === JSON.stringify(['status', ['expired', 'error', 'pending_selection']]),
|
||||
)
|
||||
expect(guardIn).toBeUndefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -105,6 +105,9 @@ function makeContext(connection: Record<string, unknown>, updateSpy: Mock, inser
|
||||
const chain: any = {}
|
||||
chain.select = vi.fn(() => chain)
|
||||
chain.eq = vi.fn(() => chain)
|
||||
// The fresh-connect existing-connection guard chains .neq('status', 'revoked')
|
||||
// and resolves via maybeSingle (null here: no established row).
|
||||
chain.neq = vi.fn(() => chain)
|
||||
chain.gte = vi.fn(() => chain)
|
||||
chain.limit = vi.fn(() => chain)
|
||||
chain.order = vi.fn(() => chain)
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
const { mockDeleteSession, mockCountLiveSiblings } = vi.hoisted(() => ({
|
||||
mockDeleteSession: vi.fn(),
|
||||
mockCountLiveSiblings: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../lib/api-client', () => ({
|
||||
deleteSession: (...args: unknown[]) => mockDeleteSession(...args),
|
||||
}))
|
||||
vi.mock('../lib/session-sharing', () => ({
|
||||
countLiveSiblings: (...args: unknown[]) => mockCountLiveSiblings(...args),
|
||||
}))
|
||||
|
||||
import { supersedeSiblingConnections } from '../lib/supersede'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import type { StoredAccount } from '../types'
|
||||
|
||||
interface RecordedCall {
|
||||
method: string
|
||||
args: unknown[]
|
||||
}
|
||||
|
||||
interface RecordedChain {
|
||||
_calls: RecordedCall[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
function makeChain(result: { data?: unknown; error?: unknown } = {}): RecordedChain {
|
||||
const calls: RecordedCall[] = []
|
||||
const chain: Record<string, unknown> = { _calls: calls }
|
||||
for (const m of ['select', 'eq', 'neq', 'in', 'order', 'limit', 'update', 'delete']) {
|
||||
chain[m] = vi.fn((...args: unknown[]) => {
|
||||
calls.push({ method: m, args })
|
||||
return chain
|
||||
})
|
||||
}
|
||||
chain.single = vi.fn().mockResolvedValue({ data: result.data ?? null, error: result.error ?? null })
|
||||
chain.maybeSingle = vi.fn().mockResolvedValue({ data: result.data ?? null, error: result.error ?? null })
|
||||
chain.then = (resolve: (v: unknown) => void) =>
|
||||
resolve({ data: result.data ?? null, error: result.error ?? null })
|
||||
return chain as RecordedChain
|
||||
}
|
||||
|
||||
interface ScriptStep {
|
||||
table: string
|
||||
chain: RecordedChain
|
||||
}
|
||||
|
||||
/** from() dispatcher that asserts the table order and hands out scripted chains. */
|
||||
function makeSupabase(script: ScriptStep[]): { client: SupabaseClient; from: ReturnType<typeof vi.fn> } {
|
||||
let i = 0
|
||||
const from = vi.fn((table: string) => {
|
||||
const step = script[i]
|
||||
i++
|
||||
expect(step, `unexpected from('${table}') call #${i}`).toBeDefined()
|
||||
expect(table).toBe(step.table)
|
||||
return step.chain
|
||||
})
|
||||
return { client: { from } as unknown as SupabaseClient, from }
|
||||
}
|
||||
|
||||
function updatePayload(chain: RecordedChain): Record<string, unknown> {
|
||||
const call = chain._calls.find((c) => c.method === 'update')
|
||||
expect(call, 'expected an update on this chain').toBeDefined()
|
||||
return call!.args[0] as Record<string, unknown>
|
||||
}
|
||||
|
||||
const BASE_INPUT = {
|
||||
companyId: 'company-1',
|
||||
userId: 'user-1',
|
||||
newConnectionId: 'new-1',
|
||||
bankName: 'TestBank',
|
||||
newSessionId: 'sess-new',
|
||||
}
|
||||
|
||||
function makeSibling(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'old-1',
|
||||
status: 'expired',
|
||||
session_id: 'sess-old',
|
||||
accounts_data: [
|
||||
{
|
||||
uid: 'uid-old',
|
||||
iban: 'SE45 5000 0000 0583 9825 7466',
|
||||
currency: 'SEK',
|
||||
dedup_scope: 'legacy-scope',
|
||||
},
|
||||
] as StoredAccount[],
|
||||
last_synced_at: '2026-08-01T00:00:00Z',
|
||||
initial_sync_completed_at: '2026-06-01T00:00:00Z',
|
||||
initial_sync_requested_from: '2026-01-01',
|
||||
initial_sync_returned_min_date: '2026-01-02',
|
||||
initial_sync_returned_max_date: '2026-07-31',
|
||||
initial_sync_lookback_days: 365,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const NEW_ACCOUNTS: StoredAccount[] = [
|
||||
{ uid: 'uid-new', iban: 'SE4550000000058398257466', currency: 'SEK' },
|
||||
]
|
||||
|
||||
describe('supersedeSiblingConnections', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
mockCountLiveSiblings.mockResolvedValue(0)
|
||||
mockDeleteSession.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('supersedes an IBAN-overlapping sibling: revoked + superseded_by, transactions re-pointed, claims released', async () => {
|
||||
const emitSpy = vi.spyOn(eventBus, 'emit')
|
||||
|
||||
const siblingSelect = makeChain({ data: [makeSibling()] })
|
||||
const revokeUpdate = makeChain({})
|
||||
const txSelect = makeChain({ data: [{ id: 't1' }, { id: 't2' }] })
|
||||
const txUpdate = makeChain({})
|
||||
const cashDemote = makeChain({})
|
||||
const newRowSelect = makeChain({ data: { last_synced_at: null, initial_sync_completed_at: null } })
|
||||
const carryUpdate = makeChain({})
|
||||
|
||||
const { client } = makeSupabase([
|
||||
{ table: 'bank_connections', chain: siblingSelect },
|
||||
{ table: 'bank_connections', chain: revokeUpdate },
|
||||
{ table: 'transactions', chain: txSelect },
|
||||
{ table: 'transactions', chain: txUpdate },
|
||||
{ table: 'cash_accounts', chain: cashDemote },
|
||||
{ table: 'bank_connections', chain: newRowSelect },
|
||||
{ table: 'bank_connections', chain: carryUpdate },
|
||||
])
|
||||
|
||||
const result = await supersedeSiblingConnections(client, {
|
||||
...BASE_INPUT,
|
||||
newAccounts: NEW_ACCOUNTS,
|
||||
})
|
||||
|
||||
expect(result.supersededIds).toEqual(['old-1'])
|
||||
// The explicit dedup scope travels, keyed by normalized IBAN.
|
||||
expect(result.dedupScopeByIban.get('SE4550000000058398257466')).toBe('legacy-scope')
|
||||
|
||||
// The dead consent is revoked at EB (nobody else shares it).
|
||||
expect(mockDeleteSession).toHaveBeenCalledWith('sess-old')
|
||||
|
||||
// The row is parked, not deleted: revoked + superseded_by disambiguates
|
||||
// a supersede from a user disconnect.
|
||||
const parked = updatePayload(revokeUpdate)
|
||||
expect(parked.status).toBe('revoked')
|
||||
expect(parked.session_id).toBeNull()
|
||||
expect(parked.superseded_by).toBe('new-1')
|
||||
expect(typeof parked.superseded_at).toBe('string')
|
||||
|
||||
// Feed rows follow the survivor, batch-scoped by the selected ids.
|
||||
expect(updatePayload(txUpdate)).toEqual({ bank_connection_id: 'new-1' })
|
||||
const inCall = txUpdate._calls.find((c) => c.method === 'in')
|
||||
expect(inCall?.args).toEqual(['id', ['t1', 't2']])
|
||||
|
||||
// Leftover ledger claims are demoted to manual, mirroring /disconnect.
|
||||
expect(updatePayload(cashDemote)).toEqual({ bank_connection_id: null })
|
||||
|
||||
// Sync state is carried onto the survivor so neither the cron's
|
||||
// first-sync backfill nor the picker treats the renewal as a first connect.
|
||||
const carried = updatePayload(carryUpdate)
|
||||
expect(carried.last_synced_at).toBe('2026-08-01T00:00:00Z')
|
||||
expect(carried.initial_sync_completed_at).toBe('2026-06-01T00:00:00Z')
|
||||
expect(carried.initial_sync_requested_from).toBe('2026-01-01')
|
||||
expect(carried.initial_sync_lookback_days).toBe(365)
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith({
|
||||
type: 'bank_connection.superseded',
|
||||
payload: {
|
||||
connectionId: 'old-1',
|
||||
supersededById: 'new-1',
|
||||
bankName: 'TestBank',
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('parks the sibling row BEFORE revoking its session at Enable Banking', async () => {
|
||||
// Revoking first and then failing to park would leave a live-looking row
|
||||
// whose session is already dead at the bank: the park update must come
|
||||
// first, in call order.
|
||||
const sequence: string[] = []
|
||||
mockDeleteSession.mockImplementation(async () => {
|
||||
sequence.push('deleteSession')
|
||||
})
|
||||
|
||||
const siblingSelect = makeChain({ data: [makeSibling()] })
|
||||
const revokeUpdate = makeChain({})
|
||||
const originalUpdate = revokeUpdate.update as ReturnType<typeof vi.fn>
|
||||
revokeUpdate.update = vi.fn((...args: unknown[]) => {
|
||||
sequence.push('parkUpdate')
|
||||
return originalUpdate(...args)
|
||||
})
|
||||
const txSelect = makeChain({ data: [] })
|
||||
const cashDemote = makeChain({})
|
||||
const newRowSelect = makeChain({ data: { last_synced_at: null, initial_sync_completed_at: null } })
|
||||
const carryUpdate = makeChain({})
|
||||
|
||||
const { client } = makeSupabase([
|
||||
{ table: 'bank_connections', chain: siblingSelect },
|
||||
{ table: 'bank_connections', chain: revokeUpdate },
|
||||
{ table: 'transactions', chain: txSelect },
|
||||
{ table: 'cash_accounts', chain: cashDemote },
|
||||
{ table: 'bank_connections', chain: newRowSelect },
|
||||
{ table: 'bank_connections', chain: carryUpdate },
|
||||
])
|
||||
|
||||
const result = await supersedeSiblingConnections(client, {
|
||||
...BASE_INPUT,
|
||||
newAccounts: NEW_ACCOUNTS,
|
||||
})
|
||||
|
||||
expect(result.supersededIds).toEqual(['old-1'])
|
||||
expect(sequence).toEqual(['parkUpdate', 'deleteSession'])
|
||||
})
|
||||
|
||||
it('skips the EB session revoke entirely when the park update fails', async () => {
|
||||
const siblingSelect = makeChain({ data: [makeSibling()] })
|
||||
const failedPark = makeChain({ error: { message: 'update refused' } })
|
||||
|
||||
// Only the lookup and the failed park run: no revoke, no re-point, no
|
||||
// demote, no sync-state carry.
|
||||
const { client, from } = makeSupabase([
|
||||
{ table: 'bank_connections', chain: siblingSelect },
|
||||
{ table: 'bank_connections', chain: failedPark },
|
||||
])
|
||||
|
||||
const result = await supersedeSiblingConnections(client, {
|
||||
...BASE_INPUT,
|
||||
newAccounts: NEW_ACCOUNTS,
|
||||
})
|
||||
|
||||
expect(result.supersededIds).toEqual([])
|
||||
expect(mockDeleteSession).not.toHaveBeenCalled()
|
||||
expect(from).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('never supersedes an ACTIVE sibling without IBAN overlap (separate login at the same bank)', async () => {
|
||||
const siblingSelect = makeChain({
|
||||
data: [
|
||||
makeSibling({
|
||||
id: 'other-login',
|
||||
status: 'active',
|
||||
accounts_data: [{ uid: 'uid-x', iban: 'SE9999999999999999999999', currency: 'SEK' }],
|
||||
}),
|
||||
],
|
||||
})
|
||||
const { client, from } = makeSupabase([{ table: 'bank_connections', chain: siblingSelect }])
|
||||
|
||||
const result = await supersedeSiblingConnections(client, {
|
||||
...BASE_INPUT,
|
||||
newAccounts: NEW_ACCOUNTS,
|
||||
})
|
||||
|
||||
expect(result.supersededIds).toEqual([])
|
||||
// Only the sibling lookup ran: nothing was updated, revoked, or re-pointed.
|
||||
expect(from).toHaveBeenCalledTimes(1)
|
||||
expect(mockDeleteSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('parks the row but keeps the EB session when other connections still share it', async () => {
|
||||
mockCountLiveSiblings.mockResolvedValue(2)
|
||||
|
||||
const siblingSelect = makeChain({ data: [makeSibling()] })
|
||||
const revokeUpdate = makeChain({})
|
||||
const txSelect = makeChain({ data: [] })
|
||||
const cashDemote = makeChain({})
|
||||
const newRowSelect = makeChain({ data: { last_synced_at: null, initial_sync_completed_at: null } })
|
||||
const carryUpdate = makeChain({})
|
||||
|
||||
const { client } = makeSupabase([
|
||||
{ table: 'bank_connections', chain: siblingSelect },
|
||||
{ table: 'bank_connections', chain: revokeUpdate },
|
||||
{ table: 'transactions', chain: txSelect },
|
||||
{ table: 'cash_accounts', chain: cashDemote },
|
||||
{ table: 'bank_connections', chain: newRowSelect },
|
||||
{ table: 'bank_connections', chain: carryUpdate },
|
||||
])
|
||||
|
||||
const result = await supersedeSiblingConnections(client, {
|
||||
...BASE_INPUT,
|
||||
newAccounts: NEW_ACCOUNTS,
|
||||
})
|
||||
|
||||
expect(result.supersededIds).toEqual(['old-1'])
|
||||
// A shared consent is never revoked upstream; the row is still parked.
|
||||
expect(mockDeleteSession).not.toHaveBeenCalled()
|
||||
expect(updatePayload(revokeUpdate).status).toBe('revoked')
|
||||
})
|
||||
|
||||
it('matches a DEAD sibling on bank identity alone only when neither side has IBANs', async () => {
|
||||
const siblingSelect = makeChain({
|
||||
data: [
|
||||
makeSibling({
|
||||
session_id: null,
|
||||
accounts_data: [{ uid: 'uid-old', currency: 'SEK' }],
|
||||
initial_sync_completed_at: null,
|
||||
last_synced_at: null,
|
||||
}),
|
||||
],
|
||||
})
|
||||
const revokeUpdate = makeChain({})
|
||||
const txSelect = makeChain({ data: [] })
|
||||
const cashDemote = makeChain({})
|
||||
const newRowSelect = makeChain({ data: { last_synced_at: null, initial_sync_completed_at: null } })
|
||||
|
||||
const { client } = makeSupabase([
|
||||
{ table: 'bank_connections', chain: siblingSelect },
|
||||
{ table: 'bank_connections', chain: revokeUpdate },
|
||||
{ table: 'transactions', chain: txSelect },
|
||||
{ table: 'cash_accounts', chain: cashDemote },
|
||||
{ table: 'bank_connections', chain: newRowSelect },
|
||||
])
|
||||
|
||||
const result = await supersedeSiblingConnections(client, {
|
||||
...BASE_INPUT,
|
||||
newAccounts: [{ uid: 'uid-new', currency: 'SEK' }],
|
||||
})
|
||||
|
||||
expect(result.supersededIds).toEqual(['old-1'])
|
||||
expect(updatePayload(revokeUpdate).superseded_by).toBe('new-1')
|
||||
})
|
||||
|
||||
it('does nothing without a bank name', async () => {
|
||||
const { client, from } = makeSupabase([])
|
||||
const result = await supersedeSiblingConnections(client, {
|
||||
...BASE_INPUT,
|
||||
bankName: null,
|
||||
newAccounts: NEW_ACCOUNTS,
|
||||
})
|
||||
expect(result.supersededIds).toEqual([])
|
||||
expect(from).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user