feat(backend): complete Module 6 (dumpsites + geofence)

dumpsites table with both coordinates POINT and boundary_polygon
POLYGON (SRID 4326). Admin CRUD accepts boundary as a list of {lat,lng}
points; ring is auto-closed. Dumpsite::containsPoint() runs ST_Contains
for geofence checks (Module 10 will fire arrived_at_dumpsite from this).
dumpsite_releases schema in place — trip_id stays nullable bigint until
Module 10 adds the FK.

109 feature tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-30 00:39:14 +08:00
parent 1150a518e8
commit 474b1cfe0b
15 changed files with 860 additions and 2 deletions

View File

@@ -160,8 +160,21 @@ bind `FakeSmsService` via `$this->app->instance(SmsService::class, ...)` in
`assigned_drop_off_point_id` to nearest active DOP within 25km)
- `POST /households/{uuid}/reassign-drop-off` re-runs the lookup
- SampleDropOffPointsSeeder creates 5 DOPs around the sample barangays
- All 97 feature tests passing
- [ ] Module 6+: see `../docs/development-roadmap.md`
- [x] Module 6: Dumpsites — complete
- `dumpsites` (uuid, name, code, city_municipality_id, coordinates POINT
4326 with `SPATIAL INDEX`, boundary_polygon POLYGON 4326, capacity_tons,
operating_hours JSON, accepted_waste_types JSON, permit_number, contacts)
- `dumpsite_releases` schema in place (trip_id is nullable bigint without
FK; Module 10 will add the constraint when `trips` exists)
- Admin CRUD: `GET/POST/PATCH/DELETE /admin/dumpsites`
- Boundary input is `[{lat,lng}, ...]` (≥3 points); auto-closes the ring
if the client doesn't repeat the first point
- `Dumpsite::containsPoint(lat, lng)` for geofence checks via
`ST_Contains` — Module 10 fires `arrived_at_dumpsite` based on this
- SampleDumpsitesSeeder creates a sample Payatas-area dumpsite with a
rectangular boundary so geofence tests + dev work
- All 109 feature tests passing
- [ ] Module 7+: see `../docs/development-roadmap.md`
### Geo notes
- Boundary polygons + centroids stored nullable for now. Once a full PSGC

View File

