diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index dc4184d..000a84a 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -57,6 +57,8 @@ paths: paths/boroughs_{boroughId}_community-districts_{communityDistrictId}_capital-projects_{z}_{x}_{y}.pbf.yaml /capital-commitment-types: $ref: paths/capital-commitment-types.yaml + /capital-projects: + $ref: paths/capital-projects.yaml /capital-projects/{managingCode}/{capitalProjectId}/capital-commitments: $ref: >- paths/capital-projects_{managingCode}_{capitalProjectId}_capital-commitments.yaml diff --git a/openapi/paths/capital-projects.yaml b/openapi/paths/capital-projects.yaml new file mode 100644 index 0000000..2eb5a3a --- /dev/null +++ b/openapi/paths/capital-projects.yaml @@ -0,0 +1,21 @@ +get: + summary: Find paginated capital projects. + operationId: findCapitalProjects + tags: + - Capital Projects + parameters: + - $ref: ../components/parameters/limitParam.yaml + - $ref: ../components/parameters/offsetParam.yaml + responses: + '200': + description: >- + An object containing pagination metadata and an array of capital + projects + content: + application/json: + schema: + $ref: ../components/schemas/CapitalProjectPage.yaml + '400': + $ref: ../components/responses/BadRequest.yaml + '500': + $ref: ../components/responses/InternalServerError.yaml diff --git a/src/capital-project/capital-project.controller.ts b/src/capital-project/capital-project.controller.ts index 91cc9db..0f9a185 100644 --- a/src/capital-project/capital-project.controller.ts +++ b/src/capital-project/capital-project.controller.ts @@ -2,6 +2,7 @@ import { Controller, Get, Param, + Query, Res, UseFilters, UsePipes, @@ -12,6 +13,7 @@ import { FindCapitalProjectByManagingCodeCapitalProjectIdPathParams, FindCapitalProjectGeoJsonByManagingCodeCapitalProjectIdPathParams, FindCapitalProjectTilesPathParams, + FindCapitalProjectsQueryParams, findCapitalCommitmentsByManagingCodeCapitalProjectIdPathParamsSchema, findCapitalProjectByManagingCodeCapitalProjectIdPathParamsSchema, findCapitalProjectGeoJsonByManagingCodeCapitalProjectIdPathParamsSchema, @@ -24,6 +26,7 @@ import { NotFoundExceptionFilter, } from "src/filter"; import { ZodTransformPipe } from "src/pipes/zod-transform-pipe"; +import { findCapitalProjectsQueryParamsSchema } from "src/gen/zod/findCapitalProjectsSchema"; @UseFilters( BadRequestExceptionFilter, InternalServerErrorExceptionFilter, @@ -33,6 +36,14 @@ import { ZodTransformPipe } from "src/pipes/zod-transform-pipe"; export class CapitalProjectController { constructor(private readonly capitalProjectService: CapitalProjectService) {} + @Get("/") + async findMany( + @Query(new ZodTransformPipe(findCapitalProjectsQueryParamsSchema)) + queryParams: FindCapitalProjectsQueryParams, + ) { + return await this.capitalProjectService.findMany(queryParams); + } + @UsePipes( new ZodTransformPipe( findCapitalProjectByManagingCodeCapitalProjectIdPathParamsSchema, diff --git a/src/capital-project/capital-project.repository.schema.ts b/src/capital-project/capital-project.repository.schema.ts index 8835438..85bf39c 100644 --- a/src/capital-project/capital-project.repository.schema.ts +++ b/src/capital-project/capital-project.repository.schema.ts @@ -10,6 +10,14 @@ import { import { mvtEntitySchema } from "src/schema/mvt"; import { z } from "zod"; +export const findCapitalProjectsRepoSchema = z.array( + capitalProjectEntitySchema, +); + +export type FindCapitalProjectsRepo = z.infer< + typeof findCapitalProjectsRepoSchema +>; + export const checkByManagingCodeCapitalProjectIdRepoSchema = capitalProjectEntitySchema.pick({ id: true, diff --git a/src/capital-project/capital-project.repository.ts b/src/capital-project/capital-project.repository.ts index c08ae6c..95800d6 100644 --- a/src/capital-project/capital-project.repository.ts +++ b/src/capital-project/capital-project.repository.ts @@ -21,6 +21,7 @@ import { FindByManagingCodeCapitalProjectIdRepo, FindCapitalCommitmentsByManagingCodeCapitalProjectIdRepo, FindGeoJsonByManagingCodeCapitalProjectIdRepo, + FindCapitalProjectsRepo, FindTilesRepo, } from "./capital-project.repository.schema"; @@ -30,6 +31,33 @@ export class CapitalProjectRepository { private readonly db: DbType, ) {} + async findMany({ + limit, + offset, + }: { + limit: number; + offset: number; + }): Promise { + try { + return await this.db + .select({ + id: capitalProject.id, + description: capitalProject.description, + managingCode: capitalProject.managingCode, + managingAgency: capitalProject.managingAgency, + maxDate: capitalProject.maxDate, + minDate: capitalProject.minDate, + category: sql`${capitalProject.category}`, + }) + .from(capitalProject) + .limit(limit) + .offset(offset) + .orderBy(capitalProject.managingCode, capitalProject.id); + } catch { + throw new DataRetrievalException(); + } + } + #checkByManagingCodeCapitalProjectId = this.db.query.capitalProject .findFirst({ columns: { diff --git a/src/capital-project/capital-project.service.spec.ts b/src/capital-project/capital-project.service.spec.ts index 3fa63b1..4c31fb5 100644 --- a/src/capital-project/capital-project.service.spec.ts +++ b/src/capital-project/capital-project.service.spec.ts @@ -7,6 +7,7 @@ import { findCapitalProjectByManagingCodeCapitalProjectIdQueryResponseSchema, findCapitalProjectGeoJsonByManagingCodeCapitalProjectIdQueryResponseSchema, findCapitalProjectTilesQueryResponseSchema, + findCapitalProjectsQueryResponseSchema, } from "src/gen"; import { ResourceNotFoundException } from "src/exception"; @@ -28,6 +29,23 @@ describe("CapitalProjectService", () => { ); }); + describe("findMany", () => { + it("should return a list of capital projects", async () => { + const capitalProjectsResponse = await capitalProjectService.findMany({}); + expect(() => + findCapitalProjectsQueryResponseSchema.parse(capitalProjectsResponse), + ).not.toThrow(); + + const parsedBody = findCapitalProjectsQueryResponseSchema.parse( + capitalProjectsResponse, + ); + expect(parsedBody.limit).toBe(20); + expect(parsedBody.offset).toBe(0); + expect(parsedBody.total).toBe(parsedBody.capitalProjects.length); + expect(parsedBody.order).toBe("managingCode, capitalProjectId"); + }); + }); + describe("findByManagingCodeCapitalProjectId", () => { it("should return a capital project with budget details", async () => { const capitalProjectMock = diff --git a/src/capital-project/capital-project.service.ts b/src/capital-project/capital-project.service.ts index e568418..1599521 100644 --- a/src/capital-project/capital-project.service.ts +++ b/src/capital-project/capital-project.service.ts @@ -4,6 +4,7 @@ import { FindCapitalProjectByManagingCodeCapitalProjectIdPathParams, FindCapitalProjectGeoJsonByManagingCodeCapitalProjectIdPathParams, FindCapitalProjectTilesPathParams, + FindCapitalProjectsQueryParams, } from "src/gen"; import { CapitalProjectRepository } from "./capital-project.repository"; import { Inject } from "@nestjs/common"; @@ -19,6 +20,21 @@ export class CapitalProjectService { private readonly capitalProjectRepository: CapitalProjectRepository, ) {} + async findMany({ limit = 20, offset = 0 }: FindCapitalProjectsQueryParams) { + const capitalProjects = await this.capitalProjectRepository.findMany({ + limit, + offset, + }); + + return { + capitalProjects, + limit, + offset, + total: capitalProjects.length, + order: "managingCode, capitalProjectId", + }; + } + async findByManagingCodeCapitalProjectId( params: FindCapitalProjectByManagingCodeCapitalProjectIdPathParams, ) { diff --git a/src/gen/types/FindCapitalProjects.ts b/src/gen/types/FindCapitalProjects.ts new file mode 100644 index 0000000..e620717 --- /dev/null +++ b/src/gen/types/FindCapitalProjects.ts @@ -0,0 +1,36 @@ +import type { CapitalProjectPage } from "./CapitalProjectPage"; +import type { Error } from "./Error"; + +export type FindCapitalProjectsQueryParams = { + /** + * @description The maximum number of results to be returned in each response. The default value is 20. It must be between 1 and 100, inclusive. + * @type integer | undefined + */ + limit?: number; + /** + * @description The position in the full list to begin returning results. Default offset is 0. If the offset is beyond the end of the list, no results will be returned. + * @type integer | undefined + */ + offset?: number; +}; +/** + * @description An object containing pagination metadata and an array of capital projects + */ +export type FindCapitalProjects200 = CapitalProjectPage; +/** + * @description Invalid client request + */ +export type FindCapitalProjects400 = Error; +/** + * @description Server side error + */ +export type FindCapitalProjects500 = Error; +/** + * @description An object containing pagination metadata and an array of capital projects + */ +export type FindCapitalProjectsQueryResponse = CapitalProjectPage; +export type FindCapitalProjectsQuery = { + Response: FindCapitalProjectsQueryResponse; + QueryParams: FindCapitalProjectsQueryParams; + Errors: FindCapitalProjects400 | FindCapitalProjects500; +}; diff --git a/src/gen/types/index.ts b/src/gen/types/index.ts index 0399b56..c483a7e 100644 --- a/src/gen/types/index.ts +++ b/src/gen/types/index.ts @@ -24,6 +24,7 @@ export * from "./FindCapitalProjectGeoJsonByManagingCodeCapitalProjectId"; export * from "./FindCapitalProjectTiles"; export * from "./FindCapitalProjectTilesByBoroughIdCommunityDistrictId"; export * from "./FindCapitalProjectTilesByCityCouncilDistrictId"; +export * from "./FindCapitalProjects"; export * from "./FindCapitalProjectsByBoroughIdCommunityDistrictId"; export * from "./FindCapitalProjectsByCityCouncilId"; export * from "./FindCityCouncilDistrictGeoJsonByCityCouncilDistrictId"; diff --git a/src/gen/zod/findCapitalProjectsSchema.ts b/src/gen/zod/findCapitalProjectsSchema.ts new file mode 100644 index 0000000..849495f --- /dev/null +++ b/src/gen/zod/findCapitalProjectsSchema.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; +import { capitalProjectPageSchema } from "./capitalProjectPageSchema"; +import { errorSchema } from "./errorSchema"; + +export const findCapitalProjectsQueryParamsSchema = z + .object({ + limit: z.coerce + .number() + .int() + .min(1) + .max(100) + .describe( + "The maximum number of results to be returned in each response. The default value is 20. It must be between 1 and 100, inclusive.", + ) + .optional(), + offset: z.coerce + .number() + .int() + .min(0) + .describe( + "The position in the full list to begin returning results. Default offset is 0. If the offset is beyond the end of the list, no results will be returned.", + ) + .optional(), + }) + .optional(); +/** + * @description An object containing pagination metadata and an array of capital projects + */ +export const findCapitalProjects200Schema = z.lazy( + () => capitalProjectPageSchema, +); +/** + * @description Invalid client request + */ +export const findCapitalProjects400Schema = z.lazy(() => errorSchema); +/** + * @description Server side error + */ +export const findCapitalProjects500Schema = z.lazy(() => errorSchema); +/** + * @description An object containing pagination metadata and an array of capital projects + */ +export const findCapitalProjectsQueryResponseSchema = z.lazy( + () => capitalProjectPageSchema, +); diff --git a/src/gen/zod/index.ts b/src/gen/zod/index.ts index d2b036c..9ff77b5 100644 --- a/src/gen/zod/index.ts +++ b/src/gen/zod/index.ts @@ -26,6 +26,7 @@ export * from "./findCapitalProjectTilesByCityCouncilDistrictIdSchema"; export * from "./findCapitalProjectTilesSchema"; export * from "./findCapitalProjectsByBoroughIdCommunityDistrictIdSchema"; export * from "./findCapitalProjectsByCityCouncilIdSchema"; +export * from "./findCapitalProjectsSchema"; export * from "./findCityCouncilDistrictGeoJsonByCityCouncilDistrictIdSchema"; export * from "./findCityCouncilDistrictTilesSchema"; export * from "./findCityCouncilDistrictsSchema"; diff --git a/src/gen/zod/operations.ts b/src/gen/zod/operations.ts index 4cd0e67..6f9ec8b 100644 --- a/src/gen/zod/operations.ts +++ b/src/gen/zod/operations.ts @@ -46,6 +46,12 @@ import { findCapitalCommitmentTypes400Schema, findCapitalCommitmentTypes500Schema, } from "./findCapitalCommitmentTypesSchema"; +import { + findCapitalProjectsQueryResponseSchema, + findCapitalProjects400Schema, + findCapitalProjects500Schema, + findCapitalProjectsQueryParamsSchema, +} from "./findCapitalProjectsSchema"; import { findCapitalCommitmentsByManagingCodeCapitalProjectIdQueryResponseSchema, findCapitalCommitmentsByManagingCodeCapitalProjectId400Schema, @@ -336,6 +342,24 @@ export const operations = { 500: findCapitalCommitmentTypes500Schema, }, }, + findCapitalProjects: { + request: undefined, + parameters: { + path: undefined, + query: findCapitalProjectsQueryParamsSchema, + header: undefined, + }, + responses: { + 200: findCapitalProjectsQueryResponseSchema, + 400: findCapitalProjects400Schema, + 500: findCapitalProjects500Schema, + default: findCapitalProjectsQueryResponseSchema, + }, + errors: { + 400: findCapitalProjects400Schema, + 500: findCapitalProjects500Schema, + }, + }, findCapitalCommitmentsByManagingCodeCapitalProjectId: { request: undefined, parameters: { @@ -774,6 +798,9 @@ export const paths = { "/capital-commitment-types": { get: operations["findCapitalCommitmentTypes"], }, + "/capital-projects": { + get: operations["findCapitalProjects"], + }, "/capital-projects/{managingCode}/{capitalProjectId}/capital-commitments": { get: operations["findCapitalCommitmentsByManagingCodeCapitalProjectId"], }, diff --git a/test/capital-project/capital-project.e2e-spec.ts b/test/capital-project/capital-project.e2e-spec.ts index 1453d28..a87f987 100644 --- a/test/capital-project/capital-project.e2e-spec.ts +++ b/test/capital-project/capital-project.e2e-spec.ts @@ -13,6 +13,7 @@ import { findCapitalCommitmentsByManagingCodeCapitalProjectIdQueryResponseSchema, findCapitalProjectByManagingCodeCapitalProjectIdQueryResponseSchema, findCapitalProjectGeoJsonByManagingCodeCapitalProjectIdQueryResponseSchema, + findCapitalProjectsQueryResponseSchema, } from "src/gen"; describe("Capital Projects", () => { @@ -35,6 +36,90 @@ describe("Capital Projects", () => { await app.close(); }); + describe("findMany", () => { + it("should 200 and return paginated capital projects", async () => { + const response = await request(app.getHttpServer()) + .get("/capital-projects") + .expect(200); + expect(() => + findCapitalProjectsQueryResponseSchema.parse(response.body), + ).not.toThrow(); + }); + + it("should 200 and return capital projects with page metadata when specifying offset and limit", async () => { + const limit = 5; + const offset = 2; + const response = await request(app.getHttpServer()).get( + `/capital-projects?limit=${limit}&offset=${offset}`, + ); + + expect(() => + findCapitalProjectsQueryResponseSchema.parse(response.body), + ).not.toThrow(); + const parsedBody = findCapitalProjectsQueryResponseSchema.parse( + response.body, + ); + expect(parsedBody.limit).toBe(limit); + expect(parsedBody.offset).toBe(offset); + }); + + it("should 400 when finding by an invalid limit", async () => { + const response = await request(app.getHttpServer()).get( + "/capital-projects?limit=b4d", + ); + expect(response.body.message).toBe( + new InvalidRequestParameterException().message, + ); + expect(response.body.error).toBe(HttpName.BAD_REQUEST); + }); + + it("should 400 when finding by a 'too-high' limit", async () => { + const response = await request(app.getHttpServer()).get( + "/capital-projects?limit=101", + ); + expect(response.body.message).toBe( + new InvalidRequestParameterException().message, + ); + expect(response.body.error).toBe(HttpName.BAD_REQUEST); + }); + + it("should 400 when finding by a 'too-low' limit", async () => { + const response = await request(app.getHttpServer()).get( + "/capital-projects?limit=0", + ); + expect(response.body.message).toBe( + new InvalidRequestParameterException().message, + ); + expect(response.body.error).toBe(HttpName.BAD_REQUEST); + }); + + it("should 400 when finding by invalid offset", async () => { + const response = await request(app.getHttpServer()).get( + "/capital-projects?offset=b4d", + ); + expect(response.body.message).toBe( + new InvalidRequestParameterException().message, + ); + expect(response.body.error).toBe(HttpName.BAD_REQUEST); + }); + + it("should 500 when there is a data retrieval error", async () => { + const dataRetrievalException = new DataRetrievalException(); + jest + .spyOn(capitalProjectRepository, "findMany") + .mockImplementationOnce(() => { + throw dataRetrievalException; + }); + + const response = await request(app.getHttpServer()) + .get("/capital-projects") + .expect(500); + + expect(response.body.message).toBe(dataRetrievalException.message); + expect(response.body.error).toBe(HttpName.INTERNAL_SEVER_ERROR); + }); + }); + describe("findByManagingCodeCapitalProjectId", () => { it("should 200 and return a capital project with budget details", async () => { const capitalProjectMock = diff --git a/test/capital-project/capital-project.repository.mock.ts b/test/capital-project/capital-project.repository.mock.ts index f64f2f8..dc83755 100644 --- a/test/capital-project/capital-project.repository.mock.ts +++ b/test/capital-project/capital-project.repository.mock.ts @@ -8,6 +8,7 @@ import { FindGeoJsonByManagingCodeCapitalProjectIdRepo, findGeoJsonByManagingCodeCapitalProjectIdRepoSchema, findTilesRepoSchema, + findCapitalProjectsRepoSchema, } from "src/capital-project/capital-project.repository.schema"; import { FindCapitalCommitmentsByManagingCodeCapitalProjectIdPathParams, @@ -16,6 +17,21 @@ import { } from "src/gen"; export class CapitalProjectRepositoryMock { + findCapitalProjectsMocks = Array.from( + Array(4), + (_) => + generateMock(findCapitalProjectsRepoSchema, { + stringMap: { + minDate: () => "2018-01-01", + maxDate: () => "2045-12-31", + }, + })[0], + ); + + async findMany() { + return this.findCapitalProjectsMocks; + } + checkByManagingCodeCapitalProjectIdMocks = Array.from(Array(5), (_, seed) => generateMock(checkByManagingCodeCapitalProjectIdRepoSchema, { seed: seed + 1,