diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index d7b52b16e..184c287e7 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -6,10 +6,18 @@ jobs: lintTest: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v4 - name: Install deps run: make composer - name: Bring up Docker run: make up - name: Lint & Test run: make test + - name: Store logs + uses: actions/upload-artifact@v4 + if: always() + with: + name: PHP Logs + path: storage/logs/ + if-no-files-found: ignore diff --git a/app/Http/Controllers/V2/Geometry/GeometryController.php b/app/Http/Controllers/V2/Geometry/GeometryController.php index 3981d6596..664d85d79 100644 --- a/app/Http/Controllers/V2/Geometry/GeometryController.php +++ b/app/Http/Controllers/V2/Geometry/GeometryController.php @@ -3,10 +3,12 @@ namespace App\Http\Controllers\V2\Geometry; use App\Http\Controllers\Controller; +use App\Http\Requests\V2\Geometry\StoreGeometryRequest; use App\Models\V2\PolygonGeometry; use App\Models\V2\Sites\Site; use App\Services\PolygonService; use App\Validators\SitePolygonValidator; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\App; @@ -36,6 +38,10 @@ class GeometryController extends Controller 'DATA', ]; + /** + * @throws AuthorizationException + * @deprecated Use POST /api/v2/geometry (include site id in the properties of each feature) + */ public function storeSiteGeometry(Request $request, Site $site): JsonResponse { $this->authorize('uploadPolygons', $site); @@ -44,12 +50,35 @@ public function storeSiteGeometry(Request $request, Site $site): JsonResponse 'geometries' => 'required|array', ]); + $geometries = $request->input('geometries'); + data_set($geometries, '*.features.*.properties.site_id', $site->uuid); + + return response()->json($this->storeAndValidateGeometries($geometries), 201); + } + + /** + * @throws AuthorizationException + * @throws ValidationException + */ + public function storeGeometry(StoreGeometryRequest $request): JsonResponse + { + $request->validateGeometries(); + foreach ($request->getSites() as $site) { + $this->authorize('uploadPolygons', $site); + } + + return response()->json($this->storeAndValidateGeometries($request->getGeometries()), 201); + } + + protected function storeAndValidateGeometries($geometries): array + { /** @var PolygonService $service */ $service = App::make(PolygonService::class); $polygonUuids = []; - foreach ($request->input('geometries') as $geometry) { - // We expect single polys on this endpoint, so just pull the first uuid returned - $polygonUuids[] = $service->createGeojsonModels($geometry, ['site_id' => $site->uuid])[0]; + foreach ($geometries as $geometry) { + // In this controller we require either single polys or a collection of Points, which get turned into a + // single poly, so just pull the first UUID returned + $polygonUuids[] = $service->createGeojsonModels($geometry)[0]; } // Do the validation in a separate step so that all of the existing polygons are taken into account @@ -62,7 +91,7 @@ public function storeSiteGeometry(Request $request, Site $site): JsonResponse } } - return response()->json(['polygon_uuids' => $polygonUuids, 'errors' => $polygonErrors], 201); + return ['polygon_uuids' => $polygonUuids, 'errors' => $polygonErrors]; } public function validateGeometries(Request $request): JsonResponse @@ -151,6 +180,11 @@ public function updateGeometry(Request $request, PolygonGeometry $polygon): Json protected function runStoredGeometryValidations(string $polygonUuid): array { + // TODO: remove when the point transformation ticket is complete + if ($polygonUuid == PolygonService::TEMP_FAKE_POLYGON_UUID) { + return []; + } + /** @var PolygonService $service */ $service = App::make(PolygonService::class); $data = ['geometry' => $polygonUuid]; diff --git a/app/Http/Requests/V2/Geometry/StoreGeometryRequest.php b/app/Http/Requests/V2/Geometry/StoreGeometryRequest.php new file mode 100644 index 000000000..41a587d40 --- /dev/null +++ b/app/Http/Requests/V2/Geometry/StoreGeometryRequest.php @@ -0,0 +1,97 @@ + 'required|array', + 'geometries.*.features' => 'required|array|min:1', + 'geometries.*.features.*.geometry.type' => 'required|string|in:Point,Polygon', + ]; + } + + public function getGeometries(): array + { + if (! empty($this->geometries)) { + return $this->geometries; + } + + return $this->geometries = $this->input('geometries'); + } + + public function getSiteIds(): array + { + if (! empty($this->siteIds)) { + return $this->siteIds; + } + + return $this->siteIds = collect( + data_get($this->getGeometries(), '*.features.*.properties.site_id') + )->unique()->filter()->toArray(); + } + + public function getSites(): array + { + if (! empty($this->sites)) { + return $this->sites; + } + + return $this->sites = Site::whereIn('uuid', $this->getSiteIds())->get()->all(); + } + + /** + * @throws ValidationException + */ + public function validateGeometries(): void + { + // Make sure the data is coherent. Since we accept both Polygons and Points on this request, we have to + // validate each geometry individually, rather than in the rules above + foreach ($this->getGeometries() as $geometry) { + $type = data_get($geometry, 'features.0.geometry.type'); + if ($type == 'Polygon') { + // Require that we only have one geometry and that it has a site_id specified + Validator::make($geometry, [ + 'features' => 'required|array|size:1', + 'features.0.properties.site_id' => 'required|string', + ])->validate(); + + // This is guaranteed to be Point given the rules specified in rules() + } else { + // Require that all geometries in the collection are valid points, include estimated area, and that the + // collection has exactly one unique site id. + $siteIds = collect(data_get($geometry, 'features.*.properties.site_id')) + ->unique()->filter()->toArray(); + Validator::make(['geometry' => $geometry, 'site_ids' => $siteIds], [ + 'geometry.features.*.geometry.type' => 'required|string|in:Point', + 'geometry.features.*.geometry.coordinates' => 'required|array|size:2', + 'geometry.features.*.properties.est_area' => 'required|numeric|min:1', + 'site_ids' => 'required|array|size:1', + ])->validate(); + } + } + + // Structure this as a validation exception just to make the return shape of this endpoint consistent. + Validator::make(['num_sites' => count($this->getSites()), 'num_site_ids' => count($this->getSiteIds())], [ + 'num_sites' => 'same:num_site_ids', + ])->validate(); + } +} diff --git a/app/Models/Traits/HasGeometry.php b/app/Models/Traits/HasGeometry.php new file mode 100644 index 000000000..4586ebc25 --- /dev/null +++ b/app/Models/Traits/HasGeometry.php @@ -0,0 +1,50 @@ +selectRaw('ST_AsGeoJSON(geom) as geojson_string') + ->first() + ?->geojson_string; + + return $geojson_string == null ? null : json_decode($geojson_string, true); + } + + public function getGeoJsonAttribute(): array + { + return self::getGeoJson($this->uuid); + } + + public static function getGeometryType(string $uuid): ?string + { + return static::isUuid($uuid) + ->selectRaw('ST_GeometryType(geom) as geometry_type_string') + ->first() + ?->geometry_type_string; + } + + public function getGeometryTypeAttribute(): string + { + return self::getGeometryType($this->uuid); + } + + public static function getDbGeometry(string $uuid) + { + return static::isUuid($uuid) + ->selectRaw('ST_Area(geom) AS area, ST_Y(ST_Centroid(geom)) AS latitude') + ->first(); + } + + public function getDbGeometryAttribute() + { + return self::getDbGeometry($this->uuid); + } +} diff --git a/app/Models/V2/PointGeometry.php b/app/Models/V2/PointGeometry.php new file mode 100644 index 000000000..f0da5fed2 --- /dev/null +++ b/app/Models/V2/PointGeometry.php @@ -0,0 +1,40 @@ +hasOne(User::class, 'id', 'created_by'); + } + + public function lastModifiedBy(): HasOne + { + return $this->hasOne(User::class, 'id', 'last_modified_by'); + } +} diff --git a/app/Models/V2/PolygonGeometry.php b/app/Models/V2/PolygonGeometry.php index 842ac30cf..199ac5c6e 100644 --- a/app/Models/V2/PolygonGeometry.php +++ b/app/Models/V2/PolygonGeometry.php @@ -2,6 +2,7 @@ namespace App\Models\V2; +use App\Models\Traits\HasGeometry; use App\Models\Traits\HasUuid; use App\Models\V2\Sites\CriteriaSite; use App\Models\V2\Sites\SitePolygon; @@ -12,15 +13,12 @@ use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\SoftDeletes; -/** - * @method static isUuid($uuid) - * @property mixed $uuid - */ class PolygonGeometry extends Model { use HasUuid; use SoftDeletes; use HasFactory; + use HasGeometry; protected $table = 'polygon_geometry'; @@ -49,44 +47,4 @@ public function createdBy(): HasOne { return $this->hasOne(User::class, 'id', 'created_by'); } - - public static function getGeoJson(string $uuid): ?array - { - $geojson_string = PolygonGeometry::isUuid($uuid) - ->selectRaw('ST_AsGeoJSON(geom) as geojson_string') - ->first() - ?->geojson_string; - - return $geojson_string == null ? null : json_decode($geojson_string, true); - } - - public function getGeoJsonAttribute(): array - { - return self::getGeoJson($this->uuid); - } - - public static function getGeometryType(string $uuid): ?string - { - return PolygonGeometry::isUuid($uuid) - ->selectRaw('ST_GeometryType(geom) as geometry_type_string') - ->first() - ?->geometry_type_string; - } - - public function getGeometryTypeAttribute(): string - { - return self::getGeometryType($this->uuid); - } - - public static function getDbGeometry(string $uuid) - { - return PolygonGeometry::isUuid($uuid) - ->selectRaw('ST_Area(geom) AS area, ST_Y(ST_Centroid(geom)) AS latitude') - ->first(); - } - - public function getDbGeometryAttribute() - { - return self::getDbGeometry($this->uuid); - } } diff --git a/app/Services/PolygonService.php b/app/Services/PolygonService.php index 89193f23e..cddbdd5bd 100644 --- a/app/Services/PolygonService.php +++ b/app/Services/PolygonService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Models\V2\PointGeometry; use App\Models\V2\PolygonGeometry; use App\Models\V2\Sites\CriteriaSite; use App\Models\V2\Sites\SitePolygon; @@ -24,8 +25,26 @@ class PolygonService public const SCHEMA_CRITERIA_ID = 13; public const DATA_CRITERIA_ID = 14; + // TODO: Remove this const and its usages when the point transformation ticket is complete. + public const TEMP_FAKE_POLYGON_UUID = 'temp_fake_polygon_uuid'; + + protected const POINT_PROPERTIES = [ + 'site_id', + 'poly_name', + 'plantstart', + 'plantend', + 'practice', + 'target_sys', + 'distr', + 'num_trees', + ]; + public function createGeojsonModels($geojson, $sitePolygonProperties = []): array { + if (data_get($geojson, 'features.0.geometry.type') == 'Point') { + return [$this->transformAndStorePoints($geojson, $sitePolygonProperties)]; + } + $uuids = []; foreach ($geojson['features'] as $feature) { if ($feature['geometry']['type'] === 'Polygon') { @@ -89,12 +108,21 @@ public function updateGeojsonModels(PolygonGeometry $polygonGeometry, array $geo )); } + protected function getGeom(array $geometry) + { + // Convert geometry to GeoJSON string + $geojson = json_encode(['type' => 'Feature', 'geometry' => $geometry, 'crs' => ['type' => 'name', 'properties' => ['name' => 'EPSG:4326']]]); + + // get GeoJSON data in the database + return DB::raw("ST_GeomFromGeoJSON('$geojson')"); + } + protected function getGeomAndArea(array $geometry): array { // Convert geometry to GeoJSON string $geojson = json_encode(['type' => 'Feature', 'geometry' => $geometry, 'crs' => ['type' => 'name', 'properties' => ['name' => 'EPSG:4326']]]); - // Update GeoJSON data in the database + // Get GeoJSON data in the database $geom = DB::raw("ST_GeomFromGeoJSON('$geojson')"); $areaSqDegrees = DB::selectOne("SELECT ST_Area(ST_GeomFromGeoJSON('$geojson')) AS area")->area; $latitude = DB::selectOne("SELECT ST_Y(ST_Centroid(ST_GeomFromGeoJSON('$geojson'))) AS latitude")->latitude; @@ -119,6 +147,16 @@ protected function insertSinglePolygon(array $geometry): array return ['uuid' => $polygonGeometry->uuid, 'area' => $dbGeometry['area']]; } + protected function insertSinglePoint(array $feature): string + { + return PointGeometry::create([ + 'geom' => $this->getGeom($feature['geometry']), + 'est_area' => data_get($feature, 'properties.est_area'), + 'created_by' => Auth::user()?->id, + 'last_modified_by' => Auth::user()?->id, + ])->uuid; + } + protected function insertSitePolygon(string $polygonUuid, array $properties) { try { @@ -180,4 +218,31 @@ protected function validateSitePolygonProperties(string $polygonUuid, array $pro 'est_area' => $properties['area'] ?? null, ]; } + + /** + * Each Point must have an est_area property, and at least one of them must have a site_id as well as + * all of the properties listed in SitePolygonValidator::SCHEMA for the resulting polygon to pass validation. + * + * @return string UUID of resulting PolygonGeometry + */ + protected function transformAndStorePoints($geojson, $sitePolygonProperties): string + { + $pointUuids = []; + foreach ($geojson['features'] as $feature) { + $pointUuids[] = $this->insertSinglePoint($feature); + } + + $properties = $sitePolygonProperties; + foreach (self::POINT_PROPERTIES as $property) { + $properties[$property] = collect(data_get($geojson, "features.*.properties.$property"))->filter()->first(); + } + + // TODO: + // * transform points into a polygon + // * Insert the polygon into PolygonGeometry + // * Create the SitePolygon using the data in $properties (including $properties['site_id'] to identify the site) + // * Return the PolygonGeometry's real UUID instead of this fake return + + return self::TEMP_FAKE_POLYGON_UUID; + } } diff --git a/app/Validators/Extensions/Polygons/WithinCountry.php b/app/Validators/Extensions/Polygons/WithinCountry.php index 98571c3ba..4e6e71767 100644 --- a/app/Validators/Extensions/Polygons/WithinCountry.php +++ b/app/Validators/Extensions/Polygons/WithinCountry.php @@ -33,6 +33,9 @@ public static function getIntersectionData(string $polygonUuid): array if ($geometry === null) { return ['valid' => false, 'status' => 404, 'error' => 'Geometry not found']; } + if ($geometry->db_geometry->area == 0) { + return ['valid' => false, 'status' => 500, 'error' => 'Geometry invalid']; + } $sitePolygonData = SitePolygon::forPolygonGeometry($polygonUuid)->select('site_id')->first(); if ($sitePolygonData == null) { diff --git a/openapi-src/V2/definitions/GeoJSON.yml b/openapi-src/V2/definitions/GeoJSON.yml index 0c42310e6..ca7ad6bd7 100644 --- a/openapi-src/V2/definitions/GeoJSON.yml +++ b/openapi-src/V2/definitions/GeoJSON.yml @@ -38,15 +38,9 @@ properties: properties: type: type: string - enum: [Polygon] + enum: [Polygon, Point] coordinates: type: array - items: - type: array - items: - type: array - items: - type: number diff --git a/openapi-src/V2/definitions/SiteGeometryPost.yml b/openapi-src/V2/definitions/GeometryPost.yml similarity index 100% rename from openapi-src/V2/definitions/SiteGeometryPost.yml rename to openapi-src/V2/definitions/GeometryPost.yml diff --git a/openapi-src/V2/definitions/_index.yml b/openapi-src/V2/definitions/_index.yml index fcff7d24b..3a8e731a1 100644 --- a/openapi-src/V2/definitions/_index.yml +++ b/openapi-src/V2/definitions/_index.yml @@ -274,5 +274,5 @@ WorkdayDemographic: $ref: './WorkdayDemographic.yml' GeoJSON: $ref: './GeoJSON.yml' -SiteGeometryPost: - $ref: './SiteGeometryPost.yml' +GeometryPost: + $ref: './GeometryPost.yml' diff --git a/openapi-src/V2/paths/Geometry/post-v2-geometry.yml b/openapi-src/V2/paths/Geometry/post-v2-geometry.yml new file mode 100644 index 000000000..6c68ff071 --- /dev/null +++ b/openapi-src/V2/paths/Geometry/post-v2-geometry.yml @@ -0,0 +1,27 @@ +summary: Upload bulk geometry +operationId: post-v2-geometry +tags: + - V2 Geometry +description: | + Takes an array of geometries and adds them to the sites indicated. For each geometry, it may either be a + single Polygon (in which case the site_id is required), or it may be a FeatureCollection of Points. If a geometry + is a collection of points, then the site_id must be present on at least one of the points. If it is present on + multiple points, all points within a given collection must have the same site_id. + + For additional properties (plantstart, num_trees, etc) on Point geometries, if the properties are present on + multiple Points, the first non-null value for each is used. +parameters: + - in: body + name: body + schema: + type: object + properties: + geometries: + type: array + items: + $ref: '../../definitions/_index.yml#/GeoJSON' +responses: + '201': + description: Created + schema: + $ref: '../../definitions/_index.yml#/GeometryPost' diff --git a/openapi-src/V2/paths/Sites/post-v2-sites-uuid-geometry.yml b/openapi-src/V2/paths/Sites/post-v2-sites-uuid-geometry.yml index d355001a6..c2cb3fe98 100644 --- a/openapi-src/V2/paths/Sites/post-v2-sites-uuid-geometry.yml +++ b/openapi-src/V2/paths/Sites/post-v2-sites-uuid-geometry.yml @@ -2,6 +2,8 @@ summary: Upload bulk geometry to a specific site. operationId: post-v2-sites-uuid-geometry tags: - V2 Sites +deprecated: true +description: Use POST /api/v2/geometry instead (and include the site ID in the polygon properties) parameters: - type: string name: UUID @@ -20,4 +22,4 @@ responses: '201': description: Created schema: - $ref: '../../definitions/_index.yml#/SiteGeometryPost' + $ref: '../../definitions/_index.yml#/GeometryPost' diff --git a/openapi-src/V2/paths/_index.yml b/openapi-src/V2/paths/_index.yml index e1e615422..6de1735d2 100644 --- a/openapi-src/V2/paths/_index.yml +++ b/openapi-src/V2/paths/_index.yml @@ -2518,6 +2518,8 @@ post: $ref: './Geometry/post-v2-geometry-validate.yml' /v2/geometry: + post: + $ref: './Geometry/post-v2-geometry.yml' delete: $ref: './Geometry/delete-v2-geometry.yml' /v2/geometry/{UUID}: diff --git a/openapi-src/v2.yml b/openapi-src/v2.yml index fdfd11f1c..9f91335c6 100644 --- a/openapi-src/v2.yml +++ b/openapi-src/v2.yml @@ -23,6 +23,7 @@ tags: - name: V2 Admin - name: V2 Application - name: V2 Disturbance + - name: V2 Geometry - name: V2 Invasive - name: V2 Project Developer - name: V2 Projects diff --git a/resources/docs/swagger-v2.yml b/resources/docs/swagger-v2.yml index aa91d215f..0cf2e0ac7 100644 --- a/resources/docs/swagger-v2.yml +++ b/resources/docs/swagger-v2.yml @@ -23,6 +23,7 @@ tags: - name: V2 Admin - name: V2 Application - name: V2 Disturbance + - name: V2 Geometry - name: V2 Invasive - name: V2 Project Developer - name: V2 Projects @@ -44021,15 +44022,10 @@ definitions: type: string enum: - Polygon + - Point coordinates: type: array - items: - type: array - items: - type: array - items: - type: number - SiteGeometryPost: + GeometryPost: title: SiteGeometryPost type: object properties: @@ -93103,6 +93099,8 @@ paths: operationId: post-v2-sites-uuid-geometry tags: - V2 Sites + deprecated: true + description: Use POST /api/v2/geometry instead (and include the site ID in the polygon properties) parameters: - type: string name: UUID @@ -93160,14 +93158,9 @@ paths: type: string enum: - Polygon + - Point coordinates: type: array - items: - type: array - items: - type: array - items: - type: number responses: '201': description: Created @@ -93777,14 +93770,9 @@ paths: type: string enum: - Polygon + - Point coordinates: type: array - items: - type: array - items: - type: array - items: - type: number responses: '200': description: 'OK: No validation errors occurred with the supplied geometries' @@ -93825,6 +93813,111 @@ paths: type: string description: A path string indicating where the error occurred. /v2/geometry: + post: + summary: Upload bulk geometry + operationId: post-v2-geometry + tags: + - V2 Geometry + description: | + Takes an array of geometries and adds them to the sites indicated. For each geometry, it may either be a + single Polygon (in which case the site_id is required), or it may be a FeatureCollection of Points. If a geometry + is a collection of points, then the site_id must be present on at least one of the points. If it is present on + multiple points, all points within a given collection must have the same site_id. + + For additional properties (plantstart, num_trees, etc) on Point geometries, if the properties are present on + multiple Points, the first non-null value for each is used. + parameters: + - in: body + name: body + schema: + type: object + properties: + geometries: + type: array + items: + title: GeoJSON + type: object + properties: + type: + type: string + enum: + - FeatureCollection + features: + type: array + items: + type: object + properties: + type: + type: string + enum: + - Feature + properties: + type: object + properties: + poly_name: + type: string + plantstart: + type: string + format: date + plantend: + type: string + format: date + practice: + type: string + target_sys: + type: string + distr: + type: string + num_trees: + type: number + site_id: + type: string + geometry: + type: object + properties: + type: + type: string + enum: + - Polygon + - Point + coordinates: + type: array + responses: + '201': + description: Created + schema: + title: SiteGeometryPost + type: object + properties: + polygon_uuids: + type: array + items: + type: string + description: The UUIDs generated by the system for the uploaded polygons. They are in the same order as the polygons in the request payload. + errors: + type: object + description: Mapping of geometry UUID to the errors associated with the geometry. The geometry was saved in the DB and must be updated instead of created once the issues are resolved. + additionalProperties: + type: array + items: + type: object + properties: + key: + type: string + enum: + - OVERLAPPING_POLYGON + - SELF_INTERSECTION + - COORDINATE_SYSTEM + - SIZE_LIMIT + - WITHIN_COUNTRY + - SPIKE + - GEOMETRY_TYPE + - TOTAL_AREA_EXPECTED + - TABLE_SCHEMA + - DATA_COMPLETED + message: + type: string + description: Human readable string in English to describe the error. delete: summary: Bulk delete geometries operationId: delete-v2-geometry @@ -93905,14 +93998,9 @@ paths: type: string enum: - Polygon + - Point coordinates: type: array - items: - type: array - items: - type: array - items: - type: number responses: '200': description: 'OK: Update was applied.' diff --git a/routes/api_v2.php b/routes/api_v2.php index 4261cc69d..cda46e83c 100644 --- a/routes/api_v2.php +++ b/routes/api_v2.php @@ -552,10 +552,12 @@ Route::get('/image/locations', SiteImageLocationsController::class); Route::delete('/', SoftDeleteSiteController::class); Route::get('/export', ExportAllSiteDataAsProjectDeveloperController::class); + // deprecated, use POST api/v2/geometry instead (include site_id in the geometry's properties Route::post('/geometry', [GeometryController::class, 'storeSiteGeometry']); }); Route::prefix('geometry')->group(function () { + Route::post('', [GeometryController::class, 'storeGeometry']); Route::post('/validate', [GeometryController::class, 'validateGeometries']); Route::delete('', [GeometryController::class, 'deleteGeometries']); Route::put('{polygon}', [GeometryController::class, 'updateGeometry']); diff --git a/tests/V2/Geometry/GeometryControllerTest.php b/tests/V2/Geometry/GeometryControllerTest.php new file mode 100644 index 000000000..d304f5709 --- /dev/null +++ b/tests/V2/Geometry/GeometryControllerTest.php @@ -0,0 +1,140 @@ +serviceAccount()->create(); + Artisan::call('v2migration:roles'); + if (WorldCountryGeneralized::count() == 0) { + $this->seed(WorldCountriesGeneralizedTableSeeder::class); + } + + // No geometry + $this->assertCreateError('features field is required', $service, [ + $this->fakeGeojson([]), + ]); + + // Invalid geometry type + $this->assertCreateError('type is invalid', $service, [ + $this->fakeGeojson([['type' => 'Feature', 'geometry' => ['type' => 'MultiPolygon']]]), + ]); + + // Multiple polygons + $this->assertCreateError('features must contain 1 item', $service, [ + $this->fakeGeojson([$this->fakePolygon(), $this->fakePolygon()]), + ]); + + // Missing site id + $this->assertCreateError('site id field is required', $service, [ + $this->fakeGeojson([$this->fakePolygon()]), + ]); + + // Mixing polygons and points + $this->assertCreateError('type is invalid', $service, [ + $this->fakeGeojson([$this->fakePoint(), $this->fakePolygon()]), + ]); + + // Multiple site ids + $this->assertCreateError('site ids must contain 1 item', $service, [ + $this->fakeGeojson([$this->fakePoint(['site_id' => '123']), $this->fakePoint(['site_id' => '456'])]), + ]); + + // Missing est area + $this->assertCreateError('est_area field is required', $service, [ + $this->fakeGeojson([$this->fakePoint(['site_id' => '123'])]), + ]); + + // Invalid est area + $this->assertCreateError('est_area must be at least 1', $service, [ + $this->fakeGeojson([$this->fakePoint(['site_id' => '123', 'est_area' => -1])]), + ]); + + // Not all sites found + $site = Site::factory()->create(); + $this->assertCreateError('num sites and num site ids must match', $service, [ + $this->fakeGeojson([$this->fakePolygon(['site_id' => $site->uuid])]), + $this->fakeGeojson([$this->fakePolygon(['site_id' => 'asdf'])]), + ]); + + // Valid payload + $this->actingAs($service) + ->postJson('/api/v2/geometry', ['geometries' => [ + $this->fakeGeojson([$this->fakePolygon(['site_id' => $site->uuid])]), + $this->fakeGeojson([ + $this->fakePoint(['site_id' => $site->uuid, 'est_area' => 10]), + $this->fakePoint(['est_area' => 20]), + ]), + ]]) + ->assertStatus(201); + } + + protected function assertCreateError(string $expected, $user, $geometries): void + { + $content = $this + ->actingAs($user) + ->postJson('/api/v2/geometry', ['geometries' => $geometries]) + ->assertStatus(422) + ->json(); + $this->assertStringContainsString($expected, implode('|', data_get($content, 'errors.*.detail'))); + } + + protected function fakeGeojson($features): array + { + return [ + 'type' => 'FeatureCollection', + 'features' => $features, + ]; + } + + protected function fakePoint($properties = []): array + { + return [ + 'type' => 'Feature', + 'geometry' => [ + 'type' => 'Point', + 'coordinates' => [45, -120], + ], + 'properties' => $properties, + ]; + } + + protected function fakePolygon($properties = []): array + { + return [ + 'type' => 'Feature', + 'geometry' => [ + 'type' => 'Polygon', + 'coordinates' => [[ + [ + 40.405701461490054, + -12.96724571876176, + ], + [ + 40.40517180334834, + -12.965903759897898, + ], + [ + 40.405701461490054, + -12.96724571876176, + ], + ]], + ], + 'properties' => $properties, + ]; + } +}