@@ -0,0 +1,156 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin;
use App\Http\Controllers\Api\V1\ApiController;
use App\Http\Requests\Dumpsite\StoreDumpsiteRequest;
use App\Http\Requests\Dumpsite\UpdateDumpsiteRequest;
use App\Http\Resources\DumpsiteResource;
use App\Models\Dumpsite;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use MatanYadaev\EloquentSpatial\Objects\LineString;
use MatanYadaev\EloquentSpatial\Objects\Point;
use MatanYadaev\EloquentSpatial\Objects\Polygon;
class AdminDumpsiteController extends ApiController
{
public function index(Request $request): JsonResponse
{
$request->validate([
'status' => ['nullable', 'in:active,maintenance,closed'],
'city_municipality_id' => ['nullable', 'integer'],
'q' => ['nullable', 'string', 'max:100'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
]);
$perPage = (int) $request->input('per_page', 25);
$dumpsites = Dumpsite::query()
->with('cityMunicipality')
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
->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').'%';
$q->where(fn ($qq) => $qq->where('name', 'like', $term)
->orWhere('code', 'like', $term)
->orWhere('address_line', 'like', $term));
})
->orderBy('name')
->paginate($perPage);
return $this->ok(
DumpsiteResource::collection($dumpsites),
null,
[
'page' => $dumpsites->currentPage(),
'per_page' => $dumpsites->perPage(),
'total' => $dumpsites->total(),
'last_page' => $dumpsites->lastPage(),
],
);
}
public function store(StoreDumpsiteRequest $request): JsonResponse
{
$data = $request->validated();
$payload = $this->mapPayload($data);
$payload['name'] = $data['name'];
$payload['code'] = $data['code'];
$payload['address_line'] = $data['address_line'];
$payload['city_municipality_id'] = $data['city_municipality_id'] ?? null;
$payload['accepted_waste_types'] = $data['accepted_waste_types'] ?? null;
$payload['operating_hours'] = $data['operating_hours'] ?? null;
$payload['capacity_tons'] = $data['capacity_tons'] ?? null;
$payload['contact_person'] = $data['contact_person'] ?? null;
$payload['contact_phone'] = $data['contact_phone'] ?? null;
$payload['permit_number'] = $data['permit_number'] ?? null;
$payload['status'] = $data['status'] ?? Dumpsite::STATUS_ACTIVE;
$dumpsite = Dumpsite::create($payload);
return $this->created(
new DumpsiteResource($dumpsite->fresh()->load('cityMunicipality')),
'Dumpsite created',
);
}
public function show(Dumpsite $dumpsite): JsonResponse
{
$dumpsite->load('cityMunicipality.province.region');
return $this->ok(new DumpsiteResource($dumpsite));
}
public function update(UpdateDumpsiteRequest $request, Dumpsite $dumpsite): JsonResponse
{
$data = $request->validated();
$payload = $this->mapPayload($data);
// Carry through scalar/json fields that mapPayload doesn't touch.
foreach ([
'name', 'code', 'address_line', 'city_municipality_id',
'accepted_waste_types', 'operating_hours', 'capacity_tons',
'contact_person', 'contact_phone', 'permit_number', 'status',
] as $key) {
if (array_key_exists($key, $data)) {
$payload[$key] = $data[$key];
}
}
$dumpsite->update($payload);
return $this->ok(
new DumpsiteResource($dumpsite->fresh()->load('cityMunicipality')),
'Dumpsite updated',
);
}
public function destroy(Dumpsite $dumpsite): JsonResponse
{
$dumpsite->delete();
return $this->ok(null, 'Dumpsite deleted');
}
/**
* Translate lat/lng + boundary_polygon[{lat,lng}, ...] inputs into
* spatial Polygon/Point objects suitable for assignment to the model.
*/
private function mapPayload(array $data): array
{
$payload = [];
if (isset($data['lat'], $data['lng'])) {
$payload['coordinates'] = new Point((float) $data['lat'], (float) $data['lng'], 4326);
}
if (array_key_exists('boundary_polygon', $data)) {
$payload['boundary_polygon'] = $data['boundary_polygon']
? $this->buildPolygon($data['boundary_polygon'])
: null;
}
return $payload;
}
private function buildPolygon(array $points): Polygon
{
$ring = array_map(
fn ($p) => new Point((float) $p['lat'], (float) $p['lng'], 4326),
$points,
);
$first = $ring[0];
$last = $ring[count($ring) - 1];
if ($first->latitude !== $last->latitude || $first->longitude !== $last->longitude) {
$ring[] = new Point($first->latitude, $first->longitude, 4326);
}
return new Polygon([new LineString($ring)], 4326);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Http\Requests\Dumpsite;
use App\Models\Dumpsite;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreDumpsiteRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:191'],
'code' => ['required', 'string', 'max:32', Rule::unique('dumpsites', 'code')->whereNull('deleted_at')],
'address_line' => ['required', 'string', 'max:255'],
'city_municipality_id' => ['nullable', 'integer', 'exists:cities_municipalities,id'],
'lat' => ['required', 'numeric', 'between:-90,90'],
'lng' => ['required', 'numeric', 'between:-180,180'],
'boundary_polygon' => ['nullable', 'array', 'min:3'],
'boundary_polygon.*.lat' => ['required_with:boundary_polygon', 'numeric', 'between:-90,90'],
'boundary_polygon.*.lng' => ['required_with:boundary_polygon', 'numeric', 'between:-180,180'],
'accepted_waste_types' => ['nullable', 'array'],
'accepted_waste_types.*' => ['string', 'max:50'],
'operating_hours' => ['nullable', 'array'],
'capacity_tons' => ['nullable', 'integer', 'min:0'],
'contact_person' => ['nullable', 'string', 'max:191'],
'contact_phone' => ['nullable', 'string', 'max:32'],
'permit_number' => ['nullable', 'string', 'max:100'],
'status' => ['nullable', Rule::in([
Dumpsite::STATUS_ACTIVE,
Dumpsite::STATUS_MAINTENANCE,
Dumpsite::STATUS_CLOSED,
])],
];
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Http\Requests\Dumpsite;
use App\Models\Dumpsite;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateDumpsiteRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$id = $this->route('dumpsite')?->id;
return [
'name' => ['sometimes', 'required', 'string', 'max:191'],
'code' => [
'sometimes', 'required', 'string', 'max:32',
Rule::unique('dumpsites', 'code')->whereNull('deleted_at')->ignore($id),
],
'address_line' => ['sometimes', 'required', 'string', 'max:255'],
'city_municipality_id' => ['sometimes', 'nullable', 'integer', 'exists:cities_municipalities,id'],
'lat' => ['sometimes', 'required_with:lng', 'numeric', 'between:-90,90'],
'lng' => ['sometimes', 'required_with:lat', 'numeric', 'between:-180,180'],
'boundary_polygon' => ['sometimes', 'nullable', 'array', 'min:3'],
'boundary_polygon.*.lat' => ['required_with:boundary_polygon', 'numeric', 'between:-90,90'],
'boundary_polygon.*.lng' => ['required_with:boundary_polygon', 'numeric', 'between:-180,180'],
'accepted_waste_types' => ['sometimes', 'nullable', 'array'],
'operating_hours' => ['sometimes', 'nullable', 'array'],
'capacity_tons' => ['sometimes', 'nullable', 'integer', 'min:0'],
'contact_person' => ['sometimes', 'nullable', 'string', 'max:191'],
'contact_phone' => ['sometimes', 'nullable', 'string', 'max:32'],
'permit_number' => ['sometimes', 'nullable', 'string', 'max:100'],
'status' => ['sometimes', Rule::in([
Dumpsite::STATUS_ACTIVE,
Dumpsite::STATUS_MAINTENANCE,
Dumpsite::STATUS_CLOSED,
])],
];
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class DumpsiteResource extends JsonResource
{
public function toArray(Request $request): array
{
$boundaryPoints = null;
if ($this->boundary_polygon) {
$rings = $this->boundary_polygon->getGeometries();
$ring = $rings->first();
if ($ring) {
$boundaryPoints = $ring->getGeometries()
->map(fn ($p) => ['lat' => $p->latitude, 'lng' => $p->longitude])
->all();
}
}
return [
'id' => $this->uuid,
'name' => $this->name,
'code' => $this->code,
'address_line' => $this->address_line,
'coordinates' => $this->coordinates ? [
'lat' => $this->coordinates->latitude,
'lng' => $this->coordinates->longitude,
] : null,
'boundary_polygon' => $boundaryPoints,
'accepted_waste_types' => $this->accepted_waste_types,
'operating_hours' => $this->operating_hours,
'capacity_tons' => $this->capacity_tons,
'permit_number' => $this->permit_number,
'status' => $this->status,
'contact_person' => $this->contact_person,
'contact_phone' => $this->contact_phone,
'city_municipality' => CityMunicipalityResource::make($this->whenLoaded('cityMunicipality')),
'created_at' => $this->created_at?->toIso8601String(),
];
}
}

