diff --git a/src/App.php b/src/App.php index 0541b5b5..30a78c1e 100644 --- a/src/App.php +++ b/src/App.php @@ -33,6 +33,8 @@ use Phenix\Exceptions\RuntimeError; use Phenix\Facades\Config; use Phenix\Facades\Route; +use Phenix\Http\Client\HttpClient; +use Phenix\Http\Client\HttpClientTestLogger; use Phenix\Http\Constants\Protocol; use Phenix\Http\ErrorHandler as AppErrorHandler; use Phenix\Http\ExceptionHandler as AppExceptionHandler; @@ -91,6 +93,9 @@ public function setup(): void \Phenix\Runtime\Config::build(...) )->setShared(true); + self::$container->add(HttpClient::class); + self::$container->add(HttpClientTestLogger::class)->setShared(true); + self::$container->add(Phenix::class)->addMethodCall('registerCommands'); /** @var array $providers */ diff --git a/src/Facades/File.php b/src/Facades/File.php index 4d486c14..f66aa776 100644 --- a/src/Facades/File.php +++ b/src/Facades/File.php @@ -14,6 +14,7 @@ * @method static bool isDirectory(string $path) * @method static bool isFile(string $path) * @method static void createDirectory(string $path, int $mode = 0755) + * @method static void move(string $from, string $to) * @method static \Amp\File\File openFile(string $path, string $mode = 'w') * @method static int getCreationTime(string $path) * @method static int getModificationTime(string $path) diff --git a/src/Facades/Http.php b/src/Facades/Http.php new file mode 100644 index 00000000..a8060ac9 --- /dev/null +++ b/src/Facades/Http.php @@ -0,0 +1,55 @@ +shouldAllowMockingProtectedMethods()->makePartial(); + + App::fake(self::getKeyName(), $mock); + + return $mock->shouldReceive($method); + } +} diff --git a/src/Filesystem/Contracts/File.php b/src/Filesystem/Contracts/File.php index 412beb03..ff52047c 100644 --- a/src/Filesystem/Contracts/File.php +++ b/src/Filesystem/Contracts/File.php @@ -17,4 +17,6 @@ public function isDirectory(string $path): bool; public function isFile(string $path): bool; public function createDirectory(string $path, int $mode = 0777): void; + + public function move(string $from, string $to): void; } diff --git a/src/Filesystem/File.php b/src/Filesystem/File.php index 4108e5a4..c271d79f 100644 --- a/src/Filesystem/File.php +++ b/src/Filesystem/File.php @@ -51,6 +51,11 @@ public function createDirectory(string $path, int $mode = 0755): void $this->driver->createDirectory($path, $mode); } + public function move(string $from, string $to): void + { + $this->driver->move($from, $to); + } + public function openFile(string $path, string $mode = 'w'): FileHandler { return $this->driver->openFile($path, $mode); diff --git a/src/Http/Client/Concerns/CaptureRequests.php b/src/Http/Client/Concerns/CaptureRequests.php new file mode 100644 index 00000000..ddbf6000 --- /dev/null +++ b/src/Http/Client/Concerns/CaptureRequests.php @@ -0,0 +1,74 @@ +getRequestLogger()->fake($response); + } + + public function fakeWhen(Closure $condition, Closure $response): void + { + if (App::isProduction()) { + return; + } + + $this->getRequestLogger()->fakeWhen($condition, $response); + } + + /** + * @return Collection + */ + public function getRequestLog(): Collection + { + return $this->getRequestLogger()->getRequestLog(); + } + + public function resetRequestLog(): void + { + $this->getRequestLogger()->resetRequestLog(); + } + + public function resetFaking(): void + { + $this->getRequestLogger()->resetFaking(); + } + + protected function getRequestLogger(): HttpClientTestLogger + { + return $this->requestLogger ??= App::make(HttpClientTestLogger::class); + } + + protected function recordRequest(Request $request): void + { + $this->getRequestLogger()->record($request); + } + + protected function getFakeResponse(Request $request): Response|null + { + return $this->getRequestLogger()->getFakeResponse($request, $this); + } + + protected function getFakeStreamResponse(Request $request): StreamResponse|null + { + return $this->getRequestLogger()->getFakeStreamResponse($request, $this); + } +} diff --git a/src/Http/Client/Concerns/HasAuthorization.php b/src/Http/Client/Concerns/HasAuthorization.php new file mode 100644 index 00000000..fd56afe3 --- /dev/null +++ b/src/Http/Client/Concerns/HasAuthorization.php @@ -0,0 +1,27 @@ +headers['Authorization'] = 'Basic ' . base64_encode("{$username}:{$password}"); + + return $this; + } + + public function withToken(#[SensitiveParameter] string $token, string $type = 'Bearer'): self + { + $this->headers['Authorization'] = "{$type} {$token}"; + + return $this; + } +} diff --git a/src/Http/Client/Concerns/HasHttpStatus.php b/src/Http/Client/Concerns/HasHttpStatus.php new file mode 100644 index 00000000..55c03224 --- /dev/null +++ b/src/Http/Client/Concerns/HasHttpStatus.php @@ -0,0 +1,90 @@ +hasStatus(HttpStatus::OK); + } + + public function created(): bool + { + return $this->hasStatus(HttpStatus::CREATED); + } + + public function accepted(): bool + { + return $this->hasStatus(HttpStatus::ACCEPTED); + } + + public function noContent(): bool + { + return $this->hasStatus(HttpStatus::NO_CONTENT) && $this->body === ''; + } + + public function movedPermanently(): bool + { + return $this->hasStatus(HttpStatus::MOVED_PERMANENTLY); + } + + public function found(): bool + { + return $this->hasStatus(HttpStatus::FOUND); + } + + public function badRequest(): bool + { + return $this->hasStatus(HttpStatus::BAD_REQUEST); + } + + public function unauthorized(): bool + { + return $this->hasStatus(HttpStatus::UNAUTHORIZED); + } + + public function paymentRequired(): bool + { + return $this->hasStatus(HttpStatus::PAYMENT_REQUIRED); + } + + public function forbidden(): bool + { + return $this->hasStatus(HttpStatus::FORBIDDEN); + } + + public function notFound(): bool + { + return $this->hasStatus(HttpStatus::NOT_FOUND); + } + + public function requestTimeout(): bool + { + return $this->hasStatus(HttpStatus::REQUEST_TIMEOUT); + } + + public function conflict(): bool + { + return $this->hasStatus(HttpStatus::CONFLICT); + } + + public function unprocessableEntity(): bool + { + return $this->hasStatus(HttpStatus::UNPROCESSABLE_ENTITY); + } + + public function tooManyRequests(): bool + { + return $this->hasStatus(HttpStatus::TOO_MANY_REQUESTS); + } + + private function hasStatus(HttpStatus $status): bool + { + return $this->status() === $status->value; + } +} diff --git a/src/Http/Client/Concerns/HasTls.php b/src/Http/Client/Concerns/HasTls.php new file mode 100644 index 00000000..3e65d35f --- /dev/null +++ b/src/Http/Client/Concerns/HasTls.php @@ -0,0 +1,44 @@ +withTlsContext($tlsContext); + $connectionFactory = new DefaultConnectionFactory(connectContext: $connectContext); + + $this->builder = $this->builder->usingPool(new UnlimitedConnectionPool($connectionFactory)); + $this->client = $this->builder->build(); + + return $this; + } + + public function withCertificate( + string $certificate, + string|null $key = null, + string|null $ca = null, + #[SensitiveParameter] + string|null $passphrase = null, + string $peerName = '' + ): self { + $tlsContext = (new ClientTlsContext($peerName)) + ->withCertificate(new Certificate($certificate, $key, $passphrase)); + + if ($ca !== null) { + $tlsContext = $tlsContext->withCaFile($ca); + } + + return $this->withTlsContext($tlsContext); + } +} diff --git a/src/Http/Client/Constants/ProtocolVersion.php b/src/Http/Client/Constants/ProtocolVersion.php new file mode 100644 index 00000000..5591941d --- /dev/null +++ b/src/Http/Client/Constants/ProtocolVersion.php @@ -0,0 +1,14 @@ +status() + ), $response->status()); + } + + public function response(): Response + { + return $this->response; + } +} diff --git a/src/Http/Client/HttpClient.php b/src/Http/Client/HttpClient.php new file mode 100644 index 00000000..c55e823f --- /dev/null +++ b/src/Http/Client/HttpClient.php @@ -0,0 +1,266 @@ +builder = new HttpClientBuilder(); + $this->client = $this->builder->build(); + $this->headers = []; + } + + public function withHeaders(array $headers): self + { + $this->headers = [...$this->headers, ...$headers]; + + return $this; + } + + public function withClient(AmpHttpClient $client): self + { + $this->client = $client; + + return $this; + } + + public function retry(int $times, Closure|int $sleepMilliseconds = 0, callable|null $when = null): self + { + if ($times <= 0) { + $this->builder = $this->builder->retry(2); + $this->client = $this->builder->build(); + + return $this; + } + + $this->builder = $this->builder + ->retry(0) + ->intercept(new RetryRequests($times, $sleepMilliseconds, $when)); + + $this->client = $this->builder->build(); + + return $this; + } + + public function listen(EventListener $eventListener): self + { + $this->builder = $this->builder->listen($eventListener); + $this->client = $this->builder->build(); + + return $this; + } + + public function log(string $path): self + { + $this->listen(new LogHttpArchive($path)); + + return $this; + } + + public function get(UriInterface|string $url, array|null $queryParameters = null): Response + { + return $this->call(HttpMethod::GET, $url, queryParameters: $queryParameters); + } + + public function head(UriInterface|string $url, array|null $queryParameters = null): Response + { + return $this->call(HttpMethod::HEAD, $url, queryParameters: $queryParameters); + } + + public function post(UriInterface|string $url, Form|Arrayable|array|string $data = []): Response + { + return $this->call(HttpMethod::POST, $url, $data); + } + + public function put(UriInterface|string $url, Form|Arrayable|array|string $data = []): Response + { + return $this->call(HttpMethod::PUT, $url, $data); + } + + public function patch(UriInterface|string $url, Form|Arrayable|array|string $data = []): Response + { + return $this->call(HttpMethod::PATCH, $url, $data); + } + + public function delete(UriInterface|string $url, Form|Arrayable|array|string $data = []): Response + { + return $this->call(HttpMethod::DELETE, $url, $data); + } + + public function stream( + UriInterface|string $url, + Closure|null $callback = null, + array|null $queryParameters = null, + int|null $bodySizeLimit = null, + float|null $transferTimeout = null, + HttpMethod $method = HttpMethod::GET, + Form|Arrayable|array|string|null $data = null + ): mixed { + $response = $this->streamCall( + method: $method, + url: $url, + data: $data, + queryParameters: $queryParameters, + bodySizeLimit: $bodySizeLimit, + transferTimeout: $transferTimeout + ); + + if ($callback !== null) { + return $callback($response); + } + + return $response; + } + + /** + * @param Closure(Pool): array $closure + * @param int|null $concurrency + * @return array + */ + public function pool(Closure $closure, int|null $concurrency = 0): array + { + $requests = $closure(new Pool()); + $semaphore = $concurrency !== null && $concurrency > 0 ? new LocalSemaphore($concurrency) : null; + $futures = []; + + foreach ($requests as $key => $request) { + $futures[$key] = async(fn (): Response => $this->executePoolRequest($request, $semaphore)); + } + + return await($futures); + } + + protected function call( + HttpMethod $method, + UriInterface|string $url, + Form|Arrayable|array|string|null $data = null, + array|null $queryParameters = null + ): Response { + $request = $this->createRequest($method, $url, $data, $queryParameters); + $this->recordRequest($request); + + if ($fake = $this->getFakeResponse($request)) { + return $fake; + } + + return new Response($this->client->request($request)); + } + + protected function streamCall( + HttpMethod $method, + UriInterface|string $url, + Form|Arrayable|array|string|null $data = null, + array|null $queryParameters = null, + int|null $bodySizeLimit = null, + float|null $transferTimeout = null + ): StreamResponse { + $request = $this->createRequest($method, $url, $data, $queryParameters); + + if ($bodySizeLimit !== null) { + $request->setBodySizeLimit($bodySizeLimit); + } + + if ($transferTimeout !== null) { + $request->setTransferTimeout($transferTimeout); + } + + $this->recordRequest($request); + + if ($fake = $this->getFakeStreamResponse($request)) { + return $fake; + } + + return new StreamResponse($this->client->request($request)); + } + + protected function createRequest( + HttpMethod $method, + UriInterface|string $url, + Form|Arrayable|array|string|null $data = null, + array|null $queryParameters = null + ): Request { + $request = new Request($url, $method->value); + $request->setHeaders($this->headers); + + if ($queryParameters !== null) { + $request->setQueryParameters($queryParameters); + } + + if ($data !== null) { + $body = match (true) { + $data instanceof Arrayable => $this->jsonBody($request, $data->toArray()), + is_array($data) => $this->jsonBody($request, $data), + default => $data, + }; + + $request->setBody($body); + } + + return $request; + } + + protected function jsonBody(Request $request, array $data): string + { + if (! $request->hasHeader('content-type')) { + $request->setHeader('Content-Type', 'application/json'); + } + + $encoded = json_encode($data); + + return $encoded !== false ? $encoded : ''; + } + + /** + * @param Closure(HttpClient): Response $request + */ + protected function executePoolRequest(Closure $request, Semaphore|null $semaphore): Response + { + if ($semaphore === null) { + return $request($this); + } + + $lock = $semaphore->acquire(); + + try { + return $request($this); + } finally { + $lock->release(); + } + } +} diff --git a/src/Http/Client/HttpClientTestLogger.php b/src/Http/Client/HttpClientTestLogger.php new file mode 100644 index 00000000..d9580e54 --- /dev/null +++ b/src/Http/Client/HttpClientTestLogger.php @@ -0,0 +1,191 @@ + + */ + protected array $fakeResponses = []; + + /** + * @var Collection + */ + protected Collection $requests; + + public function fake(Closure|null $response = null): void + { + $this->faking = true; + $this->fakeResponse = $response ?? fn (): null => null; + } + + public function fakeWhen(Closure $condition, Closure $response): void + { + $this->fakeResponses[] = [ + 'condition' => $condition, + 'response' => $response, + ]; + } + + public function record(Request $request): void + { + if (! $this->shouldRecordRequests($request)) { + return; + } + + $this->getRequestLog()->add($request); + } + + /** + * @return Collection + */ + public function getRequestLog(): Collection + { + if (! isset($this->requests)) { + $this->requests = Collection::fromArray([]); + } + + return $this->requests; + } + + public function resetRequestLog(): void + { + $this->requests = Collection::fromArray([]); + } + + public function resetFaking(): void + { + $this->fakeResponse = null; + $this->faking = false; + $this->fakeResponses = []; + $this->resetRequestLog(); + } + + public function shouldRecordRequests(Request $request): bool + { + foreach ($this->fakeResponses as $fake) { + if (($fake['condition'])($request, null)) { + return true; + } + } + + return $this->faking; + } + + public function getFakeResponse(Request $request, HttpClient|null $client = null): Response|null + { + [$matched, $response] = $this->findFakeResponse($request, $client); + + if (! $matched) { + return null; + } + + return $this->normalizeFakeResponse($response, $request); + } + + public function getFakeStreamResponse(Request $request, HttpClient|null $client = null): StreamResponse|null + { + [$matched, $response] = $this->findFakeResponse($request, $client); + + if (! $matched) { + return null; + } + + return $this->normalizeFakeStreamResponse($response, $request); + } + + /** + * @return array{bool, mixed} + */ + protected function findFakeResponse(Request $request, HttpClient|null $client): array + { + foreach ($this->fakeResponses as $fake) { + if (($fake['condition'])($request, $client)) { + return [true, ($fake['response'])($request, $client)]; + } + } + + if ($this->faking && $this->fakeResponse !== null) { + return [true, ($this->fakeResponse)($request, $client)]; + } + + return [false, null]; + } + + protected function normalizeFakeResponse(mixed $response, Request $request): Response + { + if ($response instanceof Response) { + return $response; + } + + if ($response instanceof StreamResponse) { + return new Response($response->getClientResponse()); + } + + return $response instanceof AmpResponse + ? new Response($response) + : new Response($this->makeClientResponse($response, $request)); + } + + protected function normalizeFakeStreamResponse(mixed $response, Request $request): StreamResponse + { + if ($response instanceof StreamResponse) { + return $response; + } + + if ($response instanceof Response) { + return new StreamResponse($this->makeClientResponse( + $response->body(), + $request, + $response->status(), + $response->headers() + )); + } + + return $response instanceof AmpResponse + ? new StreamResponse($response) + : new StreamResponse($this->makeClientResponse($response, $request)); + } + + /** + * @param array $headers + */ + protected function makeClientResponse( + mixed $response, + Request $request, + int $status = 200, + array $headers = [] + ): AmpResponse { + $body = $response; + + if (is_array($response)) { + $headers['Content-Type'] = 'application/json'; + $body = json_encode($response); + } + + return new AmpResponse( + ProtocolVersion::V1_1->value, + $status, + null, + $headers, + is_string($body) ? $body : '', + $request + ); + } +} diff --git a/src/Http/Client/Interceptors/RetryRequests.php b/src/Http/Client/Interceptors/RetryRequests.php new file mode 100644 index 00000000..885ae4c2 --- /dev/null +++ b/src/Http/Client/Interceptors/RetryRequests.php @@ -0,0 +1,88 @@ +attempts = max(1, $attempts); + $this->when = $when === null ? null : Closure::fromCallable($when); + } + + public function request( + Request $request, + Cancellation $cancellation, + DelegateHttpClient $httpClient + ): Response { + $exception = null; + $attempt = 1; + + do { + $clonedRequest = clone $request; + + try { + return $httpClient->request($request, $cancellation); + } catch (HttpException $exception) { + if ($attempt >= $this->attempts) { + continue; + } + + if (! $this->shouldRetry($exception, $request, $attempt)) { + throw $exception; + } + + $this->delayBeforeRetry($attempt, $exception, $request, $cancellation); + + $request = $clonedRequest; + } + } while (++$attempt <= $this->attempts); + + throw $exception; + } + + private function shouldRetry(HttpException $exception, Request $request, int $attempt): bool + { + if (! $request->isIdempotent() && ! $request->isUnprocessed()) { + return false; + } + + return $this->when === null || (bool) ($this->when)($exception, $request, $attempt); + } + + private function delayBeforeRetry( + int $attempt, + HttpException $exception, + Request $request, + Cancellation $cancellation + ): void { + $milliseconds = $this->sleepMilliseconds instanceof Closure + ? ($this->sleepMilliseconds)($attempt, $exception, $request) + : $this->sleepMilliseconds; + + if (! is_numeric($milliseconds) || $milliseconds <= 0) { + return; + } + + delay(((float) $milliseconds) / 1000, cancellation: $cancellation); + } +} diff --git a/src/Http/Client/Pool.php b/src/Http/Client/Pool.php new file mode 100644 index 00000000..a8da3e66 --- /dev/null +++ b/src/Http/Client/Pool.php @@ -0,0 +1,43 @@ + $client->get($url, $queryParameters); + } + + public function head(UriInterface|string $url, array|null $queryParameters = null): Closure + { + return fn (HttpClient $client): Response => $client->head($url, $queryParameters); + } + + public function post(UriInterface|string $url, Form|Arrayable|array|string $data = []): Closure + { + return fn (HttpClient $client): Response => $client->post($url, $data); + } + + public function put(UriInterface|string $url, Form|Arrayable|array|string $data = []): Closure + { + return fn (HttpClient $client): Response => $client->put($url, $data); + } + + public function patch(UriInterface|string $url, Form|Arrayable|array|string $data = []): Closure + { + return fn (HttpClient $client): Response => $client->patch($url, $data); + } + + public function delete(UriInterface|string $url, Form|Arrayable|array|string $data = []): Closure + { + return fn (HttpClient $client): Response => $client->delete($url, $data); + } +} diff --git a/src/Http/Client/Response.php b/src/Http/Client/Response.php new file mode 100644 index 00000000..33b122ff --- /dev/null +++ b/src/Http/Client/Response.php @@ -0,0 +1,134 @@ +body = $this->response->getBody()->buffer(); + } + + public function getClientResponse(): ClientResponse + { + return $this->response; + } + + public function body(): string + { + return $this->body; + } + + public function json(string|null $key = null, Closure|array|string|null $default = null, int $flags = 0): mixed + { + $data = json_decode($this->body, true, flags: $flags); + + if ($data === null) { + return value($default); + } + + if ($key === null) { + return $data; + } + + return is_array($data) + ? Arr::get($data, $key, $default) + : value($default); + } + + public function object(): object + { + return (object) (json_decode($this->body) ?? []); + } + + public function collect(string|null $key = null): Collection + { + $data = $this->json($key, []); + + return Collection::fromArray(is_array($data) ? $data : [$data]); + } + + public function status(): int + { + return $this->response->getStatus(); + } + + public function successful(): bool + { + return $this->response->isSuccessful(); + } + + public function redirect(): bool + { + return $this->response->isRedirect(); + } + + public function failed(): bool + { + return $this->clientError() || $this->response->isServerError(); + } + + public function clientError(): bool + { + return $this->response->isClientError(); + } + + public function serverError(): bool + { + return $this->response->isServerError(); + } + + public function header(string $header): string|null + { + return $this->response->getHeader($header); + } + + public function headers(): array + { + return $this->response->getHeaders(); + } + + public function onError(Closure $closure): self + { + if ($this->failed()) { + $closure($this); + } + + return $this; + } + + public function throw(): self + { + if ($this->failed()) { + throw new RequestException($this); + } + + return $this; + } + + public function throwIf(Closure|bool $condition): self + { + $condition = $condition instanceof Closure ? $condition($this) : $condition; + + if ($condition) { + return $this->throw(); + } + + return $this; + } +} diff --git a/src/Http/Client/StreamResponse.php b/src/Http/Client/StreamResponse.php new file mode 100644 index 00000000..617ebe37 --- /dev/null +++ b/src/Http/Client/StreamResponse.php @@ -0,0 +1,167 @@ +body = $response->getBody(); + } + + public function getClientResponse(): ClientResponse + { + return $this->response; + } + + public function read(): string|null + { + if ($this->readLocked) { + throw new LogicException('The response stream cannot be read from inside a streaming callback.'); + } + + return $this->body->read(); + } + + public function each(Closure $closure): self + { + $totalBytesRead = 0; + + while (($chunk = $this->read()) !== null) { + $totalBytesRead += strlen($chunk); + + $this->readLocked = true; + + try { + $closure($chunk, $totalBytesRead, $this); + } finally { + $this->readLocked = false; + } + } + + return $this; + } + + public function save(string $path, Closure|null $progress = null): int + { + $completed = false; + $totalBytesWritten = 0; + $temporaryPath = $this->temporaryPath($path); + $file = File::openFile($temporaryPath, 'w'); + + try { + while (($chunk = $this->read()) !== null) { + $file->write($chunk); + $totalBytesWritten += strlen($chunk); + + if ($progress !== null) { + $this->readLocked = true; + + try { + $progress($totalBytesWritten, $chunk, $this); + } finally { + $this->readLocked = false; + } + } + } + + $completed = true; + } finally { + $file->close(); + + if (! $completed) { + $this->discardTemporaryFile($temporaryPath); + } + } + + try { + File::move($temporaryPath, $path); + } catch (Throwable $exception) { + $this->discardTemporaryFile($temporaryPath); + + throw $exception; + } + + return $totalBytesWritten; + } + + public function status(): int + { + return $this->response->getStatus(); + } + + public function successful(): bool + { + return $this->response->isSuccessful(); + } + + public function redirect(): bool + { + return $this->response->isRedirect(); + } + + public function failed(): bool + { + return $this->response->isClientError() || $this->response->isServerError(); + } + + public function clientError(): bool + { + return $this->response->isClientError(); + } + + public function serverError(): bool + { + return $this->response->isServerError(); + } + + public function header(string $header): string|null + { + return $this->response->getHeader($header); + } + + public function headers(): array + { + return $this->response->getHeaders(); + } + + public function ok(): bool + { + return $this->hasStatus(HttpStatus::OK); + } + + private function hasStatus(HttpStatus $status): bool + { + return $this->status() === $status->value; + } + + private function temporaryPath(string $path): string + { + return dirname($path) . DIRECTORY_SEPARATOR . '.phenix-stream-' . bin2hex(random_bytes(8)) . '.tmp'; + } + + private function discardTemporaryFile(string $temporaryPath): void + { + try { + File::deleteFile($temporaryPath); + } catch (Throwable $th) { + report($th); + } + } +} diff --git a/src/Testing/TestCase.php b/src/Testing/TestCase.php index 91b7caea..f24b68a4 100644 --- a/src/Testing/TestCase.php +++ b/src/Testing/TestCase.php @@ -14,6 +14,7 @@ use Phenix\Facades\Cache; use Phenix\Facades\Config; use Phenix\Facades\Event; +use Phenix\Facades\Http; use Phenix\Facades\Mail; use Phenix\Facades\Queue; use Phenix\Facades\View; @@ -65,6 +66,7 @@ protected function tearDown(): void Event::resetFaking(); Queue::resetFaking(); Mail::resetSendingLog(); + Http::resetFaking(); if (config('cache.default') === Store::FILE->value) { Cache::clear(); diff --git a/tests/Unit/Filesystem/FileTest.php b/tests/Unit/Filesystem/FileTest.php index 1570f04c..b3f6e28a 100644 --- a/tests/Unit/Filesystem/FileTest.php +++ b/tests/Unit/Filesystem/FileTest.php @@ -32,6 +32,25 @@ expect(file_get_contents($path))->toBe('php'); }); +it('moves files successfully', function () { + $from = sys_get_temp_dir() . '/file.txt'; + $to = sys_get_temp_dir() . '/moved-file.txt'; + + if (file_exists($to)) { + unlink($to); + } + + file_put_contents($from, 'php'); + + $file = new File(); + $file->move($from, $to); + + expect(file_exists($from))->toBeFalse() + ->and(file_get_contents($to))->toBe('php'); + + unlink($to); +}); + it('checks if file exists', function () { $path = sys_get_temp_dir() . '/file.txt'; diff --git a/tests/Unit/Http/Client/HttpClientPoolTest.php b/tests/Unit/Http/Client/HttpClientPoolTest.php new file mode 100644 index 00000000..e6935f26 --- /dev/null +++ b/tests/Unit/Http/Client/HttpClientPoolTest.php @@ -0,0 +1,162 @@ +pool(fn (Pool $pool): array => [ + 'google' => $pool->get('https://google.com'), + 'github' => $pool->get('https://github.com'), + ]); + + expect(array_keys($responses))->toBe(['google', 'github']) + ->and($responses['google'])->toBeInstanceOf(Response::class) + ->and($responses['google']->body())->toBe('https://google.com') + ->and($responses['github']->body())->toBe('https://github.com'); +}); + +it('limits pooled request concurrency', function (): void { + $active = 0; + $maxActive = 0; + + $client = new class ($active, $maxActive) extends HttpClient { + public function __construct(private int &$active, private int &$maxActive) + { + parent::__construct(); + } + + protected function call( + HttpMethod $method, + UriInterface|string $url, + Form|Arrayable|array|string|null $data = null, + array|null $queryParameters = null + ): Response { + $this->active++; + $this->maxActive = max($this->maxActive, $this->active); + + delay(0.01); + + $this->active--; + + return new Response(new AmpResponse( + '1.1', + 200, + null, + [], + (string) $url, + new Request((string) $url) + )); + } + }; + + $responses = $client->pool(fn (Pool $pool): array => [ + $pool->get('https://phenix.test/1'), + $pool->get('https://phenix.test/2'), + $pool->get('https://phenix.test/3'), + $pool->get('https://phenix.test/4'), + ], concurrency: 2); + + expect($responses)->toHaveCount(4) + ->and($maxActive)->toBeLessThanOrEqual(2); +}); + +it('creates request callbacks for every supported http method', function (): void { + $client = new class () extends HttpClient { + public array $calls = []; + + protected function call( + HttpMethod $method, + UriInterface|string $url, + Form|Arrayable|array|string|null $data = null, + array|null $queryParameters = null + ): Response { + $this->calls[] = [ + 'method' => $method, + 'url' => (string) $url, + 'data' => $data, + 'query' => $queryParameters, + ]; + + return new Response(new AmpResponse( + '1.1', + 200, + null, + [], + (string) $url, + new Request((string) $url) + )); + } + }; + + $pool = new Pool(); + + ($pool->head('https://phenix.test/head', ['status' => 'pending']))($client); + ($pool->post('https://phenix.test/post', ['name' => 'Phenix']))($client); + ($pool->put('https://phenix.test/put', ['name' => 'Framework']))($client); + ($pool->patch('https://phenix.test/patch', ['enabled' => true]))($client); + ($pool->delete('https://phenix.test/delete', ['force' => true]))($client); + + expect($client->calls)->toBe([ + [ + 'method' => HttpMethod::HEAD, + 'url' => 'https://phenix.test/head', + 'data' => null, + 'query' => ['status' => 'pending'], + ], + [ + 'method' => HttpMethod::POST, + 'url' => 'https://phenix.test/post', + 'data' => ['name' => 'Phenix'], + 'query' => null, + ], + [ + 'method' => HttpMethod::PUT, + 'url' => 'https://phenix.test/put', + 'data' => ['name' => 'Framework'], + 'query' => null, + ], + [ + 'method' => HttpMethod::PATCH, + 'url' => 'https://phenix.test/patch', + 'data' => ['enabled' => true], + 'query' => null, + ], + [ + 'method' => HttpMethod::DELETE, + 'url' => 'https://phenix.test/delete', + 'data' => ['force' => true], + 'query' => null, + ], + ]); +}); diff --git a/tests/Unit/Http/Client/HttpClientTest.php b/tests/Unit/Http/Client/HttpClientTest.php new file mode 100644 index 00000000..95c83e63 --- /dev/null +++ b/tests/Unit/Http/Client/HttpClientTest.php @@ -0,0 +1,315 @@ +getValue($client); + + $poolProperty = new ReflectionProperty($builder, 'pool'); + $pool = $poolProperty->getValue($builder); + + $innerPoolProperty = new ReflectionProperty($pool, 'pool'); + $innerPool = $innerPoolProperty->getValue($pool); + + $connectionFactoryProperty = new ReflectionProperty($innerPool, 'connectionFactory'); + $connectionFactory = $connectionFactoryProperty->getValue($innerPool); + + expect($connectionFactory)->toBeInstanceOf(DefaultConnectionFactory::class); + + $connectContextProperty = new ReflectionProperty($connectionFactory, 'connectContext'); + + return $connectContextProperty->getValue($connectionFactory); +} + +it('fakes all http client requests with an empty successful response', function (): void { + Http::fake(); + + $response = Http::get('https://phenix.test/users'); + + expect($response)->toBeInstanceOf(Response::class) + ->and($response->ok())->toBeTrue() + ->and($response->body())->toBe(''); +}); + +it('fakes all http client requests using a closure response', function (): void { + Http::fake(fn (Request $request): array => [ + 'uri' => (string) $request->getUri(), + ]); + + $response = Http::get('https://phenix.test/users'); + + expect($response->json('uri'))->toBe('https://phenix.test/users'); +}); + +it('fakes http client requests when a condition matches', function (): void { + Http::fake(fn (): string => 'fallback'); + Http::fakeWhen( + fn (Request $request): bool => str_contains((string) $request->getUri(), '/users'), + fn (): array => ['resource' => 'users'] + ); + + expect(Http::get('https://phenix.test/users')->json('resource'))->toBe('users') + ->and(Http::get('https://phenix.test/posts')->body())->toBe('fallback'); +}); + +it('accepts wrapped and amp responses from fake callbacks', function (): void { + $request = new Request('https://phenix.test/users'); + $wrapped = new Response(new AmpResponse('1.1', 200, null, [], 'wrapped', $request)); + + Http::fake(fn (): Response => $wrapped); + expect(Http::get('https://phenix.test/users'))->toBe($wrapped); + + Http::fake(fn (Request $request): AmpResponse => new AmpResponse( + '1.1', + 200, + null, + [], + 'amp', + $request + )); + + expect(Http::get('https://phenix.test/users')->body())->toBe('amp'); +}); + +it('sends requests through the amp client when no fake is configured', function (): void { + $client = new HttpClient(); + $client->resetFaking(); + + $delegate = new class () implements DelegateHttpClient { + public function request(Request $request, Cancellation $cancellation): AmpResponse + { + return new AmpResponse('1.1', 200, null, [], 'amp-response', $request); + } + }; + + expect($client->withClient(new AmpHttpClient($delegate, [])))->toBe($client); + + $response = $client->get('https://phenix.test/live', ['page' => '1']); + + expect($response)->toBeInstanceOf(Response::class) + ->and($response->body())->toBe('amp-response') + ->and($client->getRequestLog())->toBeEmpty(); +}); + +it('applies mockery expectations through the http facade', function (): void { + $response = new Response(new AmpResponse( + '1.1', + 200, + null, + [], + 'mocked', + new Request('https://phenix.test/users') + )); + + /** @var \Mockery\Expectation $expectation */ + $expectation = Http::expect('get'); + $expectation + ->once() + ->with('https://phenix.test/users') + ->andReturn($response); + + expect(Http::get('https://phenix.test/users'))->toBe($response); + + Mockery::close(); +}); + +it('builds get and head requests with query parameters', function (): void { + $client = new HttpClient(); + $requests = []; + + $client->fake(function (Request $request) use (&$requests): string { + $requests[] = [ + 'method' => $request->getMethod(), + 'uri' => (string) $request->getUri(), + ]; + + return 'ok'; + }); + + expect($client->get('https://phenix.test/users', ['page' => '1'])->body())->toBe('ok') + ->and($client->head('https://phenix.test/status', ['check' => 'health'])->body())->toBe('ok') + ->and($requests)->toBe([ + ['method' => 'GET', 'uri' => 'https://phenix.test/users?page=1'], + ['method' => 'HEAD', 'uri' => 'https://phenix.test/status?check=health'], + ]); +}); + +it('builds write requests with the expected methods and bodies', function (): void { + $client = new HttpClient(); + $requests = []; + + $client->fake(function (Request $request) use (&$requests): string { + $requests[] = [ + 'method' => $request->getMethod(), + 'body' => buffer($request->getBody()->getContent()), + 'contentType' => $request->getHeader('content-type'), + ]; + + return 'ok'; + }); + + $arrayable = new class () implements Arrayable { + public function toArray(): array + { + return ['arrayable' => true]; + } + }; + + $client->post('https://phenix.test/users', ['name' => 'Taylor']); + $client->put('https://phenix.test/users/1', $arrayable); + $client->patch('https://phenix.test/users/1', 'patched'); + $client->delete('https://phenix.test/users/1', ['force' => true]); + + expect($requests)->toBe([ + ['method' => 'POST', 'body' => '{"name":"Taylor"}', 'contentType' => 'application/json'], + ['method' => 'PUT', 'body' => '{"arrayable":true}', 'contentType' => 'application/json'], + ['method' => 'PATCH', 'body' => 'patched', 'contentType' => null], + ['method' => 'DELETE', 'body' => '{"force":true}', 'contentType' => 'application/json'], + ]); +}); + +it('preserves explicit content type headers for json request bodies', function (): void { + $client = new HttpClient(); + $contentTypes = []; + + $client->fake(function (Request $request) use (&$contentTypes): string { + $contentTypes[] = $request->getHeader('content-type'); + + return 'ok'; + }); + + $client + ->withHeaders(['Content-Type' => 'application/vnd.api+json']) + ->post('https://phenix.test/users', ['name' => 'Taylor']); + + expect($contentTypes)->toBe(['application/vnd.api+json']); +}); + +it('applies headers and authentication helpers to requests', function (): void { + $client = new HttpClient(); + $headers = []; + + $client->fake(function (Request $request) use (&$headers): string { + $headers[] = [ + 'accept' => $request->getHeader('accept'), + 'trace' => $request->getHeader('x-trace-id'), + 'authorization' => $request->getHeader('authorization'), + ]; + + return 'ok'; + }); + + $client + ->withHeaders(['Accept' => 'application/json']) + ->withHeaders(['X-Trace-Id' => 'trace-1']) + ->withBasicAuth('phenix', 'secret') + ->get('https://phenix.test/basic'); + + $client + ->withToken('token-value', 'Token') + ->get('https://phenix.test/token'); + + expect($headers)->toBe([ + [ + 'accept' => 'application/json', + 'trace' => 'trace-1', + 'authorization' => 'Basic ' . base64_encode('phenix:secret'), + ], + [ + 'accept' => 'application/json', + 'trace' => 'trace-1', + 'authorization' => 'Token token-value', + ], + ]); +}); + +it('prefers matching conditional fakes over the global fake response', function (): void { + $client = new HttpClient(); + + $client->fake(fn (): string => 'fallback'); + $client->fakeWhen( + fn (Request $request): bool => str_contains((string) $request->getUri(), '/users'), + fn (): array => ['matched' => true] + ); + + expect($client->get('https://phenix.test/users')->json('matched'))->toBeTrue() + ->and($client->get('https://phenix.test/posts')->body())->toBe('fallback'); +}); + +it('configures retry fluently for custom and default retry attempts', function (): void { + $client = new HttpClient(); + + expect($client->retry(3))->toBe($client) + ->and($client->retry(0))->toBe($client) + ->and($client->retry(-1))->toBe($client); +}); + +it('configures http archive logging fluently', function (): void { + $client = new HttpClient(); + + expect($client->log(sys_get_temp_dir() . '/phenix-http-client.har'))->toBe($client); +}); + +it('chains logging and retry fluently regardless of order', function (): void { + $firstClient = new HttpClient(); + $secondClient = new HttpClient(); + + $firstResult = $firstClient + ->retry(3) + ->log(sys_get_temp_dir() . '/phenix-http-client-retry-first.har'); + + $secondResult = $secondClient + ->log(sys_get_temp_dir() . '/phenix-http-client-log-first.har') + ->retry(3); + + expect($firstResult)->toBe($firstClient) + ->and($secondResult)->toBe($secondClient); +}); + +it('configures a custom tls context fluently', function (): void { + $client = new HttpClient(); + $tlsContext = (new ClientTlsContext('api.phenix.test')) + ->withCaFile('/tmp/phenix-ca.pem'); + + expect($client->withTlsContext($tlsContext))->toBe($client) + ->and(httpClientConnectContext($client)->getTlsContext())->toBe($tlsContext); +}); + +it('configures certificate based tls fluently', function (): void { + $client = new HttpClient(); + + $client->withCertificate( + certificate: '/tmp/client-cert.pem', + key: '/tmp/client-key.pem', + ca: '/tmp/ca.pem', + passphrase: 'secret', + peerName: 'api.phenix.test' + ); + + $tlsContext = httpClientConnectContext($client)->getTlsContext(); + $certificate = $tlsContext?->getCertificate(); + + expect($tlsContext)->toBeInstanceOf(ClientTlsContext::class) + ->and($tlsContext?->getPeerName())->toBe('api.phenix.test') + ->and($tlsContext?->getCaFile())->toBe('/tmp/ca.pem') + ->and($certificate?->getCertFile())->toBe('/tmp/client-cert.pem') + ->and($certificate?->getKeyFile())->toBe('/tmp/client-key.pem') + ->and($certificate?->getPassphrase())->toBe('secret'); +}); diff --git a/tests/Unit/Http/Client/ResponseTest.php b/tests/Unit/Http/Client/ResponseTest.php new file mode 100644 index 00000000..e64892d1 --- /dev/null +++ b/tests/Unit/Http/Client/ResponseTest.php @@ -0,0 +1,179 @@ + 'application/json'], + '{"user":{"name":"Ada"},"tags":["php","amp"]}', + new Request('https://phenix.test') + ); + + $response = new Response($ampResponse); + + expect($response->getClientResponse())->toBe($ampResponse) + ->and($response->body())->toBe('{"user":{"name":"Ada"},"tags":["php","amp"]}') + ->and($response->json('user.name'))->toBe('Ada') + ->and($response->json('missing', 'fallback'))->toBe('fallback') + ->and($response->object()->user->name)->toBe('Ada') + ->and($response->collect('tags'))->toBeInstanceOf(Collection::class) + ->and($response->collect('tags')->toArray())->toBe(['php', 'amp']) + ->and($response->status())->toBe(201); +}); + +it('returns defaults for invalid json and full decoded payloads without a key', function (): void { + $request = new Request('https://phenix.test'); + $invalidJson = new Response(new AmpResponse('1.1', 200, null, [], 'invalid-json', $request)); + $validJson = new Response(new AmpResponse('1.1', 200, null, [], '{"active":true}', $request)); + + expect($invalidJson->json(default: 'fallback'))->toBe('fallback') + ->and($validJson->json())->toBe(['active' => true]); +}); + +it('reports common status helpers', function (): void { + $request = new Request('https://phenix.test'); + + expect((new Response(new AmpResponse('1.1', 200, null, [], '', $request)))->ok())->toBeTrue() + ->and((new Response(new AmpResponse('1.1', 201, null, [], '', $request)))->created())->toBeTrue() + ->and((new Response(new AmpResponse('1.1', 202, null, [], '', $request)))->accepted())->toBeTrue() + ->and((new Response(new AmpResponse('1.1', 204, null, [], '', $request)))->noContent())->toBeTrue() + ->and((new Response(new AmpResponse('1.1', 301, null, [], '', $request)))->movedPermanently())->toBeTrue() + ->and((new Response(new AmpResponse('1.1', 302, null, [], '', $request)))->found())->toBeTrue() + ->and((new Response(new AmpResponse('1.1', 400, null, [], '', $request)))->badRequest())->toBeTrue() + ->and((new Response(new AmpResponse('1.1', 401, null, [], '', $request)))->unauthorized())->toBeTrue() + ->and((new Response(new AmpResponse('1.1', 402, null, [], '', $request)))->paymentRequired())->toBeTrue() + ->and((new Response(new AmpResponse('1.1', 403, null, [], '', $request)))->forbidden())->toBeTrue() + ->and((new Response(new AmpResponse('1.1', 404, null, [], '', $request)))->notFound())->toBeTrue() + ->and((new Response(new AmpResponse('1.1', 408, null, [], '', $request)))->requestTimeout())->toBeTrue() + ->and((new Response(new AmpResponse('1.1', 409, null, [], '', $request)))->conflict())->toBeTrue() + ->and((new Response(new AmpResponse('1.1', 422, null, [], '', $request)))->unprocessableEntity())->toBeTrue() + ->and((new Response(new AmpResponse('1.1', 429, null, [], '', $request)))->tooManyRequests())->toBeTrue() + ->and((new Response(new AmpResponse('1.1', 500, null, [], '', $request)))->serverError())->toBeTrue() + ->and((new Response(new AmpResponse('1.1', 500, null, [], '', $request)))->failed())->toBeTrue(); +}); + +it('exposes response state and headers', function (): void { + $redirect = new Response(new AmpResponse( + '1.1', + 302, + null, + ['Location' => 'https://phenix.test/next'], + '', + new Request('https://phenix.test') + )); + + $clientError = new Response(new AmpResponse( + '1.1', + 404, + null, + ['Content-Type' => 'application/json'], + '{"message":"Not found"}', + new Request('https://phenix.test/missing') + )); + + expect($redirect->redirect())->toBeTrue() + ->and($redirect->successful())->toBeFalse() + ->and($redirect->failed())->toBeFalse() + ->and($redirect->header('location'))->toBe('https://phenix.test/next') + ->and($redirect->headers())->toBe(['location' => ['https://phenix.test/next']]) + ->and($clientError->clientError())->toBeTrue() + ->and($clientError->serverError())->toBeFalse() + ->and($clientError->failed())->toBeTrue() + ->and($clientError->header('content-type'))->toBe('application/json') + ->and($clientError->headers())->toBe(['content-type' => ['application/json']]); +}); + +it('throws request exceptions for failed responses', function (): void { + $response = new Response(new AmpResponse( + '1.1', + 404, + null, + [], + 'Missing', + new Request('https://phenix.test') + )); + + try { + $response->throw(); + } catch (RequestException $exception) { + expect($exception->response())->toBe($response) + ->and($exception->getCode())->toBe(404) + ->and($exception->getMessage())->toBe('HTTP request returned status code 404.'); + + return; + } + + expect(false)->toBeTrue(); +}); + +it('runs error callbacks only for failed responses', function (): void { + $successful = new Response(new AmpResponse( + '1.1', + 200, + null, + [], + 'Ok', + new Request('https://phenix.test') + )); + + $failed = new Response(new AmpResponse( + '1.1', + 500, + null, + [], + 'Server error', + new Request('https://phenix.test') + )); + + $handled = []; + + $successfulResult = $successful->onError(function (Response $response) use (&$handled): void { + $handled[] = $response->status(); + }); + + $failedResult = $failed->onError(function (Response $response) use (&$handled): void { + $handled[] = $response->status(); + }); + + expect($successfulResult)->toBe($successful) + ->and($failedResult)->toBe($failed) + ->and($handled)->toBe([500]); +}); + +it('does not throw for successful responses', function (): void { + $response = new Response(new AmpResponse( + '1.1', + 200, + null, + [], + 'Ok', + new Request('https://phenix.test') + )); + + expect($response->throw())->toBe($response) + ->and($response->throwIf(true))->toBe($response); +}); + +it('throws conditionally with throw if', function (): void { + $response = new Response(new AmpResponse( + '1.1', + 500, + null, + [], + 'Server error', + new Request('https://phenix.test') + )); + + expect($response->throwIf(false))->toBe($response) + ->and(fn () => $response->throwIf(fn (Response $response): bool => $response->serverError())) + ->toThrow(RequestException::class); +}); diff --git a/tests/Unit/Http/Client/RetryRequestsTest.php b/tests/Unit/Http/Client/RetryRequestsTest.php new file mode 100644 index 00000000..0ebf9e40 --- /dev/null +++ b/tests/Unit/Http/Client/RetryRequestsTest.php @@ -0,0 +1,186 @@ +calls++; + + if ($this->calls < 3) { + throw new HttpException('Connection failed.'); + } + + return new Response('1.1', 200, null, [], '', $request); + } + }; + + $response = (new RetryRequests(3))->request($request, new NullCancellation(), $client); + + expect($response->getStatus())->toBe(200) + ->and($calls)->toBe(3); +}); + +it('stops retrying when the retry condition rejects the exception', function (): void { + $request = new Request('https://phenix.test'); + $calls = 0; + + $client = new class ($calls) implements DelegateHttpClient { + public function __construct(private int &$calls) + { + } + + public function request(Request $request, Cancellation $cancellation): Response + { + $this->calls++; + + throw new HttpException('Connection failed.'); + } + }; + + expect(fn () => (new RetryRequests(3, when: fn () => false))->request( + $request, + new NullCancellation(), + $client + ))->toThrow(HttpException::class) + ->and($calls)->toBe(1); +}); + +it('stops retrying when the maximum attempts are exhausted', function (): void { + $request = new Request('https://phenix.test'); + $calls = 0; + + $client = new class ($calls) implements DelegateHttpClient { + public function __construct(private int &$calls) + { + } + + public function request(Request $request, Cancellation $cancellation): Response + { + $this->calls++; + + throw new HttpException('Connection failed.'); + } + }; + + expect(fn () => (new RetryRequests(1))->request( + $request, + new NullCancellation(), + $client + ))->toThrow(HttpException::class) + ->and($calls)->toBe(1); +}); + +it('does not retry non idempotent requests that were already processed', function (): void { + $request = new Request('https://phenix.test', 'POST'); + $calls = 0; + + $client = new class ($calls) implements DelegateHttpClient { + public function __construct(private int &$calls) + { + } + + public function request(Request $request, Cancellation $cancellation): Response + { + $this->calls++; + + $exception = new HttpException('Connection failed.'); + + EventInvoker::get()->requestFailed($request, $exception); + + throw $exception; + } + }; + + expect(fn () => (new RetryRequests(3))->request( + $request, + new NullCancellation(), + $client + ))->toThrow(HttpException::class) + ->and($calls)->toBe(1); +}); + +it('uses sleep closures before retrying requests', function (): void { + $request = new Request('https://phenix.test'); + $calls = 0; + $sleepCalls = []; + + $client = new class ($calls) implements DelegateHttpClient { + public function __construct(private int &$calls) + { + } + + public function request(Request $request, Cancellation $cancellation): Response + { + $this->calls++; + + if ($this->calls === 1) { + throw new HttpException('Connection failed.'); + } + + return new Response('1.1', 200, null, [], '', $request); + } + }; + + $response = (new RetryRequests( + 2, + function (int $attempt, HttpException $exception, Request $request) use (&$sleepCalls): string { + $sleepCalls[] = [$attempt, $exception->getMessage(), (string) $request->getUri()]; + + return 'invalid'; + } + ))->request($request, new NullCancellation(), $client); + + expect($response->getStatus())->toBe(200) + ->and($calls)->toBe(2) + ->and($sleepCalls)->toBe([ + [1, 'Connection failed.', 'https://phenix.test'], + ]); +}); + +it('delays before retrying when configured with positive milliseconds', function (): void { + $request = new Request('https://phenix.test'); + $calls = 0; + + $client = new class ($calls) implements DelegateHttpClient { + public function __construct(private int &$calls) + { + } + + public function request(Request $request, Cancellation $cancellation): Response + { + $this->calls++; + + if ($this->calls === 1) { + throw new HttpException('Connection failed.'); + } + + return new Response('1.1', 200, null, [], '', $request); + } + }; + + $startedAt = microtime(true); + + $response = (new RetryRequests(2, 20))->request($request, new NullCancellation(), $client); + + expect($response->getStatus())->toBe(200) + ->and($calls)->toBe(2) + ->and(microtime(true) - $startedAt)->toBeGreaterThanOrEqual(0.015); +}); diff --git a/tests/Unit/Http/Client/StreamResponseTest.php b/tests/Unit/Http/Client/StreamResponseTest.php new file mode 100644 index 00000000..0c4dd525 --- /dev/null +++ b/tests/Unit/Http/Client/StreamResponseTest.php @@ -0,0 +1,308 @@ + 'application/octet-stream'], + 'phenix-stream', + new Request('https://phenix.test/download') + ); + + $response = new StreamResponse($ampResponse); + + expect($response->getClientResponse())->toBe($ampResponse) + ->and($response->status())->toBe(200) + ->and($response->ok())->toBeTrue() + ->and($response->header('content-type'))->toBe('application/octet-stream') + ->and($response->read())->toBe('phenix-stream') + ->and($response->read())->toBeNull(); +}); + +it('exposes streamed response state and headers', function (): void { + $redirect = new StreamResponse(new AmpResponse( + '1.1', + 302, + null, + ['Location' => 'https://phenix.test/next'], + '', + new Request('https://phenix.test/download') + )); + + $clientError = new StreamResponse(new AmpResponse( + '1.1', + 404, + null, + ['Content-Type' => 'application/json'], + '', + new Request('https://phenix.test/download') + )); + + expect($redirect->redirect())->toBeTrue() + ->and($redirect->successful())->toBeFalse() + ->and($redirect->failed())->toBeFalse() + ->and($redirect->header('location'))->toBe('https://phenix.test/next') + ->and($redirect->headers())->toBe(['location' => ['https://phenix.test/next']]) + ->and($clientError->clientError())->toBeTrue() + ->and($clientError->serverError())->toBeFalse() + ->and($clientError->failed())->toBeTrue() + ->and($clientError->header('content-type'))->toBe('application/json') + ->and($clientError->headers())->toBe(['content-type' => ['application/json']]); +}); + +it('iterates streamed chunks and reports bytes read', function (): void { + $response = new StreamResponse(new AmpResponse( + '1.1', + 200, + null, + [], + 'abc', + new Request('https://phenix.test/download') + )); + + $chunks = []; + + $result = $response->each(function (string $chunk, int $bytes) use (&$chunks): void { + $chunks[] = [$chunk, $bytes]; + }); + + expect($result)->toBe($response) + ->and($chunks)->toBe([['abc', 3]]); +}); + +it('iterates streamed chunks and reports cumulative bytes read', function (): void { + $response = new StreamResponse(new AmpResponse( + '1.1', + 200, + null, + [], + new ReadableIterableStream(['abc', 'de']), + new Request('https://phenix.test/download') + )); + + $chunks = []; + + $response->each(function (string $chunk, int $totalBytesRead) use (&$chunks): void { + $chunks[] = [$chunk, $totalBytesRead]; + }); + + expect($chunks)->toBe([['abc', 3], ['de', 5]]); +}); + +it('prevents nested reads while iterating streamed chunks', function (): void { + $response = new StreamResponse(new AmpResponse( + '1.1', + 200, + null, + [], + new ReadableIterableStream(['abc', 'de']), + new Request('https://phenix.test/download') + )); + + expect(fn () => $response->each( + fn (string $chunk, int $bytes, StreamResponse $stream): string|null => $stream->read() + ))->toThrow(LogicException::class); + + expect($response->read())->toBe('de'); +}); + +it('saves streamed responses to disk', function (): void { + $response = new StreamResponse(new AmpResponse( + '1.1', + 200, + null, + [], + 'download-body', + new Request('https://phenix.test/download') + )); + + $path = tempnam(sys_get_temp_dir(), 'phenix-stream-'); + $progress = []; + + $bytes = $response->save($path, function (int $bytes) use (&$progress): void { + $progress[] = $bytes; + }); + + expect($bytes)->toBe(13) + ->and(File::get($path))->toBe('download-body') + ->and($progress)->toBe([13]); + + unlink($path); +}); + +it('removes partial files when saving a streamed response fails', function (): void { + $response = new StreamResponse(new AmpResponse( + '1.1', + 200, + null, + [], + new ReadableIterableStream(['partial', 'body']), + new Request('https://phenix.test/download') + )); + + $directory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'phenix-stream-' . bin2hex(random_bytes(8)); + mkdir($directory); + + $path = $directory . DIRECTORY_SEPARATOR . 'download.txt'; + + expect(fn () => $response->save( + $path, + fn (): never => throw new RuntimeException('Progress failed.') + ))->toThrow(RuntimeException::class, 'Progress failed.'); + + expect(file_exists($path))->toBeFalse() + ->and(glob($directory . DIRECTORY_SEPARATOR . '.phenix-stream-*.tmp'))->toBe([]); + + rmdir($directory); +}); + +it('streams requests through the http client callback', function (): void { + $client = new HttpClient(); + $captured = []; + + $delegate = new class ($captured) implements DelegateHttpClient { + public function __construct(private array &$captured) + { + } + + public function request(Request $request, Cancellation $cancellation): AmpResponse + { + $this->captured[] = [ + 'method' => $request->getMethod(), + 'uri' => (string) $request->getUri(), + 'body' => buffer($request->getBody()->getContent()), + 'contentType' => $request->getHeader('content-type'), + 'bodySizeLimit' => $request->getBodySizeLimit(), + 'transferTimeout' => $request->getTransferTimeout(), + ]; + + return new AmpResponse('1.1', 200, null, [], 'stream-body', $request); + } + }; + + $client->withClient(new AmpHttpClient($delegate, [])); + + $body = $client->stream( + 'https://phenix.test/download', + fn (StreamResponse $response): string|null => $response->read(), + queryParameters: ['token' => 'abc'], + bodySizeLimit: 128 * 1024 * 1024, + transferTimeout: 120 + ); + + $postBody = $client->stream( + 'https://phenix.test/export', + fn (StreamResponse $response): string|null => $response->read(), + method: HttpMethod::POST, + data: ['format' => 'csv'] + ); + + expect($body)->toBe('stream-body') + ->and($postBody)->toBe('stream-body') + ->and($captured)->toBe([ + [ + 'method' => 'GET', + 'uri' => 'https://phenix.test/download?token=abc', + 'body' => '', + 'contentType' => null, + 'bodySizeLimit' => 128 * 1024 * 1024, + 'transferTimeout' => 120.0, + ], + [ + 'method' => 'POST', + 'uri' => 'https://phenix.test/export', + 'body' => '{"format":"csv"}', + 'contentType' => 'application/json', + 'bodySizeLimit' => 10485760, + 'transferTimeout' => 10.0, + ], + ]); +}); + +it('fakes facade streamed requests with the global fake response', function (): void { + Http::fake(fn (Request $request): string => 'fake stream: ' . $request->getMethod()); + + $response = Http::stream('https://phenix.test/download'); + + expect($response)->toBeInstanceOf(StreamResponse::class) + ->and($response->read())->toBe('fake stream: GET') + ->and($response->read())->toBeNull() + ->and(Http::getRequestLog())->toHaveCount(1) + ->and((string) Http::getRequestLog()->first()->getUri())->toBe('https://phenix.test/download'); +}); + +it('prefers facade conditional fakes for streamed requests', function (): void { + Http::fake(fn (): string => 'fallback-stream'); + Http::fakeWhen( + fn (Request $request): bool => str_contains((string) $request->getUri(), '/download'), + fn (): string => 'matched-stream' + ); + + $matched = Http::stream('https://phenix.test/download'); + $fallback = Http::stream('https://phenix.test/archive'); + + expect($matched)->toBeInstanceOf(StreamResponse::class) + ->and($matched->read())->toBe('matched-stream') + ->and($fallback)->toBeInstanceOf(StreamResponse::class) + ->and($fallback->read())->toBe('fallback-stream'); +}); + +it('fakes streamed requests before touching the amp client', function (): void { + $client = new HttpClient(); + + $delegate = new class () implements DelegateHttpClient { + public function request(Request $request, Cancellation $cancellation): AmpResponse + { + throw new RuntimeException('The real Amp client should not receive faked stream requests.'); + } + }; + + $client + ->withClient(new AmpHttpClient($delegate, [])) + ->fake(function (Request $request): array { + return [ + 'method' => $request->getMethod(), + 'uri' => (string) $request->getUri(), + 'body' => buffer($request->getBody()->getContent()), + 'contentType' => $request->getHeader('content-type'), + 'bodySizeLimit' => $request->getBodySizeLimit(), + 'transferTimeout' => $request->getTransferTimeout(), + ]; + }); + + $body = $client->stream( + 'https://phenix.test/export', + fn (StreamResponse $response): string|null => $response->read(), + queryParameters: ['token' => 'abc'], + bodySizeLimit: 128 * 1024 * 1024, + transferTimeout: 120, + method: HttpMethod::POST, + data: ['format' => 'csv'] + ); + + expect(json_decode($body, true))->toBe([ + 'method' => 'POST', + 'uri' => 'https://phenix.test/export?token=abc', + 'body' => '{"format":"csv"}', + 'contentType' => 'application/json', + 'bodySizeLimit' => 128 * 1024 * 1024, + 'transferTimeout' => 120, + ]); +});