From b700f1e64a950607c53940156b478a1b56dc24ab Mon Sep 17 00:00:00 2001 From: horatio Date: Thu, 25 Jul 2024 09:57:53 -0400 Subject: [PATCH] 342 OpenAPI Implementation for findCapitalProjectGeoJsonByManagingCodeCapitalProjectId endpoint - write controller, module, repository, respo schema, service - e2e and service unit tests - use m_pnt and m_ply for cp geojson (+ multipoint regex fix) Co-authored-by: horatio Co-authored-by: tangoyankee --- openapi/openapi.yaml | 2 +- .../capital-project.controller.ts | 17 ++++ .../capital-project.repository.schema.ts | 29 ++++++- .../capital-project.repository.ts | 64 +++++++++++++++ .../capital-project.service.spec.ts | 33 ++++++++ .../capital-project.service.ts | 38 +++++++++ src/schema/capital-commitment-fund.ts | 2 +- src/schema/geometry.ts | 6 ++ .../capital-project.e2e-spec.ts | 81 +++++++++++++++++++ .../capital-project.repository.mock.ts | 27 +++++++ 10 files changed, 295 insertions(+), 4 deletions(-) diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 89fb3643..2f5c31e3 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -173,7 +173,7 @@ paths: $ref: "#/components/responses/InternalServerError" /capital-projects/{managingCode}/{capitalProjectId}/geojson: get: - summary: 🚧 Find a single capital project as a geojson feature + summary: Find a single capital project as a geojson feature operationId: findCapitalProjectGeoJsonByManagingCodeCapitalProjectId tags: - Capital Projects diff --git a/src/capital-project/capital-project.controller.ts b/src/capital-project/capital-project.controller.ts index 942fde2e..91cc9dbc 100644 --- a/src/capital-project/capital-project.controller.ts +++ b/src/capital-project/capital-project.controller.ts @@ -10,9 +10,11 @@ import { Response } from "express"; import { FindCapitalCommitmentsByManagingCodeCapitalProjectIdPathParams, FindCapitalProjectByManagingCodeCapitalProjectIdPathParams, + FindCapitalProjectGeoJsonByManagingCodeCapitalProjectIdPathParams, FindCapitalProjectTilesPathParams, findCapitalCommitmentsByManagingCodeCapitalProjectIdPathParamsSchema, findCapitalProjectByManagingCodeCapitalProjectIdPathParamsSchema, + findCapitalProjectGeoJsonByManagingCodeCapitalProjectIdPathParamsSchema, findCapitalProjectTilesPathParamsSchema, } from "src/gen"; import { CapitalProjectService } from "./capital-project.service"; @@ -45,6 +47,21 @@ export class CapitalProjectController { ); } + @UsePipes( + new ZodTransformPipe( + findCapitalProjectGeoJsonByManagingCodeCapitalProjectIdPathParamsSchema, + ), + ) + @Get("/:managingCode/:capitalProjectId/geojson") + async findGeoJsonByManagingCodeCapitalProjectId( + @Param() + params: FindCapitalProjectGeoJsonByManagingCodeCapitalProjectIdPathParams, + ) { + return await this.capitalProjectService.findGeoJsonByManagingCodeCapitalProjectId( + params, + ); + } + @UsePipes(new ZodTransformPipe(findCapitalProjectTilesPathParamsSchema)) @Get("/:z/:x/:y.pbf") async findTiles( diff --git a/src/capital-project/capital-project.repository.schema.ts b/src/capital-project/capital-project.repository.schema.ts index 87712ef0..88354381 100644 --- a/src/capital-project/capital-project.repository.schema.ts +++ b/src/capital-project/capital-project.repository.schema.ts @@ -4,6 +4,8 @@ import { capitalCommitmentEntitySchema, capitalCommitmentFundEntitySchema, capitalProjectEntitySchema, + MultiPointSchema, + MultiPolygonSchema, } from "src/schema"; import { mvtEntitySchema } from "src/schema/mvt"; import { z } from "zod"; @@ -18,18 +20,41 @@ export type CheckByManagingCodeCapitalProjectIdRepo = z.infer< typeof checkByManagingCodeCapitalProjectIdRepoSchema >; -export const findByManagingCodeCapitalProjectIdRepoSchema = z.array( +export const capitalProjectBudgetedEntitySchema = capitalProjectEntitySchema.extend({ sponsoringAgencies: z.array(agencyEntitySchema.shape.initials), budgetTypes: z.array(agencyBudgetEntitySchema.shape.type), commitmentsTotal: capitalCommitmentFundEntitySchema.shape.value, - }), + }); + +export type CapitalProjectBudgetedEntity = z.infer< + typeof capitalProjectBudgetedEntitySchema +>; + +export const findByManagingCodeCapitalProjectIdRepoSchema = z.array( + capitalProjectBudgetedEntitySchema, ); export type FindByManagingCodeCapitalProjectIdRepo = z.infer< typeof findByManagingCodeCapitalProjectIdRepoSchema >; +export const capitalProjectBudgetedGeoJsonEntitySchema = + capitalProjectBudgetedEntitySchema.extend({ + geometry: z.union([MultiPointSchema, MultiPolygonSchema]).nullable(), + }); + +export type CapitalProjectBudgetedGeoJsonEntityRepo = z.infer< + typeof capitalProjectBudgetedGeoJsonEntitySchema +>; + +export const findGeoJsonByManagingCodeCapitalProjectIdRepoSchema = z.array( + capitalProjectBudgetedGeoJsonEntitySchema, +); + +export type FindGeoJsonByManagingCodeCapitalProjectIdRepo = z.infer< + typeof findGeoJsonByManagingCodeCapitalProjectIdRepoSchema +>; export const findTilesRepoSchema = mvtEntitySchema; export type FindTilesRepo = z.infer; diff --git a/src/capital-project/capital-project.repository.ts b/src/capital-project/capital-project.repository.ts index e4e40e15..827a8081 100644 --- a/src/capital-project/capital-project.repository.ts +++ b/src/capital-project/capital-project.repository.ts @@ -4,6 +4,7 @@ import { DataRetrievalException } from "src/exception"; import { FindCapitalCommitmentsByManagingCodeCapitalProjectIdPathParams, FindCapitalProjectByManagingCodeCapitalProjectIdPathParams, + FindCapitalProjectGeoJsonByManagingCodeCapitalProjectIdPathParams, FindCapitalProjectTilesPathParams, } from "src/gen"; import { DB, DbType } from "src/global/providers/db.provider"; @@ -18,6 +19,7 @@ import { CheckByManagingCodeCapitalProjectIdRepo, FindByManagingCodeCapitalProjectIdRepo, FindCapitalCommitmentsByManagingCodeCapitalProjectIdRepo, + FindGeoJsonByManagingCodeCapitalProjectIdRepo, FindTilesRepo, } from "./capital-project.repository.schema"; @@ -103,6 +105,68 @@ export class CapitalProjectRepository { } } + async findGeoJsonByManagingCodeCapitalProjectId( + params: FindCapitalProjectGeoJsonByManagingCodeCapitalProjectIdPathParams, + ): Promise { + const { managingCode, capitalProjectId } = params; + try { + return await this.db + .select({ + id: capitalProject.id, + managingCode: capitalProject.managingCode, + description: capitalProject.description, + managingAgency: capitalProject.managingAgency, + minDate: capitalProject.minDate, + maxDate: capitalProject.maxDate, + category: capitalProject.category, + sponsoringAgencies: sql< + Array + >`ARRAY_AGG(DISTINCT ${agencyBudget.sponsor})`, + budgetTypes: sql< + Array + >`ARRAY_AGG(DISTINCT ${agencyBudget.type})`, + commitmentsTotal: sum(capitalCommitmentFund.value).mapWith(Number), + geometry: sql` + CASE + WHEN + ${capitalProject.liFtMPoly} IS NOT null + THEN + ST_asGeoJSON(ST_Transform(${capitalProject.liFtMPoly}, 4326),6) + ELSE + ST_asGeoJSON(ST_Transform(${capitalProject.liFtMPnt}, 4326),6) + END + `.as("geometry"), + }) + .from(capitalProject) + .leftJoin( + capitalCommitment, + and( + eq(capitalProject.managingCode, capitalCommitment.managingCode), + eq(capitalProject.id, capitalCommitment.capitalProjectId), + ), + ) + .leftJoin( + agencyBudget, + eq(agencyBudget.code, capitalCommitment.budgetLineCode), + ) + .leftJoin( + capitalCommitmentFund, + eq(capitalCommitmentFund.capitalCommitmentId, capitalCommitment.id), + ) + .where( + and( + eq(capitalProject.managingCode, managingCode), + eq(capitalProject.id, capitalProjectId), + eq(capitalCommitmentFund.category, "total"), + ), + ) + .groupBy(capitalProject.managingCode, capitalProject.id) + .limit(1); + } catch { + throw new DataRetrievalException(); + } + } + async findTiles( params: FindCapitalProjectTilesPathParams, ): Promise { diff --git a/src/capital-project/capital-project.service.spec.ts b/src/capital-project/capital-project.service.spec.ts index 6c6af73f..e06e8d37 100644 --- a/src/capital-project/capital-project.service.spec.ts +++ b/src/capital-project/capital-project.service.spec.ts @@ -5,6 +5,7 @@ import { CapitalProjectRepository } from "./capital-project.repository"; import { findCapitalCommitmentsByManagingCodeCapitalProjectIdQueryResponseSchema, findCapitalProjectByManagingCodeCapitalProjectIdQueryResponseSchema, + findCapitalProjectGeoJsonByManagingCodeCapitalProjectIdQueryResponseSchema, findCapitalProjectTilesQueryResponseSchema, } from "src/gen"; import { ResourceNotFoundException } from "src/exception"; @@ -58,6 +59,38 @@ describe("CapitalProjectService", () => { }); }); + describe("findGeoJsonByManagingCodeCapitalProjectId", () => { + it("should return a capital project geojson with a valid request", async () => { + const capitalProjectGeoJsonMock = + capitalProjectRepository + .findGeoJsonByManagingCodeCapitalProjectIdMock[0]; + const { managingCode, id: capitalProjectId } = capitalProjectGeoJsonMock; + const capitalProjectGeoJson = + await capitalProjectService.findGeoJsonByManagingCodeCapitalProjectId({ + managingCode, + capitalProjectId, + }); + + expect(() => + findCapitalProjectGeoJsonByManagingCodeCapitalProjectIdQueryResponseSchema.parse( + capitalProjectGeoJson, + ), + ).not.toThrow(); + }); + + it("should throw a resource error when requesting a missing project", async () => { + const missingManagingCode = "890"; + const missingCapitalProjectId = "ABCD"; + + expect( + capitalProjectService.findGeoJsonByManagingCodeCapitalProjectId({ + managingCode: missingManagingCode, + capitalProjectId: missingCapitalProjectId, + }), + ).rejects.toThrow(ResourceNotFoundException); + }); + }); + describe("findTiles", () => { it("should return an mvt when requesting coordinates", async () => { const mvt = await capitalProjectService.findTiles({ diff --git a/src/capital-project/capital-project.service.ts b/src/capital-project/capital-project.service.ts index 279dcc24..22f4333b 100644 --- a/src/capital-project/capital-project.service.ts +++ b/src/capital-project/capital-project.service.ts @@ -1,11 +1,17 @@ +import { produce } from "immer"; import { FindCapitalCommitmentsByManagingCodeCapitalProjectIdPathParams, FindCapitalProjectByManagingCodeCapitalProjectIdPathParams, + FindCapitalProjectGeoJsonByManagingCodeCapitalProjectIdPathParams, FindCapitalProjectTilesPathParams, } from "src/gen"; import { CapitalProjectRepository } from "./capital-project.repository"; import { Inject } from "@nestjs/common"; import { ResourceNotFoundException } from "src/exception"; +import { + CapitalProjectBudgetedEntity, + CapitalProjectBudgetedGeoJsonEntityRepo, +} from "./capital-project.repository.schema"; export class CapitalProjectService { constructor( @@ -25,6 +31,38 @@ export class CapitalProjectService { return capitalProjects[0]; } + async findGeoJsonByManagingCodeCapitalProjectId( + params: FindCapitalProjectGeoJsonByManagingCodeCapitalProjectIdPathParams, + ) { + const capitalProjects = + await this.capitalProjectRepository.findGeoJsonByManagingCodeCapitalProjectId( + params, + ); + + if (capitalProjects.length < 1) throw new ResourceNotFoundException(); + + const capitalProject = capitalProjects[0]; + + const geometry = + capitalProject.geometry === null + ? null + : JSON.parse(capitalProject.geometry); + + const properties = produce( + capitalProject as Partial, + (draft) => { + delete draft["geometry"]; + }, + ) as CapitalProjectBudgetedEntity; + + return { + id: `${capitalProject.managingCode}${capitalProject.id}`, + type: "Feature", + properties, + geometry, + }; + } + async findTiles(params: FindCapitalProjectTilesPathParams) { return await this.capitalProjectRepository.findTiles(params); } diff --git a/src/schema/capital-commitment-fund.ts b/src/schema/capital-commitment-fund.ts index 4bf439f6..987b17d1 100644 --- a/src/schema/capital-commitment-fund.ts +++ b/src/schema/capital-commitment-fund.ts @@ -19,6 +19,6 @@ export const capitalCommitmentFundEntitySchema = z.object({ value: z.number(), }); -export type commitmentFundEntitySchema = z.infer< +export type CapitalCommitmentFundEntitySchema = z.infer< typeof capitalCommitmentFundEntitySchema >; diff --git a/src/schema/geometry.ts b/src/schema/geometry.ts index 1109b7eb..3cf51b8f 100644 --- a/src/schema/geometry.ts +++ b/src/schema/geometry.ts @@ -1,5 +1,11 @@ import { z } from "zod"; +export const MultiPointSchema = z + .string() + .regex( + /^{"type":"MultiPoint","coordinates":\[(\[-?[1-9]([0-9]{0,})(\.[0-9]{1,14})?,-?[1-9]([0-9]{0,})(\.[0-9]{1,14})?\])(,\[-?[1-9]([0-9]{0,})(\.[0-9]{1,14})?,-?[1-9]([0-9]{0,})(\.[0-9]{1,14})?\]){0,}\]}$/, + ); + export const MultiPolygonSchema = z .string() /** diff --git a/test/capital-project/capital-project.e2e-spec.ts b/test/capital-project/capital-project.e2e-spec.ts index 62d8c5a2..1453d286 100644 --- a/test/capital-project/capital-project.e2e-spec.ts +++ b/test/capital-project/capital-project.e2e-spec.ts @@ -12,6 +12,7 @@ import { import { findCapitalCommitmentsByManagingCodeCapitalProjectIdQueryResponseSchema, findCapitalProjectByManagingCodeCapitalProjectIdQueryResponseSchema, + findCapitalProjectGeoJsonByManagingCodeCapitalProjectIdQueryResponseSchema, } from "src/gen"; describe("Capital Projects", () => { @@ -102,6 +103,86 @@ describe("Capital Projects", () => { }); }); + describe("findGeoJsonByManagingCodeCapitalProjectId", () => { + it("should 200 and return a capital project with budget details", async () => { + const capitalProjectGeoJsonMock = + capitalProjectRepository + .findGeoJsonByManagingCodeCapitalProjectIdMock[0]; + const { managingCode, id: capitalProjectId } = capitalProjectGeoJsonMock; + const response = await request(app.getHttpServer()) + .get(`/capital-projects/${managingCode}/${capitalProjectId}/geojson`) + .expect(200); + + expect(() => + findCapitalProjectGeoJsonByManagingCodeCapitalProjectIdQueryResponseSchema.parse( + response.body, + ), + ).not.toThrow(); + }); + + it("should 400 when finding by a too long managing code", async () => { + const tooLongManagingCode = "1234"; + const capitalProjectId = "JIRO"; + + const response = await request(app.getHttpServer()) + .get( + `/capital-projects/${tooLongManagingCode}/${capitalProjectId}/geojson`, + ) + .expect(400); + + expect(response.body.message).toBe( + new InvalidRequestParameterException().message, + ); + expect(response.body.error).toBe(HttpName.BAD_REQUEST); + }); + + it("should 400 when finding by a lettered managing code", async () => { + const letteredManagingCode = "ABC"; + const capitalProjectId = "JIRO"; + + const response = await request(app.getHttpServer()) + .get( + `/capital-projects/${letteredManagingCode}/${capitalProjectId}/geojson`, + ) + .expect(400); + + expect(response.body.error).toBe(HttpName.BAD_REQUEST); + }); + + it("should 404 when finding by missing managing code and capital project id", async () => { + const managingCode = "123"; + const capitalProjectId = "JIRO"; + + const response = await request(app.getHttpServer()) + .get(`/capital-projects/${managingCode}/${capitalProjectId}`) + .expect(404); + + expect(response.body.message).toBe(HttpName.NOT_FOUND); + }); + + it("should 500 when the database errors", async () => { + const dataRetrievalException = new DataRetrievalException(); + jest + .spyOn( + capitalProjectRepository, + "findGeoJsonByManagingCodeCapitalProjectId", + ) + .mockImplementationOnce(() => { + throw dataRetrievalException; + }); + + const capitalProjectMock = + capitalProjectRepository + .findGeoJsonByManagingCodeCapitalProjectIdMock[0]; + const { managingCode, id: capitalProjectId } = capitalProjectMock; + const response = await request(app.getHttpServer()) + .get(`/capital-projects/${managingCode}/${capitalProjectId}/geojson`) + .expect(500); + expect(response.body.error).toBe(HttpName.INTERNAL_SEVER_ERROR); + expect(response.body.message).toBe(dataRetrievalException.message); + }); + }); + describe("findFills", () => { it("should return pbf files when passing valid viewport", async () => { const z = 1; diff --git a/test/capital-project/capital-project.repository.mock.ts b/test/capital-project/capital-project.repository.mock.ts index 8efc5348..f64f2f8a 100644 --- a/test/capital-project/capital-project.repository.mock.ts +++ b/test/capital-project/capital-project.repository.mock.ts @@ -5,11 +5,14 @@ import { findByManagingCodeCapitalProjectIdRepoSchema, FindCapitalCommitmentsByManagingCodeCapitalProjectIdRepo, findCapitalCommitmentsByManagingCodeCapitalProjectIdRepoSchema, + FindGeoJsonByManagingCodeCapitalProjectIdRepo, + findGeoJsonByManagingCodeCapitalProjectIdRepoSchema, findTilesRepoSchema, } from "src/capital-project/capital-project.repository.schema"; import { FindCapitalCommitmentsByManagingCodeCapitalProjectIdPathParams, FindCapitalProjectByManagingCodeCapitalProjectIdPathParams, + FindCapitalProjectGeoJsonByManagingCodeCapitalProjectIdPathParams, } from "src/gen"; export class CapitalProjectRepositoryMock { @@ -81,6 +84,30 @@ export class CapitalProjectRepositoryMock { ); } + findGeoJsonByManagingCodeCapitalProjectIdMock = generateMock( + findGeoJsonByManagingCodeCapitalProjectIdRepoSchema, + { + seed: 1, + stringMap: { + minDate: () => "2018-01-01", + maxDate: () => "2045-12-31", + }, + }, + ); + + async findGeoJsonByManagingCodeCapitalProjectId({ + managingCode, + capitalProjectId, + }: FindCapitalProjectGeoJsonByManagingCodeCapitalProjectIdPathParams): Promise { + const results = this.findGeoJsonByManagingCodeCapitalProjectIdMock.filter( + (capitalProjectGeoJson) => + capitalProjectGeoJson.id === capitalProjectId && + capitalProjectGeoJson.managingCode === managingCode, + ); + + return results === undefined ? [] : results; + } + findTilesMock = generateMock(findTilesRepoSchema); /**