* 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>
172 lines
6.1 KiB
TypeScript
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.')
|
|
})
|
|
})
|