Files
accounted/app/api/mileage/book/__tests__/route.test.ts
T
MattssonandClaude Fable 5 7411a0171b feat(mileage): körjournal with milersättning booking, MCP tools and CSV export (#1448)
* feat(mileage): körjournal with milersättning booking, MCP tools and CSV export

New mileage_trips table (RLS, booked-delete trigger per BFL retention),
lib/mileage service reusing the payroll schablon rates, /api/mileage routes
(trips CRUD, period booking to 7331, salary-run push, körjournal CSV),
Körjournal dashboard page + nav, and three staged MCP tools (search-only
catalog). Trips book as one verifikat per period via the engine; salary
path inserts mileage_taxfree line items. mileage_trips classified in the
full-archive export.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(mileage): use shared roundOre helper per tightened ratchet baseline

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mileage): pending_operations op-type migration + Swedish review findings

- New migration pair adds log_mileage_trip/book_mileage_period to the
  pending_operations operation_type CHECK (pg-real audit).
- bookMileagePeriod refuses a period spanning several employees and names
  the employee in the verifikationstext when scoped (BFL motpart).
- vehicle_registration required for förmånsbil trips (schema, service,
  MCP staging, UI surfaces the field).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mileage): claim-first booking, CSV injection guard and driver column

- bookMileagePeriod claims trips (draft to booked CAS) before creating the
  verifikat, so a concurrent second booking loses the race instead of
  double-booking; claim reverts if verifikat creation fails.
- Körjournal CSV neutralizes formula-injection triggers (OWASP) and adds a
  Förare column naming the employee per trip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mileage): resolve CodeRabbit + Swedish review round: race, drift and hardening

- Copying a round trip no longer re-doubles the stored distance.
- pushMileageToSalaryRun claims trips before inserting line items (retry can
  no longer double-pay); CLAIM_LOST replaces misleading NO_TRIPS on lost races.
- Booked trips are DB-immutable via a BEFORE UPDATE trigger (new migration
  20260807113215): only claim/link/revert transitions and notes edits pass.
- Cross-year periods rejected (schablon rates are per calendar year); payroll
  config year read from the date string, not TZ-dependent getFullYear().
- MCP staged bookings freeze the previewed trip set (trip_ids in params) and
  the commit fails on drift; validation errors return 400, not 500.
- PATCH enforces the förmånsbil regnr rule on the effective row; export
  validates dates before they reach the Content-Disposition header; employee_id
  is verified company-scoped on trip creation; stale orphaned claims released.
- UI: fetch flags reset in finally; ICU plural for draft summary; distance
  stored at the column's 1-decimal precision.
- Tests: [id] route suite, pushMileageToSalaryRun suite, claim-race, drift,
  cross-year and update-trigger pg cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mileage): revert-to-draft must clear salary_run_id at the trigger level

New migration 20260807114924 replaces the booked-immutability function: a
booked -> draft revert now rejects rows keeping salary_run_id, closing the
DB-level double-pay path CodeRabbit flagged. pg test pins both directions;
the CLAIM_LOST unit test now asserts the revert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mileage): company-scope employee_id on PATCH (Superagent P2)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(mileage): valid v4 uuid in cross-company employee PATCH test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 14:17:21 +02:00

131 lines
4.0 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NextResponse } from 'next/server'
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() }))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/mileage/mileage-service', () => ({
bookMileagePeriod: vi.fn(),
}))
import { POST } from '../route'
import { requireAuth } from '@/lib/auth/require-auth'
import { bookMileagePeriod } from '@/lib/mileage/mileage-service'
const params = { params: Promise.resolve({}) } as never
function authed() {
vi.mocked(requireAuth).mockResolvedValue({
user: { id: 'user-1' } as never,
supabase: {} as never,
error: null,
} as never)
}
const VALID_BODY = {
from: '2026-05-01',
to: '2026-05-31',
entry_date: '2026-05-31',
counter_account: '2820',
}
function postReq(body: unknown) {
return new Request('https://x.test/api/mileage/book', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('POST /api/mileage/book', () => {
it('returns 401 when unauthenticated', async () => {
vi.mocked(requireAuth).mockResolvedValue({
user: null,
supabase: null,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
} as never)
const res = await POST(postReq(VALID_BODY), params)
expect(res.status).toBe(401)
})
it('returns 400 on an inverted date range', async () => {
authed()
const res = await POST(postReq({ ...VALID_BODY, from: '2026-06-01' }), params)
expect(res.status).toBe(400)
expect(bookMileagePeriod).not.toHaveBeenCalled()
})
it('returns 400 when the period has no unbooked trips', async () => {
authed()
vi.mocked(bookMileagePeriod).mockResolvedValue({ ok: false, code: 'NO_TRIPS' })
const res = await POST(postReq(VALID_BODY), params)
expect(res.status).toBe(400)
})
it('returns 400 for a period spanning calendar years', async () => {
authed()
const res = await POST(
postReq({ ...VALID_BODY, from: '2025-12-20', to: '2026-01-10', entry_date: '2026-01-10' }),
params
)
expect(res.status).toBe(400)
expect(bookMileagePeriod).not.toHaveBeenCalled()
})
it('returns 409 when a concurrent booking claimed the trips first', async () => {
authed()
vi.mocked(bookMileagePeriod).mockResolvedValue({ ok: false, code: 'CLAIM_LOST' })
const res = await POST(postReq(VALID_BODY), params)
expect(res.status).toBe(409)
})
it('returns 400 when the period spans several employees', async () => {
authed()
vi.mocked(bookMileagePeriod).mockResolvedValue({ ok: false, code: 'MIXED_EMPLOYEES' })
const res = await POST(postReq(VALID_BODY), params)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error).toContain('per anställd')
})
it('returns 400 when the entry date is in a locked period', async () => {
authed()
vi.mocked(bookMileagePeriod).mockResolvedValue({ ok: false, code: 'PERIOD_NOT_OPEN' })
const res = await POST(postReq(VALID_BODY), params)
expect(res.status).toBe(400)
})
it('returns the verifikat summary on success', async () => {
authed()
vi.mocked(bookMileagePeriod).mockResolvedValue({
ok: true,
journalEntryId: 'je-1',
voucherNumber: 42,
voucherSeries: 'A',
tripCount: 3,
totalAmount: 297.5,
summaries: [],
})
const res = await POST(postReq(VALID_BODY), params)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data).toMatchObject({
journal_entry_id: 'je-1',
voucher_number: 42,
voucher_series: 'A',
trip_count: 3,
total_amount: 297.5,
})
})
})