diff --git a/app/modules/Events/Domain/EventRepositoryInterface.php b/app/modules/Events/Domain/EventRepositoryInterface.php index e40eaae7..3ddac0a5 100644 --- a/app/modules/Events/Domain/EventRepositoryInterface.php +++ b/app/modules/Events/Domain/EventRepositoryInterface.php @@ -4,6 +4,7 @@ namespace Modules\Events\Domain; +use Cycle\ORM\Select; use Cycle\ORM\RepositoryInterface; /** @@ -14,8 +15,12 @@ interface EventRepositoryInterface extends RepositoryInterface { public function findAll(array $scope = [], array $orderBy = [], int $limit = 30, int $offset = 0): iterable; + public function select(): Select; + public function countAll(array $scope = []): int; + public function countByType(array $scope = []): array; + public function store(Event $event): bool; public function deleteAll(array $scope = []): void; diff --git a/app/modules/Events/Integration/CycleOrm/EventRepository.php b/app/modules/Events/Integration/CycleOrm/EventRepository.php index 4ab99e01..0ffeb532 100644 --- a/app/modules/Events/Integration/CycleOrm/EventRepository.php +++ b/app/modules/Events/Integration/CycleOrm/EventRepository.php @@ -5,6 +5,7 @@ namespace Modules\Events\Integration\CycleOrm; use Cycle\Database\DatabaseInterface; +use Cycle\Database\Injection\Fragment; use Cycle\ORM\EntityManagerInterface; use Cycle\ORM\Select; use Cycle\ORM\Select\Repository; @@ -61,6 +62,20 @@ public function countAll(array $scope = []): int ->count(); } + public function countByType(array $scope = []): array + { + return $this->db + ->select() + ->from(Event::TABLE_NAME) + ->columns([ + Event::TYPE, + new Fragment('COUNT(*) AS cnt'), + ]) + ->where($this->buildScope($scope)) + ->groupBy(Event::TYPE) + ->fetchAll(); + } + public function findAll(array $scope = [], array $orderBy = [], int $limit = 30, int $offset = 0): iterable { return $this->select() diff --git a/app/modules/Events/Interfaces/Http/Controllers/ListAction.php b/app/modules/Events/Interfaces/Http/Controllers/ListAction.php index 76ded9c7..65167c54 100644 --- a/app/modules/Events/Interfaces/Http/Controllers/ListAction.php +++ b/app/modules/Events/Interfaces/Http/Controllers/ListAction.php @@ -4,18 +4,20 @@ namespace Modules\Events\Interfaces\Http\Controllers; -use App\Application\Commands\FindEvents; +use App\Application\Commands\FindEventsCursor; use App\Application\HTTP\Response\ErrorResource; use Modules\Events\Interfaces\Http\Request\EventsRequest; -use Modules\Events\Interfaces\Http\Resources\EventCollection; +use Modules\Events\Interfaces\Http\Resources\EventCursorCollection; use Modules\Events\Interfaces\Http\Resources\EventResource; -use Spiral\Cqrs\QueryBusInterface; +use Modules\Events\Interfaces\Queries\EventsCursorResult; use OpenApi\Attributes as OA; +use Spiral\Cqrs\QueryBusInterface; +use Spiral\Http\Request\InputManager; use Spiral\Router\Annotation\Route; #[OA\Get( path: '/api/events', - description: 'Retrieve all events', + description: 'Retrieve events with cursor pagination. Uses a composite cursor (timestamp + uuid) ordered by timestamp DESC, uuid DESC. Use meta.next_cursor as the cursor parameter to fetch the next page; meta.has_more indicates more data.', tags: ['Events'], parameters: [ new OA\QueryParameter( @@ -26,7 +28,19 @@ ), new OA\QueryParameter( name: 'project', - description: 'Filter by event type', + description: 'Filter by event project', + required: false, + schema: new OA\Schema(type: 'string'), + ), + new OA\QueryParameter( + name: 'limit', + description: 'Page size (default 100, max 100)', + required: false, + schema: new OA\Schema(type: 'integer', minimum: 1, maximum: 100), + ), + new OA\QueryParameter( + name: 'cursor', + description: 'Opaque composite cursor (timestamp + uuid) from meta.next_cursor of the previous response', required: false, schema: new OA\Schema(type: 'string'), ), @@ -46,7 +60,11 @@ ), new OA\Property( property: 'meta', - ref: '#/components/schemas/ResponseMeta', + properties: [ + new OA\Property(property: 'limit', type: 'integer'), + new OA\Property(property: 'has_more', type: 'boolean'), + new OA\Property(property: 'next_cursor', type: 'string', nullable: true), + ], type: 'object', ), ], @@ -66,12 +84,25 @@ #[Route(route: 'events', name: 'events.list', methods: 'GET', group: 'api')] public function __invoke( EventsRequest $request, + InputManager $input, QueryBusInterface $bus, - ): EventCollection { - return new EventCollection( - $bus->ask( - new FindEvents(type: $request->type, project: $request->project), - ), + ): EventCursorCollection { + $limit = $input->query->get('limit'); + $cursor = $input->query->get('cursor'); + + /** @var EventsCursorResult $result */ + $result = $bus->ask(new FindEventsCursor( + type: $request->type, + project: $request->project, + limit: $limit, + cursor: $cursor, + )); + + return new EventCursorCollection( + $result->items, + $result->limit, + $result->hasMore, + $result->nextCursor, ); } } diff --git a/app/modules/Events/Interfaces/Http/Controllers/PreviewListAction.php b/app/modules/Events/Interfaces/Http/Controllers/PreviewListAction.php index 8506e379..f34a1b94 100644 --- a/app/modules/Events/Interfaces/Http/Controllers/PreviewListAction.php +++ b/app/modules/Events/Interfaces/Http/Controllers/PreviewListAction.php @@ -4,19 +4,21 @@ namespace Modules\Events\Interfaces\Http\Controllers; -use App\Application\Commands\FindEvents; +use App\Application\Commands\FindEventsCursor; use App\Application\Event\EventTypeMapperInterface; use App\Application\HTTP\Response\ErrorResource; use Modules\Events\Interfaces\Http\Request\EventsRequest; -use Modules\Events\Interfaces\Http\Resources\EventPreviewCollection; +use Modules\Events\Interfaces\Http\Resources\EventPreviewCursorCollection; use Modules\Events\Interfaces\Http\Resources\EventPreviewResource; +use Modules\Events\Interfaces\Queries\EventsCursorResult; use Spiral\Cqrs\QueryBusInterface; use Spiral\Router\Annotation\Route; use OpenApi\Attributes as OA; +use Spiral\Http\Request\InputManager; #[OA\Get( path: '/api/events/preview', - description: 'Retrieve all events preview', + description: 'Retrieve event previews with cursor pagination. Uses a composite cursor (timestamp + uuid) ordered by timestamp DESC, uuid DESC. Use meta.next_cursor as the cursor parameter to fetch the next page; meta.has_more indicates more data.', tags: ['Events'], parameters: [ new OA\QueryParameter( @@ -27,7 +29,19 @@ ), new OA\QueryParameter( name: 'project', - description: 'Filter by event type', + description: 'Filter by event project', + required: false, + schema: new OA\Schema(type: 'string'), + ), + new OA\QueryParameter( + name: 'limit', + description: 'Page size (default 100, max 100)', + required: false, + schema: new OA\Schema(type: 'integer', maximum: 100, minimum: 1), + ), + new OA\QueryParameter( + name: 'cursor', + description: 'Opaque composite cursor (timestamp + uuid) from meta.next_cursor of the previous response', required: false, schema: new OA\Schema(type: 'string'), ), @@ -47,7 +61,12 @@ ), new OA\Property( property: 'meta', - ref: '#/components/schemas/ResponseMeta', + properties: [ + new OA\Property(property: 'grid', type: 'array', items: new OA\Items()), + new OA\Property(property: 'limit', type: 'integer'), + new OA\Property(property: 'has_more', type: 'boolean'), + new OA\Property(property: 'next_cursor', type: 'string', nullable: true), + ], type: 'object', ), ], @@ -67,14 +86,29 @@ #[Route(route: 'events/preview', name: 'events.preview.list', methods: 'GET', group: 'api')] public function __invoke( EventsRequest $request, + InputManager $input, QueryBusInterface $bus, EventTypeMapperInterface $mapper, - ): EventPreviewCollection { - return new EventPreviewCollection( - $bus->ask( - new FindEvents(type: $request->type, project: $request->project), + ): EventPreviewCursorCollection { + $limit = $input->query->get('limit'); + $cursor = $input->query->get('cursor'); + + /** @var EventsCursorResult $result */ + $result = $bus->ask( + new FindEventsCursor( + type: $request->type, + project: $request->project, + limit: $limit, + cursor: $cursor, ), + ); + + return new EventPreviewCursorCollection( + $result->items, $mapper, + $result->limit, + $result->hasMore, + $result->nextCursor, ); } } diff --git a/app/modules/Events/Interfaces/Http/Controllers/TypeCountsAction.php b/app/modules/Events/Interfaces/Http/Controllers/TypeCountsAction.php new file mode 100644 index 00000000..7b3de7e9 --- /dev/null +++ b/app/modules/Events/Interfaces/Http/Controllers/TypeCountsAction.php @@ -0,0 +1,77 @@ +ask( + new CountEventsByType(type: $request->type, project: $request->project), + ), + ); + } +} diff --git a/app/modules/Events/Interfaces/Http/Pagination/EventsCursor.php b/app/modules/Events/Interfaces/Http/Pagination/EventsCursor.php new file mode 100644 index 00000000..9aeb3921 --- /dev/null +++ b/app/modules/Events/Interfaces/Http/Pagination/EventsCursor.php @@ -0,0 +1,110 @@ +getTimestamp(), + (string) $event->getUuid(), + ); + } + + public static function fromOpaque(string $cursor): self + { + $decoded = self::decode($cursor); + $payload = \json_decode($decoded, true); + + if (!\is_array($payload)) { + throw self::invalid('Invalid cursor payload.'); + } + + $timestamp = $payload['ts'] ?? null; + $uuid = $payload['uuid'] ?? null; + + if (!\is_string($timestamp) || !self::isValidTimestamp($timestamp)) { + throw self::invalid('Invalid cursor timestamp.'); + } + + if (!\is_string($uuid) || !Uuid::isValid($uuid)) { + throw self::invalid('Invalid cursor UUID.'); + } + + return new self($timestamp, $uuid); + } + + public function toOpaque(): string + { + $payload = \json_encode( + [ + 'ts' => $this->timestamp, + 'uuid' => $this->uuid, + ], + \JSON_UNESCAPED_SLASHES, + ); + + if ($payload === false) { + throw self::invalid('Unable to encode cursor.'); + } + + return self::encode($payload); + } + + public function getTimestamp(): string + { + return $this->timestamp; + } + + public function getUuid(): string + { + return $this->uuid; + } + + private static function encode(string $value): string + { + return \rtrim(\strtr(\base64_encode($value), '+/', '-_'), '='); + } + + private static function decode(string $value): string + { + $decoded = \base64_decode(self::padBase64Url($value), true); + if ($decoded === false) { + throw self::invalid('Invalid cursor encoding.'); + } + + return $decoded; + } + + private static function padBase64Url(string $value): string + { + $value = \strtr($value, '-_', '+/'); + $padding = \strlen($value) % 4; + + return $padding === 0 ? $value : $value . \str_repeat('=', 4 - $padding); + } + + private static function isValidTimestamp(string $value): bool + { + return (bool) \preg_match('/^\d+(?:\.\d+)?$/', $value); + } + + private static function invalid(string $message): ValidationException + { + return new ValidationException([ + 'cursor' => [$message], + ], 'Invalid cursor.'); + } +} diff --git a/app/modules/Events/Interfaces/Http/Resources/EventCursorCollection.php b/app/modules/Events/Interfaces/Http/Resources/EventCursorCollection.php new file mode 100644 index 00000000..e0781390 --- /dev/null +++ b/app/modules/Events/Interfaces/Http/Resources/EventCursorCollection.php @@ -0,0 +1,33 @@ + $this->limit, + 'has_more' => $this->hasMore, + 'next_cursor' => $this->nextCursor, + ]; + + return parent::wrapData($data, $meta); + } +} diff --git a/app/modules/Events/Interfaces/Http/Resources/EventPreviewCursorCollection.php b/app/modules/Events/Interfaces/Http/Resources/EventPreviewCursorCollection.php new file mode 100644 index 00000000..63d77656 --- /dev/null +++ b/app/modules/Events/Interfaces/Http/Resources/EventPreviewCursorCollection.php @@ -0,0 +1,36 @@ + new EventPreviewResource($event, $mapper), + ); + } + + protected function wrapData(array $data, array $meta = []): array + { + $meta = [ + 'limit' => $this->limit, + 'has_more' => $this->hasMore, + 'next_cursor' => $this->nextCursor, + ]; + + return parent::wrapData($data, $meta); + } +} diff --git a/app/modules/Events/Interfaces/Http/Resources/EventTypeCountCollection.php b/app/modules/Events/Interfaces/Http/Resources/EventTypeCountCollection.php new file mode 100644 index 00000000..7bcf3d66 --- /dev/null +++ b/app/modules/Events/Interfaces/Http/Resources/EventTypeCountCollection.php @@ -0,0 +1,19 @@ + $this->data['type'], + 'count' => (int) $this->data['cnt'], + ]; + } +} diff --git a/app/modules/Events/Interfaces/Queries/CountEventsByTypeHandler.php b/app/modules/Events/Interfaces/Queries/CountEventsByTypeHandler.php new file mode 100644 index 00000000..146b31fe --- /dev/null +++ b/app/modules/Events/Interfaces/Queries/CountEventsByTypeHandler.php @@ -0,0 +1,26 @@ +events->countByType($this->getScopeFromFindEvents($query)); + } +} diff --git a/app/modules/Events/Interfaces/Queries/EventsCursorResult.php b/app/modules/Events/Interfaces/Queries/EventsCursorResult.php new file mode 100644 index 00000000..daab693d --- /dev/null +++ b/app/modules/Events/Interfaces/Queries/EventsCursorResult.php @@ -0,0 +1,18 @@ +resolveLimit($query->limit); + $cursor = $this->resolveCursor($query->cursor); + + $source = $this->events->select(); + $scope = $this->getScopeFromFindEvents($query); + if ($scope !== []) { + $source = $this->applyScope($source, $scope); + } + + if ($cursor !== null) { + $source = $this->applyCursor($source, $cursor); + } + + $items = $this->applyOrder($source) + ->limit($limit + 1) + ->fetchAll(); + + $hasMore = \count($items) > $limit; + if ($hasMore) { + \array_pop($items); + } + + $nextCursor = null; + if ($hasMore && $items !== []) { + $last = $items[\array_key_last($items)]; + if ($last instanceof Event) { + $nextCursor = EventsCursor::fromEvent($last)->toOpaque(); + } + } + + return new EventsCursorResult( + $items, + $limit, + $hasMore, + $nextCursor, + ); + } + + private function applyScope(Select $select, array $scope): Select + { + $normalized = []; + + foreach ($scope as $key => $value) { + $normalized[$key] = \is_array($value) ? ['in' => $value] : $value; + } + + return $select->where($normalized); + } + + private function applyCursor(Select $select, EventsCursor $cursor): Select + { + $timestampExpression = $this->timestampExpression(); + + return $select->where( + static function ($query) use ($cursor, $timestampExpression): void { + $query + ->where(new Fragment($timestampExpression), '<', $cursor->getTimestamp()) + ->orWhere( + static function ($query) use ($cursor, $timestampExpression): void { + $query + ->where(new Fragment($timestampExpression), '=', $cursor->getTimestamp()) + ->andWhere(Event::UUID, '<', $cursor->getUuid()); + }, + ); + }, + ); + } + + private function applyOrder(Select $select): Select + { + $select = $select->orderBy(new Fragment($this->timestampExpression()), 'DESC'); + + return $select->orderBy(Event::UUID, 'DESC'); + } + + private function timestampExpression(): string + { + $driver = $this->db->getDriver(); + $table = $driver->identifier(Event::ROLE); + $column = $driver->identifier(Event::TIMESTAMP); + $expression = "{$table}.{$column}"; + + return match (true) { + $driver instanceof MySQLDriver, $driver instanceof SQLServerDriver => + sprintf('CAST(%s AS DECIMAL(20,6))', $expression), + + $driver instanceof SQLiteDriver => + sprintf('CAST(%s AS REAL)', $expression), + + $driver instanceof PostgresDriver => + sprintf('CAST(%s AS numeric)', $expression), + + default => + throw new \RuntimeException('Unsupported DB driver'), + }; + } + + private function resolveLimit(mixed $value): int + { + if ($value === null || $value === '') { + return self::DEFAULT_LIMIT; + } + + if (!\is_numeric($value)) { + throw $this->invalidLimit(); + } + + $limit = (int) $value; + if ($limit < 1) { + throw $this->invalidLimit(); + } + + return \min($limit, self::MAX_LIMIT); + } + + private function resolveCursor(mixed $value): ?EventsCursor + { + if ($value === null || $value === '') { + return null; + } + + if (!\is_string($value)) { + throw $this->invalidCursor(); + } + + return EventsCursor::fromOpaque($value); + } + + private function invalidLimit(): ValidationException + { + return new ValidationException([ + 'limit' => ['Limit must be a positive integer.'], + ], 'Invalid pagination limit.'); + } + + private function invalidCursor(): ValidationException + { + return new ValidationException([ + 'cursor' => ['Cursor must be a string.'], + ], 'Invalid cursor.'); + } +} diff --git a/app/src/Application/Commands/CountEventsByType.php b/app/src/Application/Commands/CountEventsByType.php new file mode 100644 index 00000000..4b3420d3 --- /dev/null +++ b/app/src/Application/Commands/CountEventsByType.php @@ -0,0 +1,7 @@ + + */ +final class FindEventsCursor extends AskEvents implements QueryInterface +{ + public function __construct( + ?string $type = null, + ?string $project = null, + public readonly mixed $limit = null, + public readonly mixed $cursor = null, + ) { + parent::__construct($type, $project); + } +} diff --git a/app/src/Application/HTTP/Response/ResourceCollection.php b/app/src/Application/HTTP/Response/ResourceCollection.php index f4ef36d6..872bda8a 100644 --- a/app/src/Application/HTTP/Response/ResourceCollection.php +++ b/app/src/Application/HTTP/Response/ResourceCollection.php @@ -64,7 +64,7 @@ public function toResponse(ResponseInterface $response): ResponseInterface return $this->writeJson($response, $this); } - protected function wrapData(array $data): array + protected function wrapData(array $data, array $meta = []): array { $grid = []; @@ -77,6 +77,7 @@ protected function wrapData(array $data): array return [ 'data' => $data, 'meta' => [ + ...$meta, 'grid' => $grid, ], ]; diff --git a/tests/App/Http/HttpFaker.php b/tests/App/Http/HttpFaker.php index 784bca57..48aa752f 100644 --- a/tests/App/Http/HttpFaker.php +++ b/tests/App/Http/HttpFaker.php @@ -113,6 +113,87 @@ public function clearEvents(?string $type = null, ?string $project = null, ?arra ); } + public function typeCounts(?string $type = null, ?string $project = null): ResponseAssertions + { + $args = []; + if ($type) { + $args['type'] = $type; + } + + if ($project) { + $args['project'] = $project; + } + + return $this->makeResponse( + $this->http->get( + uri: '/api/events/type-counts', + query: $args, + ), + ); + } + + public function listEvents( + ?string $type = null, + ?string $project = null, + ?int $limit = null, + ?string $cursor = null, + ): ResponseAssertions { + $args = []; + if ($type) { + $args['type'] = $type; + } + + if ($project) { + $args['project'] = $project; + } + + if ($limit !== null) { + $args['limit'] = $limit; + } + + if ($cursor !== null) { + $args['cursor'] = $cursor; + } + + return $this->makeResponse( + $this->http->get( + uri: '/api/events', + query: $args, + ), + ); + } + + public function previewEvents( + ?string $type = null, + ?string $project = null, + ?int $limit = null, + ?string $cursor = null, + ): ResponseAssertions { + $args = []; + if ($type) { + $args['type'] = $type; + } + + if ($project) { + $args['project'] = $project; + } + + if ($limit !== null) { + $args['limit'] = $limit; + } + + if ($cursor !== null) { + $args['cursor'] = $cursor; + } + + return $this->makeResponse( + $this->http->get( + uri: '/api/events/preview', + query: $args, + ), + ); + } + public function __call(string $name, array $arguments): ResponseAssertions|self { if (!method_exists($this->http, $name)) { diff --git a/tests/App/Http/ResponseAssertions.php b/tests/App/Http/ResponseAssertions.php index e1e18d3a..795c6e1c 100644 --- a/tests/App/Http/ResponseAssertions.php +++ b/tests/App/Http/ResponseAssertions.php @@ -118,6 +118,13 @@ public function assertJsonResponseSame(array $data): self return $this; } + public function json(): array + { + $data = \json_decode((string) $this->response, true); + + return \is_array($data) ? $data : []; + } + public function assertJsonResponseContains(array $data): self { $needle = \json_encode($data); diff --git a/tests/Feature/Interfaces/Http/Events/ListActionTest.php b/tests/Feature/Interfaces/Http/Events/ListActionTest.php new file mode 100644 index 00000000..13454eef --- /dev/null +++ b/tests/Feature/Interfaces/Http/Events/ListActionTest.php @@ -0,0 +1,77 @@ +createEventWithTimestamp('100'); + $event2 = $this->createEventWithTimestamp('200'); + $event3 = $this->createEventWithTimestamp('300'); + + $response = $this->http + ->listEvents(limit: 2) + ->assertOk(); + + $data = $response->json(); + + $this->assertCount(2, $data['data']); + $this->assertSame(2, $data['meta']['limit']); + $this->assertTrue($data['meta']['has_more']); + $this->assertNotEmpty($data['meta']['next_cursor']); + + $this->assertSame((string) $event3->getUuid(), $data['data'][0]['uuid']); + $this->assertSame((string) $event2->getUuid(), $data['data'][1]['uuid']); + + $nextResponse = $this->http + ->listEvents(limit: 2, cursor: $data['meta']['next_cursor']) + ->assertOk(); + + $nextData = $nextResponse->json(); + + $this->assertCount(1, $nextData['data']); + $this->assertSame(2, $nextData['meta']['limit']); + $this->assertFalse($nextData['meta']['has_more']); + $this->assertNull($nextData['meta']['next_cursor']); + $this->assertSame((string) $event1->getUuid(), $nextData['data'][0]['uuid']); + } + + public function testInvalidLimit(): void + { + $response = $this->http + ->listEvents(limit: 0) + ->assertUnprocessable() + ->assertJsonResponseContains([ + 'message' => 'The given data was invalid.', + 'code' => 422, + 'context' => 'Invalid pagination limit.', + ]); + + $data = $response->json(); + + $this->assertSame(['Limit must be a positive integer.'], $data['errors']['limit']); + } + + private function createEventWithTimestamp(string $timestamp): Event + { + $event = EventFactory::new([ + 'type' => 'foo', + 'project' => Key::create('default'), + 'timestamp' => Timestamp::typecast($timestamp), + ])->makeOne(); + + $this->get(EventRepositoryInterface::class)->store($event); + + return $event; + } +} diff --git a/tests/Feature/Interfaces/Http/Events/PreviewListActionTest.php b/tests/Feature/Interfaces/Http/Events/PreviewListActionTest.php new file mode 100644 index 00000000..21e3c191 --- /dev/null +++ b/tests/Feature/Interfaces/Http/Events/PreviewListActionTest.php @@ -0,0 +1,77 @@ +createEventWithTimestamp('100'); + $event2 = $this->createEventWithTimestamp('200'); + $event3 = $this->createEventWithTimestamp('300'); + + $response = $this->http + ->previewEvents(limit: 2) + ->assertOk(); + + $data = $response->json(); + + $this->assertCount(2, $data['data']); + $this->assertSame(2, $data['meta']['limit']); + $this->assertTrue($data['meta']['has_more']); + $this->assertNotEmpty($data['meta']['next_cursor']); + + $this->assertSame((string) $event3->getUuid(), $data['data'][0]['uuid']); + $this->assertSame((string) $event2->getUuid(), $data['data'][1]['uuid']); + + $nextResponse = $this->http + ->previewEvents(limit: 2, cursor: $data['meta']['next_cursor']) + ->assertOk(); + + $nextData = $nextResponse->json(); + + $this->assertCount(1, $nextData['data']); + $this->assertSame(2, $nextData['meta']['limit']); + $this->assertFalse($nextData['meta']['has_more']); + $this->assertNull($nextData['meta']['next_cursor']); + $this->assertSame((string) $event1->getUuid(), $nextData['data'][0]['uuid']); + } + + public function testInvalidCursor(): void + { + $response = $this->http + ->previewEvents(cursor: 'invalid!!') + ->assertUnprocessable() + ->assertJsonResponseContains([ + 'message' => 'The given data was invalid.', + 'code' => 422, + 'context' => 'Invalid cursor.', + ]); + + $data = $response->json(); + + $this->assertSame(['Invalid cursor encoding.'], $data['errors']['cursor']); + } + + private function createEventWithTimestamp(string $timestamp): Event + { + $event = EventFactory::new([ + 'type' => 'foo', + 'project' => Key::create('default'), + 'timestamp' => Timestamp::typecast($timestamp), + ])->makeOne(); + + $this->get(EventRepositoryInterface::class)->store($event); + + return $event; + } +} diff --git a/tests/Feature/Interfaces/Http/Events/TypeCountsActionTest.php b/tests/Feature/Interfaces/Http/Events/TypeCountsActionTest.php new file mode 100644 index 00000000..ce2fd6e9 --- /dev/null +++ b/tests/Feature/Interfaces/Http/Events/TypeCountsActionTest.php @@ -0,0 +1,47 @@ +createEvent(type: 'foo', project: 'alpha'); + $this->createEvent(type: 'foo', project: 'alpha'); + $this->createEvent(type: 'bar', project: 'alpha'); + $this->createEvent(type: 'baz', project: 'beta'); + + $this->http + ->typeCounts(project: 'alpha') + ->assertOk() + ->assertCollectionContainResources([ + new EventTypeCountResource(['type' => 'foo', 'cnt' => 2]), + new EventTypeCountResource(['type' => 'bar', 'cnt' => 1]), + ]) + ->assertCollectionMissingResources([ + new EventTypeCountResource(['type' => 'baz', 'cnt' => 1]), + ]); + } + + public function testTypeCountsByProjectAndType(): void + { + $this->createEvent(type: 'foo', project: 'alpha'); + $this->createEvent(type: 'foo', project: 'alpha'); + $this->createEvent(type: 'bar', project: 'alpha'); + + $this->http + ->typeCounts(type: 'foo', project: 'alpha') + ->assertOk() + ->assertCollectionContainResources([ + new EventTypeCountResource(['type' => 'foo', 'cnt' => 2]), + ]) + ->assertCollectionMissingResources([ + new EventTypeCountResource(['type' => 'bar', 'cnt' => 1]), + ]); + } +}