add notes/remark and refactor round-trip
PHP Tests / php-tests (push) Has been cancelled

This commit is contained in:
Nyan Lin Paing
2026-08-22 21:43:41 +07:00
parent 894352b43f
commit fa908cdcaf
46 changed files with 1679 additions and 182 deletions
@@ -23,7 +23,6 @@ class EvRouteFactory extends Factory
'ev_company_id' => EvCompany::factory(),
'from_destination_id' => Destination::factory(),
'to_destination_id' => Destination::factory(),
'is_round_trip' => false,
'is_active' => true,
];
}
@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Staff-curated flag for surfacing a route as "popular" on the customer
* side filterable via the routes index endpoint's `popular` param.
*/
public function up(): void
{
Schema::table('ev_routes', function (Blueprint $table) {
$table->boolean('is_popular')->default(false)->after('is_active');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('ev_routes', function (Blueprint $table) {
$table->dropColumn('is_popular');
});
}
};
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Round trip is no longer a flag on the route a round-trip booking now
* explicitly supplies a `return_ev_route_id`, validated server-side as
* the true reverse of the outbound route (`EvRoute::isReverseOf`), so
* this flag has no remaining purpose (domain.md §2b).
*/
public function up(): void
{
Schema::table('ev_routes', function (Blueprint $table) {
$table->dropColumn('is_round_trip');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('ev_routes', function (Blueprint $table) {
$table->boolean('is_round_trip')->default(false);
});
}
};
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* "Popular routes" is being reworked from scratch to match the
* client's actual logic (details TBD) the blunt is_popular flag
* shipped in 2026_08_20_010000 didn't align with it, so it's removed
* rather than kept around unused.
*/
public function up(): void
{
Schema::table('ev_routes', function (Blueprint $table) {
$table->dropColumn('is_popular');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('ev_routes', function (Blueprint $table) {
$table->boolean('is_popular')->default(false);
});
}
};
@@ -4,7 +4,7 @@ use Illuminate\Support\Facades\Route;
use Modules\Routing\Http\Controllers\EvRouteController;
Route::prefix('api/v1')->middleware(['api', 'api.auth', 'throttle:api-read'])->group(function () {
Route::get('/routes', [EvRouteController::class, 'index'])->name('routing.routes.index');
Route::post('/routes/search', [EvRouteController::class, 'search'])->name('routing.routes.search');
Route::get('/routes/{route}', [EvRouteController::class, 'show'])->name('routing.routes.show');
Route::get('/routes/{route}/pricing', [EvRouteController::class, 'pricing'])->name('routing.routes.pricing');
Route::get('/routes/{route}/time-slots', [EvRouteController::class, 'timeSlots'])->name('routing.routes.time-slots');
@@ -6,6 +6,8 @@ use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Modules\Shared\Enums\VehicleOption;
@@ -15,74 +17,88 @@ class EvRouteForm
{
return $schema
->components([
Select::make('ev_company_id')
->label('EV Company')
->relationship('company', 'name')
->required()
->searchable()
->preload(),
Select::make('from_destination_id')
->label('From')
->relationship('fromDestination', 'name')
->required()
->searchable()
->preload(),
Select::make('to_destination_id')
->label('To')
->relationship('toDestination', 'name')
->required()
->searchable()
->preload()
->different('from_destination_id')
->validationMessages([
'different' => 'The destination must be different from the origin.',
]),
Select::make('timeSlots')
->label('Departure Time Slots')
->relationship('timeSlots', 'label')
->multiple()
->searchable()
->preload(),
Toggle::make('is_round_trip')
->required()
->default(false),
Toggle::make('is_active')
->required()
->default(false)
->helperText('Every non-blocked vehicle option must have a price above 0 before a route can be activated.'),
Repeater::make('pricing')
->relationship()
->label('Pricing')
Section::make('Route')
->schema([
Select::make('vehicle_option')
->options(array_combine(
array_map(fn (VehicleOption $option) => $option->value, VehicleOption::cases()),
array_map(fn (VehicleOption $option) => str($option->value)->headline()->toString(), VehicleOption::cases()),
))
->disabled()
->dehydrated()
->required(),
TextInput::make('price')
->numeric()
->minValue(0)
->required(),
Toggle::make('is_blocked')
->label('Blocked')
->helperText('Hidden from booking regardless of price.'),
Grid::make(2)
->schema([
Select::make('ev_company_id')
->label('EV Company')
->relationship('company', 'name')
->required()
->searchable()
->preload(),
Select::make('from_destination_id')
->label('From')
->relationship('fromDestination', 'name')
->required()
->searchable()
->preload(),
Select::make('to_destination_id')
->label('To')
->relationship('toDestination', 'name')
->required()
->searchable()
->preload()
->different('from_destination_id')
->validationMessages([
'different' => 'The destination must be different from the origin.',
]),
Select::make('timeSlots')
->label('Departure Time Slots')
->relationship('timeSlots', 'label')
->multiple()
->searchable()
->preload(),
]),
])
->columns(3)
->default(
collect(VehicleOption::cases())
->map(fn (VehicleOption $option) => [
'vehicle_option' => $option->value,
'price' => 0,
'is_blocked' => false,
->columnSpanFull(),
Section::make('Options')
->schema([
Grid::make(2)
->schema([
Toggle::make('is_active')
->required()
->default(false)
->helperText('Every non-blocked vehicle option must have a price above 0 before a route can be activated.'),
]),
])
->columnSpanFull(),
Section::make('Pricing')
->schema([
Repeater::make('pricing')
->relationship()
->hiddenLabel()
->schema([
Select::make('vehicle_option')
->options(array_combine(
array_map(fn (VehicleOption $option) => $option->value, VehicleOption::cases()),
array_map(fn (VehicleOption $option) => str($option->value)->headline()->toString(), VehicleOption::cases()),
))
->disabled()
->dehydrated()
->required(),
TextInput::make('price')
->numeric()
->minValue(0)
->required(),
Toggle::make('is_blocked')
->label('Blocked')
->helperText('Hidden from booking regardless of price.'),
])
->all()
)
->addable(false)
->deletable(false)
->reorderable(false)
->columns(3)
->default(
collect(VehicleOption::cases())
->map(fn (VehicleOption $option) => [
'vehicle_option' => $option->value,
'price' => 0,
'is_blocked' => false,
])
->all()
)
->addable(false)
->deletable(false)
->reorderable(false),
])
->columnSpanFull(),
]);
}
@@ -5,8 +5,10 @@ namespace Modules\Routing\Filament\Resources\EvRoutes\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Support\Enums\Width;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Modules\Routing\Models\EvRoute;
@@ -40,8 +42,6 @@ class EvRoutesTable
.($pricing->is_blocked ? 'Blocked' : number_format($pricing->price, 0)))
->all())
->listWithLineBreaks(),
IconColumn::make('is_round_trip')
->boolean(),
IconColumn::make('is_active')
->boolean(),
TextColumn::make('created_at')
@@ -50,9 +50,25 @@ class EvRoutesTable
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
SelectFilter::make('ev_company_id')
->label('Company')
->relationship('company', 'name')
->searchable()
->preload(),
SelectFilter::make('from_destination_id')
->label('From')
->relationship('fromDestination', 'name')
->searchable()
->preload(),
SelectFilter::make('to_destination_id')
->label('To')
->relationship('toDestination', 'name')
->searchable()
->preload(),
TernaryFilter::make('is_active'),
TernaryFilter::make('is_round_trip'),
])
->filtersFormColumns(2)
->filtersFormWidth(Width::Large)
->recordActions([
EditAction::make(),
])
@@ -2,10 +2,16 @@
namespace Modules\Routing\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Routing\Controller;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Modules\Catalog\Models\EvCompany;
use Modules\Routing\Http\Requests\SearchRoutesRequest;
use Modules\Routing\Http\Resources\EvRouteResource;
use Modules\Routing\Http\Resources\RoutePricingResource;
use Modules\Routing\Http\Resources\RouteTimeSlotResource;
@@ -22,26 +28,111 @@ class EvRouteController extends Controller
private const CACHE_TTL_MINUTES = 5;
public function index(Request $request): AnonymousResourceCollection
/**
* POST, not GET: round_trip=true returns two independent result sets
* (routes + return_routes) in one response, which doesn't fit a plain
* GET-with-query-params search shape as cleanly (domain.md §2b).
*/
public function search(SearchRoutesRequest $request): JsonResponse
{
$filters = $request->only(['company', 'from', 'to', 'date']);
$page = $request->integer('page', 1);
$filters = $request->only(['company', 'from', 'to', 'date', 'time_slot']);
$isRoundTrip = $request->boolean('round_trip');
$routes = Cache::tags(self::CACHE_TAG)->remember(
'routes:index:'.md5(json_encode($filters + ['page' => $page])),
// Separate page params: routes and return_routes almost always have
// different totals, so paging one must never slice the other at the
// same offset (e.g. return_routes with only 3 rows would come back
// empty on page=2 while routes still has real data there).
$routes = $this->searchRoutes($filters, fromKey: 'from', toKey: 'to', pageName: 'page', page: $request->integer('page', 1));
$returnRoutes = $isRoundTrip
? $this->searchRoutes($filters, fromKey: 'to', toKey: 'from', pageName: 'return_page', page: $request->integer('return_page', 1))
: new LengthAwarePaginator([], 0, 15);
return response()->json([
'routes' => EvRouteResource::collection($routes)->response()->getData(true),
'return_routes' => EvRouteResource::collection($returnRoutes)->response()->getData(true),
// The company/time_slot options actually available for this
// from->to pair — computed from from/to alone, ignoring any
// company/time_slot already applied, so the client can offer
// switching between them rather than guessing a static list.
'filters' => $this->filterOptions($filters['from'] ?? null, $filters['to'] ?? null),
'return_filters' => $isRoundTrip
? $this->filterOptions($filters['to'] ?? null, $filters['from'] ?? null)
: ['companies' => [], 'time_slots' => []],
]);
}
/**
* @return array{companies: array<int, array<string, mixed>>, time_slots: array<int, array<string, mixed>>}
*/
private function filterOptions(mixed $from, mixed $to): array
{
if (blank($from) || blank($to)) {
return ['companies' => [], 'time_slots' => []];
}
$cacheKey = "routes:filter-options:{$from}:{$to}";
return Cache::tags(self::CACHE_TAG)->remember(
$cacheKey,
now()->addMinutes(self::CACHE_TTL_MINUTES),
function () use ($from, $to) {
$routes = EvRoute::query()
->where('is_active', true)
->where('from_destination_id', $from)
->where('to_destination_id', $to)
->with(['company', 'timeSlots' => fn (BelongsToMany $query) => $query->wherePivot('is_active', true)])
->get();
$companies = $routes->pluck('company')->filter()->unique('id')->sortBy('name')->values();
$timeSlots = $routes->flatMap(fn (EvRoute $route) => $route->timeSlots)->unique('id')->sortBy('time')->values();
return [
// Facet purposes only — not the full EvCompanyResource
// (no slug/description/contact/logo needed just to
// populate a filter option).
'companies' => $companies->map(fn (EvCompany $company) => [
'id' => $company->id,
'name' => $company->name,
'mm_name' => $company->mm_name,
])->all(),
'time_slots' => $timeSlots->map(fn ($slot) => [
'id' => $slot->id,
'label' => $slot->label,
'time' => $slot->time?->format('H:i'),
])->all(),
];
},
);
}
/**
* @param array<string, mixed> $filters Keyed by 'from'/'to' regardless of
* $fromKey/$toKey swapped for the return leg of a round trip.
*/
private function searchRoutes(array $filters, string $fromKey, string $toKey, string $pageName, int $page): LengthAwarePaginator
{
$cacheKey = 'routes:search:'.md5(json_encode($filters + ['fromKey' => $fromKey, 'page' => $page]));
return Cache::tags(self::CACHE_TAG)->remember(
$cacheKey,
now()->addMinutes(self::CACHE_TTL_MINUTES),
fn () => EvRoute::query()
->where('is_active', true)
->when($request->filled('company'), fn ($query) => $query->where('ev_company_id', $request->integer('company')))
->when($request->filled('from'), fn ($query) => $query->where('from_destination_id', $request->integer('from')))
->when($request->filled('to'), fn ($query) => $query->where('to_destination_id', $request->integer('to')))
->when(filled($filters['company'] ?? null), fn (Builder $query) => $query->where('ev_company_id', $filters['company']))
->when(filled($filters[$fromKey] ?? null), fn (Builder $query) => $query->where('from_destination_id', $filters[$fromKey]))
->when(filled($filters[$toKey] ?? null), fn (Builder $query) => $query->where('to_destination_id', $filters[$toKey]))
->when(filled($filters['time_slot'] ?? null), fn (Builder $query) => $query->whereHas(
'timeSlots',
fn (Builder $timeSlotQuery) => $timeSlotQuery
->where('departure_time_slots.time', Carbon::createFromFormat('H:i', $filters['time_slot'])->format('H:i:s'))
->where('ev_route_time_slots.is_active', true),
))
// `date` is accepted for forward-compatibility with future per-date capacity
// checks (domain.md §7), but v1 has no route-level calendar to filter against.
->with(self::EAGER_LOADS)
->paginate(),
->paginate(perPage: 15, pageName: $pageName, page: $page),
);
return EvRouteResource::collection($routes);
}
public function show(EvRoute $route): EvRouteResource
@@ -0,0 +1,41 @@
<?php
namespace Modules\Routing\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* Shape validation only for the routes search endpoint. round_trip=true
* requires both from and to "return" only means something for a specific
* origin/destination pair, not an unfiltered route list.
*/
class SearchRoutesRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<int, mixed>>
*/
public function rules(): array
{
return [
'company' => ['nullable', 'integer', 'exists:ev_companies,id'],
'from' => ['nullable', 'integer', 'exists:destinations,id', 'required_if:round_trip,true'],
'to' => ['nullable', 'integer', 'exists:destinations,id', 'different:from', 'required_if:round_trip,true'],
'date' => ['nullable', 'date'],
// The catalog's shared time value (e.g. "06:00"), not a
// DepartureTimeSlot id — matches how customers think about
// departure times (domain.md §1).
'time_slot' => ['nullable', 'date_format:H:i'],
'round_trip' => ['sometimes', 'boolean'],
// Independent page cursors — routes and return_routes almost
// always have different totals, so they can't share one `page`
// without one side silently paginating the other's offset.
'page' => ['nullable', 'integer', 'min:1'],
'return_page' => ['nullable', 'integer', 'min:1'],
];
}
}
@@ -18,7 +18,6 @@ class EvRouteResource extends JsonResource
{
return [
'id' => $this->id,
'is_round_trip' => $this->is_round_trip,
'is_active' => $this->is_active,
'company' => new EvCompanyResource($this->whenLoaded('company')),
'from_destination' => new DestinationResource($this->whenLoaded('fromDestination')),
+12 -2
View File
@@ -42,7 +42,6 @@ class EvRoute extends Model
'ev_company_id',
'from_destination_id',
'to_destination_id',
'is_round_trip',
'is_active',
];
@@ -52,7 +51,6 @@ class EvRoute extends Model
protected function casts(): array
{
return [
'is_round_trip' => 'boolean',
'is_active' => 'boolean',
];
}
@@ -99,4 +97,16 @@ class EvRoute extends Model
get: fn (): string => $this->fromDestination->name.' → '.$this->toDestination->name,
);
}
/**
* True when this route is the exact reverse direction of $other (from
* and to swapped) used to validate a booking's `return_ev_route_id`
* is genuinely the return leg of its outbound route, not an unrelated
* pair (domain.md §2b).
*/
public function isReverseOf(EvRoute $other): bool
{
return $this->from_destination_id === $other->to_destination_id
&& $this->to_destination_id === $other->from_destination_id;
}
}
@@ -41,6 +41,39 @@ test('can list ev routes', function () {
->assertCanSeeTableRecords($routes);
});
test('can filter ev routes by company, from, and to', function () {
$companyA = EvCompany::factory()->create();
$companyB = EvCompany::factory()->create();
$yangon = Destination::factory()->create();
$mandalay = Destination::factory()->create();
$bagan = Destination::factory()->create();
$matching = EvRoute::factory()->create([
'ev_company_id' => $companyA->id,
'from_destination_id' => $yangon->id,
'to_destination_id' => $mandalay->id,
]);
$wrongCompany = EvRoute::factory()->create([
'ev_company_id' => $companyB->id,
'from_destination_id' => $yangon->id,
'to_destination_id' => $mandalay->id,
]);
$wrongDestination = EvRoute::factory()->create([
'ev_company_id' => $companyA->id,
'from_destination_id' => $yangon->id,
'to_destination_id' => $bagan->id,
]);
Livewire::test(ListEvRoutes::class)
->filterTable('ev_company_id', $companyA->id)
->filterTable('from_destination_id', $yangon->id)
->filterTable('to_destination_id', $mandalay->id)
->assertCanSeeTableRecords([$matching])
->assertCanNotSeeTableRecords([$wrongCompany, $wrongDestination]);
});
test('list shows each vehicle option price stacked, and blocked options instead of a price', function () {
$route = EvRoute::factory()->create();
@@ -63,7 +96,6 @@ test('creating a route also creates all three vehicle option pricing rows, defau
'ev_company_id' => $company->id,
'from_destination_id' => $from->id,
'to_destination_id' => $to->id,
'is_round_trip' => false,
'is_active' => false,
'pricing' => pricingPayload(),
])
@@ -25,14 +25,26 @@ test('an ev route belongs to a company and two destinations', function () {
->and($route->toDestination->is($to))->toBeTrue();
});
test('is_round_trip and is_active cast to boolean', function () {
test('is_active casts to boolean', function () {
$route = EvRoute::factory()->create([
'is_round_trip' => 1,
'is_active' => 0,
]);
expect($route->is_round_trip)->toBeTrue()
->and($route->is_active)->toBeFalse();
expect($route->is_active)->toBeFalse();
});
test('isReverseOf detects a route with from/to swapped', function () {
$a = Destination::factory()->create();
$b = Destination::factory()->create();
$outbound = EvRoute::factory()->create(['from_destination_id' => $a->id, 'to_destination_id' => $b->id]);
$return = EvRoute::factory()->create(['from_destination_id' => $b->id, 'to_destination_id' => $a->id]);
$unrelated = EvRoute::factory()->create();
expect($return->isReverseOf($outbound))->toBeTrue()
->and($outbound->isReverseOf($return))->toBeTrue()
->and($unrelated->isReverseOf($outbound))->toBeFalse()
->and($outbound->isReverseOf($outbound))->toBeFalse();
});
test('a route can be attached to time slots via the pivot, carrying its own is_active flag', function () {
@@ -41,42 +41,42 @@ test('saving an ev route invalidates the routes cache tag', function () {
$route = EvRoute::factory()->create(['is_active' => true]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->getJson('/api/v1/routes')
->assertJsonCount(1, 'data');
->postJson('/api/v1/routes/search')
->assertJsonCount(1, 'routes.data');
// Bypass Eloquent so the change wouldn't be visible without invalidation.
EvRoute::query()->where('id', $route->id)->update(['is_active' => false]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->getJson('/api/v1/routes')
->assertJsonCount(1, 'data');
->postJson('/api/v1/routes/search')
->assertJsonCount(1, 'routes.data');
$route->refresh()->save();
$this->withHeader('Authorization', "Bearer {$this->token}")
->getJson('/api/v1/routes')
->assertJsonCount(0, 'data');
->postJson('/api/v1/routes/search')
->assertJsonCount(0, 'routes.data');
});
test('deleting an ev route invalidates the routes cache tag', function () {
$route = EvRoute::factory()->create(['is_active' => true]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->getJson('/api/v1/routes')
->assertJsonCount(1, 'data');
->postJson('/api/v1/routes/search')
->assertJsonCount(1, 'routes.data');
$route->delete();
$this->withHeader('Authorization', "Bearer {$this->token}")
->getJson('/api/v1/routes')
->assertJsonCount(0, 'data');
->postJson('/api/v1/routes/search')
->assertJsonCount(0, 'routes.data');
});
test('flushing the routes cache tag does not affect other cached data', function () {
Cache::put('unrelated-key', 'still here', now()->addMinutes(5));
$route = EvRoute::factory()->create(['is_active' => true]);
$route->update(['is_round_trip' => true]);
$route->update(['is_active' => false]);
expect(Cache::get('unrelated-key'))->toBe('still here');
});
@@ -12,7 +12,7 @@ beforeEach(function () {
$this->token = User::factory()->create()->createToken('test-token')->plainTextToken;
});
test('lists active routes with nested company, destinations, time slots and pricing', function () {
test('searches active routes with nested company, destinations, time slots and pricing', function () {
$route = EvRoute::factory()->create(['is_active' => true]);
EvRoute::factory()->create(['is_active' => false]);
@@ -26,17 +26,18 @@ test('lists active routes with nested company, destinations, time slots and pric
]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->getJson('/api/v1/routes')
->postJson('/api/v1/routes/search')
->assertSuccessful()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $route->id)
->assertJsonPath('data.0.company.id', $route->ev_company_id)
->assertJsonPath('data.0.from_destination.id', $route->from_destination_id)
->assertJsonPath('data.0.to_destination.id', $route->to_destination_id)
->assertJsonPath('data.0.time_slots.0.id', $slot->id)
->assertJsonPath('data.0.time_slots.0.is_active', true)
->assertJsonPath('data.0.pricing.0.vehicle_option', 'front_seat')
->assertJsonPath('data.0.pricing.0.price', '12000.00');
->assertJsonCount(1, 'routes.data')
->assertJsonPath('routes.data.0.id', $route->id)
->assertJsonPath('routes.data.0.company.id', $route->ev_company_id)
->assertJsonPath('routes.data.0.from_destination.id', $route->from_destination_id)
->assertJsonPath('routes.data.0.to_destination.id', $route->to_destination_id)
->assertJsonPath('routes.data.0.time_slots.0.id', $slot->id)
->assertJsonPath('routes.data.0.time_slots.0.is_active', true)
->assertJsonPath('routes.data.0.pricing.0.vehicle_option', 'front_seat')
->assertJsonPath('routes.data.0.pricing.0.price', '12000.00')
->assertJsonCount(0, 'return_routes.data');
});
test('filters routes by company, from, and to', function () {
@@ -68,24 +69,211 @@ test('filters routes by company, from, and to', function () {
]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->getJson('/api/v1/routes?'.http_build_query([
->postJson('/api/v1/routes/search', [
'company' => $companyA->id,
'from' => $yangon->id,
'to' => $mandalay->id,
]))
])
->assertSuccessful()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $matching->id);
->assertJsonCount(1, 'routes.data')
->assertJsonPath('routes.data.0.id', $matching->id);
});
test('filters routes by time_slot', function () {
$morning = DepartureTimeSlot::factory()->create(['time' => '06:00']);
$evening = DepartureTimeSlot::factory()->create(['time' => '18:00']);
$morningRoute = EvRoute::factory()->create(['is_active' => true]);
$morningRoute->timeSlots()->attach($morning->id, ['is_active' => true]);
$eveningRoute = EvRoute::factory()->create(['is_active' => true]);
$eveningRoute->timeSlots()->attach($evening->id, ['is_active' => true]);
// Attached but inactive on this route — must not match.
$inactivePivotRoute = EvRoute::factory()->create(['is_active' => true]);
$inactivePivotRoute->timeSlots()->attach($morning->id, ['is_active' => false]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson('/api/v1/routes/search', ['time_slot' => '06:00'])
->assertSuccessful()
->assertJsonCount(1, 'routes.data')
->assertJsonPath('routes.data.0.id', $morningRoute->id);
});
test('round trip: returns both routes and return_routes, swapped from/to', function () {
$company = EvCompany::factory()->create();
$yangon = Destination::factory()->create();
$mandalay = Destination::factory()->create();
$outbound = EvRoute::factory()->create([
'ev_company_id' => $company->id,
'from_destination_id' => $yangon->id,
'to_destination_id' => $mandalay->id,
'is_active' => true,
]);
$return = EvRoute::factory()->create([
'ev_company_id' => $company->id,
'from_destination_id' => $mandalay->id,
'to_destination_id' => $yangon->id,
'is_active' => true,
]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson('/api/v1/routes/search', [
'round_trip' => true,
'from' => $yangon->id,
'to' => $mandalay->id,
])
->assertSuccessful()
->assertJsonCount(1, 'routes.data')
->assertJsonPath('routes.data.0.id', $outbound->id)
->assertJsonCount(1, 'return_routes.data')
->assertJsonPath('return_routes.data.0.id', $return->id);
});
test('response includes the distinct companies and time_slots actually available for the from-to pair', function () {
$yangon = Destination::factory()->create();
$mandalay = Destination::factory()->create();
$companyA = EvCompany::factory()->create(['name' => 'Alpha EV']);
$companyB = EvCompany::factory()->create(['name' => 'Beta EV']);
$morning = DepartureTimeSlot::factory()->create(['time' => '06:00']);
$evening = DepartureTimeSlot::factory()->create(['time' => '18:00']);
$inactiveSlot = DepartureTimeSlot::factory()->create(['time' => '12:00']);
$routeA = EvRoute::factory()->create([
'ev_company_id' => $companyA->id,
'from_destination_id' => $yangon->id,
'to_destination_id' => $mandalay->id,
'is_active' => true,
]);
$routeA->timeSlots()->attach([$morning->id => ['is_active' => true], $inactiveSlot->id => ['is_active' => false]]);
$routeB = EvRoute::factory()->create([
'ev_company_id' => $companyB->id,
'from_destination_id' => $yangon->id,
'to_destination_id' => $mandalay->id,
'is_active' => true,
]);
$routeB->timeSlots()->attach($evening->id, ['is_active' => true]);
// Unrelated pair — must not leak into the facets.
$bagan = Destination::factory()->create();
$unrelated = EvRoute::factory()->create([
'from_destination_id' => $yangon->id,
'to_destination_id' => $bagan->id,
'is_active' => true,
]);
$unrelated->timeSlots()->attach(DepartureTimeSlot::factory()->create(['time' => '09:00'])->id, ['is_active' => true]);
// Applying a company filter narrows `routes.data` but must not narrow
// the facets themselves — facets always reflect the full from-to pair.
$response = $this->withHeader('Authorization', "Bearer {$this->token}")
->postJson('/api/v1/routes/search', [
'from' => $yangon->id,
'to' => $mandalay->id,
'company' => $companyA->id,
])
->assertSuccessful()
->assertJsonCount(1, 'routes.data')
->json();
expect(collect($response['filters']['companies'])->pluck('id')->sort()->values()->all())
->toBe([$companyA->id, $companyB->id]);
// Facet shape is trimmed to id/name/mm_name — not the full company resource.
expect(array_keys($response['filters']['companies'][0]))->toBe(['id', 'name', 'mm_name']);
expect(collect($response['filters']['time_slots'])->pluck('time')->all())
->toBe(['06:00', '18:00']); // sorted by time, inactive pivot and unrelated pair excluded
});
test('filter options are empty when from/to are not both given', function () {
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson('/api/v1/routes/search')
->assertSuccessful()
->assertJson(['filters' => ['companies' => [], 'time_slots' => []]]);
});
test('round trip: return_filters reflect the swapped to-from pair', function () {
$yangon = Destination::factory()->create();
$mandalay = Destination::factory()->create();
$returnCompany = EvCompany::factory()->create();
EvRoute::factory()->create([
'from_destination_id' => $yangon->id,
'to_destination_id' => $mandalay->id,
'is_active' => true,
]);
EvRoute::factory()->create([
'ev_company_id' => $returnCompany->id,
'from_destination_id' => $mandalay->id,
'to_destination_id' => $yangon->id,
'is_active' => true,
]);
$response = $this->withHeader('Authorization', "Bearer {$this->token}")
->postJson('/api/v1/routes/search', [
'round_trip' => true,
'from' => $yangon->id,
'to' => $mandalay->id,
])
->assertSuccessful()
->json();
expect(collect($response['return_filters']['companies'])->pluck('id')->all())
->toBe([$returnCompany->id]);
});
test('round trip: routes and return_routes paginate independently via page and return_page', function () {
$company = EvCompany::factory()->create();
$yangon = Destination::factory()->create();
$mandalay = Destination::factory()->create();
// 20 outbound routes (2 pages of 15), only 3 return routes (1 page).
EvRoute::factory()->count(20)->create([
'ev_company_id' => $company->id,
'from_destination_id' => $yangon->id,
'to_destination_id' => $mandalay->id,
'is_active' => true,
]);
EvRoute::factory()->count(3)->create([
'ev_company_id' => $company->id,
'from_destination_id' => $mandalay->id,
'to_destination_id' => $yangon->id,
'is_active' => true,
]);
// page=2 must give the 2nd page of routes (5 remaining), while
// return_routes — with no return_page given — must still return its own
// full page 1 (all 3), not an empty slice at offset 2.
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson('/api/v1/routes/search', [
'round_trip' => true,
'from' => $yangon->id,
'to' => $mandalay->id,
'page' => 2,
])
->assertSuccessful()
->assertJsonCount(5, 'routes.data')
->assertJsonPath('routes.meta.current_page', 2)
->assertJsonCount(3, 'return_routes.data')
->assertJsonPath('return_routes.meta.current_page', 1);
});
test('round_trip without from and to is rejected', function () {
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson('/api/v1/routes/search', ['round_trip' => true])
->assertStatus(422)
->assertJsonValidationErrors(['from', 'to']);
});
test('paginates routes', function () {
EvRoute::factory()->count(20)->create(['is_active' => true]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->getJson('/api/v1/routes')
->postJson('/api/v1/routes/search')
->assertSuccessful()
->assertJsonCount(15, 'data')
->assertJsonPath('meta.total', 20);
->assertJsonCount(15, 'routes.data')
->assertJsonPath('routes.meta.total', 20);
});
test('shows a single active route', function () {
@@ -148,7 +336,7 @@ test('lists a route\'s time slots with the pivot active flag', function () {
test('routes endpoints reject unauthenticated requests', function () {
$route = EvRoute::factory()->create(['is_active' => true]);
$this->getJson('/api/v1/routes')->assertUnauthorized();
$this->postJson('/api/v1/routes/search')->assertUnauthorized();
$this->getJson("/api/v1/routes/{$route->id}")->assertUnauthorized();
$this->getJson("/api/v1/routes/{$route->id}/pricing")->assertUnauthorized();
$this->getJson("/api/v1/routes/{$route->id}/time-slots")->assertUnauthorized();