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() }) })