Skip to content

Commit 9fc17f4

Browse files
committed
Improve test coverage across the entire package
Signed-off-by: Bruno Gaspar <brunofgaspar1@gmail.com>
1 parent 085e5e9 commit 9fc17f4

73 files changed

Lines changed: 3936 additions & 112 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

tests/Acceptance/ExceptionHandlerTest.php

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,24 @@
55
namespace BlueBeetle\ApiToolkit\Tests\Acceptance;
66

77
use BlueBeetle\ApiToolkit\Exceptions\ConfigureExceptionHandler;
8+
use BlueBeetle\ApiToolkit\Tests\Fixtures\Exceptions\StubDomainException;
9+
use BlueBeetle\ApiToolkit\Tests\Fixtures\Models\Product;
810
use BlueBeetle\ApiToolkit\Tests\TestCase;
11+
use BlueBeetle\IdempotencyMiddleware\IdempotencyException;
12+
use Exception;
913
use Illuminate\Auth\AuthenticationException;
14+
use Illuminate\Database\Eloquent\ModelNotFoundException;
15+
use Illuminate\Database\LazyLoadingViolationException;
16+
use Illuminate\Database\QueryException;
1017
use Illuminate\Support\Facades\Route;
1118
use Illuminate\Validation\ValidationException;
1219
use PHPUnit\Framework\Attributes\Test;
1320
use PHPUnit\Framework\Attributes\TestDox;
1421
use RuntimeException;
1522
use Symfony\Component\HttpFoundation\Response;
1623
use Symfony\Component\HttpKernel\Exception\HttpException;
24+
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
25+
use Symfony\Component\Routing\Exception\RouteNotFoundException;
1726

