1<?php 2 3namespace FPM; 4 5abstract class BaseResponse 6{ 7 /** 8 * Tester instance 9 * @var Tester 10 */ 11 private Tester $tester; 12 13 /** 14 * @var bool 15 */ 16 protected bool $debugOutputted = false; 17 18 /** 19 * @param Tester $tester 20 */ 21 public function __construct(Tester $tester) 22 { 23 $this->tester = $tester; 24 } 25 26 /** 27 * Debug response output. 28 * 29 * @return void 30 */ 31 abstract function debugOutput(): void; 32 33 /** 34 * Emit error message 35 * 36 * @param string $message 37 * @param bool $throw 38 * 39 * @return bool 40 * @throws \Exception 41 */ 42 protected function error(string $message, bool $throw = false): bool 43 { 44 $errorMessage = "ERROR: $message\n"; 45 if ($throw) { 46 throw new \Exception($errorMessage); 47 } 48 if ( ! $this->debugOutputted) { 49 $this->debugOutput(); 50 } 51 echo $errorMessage; 52 53 $this->tester->printLogs(); 54 55 return false; 56 } 57} 58 59class Response extends BaseResponse 60{ 61 const HEADER_SEPARATOR = "\r\n\r\n"; 62 63 /** 64 * @var array 65 */ 66 private array $data; 67 68 /** 69 * @var string 70 */ 71 private $rawData; 72 73 /** 74 * @var string 75 */ 76 private $rawHeaders; 77 78 /** 79 * @var string 80 */ 81 private $rawBody; 82 83 /** 84 * @var array 85 */ 86 private $headers; 87 88 /** 89 * @var bool 90 */ 91 private $valid; 92 93 /** 94 * @var bool 95 */ 96 private bool $expectInvalid; 97 98 /** 99 * @param Tester $tester 100 * @param string|array|null $data 101 * @param bool $expectInvalid 102 */ 103 public function __construct(Tester $tester, $data = null, bool $expectInvalid = false) 104 { 105 parent::__construct($tester); 106 107 if ( ! is_array($data)) { 108 $data = [ 109 'response' => $data, 110 'err_response' => null, 111 'out_response' => $data, 112 ]; 113 } 114 115 $this->data = $data; 116 $this->expectInvalid = $expectInvalid; 117 } 118 119 /** 120 * @param mixed $body 121 * @param string $contentType 122 * 123 * @return Response 124 */ 125 public function expectBody($body, $contentType = 'text/html') 126 { 127 if ($multiLine = is_array($body)) { 128 $body = implode("\n", $body); 129 } 130 131 if ( ! $this->checkIfValid()) { 132 $this->error('Response is invalid'); 133 } elseif ( ! $this->checkDefaultHeaders($contentType)) { 134 $this->error('Response default headers not found'); 135 } elseif ($body !== $this->rawBody) { 136 if ($multiLine) { 137 $this->error( 138 "==> The expected body:\n$body\n" . 139 "==> does not match the actual body:\n$this->rawBody" 140 ); 141 } else { 142 $this->error( 143 "The expected body '$body' does not match actual body '$this->rawBody'" 144 ); 145 } 146 } 147 148 return $this; 149 } 150 151 /** 152 * Expect that one of the processes in json status process list has a field with value that 153 * matches the supplied pattern. 154 * 155 * @param string $fieldName 156 * @param string $pattern 157 * 158 * @return Response 159 */ 160 public function expectJsonBodyPatternForStatusProcessField(string $fieldName, string $pattern): Response 161 { 162 $rawData = $this->getBody('application/json'); 163 $data = json_decode($rawData, true); 164 if (empty($data['processes']) || !is_array($data['processes'])) { 165 $this->error( 166 "The body data is not a valid status json containing processes field '$rawData'" 167 ); 168 } 169 foreach ($data['processes'] as $process) { 170 if (preg_match('|' . $pattern . '|', $process[$fieldName]) !== false) { 171 return $this; 172 } 173 } 174 175 $this->error( 176 "No field $fieldName matched pattern $pattern for any process in status data '$rawData'" 177 ); 178 179 return $this; 180 } 181 182 /** 183 * @return Response 184 */ 185 public function expectEmptyBody() 186 { 187 return $this->expectBody(''); 188 } 189 190 /** 191 * Expect header in the response. 192 * 193 * @param string $name Header name. 194 * @param string $value Header value. 195 * 196 * @return Response 197 */ 198 public function expectHeader($name, $value): Response 199 { 200 $this->checkHeader($name, $value); 201 202 return $this; 203 } 204 205 /** 206 * Expect error in the response. 207 * 208 * @param string|null $errorMessage Expected error message. 209 * 210 * @return Response 211 */ 212 public function expectError($errorMessage): Response 213 { 214 $errorData = $this->getErrorData(); 215 if ($errorData !== $errorMessage) { 216 $expectedErrorMessage = $errorMessage !== null 217 ? "The expected error message '$errorMessage' is not equal to returned error '$errorData'" 218 : "No error message expected but received '$errorData'"; 219 $this->error($expectedErrorMessage); 220 } 221 222 return $this; 223 } 224 225 /** 226 * Expect error pattern in the response. 227 * 228 * @param string $errorMessagePattern Expected error message RegExp patter. 229 * 230 * @return Response 231 */ 232 public function expectErrorPattern(string $errorMessagePattern): Response 233 { 234 $errorData = $this->getErrorData(); 235 if (preg_match($errorMessagePattern, $errorData) === 0) { 236 $this->error( 237 "The expected error pattern $errorMessagePattern does not match the returned error '$errorData'" 238 ); 239 $this->debugOutput(); 240 } 241 242 return $this; 243 } 244 245 /** 246 * Expect response status. 247 * 248 * @param string|null $status Expected status. 249 * 250 * @return Response 251 */ 252 public function expectStatus(string|null $status): Response { 253 $headers = $this->getHeaders(); 254 if (is_null($status) && !isset($headers['status'])) { 255 return $this; 256 } 257 if (!is_null($status) && !isset($headers['status'])) { 258 $this->error('Status is expected but not supplied'); 259 } elseif ($status !== $headers['status']) { 260 $statusMessage = $status === null ? "expected not to be set": "expected to be $status"; 261 $this->error("Status is $statusMessage but the actual value is {$headers['status']}"); 262 } 263 264 return $this; 265 } 266 267 /** 268 * Expect response status not to be set. 269 * 270 * @return Response 271 */ 272 public function expectNoStatus(): Response { 273 return $this->expectStatus(null); 274 } 275 276 /** 277 * Expect no error in the response. 278 * 279 * @return Response 280 */ 281 public function expectNoError(): Response 282 { 283 return $this->expectError(null); 284 } 285 286 /** 287 * Get response body. 288 * 289 * @param string $contentType Expect body to have specified content type. 290 * 291 * @return string|null 292 */ 293 public function getBody(string $contentType = 'text/html'): ?string 294 { 295 if ($this->checkIfValid() && $this->checkDefaultHeaders($contentType)) { 296 return $this->rawBody; 297 } 298 299 return null; 300 } 301 302 /** 303 * Print raw body. 304 * 305 * @param string $contentType Expect body to have specified content type. 306 */ 307 public function dumpBody(string $contentType = 'text/html') 308 { 309 var_dump($this->getBody($contentType)); 310 } 311 312 /** 313 * Print raw body. 314 * 315 * @param string $contentType Expect body to have specified content type. 316 */ 317 public function printBody(string $contentType = 'text/html') 318 { 319 echo $this->getBody($contentType) . "\n"; 320 } 321 322 /** 323 * Debug response output 324 */ 325 public function debugOutput(): void 326 { 327 echo ">>> Response\n"; 328 echo "----------------- OUT -----------------\n"; 329 echo $this->data['out_response'] . "\n"; 330 echo "----------------- ERR -----------------\n"; 331 echo $this->data['err_response'] . "\n"; 332 echo "---------------------------------------\n\n"; 333 334 $this->debugOutputted = true; 335 } 336 337 /** 338 * @return string|null 339 */ 340 public function getErrorData(): ?string 341 { 342 return $this->data['err_response']; 343 } 344 345 /** 346 * Check if the response is valid and if not emit error message 347 * 348 * @return bool 349 */ 350 private function checkIfValid(): bool 351 { 352 if ($this->isValid()) { 353 return true; 354 } 355 356 if ( ! $this->expectInvalid) { 357 $this->error("The response is invalid: $this->rawData"); 358 } 359 360 return false; 361 } 362 363 /** 364 * Check default headers that should be present. 365 * 366 * @param string $contentType 367 * 368 * @return bool 369 */ 370 private function checkDefaultHeaders($contentType): bool 371 { 372 // check default headers 373 return ( 374 ( ! ini_get('expose_php') || $this->checkHeader('X-Powered-By', '|^PHP/8|', true)) && 375 $this->checkHeader('Content-type', '|^' . $contentType . '(;\s?charset=\w+)?|', true) 376 ); 377 } 378 379 /** 380 * Check a specified header. 381 * 382 * @param string $name Header name. 383 * @param string $value Header value. 384 * @param bool $useRegex Whether value is regular expression. 385 * 386 * @return bool 387 */ 388 private function checkHeader(string $name, string $value, $useRegex = false): bool 389 { 390 $lcName = strtolower($name); 391 $headers = $this->getHeaders(); 392 if ( ! isset($headers[$lcName])) { 393 return $this->error("The header $name is not present"); 394 } 395 $header = $headers[$lcName]; 396 397 if ( ! $useRegex) { 398 if ($header === $value) { 399 return true; 400 } 401 402 return $this->error("The header $name value '$header' is not the same as '$value'"); 403 } 404 405 if ( ! preg_match($value, $header)) { 406 return $this->error("The header $name value '$header' does not match RegExp '$value'"); 407 } 408 409 return true; 410 } 411 412 /** 413 * Get all headers. 414 * 415 * @return array|null 416 */ 417 private function getHeaders(): ?array 418 { 419 if ( ! $this->isValid()) { 420 return null; 421 } 422 423 if (is_array($this->headers)) { 424 return $this->headers; 425 } 426 427 $headerRows = explode("\r\n", $this->rawHeaders); 428 $headers = []; 429 foreach ($headerRows as $headerRow) { 430 $colonPosition = strpos($headerRow, ':'); 431 if ($colonPosition === false) { 432 $this->error("Invalid header row (no colon): $headerRow"); 433 } 434 $headers[strtolower(substr($headerRow, 0, $colonPosition))] = trim( 435 substr($headerRow, $colonPosition + 1) 436 ); 437 } 438 439 return ($this->headers = $headers); 440 } 441 442 /** 443 * @return bool 444 */ 445 private function isValid() 446 { 447 if ($this->valid === null) { 448 $this->processData(); 449 } 450 451 return $this->valid; 452 } 453 454 /** 455 * Process data and set validity and raw data 456 */ 457 private function processData() 458 { 459 $this->rawData = $this->data['out_response']; 460 $this->valid = ( 461 ! is_null($this->rawData) && 462 strpos($this->rawData, self::HEADER_SEPARATOR) 463 ); 464 if ($this->valid) { 465 list ($this->rawHeaders, $this->rawBody) = array_map( 466 'trim', 467 explode(self::HEADER_SEPARATOR, $this->rawData) 468 ); 469 } 470 } 471} 472 473class ValuesResponse extends BaseResponse 474{ 475 /** 476 * @var array 477 */ 478 private array $values; 479 480 /** 481 * @param Tester $tester 482 * @param string|array|null $values 483 * @throws \Exception 484 */ 485 public function __construct(Tester $tester, $values = null) 486 { 487 parent::__construct($tester); 488 489 if ( ! is_array($values)) { 490 if ( ! is_null($values) ) { 491 $this->error('Invalid values supplied', true); 492 } 493 $this->values = []; 494 } else { 495 $this->values = $values; 496 } 497 } 498 499 /** 500 * Expect value. 501 * 502 * @param string $name 503 * @param mixed $value 504 * @return ValuesResponse 505 * @throws \Exception 506 */ 507 public function expectValue(string $name, $value = null) 508 { 509 if ( ! isset($this->values[$name])) { 510 $this->error("Value $name not found in values"); 511 } 512 if ( ! is_null($value) && $value !== $this->values[$name]) { 513 $this->error("Value $name is {$this->values[$name]} but expected $value"); 514 } 515 return $this; 516 } 517 518 /** 519 * Get values. 520 * 521 * @return array 522 */ 523 public function getValues() 524 { 525 return $this->values; 526 } 527 528 /** 529 * Debug output data. 530 */ 531 public function debugOutput(): void 532 { 533 echo ">>> ValuesResponse\n"; 534 echo "----------------- Values -----------------\n"; 535 var_dump($this->values); 536 echo "---------------------------------------\n\n"; 537 } 538} 539