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>
264 lines
8.5 KiB
TypeScript
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()
|
|
})
|
|
})
|