1827
final class ExceptionHandlerTest extends TestCase
1928
{
@@ -145,4 +154,202 @@ public function it_excludes_debug_when_disabled(): void
145154
$response->assertStatus(Response::HTTP_BAD_REQUEST);
146155
$response->assertJsonMissingPath('errors.0.meta');
147156
}
157+
158+
#[Test]
159+
#[TestDox('it renders query exception as 400')]
160+
public function it_renders_query_exception(): void
161+
{
162+
$this->app['config']->set('app.debug', false);
163+
164+
Route::get('/test', fn () => throw new QueryException(
165+
connectionName: 'testing',
166+
sql: 'SELECT * FROM invalid_table',
167+
bindings: [],
168+
previous: new Exception('Table not found'),
169+
));
170+
171+
$response = $this->get('/test');
172+
173+
$response->assertStatus(Response::HTTP_BAD_REQUEST);
174+
$response->assertJsonPath('errors.0.code', 'invalid_request_error');
175+
$response->assertJsonPath('errors.0.detail', 'There was a problem during a database query');
176+
}
177+
178+
#[Test]
179+
#[TestDox('it renders query exception with detail when debug is on')]
180+
public function it_renders_query_exception_with_debug(): void
181+
{
182+
$this->app['config']->set('app.debug', true);
183+
184+
Route::get('/test', fn () => throw new QueryException(
185+
connectionName: 'testing',
186+
sql: 'SELECT * FROM invalid_table',
187+
bindings: [],
188+
previous: new Exception('Table not found'),
189+
));
190+
191+
$response = $this->get('/test');
192+
193+
$response->assertStatus(Response::HTTP_BAD_REQUEST);
194+
$response->assertJsonPath('errors.0.code', 'invalid_request_error');
195+
// When debug is on, the actual exception message is shown
196+
$this->assertNotSame('There was a problem during a database query', $response->json('errors.0.detail'));
197+
}
198+
199+
#[Test]
200+
#[TestDox('it renders lazy loading violation as 400')]
201+
public function it_renders_lazy_loading_violation(): void
202+
{
203+
$this->app['config']->set('app.debug', false);
204+
205+
Route::get('/test', fn () => throw new LazyLoadingViolationException(
206+
model: new Product(),
207+
relation: 'category',
208+
));
209+
210+
$response = $this->get('/test');
211+
212+
$response->assertStatus(Response::HTTP_BAD_REQUEST);
213+
$response->assertJsonPath('errors.0.code', 'invalid_request_error');
214+
$response->assertJsonPath('errors.0.detail', 'There was a problem during a database query');
215+
}
216+
217+
#[Test]
218+
#[TestDox('it renders model not found exception')]
219+
public function it_renders_model_not_found(): void
220+
{
221+
$modelException = (new ModelNotFoundException())->setModel(Product::class, ['abc-123']);
222+
223+
Route::get('/test', fn () => throw new NotFoundHttpException(
224+
message: '',
225+
previous: $modelException,
226+
));
227+
228+
$response = $this->get('/test');
229+
230+
$response->assertStatus(Response::HTTP_NOT_FOUND);
231+
$response->assertJsonPath('errors.0.code', 'invalid_request_error');
232+
$response->assertJsonPath('errors.0.title', 'Not Found');
233+
$this->assertStringContainsString('product', $response->json('errors.0.detail'));
234+
$this->assertStringContainsString('abc-123', $response->json('errors.0.detail'));
235+
}
236+
237+
#[Test]
238+
#[TestDox('it renders idempotency exception')]
239+
public function it_renders_idempotency_exception(): void
240+
{
241+
Route::post('/test', fn () => throw new IdempotencyException('Request already processed'));
242+
243+
$response = $this->postJson('/test');
244+
245+
$response->assertStatus(Response::HTTP_BAD_REQUEST);
246+
$response->assertJsonPath('errors.0.code', 'idempotency_error');
247+
$response->assertJsonPath('errors.0.title', 'Idempotency Error');
248+
$response->assertJsonPath('errors.0.detail', 'Request already processed');
249+
}
250+
251+
#[Test]
252+
#[TestDox('it renders route not found exception')]
253+
public function it_renders_route_not_found(): void
254+
{
255+
Route::get('/test', fn () => throw new RouteNotFoundException(
256+
'Route [api.products.index] not defined.',
257+
));
258+
259+
$response = $this->get('/test');
260+
261+
$response->assertStatus(Response::HTTP_NOT_FOUND);
262+
$response->assertJsonPath('errors.0.code', 'invalid_request_error');
263+
$response->assertJsonPath('errors.0.title', 'Not Found');
264+
$this->assertStringContainsString('api.products.index', $response->json('errors.0.detail'));
265+
}
266+
267+
#[Test]
268+
#[TestDox('it renders domain exceptions as 400')]
269+
public function it_renders_domain_exceptions(): void
270+
{
271+
$this->app['config']->set('api-toolkit.exceptions.domain', [
272+
StubDomainException::class,
273+
]);
274+
275+
Route::get('/test', fn () => throw new StubDomainException('Insufficient stock'));
276+
277+
$response = $this->get('/test');
278+
279+
$response->assertStatus(Response::HTTP_BAD_REQUEST);
280+
$response->assertJsonPath('errors.0.code', 'invalid_request_error');
281+
$response->assertJsonPath('errors.0.title', 'Bad Request');
282+
$response->assertJsonPath('errors.0.detail', 'Insufficient stock');
283+
}
284+
285+
#[Test]
286+
#[TestDox('it respects dont_report config')]
287+
public function it_respects_dont_report_config(): void
288+
{
289+
$this->app['config']->set('api-toolkit.exceptions.dont_report', [
290+
RuntimeException::class,
291+
]);
292+
293+
Route::get('/test', fn () => throw new RuntimeException('Should not be reported'));
294+
295+
$response = $this->get('/test');
296+
297+
// Still renders the response, but the exception should not be reported
298+
$response->assertStatus(Response::HTTP_INTERNAL_SERVER_ERROR);
299+
}
300+
301+
#[Test]
302+
#[TestDox('it renders not found for root path')]
303+
public function it_renders_not_found_for_root_path(): void
304+
{
305+
$response = $this->get('/');
306+
307+
$response->assertStatus(Response::HTTP_NOT_FOUND);
308+
$this->assertStringContainsString('GET: /', $response->json('errors.0.detail'));
309+
}
310+
311+
#[Test]
312+
#[TestDox('it sets json api content type on error responses')]
313+
public function it_sets_content_type(): void
314+
{
315+
Route::get('/test', fn () => throw new RuntimeException('Error'));
316+
317+
$response = $this->get('/test');
318+
319+
$response->assertHeader('Content-Type', 'application/vnd.api+json');
320+
}
321+
322+
#[Test]
323+
#[TestDox('it renders multiple validation errors with source pointers')]
324+
public function it_renders_multiple_validation_errors(): void
325+
{
326+
Route::post('/test', function () {
327+
$validator = \Illuminate\Support\Facades\Validator::make(
328+
['email' => 'not-an-email'],
329+
[
330+
'name' => ['required'],
331+
'email' => ['required', 'email'],
332+
'age' => ['required', 'integer'],
333+
],
334+
);
335+
336+
throw new ValidationException($validator);
337+
});
338+
339+
$response = $this->postJson('/test');
340+
341+
$response->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY);
342+
343+
$errors = $response->json('errors');
344+
345+
// name required + email invalid + age required = 3 errors
346+
$this->assertGreaterThanOrEqual(3, count($errors));
347+
348+
// All errors should have validation_error code
349+
foreach ($errors as $error) {
350+
$this->assertSame('validation_error', $error['code']);
351+
$this->assertSame('Validation Error', $error['title']);
352+
$this->assertArrayHasKey('pointer', $error['source']);
353+
}
354+
}
148355
}

tests/Acceptance/Http/Requests/FormRequestTest.php

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
namespace BlueBeetle\ApiToolkit\Tests\Acceptance\Http\Requests;
66

