feat: add a paginated travel export for the teamer app
This commit is contained in:
1 parent
6b539f43f3
commit
7dadb6806a
10 files changed
+915
No files matched your search
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Api;
|
||||
|
||||
use App\Controller\Api\ExportController;
|
||||
use App\Controller\Api\TravelController;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Routing\Attribute\Route as RouteAttribute;
|
||||
use Symfony\Component\Routing\Matcher\UrlMatcher;
|
||||
use Symfony\Component\Routing\RequestContext;
|
||||
use Symfony\Component\Routing\Route;
|
||||
use Symfony\Component\Routing\RouteCollection;
|
||||
|
||||
/**
|
||||
* Guards the /api/travels namespace against a sibling route being swallowed by byCode().
|
||||
*
|
||||
* `/api/travels/{dateCode}` accepts an arbitrary string, so anything else placed under
|
||||
* `/api/travels/` competes with every date code, and a path falling through to the wrong
|
||||
* route answers 404 with a body that reads like an endpoint holding no data. The bulk
|
||||
* export lives at `/api/exports/travels` for exactly this reason. The collection is built
|
||||
* by reflecting over the real attributes, so this breaks if the declarations change.
|
||||
*/
|
||||
class TravelControllerRoutingTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @return array<string, array{string, string}>
|
||||
*/
|
||||
public static function pathProvider(): array
|
||||
{
|
||||
return [
|
||||
'the export lives outside the travels namespace' => ['/api/exports/travels', 'api_exports_travels'],
|
||||
// Regression: a dateCode requirement of `.+` instead of the default `[^/]+` widens
|
||||
// the segment across slashes, and this four-segment path is then swallowed by byCode().
|
||||
'availability is not shadowed by the code lookup' => [
|
||||
'/api/travels/12794/225444/2026-12-30/availability',
|
||||
'api_travel_hotel_availability',
|
||||
],
|
||||
'numeric ids still reach byId' => ['/api/travels/12794/225444', 'api_travel_single_id'],
|
||||
'a date code alone reaches byCode' => ['/api/travels/SBW261226', 'api_travel_single_code'],
|
||||
'a date and hotel code reach byCode' => ['/api/travels/SBW261226/H1', 'api_travel_single_code'],
|
||||
'the mapping index is untouched' => ['/api/travels', 'api_travel_mapping'],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('pathProvider')]
|
||||
public function testPathResolvesToTheIntendedRoute(string $path, string $expected): void
|
||||
{
|
||||
$matcher = new UrlMatcher($this->collection(), new RequestContext('', 'GET'));
|
||||
|
||||
self::assertSame($expected, $matcher->match($path)['_route']);
|
||||
}
|
||||
|
||||
public function testDateCodeNeverSpansSlashes(): void
|
||||
{
|
||||
$route = $this->collection()->get('api_travel_single_code');
|
||||
|
||||
self::assertNotNull($route);
|
||||
self::assertStringNotContainsString(
|
||||
'.+',
|
||||
(string) $route->getRequirement('dateCode'),
|
||||
'dateCode must keep the single-segment class, or it shadows the routes below it',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The export must stay out of `/api/travels/`, where it would be one date code away from
|
||||
* ambiguity and would need a negative lookahead on byCode() to be reachable at all.
|
||||
*/
|
||||
public function testNothingButTravelLookupsLivesUnderTheTravelsNamespace(): void
|
||||
{
|
||||
foreach ($this->collection()->all() as $name => $route) {
|
||||
self::assertStringNotContainsString(
|
||||
'/api/travels/export',
|
||||
$route->getPath(),
|
||||
$name.' must not sit in the date code namespace',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reflects the controller's own #[Route] attributes, in declaration order.
|
||||
*/
|
||||
private function collection(): RouteCollection
|
||||
{
|
||||
$collection = new RouteCollection();
|
||||
|
||||
foreach ([TravelController::class, ExportController::class] as $controller) {
|
||||
$this->addRoutes($collection, $controller);
|
||||
}
|
||||
|
||||
return $collection;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param class-string $controller
|
||||
*/
|
||||
private function addRoutes(RouteCollection $collection, string $controller): void
|
||||
{
|
||||
$class = new \ReflectionClass($controller);
|
||||
|
||||
$prefix = '';
|
||||
foreach ($class->getAttributes(RouteAttribute::class) as $attribute) {
|
||||
$prefix = (string) $attribute->newInstance()->path;
|
||||
}
|
||||
|
||||
foreach ($class->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
|
||||
foreach ($method->getAttributes(RouteAttribute::class) as $attribute) {
|
||||
$route = $attribute->newInstance();
|
||||
|
||||
$collection->add((string) $route->name, new Route(
|
||||
$prefix.$route->path,
|
||||
$route->defaults,
|
||||
$route->requirements,
|
||||
[],
|
||||
'',
|
||||
[],
|
||||
$route->methods,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user