Files
accounted/app/api/import/bank-file/__tests__/route.test.ts
T
MattssonandClaude Fable 5 47c039453c feat(import): undo a bank file import including ignored transactions (#1764)
* feat(import): undo a bank file import including ignored transactions (#1672)

A mis-parsed bank CSV could not be cleaned up: re-importing dedup-skips
the bad rows, the single-row DELETE refuses imported rows by design
(TRANSACTION_DELETE_IMPORTED), and there was no bulk action. Transactions
also never recorded which import batch inserted them, so a strictly
scoped undo was impossible.

- transactions.bank_file_import_id: batch link stamped at ingest by both
  bank-file import paths (dashboard execute route, v1 REST route). PSD2/
  manual/MCP rows stay NULL. No retroactive backfill: fuzzy attribution
  could delete rows belonging to a different import.
- undo_bank_file_import RPC: owner/admin-only bulk delete of the batch's
  unbooked rows, ignored INCLUDED. Booked rows (journal link, payment
  rows, voucher links) and rows with append-only payment_match_log
  history are skipped and reported, mirroring the single-row route's
  guards. Marks the import 'undone' (re-import reuses the row via the
  company_id+file_hash upsert), writes one audit_log summary row, and
  hardens the actor gate like undo_sie_import: p_user_id honored only
  for service_role callers, 42501 otherwise, no anon EXECUTE.
- DELETE /api/import/bank-file/[id]/undo returns the deletion report;
  RPC 42501 maps to BANK_FILE_UNDO_FORBIDDEN (403).

Closes #1672

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Emil <emilmattsson14@gmail.com>

* fix(import): return 404 when the bank-file undo target does not exist

An unknown or out-of-company import id answered 400 BANK_FILE_UNDO_FAILED,
hiding the not-found semantics the SIE import routes already expose
('Import not found', 404). Flag the case in undoBankFileImport (notFound)
and map it to a new BANK_FILE_UNDO_NOT_FOUND structured error (404);
status-refusals and RPC failures keep the 400 envelope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Emil <emilmattsson14@gmail.com>

* feat(import): show bank file import history with undo on the import tab

The undo shipped for issue #1672 was API-only: no surface listed a
company's bank_file_imports, so neither users nor founders could reach
DELETE /api/import/bank-file/[id]/undo, and the deletion report existed
only in JSON. Mirror the SIE pattern (SIEImportHistory, #1574):

- GET /api/import/bank-file: list the company's imports newest-first,
  same { data, count, limit, offset } shape as GET /api/import/sie.
- BankFileImportHistory: fold-open 'Tidigare bankfilsimporter' row on
  the Importera tab with filename, date, format, imported count and
  status per import, plus an undo action on completed rows behind a
  DestructiveConfirmDialog. The undo stays owner/admin-only via the
  undo_bank_file_import RPC's actor gate, like the SIE one.
- After undo the toast shows the full report: transactions removed,
  booked rows skipped, rows with match history skipped, so nothing
  disappears silently from the ledger's surroundings.
- i18n strings in messages/sv.json and messages/en.json following the
  sie_history_* key style; list-route test mirroring the SIE list test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Emil <emilmattsson14@gmail.com>

* chore(migrations): move undo_bank_file_import after main's 2026-08-19 migrations

Signed-off-by: Emil <emilmattsson14@gmail.com>

* fix(import): validate bank-file list params, fail closed on undo lookup, log lost batch attribution

Review findings on #1764 (CodeRabbit):
- GET /api/import/bank-file rejects non-integer/negative/oversized limit
  and offset and unknown status with a mapped 400
  (BANK_FILE_LIST_INVALID_QUERY), limit capped at 100; boundary and
  invalid-input tests added.
- undoBankFileImport distinguishes PGRST116 (zero rows -> notFound/404)
  from other lookup failures, which now return an error instead of
  masquerading as a permanent 404.
- The v1 import route no longer discards the bank_file_imports upsert
  error: kept non-fatal by design (an unattributed batch imports fine and
  never appears in undo history), but the failure is now logged loudly.
- Route test beforeEach clears the event bus (repo convention).

Signed-off-by: Emil <emilmattsson14@gmail.com>

---------

Signed-off-by: Emil <emilmattsson14@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 21:25:18 +02:00

172 lines
6.1 KiB
TypeScript

/**
* Tests for GET /api/import/bank-file (the bank file import list).
*
* Exercises the route through the real withRouteContext wrapper, mocking only
* its auth/company dependencies and injecting a queued Supabase mock via
* requireAuth. Covers: 401, the { data, count, limit, offset } happy-path
* shape, the status filter, and the 500 path returning a Swedish error.
* Mirrors the GET /api/import/sie list test.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createQueuedMockSupabase,
createMockRequest,
createMockRouteParams,
parseJsonResponse,
} from '@/tests/helpers'
import { eventBus } from '@/lib/events'
const { supabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
import { GET } from '../route'
// Next.js 16 always passes a params promise, even on static routes.
const staticParams = () => createMockRouteParams({})
const makeImportRow = (overrides: Record<string, unknown> = {}) => ({
id: 'import-1',
company_id: 'company-1',
filename: 'kontoutdrag-2026.csv',
file_format: 'swedbank',
transaction_count: 212,
imported_count: 208,
duplicate_count: 4,
matched_count: 12,
status: 'completed',
created_at: '2026-08-01T08:59:00Z',
updated_at: '2026-08-01T09:00:00Z',
...overrides,
})
describe('GET /api/import/bank-file', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
})
it('returns 401 when unauthenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await GET(createMockRequest('/api/import/bank-file'), staticParams())
expect(response.status).toBe(401)
})
it('returns { data, count, limit, offset } with defaults', async () => {
const rows = [makeImportRow(), makeImportRow({ id: 'import-2', status: 'undone' })]
enqueue({ data: rows, count: 2 })
const response = await GET(createMockRequest('/api/import/bank-file'), staticParams())
const { status, body } = await parseJsonResponse<{
data: { id: string }[]
count: number
limit: number
offset: number
}>(response)
expect(status).toBe(200)
expect(body.data).toHaveLength(2)
expect(body.data[0].id).toBe('import-1')
expect(body.count).toBe(2)
expect(body.limit).toBe(20)
expect(body.offset).toBe(0)
// Scoped to the active company; ordered newest-first; default range 0-19.
expect(findCalls('bank_file_imports', 'eq')).toContainEqual(['company_id', 'company-1'])
expect(findCalls('bank_file_imports', 'order')).toContainEqual([
'created_at',
{ ascending: false },
])
expect(findCalls('bank_file_imports', 'range')).toContainEqual([0, 19])
})
it('applies the status filter and custom limit/offset', async () => {
enqueue({ data: [makeImportRow()], count: 1 })
const response = await GET(
createMockRequest('/api/import/bank-file', {
searchParams: { status: 'completed', limit: '5', offset: '10' },
}),
staticParams(),
)
const { status, body } = await parseJsonResponse<{ limit: number; offset: number }>(response)
expect(status).toBe(200)
expect(body.limit).toBe(5)
expect(body.offset).toBe(10)
expect(findCalls('bank_file_imports', 'eq')).toContainEqual(['status', 'completed'])
expect(findCalls('bank_file_imports', 'range')).toContainEqual([10, 14])
})
it.each([
{ name: 'non-numeric limit', searchParams: { limit: 'abc' } },
{ name: 'partial-integer limit', searchParams: { limit: '12abc' } },
{ name: 'negative limit', searchParams: { limit: '-1' } },
{ name: 'zero limit', searchParams: { limit: '0' } },
{ name: 'limit above the cap', searchParams: { limit: '101' } },
{ name: 'negative offset', searchParams: { offset: '-5' } },
{ name: 'non-numeric offset', searchParams: { offset: 'NaN' } },
{ name: 'unknown status', searchParams: { status: 'sabotage' } },
])('returns a mapped 400 for $name', async ({ searchParams }) => {
const response = await GET(
createMockRequest('/api/import/bank-file', { searchParams }),
staticParams(),
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('BANK_FILE_LIST_INVALID_QUERY')
// Invalid input must never reach the query builder.
expect(findCalls('bank_file_imports', 'range')).toEqual([])
})
it('accepts the boundary values limit=1, limit=100 and offset=0', async () => {
enqueue({ data: [], count: 0 })
enqueue({ data: [], count: 0 })
const min = await GET(
createMockRequest('/api/import/bank-file', { searchParams: { limit: '1', offset: '0' } }),
staticParams(),
)
expect(min.status).toBe(200)
const max = await GET(
createMockRequest('/api/import/bank-file', { searchParams: { limit: '100' } }),
staticParams(),
)
expect(max.status).toBe(200)
expect(findCalls('bank_file_imports', 'range')).toContainEqual([0, 0])
expect(findCalls('bank_file_imports', 'range')).toContainEqual([0, 99])
})
it('returns 500 with a Swedish error message on a database error', async () => {
enqueue({ data: null, error: { message: 'connection reset by peer' } })
const response = await GET(createMockRequest('/api/import/bank-file'), staticParams())
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(500)
expect(typeof body.error).toBe('string')
// The raw driver message must not leak; the mapped message is Swedish.
expect(body.error).not.toContain('connection reset')
expect(body.error).toBe('Något gick fel. Försök igen.')
})
})