token = User::factory()->create()->createToken('test-token')->plainTextToken; }); 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]); $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}") ->postJson('/api/v1/routes/search') ->assertSuccessful() ->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 () { $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}") ->postJson('/api/v1/routes/search', [ 'company' => $companyA->id, 'from' => $yangon->id, 'to' => $mandalay->id, ]) ->assertSuccessful() ->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}") ->postJson('/api/v1/routes/search') ->assertSuccessful() ->assertJsonCount(15, 'routes.data') ->assertJsonPath('routes.meta.total', 20); }); 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->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(); });