From 4da9ecfe7d7a8fc4de4ca4af306fb5b0e2b3e828 Mon Sep 17 00:00:00 2001 From: Nyan Lin Paing <117423022+LinPaing21@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:49:42 +0700 Subject: [PATCH] Add Routing module: EvRoute, pricing, time slots, read API, caching (T3.1-T3.7) Implements Phase 3 in full: EvRoute model with company/destination relations and a from/to-must-differ guard; the ev_route_time_slots pivot; RoutePricing with a per-route is_blocked flag (every route auto-manages exactly one price row per vehicle option via a fixed-row Filament repeater on both create and edit); PricingService::quote() with a shared VehicleOption enum; RoutingPlugin with the EvRouteResource admin UI (activation gated on non-blocked options being priced); the /api/v1/routes read API (list/show/pricing/time-slots, AI-agent-friendly nested shape); and Redis-tag-based response caching invalidated via EvRoute/RoutePricing observers. --- .../catalog/src/Models/DepartureTimeSlot.php | 9 + .../database/factories/EvRouteFactory.php | 30 +++ .../factories/RoutePricingFactory.php | 29 +++ ...26_08_06_140000_create_ev_routes_table.php | 32 +++ ...41500_create_ev_route_time_slots_table.php | 32 +++ ...8_06_143000_create_route_pricing_table.php | 32 +++ ..._add_is_blocked_to_route_pricing_table.php | 28 +++ app-modules/routing/routes/routing-routes.php | 10 + .../routing/src/Data/PriceQuoteData.php | 14 ++ .../RoutePricingNotFoundException.php | 15 ++ .../routing/src/Filament/Pages/.gitkeep | 0 .../Resources/EvRoutes/EvRouteResource.php | 63 +++++ .../EvRoutes/Pages/CreateEvRoute.php | 28 +++ .../Resources/EvRoutes/Pages/EditEvRoute.php | 36 +++ .../Resources/EvRoutes/Pages/ListEvRoutes.php | 19 ++ .../EvRoutes/Schemas/EvRouteForm.php | 89 +++++++ .../EvRoutes/Tables/EvRoutesTable.php | 65 +++++ .../routing/src/Filament/Widgets/.gitkeep | 0 .../Http/Controllers/EvRouteController.php | 84 +++++++ .../src/Http/Resources/EvRouteResource.php | 30 +++ .../Http/Resources/RoutePricingResource.php | 23 ++ .../Http/Resources/RouteTimeSlotResource.php | 24 ++ app-modules/routing/src/Models/EvRoute.php | 78 ++++++ .../routing/src/Models/RoutePricing.php | 44 ++++ .../routing/src/Observers/EvRouteObserver.php | 19 ++ .../src/Observers/RoutePricingObserver.php | 19 ++ .../src/Providers/RoutingServiceProvider.php | 9 +- app-modules/routing/src/RoutingPlugin.php | 38 +++ .../routing/src/Services/PricingService.php | 28 +++ .../tests/Feature/EvRouteResourceTest.php | 234 ++++++++++++++++++ .../routing/tests/Feature/EvRouteTest.php | 79 ++++++ .../tests/Feature/RoutePricingTest.php | 58 +++++ .../tests/Feature/RoutesCachingTest.php | 82 ++++++ .../tests/Feature/RoutesReadApiTest.php | 145 +++++++++++ .../routing/tests/Unit/PricingServiceTest.php | 51 ++++ .../shared/src/Enums/VehicleOption.php | 10 + app/Providers/Filament/AdminPanelProvider.php | 2 + tests/Pest.php | 2 +- 38 files changed, 1588 insertions(+), 2 deletions(-) create mode 100644 app-modules/routing/database/factories/EvRouteFactory.php create mode 100644 app-modules/routing/database/factories/RoutePricingFactory.php create mode 100644 app-modules/routing/database/migrations/2026_08_06_140000_create_ev_routes_table.php create mode 100644 app-modules/routing/database/migrations/2026_08_06_141500_create_ev_route_time_slots_table.php create mode 100644 app-modules/routing/database/migrations/2026_08_06_143000_create_route_pricing_table.php create mode 100644 app-modules/routing/database/migrations/2026_08_06_150000_add_is_blocked_to_route_pricing_table.php create mode 100644 app-modules/routing/src/Data/PriceQuoteData.php create mode 100644 app-modules/routing/src/Exceptions/RoutePricingNotFoundException.php create mode 100644 app-modules/routing/src/Filament/Pages/.gitkeep create mode 100644 app-modules/routing/src/Filament/Resources/EvRoutes/EvRouteResource.php create mode 100644 app-modules/routing/src/Filament/Resources/EvRoutes/Pages/CreateEvRoute.php create mode 100644 app-modules/routing/src/Filament/Resources/EvRoutes/Pages/EditEvRoute.php create mode 100644 app-modules/routing/src/Filament/Resources/EvRoutes/Pages/ListEvRoutes.php create mode 100644 app-modules/routing/src/Filament/Resources/EvRoutes/Schemas/EvRouteForm.php create mode 100644 app-modules/routing/src/Filament/Resources/EvRoutes/Tables/EvRoutesTable.php create mode 100644 app-modules/routing/src/Filament/Widgets/.gitkeep create mode 100644 app-modules/routing/src/Http/Controllers/EvRouteController.php create mode 100644 app-modules/routing/src/Http/Resources/EvRouteResource.php create mode 100644 app-modules/routing/src/Http/Resources/RoutePricingResource.php create mode 100644 app-modules/routing/src/Http/Resources/RouteTimeSlotResource.php create mode 100644 app-modules/routing/src/Models/EvRoute.php create mode 100644 app-modules/routing/src/Models/RoutePricing.php create mode 100644 app-modules/routing/src/Observers/EvRouteObserver.php create mode 100644 app-modules/routing/src/Observers/RoutePricingObserver.php create mode 100644 app-modules/routing/src/RoutingPlugin.php create mode 100644 app-modules/routing/src/Services/PricingService.php create mode 100644 app-modules/routing/tests/Feature/EvRouteResourceTest.php create mode 100644 app-modules/routing/tests/Feature/EvRouteTest.php create mode 100644 app-modules/routing/tests/Feature/RoutePricingTest.php create mode 100644 app-modules/routing/tests/Feature/RoutesCachingTest.php create mode 100644 app-modules/routing/tests/Feature/RoutesReadApiTest.php create mode 100644 app-modules/routing/tests/Unit/PricingServiceTest.php create mode 100644 app-modules/shared/src/Enums/VehicleOption.php diff --git a/app-modules/catalog/src/Models/DepartureTimeSlot.php b/app-modules/catalog/src/Models/DepartureTimeSlot.php index 4841c97..a55dfae 100644 --- a/app-modules/catalog/src/Models/DepartureTimeSlot.php +++ b/app-modules/catalog/src/Models/DepartureTimeSlot.php @@ -4,7 +4,9 @@ namespace Modules\Catalog\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Modules\Catalog\Database\Factories\DepartureTimeSlotFactory; +use Modules\Routing\Models\EvRoute; /** * A shared catalog of departure times, attached to routes via a pivot in the @@ -34,4 +36,11 @@ class DepartureTimeSlot extends Model 'is_active' => 'boolean', ]; } + + public function routes(): BelongsToMany + { + return $this->belongsToMany(EvRoute::class, 'ev_route_time_slots') + ->withPivot('is_active') + ->withTimestamps(); + } } diff --git a/app-modules/routing/database/factories/EvRouteFactory.php b/app-modules/routing/database/factories/EvRouteFactory.php new file mode 100644 index 0000000..f31f966 --- /dev/null +++ b/app-modules/routing/database/factories/EvRouteFactory.php @@ -0,0 +1,30 @@ + + */ +class EvRouteFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'ev_company_id' => EvCompany::factory(), + 'from_destination_id' => Destination::factory(), + 'to_destination_id' => Destination::factory(), + 'is_round_trip' => false, + 'is_active' => true, + ]; + } +} diff --git a/app-modules/routing/database/factories/RoutePricingFactory.php b/app-modules/routing/database/factories/RoutePricingFactory.php new file mode 100644 index 0000000..93e2e29 --- /dev/null +++ b/app-modules/routing/database/factories/RoutePricingFactory.php @@ -0,0 +1,29 @@ + + */ +class RoutePricingFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'ev_route_id' => EvRoute::factory(), + 'vehicle_option' => fake()->randomElement(VehicleOption::cases()), + 'price' => fake()->randomFloat(2, 5000, 50000), + 'is_blocked' => false, + ]; + } +} diff --git a/app-modules/routing/database/migrations/2026_08_06_140000_create_ev_routes_table.php b/app-modules/routing/database/migrations/2026_08_06_140000_create_ev_routes_table.php new file mode 100644 index 0000000..4935fde --- /dev/null +++ b/app-modules/routing/database/migrations/2026_08_06_140000_create_ev_routes_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('ev_company_id')->constrained('ev_companies')->cascadeOnDelete(); + $table->foreignId('from_destination_id')->constrained('destinations')->cascadeOnDelete(); + $table->foreignId('to_destination_id')->constrained('destinations')->cascadeOnDelete(); + $table->boolean('is_round_trip')->default(false); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ev_routes'); + } +}; diff --git a/app-modules/routing/database/migrations/2026_08_06_141500_create_ev_route_time_slots_table.php b/app-modules/routing/database/migrations/2026_08_06_141500_create_ev_route_time_slots_table.php new file mode 100644 index 0000000..2426c7b --- /dev/null +++ b/app-modules/routing/database/migrations/2026_08_06_141500_create_ev_route_time_slots_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('ev_route_id')->constrained('ev_routes')->cascadeOnDelete(); + $table->foreignId('departure_time_slot_id')->constrained('departure_time_slots')->cascadeOnDelete(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->unique(['ev_route_id', 'departure_time_slot_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ev_route_time_slots'); + } +}; diff --git a/app-modules/routing/database/migrations/2026_08_06_143000_create_route_pricing_table.php b/app-modules/routing/database/migrations/2026_08_06_143000_create_route_pricing_table.php new file mode 100644 index 0000000..c428b51 --- /dev/null +++ b/app-modules/routing/database/migrations/2026_08_06_143000_create_route_pricing_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('ev_route_id')->constrained('ev_routes')->cascadeOnDelete(); + $table->string('vehicle_option'); + $table->decimal('price', 10, 2); + $table->timestamps(); + + $table->unique(['ev_route_id', 'vehicle_option']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('route_pricing'); + } +}; diff --git a/app-modules/routing/database/migrations/2026_08_06_150000_add_is_blocked_to_route_pricing_table.php b/app-modules/routing/database/migrations/2026_08_06_150000_add_is_blocked_to_route_pricing_table.php new file mode 100644 index 0000000..504b21f --- /dev/null +++ b/app-modules/routing/database/migrations/2026_08_06_150000_add_is_blocked_to_route_pricing_table.php @@ -0,0 +1,28 @@ +boolean('is_blocked')->default(false)->after('price'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('route_pricing', function (Blueprint $table) { + $table->dropColumn('is_blocked'); + }); + } +}; diff --git a/app-modules/routing/routes/routing-routes.php b/app-modules/routing/routes/routing-routes.php index b3d9bbc..8db0a72 100644 --- a/app-modules/routing/routes/routing-routes.php +++ b/app-modules/routing/routes/routing-routes.php @@ -1 +1,11 @@ middleware(['api', 'auth:sanctum', 'throttle:60,1'])->group(function () { + Route::get('/routes', [EvRouteController::class, 'index'])->name('routing.routes.index'); + 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'); +}); diff --git a/app-modules/routing/src/Data/PriceQuoteData.php b/app-modules/routing/src/Data/PriceQuoteData.php new file mode 100644 index 0000000..13733d4 --- /dev/null +++ b/app-modules/routing/src/Data/PriceQuoteData.php @@ -0,0 +1,14 @@ +id}] and vehicle option [{$vehicleOption->value}]."); + } +} diff --git a/app-modules/routing/src/Filament/Pages/.gitkeep b/app-modules/routing/src/Filament/Pages/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app-modules/routing/src/Filament/Resources/EvRoutes/EvRouteResource.php b/app-modules/routing/src/Filament/Resources/EvRoutes/EvRouteResource.php new file mode 100644 index 0000000..ac8a3fc --- /dev/null +++ b/app-modules/routing/src/Filament/Resources/EvRoutes/EvRouteResource.php @@ -0,0 +1,63 @@ + ListEvRoutes::route('/'), + 'create' => CreateEvRoute::route('/create'), + 'edit' => EditEvRoute::route('/{record}/edit'), + ]; + } + + /** + * A route can only be activated once every non-blocked vehicle option + * carries a price above 0 — enforced here (form/UI level), not as a DB + * constraint. A route with every option blocked can never be activated. + * + * Checked against the raw pricing repeater data submitted on the form + * (not a DB read), since on the create form no pricing rows exist yet, + * and on the edit form the repeater's relationship save hasn't run yet + * by the time this is checked. + * + * @param array $pricingItems + */ + public static function hasCompletePricingData(array $pricingItems): bool + { + $nonBlocked = collect($pricingItems)->reject(fn (array $item) => (bool) ($item['is_blocked'] ?? false)); + + return $nonBlocked->isNotEmpty() && $nonBlocked->every(fn (array $item) => (float) ($item['price'] ?? 0) > 0); + } +} diff --git a/app-modules/routing/src/Filament/Resources/EvRoutes/Pages/CreateEvRoute.php b/app-modules/routing/src/Filament/Resources/EvRoutes/Pages/CreateEvRoute.php new file mode 100644 index 0000000..55198b9 --- /dev/null +++ b/app-modules/routing/src/Filament/Resources/EvRoutes/Pages/CreateEvRoute.php @@ -0,0 +1,28 @@ +data['is_active'] ?? false) + && ! EvRouteResource::hasCompletePricingData($this->data['pricing'] ?? []) + ) { + Notification::make() + ->warning() + ->title('Route cannot be activated yet') + ->body('Set a price above 0 for every non-blocked vehicle option first, then activate the route.') + ->send(); + + $this->halt(); + } + } +} diff --git a/app-modules/routing/src/Filament/Resources/EvRoutes/Pages/EditEvRoute.php b/app-modules/routing/src/Filament/Resources/EvRoutes/Pages/EditEvRoute.php new file mode 100644 index 0000000..b83add9 --- /dev/null +++ b/app-modules/routing/src/Filament/Resources/EvRoutes/Pages/EditEvRoute.php @@ -0,0 +1,36 @@ +data['is_active'] ?? false) + && ! EvRouteResource::hasCompletePricingData($this->data['pricing'] ?? []) + ) { + Notification::make() + ->warning() + ->title('Route cannot be activated yet') + ->body('Set a price above 0 for every non-blocked vehicle option first, then activate the route.') + ->send(); + + $this->halt(); + } + } +} diff --git a/app-modules/routing/src/Filament/Resources/EvRoutes/Pages/ListEvRoutes.php b/app-modules/routing/src/Filament/Resources/EvRoutes/Pages/ListEvRoutes.php new file mode 100644 index 0000000..26c7933 --- /dev/null +++ b/app-modules/routing/src/Filament/Resources/EvRoutes/Pages/ListEvRoutes.php @@ -0,0 +1,19 @@ +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') + ->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.'), + ]) + ->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(), + ]); + } +} diff --git a/app-modules/routing/src/Filament/Resources/EvRoutes/Tables/EvRoutesTable.php b/app-modules/routing/src/Filament/Resources/EvRoutes/Tables/EvRoutesTable.php new file mode 100644 index 0000000..70bdcdb --- /dev/null +++ b/app-modules/routing/src/Filament/Resources/EvRoutes/Tables/EvRoutesTable.php @@ -0,0 +1,65 @@ +modifyQueryUsing(fn ($query) => $query->with('pricing')) + ->columns([ + TextColumn::make('company.name') + ->label('EV Company') + ->searchable() + ->sortable(), + TextColumn::make('fromDestination.name') + ->label('From') + ->searchable() + ->sortable(), + TextColumn::make('toDestination.name') + ->label('To') + ->searchable() + ->sortable(), + TextColumn::make('pricing') + ->label('Pricing') + ->state(fn (EvRoute $record) => $record->pricing + ->sortBy(fn (RoutePricing $pricing) => $pricing->vehicle_option->value) + ->map(fn (RoutePricing $pricing) => str($pricing->vehicle_option->value)->headline() + .': ' + .($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') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + TernaryFilter::make('is_active'), + TernaryFilter::make('is_round_trip'), + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app-modules/routing/src/Filament/Widgets/.gitkeep b/app-modules/routing/src/Filament/Widgets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app-modules/routing/src/Http/Controllers/EvRouteController.php b/app-modules/routing/src/Http/Controllers/EvRouteController.php new file mode 100644 index 0000000..83324c8 --- /dev/null +++ b/app-modules/routing/src/Http/Controllers/EvRouteController.php @@ -0,0 +1,84 @@ + + */ + private const EAGER_LOADS = ['company', 'fromDestination', 'toDestination', 'timeSlots', 'pricing']; + + private const CACHE_TAG = 'routes'; + + private const CACHE_TTL_MINUTES = 5; + + public function index(Request $request): AnonymousResourceCollection + { + $filters = $request->only(['company', 'from', 'to', 'date']); + + $routes = Cache::tags(self::CACHE_TAG)->remember( + 'routes:index:'.md5(json_encode($filters)), + 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'))) + // `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) + ->get(), + ); + + return EvRouteResource::collection($routes); + } + + public function show(EvRoute $route): EvRouteResource + { + abort_unless($route->is_active, 404); + + $route = Cache::tags(self::CACHE_TAG)->remember( + "routes:show:{$route->id}", + now()->addMinutes(self::CACHE_TTL_MINUTES), + fn () => $route->load(self::EAGER_LOADS), + ); + + return new EvRouteResource($route); + } + + public function pricing(EvRoute $route): AnonymousResourceCollection + { + abort_unless($route->is_active, 404); + + $pricing = Cache::tags(self::CACHE_TAG)->remember( + "routes:pricing:{$route->id}", + now()->addMinutes(self::CACHE_TTL_MINUTES), + fn () => $route->pricing()->get(), + ); + + return RoutePricingResource::collection($pricing); + } + + public function timeSlots(EvRoute $route): AnonymousResourceCollection + { + abort_unless($route->is_active, 404); + + $timeSlots = Cache::tags(self::CACHE_TAG)->remember( + "routes:time-slots:{$route->id}", + now()->addMinutes(self::CACHE_TTL_MINUTES), + fn () => $route->timeSlots()->get(), + ); + + return RouteTimeSlotResource::collection($timeSlots); + } +} diff --git a/app-modules/routing/src/Http/Resources/EvRouteResource.php b/app-modules/routing/src/Http/Resources/EvRouteResource.php new file mode 100644 index 0000000..6b8d7db --- /dev/null +++ b/app-modules/routing/src/Http/Resources/EvRouteResource.php @@ -0,0 +1,30 @@ + + */ + public function toArray(Request $request): array + { + 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')), + 'to_destination' => new DestinationResource($this->whenLoaded('toDestination')), + 'time_slots' => RouteTimeSlotResource::collection($this->whenLoaded('timeSlots')), + 'pricing' => RoutePricingResource::collection($this->whenLoaded('pricing')), + ]; + } +} diff --git a/app-modules/routing/src/Http/Resources/RoutePricingResource.php b/app-modules/routing/src/Http/Resources/RoutePricingResource.php new file mode 100644 index 0000000..515d30a --- /dev/null +++ b/app-modules/routing/src/Http/Resources/RoutePricingResource.php @@ -0,0 +1,23 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'vehicle_option' => $this->vehicle_option->value, + 'price' => (string) $this->price, + 'is_blocked' => $this->is_blocked, + ]; + } +} diff --git a/app-modules/routing/src/Http/Resources/RouteTimeSlotResource.php b/app-modules/routing/src/Http/Resources/RouteTimeSlotResource.php new file mode 100644 index 0000000..5cde8b5 --- /dev/null +++ b/app-modules/routing/src/Http/Resources/RouteTimeSlotResource.php @@ -0,0 +1,24 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'label' => $this->label, + 'time' => $this->time?->format('H:i'), + 'is_active' => $this->whenPivotLoaded('ev_route_time_slots', fn () => (bool) $this->pivot->is_active), + ]; + } +} diff --git a/app-modules/routing/src/Models/EvRoute.php b/app-modules/routing/src/Models/EvRoute.php new file mode 100644 index 0000000..c3ca942 --- /dev/null +++ b/app-modules/routing/src/Models/EvRoute.php @@ -0,0 +1,78 @@ + */ + use HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'ev_company_id', + 'from_destination_id', + 'to_destination_id', + 'is_round_trip', + 'is_active', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'is_round_trip' => 'boolean', + 'is_active' => 'boolean', + ]; + } + + protected static function booted(): void + { + static::saving(function (self $route): void { + if ($route->from_destination_id === $route->to_destination_id) { + throw new InvalidArgumentException("A route's from and to destinations must be different."); + } + }); + } + + public function company(): BelongsTo + { + return $this->belongsTo(EvCompany::class, 'ev_company_id'); + } + + public function fromDestination(): BelongsTo + { + return $this->belongsTo(Destination::class, 'from_destination_id'); + } + + public function toDestination(): BelongsTo + { + return $this->belongsTo(Destination::class, 'to_destination_id'); + } + + public function timeSlots(): BelongsToMany + { + return $this->belongsToMany(DepartureTimeSlot::class, 'ev_route_time_slots') + ->withPivot('is_active') + ->withTimestamps(); + } + + public function pricing(): HasMany + { + return $this->hasMany(RoutePricing::class, 'ev_route_id'); + } +} diff --git a/app-modules/routing/src/Models/RoutePricing.php b/app-modules/routing/src/Models/RoutePricing.php new file mode 100644 index 0000000..f2b3a94 --- /dev/null +++ b/app-modules/routing/src/Models/RoutePricing.php @@ -0,0 +1,44 @@ + */ + use HasFactory; + + protected $table = 'route_pricing'; + + /** + * @var list + */ + protected $fillable = [ + 'ev_route_id', + 'vehicle_option', + 'price', + 'is_blocked', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'vehicle_option' => VehicleOption::class, + 'price' => 'decimal:2', + 'is_blocked' => 'boolean', + ]; + } + + public function route(): BelongsTo + { + return $this->belongsTo(EvRoute::class, 'ev_route_id'); + } +} diff --git a/app-modules/routing/src/Observers/EvRouteObserver.php b/app-modules/routing/src/Observers/EvRouteObserver.php new file mode 100644 index 0000000..e46a60d --- /dev/null +++ b/app-modules/routing/src/Observers/EvRouteObserver.php @@ -0,0 +1,19 @@ +flush(); + } + + public function deleted(EvRoute $route): void + { + Cache::tags('routes')->flush(); + } +} diff --git a/app-modules/routing/src/Observers/RoutePricingObserver.php b/app-modules/routing/src/Observers/RoutePricingObserver.php new file mode 100644 index 0000000..9fd0edd --- /dev/null +++ b/app-modules/routing/src/Observers/RoutePricingObserver.php @@ -0,0 +1,19 @@ +flush(); + } + + public function deleted(RoutePricing $pricing): void + { + Cache::tags('routes')->flush(); + } +} diff --git a/app-modules/routing/src/Providers/RoutingServiceProvider.php b/app-modules/routing/src/Providers/RoutingServiceProvider.php index 5ce781b..632558a 100644 --- a/app-modules/routing/src/Providers/RoutingServiceProvider.php +++ b/app-modules/routing/src/Providers/RoutingServiceProvider.php @@ -4,6 +4,10 @@ namespace Modules\Routing\Providers; use Illuminate\Contracts\Auth\Access\Gate; use Illuminate\Support\ServiceProvider; +use Modules\Routing\Models\EvRoute; +use Modules\Routing\Models\RoutePricing; +use Modules\Routing\Observers\EvRouteObserver; +use Modules\Routing\Observers\RoutePricingObserver; use Modules\Routing\Policies\RoutePolicy; class RoutingServiceProvider extends ServiceProvider @@ -12,6 +16,9 @@ class RoutingServiceProvider extends ServiceProvider public function boot(Gate $gate): void { - $gate->policy('Modules\Routing\Models\EvRoute', RoutePolicy::class); + $gate->policy(EvRoute::class, RoutePolicy::class); + + EvRoute::observe(EvRouteObserver::class); + RoutePricing::observe(RoutePricingObserver::class); } } diff --git a/app-modules/routing/src/RoutingPlugin.php b/app-modules/routing/src/RoutingPlugin.php new file mode 100644 index 0000000..a2ff48e --- /dev/null +++ b/app-modules/routing/src/RoutingPlugin.php @@ -0,0 +1,38 @@ +discoverResources( + in: __DIR__.'/Filament/Resources', + for: 'Modules\Routing\Filament\Resources', + ) + ->discoverPages( + in: __DIR__.'/Filament/Pages', + for: 'Modules\Routing\Filament\Pages', + ) + ->discoverWidgets( + in: __DIR__.'/Filament/Widgets', + for: 'Modules\Routing\Filament\Widgets', + ); + } + + public function boot(Panel $panel): void {} + + public static function make(): static + { + return app(static::class); + } +} diff --git a/app-modules/routing/src/Services/PricingService.php b/app-modules/routing/src/Services/PricingService.php new file mode 100644 index 0000000..126d8d8 --- /dev/null +++ b/app-modules/routing/src/Services/PricingService.php @@ -0,0 +1,28 @@ +pricing() + ->where('vehicle_option', $vehicleOption) + ->first(); + + if (! $pricing) { + throw RoutePricingNotFoundException::forRouteAndOption($route, $vehicleOption); + } + + return new PriceQuoteData( + evRouteId: $route->id, + vehicleOption: $vehicleOption, + price: (string) $pricing->price, + ); + } +} diff --git a/app-modules/routing/tests/Feature/EvRouteResourceTest.php b/app-modules/routing/tests/Feature/EvRouteResourceTest.php new file mode 100644 index 0000000..2cd8c66 --- /dev/null +++ b/app-modules/routing/tests/Feature/EvRouteResourceTest.php @@ -0,0 +1,234 @@ +admin = User::factory()->create()->givePermissionTo('manage_routes'); + $this->actingAs($this->admin); +}); + +function pricingPayload(array $overrides = []): array +{ + return collect(VehicleOption::cases()) + ->map(fn (VehicleOption $option) => array_merge([ + 'vehicle_option' => $option->value, + 'price' => 0, + 'is_blocked' => false, + ], $overrides[$option->value] ?? [])) + ->values() + ->all(); +} + +test('can list ev routes', function () { + $routes = EvRoute::factory()->count(3)->create(); + + Livewire::test(ListEvRoutes::class) + ->assertOk() + ->assertCanSeeTableRecords($routes); +}); + +test('list shows each vehicle option price stacked, and blocked options instead of a price', function () { + $route = EvRoute::factory()->create(); + + RoutePricing::factory()->create(['ev_route_id' => $route->id, 'vehicle_option' => VehicleOption::FrontSeat, 'price' => 12000]); + RoutePricing::factory()->create(['ev_route_id' => $route->id, 'vehicle_option' => VehicleOption::BackSeat, 'price' => 9000]); + RoutePricing::factory()->create(['ev_route_id' => $route->id, 'vehicle_option' => VehicleOption::WholeVehicle, 'is_blocked' => true]); + + Livewire::test(ListEvRoutes::class) + ->assertOk() + ->assertTableColumnStateSet('pricing', ['Back Seat: 9,000', 'Front Seat: 12,000', 'Whole Vehicle: Blocked'], $route); +}); + +test('creating a route also creates all three vehicle option pricing rows, defaulting to 0', function () { + $company = EvCompany::factory()->create(); + $from = Destination::factory()->create(); + $to = Destination::factory()->create(); + + Livewire::test(CreateEvRoute::class) + ->fillForm([ + 'ev_company_id' => $company->id, + 'from_destination_id' => $from->id, + 'to_destination_id' => $to->id, + 'is_round_trip' => false, + 'is_active' => false, + 'pricing' => pricingPayload(), + ]) + ->call('create') + ->assertNotified() + ->assertRedirect(); + + $route = EvRoute::where('ev_company_id', $company->id)->firstOrFail(); + + expect($route->pricing)->toHaveCount(3); + + foreach (VehicleOption::cases() as $option) { + assertDatabaseHas(RoutePricing::class, [ + 'ev_route_id' => $route->id, + 'vehicle_option' => $option->value, + 'price' => '0.00', + 'is_blocked' => false, + ]); + } +}); + +test('can price all three vehicle options directly on the create form', function () { + $company = EvCompany::factory()->create(); + $from = Destination::factory()->create(); + $to = Destination::factory()->create(); + + Livewire::test(CreateEvRoute::class) + ->fillForm([ + 'ev_company_id' => $company->id, + 'from_destination_id' => $from->id, + 'to_destination_id' => $to->id, + 'is_active' => true, + 'pricing' => pricingPayload([ + 'front_seat' => ['price' => 12000], + 'back_seat' => ['price' => 9000], + 'whole_vehicle' => ['price' => 30000], + ]), + ]) + ->call('create') + ->assertNotified() + ->assertRedirect(); + + $route = EvRoute::where('ev_company_id', $company->id)->firstOrFail(); + + expect($route->is_active)->toBeTrue(); + + assertDatabaseHas(RoutePricing::class, ['ev_route_id' => $route->id, 'vehicle_option' => 'front_seat', 'price' => 12000]); + assertDatabaseHas(RoutePricing::class, ['ev_route_id' => $route->id, 'vehicle_option' => 'back_seat', 'price' => 9000]); + assertDatabaseHas(RoutePricing::class, ['ev_route_id' => $route->id, 'vehicle_option' => 'whole_vehicle', 'price' => 30000]); +}); + +test('cannot create a route with the same from and to destination', function () { + $company = EvCompany::factory()->create(); + $destination = Destination::factory()->create(); + + Livewire::test(CreateEvRoute::class) + ->fillForm([ + 'ev_company_id' => $company->id, + 'from_destination_id' => $destination->id, + 'to_destination_id' => $destination->id, + 'pricing' => pricingPayload(), + ]) + ->call('create') + ->assertHasFormErrors(['to_destination_id' => 'different']) + ->assertNotNotified() + ->assertNoRedirect(); + + $this->assertDatabaseCount(EvRoute::class, 0); +}); + +test('cannot create a route as active while any non-blocked vehicle option is priced at 0', function () { + $company = EvCompany::factory()->create(); + $from = Destination::factory()->create(); + $to = Destination::factory()->create(); + + Livewire::test(CreateEvRoute::class) + ->fillForm([ + 'ev_company_id' => $company->id, + 'from_destination_id' => $from->id, + 'to_destination_id' => $to->id, + 'is_active' => true, + 'pricing' => pricingPayload([ + 'front_seat' => ['price' => 12000], + // back_seat and whole_vehicle left at 0. + ]), + ]) + ->call('create') + ->assertNotified(); + + $this->assertDatabaseCount(EvRoute::class, 0); +}); + +test('a blocked vehicle option does not need a price to create an active route', function () { + $company = EvCompany::factory()->create(); + $from = Destination::factory()->create(); + $to = Destination::factory()->create(); + + Livewire::test(CreateEvRoute::class) + ->fillForm([ + 'ev_company_id' => $company->id, + 'from_destination_id' => $from->id, + 'to_destination_id' => $to->id, + 'is_active' => true, + 'pricing' => pricingPayload([ + 'front_seat' => ['price' => 12000], + 'back_seat' => ['price' => 9000], + 'whole_vehicle' => ['is_blocked' => true], + ]), + ]) + ->call('create') + ->assertNotified() + ->assertRedirect(); + + $route = EvRoute::where('ev_company_id', $company->id)->firstOrFail(); + + expect($route->is_active)->toBeTrue(); +}); + +test('can update pricing and toggle blocked from the edit page', function () { + $route = EvRoute::factory()->create(); + + foreach (VehicleOption::cases() as $option) { + RoutePricing::factory()->create([ + 'ev_route_id' => $route->id, + 'vehicle_option' => $option, + 'price' => 5000, + ]); + } + + Livewire::test(EditEvRoute::class, ['record' => $route->getRouteKey()]) + ->assertOk() + ->fillForm([ + 'pricing' => pricingPayload([ + 'front_seat' => ['price' => 15000], + 'back_seat' => ['price' => 8000], + 'whole_vehicle' => ['is_blocked' => true], + ]), + ]) + ->call('save') + ->assertNotified(); + + assertDatabaseHas(RoutePricing::class, ['ev_route_id' => $route->id, 'vehicle_option' => 'front_seat', 'price' => 15000]); + assertDatabaseHas(RoutePricing::class, ['ev_route_id' => $route->id, 'vehicle_option' => 'back_seat', 'price' => 8000]); + assertDatabaseHas(RoutePricing::class, ['ev_route_id' => $route->id, 'vehicle_option' => 'whole_vehicle', 'is_blocked' => true]); + + expect($route->pricing()->count())->toBe(3); +}); + +test('cannot activate a route from the edit page while any non-blocked vehicle option is priced at 0', function () { + $route = EvRoute::factory()->create(['is_active' => false]); + foreach (VehicleOption::cases() as $option) { + RoutePricing::factory()->create(['ev_route_id' => $route->id, 'vehicle_option' => $option]); + } + + Livewire::test(EditEvRoute::class, ['record' => $route->getRouteKey()]) + ->assertOk() + ->fillForm([ + 'is_active' => true, + 'pricing' => pricingPayload([ + 'front_seat' => ['price' => 12000], + // back_seat and whole_vehicle left at 0. + ]), + ]) + ->call('save') + ->assertNotified(); + + expect($route->fresh()->is_active)->toBeFalse(); +}); diff --git a/app-modules/routing/tests/Feature/EvRouteTest.php b/app-modules/routing/tests/Feature/EvRouteTest.php new file mode 100644 index 0000000..4cfdab2 --- /dev/null +++ b/app-modules/routing/tests/Feature/EvRouteTest.php @@ -0,0 +1,79 @@ +create(); + $from = Destination::factory()->create(); + $to = Destination::factory()->create(); + + $route = EvRoute::factory()->create([ + 'ev_company_id' => $company->id, + 'from_destination_id' => $from->id, + 'to_destination_id' => $to->id, + ]); + + expect($route->company)->toBeInstanceOf(EvCompany::class) + ->and($route->company->is($company))->toBeTrue() + ->and($route->fromDestination)->toBeInstanceOf(Destination::class) + ->and($route->fromDestination->is($from))->toBeTrue() + ->and($route->toDestination)->toBeInstanceOf(Destination::class) + ->and($route->toDestination->is($to))->toBeTrue(); +}); + +test('is_round_trip and is_active cast 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(); +}); + +test('a route can be attached to time slots via the pivot, carrying its own is_active flag', function () { + $route = EvRoute::factory()->create(); + $morningSlot = DepartureTimeSlot::factory()->create(); + $eveningSlot = DepartureTimeSlot::factory()->create(); + + $route->timeSlots()->attach([ + $morningSlot->id => ['is_active' => true], + $eveningSlot->id => ['is_active' => false], + ]); + + $route->refresh(); + + expect($route->timeSlots)->toHaveCount(2); + + $attachedMorning = $route->timeSlots->firstWhere('id', $morningSlot->id); + $attachedEvening = $route->timeSlots->firstWhere('id', $eveningSlot->id); + + expect($attachedMorning->pivot->is_active)->toBeTrue() + ->and($attachedEvening->pivot->is_active)->toBeFalse(); + + expect($morningSlot->routes)->toHaveCount(1) + ->and($morningSlot->routes->first()->is($route))->toBeTrue(); +}); + +test('a route cannot have the same from and to destination', function () { + $destination = Destination::factory()->create(); + + expect(fn () => EvRoute::factory()->create([ + 'from_destination_id' => $destination->id, + 'to_destination_id' => $destination->id, + ]))->toThrow(InvalidArgumentException::class); +}); + +test('a route cannot be attached to the same time slot twice', function () { + $route = EvRoute::factory()->create(); + $slot = DepartureTimeSlot::factory()->create(); + + $route->timeSlots()->attach($slot->id, ['is_active' => true]); + + expect(fn () => $route->timeSlots()->attach($slot->id, ['is_active' => true])) + ->toThrow(QueryException::class); +}); diff --git a/app-modules/routing/tests/Feature/RoutePricingTest.php b/app-modules/routing/tests/Feature/RoutePricingTest.php new file mode 100644 index 0000000..fcf0ed7 --- /dev/null +++ b/app-modules/routing/tests/Feature/RoutePricingTest.php @@ -0,0 +1,58 @@ +create(); + + $pricing = RoutePricing::factory()->create([ + 'ev_route_id' => $route->id, + 'vehicle_option' => VehicleOption::FrontSeat, + 'price' => 12000, + 'is_blocked' => true, + ]); + + expect($pricing->route)->toBeInstanceOf(EvRoute::class) + ->and($pricing->route->is($route))->toBeTrue() + ->and($pricing->vehicle_option)->toBe(VehicleOption::FrontSeat) + ->and($pricing->price)->toEqual('12000.00') + ->and($pricing->is_blocked)->toBeTrue(); + + expect($route->pricing()->first()->is($pricing))->toBeTrue(); +}); + +test('a route can only have one price per vehicle option', function () { + $route = EvRoute::factory()->create(); + + RoutePricing::factory()->create([ + 'ev_route_id' => $route->id, + 'vehicle_option' => VehicleOption::WholeVehicle, + ]); + + expect(fn () => RoutePricing::factory()->create([ + 'ev_route_id' => $route->id, + 'vehicle_option' => VehicleOption::WholeVehicle, + ]))->toThrow(QueryException::class); +}); + +test('a route can have all three vehicle options priced', function () { + $route = EvRoute::factory()->create(); + + foreach (VehicleOption::cases() as $option) { + RoutePricing::factory()->create([ + 'ev_route_id' => $route->id, + 'vehicle_option' => $option, + ]); + } + + expect($route->pricing()->count())->toBe(3); +}); + +test('pricing defaults to unblocked', function () { + $pricing = RoutePricing::factory()->create(); + + expect($pricing->is_blocked)->toBeFalse(); +}); diff --git a/app-modules/routing/tests/Feature/RoutesCachingTest.php b/app-modules/routing/tests/Feature/RoutesCachingTest.php new file mode 100644 index 0000000..f24a06d --- /dev/null +++ b/app-modules/routing/tests/Feature/RoutesCachingTest.php @@ -0,0 +1,82 @@ +token = User::factory()->create()->createToken('test-token')->plainTextToken; +}); + +test('the pricing endpoint serves a cached response until invalidated', function () { + $route = EvRoute::factory()->create(['is_active' => true]); + $pricing = RoutePricing::factory()->create([ + 'ev_route_id' => $route->id, + 'vehicle_option' => VehicleOption::FrontSeat, + 'price' => 10000, + ]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->getJson("/api/v1/routes/{$route->id}/pricing") + ->assertJsonFragment(['price' => '10000.00']); + + // Bypass Eloquent (no 'saved' event) so a stale cached response proves caching is active. + RoutePricing::query()->where('id', $pricing->id)->update(['price' => 99999]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->getJson("/api/v1/routes/{$route->id}/pricing") + ->assertJsonFragment(['price' => '10000.00']); + + // A genuine Eloquent save fires the observer and flushes the 'routes' tag. + $pricing->refresh()->save(); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->getJson("/api/v1/routes/{$route->id}/pricing") + ->assertJsonFragment(['price' => '99999.00']); +}); + +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'); + + // 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'); + + $route->refresh()->save(); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->getJson('/api/v1/routes') + ->assertJsonCount(0, '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'); + + $route->delete(); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->getJson('/api/v1/routes') + ->assertJsonCount(0, '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]); + + expect(Cache::get('unrelated-key'))->toBe('still here'); +}); diff --git a/app-modules/routing/tests/Feature/RoutesReadApiTest.php b/app-modules/routing/tests/Feature/RoutesReadApiTest.php new file mode 100644 index 0000000..609f3ea --- /dev/null +++ b/app-modules/routing/tests/Feature/RoutesReadApiTest.php @@ -0,0 +1,145 @@ +token = User::factory()->create()->createToken('test-token')->plainTextToken; +}); + +test('lists active routes with nested company, destinations, time slots and pricing', function () { + $route = EvRoute::factory()->create(['is_active' => true]); + EvRoute::factory()->create(['is_active' => false]); + + $slot = DepartureTimeSlot::factory()->create(); + $route->timeSlots()->attach($slot->id, ['is_active' => true]); + + RoutePricing::factory()->create([ + 'ev_route_id' => $route->id, + 'vehicle_option' => VehicleOption::FrontSeat, + 'price' => 12000, + ]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->getJson('/api/v1/routes') + ->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'); +}); + +test('filters 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, + 'is_active' => true, + ]); + + EvRoute::factory()->create([ + 'ev_company_id' => $companyB->id, + 'from_destination_id' => $yangon->id, + 'to_destination_id' => $mandalay->id, + 'is_active' => true, + ]); + + EvRoute::factory()->create([ + 'ev_company_id' => $companyA->id, + 'from_destination_id' => $yangon->id, + 'to_destination_id' => $bagan->id, + 'is_active' => true, + ]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->getJson('/api/v1/routes?'.http_build_query([ + 'company' => $companyA->id, + 'from' => $yangon->id, + 'to' => $mandalay->id, + ])) + ->assertSuccessful() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $matching->id); +}); + +test('shows a single active route', function () { + $route = EvRoute::factory()->create(['is_active' => true]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->getJson("/api/v1/routes/{$route->id}") + ->assertSuccessful() + ->assertJsonPath('data.id', $route->id); +}); + +test('an inactive route is not found via show, pricing, or time-slots', function () { + $route = EvRoute::factory()->create(['is_active' => false]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->getJson("/api/v1/routes/{$route->id}") + ->assertNotFound(); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->getJson("/api/v1/routes/{$route->id}/pricing") + ->assertNotFound(); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->getJson("/api/v1/routes/{$route->id}/time-slots") + ->assertNotFound(); +}); + +test('lists a route\'s pricing including blocked options', function () { + $route = EvRoute::factory()->create(['is_active' => true]); + + RoutePricing::factory()->create(['ev_route_id' => $route->id, 'vehicle_option' => VehicleOption::FrontSeat, 'price' => 12000]); + RoutePricing::factory()->create(['ev_route_id' => $route->id, 'vehicle_option' => VehicleOption::WholeVehicle, 'is_blocked' => true]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->getJson("/api/v1/routes/{$route->id}/pricing") + ->assertSuccessful() + ->assertJsonCount(2, 'data') + ->assertJsonFragment(['vehicle_option' => 'front_seat', 'price' => '12000.00', 'is_blocked' => false]) + ->assertJsonFragment(['vehicle_option' => 'whole_vehicle', 'is_blocked' => true]); +}); + +test('lists a route\'s time slots with the pivot active flag', function () { + $route = EvRoute::factory()->create(['is_active' => true]); + $active = DepartureTimeSlot::factory()->create(); + $inactive = DepartureTimeSlot::factory()->create(); + + $route->timeSlots()->attach([ + $active->id => ['is_active' => true], + $inactive->id => ['is_active' => false], + ]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->getJson("/api/v1/routes/{$route->id}/time-slots") + ->assertSuccessful() + ->assertJsonCount(2, 'data') + ->assertJsonFragment(['id' => $active->id, 'is_active' => true]) + ->assertJsonFragment(['id' => $inactive->id, 'is_active' => false]); +}); + +test('routes endpoints reject unauthenticated requests', function () { + $route = EvRoute::factory()->create(['is_active' => true]); + + $this->getJson('/api/v1/routes')->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(); +}); diff --git a/app-modules/routing/tests/Unit/PricingServiceTest.php b/app-modules/routing/tests/Unit/PricingServiceTest.php new file mode 100644 index 0000000..1ccba97 --- /dev/null +++ b/app-modules/routing/tests/Unit/PricingServiceTest.php @@ -0,0 +1,51 @@ +create(); + + RoutePricing::factory()->create([ + 'ev_route_id' => $route->id, + 'vehicle_option' => VehicleOption::FrontSeat, + 'price' => 12000, + ]); + RoutePricing::factory()->create([ + 'ev_route_id' => $route->id, + 'vehicle_option' => VehicleOption::BackSeat, + 'price' => 9000, + ]); + RoutePricing::factory()->create([ + 'ev_route_id' => $route->id, + 'vehicle_option' => VehicleOption::WholeVehicle, + 'price' => 30000, + ]); + + $service = new PricingService; + + $frontSeatQuote = $service->quote($route, VehicleOption::FrontSeat); + $backSeatQuote = $service->quote($route, VehicleOption::BackSeat); + $wholeVehicleQuote = $service->quote($route, VehicleOption::WholeVehicle); + + expect($frontSeatQuote)->toBeInstanceOf(PriceQuoteData::class) + ->and($frontSeatQuote->evRouteId)->toBe($route->id) + ->and($frontSeatQuote->vehicleOption)->toBe(VehicleOption::FrontSeat) + ->and($frontSeatQuote->price)->toEqual('12000.00'); + + expect($backSeatQuote->price)->toEqual('9000.00'); + expect($wholeVehicleQuote->price)->toEqual('30000.00'); +}); + +test('quote throws when the route has no pricing for the requested vehicle option', function () { + $route = EvRoute::factory()->create(); + + $service = new PricingService; + + expect(fn () => $service->quote($route, VehicleOption::WholeVehicle)) + ->toThrow(RoutePricingNotFoundException::class); +}); diff --git a/app-modules/shared/src/Enums/VehicleOption.php b/app-modules/shared/src/Enums/VehicleOption.php new file mode 100644 index 0000000..e752d70 --- /dev/null +++ b/app-modules/shared/src/Enums/VehicleOption.php @@ -0,0 +1,10 @@ +plugins([ CatalogPlugin::make(), + RoutingPlugin::make(), ]) ->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources') ->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages') diff --git a/tests/Pest.php b/tests/Pest.php index 9a2c021..38db60b 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -20,7 +20,7 @@ pest()->extend(TestCase::class) pest()->extend(TestCase::class) ->use(RefreshDatabase::class) - ->in('../app-modules/*/tests/Feature'); + ->in('../app-modules/*/tests/Feature', '../app-modules/*/tests/Unit'); /* |--------------------------------------------------------------------------