Files
crewli/apps/app/tests/unit/schemas/timetableContractShape.test.ts
bert.hausmans bce3081cb2 test(timetable): add real-API contract fixtures as schema regression test (3 shape variants) (B6)
apps/app/tests/unit/schemas/timetableContractShape.test.ts (NEW, 5 tests):
  - base shape: one performance with stage assigned + full engagement
    (Bert's browser-tested sample, field-for-field). Asserts decimal-as-
    string contract on fee_amount/buma_percentage/vat_percentage AND
    enum-label wrapper on booking_status AND nested computed object.
  - parked shape: stage_id=null, stage=null (Wachtrij case)
  - multi-perf shape: two performances sharing engagement_id
    (RFC §D17 "Friday + Saturday under one combined deal")
  - sanity: individual performanceSchema parses each fixture element
  - regression guard: a payload with NUMBER fee_amount throws (locks
    out the pre-B5 bug class)

Every fixture spells out explicit `null` for the schema's nullable-but-
required fields (timestamps, notes, deal_breakdown) so the
nullable() vs optional() distinction is exercised, not glossed over.

Schema surface change to support the test:
  apps/app/src/schemas/timetable.ts now EXPORTS performanceArraySchema
  (previously a private const inside useTimetable.ts).
  apps/app/src/composables/api/useTimetable.ts imports the shared one
  instead of redeclaring it locally — single source of truth for the
  array shape consumers and tests share.

Test count: 397 → 402 (+5). Typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:33:13 +02:00

264 lines
8.5 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { performanceArraySchema, performanceSchema } from '@/schemas/timetable'
/**
* Real-API contract fixtures embedded as `const` — each one is what the
* SPA actually receives from `GET /events/{event}/performances` after
* Laravel's Resources serialise the eloquent models. Three shape variants
* lock down the breadth of what `performanceArraySchema.parse(...)` must
* accept without throwing:
*
* 1. Base shape — one performance with a stage assigned, full engagement,
* all common fields populated. Matches Bert's browser-
* tested sample (B5 report) field-for-field.
* 2. Parked shape — `stage_id: null`, `stage: null`. Wachtrij case.
* 3. Multi-perf shape — two performances sharing the same `engagement_id`
* (RFC §D17: "Friday + Saturday under one combined
* deal = 1 engagement, 2 performances").
*
* Every fixture spells out explicit `null` for the schema's nullable-but-
* required fields (timestamps, notes, etc.) so we exercise the
* `nullable()` vs `optional()` distinction the schema deliberately uses.
*
* If this test fails after a backend resource change, the schema must be
* updated to mirror the new wire format (frontend adapts, backend stays —
* Bert's strategic decision in B5). The fixtures here are the canonical
* contract; update them in lockstep with backend resource changes.
*/
const baseGenre = {
id: 'genre_indie_01',
organisation_id: 'org_01',
name: 'Indie',
color: '#7c3aed',
sort_order: 3,
is_active: true,
created_at: '2026-07-01T08:00:00+00:00',
updated_at: '2026-07-01T08:00:00+00:00',
}
const baseArtist = {
id: 'artist_donker_01',
organisation_id: 'org_01',
name: 'Donker Collective',
slug: 'donker-collective',
default_genre_id: 'genre_indie_01',
default_genre: baseGenre,
default_draw: 3486,
star_rating: 2,
home_base_country: 'NL',
agent_company_id: null,
notes: null,
engagements_summary: { lifetime_count: 1, upcoming_count: 1 },
created_at: '2026-07-01T08:00:00+00:00',
updated_at: '2026-07-01T08:00:00+00:00',
deleted_at: null,
}
const baseEngagement = {
id: 'eng_01',
organisation_id: 'org_01',
artist_id: 'artist_donker_01',
event_id: 'fest_01',
artist: baseArtist,
project_leader_id: null,
// Decimal columns: backend wire format is string (Laravel default for
// decimal(N,M) without an explicit cast). The schema reflects that.
booking_status: { value: 'confirmed' as const, label: 'Bevestigd' },
fee_amount: '11503.58',
fee_currency: 'EUR',
fee_type: { value: null, label: null },
buma_applicable: true,
buma_percentage: '7.00',
buma_handled_by: { value: 'organisation' as const, label: 'Organisatie' },
vat_applicable: true,
vat_percentage: '21.00',
deal_breakdown: null,
deposit_percentage: null,
deposit_due_date: null,
balance_due_date: null,
payment_status: { value: 'none' as const, label: 'Geen betaling' },
crew_count: 4,
guests_count: 2,
requested_at: null,
option_expires_at: null,
advance_open_from: null,
advance_open_to: null,
advancing_completed_count: 0,
advancing_total_count: 0,
notes: null,
computed: {
buma_amount: 805.25,
vat_grondslag: 12308.83,
vat_amount: 2584.85,
breakdown_total: 0,
total_cost: 14893.68,
},
created_at: '2026-07-01T08:00:00+00:00',
updated_at: '2026-07-01T08:00:00+00:00',
deleted_at: null,
}
const baseStage = {
id: 'stage_main_01',
event_id: 'fest_01',
name: 'Mainstage',
color: '#e85d75',
capacity: 4500,
sort_order: 1,
created_at: '2026-07-01T08:00:00+00:00',
updated_at: '2026-07-01T08:00:00+00:00',
}
// ─── Fixture 1: base shape (one performance, stage assigned) ──────────
const fixtureBase = [
{
id: 'perf_01',
engagement_id: 'eng_01',
event_id: 'subevent_fri_01',
stage_id: 'stage_main_01',
lane: 0,
lane_resolved: 0,
start_at: '2026-07-10T18:00:00+00:00',
end_at: '2026-07-10T19:00:00+00:00',
version: 0,
notes: null,
warnings: [],
engagement: baseEngagement,
stage: baseStage,
created_at: '2026-07-10T17:00:00+00:00',
updated_at: '2026-07-10T17:00:00+00:00',
deleted_at: null,
},
]
// ─── Fixture 2: parked shape (Wachtrij — stage_id: null, stage: null) ─
const fixtureParked = [
{
id: 'perf_parked_01',
engagement_id: 'eng_01',
event_id: 'subevent_fri_01',
stage_id: null,
lane: 0,
lane_resolved: 0,
start_at: '2026-07-10T00:00:00+00:00',
end_at: '2026-07-10T01:00:00+00:00',
version: 0,
notes: null,
warnings: [],
engagement: baseEngagement,
stage: null,
created_at: '2026-07-10T17:00:00+00:00',
updated_at: '2026-07-10T17:00:00+00:00',
deleted_at: null,
},
]
// ─── Fixture 3: multi-perf shape (one engagement, two performances) ───
const fixtureMultiPerf = [
{
id: 'perf_multi_a',
engagement_id: 'eng_multi_01',
event_id: 'subevent_fri_01',
stage_id: 'stage_main_01',
lane: 0,
lane_resolved: 0,
start_at: '2026-07-10T18:00:00+00:00',
end_at: '2026-07-10T19:00:00+00:00',
version: 0,
notes: null,
warnings: [],
engagement: { ...baseEngagement, id: 'eng_multi_01' },
stage: baseStage,
created_at: '2026-07-10T17:00:00+00:00',
updated_at: '2026-07-10T17:00:00+00:00',
deleted_at: null,
},
{
id: 'perf_multi_b',
engagement_id: 'eng_multi_01',
event_id: 'subevent_sat_01',
stage_id: 'stage_main_01',
lane: 0,
lane_resolved: 0,
start_at: '2026-07-11T22:00:00+00:00',
end_at: '2026-07-11T23:30:00+00:00',
version: 0,
notes: null,
warnings: [],
engagement: { ...baseEngagement, id: 'eng_multi_01' },
stage: baseStage,
created_at: '2026-07-10T17:00:00+00:00',
updated_at: '2026-07-10T17:00:00+00:00',
deleted_at: null,
},
]
describe('Timetable contract — Zod parses real-API response fixtures', () => {
it('parses the base shape (one performance, stage assigned)', () => {
expect(() => performanceArraySchema.parse(fixtureBase)).not.toThrow()
const parsed = performanceArraySchema.parse(fixtureBase)
expect(parsed).toHaveLength(1)
// Decimal-as-string contract — the bug B5 fixed.
expect(parsed[0].engagement?.fee_amount).toBe('11503.58')
expect(typeof parsed[0].engagement?.fee_amount).toBe('string')
expect(parsed[0].engagement?.buma_percentage).toBe('7.00')
expect(parsed[0].engagement?.vat_percentage).toBe('21.00')
// Enum-label wrapper contract.
expect(parsed[0].engagement?.booking_status.value).toBe('confirmed')
expect(parsed[0].engagement?.booking_status.label).toBe('Bevestigd')
// Nested computed object survives parse.
expect(parsed[0].engagement?.computed.total_cost).toBe(14893.68)
})
it('parses the parked shape (stage_id: null, stage: null)', () => {
expect(() => performanceArraySchema.parse(fixtureParked)).not.toThrow()
const parsed = performanceArraySchema.parse(fixtureParked)
expect(parsed[0].stage_id).toBeNull()
expect(parsed[0].stage).toBeNull()
})
it('parses the multi-performance-engagement shape (RFC D17)', () => {
expect(() => performanceArraySchema.parse(fixtureMultiPerf)).not.toThrow()
const parsed = performanceArraySchema.parse(fixtureMultiPerf)
expect(parsed).toHaveLength(2)
expect(parsed[0].engagement_id).toBe(parsed[1].engagement_id)
expect(parsed[0].engagement?.id).toBe(parsed[1].engagement?.id)
})
it('individual performanceSchema parses each fixture element', () => {
// Sanity: the array schema is just z.array(performanceSchema).
// If that ever drifts, this test catches the regression.
expect(() => performanceSchema.parse(fixtureBase[0])).not.toThrow()
expect(() => performanceSchema.parse(fixtureParked[0])).not.toThrow()
expect(() => performanceSchema.parse(fixtureMultiPerf[0])).not.toThrow()
expect(() => performanceSchema.parse(fixtureMultiPerf[1])).not.toThrow()
})
it('rejects a payload that uses NUMBER for fee_amount (regression guard)', () => {
// The pre-B5 bug: Zod accepted numeric fee_amount but the wire format
// was always string. After B5 the schema rejects numbers — locking out
// the class of bug where a hand-rolled mock or a future backend
// change accidentally serialises decimals as numbers.
const broken = [{
...fixtureBase[0],
engagement: { ...baseEngagement, fee_amount: 11503.58 as unknown as string },
}]
expect(() => performanceArraySchema.parse(broken)).toThrow()
})
})