feat(import): show SIE import history with undo on the import tab (#1574)
* feat(import): show SIE import history with undo on the import tab The list route (GET /api/import/sie) and the undo route (DELETE /api/import/sie/[id]/undo) both existed, but no UI ever called the list: once the post-import result screen was gone, past imports could not be seen or undone. Add a fold-open 'Tidigare SIE-importer' row on the Importera tab (same expanded pattern as the cloud-backup row) that lazy-loads a history table: filename, date, fiscal year, voucher count, status, and an undo button on completed rows. Undo confirms through DestructiveConfirmDialog (voucher count, IB cleared, documents detached but kept; plus a voucher-gap warning for large imports), keeps the dialog open for the long-running DELETE, and refetches on completion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(import): cover the SIE list and undo routes The base list route had no test file (its siblings all do) and the undo route was only covered indirectly. Add route tests through the real withRouteContext wrapper: 401, the { data, count, limit, offset } shape with company scoping and range math, the status filter, and the Swedish 500 path for the list; 401, 403 viewer, the { success, deletedEntries } passthrough, and the SIE_UNDO_FAILED envelope (reason in details) for undo, with undoSIEImport mocked. 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
e1f13f870a
commit
0643316ac8
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Tests for DELETE /api/import/sie/[id]/undo.
|
||||
*
|
||||
* Exercises the route through the real withRouteContext wrapper, mocking its
|
||||
* auth/company/write dependencies and the undoSIEImport service. Covers: 401,
|
||||
* 403 viewer, the { success, deletedEntries } passthrough, and the
|
||||
* SIE_UNDO_FAILED envelope with the service's Swedish reason in details.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
createQueuedMockSupabase,
|
||||
createMockRequest,
|
||||
createMockRouteParams,
|
||||
parseJsonResponse,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase, reset } = 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'),
|
||||
}))
|
||||
|
||||
const requireWriteMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
|
||||
const undoSIEImportMock = vi.fn()
|
||||
vi.mock('@/lib/import/sie-import', () => ({
|
||||
undoSIEImport: (...args: unknown[]) => undoSIEImportMock(...args),
|
||||
}))
|
||||
|
||||
import { DELETE } from '../route'
|
||||
|
||||
const routeParams = () => createMockRouteParams({ id: 'import-1' })
|
||||
|
||||
describe('DELETE /api/import/sie/[id]/undo', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const response = await DELETE(
|
||||
createMockRequest('/api/import/sie/import-1/undo', { method: 'DELETE' }),
|
||||
routeParams(),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(undoSIEImportMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 403 for a viewer', async () => {
|
||||
requireWriteMock.mockResolvedValue({
|
||||
ok: false,
|
||||
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
|
||||
})
|
||||
|
||||
const response = await DELETE(
|
||||
createMockRequest('/api/import/sie/import-1/undo', { method: 'DELETE' }),
|
||||
routeParams(),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(undoSIEImportMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes through { success, deletedEntries } on a successful undo', async () => {
|
||||
undoSIEImportMock.mockResolvedValue({ success: true, deletedEntries: 214 })
|
||||
|
||||
const response = await DELETE(
|
||||
createMockRequest('/api/import/sie/import-1/undo', { method: 'DELETE' }),
|
||||
routeParams(),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
deletedEntries: number
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body).toEqual({ success: true, deletedEntries: 214 })
|
||||
expect(undoSIEImportMock).toHaveBeenCalledWith(supabase, 'company-1', 'import-1', 'user-1')
|
||||
})
|
||||
|
||||
it('returns the SIE_UNDO_FAILED envelope when the service refuses', async () => {
|
||||
undoSIEImportMock.mockResolvedValue({
|
||||
success: false,
|
||||
deletedEntries: 0,
|
||||
error: 'Kan inte ångra import i ett låst eller stängt räkenskapsår. Öppna perioden först.',
|
||||
})
|
||||
|
||||
const response = await DELETE(
|
||||
createMockRequest('/api/import/sie/import-1/undo', { method: 'DELETE' }),
|
||||
routeParams(),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: {
|
||||
code: string
|
||||
message: string
|
||||
message_en: string
|
||||
details?: { reason?: string }
|
||||
}
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('SIE_UNDO_FAILED')
|
||||
expect(body.error.message).toBe('SIE-importen kunde inte ångras.')
|
||||
expect(body.error.message_en).toBe('Failed to undo SIE import.')
|
||||
expect(body.error.details?.reason).toBe(
|
||||
'Kan inte ångra import i ett låst eller stängt räkenskapsår. Öppna perioden först.',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Tests for GET /api/import/sie (the SIE 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.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
createQueuedMockSupabase,
|
||||
createMockRequest,
|
||||
createMockRouteParams,
|
||||
parseJsonResponse,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
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: 'bokforing-2025.se',
|
||||
fiscal_year_start: '2025-01-01',
|
||||
fiscal_year_end: '2025-12-31',
|
||||
transactions_count: 214,
|
||||
status: 'completed',
|
||||
imported_at: '2026-08-01T09:00:00Z',
|
||||
created_at: '2026-08-01T08:59:00Z',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('GET /api/import/sie', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
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/sie'), 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/sie'), 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('sie_imports', 'eq')).toContainEqual(['company_id', 'company-1'])
|
||||
expect(findCalls('sie_imports', 'order')).toContainEqual(['created_at', { ascending: false }])
|
||||
expect(findCalls('sie_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/sie', {
|
||||
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('sie_imports', 'eq')).toContainEqual(['status', 'completed'])
|
||||
expect(findCalls('sie_imports', 'range')).toContainEqual([10, 14])
|
||||
})
|
||||
|
||||
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/sie'), 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.')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user