Skip to content

Commit 5bf5480

Browse files
committed
Add functional tests for API route controllers and implement item management endpoints
1 parent 5dfe833 commit 5bf5480

11 files changed

Lines changed: 470 additions & 0 deletions
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenSolid\Api\Tests\Controller;
6+
7+
use OpenSolid\Api\Tests\Fixtures\Functional\FunctionalTestKernel;
8+
use PHPUnit\Framework\Attributes\Test;
9+
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
10+
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
11+
12+
class ApiRouteControllerTest extends WebTestCase
13+
{
14+
private KernelBrowser $client;
15+
16+
protected static function getKernelClass(): string
17+
{
18+
return FunctionalTestKernel::class;
19+
}
20+
21+
protected function setUp(): void
22+
{
23+
$this->client = self::createClient();
24+
}
25+
26+
protected function tearDown(): void
27+
{
28+
parent::tearDown();
29+
restore_exception_handler();
30+
}
31+
32+
private function getJsonResponse(): array
33+
{
34+
return json_decode($this->client->getInternalResponse()->getContent(), true);
35+
}
36+
37+
#[Test]
38+
public function getResource(): void
39+
{
40+
$this->client->request('GET', '/items/abc-123', server: [
41+
'HTTP_ACCEPT' => 'application/json',
42+
]);
43+
44+
self::assertResponseIsSuccessful();
45+
self::assertResponseHeaderSame('Content-Type', 'application/json');
46+
47+
$data = $this->getJsonResponse();
48+
self::assertSame('abc-123', $data['id']);
49+
self::assertSame('Test Item', $data['name']);
50+
self::assertSame(1000, $data['price']);
51+
}
52+
53+
#[Test]
54+
public function postResource(): void
55+
{
56+
$this->client->request('POST', '/items', server: [
57+
'CONTENT_TYPE' => 'application/json',
58+
'HTTP_ACCEPT' => 'application/json',
59+
], content: json_encode([
60+
'name' => 'New Item',
61+
'price' => 1500,
62+
]));
63+
64+
self::assertResponseStatusCodeSame(201);
65+
self::assertResponseHeaderSame('Content-Type', 'application/json');
66+
67+
$data = $this->getJsonResponse();
68+
self::assertSame('new-id', $data['id']);
69+
self::assertSame('New Item', $data['name']);
70+
self::assertSame(1500, $data['price']);
71+
}
72+
73+
#[Test]
74+
public function patchResource(): void
75+
{
76+
$this->client->request('PATCH', '/items/abc-123', server: [
77+
'CONTENT_TYPE' => 'application/json',
78+
'HTTP_ACCEPT' => 'application/json',
79+
], content: json_encode([
80+
'name' => 'Updated Item',
81+
]));
82+
83+
self::assertResponseIsSuccessful();
84+
self::assertResponseHeaderSame('Content-Type', 'application/json');
85+
86+
$data = $this->getJsonResponse();
87+
self::assertSame('abc-123', $data['id']);
88+
self::assertSame('Updated Item', $data['name']);
89+
}
90+
91+
#[Test]
92+
public function putResourceReturns201WhenCreated(): void
93+
{
94+
$this->client->request('PUT', '/items/abc-123', server: [
95+
'CONTENT_TYPE' => 'application/json',
96+
'HTTP_ACCEPT' => 'application/json',
97+
], content: json_encode([
98+
'name' => 'Replaced Item',
99+
'price' => 3000,
100+
]));
101+
102+
self::assertResponseStatusCodeSame(201);
103+
self::assertResponseHeaderSame('Content-Type', 'application/json');
104+
105+
$data = $this->getJsonResponse();
106+
self::assertSame('abc-123', $data['id']);
107+
self::assertSame('Replaced Item', $data['name']);
108+
self::assertSame(3000, $data['price']);
109+
}
110+
111+
#[Test]
112+
public function deleteResourceReturns204(): void
113+
{
114+
$this->client->request('DELETE', '/items/abc-123', server: [
115+
'HTTP_ACCEPT' => 'application/json',
116+
]);
117+
118+
self::assertResponseStatusCodeSame(204);
119+
self::assertSame('', $this->client->getInternalResponse()->getContent());
120+
}
121+
122+
#[Test]
123+
public function getCollectionReturnsPaginatedResponse(): void
124+
{
125+
$this->client->request('GET', '/items', server: [
126+
'HTTP_ACCEPT' => 'application/json',
127+
]);
128+
129+
self::assertResponseIsSuccessful();
130+
self::assertResponseHeaderSame('Content-Type', 'application/json');
131+
132+
$data = $this->getJsonResponse();
133+
self::assertArrayHasKey('items', $data);
134+
self::assertArrayHasKey('totalItems', $data);
135+
self::assertCount(2, $data['items']);
136+
self::assertSame(2, $data['totalItems']);
137+
self::assertSame('Item 1', $data['items'][0]['name']);
138+
self::assertSame('Item 2', $data['items'][1]['name']);
139+
}
140+
141+
#[Test]
142+
public function responseIncludesVaryHeaders(): void
143+
{
144+
$this->client->request('GET', '/items/abc-123', server: [
145+
'HTTP_ACCEPT' => 'application/json',
146+
]);
147+
148+
$response = $this->client->getResponse();
149+
150+
self::assertResponseIsSuccessful();
151+
self::assertNotEmpty($response->getVary());
152+
self::assertContains('Content-Type', $response->getVary());
153+
self::assertContains('Authorization', $response->getVary());
154+
self::assertContains('Origin', $response->getVary());
155+
}
156+
157+
#[Test]
158+
public function postResourceWithCustomStatusCode(): void
159+
{
160+
$this->client->request('POST', '/items/import', server: [
161+
'CONTENT_TYPE' => 'application/json',
162+
'HTTP_ACCEPT' => 'application/json',
163+
], content: '{}');
164+
165+
self::assertResponseStatusCodeSame(202);
166+
self::assertResponseHeaderSame('Content-Type', 'application/json');
167+
168+
$data = $this->getJsonResponse();
169+
self::assertSame('imported', $data['id']);
170+
}
171+
172+
#[Test]
173+
public function controllerReturningJsonResponsePassesThrough(): void
174+
{
175+
$this->client->request('GET', '/health', server: [
176+
'HTTP_ACCEPT' => 'application/json',
177+
]);
178+
179+
self::assertResponseIsSuccessful();
180+
181+
$data = $this->getJsonResponse();
182+
self::assertSame('ok', $data['status']);
183+
}
184+
185+
#[Test]
186+
public function wrongMethodReturns405(): void
187+
{
188+
$this->client->request('POST', '/items/abc-123', server: [
189+
'HTTP_ACCEPT' => 'application/json',
190+
]);
191+
192+
self::assertResponseStatusCodeSame(405);
193+
}
194+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenSolid\Api\Tests\Fixtures\Functional\Controller;
6+
7+
use OpenSolid\Api\Routing\Attribute\Post;
8+
use OpenSolid\Api\Tests\Fixtures\Functional\Model\ItemView;
9+
use Symfony\Component\HttpFoundation\Request;
10+
11+
#[Post(
12+
path: '/items',
13+
name: 'func_create_item',
14+
)]
15+
final readonly class CreateItemController
16+
{
17+
public function __invoke(Request $request): ItemView
18+
{
19+
$data = json_decode($request->getContent(), true);
20+
21+
return new ItemView(id: 'new-id', name: $data['name'], price: $data['price']);
22+
}
23+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenSolid\Api\Tests\Fixtures\Functional\Controller;
6+
7+
use OpenSolid\Api\Routing\Attribute\Post;
8+
use OpenSolid\Api\Tests\Fixtures\Functional\Model\ItemView;
9+
use Symfony\Component\HttpFoundation\Request;
10+
11+
#[Post(
12+
path: '/items/import',
13+
name: 'func_import_items',
14+
statusCode: 202,
15+
)]
16+
final readonly class CustomStatusController
17+
{
18+
public function __invoke(Request $request): ItemView
19+
{
20+
return new ItemView(id: 'imported', name: 'Imported Item', price: 500);
21+
}
22+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenSolid\Api\Tests\Fixtures\Functional\Controller;
6+
7+
use OpenSolid\Api\Routing\Attribute\Delete;
8+
9+
#[Delete(
10+
path: '/items/{id}',
11+
name: 'func_delete_item',
12+
)]
13+
final readonly class DeleteItemController
14+
{
15+
public function __invoke(string $id): void
16+
{
17+
}
18+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenSolid\Api\Tests\Fixtures\Functional\Controller;
6+
7+
use OpenSolid\Api\Routing\Attribute\Get;
8+
use OpenSolid\Api\Tests\Fixtures\Functional\Model\ItemView;
9+
10+
#[Get(
11+
path: '/items/{id}',
12+
name: 'func_get_item',
13+
)]
14+
final readonly class GetItemController
15+
{
16+
public function __invoke(string $id): ItemView
17+
{
18+
return new ItemView(id: $id, name: 'Test Item', price: 1000);
19+
}
20+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenSolid\Api\Tests\Fixtures\Functional\Controller;
6+
7+
use OpenSolid\Api\Routing\Attribute\Get;
8+
use Symfony\Component\HttpFoundation\JsonResponse;
9+
10+
#[Get(
11+
path: '/health',
12+
name: 'func_health',
13+
)]
14+
final readonly class JsonResponseController
15+
{
16+
public function __invoke(): JsonResponse
17+
{
18+
return new JsonResponse(['status' => 'ok']);
19+
}
20+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenSolid\Api\Tests\Fixtures\Functional\Controller;
6+
7+
use Doctrine\Common\Collections\ArrayCollection;
8+
use OpenSolid\Api\Routing\Attribute\GetCollection;
9+
use OpenSolid\Api\Tests\Fixtures\Functional\Model\ItemView;
10+
use OpenSolid\Core\Domain\Repository\Paginator;
11+
use OpenSolid\Core\Domain\Repository\SelectablePaginator;
12+
13+
#[GetCollection(
14+
path: '/items',
15+
name: 'func_list_items',
16+
)]
17+
final readonly class ListItemsController
18+
{
19+
/**
20+
* @return Paginator<ItemView>
21+
*/
22+
public function __invoke(): Paginator
23+
{
24+
$items = new ArrayCollection([
25+
new ItemView('id-1', 'Item 1', 1000),
26+
new ItemView('id-2', 'Item 2', 2000),
27+
]);
28+
29+
return new SelectablePaginator($items, currentPage: 1, itemsPerPage: 20);
30+
}
31+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenSolid\Api\Tests\Fixtures\Functional\Controller;
6+
7+
use OpenSolid\Api\Routing\Attribute\Put;
8+
use OpenSolid\Api\Tests\Fixtures\Functional\Model\ItemView;
9+
use OpenSolid\Core\Domain\Model\GetOrCreateResource;
10+
use Symfony\Component\HttpFoundation\Request;
11+
12+
#[Put(
13+
path: '/items/{id}',
14+
name: 'func_replace_item',
15+
)]
16+
final readonly class ReplaceItemController
17+
{
18+
/**
19+
* @return GetOrCreateResource<ItemView>
20+
*/
21+
public function __invoke(string $id, Request $request): GetOrCreateResource
22+
{
23+
$data = json_decode($request->getContent(), true);
24+
25+
return GetOrCreateResource::created(
26+
new ItemView(id: $id, name: $data['name'], price: $data['price']),
27+
);
28+
}
29+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenSolid\Api\Tests\Fixtures\Functional\Controller;
6+
7+
use OpenSolid\Api\Routing\Attribute\Patch;
8+
use OpenSolid\Api\Tests\Fixtures\Functional\Model\ItemView;
9+
use Symfony\Component\HttpFoundation\Request;
10+
11+
#[Patch(
12+
path: '/items/{id}',
13+
name: 'func_update_item',
14+
)]
15+
final readonly class UpdateItemController
16+
{
17+
public function __invoke(string $id, Request $request): ItemView
18+
{
19+
$data = json_decode($request->getContent(), true);
20+
21+
return new ItemView(
22+
id: $id,
23+
name: $data['name'] ?? 'Original',
24+
price: $data['price'] ?? 1000,
25+
);
26+
}
27+
}

0 commit comments

Comments
 (0)