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.
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Modules\Catalog\Models\DepartureTimeSlot;
|
||||
use Modules\Catalog\Models\Destination;
|
||||
use Modules\Catalog\Models\EvCompany;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
use Modules\Routing\Models\RoutePricing;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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();
|
||||
});
|
||||
Reference in New Issue
Block a user