feat(reconciliation,bokslut): underlag on a balansdag + persisted closing checklist (Reko bilagor, PR 2 + PR 3) (#1873)
* 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>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
ebbdf96b74
commit
c62321988b
@@ -0,0 +1,68 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { BokslutChecklistError, buildBokslutChecklist, setChecklistItem } from '@/lib/bokslut/checklist'
|
||||
|
||||
/**
|
||||
* GET /api/bookkeeping/fiscal-periods/{id}/bokslut-checklist
|
||||
* PATCH /api/bookkeeping/fiscal-periods/{id}/bokslut-checklist
|
||||
*
|
||||
* The bokslut checklist for one räkenskapsår: the catalogue merged with the
|
||||
* live auto states and the stored rows. PATCH ticks one item
|
||||
* ({ item_key, state, note? }) as the acting user and returns the refreshed
|
||||
* checklist. Catalogue and policy in lib/bokslut/checklist.ts.
|
||||
*/
|
||||
export const GET = withRouteContext(
|
||||
'period.bokslut_checklist',
|
||||
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await params
|
||||
const { user, supabase, companyId, log, requestId } = ctx
|
||||
const checklist = await buildBokslutChecklist(supabase, companyId, user.id, id)
|
||||
if (!checklist) return errorResponseFromCode('PERIOD_NOT_FOUND', log.child({ periodId: id }), { requestId })
|
||||
return NextResponse.json({ data: checklist })
|
||||
},
|
||||
)
|
||||
|
||||
const PatchBodySchema = z.object({
|
||||
item_key: z.string().regex(/^[a-z0-9_]{1,64}$/),
|
||||
state: z.enum(['open', 'done', 'not_applicable']),
|
||||
note: z.string().max(2000).nullable().optional(),
|
||||
})
|
||||
|
||||
export const PATCH = withRouteContext(
|
||||
'period.bokslut_checklist.set',
|
||||
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await params
|
||||
const { user, supabase, companyId, log, requestId } = ctx
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Ogiltig JSON' }, { status: 400 })
|
||||
}
|
||||
const parsed = PatchBodySchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Ogiltig body: item_key och state (open/done/not_applicable) krävs' }, { status: 400 })
|
||||
}
|
||||
// The period must be this company's before anything is written.
|
||||
const existing = await buildBokslutChecklist(supabase, companyId, user.id, id, { readiness: null })
|
||||
if (!existing) return errorResponseFromCode('PERIOD_NOT_FOUND', log.child({ periodId: id }), { requestId })
|
||||
try {
|
||||
await setChecklistItem(supabase, companyId, user.id, id, {
|
||||
item_key: parsed.data.item_key,
|
||||
state: parsed.data.state,
|
||||
note: parsed.data.note ?? null,
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof BokslutChecklistError) {
|
||||
return NextResponse.json({ error: getErrorMessage(err), code: err.code }, { status: 400 })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
const checklist = await buildBokslutChecklist(supabase, companyId, user.id, id)
|
||||
return NextResponse.json({ data: checklist })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Tests for GET/PATCH /api/bookkeeping/fiscal-periods/{id}/bokslut-checklist
|
||||
* (cookie session, withRouteContext). The checklist service 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 buildMock = vi.fn()
|
||||
const setMock = vi.fn()
|
||||
vi.mock('@/lib/bokslut/checklist', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/bokslut/checklist')>('@/lib/bokslut/checklist')
|
||||
return {
|
||||
...actual,
|
||||
buildBokslutChecklist: (...args: unknown[]) => buildMock(...args),
|
||||
setChecklistItem: (...args: unknown[]) => setMock(...args),
|
||||
}
|
||||
})
|
||||
|
||||
import { BokslutChecklistError } from '@/lib/bokslut/checklist'
|
||||
import { GET, PATCH } from '../[id]/bokslut-checklist/route'
|
||||
|
||||
const p = (id: string) => ({ params: Promise.resolve({ id }) }) as never
|
||||
const CHECKLIST = {
|
||||
period: { id: 'fy-2026', name: 'Räkenskapsår 2026', period_start: '2026-01-01', period_end: '2026-12-31' },
|
||||
items: [],
|
||||
summary: { total: 0, done: 0, not_applicable: 0, open: 0 },
|
||||
}
|
||||
|
||||
describe('bokslut checklist route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
buildMock.mockResolvedValue(CHECKLIST)
|
||||
setMock.mockResolvedValue({ item_key: 'inventory_valued', state: 'done' })
|
||||
})
|
||||
|
||||
it('401 without a session', async () => {
|
||||
requireAuthMock.mockResolvedValue({ error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) })
|
||||
const res = await GET(createMockRequest('http://localhost/x'), p('fy-2026'))
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('GET returns the checklist for the period and 404s a foreign one', async () => {
|
||||
const res = await GET(createMockRequest('http://localhost/x'), p('fy-2026'))
|
||||
expect(res.status).toBe(200)
|
||||
const { body } = await parseJsonResponse<{ data: { period: { id: string } } }>(res)
|
||||
expect(body.data.period.id).toBe('fy-2026')
|
||||
expect(buildMock).toHaveBeenCalledWith(supabase, 'company-1', 'user-1', 'fy-2026')
|
||||
|
||||
buildMock.mockResolvedValue(null)
|
||||
const missing = await GET(createMockRequest('http://localhost/x'), p('nope'))
|
||||
expect(missing.status).toBe(404)
|
||||
})
|
||||
|
||||
it('PATCH validates the body, ticks the item as the user, and returns the refreshed checklist', async () => {
|
||||
const res = await PATCH(
|
||||
createMockRequest('http://localhost/x', { method: 'PATCH', body: { item_key: 'inventory_valued', state: 'done', note: 'Inventerat' } }),
|
||||
p('fy-2026'),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(setMock).toHaveBeenCalledWith(supabase, 'company-1', 'user-1', 'fy-2026', { item_key: 'inventory_valued', state: 'done', note: 'Inventerat' })
|
||||
// Existence check without recomputing readiness, then the full rebuild.
|
||||
expect(buildMock).toHaveBeenNthCalledWith(1, supabase, 'company-1', 'user-1', 'fy-2026', { readiness: null })
|
||||
expect(buildMock).toHaveBeenNthCalledWith(2, supabase, 'company-1', 'user-1', 'fy-2026')
|
||||
|
||||
const bad = await PATCH(createMockRequest('http://localhost/x', { method: 'PATCH', body: { item_key: 'inventory_valued', state: 'maybe' } }), p('fy-2026'))
|
||||
expect(bad.status).toBe(400)
|
||||
const notJson = await PATCH(new Request('http://localhost/x', { method: 'PATCH', body: '{' }) as never, p('fy-2026'))
|
||||
expect(notJson.status).toBe(400)
|
||||
})
|
||||
|
||||
it('PATCH maps policy refusals to 400 + code, 404s a foreign period, and requires write permission', async () => {
|
||||
setMock.mockRejectedValue(new BokslutChecklistError('Okänt steg.', 'UNKNOWN_ITEM'))
|
||||
const refused = await PATCH(createMockRequest('http://localhost/x', { method: 'PATCH', body: { item_key: 'nope', state: 'done' } }), p('fy-2026'))
|
||||
expect(refused.status).toBe(400)
|
||||
expect((await parseJsonResponse<{ code: string }>(refused)).body.code).toBe('UNKNOWN_ITEM')
|
||||
|
||||
buildMock.mockResolvedValue(null)
|
||||
const missing = await PATCH(createMockRequest('http://localhost/x', { method: 'PATCH', body: { item_key: 'no_drafts', state: 'done' } }), p('nope'))
|
||||
expect(missing.status).toBe(404)
|
||||
|
||||
requireWriteMock.mockResolvedValue({ ok: false, response: NextResponse.json({ error: 'Läsbehörighet' }, { status: 403 }) })
|
||||
const forbidden = await PATCH(createMockRequest('http://localhost/x', { method: 'PATCH', body: { item_key: 'no_drafts', state: 'done' } }), p('fy-2026'))
|
||||
expect(forbidden.status).toBe(403)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { contentDisposition } from '@/lib/api/content-disposition'
|
||||
import { AccountKeySchema } from '@/lib/reconciliation/schemas'
|
||||
import { getAttachmentRow } from '@/lib/reconciliation/attachments-store'
|
||||
import { downloadUnderlag, ReconciliationAttachmentError, removeUnderlag } from '@/lib/reconciliation/attachments'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
const UUID = z.string().uuid()
|
||||
|
||||
/**
|
||||
* GET /api/reconciliation/accounts/{accountKey}/attachments/{attachmentId}
|
||||
* streams the file inline (same shape as /api/documents/{id}/inline:
|
||||
* the caller's client authorizes the row, the service role reads the
|
||||
* non-public bucket).
|
||||
* DELETE /api/reconciliation/accounts/{accountKey}/attachments/{attachmentId}
|
||||
* stamps the attachment removed; body { reason? }. The file and the
|
||||
* row stay (BFL 7 kap.).
|
||||
*/
|
||||
export const GET = withRouteContext<{ params: Promise<{ accountKey: string; attachmentId: string }> }>(
|
||||
'reconciliation.accounts.attachments.file',
|
||||
async (_request, { supabase, companyId, log }, { params }) => {
|
||||
const { accountKey, attachmentId } = await params
|
||||
if (!AccountKeySchema.safeParse(accountKey).success || !UUID.safeParse(attachmentId).success) {
|
||||
return NextResponse.json({ error: 'Okänt underlag' }, { status: 404 })
|
||||
}
|
||||
const row = await getAttachmentRow(supabase, companyId, accountKey, attachmentId)
|
||||
if (!row) {
|
||||
return NextResponse.json({ error: 'Okänt underlag' }, { status: 404 })
|
||||
}
|
||||
const { blob, error } = await downloadUnderlag(createServiceClient(), row)
|
||||
if (error || !blob) {
|
||||
log.error('underlag download failed', error, { companyId, accountKey, attachmentId })
|
||||
return NextResponse.json({ error: 'Kunde inte hämta underlaget. Försök igen om en stund.' }, { status: 500 })
|
||||
}
|
||||
return new NextResponse(blob, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': row.mime_type,
|
||||
'Content-Disposition': contentDisposition('inline', row.file_name),
|
||||
'Cache-Control': 'private, no-store',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
const RemoveBodySchema = z.object({ reason: z.string().max(500).nullable().optional() })
|
||||
|
||||
export const DELETE = withRouteContext<{ params: Promise<{ accountKey: string; attachmentId: string }> }>(
|
||||
'reconciliation.accounts.attachments.remove',
|
||||
async (request, { supabase, user, companyId }, { params }) => {
|
||||
const { accountKey, attachmentId } = await params
|
||||
if (!AccountKeySchema.safeParse(accountKey).success || !UUID.safeParse(attachmentId).success) {
|
||||
return NextResponse.json({ error: 'Okänt underlag' }, { status: 404 })
|
||||
}
|
||||
let reason: string | null = null
|
||||
const raw = await request.text()
|
||||
if (raw.trim()) {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Ogiltig JSON' }, { status: 400 })
|
||||
}
|
||||
const body = RemoveBodySchema.safeParse(parsed)
|
||||
if (!body.success) return NextResponse.json({ error: 'Ogiltig body' }, { status: 400 })
|
||||
reason = body.data.reason ?? null
|
||||
}
|
||||
try {
|
||||
const attachment = await removeUnderlag(supabase, companyId, user.id, accountKey, attachmentId, { reason })
|
||||
if (!attachment) return NextResponse.json({ error: 'Okänt underlag' }, { status: 404 })
|
||||
return NextResponse.json({ data: { attachment } })
|
||||
} catch (err) {
|
||||
if (err instanceof ReconciliationAttachmentError) {
|
||||
return NextResponse.json({ error: getErrorMessage(err), code: err.code }, { status: err.code === 'ALREADY_REMOVED' ? 409 : 400 })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { AccountKeySchema } from '@/lib/reconciliation/schemas'
|
||||
import {
|
||||
attachUnderlag,
|
||||
listAttachments,
|
||||
MAX_ATTACHMENT_NOTE_LENGTH,
|
||||
ReconciliationAttachmentError,
|
||||
} from '@/lib/reconciliation/attachments'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { ISO_DATE_RE } from '@/lib/invariants'
|
||||
|
||||
/**
|
||||
* GET /api/reconciliation/accounts/{accountKey}/attachments?through_date=
|
||||
* POST /api/reconciliation/accounts/{accountKey}/attachments (multipart)
|
||||
*
|
||||
* The underlag of one account's balansdag: the files the account was
|
||||
* reconciled against. GET lists them (?include_removed=1 adds the removed
|
||||
* ones with their stamp); POST attaches one file: fields `file`,
|
||||
* `through_date` and optional `note`. Policy in lib/reconciliation/attachments.ts.
|
||||
*/
|
||||
export const GET = withRouteContext<{ params: Promise<{ accountKey: string }> }>(
|
||||
'reconciliation.accounts.attachments.list',
|
||||
async (request, { supabase, companyId }, { params }) => {
|
||||
const { accountKey } = await params
|
||||
if (!AccountKeySchema.safeParse(accountKey).success) {
|
||||
return NextResponse.json({ error: 'Okänt konto' }, { status: 404 })
|
||||
}
|
||||
const { searchParams } = new URL(request.url)
|
||||
const throughDate = searchParams.get('through_date') ?? ''
|
||||
if (!ISO_DATE_RE.test(throughDate)) {
|
||||
return NextResponse.json({ error: 'through_date (ÅÅÅÅ-MM-DD) krävs' }, { status: 400 })
|
||||
}
|
||||
const attachments = await listAttachments(supabase, companyId, accountKey, throughDate, {
|
||||
includeRemoved: searchParams.get('include_removed') === '1',
|
||||
})
|
||||
return NextResponse.json({ data: { attachments } })
|
||||
},
|
||||
)
|
||||
|
||||
export const POST = withRouteContext<{ params: Promise<{ accountKey: string }> }>(
|
||||
'reconciliation.accounts.attachments.create',
|
||||
async (request, { supabase, user, companyId }, { params }) => {
|
||||
const { accountKey } = await params
|
||||
if (!AccountKeySchema.safeParse(accountKey).success) {
|
||||
return NextResponse.json({ error: 'Okänt konto' }, { status: 404 })
|
||||
}
|
||||
let form: FormData
|
||||
try {
|
||||
form = await request.formData()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Ogiltig uppladdning: förväntade multipart/form-data' }, { status: 400 })
|
||||
}
|
||||
const file = form.get('file')
|
||||
const throughDate = String(form.get('through_date') ?? '')
|
||||
const noteRaw = form.get('note')
|
||||
const note = typeof noteRaw === 'string' ? noteRaw : null
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ error: 'Ingen fil bifogad' }, { status: 400 })
|
||||
}
|
||||
if (!ISO_DATE_RE.test(throughDate)) {
|
||||
return NextResponse.json({ error: 'through_date (ÅÅÅÅ-MM-DD) krävs' }, { status: 400 })
|
||||
}
|
||||
if (note && note.length > MAX_ATTACHMENT_NOTE_LENGTH) {
|
||||
return NextResponse.json({ error: 'Noteringen är för lång' }, { status: 400 })
|
||||
}
|
||||
try {
|
||||
const attachment = await attachUnderlag(supabase, companyId, user.id, accountKey, {
|
||||
through_date: throughDate,
|
||||
note,
|
||||
file: { name: file.name, type: file.type, size: file.size, buffer: await file.arrayBuffer() },
|
||||
})
|
||||
return NextResponse.json({ data: { attachment } }, { status: 201 })
|
||||
} catch (err) {
|
||||
if (err instanceof ReconciliationAttachmentError) {
|
||||
return NextResponse.json({ error: getErrorMessage(err), code: err.code }, { status: 400 })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user