Files
accounted/app/api/bookkeeping/no-doc-required/batch/__tests__/route.test.ts
T
Jakob WennbergandClaude Opus 4.8 4dfd790de5 feat(bookkeeping): Ny verifikat modal, ledger-style list, SIE no-underlag exemptions (#698)
* feat(bookkeeping): Ny verifikat modal, ledger-style list, SIE no-underlag exemptions

Verifikat UX
- "Ny verifikat" opens in a modal (NewJournalEntryDialog) instead of an inline tab;
  the review step renders inline in the dialog rather than stacking a second dialog.
- JournalEntryForm: konteringsrader are the focus, with a compact pre-filled metadata
  bar (datum/serie/text/valuta/period) on top; verifikationstext auto-fills from the
  first row's account.
- JournalEntryList: belopp shown on collapsed rows; expanded view is an aligned
  Konto/Benämning/Debet/Kredit table.

SIE imports no longer flood "Att hantera: saknade underlag"
- Import gains an opt-in (off by default) toggle to mark imported verifikat as "Inget
  underlag krävs"; a "Rekommenderas vid migrering" badge nudges it for historical years.
- Multi-select batch-mark in the list for selective cleanup.
- Filter-scoped bulk mark (POST /api/bookkeeping/no-doc-required/bulk-missing): marks
  every missing-doc verifikat matching the active filters across all pages, with a
  dry_run count to confirm scope — the scalable remedy for a post-import flood.
- Shared helper markEntriesNoDocRequired + per-entry batch route.

Tests: no-doc helper, batch route, bulk-missing route.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(bookkeeping): address PR #698 review findings

- JournalEntryForm: restore the explicit "no underlag" acknowledgement in the
  modal's inline review. When no document is attached, the confirm button reads
  "Bokför utan underlag" (BFL 5 kap 6-7 §§), equivalent to the blocking dialog the
  non-bare flow shows — the bare path no longer posts behind only a passive banner.
- batch no-doc route: guard the ownership query with source_type IN
  NEEDS_DOC_SOURCE_TYPES so a crafted request can't exempt non-document-requiring
  entries (defense in depth on top of company + posted scoping).
- bulk-missing route: resolve doc/exemption status by querying only the candidate
  ids (chunked) instead of loading the company's full document_attachments and
  journal_entry_no_doc_required tables into memory — data minimisation + bounded
  memory for large migrations (the most-repeated reviewer finding).

Triaged as non-issues (left as-is): partial-import exemption (gated on
result.success == zero errors), reason write-back (sidecar row is FK-linked and
carries the reason), and "bulk-exempting manual entries" (consistent with the
existing per-entry NoDocRequiredToggle). No DB migration — reuses the existing
journal_entry_no_doc_required table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(bookkeeping): centralize bulk-missing date/series validation in Zod

Move the ISO-date and verifikationsserie format checks into the Zod schema so
malformed input is rejected with a clean 400 instead of being silently nulled
(or, for a shaped-but-invalid date, throwing a 500 via fetchAllRows). The date
refinement rejects values like 9999-99-99 / 2026-02-30 that a bare
/^\d{4}-\d{2}-\d{2}$/ regex lets through. Addresses the PR #698 reviewer nit on
split schema-vs-runtime validation. +2 route tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:41:30 +02:00

85 lines
3.4 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers'
import { NextResponse } from 'next/server'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() }))
vi.mock('@/lib/company/context', () => ({ getActiveCompanyId: vi.fn() }))
vi.mock('@/lib/auth/require-write', () => ({ requireWritePermission: vi.fn() }))
import { POST } from '../route'
import { requireAuth } from '@/lib/auth/require-auth'
import { getActiveCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
const mockUser = { id: 'user-1', email: 't@t.se' }
const UUID_A = '11111111-1111-4111-8111-111111111111'
const UUID_B = '22222222-2222-4222-8222-222222222222'
function makeReq(body: unknown) {
return new Request('http://localhost/api/bookkeeping/no-doc-required/batch', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
})
}
beforeEach(() => {
vi.clearAllMocks()
reset()
;(requireAuth as ReturnType<typeof vi.fn>).mockResolvedValue({ user: mockUser, supabase: mockSupabase })
;(getActiveCompanyId as ReturnType<typeof vi.fn>).mockResolvedValue('company-1')
;(requireWritePermission as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: true })
})
describe('POST /api/bookkeeping/no-doc-required/batch', () => {
it('returns 401 when not authenticated', async () => {
;(requireAuth as ReturnType<typeof vi.fn>).mockResolvedValue({
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await POST(makeReq({ journal_entry_ids: [UUID_A] }))
const { status } = await parseJsonResponse(res)
expect(status).toBe(401)
})
it('returns 403 for read-only members', async () => {
;(requireWritePermission as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'forbidden' }, { status: 403 }),
})
const res = await POST(makeReq({ journal_entry_ids: [UUID_A] }))
const { status } = await parseJsonResponse(res)
expect(status).toBe(403)
})
it('returns 400 for an empty id list', async () => {
const res = await POST(makeReq({ journal_entry_ids: [] }))
const { status } = await parseJsonResponse(res)
expect(status).toBe(400)
})
it('returns 400 for non-uuid ids', async () => {
const res = await POST(makeReq({ journal_entry_ids: ['not-a-uuid'] }))
const { status } = await parseJsonResponse(res)
expect(status).toBe(400)
})
it('exempts only owned, posted entries (defense in depth)', async () => {
enqueue({ data: [{ id: UUID_A }], error: null }) // ownership query: only A owned
enqueue({ error: null }) // helper upsert
const res = await POST(makeReq({ journal_entry_ids: [UUID_A, UUID_B], reason: 'Importerad' }))
const { status, body } = await parseJsonResponse<{ data: { exempted: number } }>(res)
expect(status).toBe(200)
expect(body.data.exempted).toBe(1)
})
it('returns exempted:0 without writing when no ids are owned', async () => {
enqueue({ data: [], error: null }) // ownership query → none owned
const res = await POST(makeReq({ journal_entry_ids: [UUID_A] }))
const { status, body } = await parseJsonResponse<{ data: { exempted: number } }>(res)
expect(status).toBe(200)
expect(body.data.exempted).toBe(0)
})
})