Files
accounted/lib/mileage/__tests__/csv-export.test.ts
T
Mattsson 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

83 lines
2.6 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { mileageTripsToCsv } from '@/lib/mileage/csv-export'
import type { MileageTrip } from '@/types'
function trip(overrides: Partial<MileageTrip>): MileageTrip {
return {
id: 'trip-1',
company_id: 'company-1',
user_id: 'user-1',
employee_id: null,
trip_date: '2026-05-10',
vehicle_type: 'own_car',
vehicle_registration: 'ABC123',
odometer_start: 1000,
odometer_end: 1032,
distance_km: 32.3,
from_location: 'Kontoret',
to_location: 'Kunden',
purpose: 'Kundbesök',
visited: null,
is_round_trip: false,
status: 'draft',
journal_entry_id: null,
salary_run_id: null,
notes: null,
created_via: 'manual',
created_at: '2026-05-10T00:00:00Z',
updated_at: '2026-05-10T00:00:00Z',
...overrides,
}
}
describe('mileageTripsToCsv', () => {
it('starts with a UTF-8 BOM and the Swedish header row', () => {
const csv = mileageTripsToCsv([])
expect(csv.charCodeAt(0)).toBe(0xfeff)
expect(csv).toContain('Datum;Förare;Fordon;Registreringsnummer')
})
it('renders decimal comma, odometer readings and status labels', () => {
const csv = mileageTripsToCsv([trip({})])
const row = csv.split('\r\n')[1]
expect(row).toContain('2026-05-10;;Egen bil;ABC123;1000;1032;32,3;Kontoret;Kunden')
expect(row).toContain('Utkast')
})
it('names the driver for employee-attributed trips', () => {
const csv = mileageTripsToCsv(
[trip({ employee_id: 'emp-1' })],
new Map(),
new Map([['emp-1', 'Anna Andersson']])
)
expect(csv.split('\r\n')[1]).toContain('2026-05-10;Anna Andersson;Egen bil')
})
it('neutralizes formula-injection triggers in user text', () => {
const csv = mileageTripsToCsv([
trip({ purpose: '=HYPERLINK("http://evil","x")', from_location: '+SUM(A1)', visited: '@cmd' }),
])
const row = csv.split('\r\n')[1]
expect(row).toContain(`'=HYPERLINK`)
expect(row).toContain(`'+SUM(A1)`)
expect(row).toContain(`'@cmd`)
})
it('quotes fields containing separators and escapes quotes', () => {
const csv = mileageTripsToCsv([
trip({ purpose: 'Möte; leverans', visited: 'Firma "AB"' }),
])
expect(csv).toContain('"Möte; leverans"')
expect(csv).toContain('"Firma ""AB"""')
})
it('maps journal entry ids to voucher labels for booked trips', () => {
const csv = mileageTripsToCsv(
[trip({ status: 'booked', journal_entry_id: 'je-1' })],
new Map([['je-1', 'A42']])
)
const row = csv.split('\r\n')[1]
expect(row).toContain('Bokförd;A42')
})
})