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>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
263
apps/app/tests/unit/schemas/timetableContractShape.test.ts
Normal file
263
apps/app/tests/unit/schemas/timetableContractShape.test.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user