validateSchema($payload, $schema); $this->forwardCalls++; } } } beforeEach(function (): void { global $response; $response = new class { public function error(mixed $data, int $status = null): void { $encoded = is_string($data) ? $data : json_encode($data); throw new RuntimeException('HTTP_' . (string)$status . ':' . $encoded); } }; }); it('accepts valid payloads for create and nested call flow commands', function (): void { $payload = [ 'from' => '+4532330288', 'to' => '+4542331128', 'ringTimeout' => 20, 'notification' => ['url' => 'https://example.com/webhook'], 'callFlow' => [ [ 'command' => 'say', 'conditions' => [ ['variable' => 'keys', 'operator' => 'eq', 'value' => '1'], ], 'options' => ['text' => 'hello'], ], ], ]; $errors = bird_request_validator::validate($payload, bird_request_schemas::voiceCreateCallBody()); expect($errors)->toBe([]); }); it('rejects unknown fields and invalid enum values in strict schemas', function (): void { $unknownErrors = bird_request_validator::validate( ['to' => '+4542331128', 'unknownField' => 'x'], bird_request_schemas::voiceCreateCallBody() ); $enumErrors = bird_request_validator::validate( ['cause' => 'completed'], bird_request_schemas::voiceHangupBody() ); expect($unknownErrors)->not->toBe([]); expect(implode(' | ', $unknownErrors))->toContain('$.unknownField is not allowed'); expect($enumErrors)->not->toBe([]); expect(implode(' | ', $enumErrors))->toContain('$.cause must be one of: rejected, busy'); }); it('supports CSV normalization in schema validation for log filters', function (): void { $validQuery = [ 'channelId' => '2cbde4fd-8899-4f2d-95ea-2ab7cc6b8c97,5a6bd8c1-32b7-4a03-8b5a-b6a4a9d7a8b2', 'tag' => 'north,vip', ]; $invalidQuery = [ 'channelId' => 'not-a-uuid', ]; $validErrors = bird_request_validator::validate($validQuery, bird_request_schemas::voiceCallsLogQuery()); $invalidErrors = bird_request_validator::validate($invalidQuery, bird_request_schemas::voiceCallsLogQuery()); expect($validErrors)->toBe([]); expect($invalidErrors)->not->toBe([]); expect(implode(' | ', $invalidErrors))->toContain('$.channelId[0] must be a UUID'); }); it('validates flash hangup payloads for both supported request shapes', function (): void { $resultMode = [ 'result' => 'verified', 'receivedCli' => '+4542331128', ]; $numbersMode = [ 'from' => '+4532330288', 'to' => '+4542331128', 'result' => 'verified', ]; $invalid = [ 'result' => 'ok', ]; $resultErrors = bird_request_validator::validate($resultMode, bird_request_schemas::flashHangupBody()); $numbersErrors = bird_request_validator::validate($numbersMode, bird_request_schemas::flashHangupBody()); $invalidErrors = bird_request_validator::validate($invalid, bird_request_schemas::flashHangupBody()); expect($resultErrors)->toBe([]); expect($numbersErrors)->toBe([]); expect($invalidErrors)->not->toBe([]); expect(implode(' | ', $invalidErrors))->toContain('does not match any of the allowed schemas'); }); it('fails before forward step when strict validation fails and forwards when valid', function (): void { $harness = new BirdRouteValidationHarness(); try { $harness->validateThenForward(['unexpected' => true], bird_request_schemas::noBody()); $caught = null; } catch (RuntimeException $e) { $caught = $e; } expect($caught)->toBeInstanceOf(RuntimeException::class); expect($caught?->getMessage())->toContain('HTTP_400'); expect($harness->forwardCalls)->toBe(0); $harness->validateThenForward([], bird_request_schemas::noBody()); expect($harness->forwardCalls)->toBe(1); });