diff --git a/app/Rules/Stand/StandReservationPlanPayload.php b/app/Rules/Stand/StandReservationPlanPayload.php new file mode 100644 index 000000000..4314c6a94 --- /dev/null +++ b/app/Rules/Stand/StandReservationPlanPayload.php @@ -0,0 +1,511 @@ +validateUnknownKeys( + $attribute, + $value, + ['event_start', 'event_end', 'event_airport', 'event_airports', 'reservations'], + $fail + ); + + [$eventStart, $eventEnd] = $this->validateEventTimes($attribute, $value, $fail); + $eventAirports = $this->validateEventAirportScope($attribute, $value, $fail); + $reservations = $this->extractReservations($attribute, $value, $fail); + + if (is_null($reservations)) { + return; + } + + $intervalsByStand = $this->collectStandIntervals( + $attribute, + $reservations, + $eventAirports, + $eventStart, + $eventEnd, + $fail + ); + + $this->validateStandIntervalOverlaps($attribute, $intervalsByStand, $fail); + } + + /** + * @param string $attribute + * @param array $value + * @param Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail + * @return array{0:?CarbonImmutable,1:?CarbonImmutable} + */ + private function validateEventTimes(string $attribute, array $value, Closure $fail): array + { + $eventStart = $this->parseZuluTime($value['event_start'] ?? null); + if (!$eventStart) { + $fail("$attribute.event_start must be a Zulu timestamp in format YYYY-MM-DDTHH:MM:SSZ."); + } + + $eventEnd = $this->parseZuluTime($value['event_end'] ?? null); + if (!$eventEnd) { + $fail("$attribute.event_end must be a Zulu timestamp in format YYYY-MM-DDTHH:MM:SSZ."); + } + + if ($eventStart && $eventEnd && !$eventEnd->isAfter($eventStart)) { + $fail("$attribute.event_end must be after event_start."); + } + + return [$eventStart, $eventEnd]; + } + + /** + * @param string $attribute + * @param array $value + * @param Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail + * @return ?array + */ + private function extractReservations(string $attribute, array $value, Closure $fail): ?array + { + if ( + !array_key_exists('reservations', $value) + || !is_array($value['reservations']) + || count($value['reservations']) === 0 + || !array_is_list($value['reservations']) + ) { + $fail("$attribute.reservations must be a non-empty list of reservations."); + return null; + } + + return $value['reservations']; + } + + /** + * @param string $attribute + * @param array $reservations + * @param array $eventAirports + * @param ?CarbonImmutable $eventStart + * @param ?CarbonImmutable $eventEnd + * @param Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail + * @return array> + */ + private function collectStandIntervals( + string $attribute, + array $reservations, + array $eventAirports, + ?CarbonImmutable $eventStart, + ?CarbonImmutable $eventEnd, + Closure $fail + ): array { + $intervalsByStand = []; + + foreach ($reservations as $index => $reservation) { + $interval = $this->validateReservationAndBuildInterval( + $attribute, + $index, + $reservation, + $eventAirports, + $eventStart, + $eventEnd, + $fail + ); + + if (is_null($interval)) { + continue; + } + + $intervalsByStand[$interval['stand_key']][] = [ + 'index' => $interval['index'], + 'from' => $interval['from'], + 'to' => $interval['to'], + ]; + } + + return $intervalsByStand; + } + + /** + * @param string $attribute + * @param int $index + * @param mixed $reservation + * @param array $eventAirports + * @param ?CarbonImmutable $eventStart + * @param ?CarbonImmutable $eventEnd + * @param Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail + * @return ?array{index:int,stand_key:string,from:CarbonImmutable,to:CarbonImmutable} + */ + private function validateReservationAndBuildInterval( + string $attribute, + int $index, + mixed $reservation, + array $eventAirports, + ?CarbonImmutable $eventStart, + ?CarbonImmutable $eventEnd, + Closure $fail + ): ?array { + $itemPath = "$attribute.reservations.$index"; + $interval = null; + + if (!is_array($reservation)) { + $fail("$itemPath must be an object."); + } else { + $this->validateUnknownKeys($itemPath, $reservation, ['stand_id', 'stand', 'airport', 'cid', 'timefrom', 'timeto'], $fail); + + $standIdProvided = array_key_exists('stand_id', $reservation) && $reservation['stand_id'] !== null; + $standProvided = array_key_exists('stand', $reservation) && $reservation['stand'] !== null && $reservation['stand'] !== ''; + $hasSingleStandMode = $this->validateSingleStandMode($itemPath, $standIdProvided, $standProvided, $fail); + + if (!$this->isValidCid($reservation['cid'] ?? null)) { + $fail("$itemPath.cid must be a valid VATSIM CID."); + } + + [$timeFrom, $timeTo] = $this->validateReservationTimes($itemPath, $reservation, $eventStart, $eventEnd, $fail); + + $standKey = $this->resolveStandKey( + $itemPath, + $reservation, + $standIdProvided, + $standProvided, + $eventAirports, + $fail + ); + + if ($hasSingleStandMode && $standKey && $timeFrom && $timeTo) { + $interval = [ + 'index' => $index, + 'stand_key' => $standKey, + 'from' => $timeFrom, + 'to' => $timeTo, + ]; + } + } + + return $interval; + } + + /** + * @param string $itemPath + * @param bool $standIdProvided + * @param bool $standProvided + * @param Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail + * @return bool + */ + private function validateSingleStandMode( + string $itemPath, + bool $standIdProvided, + bool $standProvided, + Closure $fail + ): bool { + $hasSingleStandMode = $standIdProvided !== $standProvided; + + if (!$hasSingleStandMode) { + $fail("$itemPath must include exactly one of stand_id or stand."); + } + + return $hasSingleStandMode; + } + + /** + * @param string $itemPath + * @param array $reservation + * @param ?CarbonImmutable $eventStart + * @param ?CarbonImmutable $eventEnd + * @param Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail + * @return array{0:?CarbonImmutable,1:?CarbonImmutable} + */ + private function validateReservationTimes( + string $itemPath, + array $reservation, + ?CarbonImmutable $eventStart, + ?CarbonImmutable $eventEnd, + Closure $fail + ): array { + $timeFrom = $this->parseZuluTime($reservation['timefrom'] ?? null); + if (!$timeFrom) { + $fail("$itemPath.timefrom must be a Zulu timestamp in format YYYY-MM-DDTHH:MM:SSZ."); + } + + $timeTo = $this->parseZuluTime($reservation['timeto'] ?? null); + if (!$timeTo) { + $fail("$itemPath.timeto must be a Zulu timestamp in format YYYY-MM-DDTHH:MM:SSZ."); + } + + if ($timeFrom && $timeTo && !$timeTo->isAfter($timeFrom)) { + $fail("$itemPath.timeto must be after timefrom."); + } + + if ($eventStart && $timeFrom && $timeFrom->isBefore($eventStart)) { + $fail("$itemPath.timefrom must be within the event window."); + } + + if ($eventEnd && $timeTo && $timeTo->isAfter($eventEnd)) { + $fail("$itemPath.timeto must be within the event window."); + } + + return [$timeFrom, $timeTo]; + } + + /** + * @param string $itemPath + * @param array $reservation + * @param bool $standIdProvided + * @param bool $standProvided + * @param array $eventAirports + * @param Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail + * @return ?string + */ + private function resolveStandKey( + string $itemPath, + array $reservation, + bool $standIdProvided, + bool $standProvided, + array $eventAirports, + Closure $fail + ): ?string { + if ($standIdProvided) { + return $this->resolveStandKeyFromId($itemPath, $reservation, $fail); + } + + if ($standProvided) { + return $this->resolveStandKeyFromIdentifier($itemPath, $reservation, $eventAirports, $fail); + } + + return null; + } + + /** + * @param string $itemPath + * @param array $reservation + * @param Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail + * @return ?string + */ + private function resolveStandKeyFromId(string $itemPath, array $reservation, Closure $fail): ?string + { + if (!is_int($reservation['stand_id']) || $reservation['stand_id'] <= 0) { + $fail("$itemPath.stand_id must be a positive integer."); + return null; + } + + return sprintf('id:%d', $reservation['stand_id']); + } + + /** + * @param string $itemPath + * @param array $reservation + * @param array $eventAirports + * @param Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail + * @return ?string + */ + private function resolveStandKeyFromIdentifier( + string $itemPath, + array $reservation, + array $eventAirports, + Closure $fail + ): ?string { + $stand = $reservation['stand']; + + if (!is_string($stand) || trim($stand) === '') { + $fail("$itemPath.stand must be a non-empty string."); + return null; + } + + if ($eventAirports !== []) { + $resolvedAirport = $this->normalizeAirportCode($reservation['airport'] ?? null); + if (is_null($resolvedAirport) && count($eventAirports) === 1) { + $resolvedAirport = $eventAirports[0]; + } + + if (is_null($resolvedAirport) || !in_array($resolvedAirport, $eventAirports, true)) { + $fail( + is_null($resolvedAirport) + ? "$itemPath.airport is required when event_airports contains multiple airports and stand is used." + : "$itemPath.airport must be one of the event's airports." + ); + } else { + return sprintf( + 'code:%s:%s', + $resolvedAirport, + strtoupper(trim($stand)) + ); + } + } + + return null; + } + + /** + * @param string $path + * @param array $value + * @param array $allowedKeys + * @param Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail + * @return void + */ + private function validateUnknownKeys(string $path, array $value, array $allowedKeys, Closure $fail): void + { + $unknownKeys = array_diff(array_keys($value), $allowedKeys); + + foreach ($unknownKeys as $unknownKey) { + $fail("$path.$unknownKey is not an allowed field."); + } + } + + /** + * @param string $attribute + * @param array $payload + * @param Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail + * @return array + */ + private function validateEventAirportScope(string $attribute, array $payload, Closure $fail): array + { + $hasSingleAirport = array_key_exists('event_airport', $payload); + $hasMultipleAirports = array_key_exists('event_airports', $payload); + + if ($hasSingleAirport === $hasMultipleAirports) { + $fail("$attribute must include exactly one of event_airport or event_airports."); + return []; + } + + if ($hasSingleAirport) { + return $this->validateSingleEventAirport($attribute, $payload, $fail); + } + + return $this->validateMultipleEventAirports($attribute, $payload, $fail); + } + + /** + * @param string $attribute + * @param array $payload + * @param Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail + * @return array + */ + private function validateSingleEventAirport(string $attribute, array $payload, Closure $fail): array + { + $airport = $this->normalizeAirportCode($payload['event_airport']); + + if (!$airport) { + $fail("$attribute.event_airport must be a UK 4-letter ICAO code."); + return []; + } + + return [$airport]; + } + + /** + * @param string $attribute + * @param array $payload + * @param Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail + * @return array + */ + private function validateMultipleEventAirports(string $attribute, array $payload, Closure $fail): array + { + if (!is_array($payload['event_airports']) || count($payload['event_airports']) === 0) { + $fail("$attribute.event_airports must be a non-empty array of UK 4-letter ICAO codes."); + return []; + } + + $airports = []; + foreach ($payload['event_airports'] as $index => $airport) { + $normalizedAirport = $this->normalizeAirportCode($airport); + if (!$normalizedAirport) { + $fail("$attribute.event_airports.$index must be a UK 4-letter ICAO code."); + continue; + } + + $airports[] = $normalizedAirport; + } + + if (count(array_unique($airports)) !== count($airports)) { + $fail("$attribute.event_airports must not contain duplicate airports."); + } + + return $airports; + } + + private function normalizeAirportCode(mixed $value): ?string + { + if (!is_string($value)) { + return null; + } + + $airport = strtoupper(trim($value)); + + if (!preg_match('/^(EG|EI)[A-Z]{2}$/', $airport)) { + return null; + } + + return $airport; + } + + private function parseZuluTime(mixed $value): ?CarbonImmutable + { + $parsed = null; + + if (is_string($value) && preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/', $value)) { + try { + $candidate = CarbonImmutable::createFromFormat('Y-m-d\TH:i:s\Z', $value, 'UTC'); + } catch (\Exception) { + $candidate = null; + } + + if ($candidate && $candidate->format('Y-m-d\TH:i:s\Z') === $value) { + $parsed = $candidate; + } + } + + return $parsed; + } + + private function isValidCid(mixed $value): bool + { + return is_int($value) && VatsimCidValidator::isValid($value); + } + + /** + * @param string $attribute + * @param array> $intervalsByStand + * @param Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail + * @return void + */ + private function validateStandIntervalOverlaps(string $attribute, array $intervalsByStand, Closure $fail): void + { + foreach ($intervalsByStand as $standIntervals) { + usort( + $standIntervals, + fn (array $left, array $right): int => $left['from']->getTimestamp() <=> $right['from']->getTimestamp() + ); + + for ($i = 1; $i < count($standIntervals); $i++) { + $previous = $standIntervals[$i - 1]; + $current = $standIntervals[$i]; + + if ($current['from']->isBefore($previous['to'])) { + $fail( + sprintf( + '%s.reservations.%d overlaps with reservations.%d for the same stand.', + $attribute, + $current['index'], + $previous['index'] + ) + ); + } + } + } + } +} diff --git a/docs/guides/StandAllocation.md b/docs/guides/StandAllocation.md index 6fd6c10ad..f772dfe77 100644 --- a/docs/guides/StandAllocation.md +++ b/docs/guides/StandAllocation.md @@ -5,6 +5,11 @@ Whilst the final decision on where aircraft should park belongs to the controlle a realistic stand is assigned to each flight, based on a number of parameters. This is a highly complex system, so this guide is intended to explain how it all works under the hood. +## VAA Stand Reservation System + +For VAA event stand reservation plan payloads and JSON validation requirements, see +`docs/guides/VaaStandReservationPlans.md`. + # Stand Occupation Every minute, the system will look at aircraft currently on the ground at UK airports. If an aircraft is deemed to be within diff --git a/docs/guides/VaaStandReservationPlans.md b/docs/guides/VaaStandReservationPlans.md new file mode 100644 index 000000000..f53c3cd4d --- /dev/null +++ b/docs/guides/VaaStandReservationPlans.md @@ -0,0 +1,136 @@ +# VAA Stand Reservation Plan JSON Guide + +This guide explains how Virtual Airline Administrators (VAAs) should build JSON payloads for stand reservation plans. + +All times must be in ISO 8601 Zulu (UTC) format: + +- `YYYY-MM-DDTHH:MM:SSZ` +- Example: `2026-06-12T14:30:00Z` + +## JSON Schema + +Use this schema for pre-validation in editors, CI, or upload tooling: + +- `docs/guides/schemas/vaa-stand-reservation-plan.schema.json` + +The schema validates structure, field types, allowed keys, timestamp shape, CID ranges, EG/EI ICAO format, and stand reference mode. + +Server-side checks are still required for rules that JSON Schema cannot enforce on its own: + +- `event_end` must be after `event_start`. +- Reservation `timeto` must be after `timefrom`. +- Reservation times must stay within the event window. +- The same stand must not have overlapping reservation windows. + +## Top-Level Schema + +A stand reservation plan submission contains: + +- `name` (string, required): Human-readable plan name. +- `contact_email` (string, required): Contact for validation/import questions. +- `payload` (object, required): Event metadata and reservations. + +`payload` fields: + +- `event_start` (string, required): Event start in Zulu. +- `event_end` (string, required): Event end in Zulu and after `event_start`. +- `reservations` (array, required): One or more reservation objects. Multiple stands can be included in one plan, and the same stand can be reused at different times as long as the time windows do not overlap. + +Use exactly one of the following airport scope fields: + +- `event_airport` (string, required if single-airport event): 4-letter ICAO. +- `event_airports` (array of strings, required if multi-airport event): non-empty, unique 4-letter ICAOs. + +## Reservation Schema + +Each item in `payload.reservations` must include: + +- `cid` (integer, required): Valid VATSIM CID. +- `timefrom` (string, required): Reservation start in Zulu. Must be inside the event window (`event_start` to `event_end`). +- `timeto` (string, required): Reservation end in Zulu and after `timefrom`. Must be inside the event window (`event_start` to `event_end`). + +Use exactly one stand reference mode: + +- `stand_id` (integer, required if using stand ID mode) +- `stand` (string, required if using stand identifier mode) + +Optional field: + +- `airport` (string): 4-letter ICAO for stand identifier mode. + +`airport` inference rule: + +- If you use `stand` and the event has one airport (`event_airport`), `airport` may be omitted. +- If you use `stand` and the event has multiple airports (`event_airports`), `airport` is required per reservation. + +## Valid Example + +```json +{ + "name": "Summer Fly-In Plan", + "contact_email": "ops@example.org", + "payload": { + "event_start": "2026-06-12T08:00:00Z", + "event_end": "2026-06-12T20:00:00Z", + "event_airports": ["EGLL", "EGKK"], + "reservations": [ + { + "stand_id": 1201, + "cid": 1203533, + "timefrom": "2026-06-12T08:00:00Z", + "timeto": "2026-06-12T10:00:00Z" + }, + { + "stand_id": 1201, + "cid": 1203534, + "timefrom": "2026-06-12T10:15:00Z", + "timeto": "2026-06-12T12:00:00Z" + }, + { + "airport": "EGLL", + "stand": "A23", + "cid": 1203535, + "timefrom": "2026-06-12T09:30:00Z", + "timeto": "2026-06-12T11:00:00Z" + }, + { + "airport": "EGKK", + "stand": "55", + "cid": 1203536, + "timefrom": "2026-06-12T13:00:00Z", + "timeto": "2026-06-12T15:30:00Z" + } + ] + } +} +``` + +## Invalid Example + +```json +{ + "name": "Invalid Overlap", + "contact_email": "ops@example.org", + "payload": { + "event_start": "2026-06-12T08:00:00Z", + "event_end": "2026-06-12T20:00:00Z", + "event_airport": "EGLL", + "reservations": [ + { + "stand_id": 1201, + "cid": 1203533, + "timefrom": "2026-06-12T10:00:00Z", + "timeto": "2026-06-12T11:00:00Z" + }, + { + "stand_id": 1201, + "cid": 1203534, + "timefrom": "2026-06-12T10:30:00Z", + "timeto": "2026-06-12T11:30:00Z" + } + ] + } +} +``` + +This is rejected because the same stand has overlapping periods. diff --git a/docs/guides/schemas/vaa-stand-reservation-plan.schema.json b/docs/guides/schemas/vaa-stand-reservation-plan.schema.json new file mode 100644 index 000000000..0570b5733 --- /dev/null +++ b/docs/guides/schemas/vaa-stand-reservation-plan.schema.json @@ -0,0 +1,187 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://vatsim.uk/schemas/vaa-stand-reservation-plan.schema.json", + "title": "VAA Stand Reservation Plan", + "description": "Schema for VAA stand reservation plan submissions.", + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "contact_email", + "payload" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "contact_email": { + "type": "string", + "format": "email" + }, + "payload": { + "type": "object", + "additionalProperties": false, + "required": [ + "event_start", + "event_end", + "reservations" + ], + "properties": { + "event_start": { + "$ref": "#/$defs/zuluTimestamp" + }, + "event_end": { + "$ref": "#/$defs/zuluTimestamp" + }, + "event_airport": { + "$ref": "#/$defs/ukIcao" + }, + "event_airports": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/ukIcao" + } + }, + "reservations": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/reservation" + } + } + }, + "oneOf": [ + { + "required": [ + "event_airport" + ], + "not": { + "required": [ + "event_airports" + ] + } + }, + { + "required": [ + "event_airports" + ], + "not": { + "required": [ + "event_airport" + ] + } + } + ], + "allOf": [ + { + "if": { + "required": [ + "event_airports" + ] + }, + "then": { + "properties": { + "reservations": { + "type": "array", + "items": { + "allOf": [ + { + "if": { + "required": [ + "stand" + ] + }, + "then": { + "required": [ + "airport" + ] + } + } + ] + } + } + } + } + } + ] + } + }, + "$defs": { + "zuluTimestamp": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$" + }, + "ukIcao": { + "type": "string", + "pattern": "^(EG|EI)[A-Z]{2}$" + }, + "cid": { + "type": "integer", + "anyOf": [ + { + "minimum": 810000 + }, + { + "minimum": 800000, + "maximum": 800150 + } + ] + }, + "reservation": { + "type": "object", + "additionalProperties": false, + "required": [ + "cid", + "timefrom", + "timeto" + ], + "properties": { + "stand_id": { + "type": "integer", + "minimum": 1 + }, + "stand": { + "type": "string", + "minLength": 1 + }, + "airport": { + "$ref": "#/$defs/ukIcao" + }, + "cid": { + "$ref": "#/$defs/cid" + }, + "timefrom": { + "$ref": "#/$defs/zuluTimestamp" + }, + "timeto": { + "$ref": "#/$defs/zuluTimestamp" + } + }, + "oneOf": [ + { + "required": [ + "stand_id" + ], + "not": { + "required": [ + "stand" + ] + } + }, + { + "required": [ + "stand" + ], + "not": { + "required": [ + "stand_id" + ] + } + } + ] + } + } +} \ No newline at end of file diff --git a/tests/app/Rules/Stand/StandReservationPlanPayloadTest.php b/tests/app/Rules/Stand/StandReservationPlanPayloadTest.php new file mode 100644 index 000000000..8f5ec131a --- /dev/null +++ b/tests/app/Rules/Stand/StandReservationPlanPayloadTest.php @@ -0,0 +1,272 @@ +rule = new StandReservationPlanPayload(); + } + + private function validatePayload(array $payload): bool + { + return !Validator::make( + ['payload' => $payload], + ['payload' => $this->rule] + )->fails(); + } + + private function validSingleAirportPayload(): array + { + return [ + 'event_start' => '2026-06-12T08:00:00Z', + 'event_end' => '2026-06-12T20:00:00Z', + 'event_airport' => 'EGLL', + 'reservations' => [ + [ + 'stand_id' => 1201, + 'cid' => 1203533, + 'timefrom' => '2026-06-12T09:00:00Z', + 'timeto' => '2026-06-12T10:00:00Z', + ], + ], + ]; + } + + public function testItAcceptsAValidStandIdPlan(): void + { + $this->assertTrue($this->validatePayload($this->validSingleAirportPayload())); + } + + public function testItRejectsAPlanWhereEventEndIsNotAfterEventStart(): void + { + $payload = $this->validSingleAirportPayload(); + $payload['event_end'] = $payload['event_start']; + + $this->assertFalse($this->validatePayload($payload)); + } + + public function testItRejectsAReservationWhereTimeToIsNotAfterTimeFrom(): void + { + $payload = $this->validSingleAirportPayload(); + $payload['reservations'][0]['timeto'] = $payload['reservations'][0]['timefrom']; + + $this->assertFalse($this->validatePayload($payload)); + } + + public function testItRejectsAReservationWhenBothStandIdAndStandAreProvided(): void + { + $payload = $this->validSingleAirportPayload(); + $payload['reservations'][0]['stand'] = 'A23'; + + $this->assertFalse($this->validatePayload($payload)); + } + + public function testItRejectsAReservationWhenNeitherStandIdNorStandIsProvided(): void + { + $payload = $this->validSingleAirportPayload(); + unset($payload['reservations'][0]['stand_id']); + + $this->assertFalse($this->validatePayload($payload)); + } + public function testItAcceptsAValidStandIdentifierPlan(): void + { + $payload = [ + 'event_start' => '2026-06-12T08:00:00Z', + 'event_end' => '2026-06-12T20:00:00Z', + 'event_airports' => ['EGLL', 'EGKK'], + 'reservations' => [ + [ + 'airport' => 'EGLL', + 'stand' => 'A23', + 'cid' => 1203533, + 'timefrom' => '2026-06-12T09:00:00Z', + 'timeto' => '2026-06-12T10:00:00Z', + ], + ], + ]; + + $this->assertTrue($this->validatePayload($payload)); + } + + public function testItRejectsAStandIdentifierPlanWhenAirportIsMissingForMultiAirportEvents(): void + { + $payload = [ + 'event_start' => '2026-06-12T08:00:00Z', + 'event_end' => '2026-06-12T20:00:00Z', + 'event_airports' => ['EGLL', 'EGKK'], + 'reservations' => [ + [ + 'stand' => 'A23', + 'cid' => 1203533, + 'timefrom' => '2026-06-12T09:00:00Z', + 'timeto' => '2026-06-12T10:00:00Z', + ], + ], + ]; + + $validator = Validator::make(['payload' => $payload], ['payload' => $this->rule]); + + $this->assertFalse($validator->passes()); + $this->assertContains( + "payload.reservations.0.airport is required when event_airports contains multiple airports and stand is used.", + $validator->errors()->all() + ); + } + + public function testItRejectsAStandIdentifierPlanWhenAirportIsNotOneOfTheEventAirports(): void + { + $payload = [ + 'event_start' => '2026-06-12T08:00:00Z', + 'event_end' => '2026-06-12T20:00:00Z', + 'event_airports' => ['EGLL', 'EGKK'], + 'reservations' => [ + [ + 'airport' => 'EGCC', + 'stand' => 'A23', + 'cid' => 1203533, + 'timefrom' => '2026-06-12T09:00:00Z', + 'timeto' => '2026-06-12T10:00:00Z', + ], + ], + ]; + + $validator = Validator::make(['payload' => $payload], ['payload' => $this->rule]); + + $this->assertFalse($validator->passes()); + $this->assertContains( + "payload.reservations.0.airport must be one of the event's airports.", + $validator->errors()->all() + ); + } + + public function testItAcceptsEiPrefixedUkIcaoCodes(): void + { + $payload = [ + 'event_start' => '2026-06-12T08:00:00Z', + 'event_end' => '2026-06-12T20:00:00Z', + 'event_airport' => 'EIDW', + 'reservations' => [ + [ + 'airport' => 'EIDW', + 'stand' => 'A23', + 'cid' => 1203533, + 'timefrom' => '2026-06-12T09:00:00Z', + 'timeto' => '2026-06-12T10:00:00Z', + ], + ], + ]; + + $this->assertTrue($this->validatePayload($payload)); + } + + public function testItDoesNotEmitAStandAirportMismatchWhenEventAirportsAreInvalid(): void + { + $payload = [ + 'event_start' => '2026-06-12T08:00:00Z', + 'event_end' => '2026-06-12T20:00:00Z', + 'event_airports' => [], + 'reservations' => [ + [ + 'airport' => 'EGLL', + 'stand' => 'A23', + 'cid' => 1203533, + 'timefrom' => '2026-06-12T09:00:00Z', + 'timeto' => '2026-06-12T10:00:00Z', + ], + ], + ]; + + $validator = Validator::make(['payload' => $payload], ['payload' => $this->rule]); + + $this->assertFalse($validator->passes()); + $this->assertContains( + 'payload.event_airports must be a non-empty array of UK 4-letter ICAO codes.', + $validator->errors()->all() + ); + $this->assertNotContains( + "payload.reservations.0.airport is required when event_airports contains multiple airports and stand is used.", + $validator->errors()->all() + ); + $this->assertNotContains( + "payload.reservations.0.airport must be one of the event's airports.", + $validator->errors()->all() + ); + } + + public function testItRejectsReservationsThatAreNotAList(): void + { + $payload = $this->validSingleAirportPayload(); + $payload['reservations'] = [ + 'stand-one' => $payload['reservations'][0], + ]; + + $validator = Validator::make(['payload' => $payload], ['payload' => $this->rule]); + + $this->assertFalse($validator->passes()); + $this->assertContains( + 'payload.reservations must be a non-empty list of reservations.', + $validator->errors()->all() + ); + } + + public function testItRejectsNonUkIcaoCodes(): void + { + $payload = $this->validSingleAirportPayload(); + $payload['event_airport'] = 'LFPG'; + + $this->assertFalse($this->validatePayload($payload)); + } + + public function testItRejectsUnknownFields(): void + { + $payload = $this->validSingleAirportPayload(); + $payload['unexpected'] = 'value'; + + $this->assertFalse($this->validatePayload($payload)); + } + + public function testItRejectsReservationsOutsideTheEventWindow(): void + { + $payload = $this->validSingleAirportPayload(); + $payload['reservations'][0]['timefrom'] = '2026-06-12T07:59:59Z'; + + $this->assertFalse($this->validatePayload($payload)); + } + + public function testItRejectsOverlappingReservationsForTheSameStand(): void + { + $payload = $this->validSingleAirportPayload(); + $payload['reservations'] = [ + [ + 'stand_id' => 1201, + 'cid' => 1203533, + 'timefrom' => '2026-06-12T09:00:00Z', + 'timeto' => '2026-06-12T10:00:00Z', + ], + [ + 'stand_id' => 1201, + 'cid' => 1203534, + 'timefrom' => '2026-06-12T09:30:00Z', + 'timeto' => '2026-06-12T10:30:00Z', + ], + ]; + + $this->assertFalse($this->validatePayload($payload)); + } + + public function testItRejectsNegativeStandIds(): void + { + $payload = $this->validSingleAirportPayload(); + $payload['reservations'][0]['stand_id'] = -1; + + $this->assertFalse($this->validatePayload($payload)); + } +}