diff --git a/app/Http/Controllers/Api/V1/Admin/AdminBarangayController.php b/app/Http/Controllers/Api/V1/Admin/AdminBarangayController.php index 3bf02b7..3645005 100644 --- a/app/Http/Controllers/Api/V1/Admin/AdminBarangayController.php +++ b/app/Http/Controllers/Api/V1/Admin/AdminBarangayController.php @@ -31,6 +31,7 @@ class AdminBarangayController extends ApiController $barangays = Barangay::query() ->with(['cityMunicipality.province']) + ->withSum('households', 'household_size') ->where('city_municipality_id', $cityId) ->when($request->filled('q'), function ($q) use ($request) { $term = '%'.$request->string('q').'%'; diff --git a/app/Http/Controllers/Api/V1/Admin/AdminDropOffPointController.php b/app/Http/Controllers/Api/V1/Admin/AdminDropOffPointController.php index 2adc4a3..b8fabc0 100644 --- a/app/Http/Controllers/Api/V1/Admin/AdminDropOffPointController.php +++ b/app/Http/Controllers/Api/V1/Admin/AdminDropOffPointController.php @@ -33,7 +33,7 @@ class AdminDropOffPointController extends ApiController $perPage = (int) $request->input('per_page', 25); $points = DropOffPoint::query() - ->with('barangay') + ->with(['barangay', 'tenant']) ->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status'))) ->when($request->filled('barangay_id'), fn ($q) => $q->where('barangay_id', $request->integer('barangay_id'))) ->when($request->filled('q'), function ($q) use ($request) { diff --git a/app/Http/Controllers/Api/V1/Admin/AdminDumpsiteController.php b/app/Http/Controllers/Api/V1/Admin/AdminDumpsiteController.php index aef4a80..2bd6e09 100644 --- a/app/Http/Controllers/Api/V1/Admin/AdminDumpsiteController.php +++ b/app/Http/Controllers/Api/V1/Admin/AdminDumpsiteController.php @@ -30,7 +30,7 @@ class AdminDumpsiteController extends ApiController $perPage = (int) $request->input('per_page', 25); $dumpsites = Dumpsite::query() - ->with('cityMunicipality') + ->with(['cityMunicipality', 'tenant']) ->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status'))) ->when( $request->filled('city_municipality_id'), diff --git a/app/Http/Controllers/Api/V1/Admin/AdminHouseholdController.php b/app/Http/Controllers/Api/V1/Admin/AdminHouseholdController.php index 14d0e1b..d7a836e 100644 --- a/app/Http/Controllers/Api/V1/Admin/AdminHouseholdController.php +++ b/app/Http/Controllers/Api/V1/Admin/AdminHouseholdController.php @@ -44,7 +44,7 @@ class AdminHouseholdController extends ApiController $perPage = (int) $request->input('per_page', 25); $households = Household::query() - ->with(['head', 'barangay.serviceAreas']) + ->with(['head', 'barangay.cityMunicipality', 'barangay.serviceAreas', 'assignedDropOffPoint', 'tenant']) ->withCount('members') ->when( $request->filled('verification_status'), diff --git a/app/Http/Controllers/Api/V1/Admin/AdminRouteController.php b/app/Http/Controllers/Api/V1/Admin/AdminRouteController.php index f5790f5..7d672df 100644 --- a/app/Http/Controllers/Api/V1/Admin/AdminRouteController.php +++ b/app/Http/Controllers/Api/V1/Admin/AdminRouteController.php @@ -30,7 +30,7 @@ class AdminRouteController extends ApiController $perPage = (int) $request->input('per_page', 25); $routes = Route::query() - ->with(['area', 'defaultDumpsite']) + ->with(['area', 'defaultDumpsite', 'tenant']) ->withCount('stops') ->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status'))) ->when($request->filled('area_id'), fn ($q) => $q->where('area_id', $request->integer('area_id'))) diff --git a/app/Http/Controllers/Api/V1/Admin/AdminTeamController.php b/app/Http/Controllers/Api/V1/Admin/AdminTeamController.php index 50cde36..9712784 100644 --- a/app/Http/Controllers/Api/V1/Admin/AdminTeamController.php +++ b/app/Http/Controllers/Api/V1/Admin/AdminTeamController.php @@ -28,7 +28,7 @@ class AdminTeamController extends ApiController $perPage = (int) $request->input('per_page', 25); $teams = CollectionTeam::query() - ->with(['area', 'driver', 'scanner', 'truck', 'helpers.user', 'currentTrip']) + ->with(['area', 'driver', 'scanner', 'truck', 'helpers.user', 'currentTrip', 'tenant']) ->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status'))) ->when($request->filled('area_id'), fn ($q) => $q->where('area_id', $request->integer('area_id'))) ->when($request->filled('q'), fn ($q) => $q->where('name', 'like', '%'.$request->string('q').'%')) diff --git a/app/Http/Controllers/Api/V1/Admin/AdminTripController.php b/app/Http/Controllers/Api/V1/Admin/AdminTripController.php index 2584c2e..e09c810 100644 --- a/app/Http/Controllers/Api/V1/Admin/AdminTripController.php +++ b/app/Http/Controllers/Api/V1/Admin/AdminTripController.php @@ -30,7 +30,7 @@ class AdminTripController extends ApiController $perPage = (int) $request->input('per_page', 25); $trips = Trip::query() - ->with(['route', 'team', 'truck', 'dumpsite']) + ->with(['route', 'team', 'truck', 'dumpsite', 'tenant']) ->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status'))) ->when($request->filled('date'), fn ($q) => $q->whereDate('scheduled_date', $request->string('date'))) ->when($request->filled('team_id'), fn ($q) => $q->where('team_id', $request->integer('team_id'))) diff --git a/app/Http/Controllers/Api/V1/Admin/AdminTruckController.php b/app/Http/Controllers/Api/V1/Admin/AdminTruckController.php index a902769..d74952c 100644 --- a/app/Http/Controllers/Api/V1/Admin/AdminTruckController.php +++ b/app/Http/Controllers/Api/V1/Admin/AdminTruckController.php @@ -25,6 +25,7 @@ class AdminTruckController extends ApiController $perPage = (int) $request->input('per_page', 25); $trucks = Truck::query() + ->with('tenant') ->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status'))) ->when($request->filled('q'), function ($q) use ($request) { $term = '%'.$request->string('q').'%'; diff --git a/app/Http/Controllers/Api/V1/Admin/AdminUserController.php b/app/Http/Controllers/Api/V1/Admin/AdminUserController.php index 319e9c6..ffbb3fb 100644 --- a/app/Http/Controllers/Api/V1/Admin/AdminUserController.php +++ b/app/Http/Controllers/Api/V1/Admin/AdminUserController.php @@ -74,7 +74,7 @@ class AdminUserController extends ApiController ->orderByDesc('id') ->paginate($perPage); - $users->load(['residentProfile', 'driverProfile', 'helperProfile', 'scannerProfile', 'storePartnerProfile']); + $users->load(['residentProfile', 'driverProfile', 'helperProfile', 'scannerProfile', 'storePartnerProfile', 'tenant']); return $this->ok( UserDetailResource::collection($users), diff --git a/app/Http/Controllers/Api/V1/SuperAdmin/SuperAdminBarangayController.php b/app/Http/Controllers/Api/V1/SuperAdmin/SuperAdminBarangayController.php index 4deaa77..55365f1 100644 --- a/app/Http/Controllers/Api/V1/SuperAdmin/SuperAdminBarangayController.php +++ b/app/Http/Controllers/Api/V1/SuperAdmin/SuperAdminBarangayController.php @@ -30,6 +30,7 @@ class SuperAdminBarangayController extends ApiController $barangays = Barangay::query() ->with(['cityMunicipality.province']) + ->withSum('households', 'household_size') ->when($request->filled('city_municipality_id'), fn ($q) => $q->where('city_municipality_id', $request->integer('city_municipality_id'))) ->when($request->filled('q'), function ($q) use ($request) { $term = '%'.$request->string('q').'%'; diff --git a/app/Http/Resources/BarangayResource.php b/app/Http/Resources/BarangayResource.php index 4714ac4..59c07ad 100644 --- a/app/Http/Resources/BarangayResource.php +++ b/app/Http/Resources/BarangayResource.php @@ -27,6 +27,7 @@ class BarangayResource extends JsonResource 'name' => $this->name, 'urban_rural' => $this->urban_rural, 'population' => $this->population, + 'total_residents' => $this->households_sum_household_size ?? 0, 'city_municipality_id' => $this->city_municipality_id, 'centroid' => $this->centroid ? [ 'lat' => $this->centroid->latitude, diff --git a/app/Http/Resources/CollectionTeamResource.php b/app/Http/Resources/CollectionTeamResource.php index 25ff228..4e8e6ab 100644 --- a/app/Http/Resources/CollectionTeamResource.php +++ b/app/Http/Resources/CollectionTeamResource.php @@ -40,6 +40,10 @@ class CollectionTeamResource extends JsonResource 'performance_stats' => $this->getPerformanceStats(), 'current_trip' => TripResource::make($this->whenLoaded('currentTrip')), 'notes' => $this->notes, + 'tenant' => $this->whenLoaded('tenant', fn () => [ + 'id' => $this->tenant->id, + 'name' => $this->tenant->name, + ]), 'created_at' => $this->created_at?->toIso8601String(), ]; } diff --git a/app/Http/Resources/DropOffPointResource.php b/app/Http/Resources/DropOffPointResource.php index 64c52b5..3a26b3c 100644 --- a/app/Http/Resources/DropOffPointResource.php +++ b/app/Http/Resources/DropOffPointResource.php @@ -31,6 +31,10 @@ class DropOffPointResource extends JsonResource isset($this->distance_meters), fn () => round((float) $this->distance_meters, 1), ), + 'tenant' => $this->whenLoaded('tenant', fn () => [ + 'id' => $this->tenant->id, + 'name' => $this->tenant->name, + ]), 'barangay' => BarangayResource::make($this->whenLoaded('barangay')), ]; } diff --git a/app/Http/Resources/DumpsiteResource.php b/app/Http/Resources/DumpsiteResource.php index 60b52b3..27559ed 100644 --- a/app/Http/Resources/DumpsiteResource.php +++ b/app/Http/Resources/DumpsiteResource.php @@ -39,6 +39,10 @@ class DumpsiteResource extends JsonResource 'contact_person' => $this->contact_person, 'contact_phone' => $this->contact_phone, 'city_municipality' => CityMunicipalityResource::make($this->whenLoaded('cityMunicipality')), + 'tenant' => $this->whenLoaded('tenant', fn () => [ + 'id' => $this->tenant->id, + 'name' => $this->tenant->name, + ]), 'created_at' => $this->created_at?->toIso8601String(), ]; } diff --git a/app/Http/Resources/HouseholdResource.php b/app/Http/Resources/HouseholdResource.php index 03c12e7..d4a3362 100644 --- a/app/Http/Resources/HouseholdResource.php +++ b/app/Http/Resources/HouseholdResource.php @@ -34,6 +34,10 @@ class HouseholdResource extends JsonResource 'member_count' => $this->whenCounted('members'), 'created_at' => $this->created_at?->toIso8601String(), 'updated_at' => $this->updated_at?->toIso8601String(), + 'tenant' => $this->whenLoaded('tenant', fn () => [ + 'id' => $this->tenant->id, + 'name' => $this->tenant->name, + ]), ]; } } diff --git a/app/Http/Resources/RouteResource.php b/app/Http/Resources/RouteResource.php index bb15edd..c819d75 100644 --- a/app/Http/Resources/RouteResource.php +++ b/app/Http/Resources/RouteResource.php @@ -23,6 +23,10 @@ class RouteResource extends JsonResource 'default_team_id' => $this->default_team_id, 'stops' => RouteStopResource::collection($this->whenLoaded('stops')), 'stop_count' => $this->whenCounted('stops'), + 'tenant' => $this->whenLoaded('tenant', fn () => [ + 'id' => $this->tenant->id, + 'name' => $this->tenant->name, + ]), 'created_at' => $this->created_at?->toIso8601String(), ]; } diff --git a/app/Http/Resources/TripResource.php b/app/Http/Resources/TripResource.php index cc7bea9..02d10d9 100644 --- a/app/Http/Resources/TripResource.php +++ b/app/Http/Resources/TripResource.php @@ -50,6 +50,10 @@ class TripResource extends JsonResource 'dumpsite_attendant' => $r->dumpsite_attendant_name, 'notes' => $r->notes, ])), + 'tenant' => $this->whenLoaded('tenant', fn () => [ + 'id' => $this->tenant->id, + 'name' => $this->tenant->name, + ]), 'created_at' => $this->created_at?->toIso8601String(), ]; } diff --git a/app/Http/Resources/TruckResource.php b/app/Http/Resources/TruckResource.php index bfb6577..d2cb58b 100644 --- a/app/Http/Resources/TruckResource.php +++ b/app/Http/Resources/TruckResource.php @@ -22,6 +22,10 @@ class TruckResource extends JsonResource 'lng' => $this->last_known_coordinates->longitude, ] : null, 'last_location_updated_at' => $this->last_location_updated_at?->toIso8601String(), + 'tenant' => $this->whenLoaded('tenant', fn () => [ + 'id' => $this->tenant->id, + 'name' => $this->tenant->name, + ]), ]; } } diff --git a/app/Http/Resources/UserDetailResource.php b/app/Http/Resources/UserDetailResource.php index 96b0e47..ec5b212 100644 --- a/app/Http/Resources/UserDetailResource.php +++ b/app/Http/Resources/UserDetailResource.php @@ -31,6 +31,10 @@ class UserDetailResource extends JsonResource 'created_at' => $this->created_at?->toIso8601String(), 'profile' => $profile?->toArray(), 'has_household' => $this->headedHousehold()->exists() || $this->householdMemberships()->exists(), + 'tenant' => $this->whenLoaded('tenant', fn () => [ + 'id' => $this->tenant->id, + 'name' => $this->tenant->name, + ]), ]; } } diff --git a/app/Models/Barangay.php b/app/Models/Barangay.php index d61ce1b..d22cf47 100644 --- a/app/Models/Barangay.php +++ b/app/Models/Barangay.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use MatanYadaev\EloquentSpatial\Objects\Point; use MatanYadaev\EloquentSpatial\Objects\Polygon; @@ -50,4 +51,9 @@ class Barangay extends Model return $this->belongsToMany(ServiceArea::class, 'service_area_barangay') ->withTimestamps(); } + + public function households(): HasMany + { + return $this->hasMany(Household::class); + } } diff --git a/resources/views/admin/barangays.blade.php b/resources/views/admin/barangays.blade.php index 173f604..90d7b1d 100644 --- a/resources/views/admin/barangays.blade.php +++ b/resources/views/admin/barangays.blade.php @@ -39,7 +39,7 @@ PSGC Code Code Type - Population + Population (Registered) Centroid Actions @@ -241,7 +241,12 @@ ${b.psgc_code || 'N/A'} ${b.code || 'N/A'} ${b.urban_rural} - ${b.population?.toLocaleString() || 'N/A'} + +
+ ${b.total_residents?.toLocaleString() || '0'} + ${b.population ? `/ ${b.population.toLocaleString()}` : ''} +
+ ${centroidStr} diff --git a/resources/views/admin/drop-off-points.blade.php b/resources/views/admin/drop-off-points.blade.php index 655d0bb..918541a 100644 --- a/resources/views/admin/drop-off-points.blade.php +++ b/resources/views/admin/drop-off-points.blade.php @@ -32,6 +32,7 @@ Name + LGU Code Address Capacity @@ -210,6 +211,7 @@ rows.innerHTML = items.map(d => ` ${window.Verde.escapeHtml(d.name)} + ${window.Verde.escapeHtml(d.tenant?.name || d.barangay?.city_municipality?.name || 'N/A')} ${window.Verde.escapeHtml(d.code)} ${window.Verde.escapeHtml(d.address_line)} ${d.capacity_kg ?? '—'} kg diff --git a/resources/views/admin/dumpsites.blade.php b/resources/views/admin/dumpsites.blade.php index a678349..f08092f 100644 --- a/resources/views/admin/dumpsites.blade.php +++ b/resources/views/admin/dumpsites.blade.php @@ -20,6 +20,7 @@ Name + LGU Code Capacity Permit # @@ -173,6 +174,7 @@ rows.innerHTML = items.map(d => ` ${window.Verde.escapeHtml(d.name)} + ${window.Verde.escapeHtml(d.city_municipality?.name || d.tenant?.name || 'N/A')} ${window.Verde.escapeHtml(d.code)} ${d.capacity_tons ? `${d.capacity_tons} t` : '—'} ${window.Verde.escapeHtml(d.permit_number ?? '—')} diff --git a/resources/views/admin/households.blade.php b/resources/views/admin/households.blade.php index 1769c7d..d74ce8f 100644 --- a/resources/views/admin/households.blade.php +++ b/resources/views/admin/households.blade.php @@ -32,6 +32,7 @@ Head + LGU Address Service Area Members @@ -383,6 +384,7 @@ return ` ${head} + ${window.Verde.escapeHtml(h.tenant?.name || h.barangay?.city_municipality?.name || 'N/A')}
${window.Verde.escapeHtml(h.address_line)}
${mapLink} diff --git a/resources/views/admin/routes.blade.php b/resources/views/admin/routes.blade.php index 33a8b78..d12643d 100644 --- a/resources/views/admin/routes.blade.php +++ b/resources/views/admin/routes.blade.php @@ -30,6 +30,7 @@ Name + LGU Code Stops Distance @@ -316,6 +317,7 @@ const dur = h > 0 ? `${h}h ${m}m` : `${m}m`; return ` ${window.Verde.escapeHtml(r.name)} + ${window.Verde.escapeHtml(r.tenant?.name || 'N/A')} ${window.Verde.escapeHtml(r.code)} ${r.stop_count ?? 0} ${r.total_distance_km} km diff --git a/resources/views/admin/teams.blade.php b/resources/views/admin/teams.blade.php index 22356ca..7736bd7 100644 --- a/resources/views/admin/teams.blade.php +++ b/resources/views/admin/teams.blade.php @@ -245,7 +245,7 @@

${window.Verde.escapeHtml(t.name)}

-

${window.Verde.escapeHtml(t.area?.name || 'No Area')}

+

${window.Verde.escapeHtml(t.area?.name || 'No Area')} • ${window.Verde.escapeHtml(t.tenant?.name || 'N/A')}

${badge(t.status)}
diff --git a/resources/views/admin/trips.blade.php b/resources/views/admin/trips.blade.php index 287fb20..336ea58 100644 --- a/resources/views/admin/trips.blade.php +++ b/resources/views/admin/trips.blade.php @@ -33,6 +33,7 @@ Trip # Date + LGU Route Team Load @@ -195,6 +196,7 @@ ${window.Verde.escapeHtml(t.trip_number)} ${window.Verde.escapeHtml(t.scheduled_date)} + ${window.Verde.escapeHtml(t.tenant?.name || 'N/A')} ${t.route ? window.Verde.escapeHtml(t.route.name) : '—'} ${t.team ? window.Verde.escapeHtml(t.team.name) : '—'} ${t.total_load_kg ? `${t.total_load_kg} kg` : '—'} diff --git a/resources/views/admin/trucks.blade.php b/resources/views/admin/trucks.blade.php index 401fbbb..54014d1 100644 --- a/resources/views/admin/trucks.blade.php +++ b/resources/views/admin/trucks.blade.php @@ -26,6 +26,7 @@ Plate # + LGU Model Capacity Status @@ -129,6 +130,7 @@ rows.innerHTML = items.map(t => ` ${window.Verde.escapeHtml(t.plate_number)} + ${window.Verde.escapeHtml(t.tenant?.name || 'N/A')} ${window.Verde.escapeHtml(t.model ?? '—')} ${t.capacity_kg ? `${t.capacity_kg.toLocaleString()} kg` : '—'} ${badge(t.status)} diff --git a/resources/views/admin/users.blade.php b/resources/views/admin/users.blade.php index 645856e..ebf5726 100644 --- a/resources/views/admin/users.blade.php +++ b/resources/views/admin/users.blade.php @@ -39,6 +39,7 @@ Name Email + LGU Phone Role Status @@ -238,6 +239,7 @@ return ` ${window.Verde.escapeHtml(u.full_name || '—')} ${window.Verde.escapeHtml(u.email)} + ${window.Verde.escapeHtml(u.tenant?.name || 'N/A')} ${window.Verde.escapeHtml(u.phone ?? '')} ${u.role} ${statusBadge(u.status)} diff --git a/tests/Feature/LguTenantIsolationTest.php b/tests/Feature/LguTenantIsolationTest.php new file mode 100644 index 0000000..a702858 --- /dev/null +++ b/tests/Feature/LguTenantIsolationTest.php @@ -0,0 +1,136 @@ +lguA = $this->defaultTenant; + $this->lguB = Tenant::factory()->create(['code' => 'LGU-B', 'name' => 'LGU B']); + + $this->adminA = User::factory()->create([ + 'tenant_id' => $this->lguA->id, + 'role' => User::ROLE_ADMIN, + ]); + + $this->adminB = User::factory()->create([ + 'tenant_id' => $this->lguB->id, + 'role' => User::ROLE_ADMIN, + ]); + + $this->superAdmin = User::factory()->create([ + 'tenant_id' => null, + 'role' => User::ROLE_SUPER_ADMIN, + ]); + } + + public function test_lgu_a_can_create_and_view_own_entities() + { + $this->actingAs($this->adminA); + $this->withHeaders(['X-Tenant-Code' => $this->lguA->code]); + + $city = \App\Models\CityMunicipality::first(); + $barangay = \App\Models\Barangay::first(); + + $dumpsiteResponse = $this->postJson('/api/v1/admin/dumpsites', [ + 'name' => 'LGU A Dumpsite', + 'code' => 'DS-01', + 'address_line' => '123 Test St', + 'city_municipality_id' => $city->id, + 'lat' => 14.0, + 'lng' => 121.0, + 'status' => 'active' + ]); + $dumpsiteResponse->assertCreated(); + + // Create Drop-off Point + $dopResponse = $this->postJson('/api/v1/admin/drop-off-points', [ + 'name' => 'LGU A DOP', + 'code' => 'DOP-01', + 'address_line' => '456 Test Ave', + 'barangay_id' => $barangay->id, + 'lat' => 14.1, + 'lng' => 121.1, + 'status' => 'active' + ]); + $dopResponse->assertCreated(); + + // Create Team + $teamResponse = $this->postJson('/api/v1/admin/teams', [ + 'name' => 'LGU A Team', + 'status' => 'active' + ]); + $teamResponse->assertCreated(); + } + + public function test_lgu_b_cannot_access_lgu_a_entities() + { + // Seed LGU A data + $dumpsiteA = Dumpsite::factory()->create(['tenant_id' => $this->lguA->id]); + $dopA = DropOffPoint::factory()->create(['tenant_id' => $this->lguA->id]); + + $teamA = new CollectionTeam(['name' => 'Team A', 'status' => 'active']); + $teamA->tenant_id = $this->lguA->id; + $teamA->save(); + + $routeA = new Route(['name' => 'Route A', 'code' => 'RTA-01', 'geojson' => []]); + $routeA->tenant_id = $this->lguA->id; + $routeA->save(); + + // Act as LGU B Admin + $this->actingAs($this->adminB); + $this->withHeaders(['X-Tenant-Code' => $this->lguB->code]); + + // 1. List Endpoints Should Not Contain LGU A Data + $this->getJson('/api/v1/admin/dumpsites')->assertJsonMissing(['id' => $dumpsiteA->id]); + $this->getJson('/api/v1/admin/drop-off-points')->assertJsonMissing(['id' => $dopA->id]); + $this->getJson('/api/v1/admin/teams')->assertJsonMissing(['id' => $teamA->id]); + $this->getJson('/api/v1/admin/routes')->assertJsonMissing(['id' => $routeA->id]); + + // 2. Direct Access Should Fail (404/403) + $this->getJson("/api/v1/admin/routes/{$routeA->id}")->assertStatus(404); + + // 3. API Tampering: Attempt to create a trip for LGU B using LGU A's Route + $truckB = new Truck(['plate_number' => 'ABC-1234', 'capacity_tons' => 5]); + $truckB->tenant_id = $this->lguB->id; + $truckB->save(); + + $teamB = new CollectionTeam(['name' => 'Team B', 'status' => 'active']); + $teamB->tenant_id = $this->lguB->id; + $teamB->save(); + + $response = $this->postJson('/api/v1/admin/trips', [ + 'route_id' => $routeA->id, + 'truck_id' => $truckB->id, + 'collection_team_id' => $teamB->id, + 'scheduled_date' => now()->format('Y-m-d'), + ]); + + // Validation should fail because route_id doesn't belong to LGU B + $response->assertStatus(422); + } +} diff --git a/tests/Feature/SuperAdminHierarchyTest.php b/tests/Feature/SuperAdminHierarchyTest.php new file mode 100644 index 0000000..66b43b6 --- /dev/null +++ b/tests/Feature/SuperAdminHierarchyTest.php @@ -0,0 +1,163 @@ +lguA = $this->defaultTenant; + $this->lguB = Tenant::factory()->create(['code' => 'LGU-B', 'name' => 'LGU B']); + + $this->superAdmin = User::factory()->create([ + 'tenant_id' => null, + 'role' => User::ROLE_SUPER_ADMIN, + ]); + + $this->withoutHeader('X-Tenant-Code'); + \App\Tenancy\Tenancy::clear(); + } + + protected function seedLguEntities(Tenant $tenant, string $suffix) + { + $city = CityMunicipality::first(); + $barangay = Barangay::first(); + + $dumpsite = Dumpsite::factory()->create(['tenant_id' => $tenant->id, 'name' => "Dumpsite $suffix"]); + $dop = DropOffPoint::factory()->create(['tenant_id' => $tenant->id, 'name' => "DOP $suffix", 'barangay_id' => $barangay->id]); + + $team = new CollectionTeam(['name' => "Team $suffix", 'status' => 'active']); + $team->tenant_id = $tenant->id; + $team->save(); + + $route = new Route(['name' => "Route $suffix", 'code' => "RT-$suffix", 'geojson' => []]); + $route->tenant_id = $tenant->id; + $route->save(); + + $truck = new Truck(['plate_number' => "ABC-123$suffix", 'capacity_tons' => 5]); + $truck->tenant_id = $tenant->id; + $truck->save(); + + $trip = new Trip([ + 'route_id' => $route->id, + 'truck_id' => $truck->id, + 'team_id' => $team->id, + 'scheduled_date' => now()->format('Y-m-d'), + 'status' => 'scheduled' + ]); + $trip->tenant_id = $tenant->id; + $trip->save(); + + $household = Household::factory()->create(['tenant_id' => $tenant->id, 'barangay_id' => $barangay->id]); + + return [ + 'dumpsite' => $dumpsite, + 'dop' => $dop, + 'team' => $team, + 'route' => $route, + 'truck' => $truck, + 'trip' => $trip, + 'household' => $household, + 'barangay' => $barangay + ]; + } + + public function test_super_admin_can_view_all_entities_across_all_lgus() + { + $dataA = $this->seedLguEntities($this->lguA, 'A'); + $dataB = $this->seedLguEntities($this->lguB, 'B'); + + $this->actingAs($this->superAdmin); + + // Verify Dumpsites + $this->getJson('/api/v1/admin/dumpsites') + ->assertOk() + ->assertJsonFragment(['id' => $dataA['dumpsite']->uuid]) + ->assertJsonFragment(['id' => $dataB['dumpsite']->uuid]); + + // Verify Drop-off Points + $this->getJson('/api/v1/admin/drop-off-points') + ->assertOk() + ->assertJsonFragment(['id' => $dataA['dop']->uuid]) + ->assertJsonFragment(['id' => $dataB['dop']->uuid]); + + // Verify Teams + $this->getJson('/api/v1/admin/teams') + ->assertOk() + ->assertJsonFragment(['id' => $dataA['team']->uuid]) + ->assertJsonFragment(['id' => $dataB['team']->uuid]); + + // Verify Routes + $this->getJson('/api/v1/admin/routes') + ->assertOk() + ->assertJsonFragment(['id' => $dataA['route']->uuid]) + ->assertJsonFragment(['id' => $dataB['route']->uuid]); + + // Verify Trips + $this->getJson('/api/v1/admin/trips') + ->assertOk() + ->assertJsonFragment(['id' => $dataA['trip']->uuid]) + ->assertJsonFragment(['id' => $dataB['trip']->uuid]); + } + + public function test_super_admin_can_manipulate_all_entities_in_any_lgu() + { + $dataA = $this->seedLguEntities($this->lguA, 'A'); + $dataB = $this->seedLguEntities($this->lguB, 'B'); + + $this->actingAs($this->superAdmin); + + // Update Dumpsite in LGU A + $this->patchJson("/api/v1/admin/dumpsites/{$dataA['dumpsite']->uuid}", [ + 'name' => 'Updated Dumpsite A', + 'status' => 'maintenance' + ])->assertOk(); + + // Update DOP in LGU B + $this->patchJson("/api/v1/admin/drop-off-points/{$dataB['dop']->uuid}", [ + 'name' => 'Updated DOP B', + 'status' => 'closed' + ])->assertOk(); + } + + public function test_super_admin_can_query_by_barangay_and_lgu() + { + $dataA = $this->seedLguEntities($this->lguA, 'A'); + + $this->actingAs($this->superAdmin); + + // Filter Drop-off points by Tenant + $this->getJson('/api/v1/admin/drop-off-points?tenant_id=' . $this->lguA->id) + ->assertOk() + ->assertJsonFragment(['id' => $dataA['dop']->uuid]); + + // Filter Drop-off points by Barangay + $this->getJson('/api/v1/admin/drop-off-points?barangay_id=' . $dataA['barangay']->id) + ->assertOk() + ->assertJsonFragment(['id' => $dataA['dop']->uuid]); + } +}