* 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>
161 lines
5.3 KiB
TypeScript
161 lines
5.3 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 }),
|
|
}))
|
|
|
|
import { PATCH, DELETE } from '../route'
|
|
import { requireAuth } from '@/lib/auth/require-auth'
|
|
|
|
const params = { params: Promise.resolve({ id: 'trip-1' }) } as never
|
|
|
|
type ExistingRow = {
|
|
id: string
|
|
status: string
|
|
vehicle_type: string
|
|
vehicle_registration: string | null
|
|
} | null
|
|
|
|
function supabaseWith(existing: ExistingRow, updated: unknown = { id: 'trip-1' }) {
|
|
const chain: Record<string, unknown> = {}
|
|
for (const method of ['select', 'eq', 'update', 'delete', 'in']) {
|
|
chain[method] = vi.fn(() => chain)
|
|
}
|
|
chain.maybeSingle = vi.fn(() => Promise.resolve({ data: existing, error: null }))
|
|
chain.single = vi.fn(() => Promise.resolve({ data: updated, error: null }))
|
|
chain.then = (resolve: (v: unknown) => unknown) =>
|
|
Promise.resolve({ data: null, error: null }).then(resolve)
|
|
return { from: vi.fn(() => chain), chain }
|
|
}
|
|
|
|
function authed(supabase: unknown) {
|
|
vi.mocked(requireAuth).mockResolvedValue({
|
|
user: { id: 'user-1' } as never,
|
|
supabase: supabase as never,
|
|
error: null,
|
|
} as never)
|
|
}
|
|
|
|
function patchReq(body: unknown) {
|
|
return new Request('https://x.test/api/mileage/trips/trip-1', {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
})
|
|
}
|
|
|
|
const DRAFT_OWN_CAR: ExistingRow = {
|
|
id: 'trip-1',
|
|
status: 'draft',
|
|
vehicle_type: 'own_car',
|
|
vehicle_registration: null,
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('PATCH /api/mileage/trips/[id]', () => {
|
|
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 PATCH(patchReq({ purpose: 'Nytt ärende' }), params)
|
|
expect(res.status).toBe(401)
|
|
})
|
|
|
|
it('returns 404 for a trip outside the company', async () => {
|
|
authed(supabaseWith(null))
|
|
const res = await PATCH(patchReq({ purpose: 'Nytt ärende' }), params)
|
|
expect(res.status).toBe(404)
|
|
})
|
|
|
|
it('returns 409 for a booked trip (underlag is immutable)', async () => {
|
|
authed(
|
|
supabaseWith({ ...DRAFT_OWN_CAR, status: 'booked' })
|
|
)
|
|
const res = await PATCH(patchReq({ purpose: 'Nytt ärende' }), params)
|
|
expect(res.status).toBe(409)
|
|
})
|
|
|
|
it('rejects assigning an employee outside the company', async () => {
|
|
// maybeSingle serves the trip lookup first, then the employee lookup: the
|
|
// second call finds no company-scoped employee row.
|
|
const supabase = supabaseWith(DRAFT_OWN_CAR)
|
|
const maybeSingle = supabase.chain.maybeSingle as ReturnType<typeof vi.fn>
|
|
maybeSingle
|
|
.mockImplementationOnce(() => Promise.resolve({ data: DRAFT_OWN_CAR, error: null }))
|
|
.mockImplementationOnce(() => Promise.resolve({ data: null, error: null }))
|
|
authed(supabase)
|
|
const res = await PATCH(
|
|
patchReq({ employee_id: '9f8e7d6c-5b4a-4321-8abc-def012345678' }),
|
|
params
|
|
)
|
|
expect(res.status).toBe(400)
|
|
const body = await res.json()
|
|
expect(body.error).toContain('anställda')
|
|
})
|
|
|
|
it('rejects switching to förmånsbil when neither patch nor row has a regnr', async () => {
|
|
authed(supabaseWith(DRAFT_OWN_CAR))
|
|
const res = await PATCH(patchReq({ vehicle_type: 'company_car_fossil' }), params)
|
|
expect(res.status).toBe(400)
|
|
})
|
|
|
|
it('allows switching to förmånsbil when the stored row already has a regnr', async () => {
|
|
authed(
|
|
supabaseWith({ ...DRAFT_OWN_CAR, vehicle_registration: 'ABC123' })
|
|
)
|
|
const res = await PATCH(patchReq({ vehicle_type: 'company_car_fossil' }), params)
|
|
expect(res.status).toBe(200)
|
|
})
|
|
|
|
it('updates a draft trip', async () => {
|
|
authed(supabaseWith(DRAFT_OWN_CAR, { id: 'trip-1', purpose: 'Nytt ärende' }))
|
|
const res = await PATCH(patchReq({ purpose: 'Nytt ärende' }), params)
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json()
|
|
expect(body.data.purpose).toBe('Nytt ärende')
|
|
})
|
|
})
|
|
|
|
describe('DELETE /api/mileage/trips/[id]', () => {
|
|
it('returns 404 for a trip outside the company', async () => {
|
|
authed(supabaseWith(null))
|
|
const res = await DELETE(
|
|
new Request('https://x.test/api/mileage/trips/trip-1', { method: 'DELETE' }),
|
|
params
|
|
)
|
|
expect(res.status).toBe(404)
|
|
})
|
|
|
|
it('returns 409 for a booked trip (BFL retention)', async () => {
|
|
authed(supabaseWith({ ...DRAFT_OWN_CAR, status: 'booked' }))
|
|
const res = await DELETE(
|
|
new Request('https://x.test/api/mileage/trips/trip-1', { method: 'DELETE' }),
|
|
params
|
|
)
|
|
expect(res.status).toBe(409)
|
|
})
|
|
|
|
it('deletes a draft trip', async () => {
|
|
authed(supabaseWith(DRAFT_OWN_CAR))
|
|
const res = await DELETE(
|
|
new Request('https://x.test/api/mileage/trips/trip-1', { method: 'DELETE' }),
|
|
params
|
|
)
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json()
|
|
expect(body.data.deleted).toBe(true)
|
|
})
|
|
})
|