Fix missing LGU/tenant relations and display in tables; Add LGU hierarchy tests
This commit is contained in:
@@ -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').'%';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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')))
|
||||
|
||||
@@ -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').'%'))
|
||||
|
||||
@@ -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')))
|
||||
|
||||
@@ -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').'%';
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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').'%';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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')),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<th>PSGC Code</th>
|
||||
<th>Code</th>
|
||||
<th>Type</th>
|
||||
<th>Population</th>
|
||||
<th class="text-center">Population (Registered)</th>
|
||||
<th>Centroid</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
@@ -241,7 +241,12 @@
|
||||
<td class="font-mono text-xs">${b.psgc_code || 'N/A'}</td>
|
||||
<td class="font-mono text-xs">${b.code || 'N/A'}</td>
|
||||
<td class="capitalize">${b.urban_rural}</td>
|
||||
<td>${b.population?.toLocaleString() || 'N/A'}</td>
|
||||
<td class="text-center">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<span class="font-medium text-green-600">${b.total_residents?.toLocaleString() || '0'}</span>
|
||||
${b.population ? `<span class="text-xs text-neutral-400">/ ${b.population.toLocaleString()}</span>` : ''}
|
||||
</div>
|
||||
</td>
|
||||
<td class="font-mono text-xs">${centroidStr}</td>
|
||||
<td class="text-right space-x-2">
|
||||
<button class="btn-ghost edit-btn" data-id="${b.id}">Edit</button>
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>LGU</th>
|
||||
<th>Code</th>
|
||||
<th>Address</th>
|
||||
<th>Capacity</th>
|
||||
@@ -210,6 +211,7 @@
|
||||
rows.innerHTML = items.map(d => `
|
||||
<tr>
|
||||
<td class="font-medium text-neutral-900">${window.Verde.escapeHtml(d.name)}</td>
|
||||
<td class="text-xs text-neutral-500">${window.Verde.escapeHtml(d.tenant?.name || d.barangay?.city_municipality?.name || 'N/A')}</td>
|
||||
<td class="font-mono text-xs text-neutral-600">${window.Verde.escapeHtml(d.code)}</td>
|
||||
<td class="max-w-xs truncate text-neutral-600">${window.Verde.escapeHtml(d.address_line)}</td>
|
||||
<td>${d.capacity_kg ?? '—'} kg</td>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>LGU</th>
|
||||
<th>Code</th>
|
||||
<th>Capacity</th>
|
||||
<th>Permit #</th>
|
||||
@@ -173,6 +174,7 @@
|
||||
rows.innerHTML = items.map(d => `
|
||||
<tr>
|
||||
<td class="font-medium text-neutral-900">${window.Verde.escapeHtml(d.name)}</td>
|
||||
<td class="text-xs text-neutral-500">${window.Verde.escapeHtml(d.city_municipality?.name || d.tenant?.name || 'N/A')}</td>
|
||||
<td class="font-mono text-xs text-neutral-600">${window.Verde.escapeHtml(d.code)}</td>
|
||||
<td>${d.capacity_tons ? `${d.capacity_tons} t` : '—'}</td>
|
||||
<td class="font-mono text-xs">${window.Verde.escapeHtml(d.permit_number ?? '—')}</td>
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Head</th>
|
||||
<th>LGU</th>
|
||||
<th>Address</th>
|
||||
<th>Service Area</th>
|
||||
<th>Members</th>
|
||||
@@ -383,6 +384,7 @@
|
||||
|
||||
return `<tr>
|
||||
<td>${head}</td>
|
||||
<td class="text-xs text-neutral-500">${window.Verde.escapeHtml(h.tenant?.name || h.barangay?.city_municipality?.name || 'N/A')}</td>
|
||||
<td class="max-w-xs truncate text-neutral-700">
|
||||
<div class="truncate">${window.Verde.escapeHtml(h.address_line)}</div>
|
||||
${mapLink}
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>LGU</th>
|
||||
<th>Code</th>
|
||||
<th>Stops</th>
|
||||
<th>Distance</th>
|
||||
@@ -316,6 +317,7 @@
|
||||
const dur = h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||
return `<tr>
|
||||
<td class="font-medium text-neutral-900">${window.Verde.escapeHtml(r.name)}</td>
|
||||
<td class="text-xs text-neutral-500">${window.Verde.escapeHtml(r.tenant?.name || 'N/A')}</td>
|
||||
<td class="font-mono text-xs text-neutral-600">${window.Verde.escapeHtml(r.code)}</td>
|
||||
<td>${r.stop_count ?? 0}</td>
|
||||
<td>${r.total_distance_km} km</td>
|
||||
|
||||
@@ -245,7 +245,7 @@
|
||||
<div class="bg-neutral-50 p-4 border-b border-neutral-100 flex justify-between items-start">
|
||||
<div>
|
||||
<h3 class="font-bold text-neutral-900">${window.Verde.escapeHtml(t.name)}</h3>
|
||||
<p class="text-xs text-neutral-500 mt-0.5">${window.Verde.escapeHtml(t.area?.name || 'No Area')}</p>
|
||||
<p class="text-xs text-neutral-500 mt-0.5">${window.Verde.escapeHtml(t.area?.name || 'No Area')} • ${window.Verde.escapeHtml(t.tenant?.name || 'N/A')}</p>
|
||||
</div>
|
||||
${badge(t.status)}
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
<tr>
|
||||
<th>Trip #</th>
|
||||
<th>Date</th>
|
||||
<th>LGU</th>
|
||||
<th>Route</th>
|
||||
<th>Team</th>
|
||||
<th>Load</th>
|
||||
@@ -195,6 +196,7 @@
|
||||
<tr>
|
||||
<td class="font-mono text-xs font-medium text-neutral-900">${window.Verde.escapeHtml(t.trip_number)}</td>
|
||||
<td>${window.Verde.escapeHtml(t.scheduled_date)}</td>
|
||||
<td class="text-xs text-neutral-500">${window.Verde.escapeHtml(t.tenant?.name || 'N/A')}</td>
|
||||
<td>${t.route ? window.Verde.escapeHtml(t.route.name) : '—'}</td>
|
||||
<td>${t.team ? window.Verde.escapeHtml(t.team.name) : '—'}</td>
|
||||
<td>${t.total_load_kg ? `${t.total_load_kg} kg` : '—'}</td>
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Plate #</th>
|
||||
<th>LGU</th>
|
||||
<th>Model</th>
|
||||
<th>Capacity</th>
|
||||
<th>Status</th>
|
||||
@@ -129,6 +130,7 @@
|
||||
rows.innerHTML = items.map(t => `
|
||||
<tr>
|
||||
<td class="font-mono text-sm font-medium text-neutral-900">${window.Verde.escapeHtml(t.plate_number)}</td>
|
||||
<td class="text-xs text-neutral-500">${window.Verde.escapeHtml(t.tenant?.name || 'N/A')}</td>
|
||||
<td>${window.Verde.escapeHtml(t.model ?? '—')}</td>
|
||||
<td>${t.capacity_kg ? `${t.capacity_kg.toLocaleString()} kg` : '—'}</td>
|
||||
<td>${badge(t.status)}</td>
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>LGU</th>
|
||||
<th>Phone</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
@@ -238,6 +239,7 @@
|
||||
return `<tr>
|
||||
<td class="font-medium text-neutral-900">${window.Verde.escapeHtml(u.full_name || '—')}</td>
|
||||
<td class="text-neutral-700">${window.Verde.escapeHtml(u.email)}</td>
|
||||
<td class="text-xs text-neutral-500">${window.Verde.escapeHtml(u.tenant?.name || 'N/A')}</td>
|
||||
<td class="font-mono text-xs text-neutral-600">${window.Verde.escapeHtml(u.phone ?? '')}</td>
|
||||
<td><span class="badge badge-info">${u.role}</span></td>
|
||||
<td>${statusBadge(u.status)}</td>
|
||||
|
||||
136
tests/Feature/LguTenantIsolationTest.php
Normal file
136
tests/Feature/LguTenantIsolationTest.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\DropOffPoint;
|
||||
use App\Models\Dumpsite;
|
||||
use App\Models\Route;
|
||||
use App\Models\CollectionTeam;
|
||||
use App\Models\Trip;
|
||||
use App\Models\Truck;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class LguTenantIsolationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected $seed = true;
|
||||
|
||||
protected Tenant $lguA;
|
||||
protected Tenant $lguB;
|
||||
protected User $adminA;
|
||||
protected User $adminB;
|
||||
protected User $superAdmin;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
163
tests/Feature/SuperAdminHierarchyTest.php
Normal file
163
tests/Feature/SuperAdminHierarchyTest.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\DropOffPoint;
|
||||
use App\Models\Dumpsite;
|
||||
use App\Models\Route;
|
||||
use App\Models\CollectionTeam;
|
||||
use App\Models\Trip;
|
||||
use App\Models\Truck;
|
||||
use App\Models\CityMunicipality;
|
||||
use App\Models\Barangay;
|
||||
use App\Models\Household;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SuperAdminHierarchyTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected $seed = true;
|
||||
|
||||
protected Tenant $lguA;
|
||||
protected Tenant $lguB;
|
||||
protected User $superAdmin;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user