diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 49d03346..4a594055 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -78,9 +78,10 @@ jobs: uses: docker/build-push-action@v2 with: context: ./ - file: ./Dockerfile + file: ./docker/Dockerfile push: true build-args: APP_VERSION=${{ steps.previoustag.outputs.tag }} + FRONTEND_IMAGE_TAG=latest tags: ${{ secrets.DOCKER_HUB_USERNAME }}/buggregator:latest, ${{ secrets.DOCKER_HUB_USERNAME }}/buggregator:${{ steps.previoustag.outputs.tag }} diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml new file mode 100644 index 00000000..075f8fe3 --- /dev/null +++ b/.github/workflows/phpunit.yml @@ -0,0 +1,16 @@ +on: + pull_request: null + +name: phpunit + +jobs: + phpunit: + uses: spiral/gh-actions/.github/workflows/phpunit.yml@master + with: + install_protoc: true + os: >- + ['ubuntu-latest'] + php: >- + ['8.1', '8.2'] + stability: >- + ['prefer-stable'] diff --git a/.gitignore b/.gitignore index 2912f5a0..d408eab6 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,6 @@ rr* protoc-gen-php-grpc* .env .phpunit.result.cache +.php-cs-fixer.cache .deptrac.cache composer.lock diff --git a/.rr.yaml b/.rr.yaml index 822e037d..26550918 100644 --- a/.rr.yaml +++ b/.rr.yaml @@ -1,4 +1,4 @@ -version: '2.7' +version: '3' rpc: listen: tcp://127.0.0.1:6001 diff --git a/README.md b/README.md index a18fd218..4ec3b5f4 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![Twitter](https://img.shields.io/badge/twitter-Follow-blue)](https://twitter.com/buggregator) [![Support me on Patreon](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Fshieldsio-patreon.vercel.app%2Fapi%3Fusername%3Dbutschster%26type%3Dpatrons&style=flat)](https://patreon.com/butschster) +[![phpunit](https://github.com/buggregator/server/actions/workflows/phpunit.yml/badge.svg)](https://github.com/buggregator/server/actions/workflows/phpunit.yml) **Buggregator is a lightweight, standalone server that offers a range of debugging features for PHP applications. Think of it as a Swiss Army knife for developers. What makes it special is that it offers a range of features that you would usually find in various paid tools, but it's available for free.** diff --git a/app/config/broadcasting.php b/app/config/broadcasting.php index 1ba0f0a7..745a477d 100644 --- a/app/config/broadcasting.php +++ b/app/config/broadcasting.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Application\Broadcasting\InMemoryDriver; use Spiral\Broadcasting\Driver\NullBroadcast; return [ @@ -14,5 +15,8 @@ 'null' => [ 'driver' => NullBroadcast::class, ], + 'in-memory' => [ + 'driver' => InMemoryDriver::class, + ], ], ]; diff --git a/app/config/cache.php b/app/config/cache.php index 14cd6e06..2647bce4 100644 --- a/app/config/cache.php +++ b/app/config/cache.php @@ -3,18 +3,22 @@ declare(strict_types=1); use Spiral\Cache\Storage\ArrayStorage; -use Spiral\Cache\Storage\FileStorage; + +$defaultStorage = env('CACHE_DEFAULT_STORAGE', 'roadrunner'); return [ - 'default' => env('CACHE_STORAGE', 'local'), + 'default' => env('CACHE_STORAGE', 'roadrunner'), + 'aliases' => [ + 'events' => ['storage' => $defaultStorage, 'prefix' => 'events:'], + 'local' => ['storage' => $defaultStorage, 'prefix' => 'local:'], + ], 'storages' => [ - 'local' => [ - 'type' => 'roadrunner', - 'driver' => 'local', + 'array' => [ + 'type' => ArrayStorage::class, ], - 'events' => [ + 'roadrunner' => [ 'type' => 'roadrunner', - 'driver' => 'events', + 'driver' => 'local', ], ], ]; diff --git a/app/migrations/20221204.005409_0_0_default_create_events.php b/app/migrations/20221204.005409_0_0_default_create_events.php index 99af059b..5450b2fe 100644 --- a/app/migrations/20221204.005409_0_0_default_create_events.php +++ b/app/migrations/20221204.005409_0_0_default_create_events.php @@ -16,7 +16,7 @@ public function up(): void ->addColumn('uuid', 'string', ['nullable' => false, 'default' => null]) ->addColumn('type', 'string', ['nullable' => false, 'default' => null]) ->addColumn('payload', 'longText', ['nullable' => false, 'default' => null]) - ->addColumn('date', 'datetime', ['nullable' => false, 'default' => null]) + ->addColumn('timestamp', 'float', ['nullable' => false, 'default' => null]) ->addColumn('project_id', 'integer', ['nullable' => true, 'default' => null]) ->setPrimaryKeys(['uuid']) ->create(); diff --git a/app/modules/Events/Domain/Event.php b/app/modules/Events/Domain/Event.php index 00bbdc7c..64be4f07 100644 --- a/app/modules/Events/Domain/Event.php +++ b/app/modules/Events/Domain/Event.php @@ -26,8 +26,8 @@ public function __construct( #[Column(type: 'longText', typecast: 'json')] private Json $payload, - #[Column(type: 'datetime')] - private DateTimeImmutable $date, + #[Column(type: 'float')] + private float $timestamp, #[Column(type: 'integer', nullable: true)] private ?int $projectId, @@ -49,9 +49,9 @@ public function getPayload(): Json return $this->payload; } - public function getDate(): DateTimeImmutable + public function getTimestamp(): float { - return $this->date; + return $this->timestamp; } public function getProjectId(): ?int diff --git a/app/modules/Events/Domain/EventRepositoryInterface.php b/app/modules/Events/Domain/EventRepositoryInterface.php index 71bf096a..35ebb9de 100644 --- a/app/modules/Events/Domain/EventRepositoryInterface.php +++ b/app/modules/Events/Domain/EventRepositoryInterface.php @@ -7,7 +7,7 @@ use Cycle\ORM\RepositoryInterface; /** - * @template TEntity of Event + * @extends RepositoryInterface */ interface EventRepositoryInterface extends RepositoryInterface { diff --git a/app/modules/Events/Domain/Events/EventWasReceived.php b/app/modules/Events/Domain/Events/EventWasReceived.php index bce5e30f..1e56f60e 100644 --- a/app/modules/Events/Domain/Events/EventWasReceived.php +++ b/app/modules/Events/Domain/Events/EventWasReceived.php @@ -14,7 +14,7 @@ public function __construct( public readonly Uuid $uuid, public readonly string $type, public readonly array $payload, - public readonly int $timestamp, + public readonly float $timestamp, public readonly ?int $projectId = null, ) { } diff --git a/app/modules/Events/Application/Commands/ClearEventsHandler.php b/app/modules/Events/Interfaces/Commands/ClearEventsHandler.php similarity index 85% rename from app/modules/Events/Application/Commands/ClearEventsHandler.php rename to app/modules/Events/Interfaces/Commands/ClearEventsHandler.php index 379a3443..02bc36af 100644 --- a/app/modules/Events/Application/Commands/ClearEventsHandler.php +++ b/app/modules/Events/Interfaces/Commands/ClearEventsHandler.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Modules\Events\Application\Commands; +namespace Modules\Events\Interfaces\Commands; use App\Application\Commands\ClearEvents; use Modules\Events\Domain\EventRepositoryInterface; @@ -14,7 +14,7 @@ final class ClearEventsHandler { public function __construct( private readonly EventRepositoryInterface $events, - private readonly EventDispatcherInterface $dispatcher + private readonly EventDispatcherInterface $dispatcher, ) { } diff --git a/app/modules/Events/Application/Commands/DeleteEventHandler.php b/app/modules/Events/Interfaces/Commands/DeleteEventHandler.php similarity index 93% rename from app/modules/Events/Application/Commands/DeleteEventHandler.php rename to app/modules/Events/Interfaces/Commands/DeleteEventHandler.php index 30ce6b3c..d16a70c6 100644 --- a/app/modules/Events/Application/Commands/DeleteEventHandler.php +++ b/app/modules/Events/Interfaces/Commands/DeleteEventHandler.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Modules\Events\Application\Commands; +namespace Modules\Events\Interfaces\Commands; use App\Application\Commands\DeleteEvent; use Modules\Events\Domain\EventRepositoryInterface; diff --git a/app/modules/Events/Application/Commands/StoreEventHandler.php b/app/modules/Events/Interfaces/Commands/StoreEventHandler.php similarity index 86% rename from app/modules/Events/Application/Commands/StoreEventHandler.php rename to app/modules/Events/Interfaces/Commands/StoreEventHandler.php index 820a9a42..62767d3d 100644 --- a/app/modules/Events/Application/Commands/StoreEventHandler.php +++ b/app/modules/Events/Interfaces/Commands/StoreEventHandler.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Modules\Events\Application\Commands; +namespace Modules\Events\Interfaces\Commands; use App\Application\Commands\FindProjectByName; use App\Application\Commands\HandleReceivedEvent; @@ -20,7 +20,7 @@ final class StoreEventHandler public function __construct( private readonly EventDispatcherInterface $dispatcher, private readonly EventRepositoryInterface $events, - private readonly QueryBusInterface $queryBus + private readonly QueryBusInterface $queryBus, ) { } @@ -37,9 +37,9 @@ public function handle(HandleReceivedEvent $command): void $command->uuid, $command->type, new Json($command->payload), - Carbon::createFromTimestamp($command->timestamp)->toDateTimeImmutable(), + $command->timestamp, $projectId, - ) + ), ); $this->dispatcher->dispatch( @@ -49,7 +49,7 @@ public function handle(HandleReceivedEvent $command): void payload: $command->payload, timestamp: $command->timestamp, projectId: $projectId, - ) + ), ); } } diff --git a/app/modules/Events/Interfaces/Http/Controllers/ClearEventsAction.php b/app/modules/Events/Interfaces/Http/Controllers/ClearAction.php similarity index 64% rename from app/modules/Events/Interfaces/Http/Controllers/ClearEventsAction.php rename to app/modules/Events/Interfaces/Http/Controllers/ClearAction.php index 6380231c..ac3b4b91 100644 --- a/app/modules/Events/Interfaces/Http/Controllers/ClearEventsAction.php +++ b/app/modules/Events/Interfaces/Http/Controllers/ClearAction.php @@ -5,17 +5,21 @@ namespace Modules\Events\Interfaces\Http\Controllers; use App\Application\Commands\ClearEvents; +use App\Application\HTTP\Response\ResourceInterface; +use App\Application\HTTP\Response\SuccessResource; use Modules\Events\Interfaces\Http\Request\ClearEventsRequest; use Spiral\Cqrs\CommandBusInterface; use Spiral\Router\Annotation\Route; -final class ClearEventsAction +final class ClearAction { #[Route(route: 'events', name: 'events.clear', methods: 'DELETE', group: 'api')] - public function __invoke(ClearEventsRequest $request, CommandBusInterface $bus): void + public function __invoke(ClearEventsRequest $request, CommandBusInterface $bus): ResourceInterface { $bus->dispatch( - new ClearEvents(type: $request->type) + new ClearEvents(type: $request->type), ); + + return new SuccessResource(); } } diff --git a/app/modules/Events/Interfaces/Http/Controllers/DeleteEventAction.php b/app/modules/Events/Interfaces/Http/Controllers/DeleteAction.php similarity index 66% rename from app/modules/Events/Interfaces/Http/Controllers/DeleteEventAction.php rename to app/modules/Events/Interfaces/Http/Controllers/DeleteAction.php index ea696049..3c72df1c 100644 --- a/app/modules/Events/Interfaces/Http/Controllers/DeleteEventAction.php +++ b/app/modules/Events/Interfaces/Http/Controllers/DeleteAction.php @@ -6,16 +6,20 @@ use App\Application\Commands\DeleteEvent; use App\Application\Domain\ValueObjects\Uuid; +use App\Application\HTTP\Response\ResourceInterface; +use App\Application\HTTP\Response\SuccessResource; use Spiral\Cqrs\CommandBusInterface; use Spiral\Router\Annotation\Route; -final class DeleteEventAction +final class DeleteAction { #[Route(route: 'event/', name: 'event.delete', methods: 'DELETE', group: 'api')] - public function __invoke(CommandBusInterface $bus, Uuid $uuid): void + public function __invoke(CommandBusInterface $bus, Uuid $uuid): ResourceInterface { $bus->dispatch( - new DeleteEvent($uuid) + new DeleteEvent($uuid), ); + + return new SuccessResource(); } } diff --git a/app/modules/Events/Interfaces/Http/Controllers/ListAction.php b/app/modules/Events/Interfaces/Http/Controllers/ListAction.php index d5954244..57427357 100644 --- a/app/modules/Events/Interfaces/Http/Controllers/ListAction.php +++ b/app/modules/Events/Interfaces/Http/Controllers/ListAction.php @@ -6,18 +6,19 @@ use App\Application\Commands\FindEvents; use Modules\Events\Interfaces\Http\Request\EventsRequest; +use Modules\Events\Interfaces\Http\Resources\EventCollection; use Spiral\Cqrs\QueryBusInterface; use Spiral\Router\Annotation\Route; -class ListAction +final class ListAction { #[Route(route: 'events', name: 'events.list', methods: 'GET', group: 'api')] public function __invoke(EventsRequest $request, QueryBusInterface $bus): EventCollection { return new EventCollection( $bus->ask( - new FindEvents(type: $request->type) - ) + new FindEvents(type: $request->type), + ), ); } } diff --git a/app/modules/Events/Interfaces/Http/Controllers/ShowAction.php b/app/modules/Events/Interfaces/Http/Controllers/ShowAction.php index cd822d4e..6a53ecca 100644 --- a/app/modules/Events/Interfaces/Http/Controllers/ShowAction.php +++ b/app/modules/Events/Interfaces/Http/Controllers/ShowAction.php @@ -7,6 +7,7 @@ use App\Application\Commands\FindEventByUuid; use App\Application\Domain\ValueObjects\Uuid; use App\Application\Exception\EntityNotFoundException; +use Modules\Events\Interfaces\Http\Resources\EventResource; use Spiral\Cqrs\QueryBusInterface; use Spiral\Http\Exception\ClientException\NotFoundException; use Spiral\Router\Annotation\Route; diff --git a/app/modules/Events/Interfaces/Http/Request/ClearEventsRequest.php b/app/modules/Events/Interfaces/Http/Request/ClearEventsRequest.php index ba32315d..5b229290 100644 --- a/app/modules/Events/Interfaces/Http/Request/ClearEventsRequest.php +++ b/app/modules/Events/Interfaces/Http/Request/ClearEventsRequest.php @@ -4,7 +4,7 @@ namespace Modules\Events\Interfaces\Http\Request; -use Spiral\Filters\Attribute\Input\Post; +use Spiral\Filters\Attribute\Input\Data; use Spiral\Filters\Model\Filter; use Spiral\Filters\Model\FilterDefinitionInterface; use Spiral\Filters\Model\HasFilterDefinition; @@ -12,7 +12,7 @@ final class ClearEventsRequest extends Filter implements HasFilterDefinition { - #[Post] + #[Data] public ?string $type = null; public function filterDefinition(): FilterDefinitionInterface diff --git a/app/modules/Events/Interfaces/Http/Controllers/EventCollection.php b/app/modules/Events/Interfaces/Http/Resources/EventCollection.php similarity index 83% rename from app/modules/Events/Interfaces/Http/Controllers/EventCollection.php rename to app/modules/Events/Interfaces/Http/Resources/EventCollection.php index aead576a..56884fb0 100644 --- a/app/modules/Events/Interfaces/Http/Controllers/EventCollection.php +++ b/app/modules/Events/Interfaces/Http/Resources/EventCollection.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Modules\Events\Interfaces\Http\Controllers; +namespace Modules\Events\Interfaces\Http\Resources; use App\Application\HTTP\Response\ResourceCollection; diff --git a/app/modules/Events/Interfaces/Http/Controllers/EventResource.php b/app/modules/Events/Interfaces/Http/Resources/EventResource.php similarity index 68% rename from app/modules/Events/Interfaces/Http/Controllers/EventResource.php rename to app/modules/Events/Interfaces/Http/Resources/EventResource.php index ac26ee2f..634e137d 100644 --- a/app/modules/Events/Interfaces/Http/Controllers/EventResource.php +++ b/app/modules/Events/Interfaces/Http/Resources/EventResource.php @@ -2,11 +2,10 @@ declare(strict_types=1); -namespace Modules\Events\Interfaces\Http\Controllers; +namespace Modules\Events\Interfaces\Http\Resources; use App\Application\HTTP\Response\JsonResource; use Modules\Events\Domain\Event; -use Psr\Http\Message\ServerRequestInterface; /** * @property-read Event $data @@ -18,13 +17,13 @@ public function __construct(Event $data) parent::__construct($data); } - protected function mapData(ServerRequestInterface $request): array|\JsonSerializable + protected function mapData(): array|\JsonSerializable { return [ 'uuid' => (string)$this->data->getUuid(), 'type' => $this->data->getType(), 'payload' => $this->data->getPayload(), - 'timestamp' => $this->data->getDate()->getTimestamp(), + 'timestamp' => $this->data->getTimestamp(), 'project_id' => $this->data->getProjectId(), ]; } diff --git a/app/modules/Events/Application/Queries/CountEventsHandler.php b/app/modules/Events/Interfaces/Queries/CountEventsHandler.php similarity index 91% rename from app/modules/Events/Application/Queries/CountEventsHandler.php rename to app/modules/Events/Interfaces/Queries/CountEventsHandler.php index 946af6f2..212e70d3 100644 --- a/app/modules/Events/Application/Queries/CountEventsHandler.php +++ b/app/modules/Events/Interfaces/Queries/CountEventsHandler.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Modules\Events\Application\Queries; +namespace Modules\Events\Interfaces\Queries; use App\Application\Commands\CountEvents; use Modules\Events\Domain\EventRepositoryInterface; diff --git a/app/modules/Events/Application/Queries/EventsHandler.php b/app/modules/Events/Interfaces/Queries/EventsHandler.php similarity index 89% rename from app/modules/Events/Application/Queries/EventsHandler.php rename to app/modules/Events/Interfaces/Queries/EventsHandler.php index 5a1de0c8..9c9dde40 100644 --- a/app/modules/Events/Application/Queries/EventsHandler.php +++ b/app/modules/Events/Interfaces/Queries/EventsHandler.php @@ -1,6 +1,6 @@ [self::class, 'eventHandler'], - ]; - - public function boot( - HandlerRegistryInterface $registry, - AnyHttpRequestDump $handler - ): void { - $registry->register($handler); - } - - public function eventHandler(ContainerInterface $container): EventHandlerInterface + public function defineSingletons(): array { - return new EventHandler($container, []); + return [ + EventHandlerInterface::class => static function (ContainerInterface $container): EventHandlerInterface { + return new EventHandler($container, []); + }, + ]; } } diff --git a/app/modules/HttpDumps/Interfaces/Http/Handler/AnyHttpRequestDump.php b/app/modules/HttpDumps/Interfaces/Http/Handler/AnyHttpRequestDump.php index 2d70df1c..ca2dbfd2 100644 --- a/app/modules/HttpDumps/Interfaces/Http/Handler/AnyHttpRequestDump.php +++ b/app/modules/HttpDumps/Interfaces/Http/Handler/AnyHttpRequestDump.php @@ -51,7 +51,7 @@ public function handle(ServerRequestInterface $request, \Closure $next): Respons $event = $this->handler->handle($payload); $this->commands->dispatch( - new HandleReceivedEvent(type: 'http-dump', payload: $event) + new HandleReceivedEvent(type: 'http-dump', payload: $event), ); return $this->responseWrapper->create(200); @@ -77,7 +77,7 @@ private function createPayload(ServerRequestInterface $request): array function (UploadedFileInterface $attachment) use ($id) { $this->bucket->write( $filename = $id . '/' . $attachment->getClientFilename(), - $attachment->getStream() + $attachment->getStream(), ); return [ @@ -88,9 +88,9 @@ function (UploadedFileInterface $attachment) use ($id) { 'mime' => $attachment->getClientMediaType(), ]; }, - $request->getUploadedFiles() + $request->getUploadedFiles(), ), - ] + ], ]; } } diff --git a/app/modules/Inspector/Application/InspectorBootloader.php b/app/modules/Inspector/Application/InspectorBootloader.php index 0116788d..a9b7e94e 100644 --- a/app/modules/Inspector/Application/InspectorBootloader.php +++ b/app/modules/Inspector/Application/InspectorBootloader.php @@ -4,16 +4,9 @@ namespace Modules\Inspector\Application; -use App\Application\Service\HttpHandler\HandlerRegistryInterface; -use Modules\Inspector\Interfaces\Http\Handler\EventHandler; use Spiral\Boot\Bootloader\Bootloader; final class InspectorBootloader extends Bootloader { - public function boot( - HandlerRegistryInterface $registry, - EventHandler $handler - ): void { - $registry->register($handler); - } + } diff --git a/app/modules/Inspector/Interfaces/Http/Handler/EventHandler.php b/app/modules/Inspector/Interfaces/Http/Handler/EventHandler.php index 451eee16..c0d65d37 100644 --- a/app/modules/Inspector/Interfaces/Http/Handler/EventHandler.php +++ b/app/modules/Inspector/Interfaces/Http/Handler/EventHandler.php @@ -35,12 +35,17 @@ public function handle(ServerRequestInterface $request, \Closure $next): Respons ?? throw new ClientException\BadRequestException('Invalid data'); $type = $data[0]['type'] ?? 'unknown'; - if ($type !== 'request') { - throw new ClientException\BadRequestException('Invalid data'); - } + + $data = match ($type) { + 'process', + 'request' => $data, + default => throw new ClientException\BadRequestException( + \sprintf('Invalid type "%s". [%s] expected.', $type, \implode(', ', ['process', 'request'])), + ), + }; $this->commands->dispatch( - new HandleReceivedEvent(type: 'inspector', payload: $data) + new HandleReceivedEvent(type: 'inspector', payload: $data), ); return $this->responseWrapper->create(200); diff --git a/app/modules/Monolog/Interfaces/TCP/Service.php b/app/modules/Monolog/Interfaces/TCP/Service.php index 66080056..b430a9f2 100644 --- a/app/modules/Monolog/Interfaces/TCP/Service.php +++ b/app/modules/Monolog/Interfaces/TCP/Service.php @@ -7,7 +7,6 @@ use App\Application\Commands\HandleReceivedEvent; use Psr\Log\LoggerInterface; use Spiral\Cqrs\CommandBusInterface; -use Spiral\Exceptions\ExceptionReporterInterface; use Spiral\RoadRunner\Tcp\Request; use Spiral\RoadRunner\Tcp\TcpEvent; use Spiral\RoadRunnerBridge\Tcp\Response\CloseConnection; diff --git a/app/modules/Profiler/Application/ProfilerBootloader.php b/app/modules/Profiler/Application/ProfilerBootloader.php index b068e0ab..bf61e3b7 100644 --- a/app/modules/Profiler/Application/ProfilerBootloader.php +++ b/app/modules/Profiler/Application/ProfilerBootloader.php @@ -4,9 +4,7 @@ namespace Modules\Profiler\Application; -use App\Application\Service\HttpHandler\HandlerRegistryInterface; use Modules\Profiler\Application\Handlers\CalculateDiffsBetweenEdges; -use Modules\Profiler\Interfaces\Http\Handler\EventHandler as HttpEventHandler; use Modules\Profiler\Application\Handlers\CleanupEvent; use Modules\Profiler\Application\Handlers\PrepareEdges; use Modules\Profiler\Application\Handlers\PreparePeaks; @@ -15,24 +13,17 @@ final class ProfilerBootloader extends Bootloader { - protected const SINGLETONS = [ - EventHandlerInterface::class => [self::class, 'eventHandler'], - ]; - - public function boot( - HandlerRegistryInterface $registry, - HttpEventHandler $handler - ): void { - $registry->register($handler); - } - - public function eventHandler(ContainerInterface $container): EventHandlerInterface + public function defineSingletons(): array { - return new EventHandler($container, [ - PreparePeaks::class, - CalculateDiffsBetweenEdges::class, - PrepareEdges::class, - CleanupEvent::class, - ]); + return [ + EventHandlerInterface::class => static function (ContainerInterface $container): EventHandlerInterface { + return new EventHandler($container, [ + PreparePeaks::class, + CalculateDiffsBetweenEdges::class, + PrepareEdges::class, + CleanupEvent::class, + ]); + }, + ]; } } diff --git a/app/modules/Ray/Application/RayBootloader.php b/app/modules/Ray/Application/RayBootloader.php index 8cada904..ce34ce07 100644 --- a/app/modules/Ray/Application/RayBootloader.php +++ b/app/modules/Ray/Application/RayBootloader.php @@ -13,21 +13,21 @@ final class RayBootloader extends Bootloader { - protected const SINGLETONS = [ - EventHandlerInterface::class => [self::class, 'eventHandler'], - ]; + public function defineSingletons(): array + { + return [ + EventHandlerInterface::class => static function (ContainerInterface $container): EventHandlerInterface { + return new EventHandler($container, [ + MergeEventsHandler::class, + ]); + }, + ]; + } public function boot( HandlerRegistryInterface $registry, - HttpEventHandler $handler + HttpEventHandler $handler, ): void { $registry->register($handler); } - - public function eventHandler(ContainerInterface $container): EventHandlerInterface - { - return new EventHandler($container, [ - MergeEventsHandler::class, - ]); - } } diff --git a/app/modules/Ray/Interfaces/Http/Handler/EventHandler.php b/app/modules/Ray/Interfaces/Http/Handler/EventHandler.php index 4c3973dd..73745f81 100644 --- a/app/modules/Ray/Interfaces/Http/Handler/EventHandler.php +++ b/app/modules/Ray/Interfaces/Http/Handler/EventHandler.php @@ -58,14 +58,15 @@ private function handleEvent(ServerRequestInterface $request): ResponseInterface $this->cache->set($hash, 1, CarbonInterval::minute(5)); } elseif ($type === TypeEnum::ClearAll->value) { $this->commands->dispatch(new ClearEvents(type: 'ray')); + return $this->responseWrapper->create(200); } $event = $this->handler->handle($event); $this->commands->dispatch( new HandleReceivedEvent( - type: 'ray', payload: $event, uuid: Uuid::fromString($event['uuid']) - ) + type: 'ray', payload: $event, uuid: Uuid::fromString($event['uuid']), + ), ); return $this->responseWrapper->create(200); @@ -86,9 +87,11 @@ private function handleLocks(ServerRequestInterface $request): ResponseInterface private function isValidRequest(ServerRequestInterface $request): bool { + $userAgent = $request->getServerParams()['HTTP_USER_AGENT'] ?? ''; + return $request->getHeaderLine('X-Buggregator-Event') === 'ray' || $request->getAttribute('event-type') === 'ray' - || \str_starts_with($request->getUri()->getPath(), 'Ray') - || $request->getUri()->getUserInfo() === 'ray'; + || $request->getUri()->getUserInfo() === 'ray' + || \str_starts_with($userAgent, 'Ray'); } } diff --git a/app/modules/Sentry/Application/PayloadParser.php b/app/modules/Sentry/Application/PayloadParser.php new file mode 100644 index 00000000..378bd283 --- /dev/null +++ b/app/modules/Sentry/Application/PayloadParser.php @@ -0,0 +1,37 @@ +getHeaderLine('Content-Type') === 'application/x-sentry-envelope' || + \str_contains($request->getHeaderLine('X-Sentry-Auth'), 'sentry.php/4'); + + if ($isV4) { + if ($request->getHeaderLine('Content-Encoding') === 'gzip') { + return \iterator_to_array($this->gzippedStreamFactory->createFromRequest($request)->getPayload()); + } + + $payloads = \explode("\n", (string)$request->getBody()); + + return \array_map( + static fn(string $payload): array => \json_decode($payload, true), + \array_filter($payloads), + ); + } + + return [$request->getParsedBody()]; + } +} diff --git a/app/modules/Sentry/Application/SentryBootloader.php b/app/modules/Sentry/Application/SentryBootloader.php index 848d5641..037d5f9f 100644 --- a/app/modules/Sentry/Application/SentryBootloader.php +++ b/app/modules/Sentry/Application/SentryBootloader.php @@ -4,30 +4,18 @@ namespace Modules\Sentry\Application; -use App\Application\Service\HttpHandler\HandlerRegistryInterface; use Modules\Sentry\EventHandler; -use Modules\Sentry\Interfaces\Http\Handler\EventHandler as HttpEventHandler; -use Modules\Sentry\Interfaces\Http\Handler\JsEventHandler; use Psr\Container\ContainerInterface; use Spiral\Boot\Bootloader\Bootloader; final class SentryBootloader extends Bootloader { - protected const SINGLETONS = [ - EventHandlerInterface::class => [self::class, 'eventHandler'], - ]; - - public function boot( - HandlerRegistryInterface $registry, - HttpEventHandler $handler, - JsEventHandler $jsHandler - ): void { - $registry->register($handler); - $registry->register($jsHandler); - } - - public function eventHandler(ContainerInterface $container): EventHandlerInterface + public function defineSingletons(): array { - return new EventHandler($container, []); + return [ + EventHandlerInterface::class => static function (ContainerInterface $container): EventHandlerInterface { + return new EventHandler($container, []); + }, + ]; } } diff --git a/app/modules/Sentry/Interfaces/Http/Handler/EventHandler.php b/app/modules/Sentry/Interfaces/Http/Handler/EventHandler.php index e24a3e19..15523e4e 100644 --- a/app/modules/Sentry/Interfaces/Http/Handler/EventHandler.php +++ b/app/modules/Sentry/Interfaces/Http/Handler/EventHandler.php @@ -5,9 +5,9 @@ namespace Modules\Sentry\Interfaces\Http\Handler; use App\Application\Commands\HandleReceivedEvent; -use App\Application\HTTP\GzippedStreamFactory; use App\Application\Service\HttpHandler\HandlerInterface; use Modules\Sentry\Application\EventHandlerInterface; +use Modules\Sentry\Application\PayloadParser; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Spiral\Cqrs\CommandBusInterface; @@ -16,7 +16,7 @@ final class EventHandler implements HandlerInterface { public function __construct( - private readonly GzippedStreamFactory $gzippedStreamFactory, + private readonly PayloadParser $payloadParser, private readonly ResponseWrapper $responseWrapper, private readonly EventHandlerInterface $handler, private readonly CommandBusInterface $commands, @@ -35,7 +35,8 @@ public function handle(ServerRequestInterface $request, \Closure $next): Respons } $url = \rtrim($request->getUri()->getPath(), '/'); - $payloads = $this->gzippedStreamFactory->createFromRequest($request)->getPayload(); + + $payloads = $this->payloadParser->parse($request); match (true) { \str_ends_with($url, '/envelope') => $this->handleEnvelope($payloads), @@ -46,42 +47,35 @@ public function handle(ServerRequestInterface $request, \Closure $next): Respons return $this->responseWrapper->create(200); } - private function handleEvent(\Traversable $data): void + private function handleEvent(array $data): void { - $data = \iterator_to_array($data); - $event = $this->handler->handle($data[0]); $this->commands->dispatch( - new HandleReceivedEvent(type: 'sentry', payload: $event) + new HandleReceivedEvent(type: 'sentry', payload: $event), ); } /** * TODO handle sentry transaction and session */ - private function handleEnvelope(\Traversable $data): void + private function handleEnvelope(array $data): void { - $data = \iterator_to_array($data); - if (\count($data) == 3) { match ($data[1]['type']) { 'transaction' => null, 'session' => null, + 'event' => $this->handleEvent([$data[2]]), + default => null, }; } } private function isValidRequest(ServerRequestInterface $request): bool { - if ($request->getHeaderLine('Content-Encoding') !== 'gzip') { - return false; - } - return $request->getHeaderLine('X-Buggregator-Event') === 'sentry' || $request->getAttribute('event-type') === 'sentry' || $request->hasHeader('X-Sentry-Auth') - || $request->getUri()->getUserInfo() === 'sentry' - || (string)$request->getUri() === 'profiler/store'; + || $request->getUri()->getUserInfo() === 'sentry'; } } diff --git a/app/src/Application/AppDirectories.php b/app/src/Application/AppDirectories.php index b98dfe6e..ce494282 100644 --- a/app/src/Application/AppDirectories.php +++ b/app/src/Application/AppDirectories.php @@ -10,8 +10,7 @@ final class AppDirectories { public function __construct( private readonly DirectoriesInterface $directories - ) { - } + ) {} /** * Application root directory. diff --git a/app/src/Application/Bootloader/AppBootloader.php b/app/src/Application/Bootloader/AppBootloader.php index 374440f1..478ca44e 100644 --- a/app/src/Application/Bootloader/AppBootloader.php +++ b/app/src/Application/Bootloader/AppBootloader.php @@ -12,13 +12,19 @@ final class AppBootloader extends DomainBootloader { - protected const SINGLETONS = [ - CoreInterface::class => [self::class, 'domainCore'], - ]; + public function defineSingletons(): array + { + return [ + CoreInterface::class => [self::class, 'domainCore'], + ]; + } - protected const INTERCEPTORS = [ - StringToIntParametersInterceptor::class, - UuidParametersConverterInterceptor::class, - JsonResourceInterceptor::class, - ]; + protected static function defineInterceptors(): array + { + return [ + StringToIntParametersInterceptor::class, + UuidParametersConverterInterceptor::class, + JsonResourceInterceptor::class, + ]; + } } diff --git a/app/src/Application/Bootloader/ExceptionHandlerBootloader.php b/app/src/Application/Bootloader/ExceptionHandlerBootloader.php index 72bb3be6..22f05cdd 100644 --- a/app/src/Application/Bootloader/ExceptionHandlerBootloader.php +++ b/app/src/Application/Bootloader/ExceptionHandlerBootloader.php @@ -19,10 +19,13 @@ final class ExceptionHandlerBootloader extends Bootloader { - protected const BINDINGS = [ - SuppressErrorsInterface::class => EnvSuppressErrors::class, - RendererInterface::class => PlainRenderer::class, - ]; + public function defineBindings(): array + { + return [ + SuppressErrorsInterface::class => EnvSuppressErrors::class, + RendererInterface::class => PlainRenderer::class, + ]; + } public function init(AbstractKernel $kernel): void { diff --git a/app/src/Application/Bootloader/HttpHandlerBootloader.php b/app/src/Application/Bootloader/HttpHandlerBootloader.php index 9efb9132..ff431db1 100644 --- a/app/src/Application/Bootloader/HttpHandlerBootloader.php +++ b/app/src/Application/Bootloader/HttpHandlerBootloader.php @@ -7,28 +7,32 @@ use App\Application\Service\HttpHandler\CoreHandlerInterface; use App\Application\Service\HttpHandler\HandlerPipeline; use App\Application\Service\HttpHandler\HandlerRegistryInterface; -use App\Interfaces\Http\FrontendRequest; +use App\Interfaces\Http\Handler\FrontendRequest; use Spiral\Boot\Bootloader\Bootloader; use Spiral\Boot\DirectoriesInterface; +use Spiral\Core\FactoryInterface; +use Spiral\Tokenizer\TokenizerListenerRegistryInterface; final class HttpHandlerBootloader extends Bootloader { - protected const SINGLETONS = [ - HandlerPipeline::class => [self::class, 'initHandlerPipeline'], - HandlerRegistryInterface::class => HandlerPipeline::class, - CoreHandlerInterface::class => HandlerPipeline::class, - ]; - - private function initHandlerPipeline(DirectoriesInterface $dirs): HandlerPipeline + public function defineSingletons(): array { - $pipeline = new HandlerPipeline(); - - $pipeline->register( - new FrontendRequest( - $dirs->get('public') - ) - ); + return [ + HandlerPipeline::class => static function (FactoryInterface $factory): HandlerPipeline { + return new HandlerPipeline(factory: $factory); + }, + HandlerRegistryInterface::class => HandlerPipeline::class, + CoreHandlerInterface::class => HandlerPipeline::class, + FrontendRequest::class => static function (DirectoriesInterface $dirs): FrontendRequest { + return new FrontendRequest( + $dirs->get('public'), + ); + }, + ]; + } - return $pipeline; + public function init(TokenizerListenerRegistryInterface $tokenizerRegistry, HandlerPipeline $pipeline): void + { + $tokenizerRegistry->addListener($pipeline); } } diff --git a/app/src/Application/Bootloader/MongoDBBootloader.php b/app/src/Application/Bootloader/MongoDBBootloader.php index 9c105e2c..f81d9c08 100644 --- a/app/src/Application/Bootloader/MongoDBBootloader.php +++ b/app/src/Application/Bootloader/MongoDBBootloader.php @@ -6,29 +6,25 @@ use MongoDB\Client; use MongoDB\Database; -use MongoDB\Driver\ServerApi; use Spiral\Boot\Bootloader\Bootloader; use Spiral\Boot\EnvironmentInterface; final class MongoDBBootloader extends Bootloader { - protected const SINGLETONS = [ - Client::class => [self::class, 'createClient'], - Database::class => [self::class, 'selectDatabase'], - ]; - - private function createClient(EnvironmentInterface $env): Client - { - return new Client( - $env->get('MONGODB_CONNECTION') - ); - } - - private function selectDatabase(Client $client, EnvironmentInterface $env): Database + public function defineSingletons(): array { - $database = $client->selectDatabase($env->get('MONGODB_DATABASE')); - $database->command(['ping' => 1]); + return [ + Client::class => static function (EnvironmentInterface $env): Client { + return new Client( + $env->get('MONGODB_CONNECTION'), + ); + }, + Database::class => static function (Client $client, EnvironmentInterface $env): Database { + $database = $client->selectDatabase($env->get('MONGODB_DATABASE')); + $database->command(['ping' => 1]); - return $database; + return $database; + }, + ]; } } diff --git a/app/src/Application/Bootloader/RoutesBootloader.php b/app/src/Application/Bootloader/RoutesBootloader.php index c55e5ce3..45351b5d 100644 --- a/app/src/Application/Bootloader/RoutesBootloader.php +++ b/app/src/Application/Bootloader/RoutesBootloader.php @@ -4,14 +4,11 @@ namespace App\Application\Bootloader; -use App\Application\Service\HttpHandler\CoreHandlerInterface; use App\Interfaces\Http\EventHandlerAction; -use Psr\Container\ContainerInterface; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; use Spiral\Bootloader\Http\RoutesBootloader as BaseRoutesBootloader; use Spiral\Filter\ValidationHandlerMiddleware; use Spiral\Http\Middleware\ErrorHandlerMiddleware; +use Spiral\Http\Middleware\JsonPayloadMiddleware; use Spiral\Router\Bootloader\AnnotatedRoutesBootloader; use Spiral\Router\GroupRegistry; use Spiral\Router\Loader\Configurator\RoutingConfigurator; @@ -25,6 +22,7 @@ final class RoutesBootloader extends BaseRoutesBootloader protected function globalMiddleware(): array { return [ + JsonPayloadMiddleware::class, ErrorHandlerMiddleware::class, ValidationHandlerMiddleware::class, ]; diff --git a/app/src/Application/Broadcasting/BroadcastEventInterceptor.php b/app/src/Application/Broadcasting/BroadcastEventInterceptor.php index f5b698c2..16eaf790 100644 --- a/app/src/Application/Broadcasting/BroadcastEventInterceptor.php +++ b/app/src/Application/Broadcasting/BroadcastEventInterceptor.php @@ -12,8 +12,7 @@ final class BroadcastEventInterceptor implements CoreInterceptorInterface { public function __construct( private readonly BroadcastInterface $broadcast - ) { - } + ) {} public function process(string $controller, string $action, array $parameters, CoreInterface $core): mixed { diff --git a/app/src/Application/Broadcasting/Channel/Channel.php b/app/src/Application/Broadcasting/Channel/Channel.php index a37ca15b..eeca81c6 100644 --- a/app/src/Application/Broadcasting/Channel/Channel.php +++ b/app/src/Application/Broadcasting/Channel/Channel.php @@ -8,8 +8,7 @@ class Channel implements \Stringable { public function __construct( public readonly string $name - ) { - } + ) {} public function __toString(): string { diff --git a/app/src/Application/Broadcasting/InMemoryDriver.php b/app/src/Application/Broadcasting/InMemoryDriver.php new file mode 100644 index 00000000..3f72511c --- /dev/null +++ b/app/src/Application/Broadcasting/InMemoryDriver.php @@ -0,0 +1,36 @@ +formatTopics($this->toArray($topics)); + + foreach ($topics as $topic) { + foreach ($this->toArray($messages) as $message) { + self::$published[$topic][] = \json_decode($message, true); + } + } + } + + public function published(): array + { + return self::$published; + } + + public function reset(): void + { + self::$published = []; + } +} diff --git a/app/src/Application/Commands/AskEvents.php b/app/src/Application/Commands/AskEvents.php index 4ef6dbef..7762da3d 100644 --- a/app/src/Application/Commands/AskEvents.php +++ b/app/src/Application/Commands/AskEvents.php @@ -11,6 +11,5 @@ abstract class AskEvents implements QueryInterface public function __construct( public readonly ?string $type = null, public readonly ?int $projectId = null, - ) { - } + ) {} } diff --git a/app/src/Application/Commands/ClearEvents.php b/app/src/Application/Commands/ClearEvents.php index b6b38cb8..82f25f5f 100644 --- a/app/src/Application/Commands/ClearEvents.php +++ b/app/src/Application/Commands/ClearEvents.php @@ -10,6 +10,5 @@ class ClearEvents implements CommandInterface { public function __construct( public readonly ?string $type = null - ) { - } + ) {} } diff --git a/app/src/Application/Commands/CountEvents.php b/app/src/Application/Commands/CountEvents.php index fda8a4b8..3bf53e21 100644 --- a/app/src/Application/Commands/CountEvents.php +++ b/app/src/Application/Commands/CountEvents.php @@ -4,6 +4,4 @@ namespace App\Application\Commands; -final class CountEvents extends AskEvents -{ -} +final class CountEvents extends AskEvents {} diff --git a/app/src/Application/Commands/DeleteEvent.php b/app/src/Application/Commands/DeleteEvent.php index 8839382f..947a1939 100644 --- a/app/src/Application/Commands/DeleteEvent.php +++ b/app/src/Application/Commands/DeleteEvent.php @@ -11,6 +11,5 @@ class DeleteEvent implements CommandInterface { public function __construct( public readonly Uuid $uuid - ) { - } + ) {} } diff --git a/app/src/Application/Commands/FinUserByUsername.php b/app/src/Application/Commands/FinUserByUsername.php index f2b03f3d..8803d1d4 100644 --- a/app/src/Application/Commands/FinUserByUsername.php +++ b/app/src/Application/Commands/FinUserByUsername.php @@ -10,6 +10,5 @@ class FinUserByUsername implements QueryInterface { public function __construct( public readonly string $username - ) { - } + ) {} } diff --git a/app/src/Application/Commands/FindAllProjects.php b/app/src/Application/Commands/FindAllProjects.php index 457b4c41..983bb705 100644 --- a/app/src/Application/Commands/FindAllProjects.php +++ b/app/src/Application/Commands/FindAllProjects.php @@ -6,6 +6,4 @@ use Spiral\Cqrs\QueryInterface; -class FindAllProjects implements QueryInterface -{ -} +class FindAllProjects implements QueryInterface {} diff --git a/app/src/Application/Commands/FindAllTransactions.php b/app/src/Application/Commands/FindAllTransactions.php index e31dfe7d..e2ffc64e 100644 --- a/app/src/Application/Commands/FindAllTransactions.php +++ b/app/src/Application/Commands/FindAllTransactions.php @@ -6,6 +6,4 @@ use Spiral\Cqrs\QueryInterface; -class FindAllTransactions implements QueryInterface -{ -} +class FindAllTransactions implements QueryInterface {} diff --git a/app/src/Application/Commands/FindEventByUuid.php b/app/src/Application/Commands/FindEventByUuid.php index 0a60db6d..59734681 100644 --- a/app/src/Application/Commands/FindEventByUuid.php +++ b/app/src/Application/Commands/FindEventByUuid.php @@ -11,6 +11,5 @@ class FindEventByUuid implements QueryInterface { public function __construct( public readonly Uuid $uuid - ) { - } + ) {} } diff --git a/app/src/Application/Commands/FindEvents.php b/app/src/Application/Commands/FindEvents.php index fee8b78b..b01bbb80 100644 --- a/app/src/Application/Commands/FindEvents.php +++ b/app/src/Application/Commands/FindEvents.php @@ -4,6 +4,4 @@ namespace App\Application\Commands; -final class FindEvents extends AskEvents -{ -} +final class FindEvents extends AskEvents {} diff --git a/app/src/Application/Commands/FindProjectByName.php b/app/src/Application/Commands/FindProjectByName.php index 106d6793..6b145b2e 100644 --- a/app/src/Application/Commands/FindProjectByName.php +++ b/app/src/Application/Commands/FindProjectByName.php @@ -10,6 +10,5 @@ class FindProjectByName implements QueryInterface { public function __construct( public readonly string $name - ) { - } + ) {} } diff --git a/app/src/Application/Commands/FindTransactionByName.php b/app/src/Application/Commands/FindTransactionByName.php index 9f8818bd..c6f3b223 100644 --- a/app/src/Application/Commands/FindTransactionByName.php +++ b/app/src/Application/Commands/FindTransactionByName.php @@ -10,6 +10,5 @@ class FindTransactionByName implements QueryInterface { public function __construct( public readonly string $name - ) { - } + ) {} } diff --git a/app/src/Application/Commands/HandleReceivedEvent.php b/app/src/Application/Commands/HandleReceivedEvent.php index d17997bf..82d885fc 100644 --- a/app/src/Application/Commands/HandleReceivedEvent.php +++ b/app/src/Application/Commands/HandleReceivedEvent.php @@ -10,16 +10,16 @@ final class HandleReceivedEvent implements CommandInterface, \JsonSerializable { public readonly Uuid $uuid; - public readonly int $timestamp; + public readonly float $timestamp; public function __construct( public readonly string $type, public readonly array $payload, public readonly ?string $project = null, - ?Uuid $uuid = null + ?Uuid $uuid = null, ) { $this->uuid = $uuid ?? Uuid::generate(); - $this->timestamp = time(); + $this->timestamp = microtime(true); } public function jsonSerialize(): array diff --git a/app/src/Application/Domain/Entity/Json.php b/app/src/Application/Domain/Entity/Json.php index 296b9d37..5243ad85 100644 --- a/app/src/Application/Domain/Entity/Json.php +++ b/app/src/Application/Domain/Entity/Json.php @@ -10,8 +10,7 @@ final class Json implements \JsonSerializable { public function __construct( private readonly array $data = [] - ) { - } + ) {} public static function cast(string $value, DatabaseInterface $db): self { diff --git a/app/src/Application/Domain/ValueObjects/Uuid.php b/app/src/Application/Domain/ValueObjects/Uuid.php index 0ea9ffe8..515d5837 100644 --- a/app/src/Application/Domain/ValueObjects/Uuid.php +++ b/app/src/Application/Domain/ValueObjects/Uuid.php @@ -16,7 +16,7 @@ public static function generate(): self public function __construct(private ?UuidInterface $uuid = null) { if (!$uuid) { - $this->uuid = \Ramsey\Uuid\Uuid::uuid4(); + $this->uuid = \Ramsey\Uuid\Uuid::uuid7(); } } diff --git a/app/src/Application/Exception/EntityNotFoundException.php b/app/src/Application/Exception/EntityNotFoundException.php index 84801e9c..f742432a 100644 --- a/app/src/Application/Exception/EntityNotFoundException.php +++ b/app/src/Application/Exception/EntityNotFoundException.php @@ -4,7 +4,4 @@ namespace App\Application\Exception; -class EntityNotFoundException extends \DomainException -{ -} - +class EntityNotFoundException extends \DomainException {} diff --git a/app/src/Application/HTTP/GzippedStream.php b/app/src/Application/HTTP/GzippedStream.php index fd3788c5..b84b58f9 100644 --- a/app/src/Application/HTTP/GzippedStream.php +++ b/app/src/Application/HTTP/GzippedStream.php @@ -10,8 +10,7 @@ final class GzippedStream { public function __construct( private readonly StreamInterface $stream, - ) { - } + ) {} public function getPayload(): \Traversable { diff --git a/app/src/Application/HTTP/Interceptor/JsonResourceInterceptor.php b/app/src/Application/HTTP/Interceptor/JsonResourceInterceptor.php index 6162ad18..bf326636 100644 --- a/app/src/Application/HTTP/Interceptor/JsonResourceInterceptor.php +++ b/app/src/Application/HTTP/Interceptor/JsonResourceInterceptor.php @@ -4,28 +4,36 @@ namespace App\Application\HTTP\Interceptor; +use App\Application\HTTP\Response\ErrorResource; use App\Application\HTTP\Response\ResourceInterface; +use App\Application\HTTP\Response\ValidationResource; use Psr\Http\Message\ResponseFactoryInterface; use Spiral\Core\CoreInterceptorInterface; use Spiral\Core\CoreInterface; -use Spiral\Http\Request\InputManager; +use Spiral\Exceptions\ExceptionHandlerInterface; +use Spiral\Filters\Exception\ValidationException; final class JsonResourceInterceptor implements CoreInterceptorInterface { public function __construct( - private readonly InputManager $manager, - private readonly ResponseFactoryInterface $responseFactory - ) { - } + private readonly ResponseFactoryInterface $responseFactory, + private readonly ExceptionHandlerInterface $exceptionHandler, + ) {} public function process(string $controller, string $action, array $parameters, CoreInterface $core): mixed { - $response = $core->callAction($controller, $action, $parameters); + try { + $response = $core->callAction($controller, $action, $parameters); + } catch (ValidationException $e) { + $response = new ValidationResource($e); + } catch (\Throwable $e) { + $this->exceptionHandler->report($e); + $response = new ErrorResource($e); + } if ($response instanceof ResourceInterface) { $response = $response->toResponse( - $this->manager->request(), - $this->responseFactory->createResponse() + $this->responseFactory->createResponse(), ); } diff --git a/app/src/Application/HTTP/Response/ErrorResource.php b/app/src/Application/HTTP/Response/ErrorResource.php new file mode 100644 index 00000000..07fa0d3c --- /dev/null +++ b/app/src/Application/HTTP/Response/ErrorResource.php @@ -0,0 +1,36 @@ + $this->data->getMessage(), + 'code' => $this->getCode(), + ]; + } + + protected function getCode(): int + { + return match (true) { + $this->data instanceof EntityNotFoundException => 404, + $this->data instanceof ClientException => $this->data->getCode(), + default => 500, + }; + } +} diff --git a/app/src/Application/HTTP/Response/JsonResource.php b/app/src/Application/HTTP/Response/JsonResource.php index ae86506e..847c207d 100644 --- a/app/src/Application/HTTP/Response/JsonResource.php +++ b/app/src/Application/HTTP/Response/JsonResource.php @@ -6,39 +6,32 @@ use JsonSerializable; use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; use Spiral\Http\Traits\JsonTrait; -class JsonResource implements ResourceInterface, \ArrayAccess +class JsonResource implements ResourceInterface { use JsonTrait; protected readonly mixed $data; - public function __construct(mixed $data) + public function __construct(mixed $data = []) { $this->data = $data; } - protected function mapData(ServerRequestInterface $request): array|JsonSerializable + protected function mapData(): array|JsonSerializable { return $this->data; } - public function resolve(ServerRequestInterface $request): array + protected function getCode(): int { - $data = $this->mapData($request); - - if ($data instanceof JsonSerializable) { - $data = $data->jsonSerialize(); - } - - return $this->wrapData($data); + return 200; } - public function toResponse(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface + public function toResponse(ResponseInterface $response): ResponseInterface { - return $this->writeJson($response, $this->resolve($request)); + return $this->writeJson($response, $this, $this->getCode()); } protected function wrapData(array $data): array @@ -46,33 +39,20 @@ protected function wrapData(array $data): array return $data; } - public function offsetExists(mixed $offset): bool + public function jsonSerialize(): array { - return isset($this->data[$offset]); - } - - public function offsetGet(mixed $offset): mixed - { - return $this->data[$offset]; - } + $data = $this->mapData(); - public function offsetSet(mixed $offset, mixed $value): void - { - throw new \RuntimeException('Resource is read-only'); - } - - public function offsetUnset(mixed $offset): void - { - throw new \RuntimeException('Resource is read-only'); - } + if ($data instanceof JsonSerializable) { + $data = $data->jsonSerialize(); + } - public function __isset($key) - { - return $this->offsetExists($key); - } + foreach ($data as $key => $value) { + if ($value instanceof ResourceInterface) { + $data[$key] = $value->jsonSerialize(); + } + } - public function __get($key) - { - return $this->offsetGet($key); + return $this->wrapData($data); } } diff --git a/app/src/Application/HTTP/Response/ResourceCollection.php b/app/src/Application/HTTP/Response/ResourceCollection.php index c99d5317..787c866f 100644 --- a/app/src/Application/HTTP/Response/ResourceCollection.php +++ b/app/src/Application/HTTP/Response/ResourceCollection.php @@ -5,7 +5,6 @@ namespace App\Application\HTTP\Response; use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; use Spiral\DataGrid\GridInterface; use Spiral\Http\Traits\JsonTrait; @@ -13,13 +12,17 @@ class ResourceCollection implements ResourceInterface { use JsonTrait; + private readonly array $args; + /** * @param class-string $resourceClass */ public function __construct( protected readonly iterable $data, - protected string $resourceClass = JsonResource::class + protected string $resourceClass = JsonResource::class, + mixed ...$args ) { + $this->args = $args; } /** @@ -35,21 +38,30 @@ protected function getData(): iterable return $this->data; } - public function resolve(ServerRequestInterface $request): array + public function jsonSerialize(): array { $data = []; - $resourceClass = $this->getResourceClass(); + $resource = $this->getResourceClass(); foreach ($this->getData() as $key => $row) { - $data[$key] = (new $resourceClass($row))->resolve($request); + if ($row instanceof \JsonSerializable) { + $data[$key] = $row; + continue; + } + + if (\is_string($resource)) { + $resource = static fn(mixed $row, mixed ...$args): ResourceInterface => new $resource($row, ...$args); + } + + $data[$key] = $resource($row, ...$this->args); } return $this->wrapData($data); } - public function toResponse(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface + public function toResponse(ResponseInterface $response): ResponseInterface { - return $this->writeJson($response, $this->resolve($request)); + return $this->writeJson($response, $this); } protected function wrapData(array $data): array diff --git a/app/src/Application/HTTP/Response/ResourceInterface.php b/app/src/Application/HTTP/Response/ResourceInterface.php index a4dfa83f..d8741918 100644 --- a/app/src/Application/HTTP/Response/ResourceInterface.php +++ b/app/src/Application/HTTP/Response/ResourceInterface.php @@ -7,9 +7,7 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -interface ResourceInterface +interface ResourceInterface extends \JsonSerializable { - public function resolve(ServerRequestInterface $request): array; - - public function toResponse(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface; + public function toResponse(ResponseInterface $response): ResponseInterface; } diff --git a/app/src/Application/HTTP/Response/SuccessResource.php b/app/src/Application/HTTP/Response/SuccessResource.php new file mode 100644 index 00000000..c7675b8b --- /dev/null +++ b/app/src/Application/HTTP/Response/SuccessResource.php @@ -0,0 +1,23 @@ + $this->data, + ]; + } +} diff --git a/app/src/Application/HTTP/Response/ValidationResource.php b/app/src/Application/HTTP/Response/ValidationResource.php new file mode 100644 index 00000000..bf4a1d97 --- /dev/null +++ b/app/src/Application/HTTP/Response/ValidationResource.php @@ -0,0 +1,33 @@ + $this->data->getMessage(), + 'code' => $this->getCode(), + 'errors' => $this->data->errors, + 'context' => $this->data->context, + ]; + } + + protected function getCode(): int + { + return $this->data->getCode(); + } +} diff --git a/app/src/Application/Kernel.php b/app/src/Application/Kernel.php index 14d28aaf..bf5f2a7e 100644 --- a/app/src/Application/Kernel.php +++ b/app/src/Application/Kernel.php @@ -90,12 +90,7 @@ protected function defineBootloaders(): array StorageBootloader::class, DistributionBootloader::class, - ]; - } - protected function defineAppBootloaders(): array - { - return [ HttpHandlerBootloader::class, AppBootloader::class, InspectorBootloader::class, diff --git a/app/src/Application/Persistence/CacheEventRepository.php b/app/src/Application/Persistence/CacheEventRepository.php index 134260e9..0013e216 100644 --- a/app/src/Application/Persistence/CacheEventRepository.php +++ b/app/src/Application/Persistence/CacheEventRepository.php @@ -30,7 +30,7 @@ final class CacheEventRepository implements EventRepositoryInterface public function __construct( CacheStorageProviderInterface $provider, - private readonly int $ttl = 60 * 60 * 2 + private readonly int $ttl = 60 * 60 * 2, ) { $this->cache = $provider->storage('events'); } @@ -66,7 +66,7 @@ public function store(Event $event): bool 'id' => $id, 'type' => $event->getType(), 'project_id' => $event->getProjectId(), - 'date' => $event->getDate()->getTimestamp(), + 'date' => $event->getTimestamp(), 'payload' => $event->getPayload()->jsonSerialize(), ], Carbon::now()->addSeconds($this->ttl)->diffAsCarbonInterval()); } @@ -159,7 +159,7 @@ private function mapDocumentInfoEvent(array $document): Event uuid: Uuid::fromString($document['id']), type: $document['type'], payload: new Json((array)$document['payload']), - date: Carbon::createFromTimestamp($document['date'])->toDateTimeImmutable(), + timestamp: $document['date'], projectId: $document['project_id'], ); } diff --git a/app/src/Application/Persistence/MongoDBEventRepository.php b/app/src/Application/Persistence/MongoDBEventRepository.php index c699996d..4dfcac89 100644 --- a/app/src/Application/Persistence/MongoDBEventRepository.php +++ b/app/src/Application/Persistence/MongoDBEventRepository.php @@ -15,8 +15,7 @@ final class MongoDBEventRepository implements EventRepositoryInterface { public function __construct( private readonly Collection $collection, - ) { - } + ) {} public function store(Event $event): bool { @@ -24,7 +23,7 @@ public function store(Event $event): bool '_id' => (string)$event->getUuid(), 'type' => $event->getType(), 'project_id' => $event->getProjectId(), - 'date' => $event->getDate()->getTimestamp(), + 'date' => $event->getTimestamp(), 'payload' => $event->getPayload()->jsonSerialize(), ]); @@ -82,8 +81,8 @@ public function mapDocumentInfoEvent(\MongoDB\Model\BSONDocument $document): Eve return new Event( uuid: Uuid::fromString($document['_id']), type: $document['type'], - payload: new Json((array) $document['payload']), - date: Carbon::createFromTimestamp($document['date'])->toDateTimeImmutable(), + payload: new Json((array)$document['payload']), + timestamp: $document['date'], projectId: $document['project_id'], ); } diff --git a/app/src/Application/Service/HttpHandler/HandlerPipeline.php b/app/src/Application/Service/HttpHandler/HandlerPipeline.php index 1542d873..8877d5bd 100644 --- a/app/src/Application/Service/HttpHandler/HandlerPipeline.php +++ b/app/src/Application/Service/HttpHandler/HandlerPipeline.php @@ -7,14 +7,22 @@ use Nyholm\Psr7\Response; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use Spiral\Core\FactoryInterface; +use Spiral\Tokenizer\Attribute\TargetClass; +use Spiral\Tokenizer\TokenizationListenerInterface; -final class HandlerPipeline implements HandlerRegistryInterface, CoreHandlerInterface +#[TargetClass(class: HandlerInterface::class)] +final class HandlerPipeline implements HandlerRegistryInterface, CoreHandlerInterface, TokenizationListenerInterface { /** @var HandlerInterface[] */ private array $handlers = []; private int $position = 0; private bool $isHandled = false; + public function __construct( + private readonly FactoryInterface $factory, + ) {} + public function register(HandlerInterface $handler): void { if ($this->isHandled) { @@ -55,7 +63,17 @@ private function handlePipeline(ServerRequestInterface $request): ResponseInterf return $handler->handle( $request, - fn(ServerRequestInterface $request) => $this->handlePipeline($request) + fn(ServerRequestInterface $request) => $this->handlePipeline($request), ); } + + public function listen(\ReflectionClass $class): void + { + $this->register($this->factory->make($class->getName())); + } + + public function finalize(): void + { + // TODO: Implement finalize() method. + } } diff --git a/app/src/Application/TCP/ExceptionHandlerInterceptor.php b/app/src/Application/TCP/ExceptionHandlerInterceptor.php index 15e3bc0d..7f935670 100644 --- a/app/src/Application/TCP/ExceptionHandlerInterceptor.php +++ b/app/src/Application/TCP/ExceptionHandlerInterceptor.php @@ -14,8 +14,7 @@ final class ExceptionHandlerInterceptor implements CoreInterceptorInterface { public function __construct( private readonly ExceptionReporterInterface $reporter - ) { - } + ) {} public function process(string $controller, string $action, array $parameters, CoreInterface $core): ResponseInterface { diff --git a/app/src/Interfaces/Centrifugo/RPCService.php b/app/src/Interfaces/Centrifugo/RPCService.php index bb04b573..a4b58383 100644 --- a/app/src/Interfaces/Centrifugo/RPCService.php +++ b/app/src/Interfaces/Centrifugo/RPCService.php @@ -17,8 +17,7 @@ final class RPCService implements ServiceInterface public function __construct( private readonly Http $http, private readonly ServerRequestFactoryInterface $requestFactory, - ) { - } + ) {} /** * @param Request\RPC $request @@ -60,10 +59,6 @@ public function createHttpRequest(Request\RPC $request): ServerRequestInterface $httpRequest = $this->requestFactory->createServerRequest(\strtoupper($method), \ltrim($uri, '/')) ->withHeader('Content-Type', 'application/json'); -// foreach ($request->headers as $key => $headers) { -// $httpRequest = $httpRequest->withHeader($key, $headers); -// } - return match ($method) { 'GET', 'HEAD' => $httpRequest->withQueryParams($request->getData()), 'POST', 'PUT', 'DELETE' => $httpRequest->withParsedBody($request->getData()), diff --git a/app/src/Interfaces/Http/GetVersionAction.php b/app/src/Interfaces/Http/Controller/GetVersionAction.php similarity index 92% rename from app/src/Interfaces/Http/GetVersionAction.php rename to app/src/Interfaces/Http/Controller/GetVersionAction.php index e5d20f31..7d1642c3 100644 --- a/app/src/Interfaces/Http/GetVersionAction.php +++ b/app/src/Interfaces/Http/Controller/GetVersionAction.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Interfaces\Http; +namespace App\Interfaces\Http\Controller; use App\Application\HTTP\Response\JsonResource; use App\Application\HTTP\Response\ResourceInterface; diff --git a/app/src/Interfaces/Http/FrontendRequest.php b/app/src/Interfaces/Http/Handler/FrontendRequest.php similarity index 97% rename from app/src/Interfaces/Http/FrontendRequest.php rename to app/src/Interfaces/Http/Handler/FrontendRequest.php index 3ff8db71..607e902b 100644 --- a/app/src/Interfaces/Http/FrontendRequest.php +++ b/app/src/Interfaces/Http/Handler/FrontendRequest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Interfaces\Http; +namespace App\Interfaces\Http\Handler; use App\Application\Service\HttpHandler\HandlerInterface; use GuzzleHttp\Psr7\MimeType; @@ -24,8 +24,7 @@ final class FrontendRequest implements HandlerInterface public function __construct( private readonly string $publicPath, - ) { - } + ) {} public function priority(): int { diff --git a/composer.json b/composer.json index 1a810e51..6d98bb4b 100644 --- a/composer.json +++ b/composer.json @@ -38,7 +38,7 @@ "spiral-packages/league-event": "^1.0", "spiral/cycle-bridge": "^2.5", "spiral/data-grid": "^3.0", - "spiral/framework": "^3.8", + "spiral/framework": "^3.10", "spiral/nyholm-bridge": "^1.3", "spiral/roadrunner-bridge": "^3.0", "spiral/validator": "^1.1", @@ -49,8 +49,9 @@ "require-dev": { "phpunit/phpunit": "^9.5", "qossmic/deptrac-shim": "^1.0", - "spiral/testing": "^2.2", - "vimeo/psalm": "^4.1" + "spiral/testing": "^2.6", + "friendsofphp/php-cs-fixer": "^3.40", + "vimeo/psalm": "^5.16" }, "autoload": { "psr-4": { @@ -66,7 +67,8 @@ "config": { "sort-packages": true, "allow-plugins": { - "spiral/composer-publish-plugin": true + "spiral/composer-publish-plugin": true, + "php-http/discovery": false } }, "scripts": { @@ -76,9 +78,9 @@ "php app.php configure -vv", "rr get-binary" ], - "rr:download": "rr get-binary", - "rr:download-protoc": "rr download-protoc-binary", - "psalm:config": "psalm", + "psalm": "vendor/bin/psalm --config=psalm.xml ./app", + "cs-check": "vendor/bin/php-cs-fixer fix ./app/src --rules=@PER-CS2.0 --dry-run", + "cs-fix": "vendor/bin/php-cs-fixer fix ./app/src --rules=@PER-CS2.0", "deptrack": [ "deptrac analyze --report-uncovered" ] diff --git a/docker/Dockerfile b/docker/Dockerfile index 3017df66..414d6efb 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,4 +1,4 @@ -ARG ROAD_RUNNER_IMAGE=2.12.3 +ARG ROAD_RUNNER_IMAGE=2023.3.7 ARG CENTRIFUGO_IMAGE=v4 ARG FRONTEND_IMAGE_TAG=latest diff --git a/phpunit.xml b/phpunit.xml index a875d9e7..42bb75ba 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -14,20 +14,19 @@ stopOnFailure="false" stopOnError="false" stderr="true"> - - - app/src - - - - - tests/Unit - - - tests/Feature - - - app/src/Module/*/Test - - + + + + + + + + + + + + + tests/Feature + + diff --git a/tests/App/Broadcasting/BroadcastFaker.php b/tests/App/Broadcasting/BroadcastFaker.php new file mode 100644 index 00000000..64b1099a --- /dev/null +++ b/tests/App/Broadcasting/BroadcastFaker.php @@ -0,0 +1,107 @@ +getMessages()); + + return $this; + } + + public function reset(): self + { + $this->container->get(InMemoryDriver::class)->reset(); + + return $this; + } + + public function assertPushedTimes(string|\Stringable $topic, int $times = 1): array + { + $messages = $this->filterMessages((string)$topic); + + TestCase::assertCount( + $times, + $messages, + \sprintf( + 'The expected message in topic [%s] was sent {%d} times instead of {%d} times.', + $topic, + \count($messages), + $times, + ), + ); + + return $messages; + } + + + public function assertPushed(string|\Stringable $topic, \Closure $callback = null): self + { + $messages = $this->filterMessages((string)$topic, $callback); + + TestCase::assertTrue( + \count($messages) > 0, + \sprintf('The expected message [%s] was not pushed.', $topic), + ); + + return $this; + } + + public function assertNotPushed(string|\Stringable $topic, \Closure $callback = null): self + { + $messages = $this->filterMessages((string)$topic, $callback); + + TestCase::assertCount( + 0, + $messages, + \sprintf('The unexpected message [%s] was pushed.', $topic), + ); + + return $this; + } + + public function assertNothingPushed(): self + { + $pushedMessages = $this->getMessages(); + $messages = \implode(', ', \array_keys($this->getMessages())); + + TestCase::assertCount( + 0, + $pushedMessages, + \sprintf('The following messages were pushed unexpectedly in the following topics: %s', $messages), + ); + + return $this; + } + + private function getMessages(): array + { + return $this->container->get(InMemoryDriver::class)->published(); + } + + private function filterMessages(string $topic, \Closure $callback = null): array + { + $messages = $this->getMessages()[$topic] ?? []; + + $callback = $callback ?: static function (array $data): bool { + return true; + }; + + return \array_filter($messages, static function (array $data) use ($callback) { + return $callback($data); + }); + } +} diff --git a/tests/App/Events/EventExpectation.php b/tests/App/Events/EventExpectation.php new file mode 100644 index 00000000..2b73b04d --- /dev/null +++ b/tests/App/Events/EventExpectation.php @@ -0,0 +1,27 @@ +expectation->andReturn($event); + } + + public function andThrowNotFound(): void + { + $this->expectation->andThrow(new EntityNotFoundException('Event not found')); + } +} diff --git a/tests/App/Events/EventsMocker.php b/tests/App/Events/EventsMocker.php new file mode 100644 index 00000000..db1e2e3b --- /dev/null +++ b/tests/App/Events/EventsMocker.php @@ -0,0 +1,44 @@ +events + ->shouldReceive('findByPK') + ->with((string)$uuid) + ->once(), + ); + } + + public function eventShouldBeDeleted(Uuid $uuid, bool $status = true): void + { + $this->events + ->shouldReceive('deleteByPK') + ->with((string)$uuid) + ->once() + ->andReturn($status); + } + + public function eventShouldBeClear(?string $type = null): void + { + $this->events + ->shouldReceive('deleteAll') + ->with($type ? ['type' => $type] : []) + ->once(); + } +} diff --git a/tests/App/Http/HttpFaker.php b/tests/App/Http/HttpFaker.php new file mode 100644 index 00000000..8512ad99 --- /dev/null +++ b/tests/App/Http/HttpFaker.php @@ -0,0 +1,77 @@ +date = Carbon::create(2021, 1, 1, 0, 0, 0); + } + + public function showEvent(Uuid $uuid): ResponseAssertions + { + return $this->makeResponse( + $this->http->getJson(uri: '/api/event/' . $uuid), + ); + } + + public function deleteEvent(Uuid $uuid): ResponseAssertions + { + return $this->makeResponse( + $this->http->deleteJson(uri: '/api/event/' . $uuid), + ); + } + + public function clearEvents(?string $type = null): ResponseAssertions + { + return $this->makeResponse( + $this->http->deleteJson( + uri: '/api/events/', + data: $type ? ['type' => $type] : [], + ), + ); + } + + public function __call(string $name, array $arguments): ResponseAssertions + { + if (!method_exists($this->http, $name)) { + throw new \Exception("Method $name does not exist"); + } + + return $this->makeResponse( + $this->http->$name(...$arguments), + ); + } + + private function makeResponse(TestResponse $response): ResponseAssertions + { + if ($this->dumpResponse) { + $body = (string)$response; + + try { + $body = \json_decode($body, true, 512, JSON_THROW_ON_ERROR); + } catch (\Throwable) { + } + } + + return new ResponseAssertions($response); + } +} diff --git a/tests/App/Http/ResponseAssertions.php b/tests/App/Http/ResponseAssertions.php new file mode 100644 index 00000000..c7afdac0 --- /dev/null +++ b/tests/App/Http/ResponseAssertions.php @@ -0,0 +1,207 @@ +response); + + return $this; + } + + public function assertNotFoundResource(string $message = 'Not found'): self + { + return $this + ->assertNotFound() + ->assertBodySame( + \json_encode([ + 'message' => $message, + 'code' => 404, + ]), + ); + } + + public function assertResource(ResourceInterface $resource): self + { + $needle = \json_encode($resource); + TestCase::assertSame( + $needle, + (string)$this->response, + \sprintf('Response is not same with [%s]', $needle), + ); + + return $this; + } + + /** + * @param ResourceInterface[] $resources + */ + public function assertCollectionContainResources(array $resources): self + { + foreach ($resources as $resource) { + $this->assertCollectionHasResource($resource); + } + + return $this; + } + + /** + * @param ResourceInterface[] $resources + */ + public function assertCollectionMissingResources(array $resources): self + { + foreach ($resources as $resource) { + $this->assertCollectionMissingResource($resource); + } + + return $this; + } + + public function assertCollectionHasResource(ResourceInterface $resource): self + { + $needle = \json_encode($resource); + $responseData = \json_decode((string)$this->response, true); + + foreach ($responseData['data'] as $item) { + if ($needle === \json_encode($item)) { + return $this; + } + } + + TestCase::fail( + \sprintf('Response does not contain resource [%s]', $needle), + ); + } + + public function assertCollectionMissingResource(ResourceInterface $resource): self + { + $needle = \json_encode($resource); + $responseData = \json_decode((string)$this->response, true); + + foreach ($responseData['data'] as $item) { + if ($needle === \json_encode($item)) { + TestCase::fail( + \sprintf('Response contains resource [%s]', $needle), + ); + } + } + + return $this; + } + + public function assertJsonResponseSame(array $data): self + { + $needle = \json_encode($data); + TestCase::assertSame( + $needle, + (string)$this->response, + \sprintf('Response is not same with [%s]', $needle), + ); + + return $this; + } + + public function assertJsonResponseContains(array $data): self + { + $needle = \json_encode($data); + $responseData = \json_decode((string)$this->response, true); + + $intersection = \array_intersect_key($responseData, $data); + + $diff = []; + + foreach ($data as $key => $value) { + if ($intersection[$key] !== $value) { + $diff[] = $key; + } + } + + TestCase::assertSame( + $needle, + \json_encode($intersection), + \sprintf('The following keys are not same: [%s]', \implode(', ', $diff)), + ); + + return $this; + } + + public function assertValidationErrors(array $errors = []): self + { + $responseData = \json_decode((string)$this->response, true); + + if (!\array_is_list($errors)) { + foreach ($errors as $key => $value) { + TestCase::assertArrayHasKey( + $key, + $responseData['errors'] ?? [], + \sprintf('Validation error for key [%s] not found', $key), + ); + + TestCase::assertSame( + $value, + $responseData['errors'][$key], + \sprintf('Validation error for key [%s] is not same', $key), + ); + } + } else { + $diff = \array_diff($errors, \array_keys($responseData['errors'])); + + TestCase::assertEmpty( + $diff, + \sprintf('Validation errors for keys [%s] not found', \implode(', ', $diff)), + ); + } + + return $this + ->assertJsonResponseContains([ + 'message' => 'The given data was invalid.', + 'code' => 422, + 'context' => null, + ]) + ->assertUnprocessable(); + } + + public function assertSuccessResource(bool $status = true): self + { + return $this->assertOk()->assertResource(new SuccessResource($status)); + } + + public function assertRedirect(int $status = 302, ?string $uri = null): self + { + $this->assertStatus($status); + + if ($uri !== null) { + $this->assertHasHeader('Location', $uri); + } + + return $this; + } + + public function __call(string $name, array $arguments): self + { + if (!method_exists($this->response, $name)) { + throw new \Exception("Method $name does not exist"); + } + + $this->response->$name(...$arguments); + + return $this; + } +} diff --git a/tests/Feature/Interfaces/Http/ControllerTestCase.php b/tests/Feature/Interfaces/Http/ControllerTestCase.php new file mode 100644 index 00000000..ac687563 --- /dev/null +++ b/tests/Feature/Interfaces/Http/ControllerTestCase.php @@ -0,0 +1,26 @@ +http = new HttpFaker($this->fakeHttp(), $this); + } + + protected function randomUuid(): Uuid + { + return Uuid::generate(); + } +} diff --git a/tests/Feature/Interfaces/Http/Events/ClearActionTest.php b/tests/Feature/Interfaces/Http/Events/ClearActionTest.php new file mode 100644 index 00000000..3547c5ea --- /dev/null +++ b/tests/Feature/Interfaces/Http/Events/ClearActionTest.php @@ -0,0 +1,28 @@ +fakeEvents()->eventShouldBeClear(); + + $this->http + ->clearEvents() + ->assertSuccessResource(); + } + + public function testClearEventsByType(): void + { + $this->fakeEvents()->eventShouldBeClear(type: 'test'); + + $this->http + ->clearEvents(type: 'test') + ->assertSuccessResource(); + } +} diff --git a/tests/Feature/Interfaces/Http/Events/DeleteActionTest.php b/tests/Feature/Interfaces/Http/Events/DeleteActionTest.php new file mode 100644 index 00000000..aa135d39 --- /dev/null +++ b/tests/Feature/Interfaces/Http/Events/DeleteActionTest.php @@ -0,0 +1,21 @@ +randomUuid(); + + $this->fakeEvents()->eventShouldBeDeleted($uuid); + + $this->http + ->deleteEvent($uuid) + ->assertSuccessResource(); + } +} diff --git a/tests/Feature/Interfaces/Http/Events/ShowActionTest.php b/tests/Feature/Interfaces/Http/Events/ShowActionTest.php new file mode 100644 index 00000000..2db27e0f --- /dev/null +++ b/tests/Feature/Interfaces/Http/Events/ShowActionTest.php @@ -0,0 +1,49 @@ +randomUuid(), + type: 'test', + payload: new Json(['foo' => 'bar']), + timestamp: 123.456, + projectId: null, + ); + + $this->fakeEvents() + ->shouldRequestEventByUuid($event->getUuid()) + ->andReturnEvent($event); + + $this->http + ->showEvent($event->getUuid()) + ->assertOk() + ->assertResource(new EventResource($event)); + } + + public function testNotFoundShowEvent(): void + { + $uuid = $this->randomUuid(); + $this->fakeEvents() + ->shouldRequestEventByUuid($uuid) + ->andThrowNotFound(); + + $this->http + ->showEvent($uuid) + ->assertNotFound() + ->assertJsonResponseSame([ + 'message' => 'Event not found', + 'code' => 404, + ]); + } +} diff --git a/tests/Feature/Interfaces/Http/HttpDumps/HttpDumpsActionTest.php b/tests/Feature/Interfaces/Http/HttpDumps/HttpDumpsActionTest.php new file mode 100644 index 00000000..c3342c38 --- /dev/null +++ b/tests/Feature/Interfaces/Http/HttpDumps/HttpDumpsActionTest.php @@ -0,0 +1,103 @@ +http + ->postJson( + uri: '/', + data: ['foo' => 'bar'], + headers: ['X-Buggregator-Event' => 'http-dump'], + cookies: ['foo' => 'bar'], + ) + ->assertOk(); + + $this->broadcastig->assertPushed('events', function (array $data) { + $this->assertSame('event.received', $data['event']); + $this->assertSame('http-dump', $data['data']['type']); + $this->assertSame('POST', $data['data']['payload']['request']['method']); + $this->assertSame('', $data['data']['payload']['request']['uri']); + $this->assertSame(['http-dump'], $data['data']['payload']['request']['headers']['X-Buggregator-Event']); + $this->assertSame('{"foo":"bar"}', $data['data']['payload']['request']['body']); + $this->assertSame(['foo' => 'bar'], $data['data']['payload']['request']['cookies']); + $this->assertSame([], $data['data']['payload']['request']['files']); + $this->assertSame(['foo' => 'bar'], $data['data']['payload']['request']['post']); + $this->assertSame([], $data['data']['payload']['request']['query']); + + $this->assertNotEmpty($data['data']['uuid']); + $this->assertNotEmpty($data['data']['timestamp']); + $this->assertNotEmpty($data['data']['payload']['received_at']); + + return true; + }); + } + + public function testHttpDumpsGet(): void + { + $this->http + ->getJson( + uri: '/?bar=foo', + query: ['foo' => 'bar'], + headers: ['X-Buggregator-Event' => 'http-dump'], + cookies: ['foo' => 'bar'], + ) + ->assertOk(); + + $this->broadcastig->assertPushed('events', function (array $data) { + $this->assertSame('event.received', $data['event']); + $this->assertSame('http-dump', $data['data']['type']); + $this->assertSame('GET', $data['data']['payload']['request']['method']); + $this->assertSame('', $data['data']['payload']['request']['uri']); + $this->assertSame(['http-dump'], $data['data']['payload']['request']['headers']['X-Buggregator-Event']); + $this->assertSame('{"foo":"bar"}', $data['data']['payload']['request']['body']); + $this->assertSame(['foo' => 'bar'], $data['data']['payload']['request']['cookies']); + $this->assertSame([], $data['data']['payload']['request']['files']); + $this->assertSame(['foo' => 'bar'], $data['data']['payload']['request']['post']); + $this->assertSame(['bar' => 'foo'], $data['data']['payload']['request']['query']); + + $this->assertNotEmpty($data['data']['uuid']); + $this->assertNotEmpty($data['data']['timestamp']); + $this->assertNotEmpty($data['data']['payload']['received_at']); + + return true; + }); + } + + public function testHttpDumpsDelete(): void + { + $this->http + ->deleteJson( + uri: '/', + data: ['foo' => 'bar'], + headers: ['X-Buggregator-Event' => 'http-dump'], + cookies: ['foo' => 'bar'], + ) + ->assertOk(); + + $this->broadcastig->assertPushed('events', function (array $data) { + $this->assertSame('event.received', $data['event']); + $this->assertSame('http-dump', $data['data']['type']); + $this->assertSame('DELETE', $data['data']['payload']['request']['method']); + $this->assertSame('', $data['data']['payload']['request']['uri']); + $this->assertSame(['http-dump'], $data['data']['payload']['request']['headers']['X-Buggregator-Event']); + $this->assertSame('{"foo":"bar"}', $data['data']['payload']['request']['body']); + $this->assertSame(['foo' => 'bar'], $data['data']['payload']['request']['cookies']); + $this->assertSame([], $data['data']['payload']['request']['files']); + $this->assertSame(['foo' => 'bar'], $data['data']['payload']['request']['post']); + $this->assertSame([], $data['data']['payload']['request']['query']); + + $this->assertNotEmpty($data['data']['uuid']); + $this->assertNotEmpty($data['data']['timestamp']); + $this->assertNotEmpty($data['data']['payload']['received_at']); + + return true; + }); + } +} diff --git a/tests/Feature/Interfaces/Http/Inspector/InspectorActionTest.php b/tests/Feature/Interfaces/Http/Inspector/InspectorActionTest.php new file mode 100644 index 00000000..377c1096 --- /dev/null +++ b/tests/Feature/Interfaces/Http/Inspector/InspectorActionTest.php @@ -0,0 +1,50 @@ +http + ->post( + uri: '/', + data: Stream::create( + <<<'BODY' +W3sibW9kZWwiOiJ0cmFuc2FjdGlvbiIsIm5hbWUiOiJcL2ZvbyIsInR5cGUiOiJwcm9jZXNzIiwiaGFzaCI6Ijk3OWZmYmNlY2ZjZDNhNzJjMWM0ZDUzNmFhMWZlODViM2U5OTZkZjFkNzA5Mzc1NWI5YjRhMWRlZDhlMzNiNWMiLCJob3N0Ijp7Imhvc3RuYW1lIjoiQnV0c2Noc3RlckxwcCIsImlwIjoiMTI3LjAuMS4xIiwib3MiOiJMaW51eCJ9LCJ0aW1lc3RhbXAiOjE3MDE0NjQwMzkuNjUwNjIyLCJtZW1vcnlfcGVhayI6MTUuNTMsImR1cmF0aW9uIjowLjIyfSx7Im1vZGVsIjoic2VnbWVudCIsInR5cGUiOiJteS1wcm9jZXNzIiwiaG9zdCI6eyJob3N0bmFtZSI6IkJ1dHNjaHN0ZXJMcHAiLCJpcCI6IjEyNy4wLjEuMSIsIm9zIjoiTGludXgifSwidHJhbnNhY3Rpb24iOnsibmFtZSI6IlwvZm9vIiwiaGFzaCI6Ijk3OWZmYmNlY2ZjZDNhNzJjMWM0ZDUzNmFhMWZlODViM2U5OTZkZjFkNzA5Mzc1NWI5YjRhMWRlZDhlMzNiNWMiLCJ0aW1lc3RhbXAiOjE3MDE0NjQwMzkuNjUwNjIyfSwic3RhcnQiOjAuMiwidGltZXN0YW1wIjoxNzAxNDY0MDM5LjY1MDgyNiwiZHVyYXRpb24iOjAuMDF9XQ== +BODY, + ), + headers: [ + 'X-Buggregator-Event' => 'inspector', + 'X-Inspector-Key' => 'test', + 'X-Inspector-Version' => '1.0.0', + ], + )->assertOk(); + + $this->broadcastig->assertPushed('events', function (array $data) { + $this->assertSame('event.received', $data['event']); + $this->assertSame('inspector', $data['data']['type']); + + $this->assertSame('transaction', $data['data']['payload'][0]['model']); + $this->assertSame('/foo', $data['data']['payload'][0]['name']); + $this->assertSame('process', $data['data']['payload'][0]['type']); + $this->assertSame( + '979ffbcecfcd3a72c1c4d536aa1fe85b3e996df1d7093755b9b4a1ded8e33b5c', + $data['data']['payload'][0]['hash'], + ); + + $this->assertSame('segment', $data['data']['payload'][1]['model']); + $this->assertSame('my-process', $data['data']['payload'][1]['type']); + + $this->assertNotEmpty($data['data']['uuid']); + $this->assertNotEmpty($data['data']['timestamp']); + + return true; + }); + } +} diff --git a/tests/Feature/Interfaces/Http/Ray/AvailabilityCheckActionTest.php b/tests/Feature/Interfaces/Http/Ray/AvailabilityCheckActionTest.php new file mode 100644 index 00000000..0c63be3e --- /dev/null +++ b/tests/Feature/Interfaces/Http/Ray/AvailabilityCheckActionTest.php @@ -0,0 +1,18 @@ +http->getJson('/_availability_check', headers: [ + 'X-Buggregator-Event' => 'ray', + ]) + ->assertStatus(400); + } +} diff --git a/tests/Feature/Interfaces/Http/Ray/LocksActionTest.php b/tests/Feature/Interfaces/Http/Ray/LocksActionTest.php new file mode 100644 index 00000000..30d2b4d7 --- /dev/null +++ b/tests/Feature/Interfaces/Http/Ray/LocksActionTest.php @@ -0,0 +1,35 @@ +http->getJson('/locks/123', headers: [ + 'X-Buggregator-Event' => 'ray', + ]) + ->assertOk() + ->assertJsonResponseSame([ + 'active' => true, + 'stop_execution' => false, + ]); + } + + public function testCheckWithLock(): void + { + $cache = $this->get(CacheInterface::class); + $cache->set('123', $response = ['active' => false, 'stop_execution' => true]); + + $this->http->getJson('/locks/123', headers: [ + 'X-Buggregator-Event' => 'ray', + ]) + ->assertOk() + ->assertJsonResponseSame($response); + } +} diff --git a/tests/Feature/Interfaces/Http/Ray/RayActionTest.php b/tests/Feature/Interfaces/Http/Ray/RayActionTest.php new file mode 100644 index 00000000..4630d1b9 --- /dev/null +++ b/tests/Feature/Interfaces/Http/Ray/RayActionTest.php @@ -0,0 +1,82 @@ +http->postJson( + uri: '/', + data: Stream::create( + <<<'JSON' +{"uuid":"11325003-b9cf-4c06-83d0-8a18fe368ac4","payloads":[{"type":"log","content":{"values":["foo"],"meta":[{"clipboard_data":"foo"}]},"origin":{"file":"\/root\/repos\/buggreagtor\/spiral-app\/tests\/Feature\/Interfaces\/Http\/Ray\/RayActionTest.php","line_number":13,"hostname":"ButschsterLpp"}}],"meta":{"php_version":"8.2.5","php_version_id":80205,"project_name":"","ray_package_version":"1.40.1.0"}} +JSON, + ), + headers: [ + 'X-Buggregator-Event' => 'ray', + ], + )->assertOk(); + + $this->broadcastig->assertPushed('events', function (array $data) { + $this->assertSame('event.received', $data['event']); + $this->assertSame('ray', $data['data']['type']); + + $this->assertSame('11325003-b9cf-4c06-83d0-8a18fe368ac4', $data['data']['payload']['uuid']); + $this->assertSame('8.2.5', $data['data']['payload']['meta']['php_version']); + $this->assertSame('1.40.1.0', $data['data']['payload']['meta']['ray_package_version']); + + + $this->assertSame('log', $data['data']['payload']['payloads'][0]['type']); + $this->assertSame(['foo'], $data['data']['payload']['payloads'][0]['content']['values']); + + $this->assertNotEmpty($data['data']['uuid']); + $this->assertNotEmpty($data['data']['timestamp']); + + return true; + }); + } + + public function testSendDumpWithMerge(): void + { + $payload = <<<'JSON' +{"uuid":"11325003-b9cf-4c06-83d0-8a18fe368ac4","payloads":[{"type":"log","content":{"values":["foo"],"meta":[{"clipboard_data":"foo"}]},"origin":{"file":"\/root\/repos\/buggreagtor\/spiral-app\/tests\/Feature\/Interfaces\/Http\/Ray\/RayActionTest.php","line_number":13,"hostname":"ButschsterLpp"}}],"meta":{"php_version":"8.2.5","php_version_id":80205,"project_name":"","ray_package_version":"1.40.1.0"}} +JSON; + $color = <<<'JSON' +{"uuid":"11325003-b9cf-4c06-83d0-8a18fe368ac4","payloads":[{"type":"color","content":{"color":"red"},"origin":{"file":"\/root\/repos\/buggreagtor\/spiral-app\/tests\/Feature\/Interfaces\/Http\/Ray\/RayActionTest.php","line_number":47,"hostname":"ButschsterLpp"}}],"meta":{"php_version":"8.2.5","php_version_id":80205,"project_name":"","ray_package_version":"1.40.1.0"}} +JSON; + + $this->http->postJson(uri: '/', data: Stream::create($payload), headers: ['X-Buggregator-Event' => 'ray',], + )->assertOk(); + $this->broadcastig->reset(); + $this->http->postJson(uri: '/', data: Stream::create($color), headers: ['X-Buggregator-Event' => 'ray',], + )->assertOk(); + + $this->broadcastig->assertPushed('events', function (array $data) { + $this->assertSame('event.received', $data['event']); + $this->assertSame('ray', $data['data']['type']); + + $this->assertSame('11325003-b9cf-4c06-83d0-8a18fe368ac4', $data['data']['payload']['uuid']); + $this->assertSame('8.2.5', $data['data']['payload']['meta']['php_version']); + $this->assertSame('1.40.1.0', $data['data']['payload']['meta']['ray_package_version']); + + + $this->assertSame('log', $data['data']['payload']['payloads'][0]['type']); + $this->assertSame(['foo'], $data['data']['payload']['payloads'][0]['content']['values']); + + + $this->assertSame('color', $data['data']['payload']['payloads'][1]['type']); + $this->assertSame(['color' => 'red'], $data['data']['payload']['payloads'][1]['content']); + + $this->assertNotEmpty($data['data']['uuid']); + $this->assertNotEmpty($data['data']['timestamp']); + + return true; + }); + } +} diff --git a/tests/Feature/Interfaces/Http/Sentry/SentryV3ActionTest.php b/tests/Feature/Interfaces/Http/Sentry/SentryV3ActionTest.php new file mode 100644 index 00000000..1952f05a --- /dev/null +++ b/tests/Feature/Interfaces/Http/Sentry/SentryV3ActionTest.php @@ -0,0 +1,45 @@ +http + ->postJson( + uri: '/api/1/store/', + data: Stream::create( + <<<'BODY' +{"event_id":"f7b7f09d40e645c79a8a2846e2111c81","timestamp":1701453725.632805,"platform":"php","sdk":{"name":"sentry.php","version":"3.22.1"},"logger":"php","server_name":"Test","environment":"production","modules":{"amphp\/amp":"v2.6.2","amphp\/byte-stream":"v1.8.1","brick\/math":"0.11.0","buggregator\/app":"dev-master@818ea82","clue\/stream-filter":"v1.6.0","cocur\/slugify":"v3.2","codedungeon\/php-cli-colors":"1.12.2","composer\/pcre":"3.1.1","composer\/semver":"3.4.0","composer\/xdebug-handler":"3.0.3","cycle\/annotated":"v3.4.0","cycle\/database":"2.6.1","cycle\/migrations":"v4.2.1","cycle\/orm":"v2.4.0","cycle\/schema-builder":"v2.6.1","cycle\/schema-migrations-generator":"2.2.0","cycle\/schema-renderer":"1.2.0","defuse\/php-encryption":"v2.4.0","dnoegel\/php-xdg-base-dir":"v0.1.1","doctrine\/annotations":"2.0.1","doctrine\/collections":"1.8.0","doctrine\/deprecations":"1.1.2","doctrine\/inflector":"2.0.8","doctrine\/instantiator":"2.0.0","doctrine\/lexer":"3.0.0","egulias\/email-validator":"4.0.2","felixfbecker\/advanced-json-rpc":"v3.2.1","felixfbecker\/language-server-protocol":"v1.5.2","fidry\/cpu-core-counter":"0.5.1","google\/common-protos":"v4.4.0","google\/protobuf":"v3.25.1","graham-campbell\/result-type":"v1.1.2","grpc\/grpc":"1.57.0","guzzlehttp\/promises":"2.0.1","guzzlehttp\/psr7":"2.6.1","hamcrest\/hamcrest-php":"v2.0.1","http-interop\/http-factory-guzzle":"1.2.0","jean85\/pretty-package-versions":"2.0.5","league\/event":"3.0.2","league\/flysystem":"2.5.0","league\/mime-type-detection":"1.14.0","mockery\/mockery":"1.6.6","monolog\/monolog":"2.9.2","myclabs\/deep-copy":"1.11.1","nesbot\/carbon":"2.71.0","netresearch\/jsonmapper":"v4.2.0","nette\/php-generator":"v4.1.2","nette\/utils":"v4.0.3","nikic\/php-parser":"v4.17.1","nyholm\/psr7":"1.8.1","paragonie\/random_compat":"v9.99.100","phar-io\/manifest":"2.0.3","phar-io\/version":"3.2.1","php-http\/client-common":"2.7.1","php-http\/discovery":"1.19.2","php-http\/httplug":"2.4.0","php-http\/message":"1.16.0","php-http\/message-factory":"1.1.0","php-http\/promise":"1.2.1","phpdocumentor\/reflection-common":"2.2.0","phpdocumentor\/reflection-docblock":"5.3.0","phpdocumentor\/type-resolver":"1.7.3","phpoption\/phpoption":"1.9.2","phpstan\/phpdoc-parser":"1.24.3","phpunit\/php-code-coverage":"9.2.29","phpunit\/php-file-iterator":"3.0.6","phpunit\/php-invoker":"3.1.1","phpunit\/php-text-template":"2.0.4","phpunit\/php-timer":"5.0.3","phpunit\/phpunit":"9.6.13","pimple\/pimple":"v3.5.0","psr\/cache":"3.0.0","psr\/clock":"1.0.0","psr\/container":"2.0.2","psr\/event-dispatcher":"1.0.0","psr\/http-client":"1.0.3","psr\/http-factory":"1.0.2","psr\/http-message":"2.0","psr\/http-server-handler":"1.0.2","psr\/http-server-middleware":"1.0.2","psr\/log":"3.0.0","psr\/simple-cache":"3.0.0","qossmic\/deptrac-shim":"1.0.2","ralouphie\/getallheaders":"3.0.3","ramsey\/collection":"2.0.0","ramsey\/uuid":"4.7.5","roadrunner-php\/app-logger":"1.1.0","roadrunner-php\/centrifugo":"2.0.0","roadrunner-php\/roadrunner-api-dto":"1.4.0","sebastian\/cli-parser":"1.0.1","sebastian\/code-unit":"1.0.8","sebastian\/code-unit-reverse-lookup":"2.0.3","sebastian\/comparator":"4.0.8","sebastian\/complexity":"2.0.2","sebastian\/diff":"4.0.5","sebastian\/environment":"5.1.5","sebastian\/exporter":"4.0.5","sebastian\/global-state":"5.0.6","sebastian\/lines-of-code":"1.0.3","sebastian\/object-enumerator":"4.0.4","sebastian\/object-reflector":"2.0.4","sebastian\/recursion-context":"4.0.5","sebastian\/resource-operations":"3.0.3","sebastian\/type":"3.2.1","sebastian\/version":"3.0.2","sentry\/sdk":"3.5.0","sentry\/sentry":"3.22.1","spatie\/array-to-xml":"3.2.2","spiral-packages\/cqrs":"v2.2.0","spiral-packages\/league-event":"1.0.1","spiral\/attributes":"v3.1.2","spiral\/composer-publish-plugin":"v1.1.2","spiral\/cycle-bridge":"v2.8.0","spiral\/data-grid":"v3.0.0","spiral\/data-grid-bridge":"v3.0.1","spiral\/framework":"3.10.0","spiral\/goridge":"4.1.0","spiral\/nyholm-bridge":"v1.3.0","spiral\/roadrunner":"v2023.3.6","spiral\/roadrunner-bridge":"3.0.2","spiral\/roadrunner-grpc":"3.1.0","spiral\/roadrunner-http":"3.2.0","spiral\/roadrunner-jobs":"4.2.0","spiral\/roadrunner-kv":"4.0.0","spiral\/roadrunner-metrics":"3.1.0","spiral\/roadrunner-services":"2.1.0","spiral\/roadrunner-tcp":"3.0.0","spiral\/roadrunner-worker":"3.2.0","spiral\/testing":"2.6.2","spiral\/validator":"1.5.0","symfony\/clock":"v6.3.4","symfony\/console":"v6.3.8","symfony\/deprecation-contracts":"v3.4.0","symfony\/event-dispatcher":"v6.3.2","symfony\/event-dispatcher-contracts":"v3.4.0","symfony\/filesystem":"v6.3.1","symfony\/finder":"v6.3.5","symfony\/http-client":"v6.4.0","symfony\/http-client-contracts":"v3.4.0","symfony\/mailer":"v6.3.5","symfony\/messenger":"v6.3.7","symfony\/mime":"v6.3.5","symfony\/options-resolver":"v7.0.0","symfony\/polyfill-ctype":"v1.28.0","symfony\/polyfill-iconv":"v1.28.0","symfony\/polyfill-intl-grapheme":"v1.28.0","symfony\/polyfill-intl-idn":"v1.28.0","symfony\/polyfill-intl-normalizer":"v1.28.0","symfony\/polyfill-mbstring":"v1.28.0","symfony\/polyfill-php72":"v1.28.0","symfony\/polyfill-php80":"v1.28.0","symfony\/polyfill-php83":"v1.28.0","symfony\/process":"v6.3.4","symfony\/service-contracts":"v3.4.0","symfony\/string":"v6.3.8","symfony\/translation":"v6.3.7","symfony\/translation-contracts":"v3.4.0","symfony\/var-dumper":"v6.3.8","symfony\/yaml":"v6.3.8","theseer\/tokenizer":"1.2.2","vimeo\/psalm":"5.16.0","vlucas\/phpdotenv":"v5.6.0","webmozart\/assert":"1.11.0","yiisoft\/friendly-exception":"1.1.0","zbateson\/mail-mime-parser":"2.4.0","zbateson\/mb-wrapper":"1.2.0","zbateson\/stream-decorators":"1.2.1","zentlix\/swagger-php":"1.x-dev@1f4927a","zircote\/swagger-php":"4.7.15"},"contexts":{"os":{"name":"Linux","version":"5.15.133.1-microsoft-standard-WSL2","build":"#1 SMP Thu Oct 5 21:02:42 UTC 2023","kernel_version":"Linux Test 5.15.133.1-microsoft-standard-WSL2 #1 SMP Thu Oct 5 21:02:42 UTC 2023 x86_64"},"runtime":{"name":"php","version":"8.2.5"},"trace":{"trace_id":"9fc5094fa3a048209c9cc5f86fab33c8","span_id":"ce2a557634354a20"}},"exception":{"values":[{"type":"Exception","value":"test","stacktrace":{"frames":[{"filename":"\/vendor\/phpunit\/phpunit\/phpunit","lineno":107,"in_app":true,"abs_path":"\\/vendor\/phpunit\/phpunit\/phpunit","pre_context":["","unset($options);","","require PHPUNIT_COMPOSER_INSTALL;",""],"context_line":"PHPUnit\\TextUI\\Command::main();","post_context":[""]},{"filename":"\/vendor\/phpunit\/phpunit\/src\/TextUI\/Command.php","lineno":97,"in_app":true,"abs_path":"\\/vendor\/phpunit\/phpunit\/src\/TextUI\/Command.php","function":"PHPUnit\\TextUI\\Command::main","raw_function":"PHPUnit\\TextUI\\Command::main","pre_context":[" * @throws Exception"," *\/"," public static function main(bool $exit = true): int"," {"," try {"],"context_line":" return (new static)->run($_SERVER['argv'], $exit);","post_context":[" } catch (Throwable $t) {"," throw new RuntimeException("," $t->getMessage(),"," (int) $t->getCode(),"," $t,"]},{"filename":"\/vendor\/phpunit\/phpunit\/src\/TextUI\/Command.php","lineno":144,"in_app":true,"abs_path":"\\/vendor\/phpunit\/phpunit\/src\/TextUI\/Command.php","function":"PHPUnit\\TextUI\\Command::run","raw_function":"PHPUnit\\TextUI\\Command::run","pre_context":[" }",""," unset($this->arguments['test'], $this->arguments['testFile']);",""," try {"],"context_line":" $result = $runner->run($suite, $this->arguments, $this->warnings, $exit);","post_context":[" } catch (Throwable $t) {"," print $t->getMessage() . PHP_EOL;"," }",""," $return = TestRunner::FAILURE_EXIT;"]},{"filename":"\/vendor\/phpunit\/phpunit\/src\/TextUI\/TestRunner.php","lineno":651,"in_app":true,"abs_path":"\\/vendor\/phpunit\/phpunit\/src\/TextUI\/TestRunner.php","function":"PHPUnit\\TextUI\\TestRunner::run","raw_function":"PHPUnit\\TextUI\\TestRunner::run","pre_context":[" if ($extension instanceof BeforeFirstTestHook) {"," $extension->executeBeforeFirstTest();"," }"," }",""],"context_line":" $suite->run($result);","post_context":[""," foreach ($this->extensions as $extension) {"," if ($extension instanceof AfterLastTestHook) {"," $extension->executeAfterLastTest();"," }"]},{"filename":"\/vendor\/phpunit\/phpunit\/src\/Framework\/TestSuite.php","lineno":684,"in_app":true,"abs_path":"\\/vendor\/phpunit\/phpunit\/src\/Framework\/TestSuite.php","function":"PHPUnit\\Framework\\TestSuite::run","raw_function":"PHPUnit\\Framework\\TestSuite::run","pre_context":[" $test->setBackupGlobals($this->backupGlobals);"," $test->setBackupStaticAttributes($this->backupStaticAttributes);"," $test->setRunTestInSeparateProcess($this->runTestInSeparateProcess);"," }",""],"context_line":" $test->run($result);","post_context":[" }",""," if ($this->testCase && class_exists($this->name, false)) {"," foreach ($hookMethods['afterClass'] as $afterClassMethod) {"," if (method_exists($this->name, $afterClassMethod)) {"]},{"filename":"\/vendor\/phpunit\/phpunit\/src\/Framework\/TestSuite.php","lineno":684,"in_app":true,"abs_path":"\\/vendor\/phpunit\/phpunit\/src\/Framework\/TestSuite.php","function":"PHPUnit\\Framework\\TestSuite::run","raw_function":"PHPUnit\\Framework\\TestSuite::run","pre_context":[" $test->setBackupGlobals($this->backupGlobals);"," $test->setBackupStaticAttributes($this->backupStaticAttributes);"," $test->setRunTestInSeparateProcess($this->runTestInSeparateProcess);"," }",""],"context_line":" $test->run($result);","post_context":[" }",""," if ($this->testCase && class_exists($this->name, false)) {"," foreach ($hookMethods['afterClass'] as $afterClassMethod) {"," if (method_exists($this->name, $afterClassMethod)) {"]},{"filename":"\/vendor\/phpunit\/phpunit\/src\/Framework\/TestCase.php","lineno":968,"in_app":true,"abs_path":"\\/vendor\/phpunit\/phpunit\/src\/Framework\/TestCase.php","function":"PHPUnit\\Framework\\TestCase::run","raw_function":"PHPUnit\\Framework\\TestCase::run","pre_context":[" $template->setVar($var);",""," $php = AbstractPhpProcess::factory();"," $php->runTestJob($template->render(), $this, $result, $processResultFile);"," } else {"],"context_line":" $result->run($this);","post_context":[" }",""," $this->result = null;",""," return $result;"]},{"filename":"\/vendor\/phpunit\/phpunit\/src\/Framework\/TestResult.php","lineno":728,"in_app":true,"abs_path":"\\/vendor\/phpunit\/phpunit\/src\/Framework\/TestResult.php","function":"PHPUnit\\Framework\\TestResult::run","raw_function":"PHPUnit\\Framework\\TestResult::run","pre_context":[" $_timeout = $this->defaultTimeLimit;"," }",""," $invoker->invoke([$test, 'runBare'], [], $_timeout);"," } else {"],"context_line":" $test->runBare();","post_context":[" }"," } catch (TimeoutException $e) {"," $this->addFailure("," $test,"," new RiskyTestError("]},{"filename":"\/vendor\/phpunit\/phpunit\/src\/Framework\/TestCase.php","lineno":1218,"in_app":true,"abs_path":"\\/vendor\/phpunit\/phpunit\/src\/Framework\/TestCase.php","function":"PHPUnit\\Framework\\TestCase::runBare","raw_function":"PHPUnit\\Framework\\TestCase::runBare","pre_context":[""," foreach ($hookMethods['preCondition'] as $method) {"," $this->{$method}();"," }",""],"context_line":" $this->testResult = $this->runTest();","post_context":[" $this->verifyMockObjects();",""," foreach ($hookMethods['postCondition'] as $method) {"," $this->{$method}();"," }"]},{"filename":"\/vendor\/phpunit\/phpunit\/src\/Framework\/TestCase.php","lineno":1612,"in_app":true,"abs_path":"\\/vendor\/phpunit\/phpunit\/src\/Framework\/TestCase.php","function":"PHPUnit\\Framework\\TestCase::runTest","raw_function":"PHPUnit\\Framework\\TestCase::runTest","pre_context":[" $testArguments = array_merge($this->data, $this->dependencyInput);",""," $this->registerMockObjectsFromTestArguments($testArguments);",""," try {"],"context_line":" $testResult = $this->{$this->name}(...array_values($testArguments));","post_context":[" } catch (Throwable $exception) {"," if (!$this->checkExceptionExpectations($exception)) {"," throw $exception;"," }",""]},{"filename":"\/tests\/Feature\/Interfaces\/Http\/HttpDumps\/HttpDumpsActionTest.php","lineno":14,"in_app":true,"abs_path":"\\/tests\/Feature\/Interfaces\/Http\/HttpDumps\/HttpDumpsActionTest.php","function":"Tests\\Feature\\Interfaces\\Http\\HttpDumps\\HttpDumpsActionTest::testHttpDumpsPost","raw_function":"Tests\\Feature\\Interfaces\\Http\\HttpDumps\\HttpDumpsActionTest::testHttpDumpsPost","pre_context":["final class HttpDumpsActionTest extends ControllerTestCase","{"," public function testHttpDumpsPost(): void"," {"," \\Sentry\\init(['dsn' => 'http:\/\/user@127.0.0.1:8082\/1']);"],"context_line":" \\Sentry\\captureException(new \\Exception('test'));","post_context":["",""," $this->http"," ->postJson("," uri: '\/',"]}]},"mechanism":{"type":"generic","handled":true,"data":{"code":0}}}]}} +BODY, + ), + headers: ['X-Buggregator-Event' => 'sentry'], + )->assertOk(); + + $this->broadcastig->assertPushed('events', function (array $data) { + $this->assertSame('event.received', $data['event']); + $this->assertSame('sentry', $data['data']['type']); + + $this->assertSame('f7b7f09d40e645c79a8a2846e2111c81', $data['data']['payload']['event_id']); + $this->assertSame(1701453725.632805, $data['data']['payload']['timestamp']); + $this->assertSame('php', $data['data']['payload']['platform']); + $this->assertSame('php', $data['data']['payload']['logger']); + $this->assertSame('sentry.php', $data['data']['payload']['sdk']['name']); + $this->assertSame('3.22.1', $data['data']['payload']['sdk']['version']); + $this->assertSame('Test', $data['data']['payload']['server_name']); + $this->assertSame('production', $data['data']['payload']['environment']); + + $this->assertNotEmpty($data['data']['uuid']); + $this->assertNotEmpty($data['data']['timestamp']); + + + return true; + }); + } +} diff --git a/tests/Feature/Interfaces/Http/Sentry/SentryV4ActionTest.php b/tests/Feature/Interfaces/Http/Sentry/SentryV4ActionTest.php new file mode 100644 index 00000000..336b5271 --- /dev/null +++ b/tests/Feature/Interfaces/Http/Sentry/SentryV4ActionTest.php @@ -0,0 +1,82 @@ +run($_SERVER['argv'], $exit);","post_context":[" } catch (Throwable $t) {"," throw new RuntimeException("," $t->getMessage(),"," (int) $t->getCode(),"," $t,"]},{"filename":"\/vendor\/phpunit\/phpunit\/src\/TextUI\/Command.php","lineno":144,"in_app":true,"abs_path":"\\/vendor\/phpunit\/phpunit\/src\/TextUI\/Command.php","function":"PHPUnit\\TextUI\\Command::run","raw_function":"PHPUnit\\TextUI\\Command::run","pre_context":[" }",""," unset($this->arguments['test'], $this->arguments['testFile']);",""," try {"],"context_line":" $result = $runner->run($suite, $this->arguments, $this->warnings, $exit);","post_context":[" } catch (Throwable $t) {"," print $t->getMessage() . PHP_EOL;"," }",""," $return = TestRunner::FAILURE_EXIT;"]},{"filename":"\/vendor\/phpunit\/phpunit\/src\/TextUI\/TestRunner.php","lineno":651,"in_app":true,"abs_path":"\\/vendor\/phpunit\/phpunit\/src\/TextUI\/TestRunner.php","function":"PHPUnit\\TextUI\\TestRunner::run","raw_function":"PHPUnit\\TextUI\\TestRunner::run","pre_context":[" if ($extension instanceof BeforeFirstTestHook) {"," $extension->executeBeforeFirstTest();"," }"," }",""],"context_line":" $suite->run($result);","post_context":[""," foreach ($this->extensions as $extension) {"," if ($extension instanceof AfterLastTestHook) {"," $extension->executeAfterLastTest();"," }"]},{"filename":"\/vendor\/phpunit\/phpunit\/src\/Framework\/TestSuite.php","lineno":684,"in_app":true,"abs_path":"\\/vendor\/phpunit\/phpunit\/src\/Framework\/TestSuite.php","function":"PHPUnit\\Framework\\TestSuite::run","raw_function":"PHPUnit\\Framework\\TestSuite::run","pre_context":[" $test->setBackupGlobals($this->backupGlobals);"," $test->setBackupStaticAttributes($this->backupStaticAttributes);"," $test->setRunTestInSeparateProcess($this->runTestInSeparateProcess);"," }",""],"context_line":" $test->run($result);","post_context":[" }",""," if ($this->testCase && class_exists($this->name, false)) {"," foreach ($hookMethods['afterClass'] as $afterClassMethod) {"," if (method_exists($this->name, $afterClassMethod)) {"]},{"filename":"\/vendor\/phpunit\/phpunit\/src\/Framework\/TestSuite.php","lineno":684,"in_app":true,"abs_path":"\\/vendor\/phpunit\/phpunit\/src\/Framework\/TestSuite.php","function":"PHPUnit\\Framework\\TestSuite::run","raw_function":"PHPUnit\\Framework\\TestSuite::run","pre_context":[" $test->setBackupGlobals($this->backupGlobals);"," $test->setBackupStaticAttributes($this->backupStaticAttributes);"," $test->setRunTestInSeparateProcess($this->runTestInSeparateProcess);"," }",""],"context_line":" $test->run($result);","post_context":[" }",""," if ($this->testCase && class_exists($this->name, false)) {"," foreach ($hookMethods['afterClass'] as $afterClassMethod) {"," if (method_exists($this->name, $afterClassMethod)) {"]},{"filename":"\/vendor\/phpunit\/phpunit\/src\/Framework\/TestCase.php","lineno":968,"in_app":true,"abs_path":"\\/vendor\/phpunit\/phpunit\/src\/Framework\/TestCase.php","function":"PHPUnit\\Framework\\TestCase::run","raw_function":"PHPUnit\\Framework\\TestCase::run","pre_context":[" $template->setVar($var);",""," $php = AbstractPhpProcess::factory();"," $php->runTestJob($template->render(), $this, $result, $processResultFile);"," } else {"],"context_line":" $result->run($this);","post_context":[" }",""," $this->result = null;",""," return $result;"]},{"filename":"\/vendor\/phpunit\/phpunit\/src\/Framework\/TestResult.php","lineno":728,"in_app":true,"abs_path":"\\/vendor\/phpunit\/phpunit\/src\/Framework\/TestResult.php","function":"PHPUnit\\Framework\\TestResult::run","raw_function":"PHPUnit\\Framework\\TestResult::run","pre_context":[" $_timeout = $this->defaultTimeLimit;"," }",""," $invoker->invoke([$test, 'runBare'], [], $_timeout);"," } else {"],"context_line":" $test->runBare();","post_context":[" }"," } catch (TimeoutException $e) {"," $this->addFailure("," $test,"," new RiskyTestError("]},{"filename":"\/vendor\/phpunit\/phpunit\/src\/Framework\/TestCase.php","lineno":1218,"in_app":true,"abs_path":"\\/vendor\/phpunit\/phpunit\/src\/Framework\/TestCase.php","function":"PHPUnit\\Framework\\TestCase::runBare","raw_function":"PHPUnit\\Framework\\TestCase::runBare","pre_context":[""," foreach ($hookMethods['preCondition'] as $method) {"," $this->{$method}();"," }",""],"context_line":" $this->testResult = $this->runTest();","post_context":[" $this->verifyMockObjects();",""," foreach ($hookMethods['postCondition'] as $method) {"," $this->{$method}();"," }"]},{"filename":"\/vendor\/phpunit\/phpunit\/src\/Framework\/TestCase.php","lineno":1612,"in_app":true,"abs_path":"\\/vendor\/phpunit\/phpunit\/src\/Framework\/TestCase.php","function":"PHPUnit\\Framework\\TestCase::runTest","raw_function":"PHPUnit\\Framework\\TestCase::runTest","pre_context":[" $testArguments = array_merge($this->data, $this->dependencyInput);",""," $this->registerMockObjectsFromTestArguments($testArguments);",""," try {"],"context_line":" $testResult = $this->{$this->name}(...array_values($testArguments));","post_context":[" } catch (Throwable $exception) {"," if (!$this->checkExceptionExpectations($exception)) {"," throw $exception;"," }",""]},{"filename":"\/tests\/Feature\/Interfaces\/Http\/Sentry\/SentryV4ActionTest.php","lineno":14,"in_app":true,"abs_path":"\\/tests\/Feature\/Interfaces\/Http\/Sentry\/SentryV4ActionTest.php","function":"Interfaces\\Http\\Sentry\\SentryV4ActionTest::testSend","raw_function":"Interfaces\\Http\\Sentry\\SentryV4ActionTest::testSend","pre_context":["final class SentryV4ActionTest extends ControllerTestCase","{"," public function testSend(): void"," {"," \\Sentry\\init(['dsn' => 'http:\/\/user@127.0.0.1:8082\/1']);"],"context_line":" \\Sentry\\captureException(new \\Exception('test'));","post_context":["","\/\/ $this->http","\/\/ ->postJson(","\/\/ uri: '\/api\/1\/store\/',","\/\/ data: Stream::create("]}]},"mechanism":{"type":"generic","handled":true,"data":{"code":0}}}]}} +BODY; + + public function testSendWithoutGzip(): void + { + $this->http + ->postJson( + uri: '/api/1/envelope/', + data: Stream::create(self::JSON), + headers: [ + 'X-Buggregator-Event' => 'sentry', + 'Content-Type' => 'application/x-sentry-envelope', + 'X-Sentry-Auth' => 'Sentry sentry_version=7, sentry_client=sentry.php/4.0.1, sentry_key=user', + ], + )->assertOk(); + + $this->broadcastig->assertPushed('events', function (array $data) { + $this->assertSame('event.received', $data['event']); + $this->assertSame('sentry', $data['data']['type']); + + $this->assertSame(1701455435.634665, $data['data']['payload']['timestamp']); + $this->assertSame('php', $data['data']['payload']['platform']); + $this->assertSame('sentry.php', $data['data']['payload']['sdk']['name']); + $this->assertSame('4.0.1', $data['data']['payload']['sdk']['version']); + $this->assertSame('Test', $data['data']['payload']['server_name']); + $this->assertSame('production', $data['data']['payload']['environment']); + + $this->assertNotEmpty($data['data']['uuid']); + $this->assertNotEmpty($data['data']['timestamp']); + + + return true; + }); + } + + public function testSendGzipped(): void + { + $this->http + ->postJson( + uri: '/api/1/envelope/', + data: Stream::create(\gzcompress(self::JSON, -1, \ZLIB_ENCODING_GZIP)), + headers: [ + 'Content-Encoding' => 'gzip', + 'X-Buggregator-Event' => 'sentry', + 'Content-Type' => 'application/x-sentry-envelope', + 'X-Sentry-Auth' => 'Sentry sentry_version=7, sentry_client=sentry.php/4.0.1, sentry_key=user', + ], + )->assertOk(); + + $this->broadcastig->assertPushed('events', function (array $data) { + $this->assertSame('event.received', $data['event']); + $this->assertSame('sentry', $data['data']['type']); + + $this->assertSame(1701455435.634665, $data['data']['payload']['timestamp']); + $this->assertSame('php', $data['data']['payload']['platform']); + $this->assertSame('sentry.php', $data['data']['payload']['sdk']['name']); + $this->assertSame('4.0.1', $data['data']['payload']['sdk']['version']); + $this->assertSame('Test', $data['data']['payload']['server_name']); + $this->assertSame('production', $data['data']['payload']['environment']); + + $this->assertNotEmpty($data['data']['uuid']); + $this->assertNotEmpty($data['data']['timestamp']); + + + return true; + }); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 282bd126..d4391119 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -5,57 +5,90 @@ namespace Tests; use App\Application\Service\ErrorHandler\Handler; -use Spiral\Config\ConfiguratorInterface; -use Spiral\Config\Patch\Set; +use Modules\Events\Domain\EventRepositoryInterface; +use Psr\SimpleCache\CacheInterface; +use Spiral\Cache\Storage\ArrayStorage; use Spiral\Core\Container; +use Spiral\Core\ContainerScope; use Spiral\Testing\TestableKernelInterface; use Spiral\Testing\TestCase as BaseTestCase; -use Spiral\Translator\TranslatorInterface; +use Tests\App\Broadcasting\BroadcastFaker; +use Tests\App\Events\EventsMocker; use Tests\App\TestKernel; class TestCase extends BaseTestCase { + protected BroadcastFaker $broadcastig; + private ?EventsMocker $events = null; + protected function setUp(): void { - $this->beforeBooting(static function (ConfiguratorInterface $config): void { - if (! $config->exists('session')) { - return; - } - - $config->modify('session', new Set('handler', null)); - }); - parent::setUp(); - $this->getContainer()->get(TranslatorInterface::class)->setLocale('en'); + // Bind container to ContainerScope + (new \ReflectionClass(ContainerScope::class))->setStaticPropertyValue('container', $this->getContainer()); + $this->broadcastig = new BroadcastFaker($this->getContainer()); } public function createAppInstance(Container $container = new Container()): TestableKernelInterface { return TestKernel::create( directories: $this->defineDirectories( - $this->rootDirectory() + $this->rootDirectory(), ), exceptionHandler: Handler::class, - container: $container + container: $container, ); } protected function tearDown(): void { + parent::tearDown(); + // Uncomment this line if you want to clean up runtime directory. - $this->cleanUpRuntimeDirectory(); + // $this->cleanUpRuntimeDirectory(); + + (new \ReflectionClass(ContainerScope::class))->setStaticPropertyValue('container', null); + $this->broadcastig->reset(); } public function rootDirectory(): string { - return __DIR__.'/..'; + return __DIR__ . '/..'; } public function defineDirectories(string $root): array { return [ 'root' => $root, + 'modules' => $root . '/app/modules', + 'public' => $root . '/frontend/.output/public', ]; } + + /** + * @template T + * + * @param class-string|string $id + * + * @return T|mixed + * @psalm-return ($id is class-string ? T : mixed) + * + * @throws \Throwable + */ + public function get(string $id): mixed + { + return $this->getApp()->getContainer()->get($id); + } + + public function fakeEvents(): EventsMocker + { + if ($this->events === null) { + $this->events = new EventsMocker( + $this->mockContainer(EventRepositoryInterface::class), + ); + } + + return $this->events; + } }