From bce3081cb21cce9cc1c68c31b35070543ff9f6cb Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 9 May 2026 22:47:24 +0200 Subject: [PATCH] test(timetable): add real-API contract fixtures as schema regression test (3 shape variants) (B6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/app/src/composables/api/useTimetable.ts | 3 +- apps/app/src/schemas/timetable.ts | 6 + .../schemas/timetableContractShape.test.ts | 263 ++++++++++++++++++ 3 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 apps/app/tests/unit/schemas/timetableContractShape.test.ts diff --git a/apps/app/src/composables/api/useTimetable.ts b/apps/app/src/composables/api/useTimetable.ts index 14d4a86f..282a9661 100644 --- a/apps/app/src/composables/api/useTimetable.ts +++ b/apps/app/src/composables/api/useTimetable.ts @@ -5,7 +5,7 @@ import { z } from 'zod' import { apiClient } from '@/lib/axios' import { artistEngagementSchema, - performanceSchema, + performanceArraySchema, stageSchema, } from '@/schemas/timetable' import type { @@ -15,7 +15,6 @@ import type { } from '@/types/timetable' const stageArraySchema = z.array(stageSchema) -const performanceArraySchema = z.array(performanceSchema) /** * RFC v0.2 §6.2 — read-side composables for the timetable canvas. diff --git a/apps/app/src/schemas/timetable.ts b/apps/app/src/schemas/timetable.ts index e4a70e30..56e8ef0a 100644 --- a/apps/app/src/schemas/timetable.ts +++ b/apps/app/src/schemas/timetable.ts @@ -190,6 +190,12 @@ export const performanceSchema = z.object({ deleted_at: z.string().nullable(), }) +/** + * Wire-shape of `GET …/performances` and the `cascaded[]` payload from + * `POST …/timetable/move`. Useful for contract regression tests. + */ +export const performanceArraySchema = z.array(performanceSchema) + export const moveTimetableSuccessSchema = z.object({ moved: performanceSchema, cascaded: z.array(performanceSchema), diff --git a/apps/app/tests/unit/schemas/timetableContractShape.test.ts b/apps/app/tests/unit/schemas/timetableContractShape.test.ts new file mode 100644 index 00000000..407c7e5f --- /dev/null +++ b/apps/app/tests/unit/schemas/timetableContractShape.test.ts @@ -0,0 +1,263 @@ +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() + }) +})