77
use BlueBeetle\ApiToolkit\Tests\Acceptance\Http\Requests\Stubs\FormRequestWithFormInputRules;
8+
use BlueBeetle\ApiToolkit\Tests\Acceptance\Http\Requests\Stubs\FormRequestWithNoRules;
9+
use BlueBeetle\ApiToolkit\Tests\Acceptance\Http\Requests\Stubs\FormRequestWithQueryAndBodyRules;
810
use BlueBeetle\ApiToolkit\Tests\Acceptance\Http\Requests\Stubs\FormRequestWithQueryParamRules;
911
use BlueBeetle\ApiToolkit\Tests\TestCase;
1012
use Illuminate\Support\Facades\Route;
@@ -79,4 +81,70 @@ public function it_rejects_unknown_form_fields(): void
7981

8082
$response->assertStatus(Response::HTTP_BAD_REQUEST);
8183
}
84+
85+
#[Test]
86+
#[TestDox('it passes through when no rules are defined')]
87+
public function it_passes_through_with_no_rules(): void
88+
{
89+
Route::get('/', fn (FormRequestWithNoRules $request) => response()->json(['ok' => true]));
90+
91+
$response = $this->getJson('/');
92+
93+
$response->assertStatus(Response::HTTP_OK);
94+
}
95+
96+
#[Test]
97+
#[TestDox('it rejects multiple unknown query params with plural message')]
98+
public function it_rejects_multiple_unknown_query_params(): void
99+
{
100+
Route::get('/', fn (FormRequestWithQueryParamRules $request) => response()->json(['ok' => true]));
101+
102+
$response = $this->getJson('/?include[]=category&foo=bar&baz=qux');
103+
104+
$response->assertStatus(Response::HTTP_BAD_REQUEST);
105+
}
106+
107+
#[Test]
108+
#[TestDox('it rejects multiple unknown form fields with plural message')]
109+
public function it_rejects_multiple_unknown_form_fields(): void
110+
{
111+
Route::post('/', fn (FormRequestWithFormInputRules $request) => response()->json(['ok' => true]));
112+
113+
$response = $this->postJson('/', ['name' => 'John', 'foo' => 'bar', 'baz' => 'qux']);
114+
115+
$response->assertStatus(Response::HTTP_BAD_REQUEST);
116+
}
117+
118+
#[Test]
119+
#[TestDox('it validates both query params and form data together')]
120+
public function it_validates_query_and_form_together(): void
121+
{
122+
Route::post('/', fn (FormRequestWithQueryAndBodyRules $request) => response()->json(['ok' => true]));
123+
124+
$response = $this->postJson('/?include[]=category', ['name' => 'John']);
125+
126+
$response->assertStatus(Response::HTTP_OK);
127+
}
128+
129+
#[Test]
130+
#[TestDox('it rejects unknown query params even when form data is valid')]
131+
public function it_rejects_unknown_query_with_valid_body(): void
132+
{
133+
Route::post('/', fn (FormRequestWithQueryAndBodyRules $request) => response()->json(['ok' => true]));
134+
135+
$response = $this->postJson('/?include[]=category&unknown=value', ['name' => 'John']);
136+
137+
$response->assertStatus(Response::HTTP_BAD_REQUEST);
138+
}
139+
140+
#[Test]
141+
#[TestDox('it rejects unknown form fields even when query params are valid')]
142+
public function it_rejects_unknown_body_with_valid_query(): void
143+
{
144+
Route::post('/', fn (FormRequestWithQueryAndBodyRules $request) => response()->json(['ok' => true]));
145+
146+
$response = $this->postJson('/?include[]=category', ['name' => 'John', 'unknown' => 'value']);
147+
148+
$response->assertStatus(Response::HTTP_BAD_REQUEST);
149+
}
82150
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
declare(strict_types = 1);
4+
5+
namespace BlueBeetle\ApiToolkit\Tests\Acceptance\Http\Requests\Stubs;
6+
7+
use BlueBeetle\ApiToolkit\Http\Requests\FormRequest;
8+
9+
final class FormRequestWithNoRules extends FormRequest
10+
{
11+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
declare(strict_types = 1);
4+
5+
namespace BlueBeetle\ApiToolkit\Tests\Acceptance\Http\Requests\Stubs;
6+
7+
use BlueBeetle\ApiToolkit\Http\Requests\FormRequest;
8+
9+
final class FormRequestWithQueryAndBodyRules extends FormRequest
10+
{
11+
public function queryParamRules(): array
12+
{
13+
return [
14+
'include' => ['sometimes', 'array'],
15+
'include.*' => ['string'],
16+
];
17+
}
18+
19+
public function rules(): array
20+
{
21+
return [
22+
'name' => ['required'],
23+
];
24+
}
25+
}

0 commit comments

Comments
 (0)