executeApiCall($callback); } public function callCatchNotFound(callable $callback): mixed { return $this->catchNotFound($callback); } public function callToDateTime(?\DateTimeInterface $date): ?DateTime { return $this->toDateTime($date); } public function callToPaginationDto(?PaginationMeta $meta, Pagination $pagination): PaginationDto { return $this->toPaginationDto($meta, $pagination); } }; } describe('AbstractApiRepository', function () { describe('executeApiCall', function () { it('returns callback result on success', function () { // GIVEN $repository = createConcreteApiRepository(); // WHEN $result = $repository->callExecute(fn () => 'success_payload'); // THEN expect($result)->toBe('success_payload'); }); it('converts thrown exception to ApiError', function () { // GIVEN $repository = createConcreteApiRepository(); $exception = new \RuntimeException('Something went wrong', 500); // WHEN $result = $repository->callExecute(function () use ($exception) { throw $exception; }); // THEN expect($result)->toBeInstanceOf(ApiError::class); expect($result)->toBeInstanceOf(UnexpectedApiError::class); expect($result->message())->toBe('Something went wrong'); }); }); describe('catchNotFound', function () { it('returns callback result on success', function () { // GIVEN $repository = createConcreteApiRepository(); // WHEN $result = $repository->callCatchNotFound(fn () => 'found_item'); // THEN expect($result)->toBe('found_item'); }); it('returns null when ApiException has status 404', function () { // GIVEN $repository = createConcreteApiRepository(); $exception = new ApiException('Resource not found'); $exception->setResponseStatusCode(404); // WHEN $result = $repository->callCatchNotFound(function () use ($exception) { throw $exception; }); // THEN expect($result)->toBeNull(); }); it('returns null when ProblemDetails has status 404', function () { // GIVEN $repository = createConcreteApiRepository(); $problem = new ProblemDetails(); $problem->setStatus(404); $problem->setDetail('Not found'); // WHEN $result = $repository->callCatchNotFound(function () use ($problem) { throw $problem; }); // THEN expect($result)->toBeNull(); }); it('returns ApiError when ApiException status is not 404', function () { // GIVEN $repository = createConcreteApiRepository(); $exception = new ApiException('Internal error'); $exception->setResponseStatusCode(500); // WHEN $result = $repository->callCatchNotFound(function () use ($exception) { throw $exception; }); // THEN expect($result)->toBeInstanceOf(ApiError::class); expect($result)->toBeInstanceOf(ServerError::class); expect($result->statusCode())->toBe(500); }); it('returns ApiError when generic exception is thrown', function () { // GIVEN $repository = createConcreteApiRepository(); // WHEN $result = $repository->callCatchNotFound(function () { throw new \InvalidArgumentException('Bad arg'); }); // THEN expect($result)->toBeInstanceOf(ApiError::class); expect($result)->toBeInstanceOf(UnexpectedApiError::class); }); }); describe('toApiError conversion', function () { it('converts ValidationErrorProblemDetails to ValidationError with violations', function () { // GIVEN $repository = createConcreteApiRepository(); $param1 = new InvalidParamItem(); $param1->setName('email'); $param1->setReason('must be a valid email'); $problem = new ValidationErrorProblemDetails(); $problem->setDetail('Validation failed'); $problem->setInvalidParams([$param1]); // WHEN $result = $repository->callExecute(function () use ($problem) { throw $problem; }); // THEN expect($result)->toBeInstanceOf(ValidationError::class); /** @var ValidationError $result */ expect($result->message())->toBe('Validation failed'); expect($result->violations)->toBe(['email' => 'must be a valid email']); }); it('converts ProblemDetails with 401/403 to AuthenticationError', function () { // GIVEN $repository = createConcreteApiRepository(); $problem = new ProblemDetails(); $problem->setStatus(401); $problem->setTitle('Unauthorized'); // WHEN $result = $repository->callExecute(function () use ($problem) { throw $problem; }); // THEN expect($result)->toBeInstanceOf(AuthenticationError::class); expect($result->statusCode())->toBe(401); }); it('converts ProblemDetails with 429 to RateLimitError', function () { // GIVEN $repository = createConcreteApiRepository(); $problem = new ProblemDetails(); $problem->setStatus(429); $problem->setTitle('Too Many Requests'); // WHEN $result = $repository->callExecute(function () use ($problem) { throw $problem; }); // THEN expect($result)->toBeInstanceOf(RateLimitError::class); expect($result->statusCode())->toBe(429); }); it('converts ProblemDetails with 500 to ServerError', function () { // GIVEN $repository = createConcreteApiRepository(); $problem = new ProblemDetails(); $problem->setStatus(502); $problem->setDetail('Bad Gateway'); // WHEN $result = $repository->callExecute(function () use ($problem) { throw $problem; }); // THEN expect($result)->toBeInstanceOf(ServerError::class); expect($result->statusCode())->toBe(502); }); it('converts ConnectException to NetworkError', function () { // GIVEN $repository = createConcreteApiRepository(); $request = new Request('GET', 'https://api.example.com'); $exception = new ConnectException('Connection refused', $request); // WHEN $result = $repository->callExecute(function () use ($exception) { throw $exception; }); // THEN expect($result)->toBeInstanceOf(NetworkError::class); expect($result->message())->toBe('Connection refused'); }); }); describe('toDateTime', function () { it('returns null when input is null', function () { // GIVEN $repository = createConcreteApiRepository(); // WHEN $result = $repository->callToDateTime(null); // THEN expect($result)->toBeNull(); }); it('returns same DateTime instance when input is DateTime', function () { // GIVEN $repository = createConcreteApiRepository(); $date = new DateTime('2024-01-15 10:00:00'); // WHEN $result = $repository->callToDateTime($date); // THEN expect($result)->toBe($date); }); it('converts DateTimeImmutable to DateTime preserving value', function () { // GIVEN $repository = createConcreteApiRepository(); $immutable = new DateTimeImmutable('2024-01-15T10:00:00+00:00'); // WHEN $result = $repository->callToDateTime($immutable); // THEN expect($result)->toBeInstanceOf(DateTime::class); expect($result?->format(DATE_RFC3339))->toBe($immutable->format(DATE_RFC3339)); }); }); describe('toPaginationDto', function () { it('maps PaginationMeta to PaginationDto', function () { // GIVEN $repository = createConcreteApiRepository(); $pagination = Pagination::firstPage(); $meta = new PaginationMeta(); $meta->setPage(2); $meta->setPageSize(25); $meta->setTotalItems(100); $meta->setTotalPages(4); // WHEN $dto = $repository->callToPaginationDto($meta, $pagination); // THEN expect($dto)->toBeInstanceOf(PaginationDto::class); expect($dto->page)->toBe(2); expect($dto->pageSize)->toBe(25); expect($dto->totalItems)->toBe(100); expect($dto->totalPages)->toBe(4); }); it('creates default PaginationDto when meta is null', function () { // GIVEN $repository = createConcreteApiRepository(); $pagination = Pagination::firstPage(); // WHEN $dto = $repository->callToPaginationDto(null, $pagination); // THEN expect($dto)->toBeInstanceOf(PaginationDto::class); expect($dto->page)->toBe(1); expect($dto->pageSize)->toBe(50); expect($dto->totalItems)->toBe(0); expect($dto->totalPages)->toBe(0); }); }); });