94
app/Models/Dumpsite.php Normal file
View File

@@ -0,0 +1,94 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use MatanYadaev\EloquentSpatial\Objects\Point;
use MatanYadaev\EloquentSpatial\Objects\Polygon;
use MatanYadaev\EloquentSpatial\Traits\HasSpatial;
class Dumpsite extends Model
{
use HasFactory, HasSpatial, SoftDeletes;
public const STATUS_ACTIVE = 'active';
public const STATUS_MAINTENANCE = 'maintenance';
public const STATUS_CLOSED = 'closed';
protected $fillable = [
'uuid',
'name',
'code',
'address_line',
'city_municipality_id',
'coordinates',
'boundary_polygon',
'accepted_waste_types',
'operating_hours',
'capacity_tons',
'contact_person',
'contact_phone',
'permit_number',
'status',
];
protected function casts(): array
{
return [
'coordinates' => Point::class,
'boundary_polygon' => Polygon::class,
'accepted_waste_types' => 'array',
'operating_hours' => 'array',
'capacity_tons' => 'integer',
];
}
public function getRouteKeyName(): string
{
return 'uuid';
}
protected static function booted(): void
{
static::creating(function (self $d): void {
if (empty($d->uuid)) {
$d->uuid = (string) Str::uuid();
}
});
}
public function cityMunicipality(): BelongsTo
{
return $this->belongsTo(CityMunicipality::class);
}
public function releases(): HasMany
{
return $this->hasMany(DumpsiteRelease::class);
}
/**
* Geofence check: is the given (lat, lng) inside this dumpsite's
* boundary polygon? Returns false if no boundary is configured.
*/
public function containsPoint(float $latitude, float $longitude): bool
{
if (! $this->boundary_polygon) {
return false;
}
$row = DB::selectOne(
'SELECT ST_Contains(boundary_polygon, ST_SRID(POINT(?, ?), 4326)) AS contained
FROM dumpsites WHERE id = ? LIMIT 1',
[$longitude, $latitude, $this->id],
);
return (bool) ($row->contained ?? false);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use MatanYadaev\EloquentSpatial\Objects\Point;
use MatanYadaev\EloquentSpatial\Traits\HasSpatial;
/**
* Records a load released at a dumpsite at the end of a trip. Populated
* by Module 10 (Trips & Timeline). Module 6 only creates the schema.
*/
class DumpsiteRelease extends Model
{
use HasFactory, HasSpatial;
protected $fillable = [
'trip_id',
'dumpsite_id',
'released_at',
'released_by_driver_id',
'weight_kg',
'waste_type_breakdown',
'gate_pass_number',
'dumpsite_attendant_name',
'photo_evidence_path',
'coordinates_at_release',
'notes',
];
protected function casts(): array
{
return [
'released_at' => 'datetime',
'weight_kg' => 'integer',
'waste_type_breakdown' => 'array',
'coordinates_at_release' => Point::class,
];
}
public function dumpsite(): BelongsTo
{
return $this->belongsTo(Dumpsite::class);
}
public function releasedByDriver(): BelongsTo
{
return $this->belongsTo(User::class, 'released_by_driver_id');
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace Database\Factories;
use App\Models\Dumpsite;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
use MatanYadaev\EloquentSpatial\Objects\LineString;
use MatanYadaev\EloquentSpatial\Objects\Point;
use MatanYadaev\EloquentSpatial\Objects\Polygon;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Dumpsite>
*/
class DumpsiteFactory extends Factory
{
protected $model = Dumpsite::class;
public function definition(): array
{
$lat = 14.7;
$lng = 121.1;
$half = 0.003;
return [
'uuid' => (string) Str::uuid(),
'name' => fake()->city().' Dumpsite',
'code' => 'DS-'.strtoupper(Str::random(6)),
'address_line' => fake()->streetAddress(),
'coordinates' => new Point($lat, $lng, 4326),
'boundary_polygon' => new Polygon([new LineString([
new Point($lat - $half, $lng - $half, 4326),
new Point($lat - $half, $lng + $half, 4326),
new Point($lat + $half, $lng + $half, 4326),
new Point($lat + $half, $lng - $half, 4326),
new Point($lat - $half, $lng - $half, 4326),
])], 4326),
'capacity_tons' => 1000,
'status' => Dumpsite::STATUS_ACTIVE,
];
}
}

View File

@@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('dumpsites', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->string('name', 191);
$table->string('code', 32)->unique();
$table->string('address_line', 255);
$table->foreignId('city_municipality_id')
->nullable()
->constrained('cities_municipalities')
->nullOnDelete();
$table->geometry('coordinates', subtype: 'point', srid: 4326);
$table->geometry('boundary_polygon', subtype: 'polygon', srid: 4326)->nullable();
$table->json('accepted_waste_types')->nullable();
$table->json('operating_hours')->nullable();
$table->unsignedInteger('capacity_tons')->nullable();
$table->string('contact_person', 191)->nullable();
$table->string('contact_phone', 32)->nullable();
$table->string('permit_number', 100)->nullable();
$table->enum('status', ['active', 'maintenance', 'closed'])
->default('active')
->index();
$table->timestamps();
$table->softDeletes();
$table->index(['city_municipality_id', 'status']);
});
DB::statement(
'ALTER TABLE dumpsites ADD SPATIAL INDEX dumpsites_coords_spx (coordinates)',
);
}
public function down(): void
{
Schema::dropIfExists('dumpsites');
}
};

View File

@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('dumpsite_releases', function (Blueprint $table) {
$table->id();
// trip_id will reference `trips` once Module 10 lands; add the
// FK constraint there. For now keep it nullable bigint.
$table->unsignedBigInteger('trip_id')->nullable()->index();
$table->foreignId('dumpsite_id')->constrained('dumpsites')->cascadeOnDelete();
$table->timestamp('released_at');
$table->foreignId('released_by_driver_id')
->nullable()
->constrained('users')
->nullOnDelete();
$table->unsignedInteger('weight_kg');
$table->json('waste_type_breakdown')->nullable();
$table->string('gate_pass_number', 100)->nullable();
$table->string('dumpsite_attendant_name', 191)->nullable();
$table->string('photo_evidence_path')->nullable();
$table->geometry('coordinates_at_release', subtype: 'point', srid: 4326)->nullable();
$table->text('notes')->nullable();
$table->timestamps();
$table->index(['dumpsite_id', 'released_at']);
});
}
public function down(): void
{
Schema::dropIfExists('dumpsite_releases');
}
};

