* feat(reconciliation): underlag on a balansdag, the files behind a sign-off (Reko bilagor, PR 2) A konsult attaches the kontoutdrag, engagemangsbesked or reskontralista an account was reconciled against to (account_key, through_date), before or after the sign-off, from every account body on the Avstämning page. Rows live in account_reconciliation_attachments (append-only, removal stamp by trigger, RLS like account_reconciliations), bytes in the documents bucket under the company prefix so its RLS applies unchanged, and the full archive copies them into bilagor/ with a hash manifest. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz * fix(reconciliation): literal selects and payload in the attachments store so the phantom-column scanner can read them Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz * feat(bokslut): persisted closing checklist and missing-fiscal-year warning (Reko bilagor, PR 3) (#1867) * feat(bokslut): persisted closing checklist and missing-fiscal-year warning (Reko bilagor, PR 3) The bokslut checklist is a catalogue in code with one state row per period (bokslut_checklist_items): the steps the system can judge (sign-offs through balansdagen, reskontra tie-outs, drafts, voucher gaps, trial balance) are computed live and a stored row only overrides them; the manual steps are the konsult's ticks, with who and when. It sits on the wizard's Kontroll step and is dumped into the full archive. A hole between fiscal years (one-file SIE migrations) is now named on the bokslut readiness screen and on the import result screen, where the next file is one click away. Non-adjacent period links are #1849's fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz * fix(bokslut): count unexplained voucher gaps, literal select and payload for the checklist store Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> 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>
189 lines
9.3 KiB
TypeScript
189 lines
9.3 KiB
TypeScript
/**
|
|
* Tests for the dashboard underlag routes (cookie session, withRouteContext):
|
|
* GET/POST /api/reconciliation/accounts/{accountKey}/attachments and
|
|
* GET/DELETE .../attachments/{attachmentId}. The policy layer is mocked; the wrapper is real.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { NextResponse } from 'next/server'
|
|
import { createQueuedMockSupabase, createMockRequest, 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),
|
|
}))
|
|
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
|
|
|
const listMock = vi.fn()
|
|
const attachMock = vi.fn()
|
|
const removeMock = vi.fn()
|
|
const downloadMock = vi.fn()
|
|
vi.mock('@/lib/reconciliation/attachments', async () => {
|
|
const actual = await vi.importActual<typeof import('@/lib/reconciliation/attachments')>('@/lib/reconciliation/attachments')
|
|
return {
|
|
...actual,
|
|
listAttachments: (...args: unknown[]) => listMock(...args),
|
|
attachUnderlag: (...args: unknown[]) => attachMock(...args),
|
|
removeUnderlag: (...args: unknown[]) => removeMock(...args),
|
|
downloadUnderlag: (...args: unknown[]) => downloadMock(...args),
|
|
}
|
|
})
|
|
const getRowMock = vi.fn()
|
|
vi.mock('@/lib/reconciliation/attachments-store', () => ({
|
|
getAttachmentRow: (...args: unknown[]) => getRowMock(...args),
|
|
}))
|
|
vi.mock('@/lib/supabase/server', () => ({
|
|
createServiceClient: () => ({ storage: { from: () => ({}) } }),
|
|
}))
|
|
|
|
import { ReconciliationAttachmentError } from '@/lib/reconciliation/attachments'
|
|
import { GET as listGET, POST as attachPOST } from '../[accountKey]/attachments/route'
|
|
import { GET as fileGET, DELETE as removeDELETE } from '../[accountKey]/attachments/[attachmentId]/route'
|
|
|
|
const ATTACHMENT_ID = '11111111-1111-4111-8111-111111111111'
|
|
const p = (obj: Record<string, string>) => ({ params: Promise.resolve(obj) }) as never
|
|
const attachment = {
|
|
id: ATTACHMENT_ID,
|
|
account_key: 'manual:2350',
|
|
through_date: '2026-12-31',
|
|
file_name: 'kontoutdrag.pdf',
|
|
mime_type: 'application/pdf',
|
|
size_bytes: 10,
|
|
sha256: 'ab'.repeat(32),
|
|
note: null,
|
|
uploaded_by: 'user-1',
|
|
uploaded_at: '2027-01-10T08:00:00Z',
|
|
removed_at: null,
|
|
removed_by: null,
|
|
removed_reason: null,
|
|
}
|
|
|
|
describe('dashboard underlag routes', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
reset()
|
|
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
|
|
requireWriteMock.mockResolvedValue({ ok: true })
|
|
listMock.mockResolvedValue([attachment])
|
|
attachMock.mockResolvedValue(attachment)
|
|
removeMock.mockResolvedValue({ ...attachment, removed_at: '2027-01-11T08:00:00Z', removed_by: 'user-1' })
|
|
})
|
|
|
|
it('401 without a session', async () => {
|
|
requireAuthMock.mockResolvedValue({ error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) })
|
|
const res = await listGET(createMockRequest('http://localhost/x?through_date=2026-12-31'), p({ accountKey: 'manual:2350' }))
|
|
expect(res.status).toBe(401)
|
|
})
|
|
|
|
it('GET lists the files for the date, 400s without a date, 404s a bad key', async () => {
|
|
const ok = await listGET(
|
|
createMockRequest('http://localhost/api/reconciliation/accounts/manual:2350/attachments?through_date=2026-12-31&include_removed=1'),
|
|
p({ accountKey: 'manual:2350' }),
|
|
)
|
|
expect(ok.status).toBe(200)
|
|
const { body } = await parseJsonResponse<{ data: { attachments: unknown[] } }>(ok)
|
|
expect(body.data.attachments).toHaveLength(1)
|
|
expect(listMock).toHaveBeenCalledWith(supabase, 'company-1', 'manual:2350', '2026-12-31', { includeRemoved: true })
|
|
|
|
const noDate = await listGET(createMockRequest('http://localhost/x'), p({ accountKey: 'manual:2350' }))
|
|
expect(noDate.status).toBe(400)
|
|
const badKey = await listGET(createMockRequest('http://localhost/x?through_date=2026-12-31'), p({ accountKey: '2350' }))
|
|
expect(badKey.status).toBe(404)
|
|
})
|
|
|
|
it('POST attaches the multipart file with its date and note, and maps policy refusals to 400 + code', async () => {
|
|
const form = new FormData()
|
|
form.set('file', new File([new Uint8Array([0x25, 0x50, 0x44, 0x46])], 'kontoutdrag.pdf', { type: 'application/pdf' }))
|
|
form.set('through_date', '2026-12-31')
|
|
form.set('note', 'Kontoutdrag december')
|
|
const req = new Request('http://localhost/api/reconciliation/accounts/manual:2350/attachments', { method: 'POST', body: form })
|
|
const res = await attachPOST(req as never, p({ accountKey: 'manual:2350' }))
|
|
expect(res.status).toBe(201)
|
|
expect(attachMock).toHaveBeenCalledWith(
|
|
supabase,
|
|
'company-1',
|
|
'user-1',
|
|
'manual:2350',
|
|
expect.objectContaining({
|
|
through_date: '2026-12-31',
|
|
note: 'Kontoutdrag december',
|
|
file: expect.objectContaining({ name: 'kontoutdrag.pdf', type: 'application/pdf', size: 4 }),
|
|
}),
|
|
)
|
|
|
|
attachMock.mockRejectedValue(new ReconciliationAttachmentError('Filtypen stöds inte.', 'INVALID_FILE'))
|
|
const form2 = new FormData()
|
|
form2.set('file', new File(['x'], 'x.csv', { type: 'text/csv' }))
|
|
form2.set('through_date', '2026-12-31')
|
|
const refused = await attachPOST(new Request('http://localhost/x', { method: 'POST', body: form2 }) as never, p({ accountKey: 'manual:2350' }))
|
|
expect(refused.status).toBe(400)
|
|
expect((await parseJsonResponse<{ code: string }>(refused)).body.code).toBe('INVALID_FILE')
|
|
})
|
|
|
|
it('POST 400s without a file or a date, and requires write permission', async () => {
|
|
const noFile = new FormData()
|
|
noFile.set('through_date', '2026-12-31')
|
|
const res = await attachPOST(new Request('http://localhost/x', { method: 'POST', body: noFile }) as never, p({ accountKey: 'manual:2350' }))
|
|
expect(res.status).toBe(400)
|
|
|
|
const noDate = new FormData()
|
|
noDate.set('file', new File(['x'], 'x.pdf', { type: 'application/pdf' }))
|
|
const res2 = await attachPOST(new Request('http://localhost/x', { method: 'POST', body: noDate }) as never, p({ accountKey: 'manual:2350' }))
|
|
expect(res2.status).toBe(400)
|
|
|
|
requireWriteMock.mockResolvedValue({ ok: false, response: NextResponse.json({ error: 'Läsbehörighet' }, { status: 403 }) })
|
|
const form = new FormData()
|
|
form.set('file', new File(['x'], 'x.pdf', { type: 'application/pdf' }))
|
|
form.set('through_date', '2026-12-31')
|
|
const forbidden = await attachPOST(new Request('http://localhost/x', { method: 'POST', body: form }) as never, p({ accountKey: 'manual:2350' }))
|
|
expect(forbidden.status).toBe(403)
|
|
expect(attachMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('GET file streams the bytes inline after the row authorizes, 404s otherwise', async () => {
|
|
getRowMock.mockResolvedValue({ ...attachment, storage_bucket: 'documents', storage_path: 'documents/company-1/reconciliation/x' })
|
|
downloadMock.mockResolvedValue({ blob: new Blob(['%PDF'], { type: 'application/pdf' }), error: null })
|
|
const res = await fileGET(createMockRequest('http://localhost/x'), p({ accountKey: 'manual:2350', attachmentId: ATTACHMENT_ID }))
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('Content-Type')).toBe('application/pdf')
|
|
expect(res.headers.get('Content-Disposition')).toMatch(/^inline/)
|
|
expect(res.headers.get('X-Content-Type-Options')).toBe('nosniff')
|
|
|
|
getRowMock.mockResolvedValue(null)
|
|
const missing = await fileGET(createMockRequest('http://localhost/x'), p({ accountKey: 'manual:2350', attachmentId: ATTACHMENT_ID }))
|
|
expect(missing.status).toBe(404)
|
|
const badId = await fileGET(createMockRequest('http://localhost/x'), p({ accountKey: 'manual:2350', attachmentId: 'nope' }))
|
|
expect(badId.status).toBe(404)
|
|
})
|
|
|
|
it('DELETE stamps removal with an optional reason, 409s an already removed file, 404s unknown', async () => {
|
|
const res = await removeDELETE(
|
|
createMockRequest('http://localhost/x', { method: 'DELETE', body: { reason: 'fel fil' } }),
|
|
p({ accountKey: 'manual:2350', attachmentId: ATTACHMENT_ID }),
|
|
)
|
|
expect(res.status).toBe(200)
|
|
expect(removeMock).toHaveBeenCalledWith(supabase, 'company-1', 'user-1', 'manual:2350', ATTACHMENT_ID, { reason: 'fel fil' })
|
|
|
|
const empty = await removeDELETE(new Request('http://localhost/x', { method: 'DELETE' }) as never, p({ accountKey: 'manual:2350', attachmentId: ATTACHMENT_ID }))
|
|
expect(empty.status).toBe(200)
|
|
expect(removeMock).toHaveBeenLastCalledWith(supabase, 'company-1', 'user-1', 'manual:2350', ATTACHMENT_ID, { reason: null })
|
|
|
|
removeMock.mockRejectedValue(new ReconciliationAttachmentError('Underlaget är redan borttaget.', 'ALREADY_REMOVED'))
|
|
const gone = await removeDELETE(new Request('http://localhost/x', { method: 'DELETE' }) as never, p({ accountKey: 'manual:2350', attachmentId: ATTACHMENT_ID }))
|
|
expect(gone.status).toBe(409)
|
|
|
|
removeMock.mockResolvedValue(null)
|
|
const unknown = await removeDELETE(new Request('http://localhost/x', { method: 'DELETE' }) as never, p({ accountKey: 'manual:2350', attachmentId: ATTACHMENT_ID }))
|
|
expect(unknown.status).toBe(404)
|
|
})
|
|
})
|