diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index e0e31e6..822c6b7 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -2,9 +2,9 @@ name: PHP Composer on: push: - branches: [ "master" ] + branches: ["master"] pull_request: - branches: [ "master" ] + branches: ["master"] permissions: contents: read @@ -12,41 +12,47 @@ permissions: jobs: build: runs-on: ${{ matrix.operating-system }} + continue-on-error: ${{ matrix.experimental || false }} strategy: matrix: - operating-system: [ 'ubuntu-latest', 'windows-latest', 'macos-latest' ] - php-versions: [ '8.1', '8.2', '8.3' ] - phpunit-versions: [ 'latest' ] + operating-system: ["ubuntu-latest", "windows-latest", "macos-latest"] + php-versions: ["8.1", "8.2", "8.3", "8.4"] + phpunit-versions: ["latest"] include: - - operating-system: 'ubuntu-latest' - php-versions: '8.0' + - operating-system: "ubuntu-latest" + php-versions: "8.0" phpunit-versions: 9 + experimental: true + - operating-system: "ubuntu-latest" + php-versions: "8.1" + phpunit-versions: "latest" + experimental: true steps: - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-versions }} - extensions: mbstring, intl - ini-values: post_max_size=256M, max_execution_time=180 - coverage: xdebug - tools: php-cs-fixer, phpunit:${{ matrix.phpunit-versions }} - - uses: actions/checkout@v3 - - name: Check PHP Version - run: php -v - - name: Validate composer.json and composer.lock - run: composer validate --strict - - name: Cache Composer packages - id: composer-cache - uses: actions/cache@v3 - with: - path: vendor - key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} - restore-keys: | - ${{ runner.os }}-php- - - name: Install dependencies - run: composer install --prefer-dist --no-progress + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + extensions: mbstring, intl + ini-values: post_max_size=256M, max_execution_time=180 + coverage: xdebug + tools: php-cs-fixer, phpunit:${{ matrix.phpunit-versions }} + - uses: actions/checkout@v3 + - name: Check PHP Version + run: php -v + - name: Validate composer.json and composer.lock + run: composer validate --strict + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php- + - name: Install dependencies + run: composer install --prefer-dist --no-progress - # Add a test script to composer.json, for instance: "test": "vendor/bin/phpunit" - # Docs: https://getcomposer.org/doc/articles/scripts.md - - name: Run test suite - run: composer run-script test + # Add a test script to composer.json, for instance: "test": "vendor/bin/phpunit" + # Docs: https://getcomposer.org/doc/articles/scripts.md + - name: Run test suite + run: composer run-script test diff --git a/Dockerfile b/Dockerfile index 8da2154..2957fc0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM php:8.1-fpm-alpine +FROM php:8.3-fpm-alpine RUN apk add --update \ git \ @@ -7,7 +7,8 @@ RUN apk add --update \ bash \ g++ \ vim \ - make + make \ + linux-headers RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/bin --filename=composer @@ -18,18 +19,18 @@ ARG php_ini_dir="/usr/local/etc/php" RUN mv "$php_ini_dir/php.ini-production" "$PHP_INI_DIR/php.ini" # @see https://xdebug.org/docs/compat -# Xdebug 3.1 +# Xdebug 3.3+ for PHP 8.3 ARG xdebug_client_host="127.0.0.1" ARG xdebug_client_port=9003 RUN echo $php_ini_dir -RUN apk update -RUN apk add --upgrade php81-pecl-xdebug \ - && echo "zend_extension=/usr/lib/php81/modules/xdebug.so" > $php_ini_dir/conf.d/99-xdebug.ini \ - && echo "xdebug.client_port=$xdebug_client_port" >> $php_ini_dir/conf.d/99-xdebug.ini \ - && echo "xdebug.client_host=$xdebug_client_host" >> $php_ini_dir/conf.d/99-xdebug.ini \ - && echo "xdebug.mode=debug,develop,coverage" >> $php_ini_dir/conf.d/99-xdebug.ini +# Install Xdebug via PECL for PHP 8.3 +RUN pecl install xdebug \ + && docker-php-ext-enable xdebug \ + && echo "xdebug.client_port=$xdebug_client_port" >> $php_ini_dir/conf.d/docker-php-ext-xdebug.ini \ + && echo "xdebug.client_host=$xdebug_client_host" >> $php_ini_dir/conf.d/docker-php-ext-xdebug.ini \ + && echo "xdebug.mode=debug,develop,coverage" >> $php_ini_dir/conf.d/docker-php-ext-xdebug.ini WORKDIR /app diff --git a/README.md b/README.md index 5beb0f4..7c1e793 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,20 @@ [![Coverage Status](https://coveralls.io/repos/emgiezet/errbitPHP/badge.png)](https://coveralls.io/r/emgiezet/errbitPHP) -[![Build Status](https://travis-ci.org/emgiezet/errbitPHP.png?branch=master)](https://travis-ci.org/emgiezet/errbitPHP) -[![Dependency Status](https://www.versioneye.com/user/projects/5249e725632bac0a4900b2bf/badge.png)](https://www.versioneye.com/user/projects/5249e725632bac0a4900b2bf) +[![Build Status](https://github.com/emgiezet/errbit-php/actions/workflows/ci.yml/badge.svg)](https://github.com/emgiezet/errbit-php/actions/workflows/ci.yml) [![Latest Stable Version](https://poser.pugx.org/emgiezet/errbit-php/v/stable.png)](https://packagist.org/packages/emgiezet/errbit-php) [![SymfonyInsight](https://insight.symfony.com/projects/a0c405fb-8ee9-40e9-acf1-eee084fc35a6/mini.svg)](https://insight.symfony.com/projects/a0c405fb-8ee9-40e9-acf1-eee084fc35a6) -[![Join the chat at https://gitter.im/emgiezet/errbitPHP](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/emgiezet/errbitPHP?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) This is a full-featured client to add integration with [Errbit](https://github.com/errbit/errbit) (or Airbrake) -to any PHP 8.0 and 8.1 application. +to any PHP 8.2+ application. + +## PHP Version Requirements + +| Library Version | PHP Version | +|-----------------|--------------------------------| +| 1.x | PHP 5.3 | +| 2.x | PHP 8.0, 8.1 | +| **3.x** | **PHP 8.2, 8.3** (recommended) | Original idea and source has no support for php namespaces. Moreover it has a bug and with newest errbit version the xml has not supported chars. @@ -21,28 +27,46 @@ Check the presentation below! [![Huston whe have an Airbrake](http://image.slidesharecdn.com/hustonwehaveanairbrake-131125152637-phpapp02/95/slide-1-638.jpg?1385415083)](http://www.slideshare.net/MaxMaecki/meetphp-11-huston-we-have-an-airbrake) +## Upgrading to v3.0 + +Version 3.0 contains breaking changes. If you're upgrading from v2.x, please note: + +- **PHP 8.0 and 8.1 support has been dropped** - Minimum PHP version is now 8.2 +- **Removed `ErrorInterface`** - Use `\Throwable` type hints instead +- **Changed `WriterInterface::write()` signature** - Now accepts `\Throwable` instead of `ErrorInterface` +- **Updated error class constructors** - `Notice`, `Warning`, `Error`, `Fatal` now use named parameters + ## ChangeLog + Check the: -[![Full change log here](Resources/doc/changlelog.md)] -[![Releases](https://github.com/emgiezet/errbitPHP/releases)] +- [Full change log here](Resources/doc/changlelog.md) +- [Releases](https://github.com/emgiezet/errbitPHP/releases) ## Installation ### Composer Way -For php 5.3 +For PHP 5.3 ```json require: { - ... "emgiezet/errbit-php": "1.*" - } +} ``` -For php 8.0+ + +For PHP 8.0, 8.1 + ```json require: { - ... "emgiezet/errbit-php": "2.*" - } +} +``` + +For PHP 8.2+ (recommended) + +```json +require: { + "emgiezet/errbit-php": "^3.0" +} ``` ## Usage diff --git a/composer.json b/composer.json index 97c6bb7..381d7c0 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ "issues": "https://github.com/emgiezet/errbitPHP/issues" }, "require": { - "php": "^8.0||8.1||8.2||^8.3", + "php": "^8.1||^8.2||^8.3||^8.4", "guzzlehttp/guzzle": "^7.5.0", "ext-simplexml": "*" }, @@ -37,7 +37,7 @@ "mockery/mockery": "1.5.1", "phpunit/phpunit": "9.4.4", "php-coveralls/php-coveralls": "^2.5", - "vimeo/psalm": "^5.6", + "vimeo/psalm": "^6.0", "phpstan/phpstan": "^1.9" }, "replace": { diff --git a/psalm.xml b/psalm.xml index 5d64002..72dfcca 100644 --- a/psalm.xml +++ b/psalm.xml @@ -2,9 +2,12 @@ diff --git a/src/Errbit/Errbit.php b/src/Errbit/Errbit.php index fd2cccb..f346af5 100644 --- a/src/Errbit/Errbit.php +++ b/src/Errbit/Errbit.php @@ -2,14 +2,8 @@ declare(strict_types=1); namespace Errbit; -use Errbit\Errors\ErrorInterface; -use Errbit\Errors\Warning; use Errbit\Exception\ConfigurationException; use Errbit\Exception\Exception; - -use Errbit\Errors\Notice; -use Errbit\Errors\Error; -use Errbit\Errors\Fatal; use Errbit\Handlers\ErrorHandlers; use Errbit\Writer\WriterInterface; @@ -138,13 +132,13 @@ public function start(array $handlers = ['exception', 'error', 'fatal']): static /** * Notify an individual exception manually. * - * @param \Errbit\Errors\ErrorInterface $exception + * @param \Throwable $exception * @param array $options * * @return static [Errbit] the current instance * @throws \Errbit\Exception\ConfigurationException */ - public function notify(ErrorInterface $exception, array $options = []): static + public function notify(\Throwable $exception, array $options = []): static { $this->checkConfig(); $config = array_merge($this->config, $options); @@ -158,20 +152,21 @@ public function notify(ErrorInterface $exception, array $options = []): static } /** - * @param \Errbit\Errors\ErrorInterface $exception + * @param \Throwable $exception * @param array $skippedExceptions * * @return bool */ - protected function shouldNotify(ErrorInterface $exception, array $skippedExceptions): bool + protected function shouldNotify(\Throwable $exception, array $skippedExceptions): bool { foreach ($skippedExceptions as $skippedException) { if ($exception instanceof $skippedException) { return false; } } + $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? ''; foreach ($this->config['ignore_user_agent'] as $ua) { - if (str_contains($_SERVER['HTTP_USER_AGENT'],$ua) ) { + if ($userAgent !== '' && str_contains($userAgent, $ua)) { return false; } } @@ -180,12 +175,12 @@ protected function shouldNotify(ErrorInterface $exception, array $skippedExcepti } /** - * @param \Errbit\Errors\ErrorInterface $exception + * @param \Throwable $exception * @param array $config * * @return void */ - protected function notifyObservers(ErrorInterface $exception, array $config): void + protected function notifyObservers(\Throwable $exception, array $config): void { foreach ($this->observers as $observer) { $observer($exception, $config); diff --git a/src/Errbit/Errors/BaseError.php b/src/Errbit/Errors/BaseError.php index 20760ba..f120d5d 100644 --- a/src/Errbit/Errors/BaseError.php +++ b/src/Errbit/Errors/BaseError.php @@ -2,56 +2,38 @@ declare(strict_types=1); namespace Errbit\Errors; +use Throwable; + /** * */ -abstract class BaseError +abstract class BaseError extends \Exception { - /** - * Create a new error wrapping the given error context info. - * - * @param string $message message - * @param integer $line line - * @param string $file filename - * @param array $trace - */ - public function __construct(private string $message, private int $line, private string $file, private array $trace) - { - } - /** - * Message getter - * - * @return string error message - * - */ - public function getMessage(): string - { - return $this->message; - } - /** - * Line getter - * - * @return integer the number of line - */ - public function getLine(): int - { - return $this->line; + protected string $errorFile = ''; + protected array $backtrace = []; + + public function __construct( + string $message = "", + int $code = 0, + ?Throwable $previous = null, + string $file = '', + array $backtrace = [] + ) { + parent::__construct($message, $code, $previous); + $this->errorFile = $file; + $this->backtrace = $backtrace; + if ($file !== '') { + $this->file = $file; + } } - /** - * File getter - * - * @return string name of the file - */ - public function getFile(): string + + public function getErrorFile(): string { - return $this->file; + return $this->errorFile; } - - /** - * @return array - */ - public function getTrace(): array + + public function getBacktrace(): array { - return $this->trace; + return $this->backtrace; } } diff --git a/src/Errbit/Errors/Error.php b/src/Errbit/Errors/Error.php index 74427d2..ccc5087 100644 --- a/src/Errbit/Errors/Error.php +++ b/src/Errbit/Errors/Error.php @@ -2,6 +2,18 @@ declare(strict_types=1); namespace Errbit\Errors; -class Error extends BaseError implements ErrorInterface +class Error extends BaseError { + public function __construct( + string $message, + ?int $line = null, + ?\Throwable $previous = null, + string $file = '', + array $backtrace = [] + ) { + parent::__construct($message, 0, $previous, $file, $backtrace); + if ($line !== null) { + $this->line = $line; + } + } } diff --git a/src/Errbit/Errors/ErrorInterface.php b/src/Errbit/Errors/ErrorInterface.php deleted file mode 100644 index a24fbe5..0000000 --- a/src/Errbit/Errors/ErrorInterface.php +++ /dev/null @@ -1,34 +0,0 @@ - $line, 'file' => $file, 'function' => '']] + $previous, + $file ); + if ($line > 0) { + $this->line = $line; + } } } diff --git a/src/Errbit/Errors/Notice.php b/src/Errbit/Errors/Notice.php index 534403c..cf31cff 100644 --- a/src/Errbit/Errors/Notice.php +++ b/src/Errbit/Errors/Notice.php @@ -2,6 +2,18 @@ declare(strict_types=1); namespace Errbit\Errors; -class Notice extends BaseError implements ErrorInterface +class Notice extends BaseError { + public function __construct( + string $message, + ?int $line = null, + ?\Throwable $previous = null, + string $file = '', + array $backtrace = [] + ) { + parent::__construct($message, 0, $previous, $file, $backtrace); + if ($line !== null) { + $this->line = $line; + } + } } diff --git a/src/Errbit/Errors/Warning.php b/src/Errbit/Errors/Warning.php index 0fb28b5..32e5da1 100644 --- a/src/Errbit/Errors/Warning.php +++ b/src/Errbit/Errors/Warning.php @@ -2,7 +2,18 @@ declare(strict_types=1); namespace Errbit\Errors; - -class Warning extends BaseError implements ErrorInterface +class Warning extends BaseError { + public function __construct( + string $message, + ?int $line = null, + ?\Throwable $previous = null, + string $file = '', + array $backtrace = [] + ) { + parent::__construct($message, 0, $previous, $file, $backtrace); + if ($line !== null) { + $this->line = $line; + } + } } diff --git a/src/Errbit/Handlers/ErrorHandlers.php b/src/Errbit/Handlers/ErrorHandlers.php index d9ded07..0ee15df 100644 --- a/src/Errbit/Handlers/ErrorHandlers.php +++ b/src/Errbit/Handlers/ErrorHandlers.php @@ -54,7 +54,7 @@ public function __construct(private Errbit $errbit, array $handlers=[]) */ public function onError(int $code, string $message, string $file, int $line): void { - $exception = $this->converter->convert($code, $message, $file, + $exception = $this->converter->convert($code, $message, null, $file, $line, debug_backtrace()); $this->errbit->notify($exception); } @@ -66,8 +66,14 @@ public function onError(int $code, string $message, string $file, int $line): vo */ public function onException(\Exception $exception): void { - $error = $this->converter->convert($exception->getCode(), $exception->getMessage(), $exception->getFile(), - $exception->getLine(), debug_backtrace()); + $error = $this->converter->convert( + $exception->getCode(), + $exception->getMessage(), + $exception, + $exception->getFile(), + $exception->getLine(), + $exception->getTrace() + ); $this->errbit->notify($error); } @@ -79,7 +85,7 @@ public function onException(\Exception $exception): void public function onShutdown(): void { if (($error = error_get_last()) && $error['type'] & error_reporting()) { - $this->errbit->notify(new Fatal($error['message'], $error['file'], $error['line'])); + $this->errbit->notify(new Fatal($error['message'], $error['line'], null, $error['file'])); } } diff --git a/src/Errbit/Utils/Converter.php b/src/Errbit/Utils/Converter.php index 6f440cb..993ac9c 100644 --- a/src/Errbit/Utils/Converter.php +++ b/src/Errbit/Utils/Converter.php @@ -4,7 +4,6 @@ namespace Errbit\Utils; use Errbit\Errors\Error; -use Errbit\Errors\ErrorInterface; use Errbit\Errors\Fatal; use Errbit\Errors\Notice; use Errbit\Errors\Warning; @@ -21,13 +20,13 @@ public static function createDefault(): Converter return new self(); } - public function convert(int $code, string $message, string $file, int $line, array $backtrace): ErrorInterface + public function convert(int $code, string $message, ?\Throwable $previous = null, string $file ='', ?int $line = null, array $backtrace = []): \Throwable { return match ($code) { - E_NOTICE, E_USER_NOTICE => new Notice($message, $line, $file, $backtrace), - E_WARNING, E_USER_WARNING => new Warning($message, $line, $file, $backtrace), - E_RECOVERABLE_ERROR, E_ERROR, E_CORE_ERROR => new Fatal($message, $line, $file), - default => new Error($message, $line, $file, $backtrace), + E_NOTICE, E_USER_NOTICE => new Notice($message, $line, $previous, $file, $backtrace), + E_WARNING, E_USER_WARNING => new Warning($message, $line, $previous, $file, $backtrace), + E_RECOVERABLE_ERROR, E_ERROR, E_CORE_ERROR => new Fatal($message, $line, $previous, $file), + default => new Error($message, $line, $previous, $file, $backtrace), }; } } diff --git a/src/Errbit/Writer/AbstractWriter.php b/src/Errbit/Writer/AbstractWriter.php index 4a11d3f..8fceaab 100644 --- a/src/Errbit/Writer/AbstractWriter.php +++ b/src/Errbit/Writer/AbstractWriter.php @@ -2,7 +2,6 @@ declare(strict_types=1); namespace Errbit\Writer; -use Errbit\Errors\ErrorInterface; use Errbit\Exception\Notice; abstract class AbstractWriter @@ -29,12 +28,12 @@ protected function buildConnectionScheme(array $config): string } /** - * @param \Errbit\Errors\ErrorInterface $exception + * @param \Throwable $exception * @param array $options * * @return string */ - protected function buildNoticeFor(ErrorInterface $exception, array $options): string + protected function buildNoticeFor(\Throwable $exception, array $options): string { return Notice::forException($exception, $options)->asXml(); } diff --git a/src/Errbit/Writer/GuzzleWriter.php b/src/Errbit/Writer/GuzzleWriter.php index c81a63a..9f6debb 100644 --- a/src/Errbit/Writer/GuzzleWriter.php +++ b/src/Errbit/Writer/GuzzleWriter.php @@ -2,12 +2,6 @@ declare(strict_types=1); namespace Errbit\Writer; -use Errbit\Errors\Error; -use Errbit\Errors\ErrorInterface; -use Errbit\Errors\Fatal; -use Errbit\Errors\Notice; -use Errbit\Errors\Warning; -use Exception; use GuzzleHttp\ClientInterface; use GuzzleHttp\Promise\PromiseInterface; use Psr\Http\Message\ResponseInterface; @@ -17,7 +11,6 @@ */ class GuzzleWriter extends AbstractWriter implements WriterInterface { - /** * @param array $config * @@ -25,24 +18,26 @@ class GuzzleWriter extends AbstractWriter implements WriterInterface */ protected function buildConnectionScheme(array $config): string { - if ($config['secure']) { + if ($config['secure']) { $proto = "https"; } else { $proto = 'http'; } - return sprintf('%s://%s%s', $proto, $config['host'], (isset($config['port'])?':'.$config['port']:'')); + return sprintf('%s://%s%s', $proto, $config['host'], (isset($config['port']) ? ':' . $config['port'] : '')); } - - public function __construct(private ClientInterface $client) + + public function __construct(private readonly ClientInterface $client) { } - + /** - * @param \Exception $exception + * @param \Throwable $exception + * @param array $config * + * @return ResponseInterface|PromiseInterface * @throws \GuzzleHttp\Exception\GuzzleException */ - public function write(ErrorInterface $exception, array $config): ResponseInterface|PromiseInterface + public function write(\Throwable $exception, array $config): mixed { if($config['async']) { return $this->asyncWrite($exception, $config); @@ -55,7 +50,7 @@ public function write(ErrorInterface $exception, array $config): ResponseInterfa * * @throws \GuzzleHttp\Exception\GuzzleException */ - public function synchronousWrite(ErrorInterface $exception, array $config): ResponseInterface + public function synchronousWrite(\Throwable $exception, array $config): ResponseInterface { $uri = $this->buildConnectionScheme($config); $body = $this->buildNoticeFor($exception, $config); @@ -73,7 +68,7 @@ public function synchronousWrite(ErrorInterface $exception, array $config): Res ); } - public function asyncWrite(ErrorInterface $exception, array $config): PromiseInterface + public function asyncWrite(\Throwable $exception, array $config): PromiseInterface { $uri = $this->buildConnectionScheme($config); $promise = $this->client->postAsync( diff --git a/src/Errbit/Writer/SocketWriter.php b/src/Errbit/Writer/SocketWriter.php index 59e7cb7..5d34d60 100644 --- a/src/Errbit/Writer/SocketWriter.php +++ b/src/Errbit/Writer/SocketWriter.php @@ -2,32 +2,27 @@ declare(strict_types=1); namespace Errbit\Writer; -use Errbit\Errors\ErrorInterface; -use Errbit\Exception\Notice; - class SocketWriter extends AbstractWriter implements WriterInterface { - - /** * @var false|int How many characters to read after request has been made */ - public $charactersToRead = false; - + public int|false $charactersToRead = false; + /** * {@inheritdoc} * - * @param \Errbit\Errors\ErrorInterface $exception + * @param \Throwable $exception * @param array $config * - * @return void + * @return mixed * @throws \JsonException */ - public function write(ErrorInterface $exception, array $config) + public function write(\Throwable $exception, array $config): mixed { $socket = fsockopen( $this->buildConnectionScheme($config), - (integer) $config['port'], + (int) $config['port'], $errno, $errstr, $config['connect_timeout'] @@ -67,15 +62,17 @@ public function write(ErrorInterface $exception, array $config) fclose($socket); } + + return null; } - + /** - * @param \Errbit\Errors\ErrorInterface $exception + * @param \Throwable $exception * @param array $config * * @return string */ - protected function buildPayload(ErrorInterface $exception, array $config): string + protected function buildPayload(\Throwable $exception, array $config): string { return $this->addHttpHeadersIfNeeded( $this->buildNoticeFor($exception, $config), diff --git a/src/Errbit/Writer/WriterInterface.php b/src/Errbit/Writer/WriterInterface.php index 82d78d3..e9f1372 100644 --- a/src/Errbit/Writer/WriterInterface.php +++ b/src/Errbit/Writer/WriterInterface.php @@ -1,20 +1,15 @@ getProperty('instance'); + $instance->setAccessible(true); + $instance->setValue(null, null); + $config = ['api_key' => 'test', 'host' => 'test', 'skipped_exceptions' => ['BadMethodCallException']]; $this->errbit = new Errbit($config); } @@ -22,7 +33,7 @@ public function setUp(): void /** * @test */ - public function shouldPassExceptionsToWriter() + public function shouldPassExceptionsToWriter(): void { $exception = Mockery::mock(Error::class); @@ -37,14 +48,210 @@ public function shouldPassExceptionsToWriter() /** * @test */ - public function shouldIgnoreSkippedExceptions() + public function shouldIgnoreSkippedExceptions(): void { - $this->errbit->configure(['skipped_exceptions'=>[Notice::class]]); - $exception = new Notice('Notice test', 123,'test.php', ['test']); - //don't write because this Notice should be ignored + $this->errbit->configure(['skipped_exceptions' => [Notice::class]]); + $previous = new \Exception('prev exception'); + $exception = new Notice('Notice test', 123, $previous); + // don't write because this Notice should be ignored $writer = Mockery::mock(\Errbit\Writer\WriterInterface::class)->shouldNotReceive('write')->getMock(); $this->errbit->setWriter($writer); $return = $this->errbit->notify($exception, []); $this->assertIsObject($return); } + + public function testSingletonInstance(): void + { + $instance1 = Errbit::instance(); + $instance2 = Errbit::instance(); + + $this->assertSame($instance1, $instance2); + } + + public function testConfigureThrowsExceptionWithoutApiKey(): void + { + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage("`api_key' must be configured"); + + $errbit = new Errbit(); + $errbit->configure(['host' => 'test.com']); + } + + public function testConfigureThrowsExceptionWithoutHost(): void + { + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage("`host' must be configured"); + + $errbit = new Errbit(); + $errbit->configure(['api_key' => 'test-key']); + } + + public function testConfigureSetsDefaultPort80ForInsecure(): void + { + $errbit = new Errbit(); + $errbit->configure(['api_key' => 'test', 'host' => 'test.com', 'secure' => false]); + + $reflection = new \ReflectionClass($errbit); + $config = $reflection->getProperty('config'); + $config->setAccessible(true); + $configValue = $config->getValue($errbit); + + $this->assertEquals(80, $configValue['port']); + } + + public function testConfigureSetsDefaultPort443ForSecure(): void + { + $errbit = new Errbit(); + $errbit->configure(['api_key' => 'test', 'host' => 'test.com', 'secure' => true]); + + $reflection = new \ReflectionClass($errbit); + $config = $reflection->getProperty('config'); + $config->setAccessible(true); + $configValue = $config->getValue($errbit); + + $this->assertEquals(443, $configValue['port']); + } + + public function testConfigureSetsSecureBasedOnPort(): void + { + $errbit = new Errbit(); + $errbit->configure(['api_key' => 'test', 'host' => 'test.com', 'port' => 443]); + + $reflection = new \ReflectionClass($errbit); + $config = $reflection->getProperty('config'); + $config->setAccessible(true); + $configValue = $config->getValue($errbit); + + $this->assertTrue($configValue['secure']); + } + + public function testConfigureSetsDefaults(): void + { + $errbit = new Errbit(); + $errbit->configure(['api_key' => 'test', 'host' => 'test.com']); + + $reflection = new \ReflectionClass($errbit); + $config = $reflection->getProperty('config'); + $config->setAccessible(true); + $configValue = $config->getValue($errbit); + + $this->assertEquals('development', $configValue['environment_name']); + $this->assertEquals(['/password/'], $configValue['params_filters']); + $this->assertEquals(3, $configValue['connect_timeout']); + $this->assertEquals(3, $configValue['write_timeout']); + $this->assertEquals([], $configValue['skipped_exceptions']); + $this->assertEquals('errbitPHP', $configValue['agent']); + $this->assertFalse($configValue['async']); + $this->assertEquals([], $configValue['ignore_user_agent']); + } + + public function testOnNotifyRegistersCallback(): void + { + $called = false; + $callback = function ($exception, $config) use (&$called) { + $called = true; + }; + + $this->errbit->onNotify($callback); + + $writer = Mockery::mock(\Errbit\Writer\WriterInterface::class); + $writer->shouldReceive('write'); + $this->errbit->setWriter($writer); + + $exception = new Error('test', 1); + $this->errbit->notify($exception); + + $this->assertTrue($called); + } + + public function testOnNotifyThrowsExceptionForNonCallable(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Notify callback must be callable'); + + $this->errbit->onNotify('not-a-callable'); + } + + public function testOnNotifyCallbackReceivesExceptionAndConfig(): void + { + $receivedException = null; + $receivedConfig = null; + + $callback = function ($exception, $config) use (&$receivedException, &$receivedConfig) { + $receivedException = $exception; + $receivedConfig = $config; + }; + + $this->errbit->onNotify($callback); + + $writer = Mockery::mock(\Errbit\Writer\WriterInterface::class); + $writer->shouldReceive('write'); + $this->errbit->setWriter($writer); + + $exception = new Error('test error', 1); + $this->errbit->notify($exception); + + $this->assertSame($exception, $receivedException); + $this->assertIsArray($receivedConfig); + $this->assertEquals('test', $receivedConfig['api_key']); + } + + public function testStartReturnsInstance(): void + { + $result = $this->errbit->start([]); + + $this->assertSame($this->errbit, $result); + } + + public function testNotifyReturnsInstance(): void + { + $writer = Mockery::mock(\Errbit\Writer\WriterInterface::class); + $writer->shouldReceive('write'); + $this->errbit->setWriter($writer); + + $result = $this->errbit->notify(new Error('test', 1)); + + $this->assertSame($this->errbit, $result); + } + + public function testIgnoreUserAgentSkipsNotification(): void + { + $_SERVER['HTTP_USER_AGENT'] = 'Googlebot/2.1'; + + $this->errbit->configure([ + 'api_key' => 'test', + 'host' => 'test', + 'ignore_user_agent' => ['Googlebot'] + ]); + + $writer = Mockery::mock(\Errbit\Writer\WriterInterface::class); + $writer->shouldNotReceive('write'); + $this->errbit->setWriter($writer); + + $exception = new Error('test', 1); + $this->errbit->notify($exception); + + unset($_SERVER['HTTP_USER_AGENT']); + } + + public function testNotifyWithOptionsOverridesConfig(): void + { + $receivedConfig = null; + + $callback = function ($exception, $config) use (&$receivedConfig) { + $receivedConfig = $config; + }; + + $this->errbit->onNotify($callback); + + $writer = Mockery::mock(\Errbit\Writer\WriterInterface::class); + $writer->shouldReceive('write'); + $this->errbit->setWriter($writer); + + $exception = new Error('test', 1); + $this->errbit->notify($exception, ['controller' => 'TestController', 'action' => 'index']); + + $this->assertEquals('TestController', $receivedConfig['controller']); + $this->assertEquals('index', $receivedConfig['action']); + } } diff --git a/tests/Unit/Errbit/Tests/Errors/BaseErrorTest.php b/tests/Unit/Errbit/Tests/Errors/BaseErrorTest.php new file mode 100644 index 0000000..eadc005 --- /dev/null +++ b/tests/Unit/Errbit/Tests/Errors/BaseErrorTest.php @@ -0,0 +1,95 @@ + 'testFunc', 'file' => '/test.php', 'line' => 10], + ]; + + $error = new Error('Test error', 42, $previous, '/path/to/file.php', $backtrace); + + $this->assertEquals('Test error', $error->getMessage()); + $this->assertEquals(42, $error->getLine()); + $this->assertEquals('/path/to/file.php', $error->getFile()); + $this->assertEquals('/path/to/file.php', $error->getErrorFile()); + $this->assertSame($previous, $error->getPrevious()); + $this->assertEquals($backtrace, $error->getBacktrace()); + } + + public function testErrorWithDefaultValues(): void + { + $error = new Error('Simple error'); + + $this->assertEquals('Simple error', $error->getMessage()); + $this->assertEquals('', $error->getErrorFile()); + $this->assertEquals([], $error->getBacktrace()); + $this->assertNull($error->getPrevious()); + } + + public function testNoticeCreation(): void + { + $notice = new Notice('Notice message', 100, null, '/notice/file.php', []); + + $this->assertEquals('Notice message', $notice->getMessage()); + $this->assertEquals(100, $notice->getLine()); + $this->assertEquals('/notice/file.php', $notice->getFile()); + } + + public function testWarningCreation(): void + { + $warning = new Warning('Warning message', 200, null, '/warning/file.php', []); + + $this->assertEquals('Warning message', $warning->getMessage()); + $this->assertEquals(200, $warning->getLine()); + $this->assertEquals('/warning/file.php', $warning->getFile()); + } + + public function testErrorIsThrowable(): void + { + $error = new Error('Throwable error', 1); + + $this->assertInstanceOf(\Throwable::class, $error); + $this->assertInstanceOf(\Exception::class, $error); + } + + public function testErrorWithNullLine(): void + { + $error = new Error('Error with null line', null); + + $this->assertEquals('Error with null line', $error->getMessage()); + // Line should not be set when null + $this->assertIsInt($error->getLine()); + } + + public function testErrorFileIsSetCorrectly(): void + { + $error = new Error('Error', 10, null, '/custom/path.php'); + + // Both getFile() and getErrorFile() should return the custom path + $this->assertEquals('/custom/path.php', $error->getFile()); + $this->assertEquals('/custom/path.php', $error->getErrorFile()); + } + + public function testErrorFileEmptyString(): void + { + $error = new Error('Error', 10, null, ''); + + $this->assertEquals('', $error->getErrorFile()); + // getFile() returns the actual file where exception was created + $this->assertStringContainsString('BaseErrorTest.php', $error->getFile()); + } +} diff --git a/tests/Unit/Errbit/Tests/Errors/FatalTest.php b/tests/Unit/Errbit/Tests/Errors/FatalTest.php index c065a10..2e9be35 100644 --- a/tests/Unit/Errbit/Tests/Errors/FatalTest.php +++ b/tests/Unit/Errbit/Tests/Errors/FatalTest.php @@ -9,22 +9,35 @@ class FatalTest extends TestCase { use MockeryPHPUnitIntegration; - public function testFatal() + public function testFatal(): void { - $object = new Fatal('test', 12, __FILE__); + $previous = new \Exception("prev"); + $object = new Fatal('test', 15, $previous); - $msg = $object->getMessage(); - $this->assertEquals('test', $msg, 'Message of base error missmatch'); - - $line = $object->getLine(); - $this->assertEquals(12, $line, 'Line no mismatch'); - - $file = $object->getFile(); - $this->assertEquals(__FILE__, $file, 'File missmatch'); + $this->assertEquals('test', $object->getMessage(), 'Message of base error mismatch'); + $this->assertEquals(15, $object->getLine(), 'Line no mismatch'); + $this->assertEquals($previous, $object->getPrevious(), 'Prev mismatch'); + // Check that trace contains this test method (without checking specific line numbers) $trace = $object->getTrace(); + $found = false; + foreach ($trace as $frame) { + if (isset($frame['function']) && $frame['function'] === 'testFatal' + && isset($frame['class']) && $frame['class'] === self::class) { + $found = true; + break; + } + } + $this->assertTrue($found, 'Trace should contain the test method'); + } + + public function testFatalWithFile(): void + { + $object = new Fatal('error message', 42, null, '/path/to/file.php'); - $actualTrace = [['line' => 12, 'file' => __FILE__, 'function' => '']]; - $this->assertEquals($actualTrace, $trace, 'trace missmatch'); + $this->assertEquals('error message', $object->getMessage()); + $this->assertEquals(42, $object->getLine()); + $this->assertEquals('/path/to/file.php', $object->getFile()); + $this->assertNull($object->getPrevious()); } } diff --git a/tests/Unit/Errbit/Tests/Exception/NoticeTest.php b/tests/Unit/Errbit/Tests/Exception/NoticeTest.php index a1d1f5f..5d9adeb 100644 --- a/tests/Unit/Errbit/Tests/Exception/NoticeTest.php +++ b/tests/Unit/Errbit/Tests/Exception/NoticeTest.php @@ -1,6 +1,14 @@ 'test-api-key', + 'host' => 'test.errbit.com', + 'project_root' => '/var/www/app', + 'environment_name' => 'testing', + 'params_filters' => [], + 'backtrace_filters' => [], + ]; + } + + public function testForExceptionCreatesNotice(): void + { + $exception = new \Exception('Test exception'); + $options = $this->getDefaultOptions(); + + $notice = Notice::forException($exception, $options); + + $this->assertInstanceOf(Notice::class, $notice); + } + + public function testAsXmlReturnsValidXml(): void + { + $exception = new \Exception('Test exception'); + $options = $this->getDefaultOptions(); + + $notice = new Notice($exception, $options); + $xml = $notice->asXml(); + + $this->assertStringContainsString('', $xml); + $this->assertStringContainsString('test-api-key', $xml); + } + + public function testAsXmlContainsErrorDetails(): void + { + $exception = new \Exception('Test error message'); + $options = $this->getDefaultOptions(); + + $notice = new Notice($exception, $options); + $xml = $notice->asXml(); + + $this->assertStringContainsString('', $xml); + $this->assertStringContainsString('Test error message', $xml); + $this->assertStringContainsString('', $xml); + } + + public function testAsXmlContainsServerEnvironment(): void + { + $options = $this->getDefaultOptions(); + $notice = new Notice(new \Exception(), $options); + $xml = $notice->asXml(); + + $this->assertStringContainsString('', $xml); + $this->assertStringContainsString('/var/www/app', $xml); + $this->assertStringContainsString('testing', $xml); + } + + public function testClassNameReturnsNoticeForNotice(): void + { + $exception = new ErrorNotice('test', 1); + $className = Notice::className($exception); + + $this->assertEquals('Notice', $className); + } + + public function testClassNameReturnsWarningForWarning(): void + { + $exception = new Warning('test', 1); + $className = Notice::className($exception); + + $this->assertEquals('Warning', $className); + } + + public function testClassNameReturnsErrorForError(): void + { + $exception = new Error('test', 1); + $className = Notice::className($exception); + + $this->assertEquals('Error', $className); + } + + public function testClassNameReturnsFatalErrorForFatal(): void { - $this->markTestIncomplete('This test has not been implemented yet.'); + $exception = new Fatal('test', 1); + $className = Notice::className($exception); + + $this->assertEquals('Fatal Error', $className); + } + + public function testClassNameReturnsClassNameForOtherExceptions(): void + { + $exception = new \RuntimeException('test'); + $className = Notice::className($exception); + + $this->assertEquals('RuntimeException', $className); + } + + public function testFilterTraceReplacesPatterns(): void + { + $options = $this->getDefaultOptions(); + $options['backtrace_filters'] = [ + '/\/var\/www\/app/' => '[PROJECT_ROOT]', + ]; + + $notice = new Notice(new \Exception(), $options); + $result = $notice->filterTrace('/var/www/app/src/file.php'); + + $this->assertEquals('[PROJECT_ROOT]/src/file.php', $result); + } + + public function testFilterTraceWithNoFiltersReturnsOriginal(): void + { + $options = $this->getDefaultOptions(); + $options['backtrace_filters'] = []; + + $notice = new Notice(new \Exception(), $options); + $result = $notice->filterTrace('/var/www/app/src/file.php'); + + $this->assertEquals('/var/www/app/src/file.php', $result); + } + + public function testFilterTraceWithNonArrayFiltersReturnsOriginal(): void + { + $options = $this->getDefaultOptions(); + $options['backtrace_filters'] = 'not-an-array'; + + $notice = new Notice(new \Exception(), $options); + $result = $notice->filterTrace('/var/www/app/src/file.php'); + + $this->assertEquals('/var/www/app/src/file.php', $result); + } + + public function testFormatMethodWithClassAndType(): void + { + $frame = [ + 'class' => 'MyClass', + 'type' => '->', + 'function' => 'myMethod', + ]; + + $result = Notice::formatMethod($frame); + + $this->assertEquals('MyClass->myMethod()', $result); + } + + public function testFormatMethodWithStaticCall(): void + { + $frame = [ + 'class' => 'MyClass', + 'type' => '::', + 'function' => 'staticMethod', + ]; + + $result = Notice::formatMethod($frame); + + $this->assertEquals('MyClass::staticMethod()', $result); + } + + public function testFormatMethodWithFunctionOnly(): void + { + $frame = [ + 'function' => 'globalFunction', + ]; + + $result = Notice::formatMethod($frame); + + $this->assertEquals('globalFunction()', $result); + } + + public function testFormatMethodWithEmptyFrame(): void + { + $frame = []; + + $result = Notice::formatMethod($frame); + + $this->assertEquals('()', $result); + } + + public function testXmlVarsForWithSimpleArray(): void + { + $builder = new XmlBuilder(); + $array = ['key1' => 'value1', 'key2' => 'value2']; + + $builder->tag('root', '', [], function ($root) use ($array) { + Notice::xmlVarsFor($root, $array); + }); + + $xml = $builder->asXml(); + + $this->assertStringContainsString('key="key1"', $xml); + $this->assertStringContainsString('value1', $xml); + $this->assertStringContainsString('key="key2"', $xml); + $this->assertStringContainsString('value2', $xml); + } + + public function testXmlVarsForWithNestedArray(): void + { + $builder = new XmlBuilder(); + $array = [ + 'outer' => [ + 'inner' => 'nested value', + ], + ]; + + $builder->tag('root', '', [], function ($root) use ($array) { + Notice::xmlVarsFor($root, $array); + }); + + $xml = $builder->asXml(); + + $this->assertStringContainsString('key="outer"', $xml); + $this->assertStringContainsString('key="inner"', $xml); + $this->assertStringContainsString('nested value', $xml); + } + + public function testXmlVarsForWithObject(): void + { + $builder = new XmlBuilder(); + $obj = new \stdClass(); + $obj->property = 'object value'; + $array = ['myObject' => $obj]; + + $builder->tag('root', '', [], function ($root) use ($array) { + Notice::xmlVarsFor($root, $array); + }); + + $xml = $builder->asXml(); + + $this->assertStringContainsString('key="myObject"', $xml); + $this->assertStringContainsString('object value', $xml); + } + + public function testParamsFiltering(): void + { + $options = $this->getDefaultOptions(); + $options['params_filters'] = ['/password/', '/secret/']; + $options['parameters'] = [ + 'username' => 'john', + 'password' => 'secret123', + 'api_secret' => 'key123', + ]; + + $notice = new Notice(new \Exception(), $options); + $xml = $notice->asXml(); + + $this->assertStringContainsString('john', $xml); + $this->assertStringContainsString('[FILTERED]', $xml); + $this->assertStringNotContainsString('secret123', $xml); + $this->assertStringNotContainsString('key123', $xml); + } + + public function testAsXmlWithUrl(): void + { + $options = $this->getDefaultOptions(); + $options['url'] = 'https://example.com/test'; + + $notice = new Notice(new \Exception(), $options); + $xml = $notice->asXml(); + + $this->assertStringContainsString('https://example.com/test', $xml); + } + + public function testAsXmlWithControllerAndAction(): void + { + $options = $this->getDefaultOptions(); + $options['controller'] = 'UsersController'; + $options['action'] = 'show'; + + $notice = new Notice(new \Exception(), $options); + $xml = $notice->asXml(); + + $this->assertStringContainsString('UsersController', $xml); + $this->assertStringContainsString('show', $xml); + } + + public function testAsXmlWithUserAttributes(): void + { + $options = $this->getDefaultOptions(); + $options['user'] = [ + 'id' => '123', + 'email' => 'user@example.com', + ]; + + $notice = new Notice(new \Exception(), $options); + $xml = $notice->asXml(); + + $this->assertStringContainsString('', $xml); + $this->assertStringContainsString('key="id"', $xml); + $this->assertStringContainsString('123', $xml); + } + + public function testAsXmlWithSessionData(): void + { + $options = $this->getDefaultOptions(); + $options['session_data'] = ['session_id' => 'abc123']; + + $notice = new Notice(new \Exception(), $options); + $xml = $notice->asXml(); + + $this->assertStringContainsString('', $xml); + $this->assertStringContainsString('session_id', $xml); + } + + public function testAsXmlWithCgiData(): void + { + $options = $this->getDefaultOptions(); + $options['cgi_data'] = ['REQUEST_METHOD' => 'POST']; + + $notice = new Notice(new \Exception(), $options); + $xml = $notice->asXml(); + + $this->assertStringContainsString('', $xml); + $this->assertStringContainsString('REQUEST_METHOD', $xml); + } + + public function testAsXmlContainsNotifierInfo(): void + { + $options = $this->getDefaultOptions(); + $notice = new Notice(new \Exception(), $options); + $xml = $notice->asXml(); + + $this->assertStringContainsString('', $xml); + $this->assertStringContainsString('errbit-php', $xml); + $this->assertStringContainsString('', $xml); + $this->assertStringContainsString('https://github.com/emgiezet/errbit-php', $xml); + } + + public function testBacktraceContainsLineElements(): void + { + $exception = new \Exception('Test'); + $options = $this->getDefaultOptions(); + $notice = new Notice($exception, $options); + $xml = $notice->asXml(); + + // Should contain backtrace with line elements + $this->assertStringContainsString('', $xml); + $this->assertStringContainsString('assertStringContainsString('number=', $xml); + $this->assertStringContainsString('file=', $xml); + $this->assertStringContainsString('method=', $xml); } } diff --git a/tests/Unit/Errbit/Tests/Handlers/ErrorHandlersTest.php b/tests/Unit/Errbit/Tests/Handlers/ErrorHandlersTest.php index cd42cb1..54ec185 100644 --- a/tests/Unit/Errbit/Tests/Handlers/ErrorHandlersTest.php +++ b/tests/Unit/Errbit/Tests/Handlers/ErrorHandlersTest.php @@ -1,8 +1,11 @@ '9fa28ccc56ed3aae882d25a9cee5695a', + 'host' => '127.0.0.1', + 'port' => '8080', + 'secure' => false + ]; + $errbit = new Errbit($config); - // $service = m::mock('service'); - // $service->shouldReceive('readTemp')->times(3)->andReturn(10, 12, 14); - - $config = ['api_key'=>'9fa28ccc56ed3aae882d25a9cee5695a', 'host'=>'127.0.0.1', 'port' => '8080', 'secure' => false]; - $errbit= new Errbit($config); - $writerMock = \Mockery::mock(SocketWriter::class); $writerMock->shouldReceive('write'); $errbit->setWriter($writerMock); + + return [$errbit, $writerMock]; + } + + public function testNoticeHandle(): void + { + [$errbit, $writerMock] = $this->createErrbitWithMockedWriter(); $handler = new ErrorHandlers($errbit, ['exception', 'error', 'fatal', 'lol', 'doink']); $errors = [E_NOTICE, E_USER_NOTICE, E_WARNING, E_USER_WARNING, E_ERROR, E_USER_ERROR]; $catched = []; try { foreach ($errors as $error) { - $handler->onError($error, 'Errbit Test: '.$error, __FILE__, 666); + $handler->onError($error, 'Errbit Test: ' . $error, __FILE__, 666); } - } catch ( \Exception $e) { + } catch (\Exception $e) { $catched[] = $e->getMessage(); } - $this->assertTrue(count($catched) === 0, 'Exceptions are thrown during errbit notice'); + $this->assertCount(0, $catched, 'Exceptions are thrown during errbit notice'); + } + + public function testOnErrorCreatesCorrectErrorType(): void + { + $config = [ + 'api_key' => 'test-key', + 'host' => '127.0.0.1', + 'port' => '8080', + 'secure' => false + ]; + $errbit = new Errbit($config); + + $receivedException = null; + $writerMock = \Mockery::mock(SocketWriter::class); + $writerMock->shouldReceive('write')->andReturnUsing(function ($exception, $config) use (&$receivedException) { + $receivedException = $exception; + }); + $errbit->setWriter($writerMock); + + $handler = new ErrorHandlers($errbit, ['error']); + $handler->onError(E_NOTICE, 'Test notice message', '/test/file.php', 42); + + $this->assertInstanceOf(\Errbit\Errors\Notice::class, $receivedException); + $this->assertEquals('Test notice message', $receivedException->getMessage()); + } + + public function testOnExceptionHandlesException(): void + { + $config = [ + 'api_key' => 'test-key', + 'host' => '127.0.0.1', + 'port' => '8080', + 'secure' => false + ]; + $errbit = new Errbit($config); + + $receivedException = null; + $writerMock = \Mockery::mock(SocketWriter::class); + $writerMock->shouldReceive('write')->once()->andReturnUsing(function ($exception, $config) use (&$receivedException) { + $receivedException = $exception; + }); + $errbit->setWriter($writerMock); + + $handler = new ErrorHandlers($errbit, ['exception']); + + $originalException = new \Exception('Test exception message', 123); + $handler->onException($originalException); + + $this->assertNotNull($receivedException); + $this->assertEquals('Test exception message', $receivedException->getMessage()); + } + + public function testRegisterCreatesHandler(): void + { + [$errbit, $writerMock] = $this->createErrbitWithMockedWriter(); + + // This should not throw + ErrorHandlers::register($errbit, []); + + $this->assertTrue(true); + } + + public function testHandlerOnlyRegistersRequestedHandlers(): void + { + [$errbit, $writerMock] = $this->createErrbitWithMockedWriter(); + + // Register only error handler + $handler = new ErrorHandlers($errbit, ['error']); + + // Should be able to call onError without issues + $handler->onError(E_NOTICE, 'Test', __FILE__, 1); + + $this->assertTrue(true); + } + + public function testOnErrorWithDifferentErrorTypes(): void + { + $config = [ + 'api_key' => 'test-key', + 'host' => '127.0.0.1', + 'port' => '8080', + 'secure' => false + ]; + + $testCases = [ + ['code' => E_NOTICE, 'expectedClass' => \Errbit\Errors\Notice::class], + ['code' => E_USER_NOTICE, 'expectedClass' => \Errbit\Errors\Notice::class], + ['code' => E_WARNING, 'expectedClass' => \Errbit\Errors\Warning::class], + ['code' => E_USER_WARNING, 'expectedClass' => \Errbit\Errors\Warning::class], + ['code' => E_USER_ERROR, 'expectedClass' => \Errbit\Errors\Error::class], + ]; + + foreach ($testCases as $testCase) { + $errbit = new Errbit($config); + $receivedException = null; + + $writerMock = \Mockery::mock(SocketWriter::class); + $writerMock->shouldReceive('write')->andReturnUsing(function ($exception, $config) use (&$receivedException) { + $receivedException = $exception; + }); + $errbit->setWriter($writerMock); + + $handler = new ErrorHandlers($errbit, ['error']); + $handler->onError($testCase['code'], 'Test message', '/test.php', 10); + + $this->assertInstanceOf( + $testCase['expectedClass'], + $receivedException, + "Error code {$testCase['code']} should create {$testCase['expectedClass']}" + ); + } } } diff --git a/tests/Unit/Errbit/Tests/Utils/ConverterTest.php b/tests/Unit/Errbit/Tests/Utils/ConverterTest.php index 4fcbe05..b60c405 100644 --- a/tests/Unit/Errbit/Tests/Utils/ConverterTest.php +++ b/tests/Unit/Errbit/Tests/Utils/ConverterTest.php @@ -29,52 +29,56 @@ public function setUp():void public function testNotice() { - $notice = $this->object->convert(E_NOTICE, "TestNotice", "test.php", 8, [""]); - $expected = new Notice("TestNotice", 8, "test.php", [""]); + $prev = new \Exception('prev'); + $notice = $this->object->convert(E_NOTICE, "TestNotice", $prev,"test.php", 8, [""]); + $expected = new Notice("TestNotice", 8, $prev, "test.php", [""]); $this->assertEquals($notice, $expected); } public function testUserNotice() { - - $notice = $this->object->convert(E_USER_NOTICE, "TestNotice", "test.php", 8, [""]); - $expected = new Notice("TestNotice", 8, "test.php", [""]); + $prev = new \Exception('prev'); + $notice = $this->object->convert(E_USER_NOTICE, "TestNotice", $prev, "test.php", 8, [""]); + $expected = new Notice("TestNotice", 8, $prev, "test.php", [""]); $this->assertEquals($notice, $expected); } public function testFatalError() { - $fatal = $this->object->convert(E_ERROR, "TestError", "test.php", 8, [""]); - $expected = new Fatal("TestError", 8, "test.php"); + $prev = new \Exception('prev'); + $fatal = $this->object->convert(E_ERROR, "TestError", $prev, "test.php", 8, [""]); + $expected = new Fatal("TestError", 8, $prev, "test.php"); $this->assertEquals($fatal, $expected); } public function testCatchableFatalError() { - $notice = $this->object->convert(E_RECOVERABLE_ERROR, "TestError", "test.php", 8, [""]); - $expected = new Fatal("TestError", 8, "test.php"); + $prev = new \Exception('prev'); + $notice = $this->object->convert(E_RECOVERABLE_ERROR, "TestError", $prev,"test.php", 8, [""]); + $expected = new Fatal("TestError", 8, $prev, "test.php"); $this->assertEquals($notice, $expected); } public function testUserError() { - $notice = $this->object->convert(E_USER_ERROR, "TestError", "test.php", 8, [""]); - $expected = new Error("TestError", 8, "test.php", [""]); + + $notice = $this->object->convert(E_USER_ERROR, "TestError", null, "test.php", 8, [""]); + $expected = new Error("TestError", 8, null, "test.php", [""]); $this->assertEquals($notice, $expected); } public function testWarning() { - $notice = $this->object->convert(E_WARNING, "TestWarning", "test.php", 8, [""]); - $expected = new Warning("TestWarning", 8, "test.php", [""]); + $notice = $this->object->convert(E_WARNING, "TestWarning", null, "test.php", 8, [""]); + $expected = new Warning("TestWarning", 8, null, "test.php", [""]); $this->assertEquals($notice, $expected); } public function testUserWarning() { - $notice = $this->object->convert(E_USER_WARNING, "TestWarning", "test.php", 8, [""]); - $expected = new Warning("TestWarning", 8, "test.php", [""]); + $notice = $this->object->convert(E_USER_WARNING, "TestWarning", null, "test.php", 8, [""]); + $expected = new Warning("TestWarning", 8, null, "test.php", [""]); $this->assertEquals($notice, $expected); } } diff --git a/tests/Unit/Errbit/Tests/Utils/XmlBuilderTest.php b/tests/Unit/Errbit/Tests/Utils/XmlBuilderTest.php index d28c43b..ace072c 100644 --- a/tests/Unit/Errbit/Tests/Utils/XmlBuilderTest.php +++ b/tests/Unit/Errbit/Tests/Utils/XmlBuilderTest.php @@ -1,36 +1,41 @@ config = ['api_key'=>'9fa28ccc56ed3aae882d25a9cee5695a', 'host' => 'errbit.redexperts.net', 'port' => '80', 'secure' => '443', 'project_root' => 'test', 'environment_name' => 'test', 'url' => 'test', 'controller' => 'test', 'action' => 'test', 'session_data' => ['test'], 'parameters' => ['test', 'doink'], 'cgi_data' => ['test'], 'params_filters' => ['test'=>'/test/'], 'backtrace_filters' => 'test']; + $this->config = [ + 'api_key' => '9fa28ccc56ed3aae882d25a9cee5695a', + 'host' => 'errbit.redexperts.net', + 'port' => '80', + 'secure' => '443', + 'project_root' => 'test', + 'environment_name' => 'test', + 'url' => 'test', + 'controller' => 'test', + 'action' => 'test', + 'session_data' => ['test'], + 'parameters' => ['test', 'doink'], + 'cgi_data' => ['test'], + 'params_filters' => ['test' => '/test/'], + 'backtrace_filters' => 'test' + ]; } - - /** - * @return void - */ - public function testBase() - { + public function testBase(): void + { $notice = new Notice(new \Exception(), $this->config); $xml = $notice->asXml(); @@ -38,17 +43,12 @@ public function testBase() $dom = new \DOMDocument(); $dom->loadXML($xml); - $valid = $dom->schemaValidate(__DIR__.'/../../../../../Resources/xsd/hoptoad_2_0.xsd'); + $valid = $dom->schemaValidate(__DIR__ . '/../../../../../Resources/xsd/hoptoad_2_0.xsd'); $this->assertTrue($valid, 'Not Valid XSD'); - } - - /** - * @return void - */ - public function testShouldNotFollowRecursion() - { + public function testShouldNotFollowRecursion(): void + { $foo = new \StdClass; $bar = new \StdClass; $foo->bar = $bar; @@ -64,14 +64,11 @@ public function testShouldNotFollowRecursion() $dom = new \DOMDocument(); $dom->loadXML($xml); - $valid = $dom->schemaValidate(__DIR__.'/../../../../../Resources/xsd/XSD.xml'); + $valid = $dom->schemaValidate(__DIR__ . '/../../../../../Resources/xsd/XSD.xml'); $this->assertTrue($valid, 'Not Valid XSD'); } - - /** - * @return void - */ - public function testSimpleObjectInXml() + + public function testSimpleObjectInXml(): void { $foo = new \StdClass; @@ -88,7 +85,191 @@ public function testSimpleObjectInXml() $dom = new \DOMDocument(); $dom->loadXML($xml); - $valid = $dom->schemaValidate(__DIR__.'/../../../../../Resources/xsd/XSD.xml'); + $valid = $dom->schemaValidate(__DIR__ . '/../../../../../Resources/xsd/XSD.xml'); $this->assertTrue($valid, 'Not Valid XSD'); } + + // XmlBuilder specific tests + + public function testTagCreatesElement(): void + { + $builder = new XmlBuilder(); + $builder->tag('test', 'value'); + + $xml = $builder->asXml(); + + $this->assertStringContainsString('value', $xml); + } + + public function testTagWithAttributes(): void + { + $builder = new XmlBuilder(); + $builder->tag('test', 'value', ['attr1' => 'val1', 'attr2' => 'val2']); + + $xml = $builder->asXml(); + + $this->assertStringContainsString('attr1="val1"', $xml); + $this->assertStringContainsString('attr2="val2"', $xml); + } + + public function testTagWithCallback(): void + { + $builder = new XmlBuilder(); + $builder->tag('parent', '', [], function ($child) { + $child->tag('child', 'child value'); + }); + + $xml = $builder->asXml(); + + $this->assertStringContainsString('', $xml); + $this->assertStringContainsString('child value', $xml); + } + + public function testNestedTags(): void + { + $builder = new XmlBuilder(); + $builder->tag('root', '', [], function ($root) { + $root->tag('level1', '', [], function ($level1) { + $level1->tag('level2', 'deep value'); + }); + }); + + $xml = $builder->asXml(); + + $this->assertStringContainsString('deep value', $xml); + } + + public function testMultipleSiblingTags(): void + { + $builder = new XmlBuilder(); + $builder->tag('items', '', [], function ($items) { + $items->tag('item', 'first'); + $items->tag('item', 'second'); + $items->tag('item', 'third'); + }); + + $xml = $builder->asXml(); + + $this->assertStringContainsString('first', $xml); + $this->assertStringContainsString('second', $xml); + $this->assertStringContainsString('third', $xml); + } + + public function testAttributeMethod(): void + { + $builder = new XmlBuilder(); + $node = $builder->tag('test', 'value'); + $node->attribute('custom', 'attr-value'); + + $xml = $builder->asXml(); + + $this->assertStringContainsString('custom="attr-value"', $xml); + } + + public function testTagWithObjectValueConvertsToClassName(): void + { + $builder = new XmlBuilder(); + $object = new \stdClass(); + $builder->tag('test', $object); + + $xml = $builder->asXml(); + + $this->assertStringContainsString('[stdClass]', $xml); + } + + public function testTagWithIntegerValue(): void + { + $builder = new XmlBuilder(); + $builder->tag('number', 42); + + $xml = $builder->asXml(); + + $this->assertStringContainsString('42', $xml); + } + + public function testUtf8ForXmlRemovesInvalidCharacters(): void + { + $invalidString = "Valid text\x00\x01\x02Invalid"; + + $result = XmlBuilder::utf8ForXML($invalidString); + + $this->assertStringNotContainsString("\x00", $result); + $this->assertStringNotContainsString("\x01", $result); + $this->assertStringNotContainsString("\x02", $result); + $this->assertStringContainsString('Valid text', $result); + } + + public function testUtf8ForXmlKeepsValidCharacters(): void + { + $validString = "Hello World\nNew Line\tTab"; + + $result = XmlBuilder::utf8ForXML($validString); + + $this->assertEquals($validString, $result); + } + + public function testAsXmlReturnsString(): void + { + $builder = new XmlBuilder(); + $builder->tag('test', 'value'); + + $xml = $builder->asXml(); + + $this->assertIsString($xml); + $this->assertStringStartsWith('tag('test', 'value'); + + $this->assertInstanceOf(XmlBuilder::class, $result); + } + + public function testAttributeReturnsStaticForChaining(): void + { + $builder = new XmlBuilder(); + $node = $builder->tag('test', 'value'); + $result = $node->attribute('attr', 'value'); + + $this->assertInstanceOf(XmlBuilder::class, $result); + } + + public function testComplexXmlStructure(): void + { + $builder = new XmlBuilder(); + $builder->tag('notice', '', ['version' => '2.2'], function ($notice) { + $notice->tag('api-key', 'test-api-key'); + $notice->tag('notifier', '', [], function ($notifier) { + $notifier->tag('name', 'errbit-php'); + $notifier->tag('version', '2.0.1'); + }); + $notice->tag('error', '', [], function ($error) { + $error->tag('class', 'TestError'); + $error->tag('message', 'Test error message'); + }); + }); + + $xml = $builder->asXml(); + + $this->assertStringContainsString('version="2.2"', $xml); + $this->assertStringContainsString('test-api-key', $xml); + $this->assertStringContainsString('errbit-php', $xml); + $this->assertStringContainsString('TestError', $xml); + } + + public function testTagWithMultipleChildren(): void + { + $builder = new XmlBuilder(); + $builder->tag('parent', '', [], function ($parent) { + $parent->tag('child', 'first'); + $parent->tag('child', 'second'); + }); + + $xml = $builder->asXml(); + + $this->assertStringContainsString('first', $xml); + $this->assertStringContainsString('second', $xml); + } } diff --git a/tests/Unit/Errbit/Tests/Writer/GuzzleWriterTest.php b/tests/Unit/Errbit/Tests/Writer/GuzzleWriterTest.php index 17d6701..c47b35e 100644 --- a/tests/Unit/Errbit/Tests/Writer/GuzzleWriterTest.php +++ b/tests/Unit/Errbit/Tests/Writer/GuzzleWriterTest.php @@ -1,32 +1,51 @@ 'test-api-key', + 'host' => '127.0.0.1', + 'port' => '8080', + 'secure' => false, + 'async' => false, + 'connect_timeout' => 3, + 'write_timeout' => 3, + 'project_root' => '/app', + 'environment_name' => 'test', + 'params_filters' => [], + 'backtrace_filters' => [], + ]; + } + /** * @dataProvider dataProviderTestWrite - * */ - public function testWrite( int $error ): void + public function testWrite(int $error): void { $config = [ - 'host'=>'127.0.0.1', - 'port'=>'8080', - 'secure'=>false, - 'api_key'=>'fa7619c7bfe2b9725992a495eea61f0f' + 'host' => '127.0.0.1', + 'port' => '8080', + 'secure' => false, + 'api_key' => 'fa7619c7bfe2b9725992a495eea61f0f' ]; - $errbit= new Errbit($config); + $errbit = new Errbit($config); $clientMock = \Mockery::mock(Client::class); $clientMock->shouldReceive('post')->once(); $writer = new GuzzleWriter($clientMock); @@ -34,14 +53,13 @@ public function testWrite( int $error ): void $handler = new \Errbit\Handlers\ErrorHandlers($errbit, ['exception', 'error', ['fatal', 'lol', 'doink']]); $catched = []; try { - $handler->onError($error, 'Errbit Test: '.$error, __FILE__, 666); - } catch ( \Exception $e) { + $handler->onError($error, 'Errbit Test: ' . $error, __FILE__, 666); + } catch (\Exception $e) { $catched[] = $e->getMessage(); } - $this->assertEmpty($catched, 'Exceptions are thrown during errbit notice: '.print_r($catched,1)); - + $this->assertEmpty($catched, 'Exceptions are thrown during errbit notice: ' . print_r($catched, true)); } - + public function dataProviderTestWrite(): array { return [ @@ -53,4 +71,94 @@ public function dataProviderTestWrite(): array [E_USER_ERROR] ]; } + + public function testAsyncWrite(): void + { + $config = $this->getDefaultConfig(); + $config['async'] = true; + + $exception = new Notice('Test notice', 10, null, '/test.php', []); + + $promiseMock = \Mockery::mock(PromiseInterface::class); + $clientMock = \Mockery::mock(Client::class); + $clientMock->shouldReceive('postAsync') + ->once() + ->andReturn($promiseMock); + + $writer = new GuzzleWriter($clientMock); + $result = $writer->write($exception, $config); + + $this->assertInstanceOf(PromiseInterface::class, $result); + } + + public function testSynchronousWrite(): void + { + $config = $this->getDefaultConfig(); + $config['async'] = false; + + $exception = new Notice('Test notice', 10, null, '/test.php', []); + + $responseMock = \Mockery::mock(ResponseInterface::class); + $clientMock = \Mockery::mock(Client::class); + $clientMock->shouldReceive('post') + ->once() + ->andReturn($responseMock); + + $writer = new GuzzleWriter($clientMock); + $result = $writer->write($exception, $config); + + $this->assertInstanceOf(ResponseInterface::class, $result); + } + + public function testBuildConnectionSchemeHttp(): void + { + $clientMock = \Mockery::mock(Client::class); + $writer = new GuzzleWriter($clientMock); + + $config = $this->getDefaultConfig(); + $config['secure'] = false; + + $reflection = new \ReflectionClass($writer); + $method = $reflection->getMethod('buildConnectionScheme'); + $method->setAccessible(true); + + $result = $method->invoke($writer, $config); + + $this->assertEquals('http://127.0.0.1:8080', $result); + } + + public function testBuildConnectionSchemeHttps(): void + { + $clientMock = \Mockery::mock(Client::class); + $writer = new GuzzleWriter($clientMock); + + $config = $this->getDefaultConfig(); + $config['secure'] = true; + + $reflection = new \ReflectionClass($writer); + $method = $reflection->getMethod('buildConnectionScheme'); + $method->setAccessible(true); + + $result = $method->invoke($writer, $config); + + $this->assertEquals('https://127.0.0.1:8080', $result); + } + + public function testBuildConnectionSchemeWithoutPort(): void + { + $clientMock = \Mockery::mock(Client::class); + $writer = new GuzzleWriter($clientMock); + + $config = $this->getDefaultConfig(); + $config['secure'] = true; + unset($config['port']); + + $reflection = new \ReflectionClass($writer); + $method = $reflection->getMethod('buildConnectionScheme'); + $method->setAccessible(true); + + $result = $method->invoke($writer, $config); + + $this->assertEquals('https://127.0.0.1', $result); + } } diff --git a/tests/Unit/Errbit/Tests/Writer/SocketWriterTest.php b/tests/Unit/Errbit/Tests/Writer/SocketWriterTest.php new file mode 100644 index 0000000..83ac57e --- /dev/null +++ b/tests/Unit/Errbit/Tests/Writer/SocketWriterTest.php @@ -0,0 +1,168 @@ + 'test-api-key', + 'host' => '127.0.0.1', + 'port' => 8080, + 'secure' => false, + 'async' => false, + 'connect_timeout' => 1, + 'write_timeout' => 1, + 'agent' => 'errbitPHP', + 'project_root' => '/app', + 'environment_name' => 'test', + 'params_filters' => [], + 'backtrace_filters' => [], + ]; + } + + public function testCharactersToReadDefaultValue(): void + { + $writer = new SocketWriter(); + $this->assertFalse($writer->charactersToRead); + } + + public function testCharactersToReadCanBeSet(): void + { + $writer = new SocketWriter(); + $writer->charactersToRead = 1024; + $this->assertEquals(1024, $writer->charactersToRead); + } + + public function testBuildPayloadReturnsString(): void + { + $writer = new SocketWriter(); + $config = $this->getDefaultConfig(); + $exception = new Notice('Test notice', 10, null, '/test/file.php', []); + + $reflection = new \ReflectionClass($writer); + $method = $reflection->getMethod('buildPayload'); + $method->setAccessible(true); + + $result = $method->invoke($writer, $exception, $config); + + $this->assertIsString($result); + $this->assertStringContainsString('POST /notifier_api/v2/notices/ HTTP/1.1', $result); + $this->assertStringContainsString('Host: 127.0.0.1', $result); + $this->assertStringContainsString('Content-Type: text/xml', $result); + } + + public function testBuildPayloadAsyncModeNoHeaders(): void + { + $writer = new SocketWriter(); + $config = $this->getDefaultConfig(); + $config['async'] = true; + $exception = new Notice('Test notice', 10, null, '/test/file.php', []); + + $reflection = new \ReflectionClass($writer); + $method = $reflection->getMethod('buildPayload'); + $method->setAccessible(true); + + $result = $method->invoke($writer, $exception, $config); + + $this->assertIsString($result); + // In async mode, no HTTP headers should be added + $this->assertStringNotContainsString('POST /notifier_api/v2/notices/ HTTP/1.1', $result); + // XML notice content should be present + $this->assertStringContainsString('', $result); + } + + public function testBuildConnectionSchemeTcp(): void + { + $writer = new SocketWriter(); + $config = $this->getDefaultConfig(); + $config['secure'] = false; + $config['async'] = false; + + $reflection = new \ReflectionClass($writer); + $method = $reflection->getMethod('buildConnectionScheme'); + $method->setAccessible(true); + + $result = $method->invoke($writer, $config); + + $this->assertEquals('tcp://127.0.0.1', $result); + } + + public function testBuildConnectionSchemeSsl(): void + { + $writer = new SocketWriter(); + $config = $this->getDefaultConfig(); + $config['secure'] = true; + $config['async'] = false; + + $reflection = new \ReflectionClass($writer); + $method = $reflection->getMethod('buildConnectionScheme'); + $method->setAccessible(true); + + $result = $method->invoke($writer, $config); + + $this->assertEquals('ssl://127.0.0.1', $result); + } + + public function testBuildConnectionSchemeUdp(): void + { + $writer = new SocketWriter(); + $config = $this->getDefaultConfig(); + $config['async'] = true; + + $reflection = new \ReflectionClass($writer); + $method = $reflection->getMethod('buildConnectionScheme'); + $method->setAccessible(true); + + $result = $method->invoke($writer, $config); + + $this->assertEquals('udp://127.0.0.1', $result); + } + + public function testAddHttpHeadersIfNeededSync(): void + { + $writer = new SocketWriter(); + $config = $this->getDefaultConfig(); + $config['async'] = false; + $body = 'test'; + + $reflection = new \ReflectionClass($writer); + $method = $reflection->getMethod('addHttpHeadersIfNeeded'); + $method->setAccessible(true); + + $result = $method->invoke($writer, $body, $config); + + $this->assertStringContainsString('POST /notifier_api/v2/notices/ HTTP/1.1', $result); + $this->assertStringContainsString('Host: 127.0.0.1', $result); + $this->assertStringContainsString('Content-Type: text/xml', $result); + $this->assertStringContainsString('Connection: close', $result); + $this->assertStringContainsString($body, $result); + } + + public function testAddHttpHeadersIfNeededAsync(): void + { + $writer = new SocketWriter(); + $config = $this->getDefaultConfig(); + $config['async'] = true; + $body = 'test'; + + $reflection = new \ReflectionClass($writer); + $method = $reflection->getMethod('addHttpHeadersIfNeeded'); + $method->setAccessible(true); + + $result = $method->invoke($writer, $body, $config); + + // In async mode, body is returned as-is without headers + $this->assertEquals($body, $result); + } +}