View File

@@ -13,6 +13,7 @@ class DatabaseSeeder extends Seeder
AdminUserSeeder::class,
SamplePsgcSeeder::class,
SampleDropOffPointsSeeder::class,
SampleDumpsitesSeeder::class,
]);
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace Database\Seeders;
use App\Models\CityMunicipality;
use App\Models\Dumpsite;
use Illuminate\Database\Seeder;
use MatanYadaev\EloquentSpatial\Objects\LineString;
use MatanYadaev\EloquentSpatial\Objects\Point;
use MatanYadaev\EloquentSpatial\Objects\Polygon;
/**
* Sample dumpsite for development. Real Payatas / Rizal MRF coordinates
* with a small rectangular geofence so geofence-trigger logic in Module
* 10 has something to fire against.
*/
class SampleDumpsitesSeeder extends Seeder
{
public function run(): void
{
$qc = CityMunicipality::where('code', 'QC')->first();
// Old Payatas dumpsite area (closed in real life — used here as a
// sample only). Centered at ~14.7155, 121.1083.
$center = ['lat' => 14.7155, 'lng' => 121.1083];
$half = 0.0035; // ~390m at this latitude
$boundaryPoints = [
new Point($center['lat'] - $half, $center['lng'] - $half, 4326),
new Point($center['lat'] - $half, $center['lng'] + $half, 4326),
new Point($center['lat'] + $half, $center['lng'] + $half, 4326),
new Point($center['lat'] + $half, $center['lng'] - $half, 4326),
new Point($center['lat'] - $half, $center['lng'] - $half, 4326),
];
Dumpsite::updateOrCreate(
['code' => 'DS-PAYATAS-01'],
[
'name' => 'Payatas Sanitary Landfill (sample)',
'address_line' => 'Payatas, Quezon City',
'city_municipality_id' => $qc?->id,
'coordinates' => new Point($center['lat'], $center['lng'], 4326),
'boundary_polygon' => new Polygon([new LineString($boundaryPoints)], 4326),
'accepted_waste_types' => ['general', 'biodegradable', 'residual'],
'operating_hours' => [
'mon' => ['open' => '06:00', 'close' => '22:00'],
'tue' => ['open' => '06:00', 'close' => '22:00'],
'wed' => ['open' => '06:00', 'close' => '22:00'],
'thu' => ['open' => '06:00', 'close' => '22:00'],
'fri' => ['open' => '06:00', 'close' => '22:00'],
'sat' => ['open' => '06:00', 'close' => '18:00'],
'sun' => null,
],
'capacity_tons' => 5000,
'permit_number' => 'DENR-NCR-SAMPLE-2026',
'contact_person' => 'Site Manager',
'contact_phone' => '+63281234567',
'status' => Dumpsite::STATUS_ACTIVE,
],
);
}
}

