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 { apiClient } from '@/lib/axios'
|
||||||
import {
|
import {
|
||||||
artistEngagementSchema,
|
artistEngagementSchema,
|
||||||
performanceSchema,
|
performanceArraySchema,
|
||||||
stageSchema,
|
stageSchema,
|
||||||
} from '@/schemas/timetable'
|
} from '@/schemas/timetable'
|
||||||
import type {
|
import type {
|
||||||
@@ -15,7 +15,6 @@ import type {
|
|||||||
} from '@/types/timetable'
|
} from '@/types/timetable'
|
||||||
|
|
||||||
const stageArraySchema = z.array(stageSchema)
|
const stageArraySchema = z.array(stageSchema)
|
||||||
const performanceArraySchema = z.array(performanceSchema)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RFC v0.2 §6.2 — read-side composables for the timetable canvas.
|
* 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(),
|
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({
|
export const moveTimetableSuccessSchema = z.object({
|
||||||
moved: performanceSchema,
|
moved: performanceSchema,
|
||||||
cascaded: z.array(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