This commit is contained in:
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
+29
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
+31
@@ -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);
|
||||
});
|
||||
}
|
||||
};
|
||||
+31
@@ -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')),
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user