diff --git a/CLAUDE.md b/CLAUDE.md index edb7cb8..ce62938 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/app/Http/Controllers/Api/V1/Admin/AdminDumpsiteController.php b/app/Http/Controllers/Api/V1/Admin/AdminDumpsiteController.php new file mode 100644 index 0000000..cdf8779 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/AdminDumpsiteController.php @@ -0,0 +1,156 @@ +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); + } +} diff --git a/app/Http/Requests/Dumpsite/StoreDumpsiteRequest.php b/app/Http/Requests/Dumpsite/StoreDumpsiteRequest.php new file mode 100644 index 0000000..1363567 --- /dev/null +++ b/app/Http/Requests/Dumpsite/StoreDumpsiteRequest.php @@ -0,0 +1,42 @@ + ['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, + ])], + ]; + } +} diff --git a/app/Http/Requests/Dumpsite/UpdateDumpsiteRequest.php b/app/Http/Requests/Dumpsite/UpdateDumpsiteRequest.php new file mode 100644 index 0000000..6907989 --- /dev/null +++ b/app/Http/Requests/Dumpsite/UpdateDumpsiteRequest.php @@ -0,0 +1,46 @@ +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, + ])], + ]; + } +} diff --git a/app/Http/Resources/DumpsiteResource.php b/app/Http/Resources/DumpsiteResource.php new file mode 100644 index 0000000..9115b4d --- /dev/null +++ b/app/Http/Resources/DumpsiteResource.php @@ -0,0 +1,44 @@ +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(), + ]; + } +} diff --git a/app/Models/Dumpsite.php b/app/Models/Dumpsite.php new file mode 100644 index 0000000..c123f9d --- /dev/null +++ b/app/Models/Dumpsite.php @@ -0,0 +1,94 @@ + 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); + } +} diff --git a/app/Models/DumpsiteRelease.php b/app/Models/DumpsiteRelease.php new file mode 100644 index 0000000..3569721 --- /dev/null +++ b/app/Models/DumpsiteRelease.php @@ -0,0 +1,52 @@ + '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'); + } +} diff --git a/database/factories/DumpsiteFactory.php b/database/factories/DumpsiteFactory.php new file mode 100644 index 0000000..88f13f9 --- /dev/null +++ b/database/factories/DumpsiteFactory.php @@ -0,0 +1,42 @@ + + */ +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, + ]; + } +} diff --git a/database/migrations/2026_05_04_100000_create_dumpsites_table.php b/database/migrations/2026_05_04_100000_create_dumpsites_table.php new file mode 100644 index 0000000..2332f28 --- /dev/null +++ b/database/migrations/2026_05_04_100000_create_dumpsites_table.php @@ -0,0 +1,48 @@ +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'); + } +}; diff --git a/database/migrations/2026_05_04_100001_create_dumpsite_releases_table.php b/database/migrations/2026_05_04_100001_create_dumpsite_releases_table.php new file mode 100644 index 0000000..68e5e89 --- /dev/null +++ b/database/migrations/2026_05_04_100001_create_dumpsite_releases_table.php @@ -0,0 +1,39 @@ +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'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 0759cdf..8513312 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -13,6 +13,7 @@ class DatabaseSeeder extends Seeder AdminUserSeeder::class, SamplePsgcSeeder::class, SampleDropOffPointsSeeder::class, + SampleDumpsitesSeeder::class, ]); } } diff --git a/database/seeders/SampleDumpsitesSeeder.php b/database/seeders/SampleDumpsitesSeeder.php new file mode 100644 index 0000000..ae7546d --- /dev/null +++ b/database/seeders/SampleDumpsitesSeeder.php @@ -0,0 +1,62 @@ +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, + ], + ); + } +} diff --git a/routes/api.php b/routes/api.php index 4fa202c..263564b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,6 +1,7 @@ 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'); diff --git a/tests/Feature/Api/V1/Dumpsite/DumpsiteCrudTest.php b/tests/Feature/Api/V1/Dumpsite/DumpsiteCrudTest.php new file mode 100644 index 0000000..bdccd6b --- /dev/null +++ b/tests/Feature/Api/V1/Dumpsite/DumpsiteCrudTest.php @@ -0,0 +1,165 @@ +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']); + } +} diff --git a/tests/Feature/Api/V1/Dumpsite/DumpsiteGeofenceTest.php b/tests/Feature/Api/V1/Dumpsite/DumpsiteGeofenceTest.php new file mode 100644 index 0000000..f522ca0 --- /dev/null +++ b/tests/Feature/Api/V1/Dumpsite/DumpsiteGeofenceTest.php @@ -0,0 +1,42 @@ +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)); + } +}