diff --git a/composer.json b/composer.json index 2ffafe18..c82641e9 100644 --- a/composer.json +++ b/composer.json @@ -44,7 +44,7 @@ "phpunit/phpunit": "^11.5.46", "psr/log": "^3.0.2", "yiisoft/files": "^2.1", - "yiisoft/test-support": "^3.1.0" + "yiisoft/test-support": "^3.2.0" }, "suggest": { "ext-curl": "To use `CurlTransport`.", diff --git a/infection.json.dist b/infection.json.dist index b80ad28c..48e25a2a 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -5,11 +5,12 @@ ] }, "logs": { - "text": "php:\/\/stderr", + "text": "runtime/infection/run.log", "stryker": { "report": "master" } }, + "tmpDir": "runtime/infection", "mutators": { "@default": true }, diff --git a/src/DownloadedFile.php b/src/DownloadedFile.php new file mode 100644 index 00000000..7612aac5 --- /dev/null +++ b/src/DownloadedFile.php @@ -0,0 +1,60 @@ +stream; + } + + /** + * Saves the file content to the specified path. + * + * @throws SaveFileException If an error occurred while saving the file. + */ + public function saveTo(string $path): void + { + set_error_handler( + static function (int $errorNumber, string $errorString): bool { + throw new SaveFileException($errorString); + }, + ); + try { + file_put_contents($path, $this->stream); + } finally { + restore_error_handler(); + } + } + + /** + * Returns the file content as a string. + */ + public function getBody(): string + { + /** + * @var string We expect the stream to be valid, so `stream_get_contents()` returns string. + */ + return stream_get_contents($this->stream); + } +} diff --git a/src/Transport/SaveFileException.php b/src/SaveFileException.php similarity index 79% rename from src/Transport/SaveFileException.php rename to src/SaveFileException.php index a934e52b..77377945 100644 --- a/src/Transport/SaveFileException.php +++ b/src/SaveFileException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Phptg\BotApi\Transport; +namespace Phptg\BotApi; use RuntimeException; diff --git a/src/TelegramBotApi.php b/src/TelegramBotApi.php index 2ebac77d..d2252566 100644 --- a/src/TelegramBotApi.php +++ b/src/TelegramBotApi.php @@ -177,7 +177,6 @@ use Phptg\BotApi\Transport\CurlTransport; use Phptg\BotApi\Transport\DownloadFileException; use Phptg\BotApi\Transport\NativeTransport; -use Phptg\BotApi\Transport\SaveFileException; use Phptg\BotApi\Transport\TransportInterface; use Phptg\BotApi\Type\AcceptedGiftTypes; use Phptg\BotApi\Type\BotCommand; @@ -310,38 +309,24 @@ public function makeFileUrl(string|File $file): string } /** - * Downloads a file from the Telegram servers and returns its content. + * Downloads a file from the Telegram servers. * * @param string|File $file File path or {@see File} object. * - * @return string The file content. - * - * @throws DownloadFileException If an error occurred while downloading the file. - * @throws LogicException If the file path is not specified in `File` object. - */ - public function downloadFile(string|File $file): string - { - return $this->transport->downloadFile( - $this->makeFileUrl($file), - ); - } - - /** - * Downloads a file from the Telegram servers and saves it to a file. - * - * @param string|File $file File path or {@see File} object. - * @param string $savePath The path to save the file. - * * @throws DownloadFileException If an error occurred while downloading the file. - * @throws SaveFileException If an error occurred while saving the file. - * @throws LogicException If the file path is not specified in `File` object. */ - public function downloadFileTo(string|File $file, string $savePath): void + public function downloadFile(string|File $file): DownloadedFile { - $this->transport->downloadFileTo( + /** + * @var resource $stream `php://temp` always opens successfully. + */ + $stream = fopen('php://temp', 'r+b'); + $this->transport->downloadFile( $this->makeFileUrl($file), - $savePath, + $stream, ); + rewind($stream); + return new DownloadedFile($stream); } /** diff --git a/src/Transport/CurlTransport.php b/src/Transport/CurlTransport.php index f121b2d7..b73446cd 100644 --- a/src/Transport/CurlTransport.php +++ b/src/Transport/CurlTransport.php @@ -78,53 +78,11 @@ public function postWithFiles(string $url, array $data, array $files): ApiRespon return $this->send($options); } - public function downloadFile(string $url): string + public function downloadFile(string $url, mixed $stream): void { $options = [ CURLOPT_URL => $url, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_FAILONERROR => true, - CURLOPT_SHARE => $this->curlShareHandle, - ]; - - try { - $curl = $this->curl->init(); - } catch (CurlException $exception) { - throw new DownloadFileException($exception->getMessage(), previous: $exception); - } - - try { - $this->curl->setopt_array($curl, $options); - - /** - * @var string $result `curl_exec` returns string because `CURLOPT_RETURNTRANSFER` is set to `true`. - */ - $result = $this->curl->exec($curl); - } catch (CurlException $exception) { - throw new DownloadFileException($exception->getMessage(), previous: $exception); - } finally { - $this->curl->close($curl); - } - - return $result; - } - - public function downloadFileTo(string $url, string $savePath): void - { - set_error_handler( - static function (int $errorNumber, string $errorString): bool { - throw new SaveFileException($errorString); - }, - ); - try { - $fileHandler = fopen($savePath, 'wb'); - } finally { - restore_error_handler(); - } - - $options = [ - CURLOPT_URL => $url, - CURLOPT_FILE => $fileHandler, + CURLOPT_FILE => $stream, CURLOPT_FAILONERROR => true, CURLOPT_SHARE => $this->curlShareHandle, ]; diff --git a/src/Transport/NativeTransport.php b/src/Transport/NativeTransport.php index ef30443e..bac6618b 100644 --- a/src/Transport/NativeTransport.php +++ b/src/Transport/NativeTransport.php @@ -77,7 +77,7 @@ public function postWithFiles(string $url, array $data, array $files): ApiRespon ); } - public function downloadFile(string $url): string + public function downloadFile(string $url, mixed $stream): void { set_error_handler( static function (int $errorNumber, string $errorString): bool { @@ -86,25 +86,14 @@ static function (int $errorNumber, string $errorString): bool { ); try { /** - * @var string We throw exception on error, so `file_get_contents()` returns string. + * @var resource $source We throw exception on error, so `fopen()` returns resource. */ - return file_get_contents($url); - } finally { - restore_error_handler(); - } - } - - public function downloadFileTo(string $url, string $savePath): void - { - $content = $this->downloadFile($url); - - set_error_handler( - static function (int $errorNumber, string $errorString): bool { - throw new SaveFileException($errorString); - }, - ); - try { - file_put_contents($savePath, $content); + $source = fopen($url, 'rb'); + try { + stream_copy_to_stream($source, $stream); + } finally { + fclose($source); + } } finally { restore_error_handler(); } diff --git a/src/Transport/PsrTransport.php b/src/Transport/PsrTransport.php index 89fb8973..425cddad 100644 --- a/src/Transport/PsrTransport.php +++ b/src/Transport/PsrTransport.php @@ -75,25 +75,41 @@ public function postWithFiles(string $url, array $data, array $files): ApiRespon return $this->send($request); } - public function downloadFile(string $url): string + public function downloadFile(string $url, mixed $stream): void { - return $this->internalDownload($url)->getContents(); - } + $request = $this->requestFactory->createRequest('GET', $url); - public function downloadFileTo(string $url, string $savePath): void - { - $body = $this->internalDownload($url); + try { + $response = $this->client->sendRequest($request); + } catch (ClientExceptionInterface $exception) { + throw new DownloadFileException($exception->getMessage(), previous: $exception); + } - $content = $body->detach(); - $content ??= $body->getContents(); + $body = $response->getBody(); + if ($body->isSeekable()) { + $body->rewind(); + } + + $resource = $body->detach(); set_error_handler( static function (int $errorNumber, string $errorString): bool { - throw new SaveFileException($errorString); + throw new DownloadFileException($errorString); }, ); try { - file_put_contents($savePath, $content); + if ($resource === null) { + $result = fwrite($stream, (string) $body); + if ($result === false) { + throw new DownloadFileException('Failed to write file content to stream.'); + } + return; + } + try { + stream_copy_to_stream($resource, $stream); + } finally { + fclose($resource); + } } finally { restore_error_handler(); } @@ -113,25 +129,4 @@ private function send(RequestInterface $request): ApiResponse $body->getContents(), ); } - - /** - * @throws DownloadFileException - */ - private function internalDownload(string $url): StreamInterface - { - $request = $this->requestFactory->createRequest('GET', $url); - - try { - $response = $this->client->sendRequest($request); - } catch (ClientExceptionInterface $exception) { - throw new DownloadFileException($exception->getMessage(), previous: $exception); - } - - $body = $response->getBody(); - if ($body->isSeekable()) { - $body->rewind(); - } - - return $body; - } } diff --git a/src/Transport/TransportInterface.php b/src/Transport/TransportInterface.php index c860ea3a..44a083cd 100644 --- a/src/Transport/TransportInterface.php +++ b/src/Transport/TransportInterface.php @@ -52,24 +52,12 @@ public function post(string $url, string $body, array $headers): ApiResponse; public function postWithFiles(string $url, array $data, array $files): ApiResponse; /** - * Downloads a file by URL. + * Downloads a file by URL and writes its content to the given stream. * * @param string $url The URL of the file to download. - * - * @return string The file content. - * - * @throws DownloadFileException If an error occurred while downloading the file. - */ - public function downloadFile(string $url): string; - - /** - * Downloads a file by URL and saves it to a file. - * - * @param string $url The URL of the file to download. - * @param string $savePath The path to save the file. + * @param resource $stream The stream to write the file content to. * * @throws DownloadFileException If an error occurred while downloading the file. - * @throws SaveFileException If an error occurred while saving the file. */ - public function downloadFileTo(string $url, string $savePath): void; + public function downloadFile(string $url, mixed $stream): void; } diff --git a/tests/DownloadedFile/DownloadedFileTest.php b/tests/DownloadedFile/DownloadedFileTest.php new file mode 100644 index 00000000..a0234899 --- /dev/null +++ b/tests/DownloadedFile/DownloadedFileTest.php @@ -0,0 +1,35 @@ +getStream()); + } + + public function testGetBody(): void + { + $stream = fopen('php://temp', 'r+b'); + fwrite($stream, 'hello-content'); + rewind($stream); + + $file = new DownloadedFile($stream); + + assertSame('hello-content', $file->getBody()); + } +} diff --git a/tests/DownloadedFile/SaveTo/.gitignore b/tests/DownloadedFile/SaveTo/.gitignore new file mode 100644 index 00000000..341707b0 --- /dev/null +++ b/tests/DownloadedFile/SaveTo/.gitignore @@ -0,0 +1 @@ +/test.txt diff --git a/tests/DownloadedFile/SaveTo/SaveToTest.php b/tests/DownloadedFile/SaveTo/SaveToTest.php new file mode 100644 index 00000000..f30403b6 --- /dev/null +++ b/tests/DownloadedFile/SaveTo/SaveToTest.php @@ -0,0 +1,44 @@ +saveTo(self::FILE); + + assertFileExists(self::FILE); + assertSame('hello-content', file_get_contents(self::FILE)); + } + + public function testError(): void + { + $stream = fopen('php://temp', 'r+b'); + $file = new DownloadedFile($stream); + + $this->expectException(SaveFileException::class); + $file->saveTo(__DIR__ . '/non-existent-directory/file.txt'); + } +} diff --git a/tests/Support/TransportMock.php b/tests/Support/TransportMock.php index 512c76a1..316156eb 100644 --- a/tests/Support/TransportMock.php +++ b/tests/Support/TransportMock.php @@ -10,7 +10,6 @@ final class TransportMock implements TransportInterface { private ?string $url = null; - private array $savedFiles = []; private ?string $sentBody = null; private ?array $sentHeaders = null; @@ -54,19 +53,9 @@ public function postWithFiles(string $url, array $data, array $files): ApiRespon return $this->response; } - public function downloadFile(string $url): string + public function downloadFile(string $url, mixed $stream): void { - return $url; - } - - public function downloadFileTo(string $url, string $savePath): void - { - $this->savedFiles[] = [$url, $savePath]; - } - - public function savedFiles(): array - { - return $this->savedFiles; + fwrite($stream, $url); } public function url(): ?string diff --git a/tests/TelegramBotApiTest.php b/tests/TelegramBotApiTest.php index 88f0cdaa..88f2d8e0 100644 --- a/tests/TelegramBotApiTest.php +++ b/tests/TelegramBotApiTest.php @@ -455,22 +455,7 @@ public function testDownloadFile(): void $result = $api->downloadFile('test.jpg'); - assertSame('https://api.telegram.org/file/botxyz/test.jpg', $result); - } - - public function testDownloadFileTo(): void - { - $transport = new TransportMock(); - $api = new TelegramBotApi('xyz', transport: $transport); - - $api->downloadFileTo('test.jpg', 'path/to/my_file.jpg'); - - assertSame( - [ - ['https://api.telegram.org/file/botxyz/test.jpg', 'path/to/my_file.jpg'], - ], - $transport->savedFiles(), - ); + assertSame('https://api.telegram.org/file/botxyz/test.jpg', $result->getBody()); } public function testAddStickerToSet(): void diff --git a/tests/Transport/CurlTransport/CurlTransportDownloadFileTest.php b/tests/Transport/CurlTransport/CurlTransportDownloadFileTest.php index d5133ebb..c7f23b51 100644 --- a/tests/Transport/CurlTransport/CurlTransportDownloadFileTest.php +++ b/tests/Transport/CurlTransport/CurlTransportDownloadFileTest.php @@ -25,14 +25,16 @@ public function testBase(): void $curl = new CurlMock('hello-content'); $transport = new CurlTransport(curl: $curl); - $result = $transport->downloadFile('https://example.test/hello.jpg'); + $stream = fopen('php://temp', 'r+b'); + $transport->downloadFile('https://example.test/hello.jpg', $stream); + rewind($stream); - assertSame('hello-content', $result); + assertSame('hello-content', stream_get_contents($stream)); $options = $curl->getOptions(); assertCount(4, $options); assertSame('https://example.test/hello.jpg', $options[CURLOPT_URL]); - assertTrue($options[CURLOPT_RETURNTRANSFER]); + assertSame($stream, $options[CURLOPT_FILE]); assertTrue($options[CURLOPT_FAILONERROR]); assertInstanceOf(CurlShareHandle::class, $options[CURLOPT_SHARE]); } @@ -42,10 +44,11 @@ public function testInitException(): void $initException = new CurlException('test'); $curl = new CurlMock(initException: $initException); $transport = new CurlTransport(curl: $curl); + $stream = fopen('php://temp', 'r+b'); $exception = null; try { - $transport->downloadFile('https://example.test/hello.jpg'); + $transport->downloadFile('https://example.test/hello.jpg', $stream); } catch (Throwable $exception) { } @@ -59,10 +62,11 @@ public function testExecException(): void $execException = new CurlException('test'); $curl = new CurlMock(execResult: $execException); $transport = new CurlTransport(curl: $curl); + $stream = fopen('php://temp', 'r+b'); $exception = null; try { - $transport->downloadFile('https://example.test/hello.jpg'); + $transport->downloadFile('https://example.test/hello.jpg', $stream); } catch (Throwable $exception) { } @@ -75,9 +79,10 @@ public function testCloseOnException(): void { $curl = new CurlMock(new RuntimeException()); $transport = new CurlTransport(curl: $curl); + $stream = fopen('php://temp', 'r+b'); try { - $transport->downloadFile('https://example.test/hello.jpg'); + $transport->downloadFile('https://example.test/hello.jpg', $stream); } catch (Throwable) { } diff --git a/tests/Transport/CurlTransport/DownloadFileTo/.gitignore b/tests/Transport/CurlTransport/DownloadFileTo/.gitignore deleted file mode 100644 index f78da738..00000000 --- a/tests/Transport/CurlTransport/DownloadFileTo/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/runtime diff --git a/tests/Transport/CurlTransport/DownloadFileTo/CurlTransportDownloadFileToTest.php b/tests/Transport/CurlTransport/DownloadFileTo/CurlTransportDownloadFileToTest.php deleted file mode 100644 index e8d1322d..00000000 --- a/tests/Transport/CurlTransport/DownloadFileTo/CurlTransportDownloadFileToTest.php +++ /dev/null @@ -1,125 +0,0 @@ -downloadFileTo('https://example.test/test.txt', $filePath); - - $options = $curl->getOptions(); - assertCount(4, $options); - assertSame('https://example.test/test.txt', $options[CURLOPT_URL]); - assertIsResource($options[CURLOPT_FILE]); - assertTrue($options[CURLOPT_FAILONERROR]); - assertInstanceOf(CurlShareHandle::class, $options[CURLOPT_SHARE]); - - assertFileExists($filePath); - assertStringEqualsFile($filePath, 'hello-content'); - } - - public function testErrorOnFopen(): void - { - $filePath = self::RUNTIME_PATH . '/test.txt'; - touch($filePath); - chmod($filePath, 0444); - assertFileExists($filePath); - - $transport = new CurlTransport(curl: new CurlMock()); - - $this->expectException(SaveFileException::class); - $this->expectExceptionMessage('Failed to open stream: Permission denied'); - $transport->downloadFileTo('https://example.test/test.txt', $filePath); - } - - public function testInitException(): void - { - $initException = new CurlException('test'); - $curl = new CurlMock(initException: $initException); - $transport = new CurlTransport(curl: $curl); - - $exception = null; - try { - $transport->downloadFileTo( - 'https://example.test/hello.jpg', - self::RUNTIME_PATH . '/init-exception.txt', - ); - } catch (Throwable $exception) { - } - - assertInstanceOf(DownloadFileException::class, $exception); - assertSame('test', $exception->getMessage()); - assertSame($initException, $exception->getPrevious()); - } - - public function testExecException(): void - { - $execException = new CurlException('test'); - $curl = new CurlMock(execResult: $execException); - $transport = new CurlTransport(curl: $curl); - - $exception = null; - try { - $transport->downloadFileTo( - 'https://example.test/hello.jpg', - self::RUNTIME_PATH . '/exec-exception.txt', - ); - } catch (Throwable $exception) { - } - - assertInstanceOf(DownloadFileException::class, $exception); - assertSame('test', $exception->getMessage()); - assertSame($execException, $exception->getPrevious()); - } - - public function testCloseOnException(): void - { - $curl = new CurlMock(new RuntimeException()); - $transport = new CurlTransport(curl: $curl); - - try { - $transport->downloadFileTo( - 'https://example.test/hello.jpg', - self::RUNTIME_PATH . '/close-on-exception.txt', - ); - } catch (Throwable) { - } - - assertSame(1, $curl->getCountCallOfClose()); - } -} diff --git a/tests/Transport/NativeTransport/DownloadFileCloseSourceStream/CloseCounterStream.php b/tests/Transport/NativeTransport/DownloadFileCloseSourceStream/CloseCounterStream.php new file mode 100644 index 00000000..89249c44 --- /dev/null +++ b/tests/Transport/NativeTransport/DownloadFileCloseSourceStream/CloseCounterStream.php @@ -0,0 +1,45 @@ +downloadFile('close-counter://test', $stream); + } catch (DownloadFileException) { + } + + assertSame(1, CloseCounterStream::$closeCount); + } +} diff --git a/tests/Transport/NativeTransport/DownloadFileCloseSourceStream/ErrorOnWriteStream.php b/tests/Transport/NativeTransport/DownloadFileCloseSourceStream/ErrorOnWriteStream.php new file mode 100644 index 00000000..1cd85652 --- /dev/null +++ b/tests/Transport/NativeTransport/DownloadFileCloseSourceStream/ErrorOnWriteStream.php @@ -0,0 +1,34 @@ +downloadFileTo('http://example.test/test.txt', $filePath); - $request = StreamMock::disable(); - - assertSame( - [ - 'path' => 'http://example.test/test.txt', - 'options' => [], - ], - $request, - ); - assertFileExists($filePath); - assertStringEqualsFile($filePath, 'hello-content'); - } - - public function testErrorOnSave(): void - { - $transport = new NativeTransport(); - $filePath = self::RUNTIME_PATH . '/non-exists/file.txt'; - - StreamMock::enable(responseBody: 'hello-content'); - - $exception = null; - try { - $transport->downloadFileTo('http://example.test/test.txt', $filePath); - } catch (Throwable $exception) { - } finally { - StreamMock::disable(); - } - - assertInstanceOf(RuntimeException::class, $exception); - assertStringContainsString('Failed to open stream: No such file or directory', $exception->getMessage()); - } -} diff --git a/tests/Transport/NativeTransport/NativeTransportDownloadFileTest.php b/tests/Transport/NativeTransport/NativeTransportDownloadFileTest.php index d17145a6..f8c01a23 100644 --- a/tests/Transport/NativeTransport/NativeTransportDownloadFileTest.php +++ b/tests/Transport/NativeTransport/NativeTransportDownloadFileTest.php @@ -17,9 +17,12 @@ public function testBase(): void { $transport = new NativeTransport(); + $stream = fopen('php://temp', 'r+b'); + StreamMock::enable(responseBody: 'hello-content'); - $result = $transport->downloadFile('http://example.test/test.txt'); + $transport->downloadFile('http://example.test/test.txt', $stream); $request = StreamMock::disable(); + rewind($stream); assertSame( [ @@ -28,15 +31,16 @@ public function testBase(): void ], $request, ); - assertSame('hello-content', $result); + assertSame('hello-content', stream_get_contents($stream)); } public function testError(): void { $transport = new NativeTransport(); + $stream = fopen('php://temp', 'r+b'); $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('file_get_contents(): Unable to find the wrapper "example"'); - $transport->downloadFile('example://example.test/test.txt'); + $this->expectExceptionMessage('fopen(): Unable to find the wrapper "example"'); + $transport->downloadFile('example://example.test/test.txt', $stream); } } diff --git a/tests/Transport/PsrTransport/DownloadFile/DownloadFileTest.php b/tests/Transport/PsrTransport/DownloadFile/DownloadFileTest.php new file mode 100644 index 00000000..827e21c9 --- /dev/null +++ b/tests/Transport/PsrTransport/DownloadFile/DownloadFileTest.php @@ -0,0 +1,224 @@ +createMock(ClientInterface::class); + $client + ->expects($this->once()) + ->method('sendRequest') + ->with($httpRequest) + ->willReturn(new Response(200, body: $streamFactory->createStream('hello-content'))); + + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $requestFactory + ->expects($this->once()) + ->method('createRequest') + ->with('GET', 'https://example.com/test.txt') + ->willReturn($httpRequest); + + $transport = new PsrTransport( + $client, + $requestFactory, + $streamFactory, + ); + + $stream = fopen('php://temp', 'r+b'); + $transport->downloadFile('https://example.com/test.txt', $stream); + rewind($stream); + + assertSame('hello-content', stream_get_contents($stream)); + } + + public function testSendRequestException(): void + { + $httpRequest = new Request(); + $requestException = new RequestException('test', $httpRequest); + + $client = $this->createMock(ClientInterface::class); + $client + ->method('sendRequest') + ->with($httpRequest) + ->willThrowException($requestException); + + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $requestFactory->method('createRequest')->willReturn($httpRequest); + + $transport = new PsrTransport( + $client, + $requestFactory, + new StreamFactory(), + ); + + $stream = fopen('php://temp', 'r+b'); + + $exception = null; + try { + $transport->downloadFile('https://example.com/test.txt', $stream); + } catch (Throwable $exception) { + } + + assertInstanceOf(DownloadFileException::class, $exception); + assertSame('test', $exception->getMessage()); + assertSame($requestException, $exception->getPrevious()); + } + + public function testRewind(): void + { + $streamFactory = new StreamFactory(); + $httpRequest = new Request(); + + $httpResponse = new Response(200, body: $streamFactory->createStream('hello-content')); + $httpResponse->getBody()->getContents(); + + $client = $this->createMock(ClientInterface::class); + $client + ->expects($this->once()) + ->method('sendRequest') + ->with($httpRequest) + ->willReturn($httpResponse); + + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $requestFactory + ->expects($this->once()) + ->method('createRequest') + ->with('GET', 'https://example.com/test.txt') + ->willReturn($httpRequest); + + $transport = new PsrTransport( + $client, + $requestFactory, + $streamFactory, + ); + + $stream = fopen('php://temp', 'r+b'); + $transport->downloadFile('https://example.com/test.txt', $stream); + rewind($stream); + + assertSame('hello-content', stream_get_contents($stream)); + } + + public function testWritesBodyContentWhenDetachReturnsNull(): void + { + $httpRequest = new Request(); + + $body = $this->createMock(StreamInterface::class); + $body->method('isSeekable')->willReturn(false); + $body->method('detach')->willReturn(null); + $body->method('__toString')->willReturn('file-content'); + + $response = new Response(body: $body); + + $client = $this->createMock(ClientInterface::class); + $client->method('sendRequest')->with($httpRequest)->willReturn($response); + + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $requestFactory->method('createRequest') + ->with('GET', 'https://example.com/file') + ->willReturn($httpRequest); + + $transport = new PsrTransport( + $client, + $requestFactory, + new StreamFactory(), + ); + + $stream = fopen('php://memory', 'w+'); + $transport->downloadFile('https://example.com/file', $stream); + rewind($stream); + + assertSame('file-content', stream_get_contents($stream)); + fclose($stream); + } + + public function testThrowsExceptionOnStreamCopyWarning(): void + { + $httpRequest = new Request(); + + $streamFactory = new StreamFactory(); + $bodyStream = $streamFactory->createStream('content'); + $response = new Response(body: $bodyStream); + + $client = $this->createMock(ClientInterface::class); + $client->method('sendRequest')->with($httpRequest)->willReturn($response); + + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $requestFactory->method('createRequest') + ->with('GET', 'https://example.com/file') + ->willReturn($httpRequest); + + $transport = new PsrTransport( + $client, + $requestFactory, + $streamFactory, + ); + + stream_wrapper_register('warnwrite', WarnWriteStreamWrapper::class); + try { + $stream = fopen('warnwrite://test', 'w'); + + $this->expectException(DownloadFileException::class); + $this->expectExceptionMessage('Custom write error'); + $transport->downloadFile('https://example.com/file', $stream); + } finally { + stream_wrapper_unregister('warnwrite'); + } + } + + public function testThrowsExceptionWhenFwriteFails(): void + { + $httpRequest = new Request(); + $response = new Response(body: new StringStream('content', seekable: false)); + + $client = $this->createMock(ClientInterface::class); + $client->method('sendRequest') + ->with($httpRequest) + ->willReturn($response); + + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $requestFactory->method('createRequest') + ->with('GET', 'https://example.com/file') + ->willReturn($httpRequest); + + $transport = new PsrTransport( + $client, + $requestFactory, + new StreamFactory(), + ); + + stream_wrapper_register('failwrite', FailWriteStreamWrapper::class); + try { + $stream = fopen('failwrite://test', 'w'); + + $this->expectException(DownloadFileException::class); + $this->expectExceptionMessage('Failed to write file content to stream.'); + $transport->downloadFile('https://example.com/file', $stream); + } finally { + stream_wrapper_unregister('failwrite'); + } + } +} diff --git a/tests/Transport/PsrTransport/DownloadFile/FailWriteStreamWrapper.php b/tests/Transport/PsrTransport/DownloadFile/FailWriteStreamWrapper.php new file mode 100644 index 00000000..3177658b --- /dev/null +++ b/tests/Transport/PsrTransport/DownloadFile/FailWriteStreamWrapper.php @@ -0,0 +1,21 @@ +createMock(StreamInterface::class); + $body->method('isSeekable')->willReturn(false); + $body->method('detach')->willReturn($sourceResource); + $response = new Response(body: $body); + + $client = $this->createMock(ClientInterface::class); + $client->method('sendRequest')->with($httpRequest)->willReturn($response); + + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $requestFactory->method('createRequest') + ->with('GET', 'https://example.com/test.txt') + ->willReturn($httpRequest); + + $transport = new PsrTransport( + $client, + $requestFactory, + new StreamFactory(), + ); + + $stream = fopen('error-on-write://test', 'wb'); + + try { + $transport->downloadFile('https://example.com/test.txt', $stream); + } catch (DownloadFileException) { + } + + assertSame(1, CloseCounterStream::$closeCount); + } +} diff --git a/tests/Transport/PsrTransport/DownloadFileCloseSourceStream/ErrorOnWriteStream.php b/tests/Transport/PsrTransport/DownloadFileCloseSourceStream/ErrorOnWriteStream.php new file mode 100644 index 00000000..2809f9f5 --- /dev/null +++ b/tests/Transport/PsrTransport/DownloadFileCloseSourceStream/ErrorOnWriteStream.php @@ -0,0 +1,36 @@ +createMock(ClientInterface::class); - $client - ->expects($this->once()) - ->method('sendRequest') - ->with($httpRequest) - ->willReturn(new Response(200, body: $streamFactory->createStream('hello-content'))); - - $requestFactory = $this->createMock(RequestFactoryInterface::class); - $requestFactory - ->expects($this->once()) - ->method('createRequest') - ->with('GET', 'https://example.com/test.txt') - ->willReturn($httpRequest); - - $transport = new PsrTransport( - $client, - $requestFactory, - $streamFactory, - ); - - $filePath = self::RUNTIME_PATH . '/file.txt'; - - $transport->downloadFileTo('https://example.com/test.txt', $filePath); - - assertFileExists($filePath); - assertStringEqualsFile($filePath, 'hello-content'); - } - - public function testExceptionOnFilePutContents(): void - { - $filePath = self::RUNTIME_PATH . '/exception-file-put-contents.txt'; - touch($filePath); - chmod($filePath, 0444); - assertFileExists($filePath); - - $client = $this->createMock(ClientInterface::class); - $client->method('sendRequest')->willReturn(new Response(200)); - - $transport = new PsrTransport( - $client, - $this->createMock(RequestFactoryInterface::class), - new StreamFactory(), - ); - - $this->expectException(SaveFileException::class); - $this->expectExceptionMessage('Failed to open stream: Permission denied'); - $transport->downloadFileTo('https://example.com/test.txt', $filePath); - } -} diff --git a/tests/Transport/PsrTransport/PsrTransportDownloadFileTest.php b/tests/Transport/PsrTransport/PsrTransportDownloadFileTest.php deleted file mode 100644 index 0f875b2a..00000000 --- a/tests/Transport/PsrTransport/PsrTransportDownloadFileTest.php +++ /dev/null @@ -1,116 +0,0 @@ -createMock(ClientInterface::class); - $client - ->expects($this->once()) - ->method('sendRequest') - ->with($httpRequest) - ->willReturn(new Response(200, body: $streamFactory->createStream('hello-content'))); - - $requestFactory = $this->createMock(RequestFactoryInterface::class); - $requestFactory - ->expects($this->once()) - ->method('createRequest') - ->with('GET', 'https://example.com/test.txt') - ->willReturn($httpRequest); - - $transport = new PsrTransport( - $client, - $requestFactory, - $streamFactory, - ); - - $result = $transport->downloadFile('https://example.com/test.txt'); - - assertSame('hello-content', $result); - } - - public function testSendRequestException(): void - { - $httpRequest = new Request(); - $requestException = new RequestException('test', $httpRequest); - - $client = $this->createMock(ClientInterface::class); - $client - ->method('sendRequest') - ->with($httpRequest) - ->willThrowException($requestException); - - $requestFactory = $this->createMock(RequestFactoryInterface::class); - $requestFactory->method('createRequest')->willReturn($httpRequest); - - $transport = new PsrTransport( - $client, - $requestFactory, - new StreamFactory(), - ); - - $exception = null; - try { - $transport->downloadFile('https://example.com/test.txt'); - } catch (Throwable $exception) { - } - - assertInstanceOf(DownloadFileException::class, $exception); - assertSame('test', $exception->getMessage()); - assertSame($requestException, $exception->getPrevious()); - } - - public function testRewind(): void - { - $streamFactory = new StreamFactory(); - $httpRequest = new Request(); - - $httpResponse = new Response(200, body: $streamFactory->createStream('hello-content')); - $httpResponse->getBody()->getContents(); - - $client = $this->createMock(ClientInterface::class); - $client - ->expects($this->once()) - ->method('sendRequest') - ->with($httpRequest) - ->willReturn($httpResponse); - - $requestFactory = $this->createMock(RequestFactoryInterface::class); - $requestFactory - ->expects($this->once()) - ->method('createRequest') - ->with('GET', 'https://example.com/test.txt') - ->willReturn($httpRequest); - - $transport = new PsrTransport( - $client, - $requestFactory, - $streamFactory, - ); - - $result = $transport->downloadFile('https://example.com/test.txt'); - - assertSame('hello-content', $result); - } -}