View File

@@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\Api\V1\Admin\AdminDropOffPointController;
use App\Http\Controllers\Api\V1\Admin\AdminDumpsiteController;
use App\Http\Controllers\Api\V1\Admin\AdminHouseholdController;
use App\Http\Controllers\Api\V1\Admin\AdminUserController;
use App\Http\Controllers\Api\V1\Auth\ForgotPasswordController;
@@ -110,6 +111,17 @@ Route::prefix('admin/drop-off-points')
Route::post('/{drop_off_point}/capacity', [AdminDropOffPointController::class, 'logCapacity'])->name('capacity.store');
});
Route::prefix('admin/dumpsites')
->name('api.v1.admin.dumpsites.')
->middleware(['auth:sanctum', 'role:admin'])
->group(function () {
Route::get('/', [AdminDumpsiteController::class, 'index'])->name('index');
Route::post('/', [AdminDumpsiteController::class, 'store'])->name('store');
Route::get('/{dumpsite}', [AdminDumpsiteController::class, 'show'])->name('show');
Route::patch('/{dumpsite}', [AdminDumpsiteController::class, 'update'])->name('update');
Route::delete('/{dumpsite}', [AdminDumpsiteController::class, 'destroy'])->name('destroy');
});
Route::middleware('auth:sanctum')->prefix('households')->name('api.v1.households.')->group(function () {
Route::post('/{household}/reassign-drop-off', [HouseholdController::class, 'reassignDropOff'])
->name('reassign-drop-off');

View File

@@ -0,0 +1,165 @@
<?php
namespace Tests\Feature\Api\V1\Dumpsite;
use App\Models\Dumpsite;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Database\Seeders\SamplePsgcSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class DumpsiteCrudTest extends TestCase
{
use RefreshDatabase;
private User $admin;
protected function setUp(): void
{
parent::setUp();
$this->seed([RoleSeeder::class, SamplePsgcSeeder::class]);
$this->admin = User::factory()->create([
'role' => User::ROLE_ADMIN,
'status' => User::STATUS_ACTIVE,
]);
}
public function test_admin_can_create_dumpsite_with_boundary(): void
{
Sanctum::actingAs($this->admin);
$response = $this->postJson('/api/v1/admin/dumpsites', [
'name' => 'Test Dumpsite',
'code' => 'DS-TEST-01',
'address_line' => '123 Landfill Rd',
'lat' => 14.7,
'lng' => 121.1,
'boundary_polygon' => [
['lat' => 14.695, 'lng' => 121.095],
['lat' => 14.695, 'lng' => 121.105],
['lat' => 14.705, 'lng' => 121.105],
['lat' => 14.705, 'lng' => 121.095],
],
'accepted_waste_types' => ['general', 'biodegradable'],
'capacity_tons' => 2000,
'permit_number' => 'DENR-2026-001',
]);
$response->assertCreated()
->assertJsonPath('data.code', 'DS-TEST-01')
->assertJsonPath('data.permit_number', 'DENR-2026-001');
$this->assertDatabaseHas('dumpsites', ['code' => 'DS-TEST-01']);
$boundary = $response->json('data.boundary_polygon');
$this->assertCount(5, $boundary, 'Boundary should be auto-closed (4 points -> 5 with first repeated)');
}
public function test_create_rejects_polygon_with_fewer_than_3_points(): void
{
Sanctum::actingAs($this->admin);
$response = $this->postJson('/api/v1/admin/dumpsites', [
'name' => 'Bad', 'code' => 'DS-BAD',
'address_line' => 'X', 'lat' => 14, 'lng' => 121,
'boundary_polygon' => [
['lat' => 14, 'lng' => 121],
['lat' => 14.1, 'lng' => 121.1],
],
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['boundary_polygon']);
}
public function test_admin_can_update_boundary_polygon(): void
{
Sanctum::actingAs($this->admin);
$d = Dumpsite::factory()->create();
$newRing = [
['lat' => 14.0, 'lng' => 121.0],
['lat' => 14.0, 'lng' => 121.1],
['lat' => 14.1, 'lng' => 121.1],
['lat' => 14.1, 'lng' => 121.0],
];
$response = $this->patchJson("/api/v1/admin/dumpsites/{$d->uuid}", [
'boundary_polygon' => $newRing,
]);
$response->assertOk();
$this->assertCount(5, $response->json('data.boundary_polygon'));
}
public function test_admin_can_clear_boundary(): void
{
Sanctum::actingAs($this->admin);
$d = Dumpsite::factory()->create();
$response = $this->patchJson("/api/v1/admin/dumpsites/{$d->uuid}", [
'boundary_polygon' => null,
]);
$response->assertOk()
->assertJsonPath('data.boundary_polygon', null);
}
public function test_admin_can_list_dumpsites_with_filter(): void
{
Sanctum::actingAs($this->admin);
Dumpsite::factory()->create(['status' => 'active', 'code' => 'DS-A']);
Dumpsite::factory()->create(['status' => 'closed', 'code' => 'DS-B']);
$response = $this->getJson('/api/v1/admin/dumpsites?status=active');
$response->assertOk();
$codes = collect($response->json('data'))->pluck('code')->all();
$this->assertContains('DS-A', $codes);
$this->assertNotContains('DS-B', $codes);
}
public function test_admin_can_show_and_delete_dumpsite(): void
{
Sanctum::actingAs($this->admin);
$d = Dumpsite::factory()->create();
$this->getJson("/api/v1/admin/dumpsites/{$d->uuid}")
->assertOk()
->assertJsonPath('data.code', $d->code);
$this->deleteJson("/api/v1/admin/dumpsites/{$d->uuid}")->assertOk();
$this->assertSoftDeleted('dumpsites', ['id' => $d->id]);
}
public function test_resident_blocked(): void
{
$resident = User::factory()->create([
'role' => User::ROLE_RESIDENT,
'status' => User::STATUS_ACTIVE,
]);
Sanctum::actingAs($resident);
$this->getJson('/api/v1/admin/dumpsites')->assertStatus(403);
}
public function test_unauthed_blocked(): void
{
$this->getJson('/api/v1/admin/dumpsites')->assertStatus(401);
}
public function test_duplicate_code_rejected(): void
{
Sanctum::actingAs($this->admin);
Dumpsite::factory()->create(['code' => 'DS-DUP']);
$response = $this->postJson('/api/v1/admin/dumpsites', [
'name' => 'Other', 'code' => 'DS-DUP', 'address_line' => 'X',
'lat' => 14, 'lng' => 121,
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['code']);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace Tests\Feature\Api\V1\Dumpsite;
use App\Models\Dumpsite;
use Database\Seeders\RoleSeeder;
use Database\Seeders\SampleDumpsitesSeeder;
use Database\Seeders\SamplePsgcSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class DumpsiteGeofenceTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed([RoleSeeder::class, SamplePsgcSeeder::class, SampleDumpsitesSeeder::class]);
}
public function test_contains_point_returns_true_inside_boundary(): void
{
$d = Dumpsite::where('code', 'DS-PAYATAS-01')->firstOrFail();
$this->assertTrue($d->containsPoint(14.7155, 121.1083));
}
public function test_contains_point_returns_false_outside_boundary(): void
{
$d = Dumpsite::where('code', 'DS-PAYATAS-01')->firstOrFail();
$this->assertFalse($d->containsPoint(14.5, 121.0));
}
public function test_contains_point_false_when_no_boundary(): void
{
$d = Dumpsite::factory()->create(['boundary_polygon' => null]);
$this->assertFalse($d->containsPoint(14.7, 121.1));
}
}