* feat(salary): update_salary_run MCP tool and editable draft payment date payment_date drives the booking entry date but was only editable via the v1 PATCH. Close the gap on both remaining surfaces: - New staged MCP write tool gnubok_update_salary_run (search-only catalog; tools/list budget is at zero headroom) accepting the exact v1 PATCH field set: payment_date, voucher_series, notes. Draft-only with the same optimistic lock semantics, via a new shared service lib/salary/update-run.ts used by both the staging preflight and the commit executor. - Run header UI: payment date on a draft run is now an inline date input (prefilled, committed on blur/Enter, snaps back on failure), saved through the existing internal PATCH. Read-only once not draft. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy * fix(salary): op-type migration, calc invalidation on date change, scanner compliance Consolidated CI + review fix pass for #2041: - pg-real: add 'update_salary_run' to pending_operations_operation_type_check (wholesale re-create, NOT VALID + VALIDATE pair, mirroring 20260828160000/1). - Swedish accounting review: a payment_date change on a draft run now clears every roster row's calculation_breakdown (shared service and internal PATCH alike), so both book preflights refuse the run until a recalculation has run against the new date; skatteavdrag and the AGI redovisningsperiod follow the payment month. Staging preview exposes invalidates_calculation and the next hint states the clearing. - no-phantom-columns: literal select strings in update-run.ts; ceiling +1 with a documented reason for the inherent patch-shaped UPDATE payload. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy * fix(salary): close skeptic findings on payment_date editing Skeptic round 1 refuted two paths; both closed: - Retry idempotency (correctness): the calculation_breakdown clear was gated on new-date-differs-from-stored, so a retry after a partial failure (header committed, clear failed) compared against the already updated date and skipped the clear forever, leaving a stale calculation bookable. The clear is now gated on payment_date being SUPPLIED, on all three surfaces (shared service, internal PATCH, v1 PATCH: the v1 route previously had no clear at all and bypassed the invariant). - Kontantprincipen (compliance): AGI derives its redovisningsperiod from period_year/period_month while the verifikat books on payment_date, so a cross-month payment_date change could book salary in one month and declare it in another. All three edit surfaces now refuse a payment_date outside the run's period month with the new structured error SALARY_RUN_PAYMENT_DATE_OUTSIDE_PERIOD; the UI date input is min/max-bounded to the period month. - The internal PATCH update is now optimistic-locked on status='draft' (races return 400 instead of silently writing), matching the v1 PATCH and the shared service, and the clear cannot fire for a run that left draft. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy * fix(salary): carry book_skattekonto op types through the constraint re-create The sibling migration 20260830130000 (merged from main) re-created pending_operations_operation_type_check with book_skattekonto_row and book_skattekonto_rows. This branch's 20260830150000 sorts after it and re-creates the constraint wholesale, so its list must be that migration's superset or the two values would be silently revoked at apply time. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy * fix(salary): value-validate internal PATCH and grandfather out-of-period dates Two skeptic follow-ups: - The internal PATCH now validates values, not just keys: JSON body must be an object, payment_date must be ISO (shared ISO_DATE_RE), voucher_series a single A-Z letter, notes a string of max 2000 chars or null: the same rules as the v1 UpdateSalaryRunSchema, so nothing unvalidated can reach the DB through the whitelist. - Creation does not (yet) couple payment_date to the period month, so a legally created out-of-period date must stay correctable. All three edit surfaces now allow day adjustments within the run's CURRENT payment month as well as the period month (grandfather clause); no move can introduce a new wrong month. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy * fix(salary): resolve migration version collision with delete_draft_invoice Main's delete_draft_invoice PR landed on the same 20260830150000/150001 versions and also re-creates pending_operations_operation_type_check. Rename this branch's pair to 20260830160000/160001 (applies last) and carry delete_draft_invoice through the wholesale re-create so nothing is silently revoked. Final list = sibling's list + update_salary_run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy * docs(salary): regenerate accounted-api skill for the new PATCH pitfalls apiskill:check byte-compares the generated skill against the registry; the two pitfalls added to the v1 salary-runs PATCH endpoint made references/salary-runs.md stale and failed Core Build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
490 lines
17 KiB
TypeScript
490 lines
17 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { NextResponse } from 'next/server'
|
|
import {
|
|
createQueuedMockSupabase,
|
|
createMockRequest,
|
|
parseJsonResponse,
|
|
createMockRouteParams,
|
|
} from '@/tests/helpers'
|
|
|
|
// ── Mocks ────────────────────────────────────────────────────
|
|
// The route is wrapped in withRouteContext, which resolves auth via
|
|
// requireAuth() (the only path that enforces MFA/AAL2 on hosted) and the active
|
|
// company via getActiveCompanyId(). Mock those, not createClient/getUser.
|
|
|
|
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'),
|
|
}))
|
|
|
|
vi.mock('@/lib/auth/require-write', () => ({
|
|
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
|
}))
|
|
|
|
import { DELETE, GET, PATCH } from '../route'
|
|
import { requireAuth } from '@/lib/auth/require-auth'
|
|
import { encryptPersonnummer } from '@/lib/salary/personnummer'
|
|
|
|
// ── Test data ────────────────────────────────────────────────
|
|
|
|
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
|
|
|
// ── Tests ────────────────────────────────────────────────────
|
|
|
|
describe('DELETE /api/salary/runs/[id]', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('returns 401 when not authenticated', async () => {
|
|
vi.mocked(requireAuth).mockResolvedValue({
|
|
user: null as never,
|
|
supabase: {} as never,
|
|
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
|
})
|
|
|
|
const request = createMockRequest('/api/salary/runs/run-1', { method: 'DELETE' })
|
|
const response = await DELETE(request, createMockRouteParams({ id: 'run-1' }))
|
|
const { status, body } = await parseJsonResponse(response)
|
|
|
|
expect(status).toBe(401)
|
|
expect(body).toEqual({ error: 'Unauthorized' })
|
|
})
|
|
|
|
it('returns 404 when salary run not found', async () => {
|
|
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
|
vi.mocked(requireAuth).mockResolvedValue({
|
|
user: mockUser as never,
|
|
supabase: supabase as never,
|
|
error: null,
|
|
})
|
|
|
|
enqueueMany([
|
|
{ data: null, error: { message: 'Not found' } }, // salary_runs lookup
|
|
])
|
|
|
|
const request = createMockRequest('/api/salary/runs/run-1', { method: 'DELETE' })
|
|
const response = await DELETE(request, createMockRouteParams({ id: 'run-1' }))
|
|
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
|
|
|
expect(status).toBe(404)
|
|
expect(body.error).toContain('hittades inte')
|
|
})
|
|
|
|
it('returns 400 when the run is not a draft (booked must be storno-reversed)', async () => {
|
|
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
|
vi.mocked(requireAuth).mockResolvedValue({
|
|
user: mockUser as never,
|
|
supabase: supabase as never,
|
|
error: null,
|
|
})
|
|
|
|
enqueueMany([
|
|
{ data: { id: 'run-1', status: 'booked' } }, // salary_runs lookup
|
|
])
|
|
|
|
const request = createMockRequest('/api/salary/runs/run-1', { method: 'DELETE' })
|
|
const response = await DELETE(request, createMockRouteParams({ id: 'run-1' }))
|
|
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
|
|
|
expect(status).toBe(400)
|
|
expect(body.error).toContain('utkast')
|
|
})
|
|
|
|
it('deletes a draft run and returns success', async () => {
|
|
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
|
vi.mocked(requireAuth).mockResolvedValue({
|
|
user: mockUser as never,
|
|
supabase: supabase as never,
|
|
error: null,
|
|
})
|
|
|
|
enqueueMany([
|
|
{ data: { id: 'run-1', status: 'draft' } }, // salary_runs lookup
|
|
{ data: null }, // salary_runs delete (cascade handles children)
|
|
])
|
|
|
|
const request = createMockRequest('/api/salary/runs/run-1', { method: 'DELETE' })
|
|
const response = await DELETE(request, createMockRouteParams({ id: 'run-1' }))
|
|
const { status, body } = await parseJsonResponse<{ data: { id: string; deleted: boolean } }>(response)
|
|
|
|
expect(status).toBe(200)
|
|
expect(body.data).toEqual({ id: 'run-1', deleted: true })
|
|
})
|
|
})
|
|
|
|
// ── PATCH (draft-only header edit) ───────────────────────────────────────────
|
|
|
|
describe('PATCH /api/salary/runs/[id]', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
function authorize(supabase: unknown) {
|
|
vi.mocked(requireAuth).mockResolvedValue({
|
|
user: mockUser as never,
|
|
supabase: supabase as never,
|
|
error: null,
|
|
})
|
|
}
|
|
|
|
const DRAFT_RUN = {
|
|
id: 'run-1',
|
|
status: 'draft',
|
|
payment_date: '2026-07-25',
|
|
period_year: 2026,
|
|
period_month: 7,
|
|
}
|
|
|
|
it('returns 400 when the run is not a draft', async () => {
|
|
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
|
authorize(supabase)
|
|
|
|
enqueueMany([
|
|
{ data: { ...DRAFT_RUN, status: 'review' } }, // lookup
|
|
])
|
|
|
|
const request = createMockRequest('/api/salary/runs/run-1', {
|
|
method: 'PATCH',
|
|
body: { payment_date: '2026-07-23' },
|
|
})
|
|
const response = await PATCH(request, createMockRouteParams({ id: 'run-1' }))
|
|
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
|
|
|
expect(status).toBe(400)
|
|
expect(body.error).toContain('utkast')
|
|
})
|
|
|
|
it('updates payment_date on a draft and clears roster calculation_breakdown', async () => {
|
|
const { supabase, enqueueMany, findCall } = createQueuedMockSupabase()
|
|
authorize(supabase)
|
|
|
|
enqueueMany([
|
|
{ data: DRAFT_RUN }, // lookup
|
|
{ data: { ...DRAFT_RUN, payment_date: '2026-07-23' } }, // update
|
|
{ data: null }, // roster calculation_breakdown clear
|
|
])
|
|
|
|
const request = createMockRequest('/api/salary/runs/run-1', {
|
|
method: 'PATCH',
|
|
body: { payment_date: '2026-07-23' },
|
|
})
|
|
const response = await PATCH(request, createMockRouteParams({ id: 'run-1' }))
|
|
const { status, body } = await parseJsonResponse<{ data: { payment_date: string } }>(response)
|
|
|
|
expect(status).toBe(200)
|
|
expect(body.data.payment_date).toBe('2026-07-23')
|
|
// The new date invalidates any existing calculation: skatteavdrag and the
|
|
// AGI redovisningsperiod follow the payment month, and the book preflights
|
|
// refuse roster rows without a breakdown until a recalculation runs.
|
|
expect(findCall('salary_run_employees', 'update')).toEqual([
|
|
{ calculation_breakdown: null },
|
|
])
|
|
})
|
|
|
|
it('does not clear calculations when only notes change', async () => {
|
|
const { supabase, enqueueMany, findCall } = createQueuedMockSupabase()
|
|
authorize(supabase)
|
|
|
|
enqueueMany([
|
|
{ data: DRAFT_RUN }, // lookup
|
|
{ data: { ...DRAFT_RUN, notes: 'Julbonus' } }, // update
|
|
])
|
|
|
|
const request = createMockRequest('/api/salary/runs/run-1', {
|
|
method: 'PATCH',
|
|
body: { notes: 'Julbonus' },
|
|
})
|
|
const response = await PATCH(request, createMockRouteParams({ id: 'run-1' }))
|
|
const { status } = await parseJsonResponse(response)
|
|
|
|
expect(status).toBe(200)
|
|
expect(findCall('salary_run_employees', 'update')).toBeUndefined()
|
|
})
|
|
|
|
it('rejects a payment_date outside the run period month (kontantprincipen)', async () => {
|
|
const { supabase, enqueueMany, findCall } = createQueuedMockSupabase()
|
|
authorize(supabase)
|
|
|
|
enqueueMany([
|
|
{ data: DRAFT_RUN }, // lookup: period 2026-07
|
|
])
|
|
|
|
const request = createMockRequest('/api/salary/runs/run-1', {
|
|
method: 'PATCH',
|
|
body: { payment_date: '2026-08-01' },
|
|
})
|
|
const response = await PATCH(request, createMockRouteParams({ id: 'run-1' }))
|
|
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
|
|
|
expect(status).toBe(400)
|
|
expect(body.error).toContain('period')
|
|
// Refused before any write.
|
|
expect(findCall('salary_runs', 'update')).toBeUndefined()
|
|
})
|
|
|
|
it('grandfathers day adjustments when the current date is already outside the period', async () => {
|
|
const { supabase, enqueueMany, findCall } = createQueuedMockSupabase()
|
|
authorize(supabase)
|
|
|
|
enqueueMany([
|
|
{ data: { ...DRAFT_RUN, payment_date: '2026-08-05' } }, // period 2026-07, paid in August
|
|
{ data: { ...DRAFT_RUN, payment_date: '2026-08-07' } }, // update
|
|
{ data: null }, // roster calculation_breakdown clear
|
|
])
|
|
|
|
const request = createMockRequest('/api/salary/runs/run-1', {
|
|
method: 'PATCH',
|
|
body: { payment_date: '2026-08-07' },
|
|
})
|
|
const response = await PATCH(request, createMockRouteParams({ id: 'run-1' }))
|
|
const { status } = await parseJsonResponse(response)
|
|
|
|
expect(status).toBe(200)
|
|
expect(findCall('salary_run_employees', 'update')).toEqual([{ calculation_breakdown: null }])
|
|
})
|
|
|
|
it('rejects an invalid voucher_series value', async () => {
|
|
const { supabase, enqueueMany, findCall } = createQueuedMockSupabase()
|
|
authorize(supabase)
|
|
|
|
enqueueMany([
|
|
{ data: DRAFT_RUN }, // lookup
|
|
])
|
|
|
|
const request = createMockRequest('/api/salary/runs/run-1', {
|
|
method: 'PATCH',
|
|
body: { voucher_series: 'AB' },
|
|
})
|
|
const response = await PATCH(request, createMockRouteParams({ id: 'run-1' }))
|
|
const { status } = await parseJsonResponse<{ error: string }>(response)
|
|
|
|
expect(status).toBe(400)
|
|
expect(findCall('salary_runs', 'update')).toBeUndefined()
|
|
})
|
|
|
|
it('returns 400 when the run advances past draft between fetch and update (race)', async () => {
|
|
const { supabase, enqueueMany, findCall } = createQueuedMockSupabase()
|
|
authorize(supabase)
|
|
|
|
enqueueMany([
|
|
{ data: DRAFT_RUN }, // lookup still sees draft
|
|
{ data: null }, // status-locked update matches no row
|
|
])
|
|
|
|
const request = createMockRequest('/api/salary/runs/run-1', {
|
|
method: 'PATCH',
|
|
body: { payment_date: '2026-07-23' },
|
|
})
|
|
const response = await PATCH(request, createMockRouteParams({ id: 'run-1' }))
|
|
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
|
|
|
expect(status).toBe(400)
|
|
expect(body.error).toContain('utkast')
|
|
// The clear must not run when the locked update matched nothing.
|
|
expect(findCall('salary_run_employees', 'update')).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
// ── GET detail additions (previous_run, corrected_by_run_id, deliveries) ────
|
|
//
|
|
// GET consumes queue entries in from() order. The run loads first; the rest
|
|
// fire together in a Promise.all, so their from() calls resolve in this order:
|
|
// 1 run, 2 employees, 3 settings (arbetsgivare), 4 previous-run lookup,
|
|
// [corrected_by lookup when status is 'corrected'], deliveries,
|
|
// [previous-run employees when found — nested after the lookup's await, so
|
|
// it lands last].
|
|
|
|
const GET_RUN = {
|
|
id: 'run-2',
|
|
company_id: 'company-1',
|
|
status: 'draft',
|
|
period_year: 2026,
|
|
period_month: 7,
|
|
payment_date: '2026-07-25',
|
|
}
|
|
|
|
describe('GET /api/salary/runs/[id] — additive detail fields', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
function authed(supabase: unknown) {
|
|
vi.mocked(requireAuth).mockResolvedValue({
|
|
user: mockUser as never,
|
|
supabase: supabase as never,
|
|
error: null,
|
|
})
|
|
}
|
|
|
|
it('returns previous_run with effective (override-coalesced) values', async () => {
|
|
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
|
authed(supabase)
|
|
|
|
enqueueMany([
|
|
{ data: GET_RUN },
|
|
{ data: [] }, // employees in this run
|
|
{ data: { org_number: null, entity_type: null } },
|
|
{ data: { id: 'run-1', period_year: 2026, period_month: 6 } }, // previous-run lookup
|
|
{ data: [] }, // deliveries
|
|
{
|
|
// previous-run employees — nested after the lookup's await, resolves last
|
|
data: [
|
|
{
|
|
employee_id: 'emp-1',
|
|
gross_salary: 35000,
|
|
tax_withheld: 8000,
|
|
tax_withheld_override: 7000,
|
|
net_salary: 27000,
|
|
},
|
|
],
|
|
},
|
|
])
|
|
|
|
const response = await GET(
|
|
createMockRequest('/api/salary/runs/run-2'),
|
|
createMockRouteParams({ id: 'run-2' }),
|
|
)
|
|
const { status, body } = await parseJsonResponse<{
|
|
data: {
|
|
previous_run: {
|
|
id: string
|
|
by_employee: Record<string, { gross: number; tax: number; net: number }>
|
|
} | null
|
|
corrected_by_run_id: string | null
|
|
payslip_deliveries_summary: { sent: number; failed: number; skipped: number }
|
|
}
|
|
}>(response)
|
|
|
|
expect(status).toBe(200)
|
|
expect(body.data.previous_run?.id).toBe('run-1')
|
|
// Override coalesced: tax 7000, net compensated +1000
|
|
expect(body.data.previous_run?.by_employee['emp-1']).toEqual({
|
|
gross: 35000,
|
|
tax: 7000,
|
|
net: 28000,
|
|
})
|
|
expect(body.data.corrected_by_run_id).toBeNull()
|
|
expect(body.data.payslip_deliveries_summary).toMatchObject({ sent: 0, failed: 0, skipped: 0 })
|
|
})
|
|
|
|
it('returns previous_run null on the first-ever run', async () => {
|
|
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
|
authed(supabase)
|
|
|
|
enqueueMany([
|
|
{ data: GET_RUN },
|
|
{ data: [] },
|
|
{ data: null }, // settings
|
|
{ data: null }, // no previous booked run
|
|
{ data: [] }, // deliveries
|
|
])
|
|
|
|
const response = await GET(
|
|
createMockRequest('/api/salary/runs/run-2'),
|
|
createMockRouteParams({ id: 'run-2' }),
|
|
)
|
|
const { status, body } = await parseJsonResponse<{ data: { previous_run: unknown } }>(response)
|
|
|
|
expect(status).toBe(200)
|
|
expect(body.data.previous_run).toBeNull()
|
|
})
|
|
|
|
it('masks embedded employees: no ciphertext, no personnummer_last4', async () => {
|
|
// The mask is YYYYMMDD-XXXX; a payload carrying the mask AND the last four
|
|
// digits would reassemble the full personnummer by concatenation.
|
|
const STORED_PNR = '190203040000' // synthetic
|
|
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
|
authed(supabase)
|
|
|
|
enqueueMany([
|
|
{ data: GET_RUN },
|
|
{
|
|
data: [
|
|
{
|
|
id: 'sre-1',
|
|
gross_salary: 30000,
|
|
employee: {
|
|
id: 'emp-1',
|
|
first_name: 'Test',
|
|
last_name: 'Testsson',
|
|
personnummer: encryptPersonnummer(STORED_PNR),
|
|
personnummer_last4: '0000',
|
|
employment_type: 'employee',
|
|
default_dimensions: {},
|
|
},
|
|
line_items: [],
|
|
},
|
|
],
|
|
},
|
|
{ data: null }, // settings
|
|
{ data: null }, // no previous booked run
|
|
{ data: [] }, // deliveries
|
|
])
|
|
|
|
const response = await GET(
|
|
createMockRequest('/api/salary/runs/run-2'),
|
|
createMockRouteParams({ id: 'run-2' }),
|
|
)
|
|
const { status, body } = await parseJsonResponse<{
|
|
data: { employees: Array<{ employee: Record<string, unknown> }> }
|
|
}>(response)
|
|
|
|
expect(status).toBe(200)
|
|
const employee = body.data.employees[0].employee
|
|
expect(employee.personnummer_masked).toBe('19020304-XXXX')
|
|
expect(employee).not.toHaveProperty('personnummer')
|
|
expect(employee).not.toHaveProperty('personnummer_last4')
|
|
// Neither the plaintext nor the suffix may appear anywhere in the payload.
|
|
expect(JSON.stringify(body)).not.toContain(STORED_PNR)
|
|
})
|
|
|
|
it('exposes corrected_by_run_id on corrected originals and counts latest deliveries', async () => {
|
|
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
|
authed(supabase)
|
|
|
|
enqueueMany([
|
|
{ data: { ...GET_RUN, status: 'corrected' } },
|
|
{ data: [] },
|
|
{ data: null }, // settings
|
|
{ data: null }, // previous booked run
|
|
{ data: { id: 'run-correction' } }, // corrected_by lookup
|
|
{
|
|
data: [
|
|
// newest first (route orders sent_at desc): emp-1 latest = sent
|
|
{ employee_id: 'emp-1', status: 'sent', sent_at: '2026-07-01T10:00:00Z' },
|
|
{ employee_id: 'emp-1', status: 'failed', sent_at: '2026-06-30T10:00:00Z' },
|
|
{ employee_id: 'emp-2', status: 'skipped', sent_at: '2026-07-01T10:00:00Z' },
|
|
],
|
|
},
|
|
])
|
|
|
|
const response = await GET(
|
|
createMockRequest('/api/salary/runs/run-2'),
|
|
createMockRouteParams({ id: 'run-2' }),
|
|
)
|
|
const { status, body } = await parseJsonResponse<{
|
|
data: {
|
|
corrected_by_run_id: string | null
|
|
payslip_deliveries_summary: { sent: number; failed: number; skipped: number }
|
|
}
|
|
}>(response)
|
|
|
|
expect(status).toBe(200)
|
|
expect(body.data.corrected_by_run_id).toBe('run-correction')
|
|
// Latest attempt per employee: emp-1 sent (failure superseded), emp-2 skipped
|
|
expect(body.data.payslip_deliveries_summary).toMatchObject({
|
|
sent: 1,
|
|
failed: 0,
|
|
skipped: 1,
|
|
})
|
|
})
|
|
})
|