routes + route_stops tables. Admin CRUD with stops submitted as an ordered array (drag-reorder is client-side; sequence is array-index). Cloning produces an inactive copy with -COPY-XXXX suffix. RouteCalculator recomputes total_distance_km via ST_Distance_Sphere across stop coordinates + dumpsite, and estimated_duration_minutes from dwell time + travel time at config-driven avg speed (default 25 km/h). default_team_id stays nullable bigint until Module 9 adds the FK. 139 feature tests passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
217 lines
7.3 KiB
PHP
217 lines
7.3 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Api\V1\Route;
|
|
|
|
use App\Models\DropOffPoint;
|
|
use App\Models\Dumpsite;
|
|
use App\Models\Route;
|
|
use App\Models\User;
|
|
use Database\Seeders\RoleSeeder;
|
|
use Database\Seeders\SampleDropOffPointsSeeder;
|
|
use Database\Seeders\SampleDumpsitesSeeder;
|
|
use Database\Seeders\SamplePsgcSeeder;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Laravel\Sanctum\Sanctum;
|
|
use Tests\TestCase;
|
|
|
|
class RouteCrudTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private User $admin;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$this->seed([
|
|
RoleSeeder::class,
|
|
SamplePsgcSeeder::class,
|
|
SampleDropOffPointsSeeder::class,
|
|
SampleDumpsitesSeeder::class,
|
|
]);
|
|
$this->admin = User::factory()->create([
|
|
'role' => User::ROLE_ADMIN,
|
|
'status' => User::STATUS_ACTIVE,
|
|
]);
|
|
}
|
|
|
|
public function test_admin_can_create_route_with_stops(): void
|
|
{
|
|
Sanctum::actingAs($this->admin);
|
|
$dops = DropOffPoint::limit(3)->pluck('id')->all();
|
|
$dumpsite = Dumpsite::first();
|
|
|
|
$response = $this->postJson('/api/v1/admin/routes', [
|
|
'name' => 'QC North Loop',
|
|
'code' => 'RT-QC-N1',
|
|
'default_dumpsite_id' => $dumpsite->id,
|
|
'stops' => [
|
|
['drop_off_point_id' => $dops[0], 'estimated_duration_minutes' => 12],
|
|
['drop_off_point_id' => $dops[1], 'estimated_duration_minutes' => 8],
|
|
['drop_off_point_id' => $dops[2], 'estimated_duration_minutes' => 15],
|
|
],
|
|
]);
|
|
|
|
$response->assertCreated()
|
|
->assertJsonPath('data.code', 'RT-QC-N1')
|
|
->assertJsonPath('data.stop_count', 3);
|
|
$this->assertGreaterThan(0, $response->json('data.total_distance_km'));
|
|
$this->assertGreaterThan(0, $response->json('data.estimated_duration_minutes'));
|
|
|
|
$stops = $response->json('data.stops');
|
|
$this->assertSame([1, 2, 3], array_column($stops, 'sequence'));
|
|
}
|
|
|
|
public function test_create_requires_at_least_one_stop(): void
|
|
{
|
|
Sanctum::actingAs($this->admin);
|
|
|
|
$response = $this->postJson('/api/v1/admin/routes', [
|
|
'name' => 'Empty', 'code' => 'RT-EMPTY',
|
|
'stops' => [],
|
|
]);
|
|
|
|
$response->assertStatus(422)
|
|
->assertJsonValidationErrors(['stops']);
|
|
}
|
|
|
|
public function test_admin_can_reorder_stops(): void
|
|
{
|
|
Sanctum::actingAs($this->admin);
|
|
$dops = DropOffPoint::limit(3)->pluck('id')->values();
|
|
$dumpsite = Dumpsite::first();
|
|
|
|
$created = $this->postJson('/api/v1/admin/routes', [
|
|
'name' => 'X', 'code' => 'RT-X',
|
|
'default_dumpsite_id' => $dumpsite->id,
|
|
'stops' => [
|
|
['drop_off_point_id' => $dops[0]],
|
|
['drop_off_point_id' => $dops[1]],
|
|
['drop_off_point_id' => $dops[2]],
|
|
],
|
|
]);
|
|
$uuid = $created->json('data.id');
|
|
|
|
// Reverse the order.
|
|
$response = $this->patchJson("/api/v1/admin/routes/{$uuid}", [
|
|
'stops' => [
|
|
['drop_off_point_id' => $dops[2]],
|
|
['drop_off_point_id' => $dops[1]],
|
|
['drop_off_point_id' => $dops[0]],
|
|
],
|
|
]);
|
|
|
|
$response->assertOk();
|
|
$route = Route::where('uuid', $uuid)->firstOrFail();
|
|
$reordered = $route->stops()->orderBy('sequence')->pluck('drop_off_point_id')->all();
|
|
$this->assertSame([$dops[2], $dops[1], $dops[0]], $reordered);
|
|
}
|
|
|
|
public function test_admin_can_clone_route(): void
|
|
{
|
|
Sanctum::actingAs($this->admin);
|
|
$dops = DropOffPoint::limit(2)->pluck('id')->all();
|
|
|
|
$created = $this->postJson('/api/v1/admin/routes', [
|
|
'name' => 'Original', 'code' => 'RT-ORIG',
|
|
'stops' => [
|
|
['drop_off_point_id' => $dops[0]],
|
|
['drop_off_point_id' => $dops[1]],
|
|
],
|
|
]);
|
|
$uuid = $created->json('data.id');
|
|
|
|
$response = $this->postJson("/api/v1/admin/routes/{$uuid}/clone");
|
|
|
|
$response->assertCreated()
|
|
->assertJsonPath('data.status', 'inactive')
|
|
->assertJsonPath('data.stop_count', 2);
|
|
$this->assertStringStartsWith('RT-ORIG-COPY-', $response->json('data.code'));
|
|
}
|
|
|
|
public function test_admin_can_delete_route(): void
|
|
{
|
|
Sanctum::actingAs($this->admin);
|
|
$dop = DropOffPoint::first();
|
|
$created = $this->postJson('/api/v1/admin/routes', [
|
|
'name' => 'Del', 'code' => 'RT-DEL',
|
|
'stops' => [['drop_off_point_id' => $dop->id]],
|
|
]);
|
|
$uuid = $created->json('data.id');
|
|
|
|
$this->deleteJson("/api/v1/admin/routes/{$uuid}")->assertOk();
|
|
$this->assertSoftDeleted('routes', ['uuid' => $uuid]);
|
|
}
|
|
|
|
public function test_admin_can_list_with_filter(): void
|
|
{
|
|
Sanctum::actingAs($this->admin);
|
|
$dop = DropOffPoint::first();
|
|
$this->postJson('/api/v1/admin/routes', [
|
|
'name' => 'Active1', 'code' => 'RT-A1', 'status' => 'active',
|
|
'stops' => [['drop_off_point_id' => $dop->id]],
|
|
]);
|
|
$this->postJson('/api/v1/admin/routes', [
|
|
'name' => 'Inactive1', 'code' => 'RT-I1', 'status' => 'inactive',
|
|
'stops' => [['drop_off_point_id' => $dop->id]],
|
|
]);
|
|
|
|
$response = $this->getJson('/api/v1/admin/routes?status=active');
|
|
|
|
$response->assertOk();
|
|
$codes = collect($response->json('data'))->pluck('code')->all();
|
|
$this->assertContains('RT-A1', $codes);
|
|
$this->assertNotContains('RT-I1', $codes);
|
|
}
|
|
|
|
public function test_resident_blocked(): void
|
|
{
|
|
$resident = User::factory()->create(['role' => User::ROLE_RESIDENT, 'status' => 'active']);
|
|
Sanctum::actingAs($resident);
|
|
|
|
$this->getJson('/api/v1/admin/routes')->assertStatus(403);
|
|
}
|
|
|
|
public function test_distance_recomputes_when_stops_change(): void
|
|
{
|
|
Sanctum::actingAs($this->admin);
|
|
$dops = DropOffPoint::limit(2)->pluck('id')->all();
|
|
$dumpsite = Dumpsite::first();
|
|
|
|
$created = $this->postJson('/api/v1/admin/routes', [
|
|
'name' => 'X', 'code' => 'RT-D1',
|
|
'default_dumpsite_id' => $dumpsite->id,
|
|
'stops' => [['drop_off_point_id' => $dops[0]]],
|
|
]);
|
|
$oneStopDistance = $created->json('data.total_distance_km');
|
|
$uuid = $created->json('data.id');
|
|
|
|
$updated = $this->patchJson("/api/v1/admin/routes/{$uuid}", [
|
|
'stops' => [
|
|
['drop_off_point_id' => $dops[0]],
|
|
['drop_off_point_id' => $dops[1]],
|
|
],
|
|
]);
|
|
|
|
$this->assertNotSame($oneStopDistance, $updated->json('data.total_distance_km'));
|
|
}
|
|
|
|
public function test_duplicate_code_rejected(): void
|
|
{
|
|
Sanctum::actingAs($this->admin);
|
|
$dop = DropOffPoint::first();
|
|
$this->postJson('/api/v1/admin/routes', [
|
|
'name' => 'A', 'code' => 'RT-DUP',
|
|
'stops' => [['drop_off_point_id' => $dop->id]],
|
|
])->assertCreated();
|
|
|
|
$response = $this->postJson('/api/v1/admin/routes', [
|
|
'name' => 'B', 'code' => 'RT-DUP',
|
|
'stops' => [['drop_off_point_id' => $dop->id]],
|
|
]);
|
|
|
|
$response->assertStatus(422)
|
|
->assertJsonValidationErrors(['code']);
|
|
}
|
|
}
|