diff --git a/src/__tests__/proposalVersions.int.test.ts b/src/__tests__/proposalVersions.int.test.ts index 17388f43..cd863e44 100644 --- a/src/__tests__/proposalVersions.int.test.ts +++ b/src/__tests__/proposalVersions.int.test.ts @@ -6,6 +6,7 @@ import { createBaseField, createOpportunity, createProposal, + createProposalVersion, loadSystemSource, loadTableMetrics, } from '../database'; @@ -34,6 +35,67 @@ const createTestBaseFields = async () => { }; describe('/proposalVersions', () => { + describe('GET /:proposalVersionId', () => { + it('requires authentication', async () => { + await request(app).get('/proposalVersions/1').expect(401); + }); + + it('returns exactly one proposal version selected by id', async () => { + const systemSource = await loadSystemSource(); + await createOpportunity({ title: '🔥' }); + const testUser = await loadTestUser(); + await createProposal({ + externalId: 'proposal-1', + opportunityId: 1, + createdBy: testUser.keycloakUserId, + }); + await createApplicationForm({ + opportunityId: 1, + }); + const newProposalVersion = await createProposalVersion({ + proposalId: 1, + applicationFormId: 1, + sourceId: systemSource.id, + createdBy: testUser.keycloakUserId, + }); + + const response = await request(app) + .get(`/proposalVersions/1`) + .set(authHeader) + .expect(200); + expect(response.body).toEqual(newProposalVersion); + }); + + it('returns 400 bad request when id is a letter', async () => { + const result = await request(app) + .get('/proposalVersions/a') + .set(authHeader) + .expect(400); + expect(result.body).toMatchObject({ + name: 'InputValidationError', + details: expect.any(Array) as unknown[], + }); + }); + + it('returns 400 bad request when id is a number greater than 2^32-1', async () => { + const result = await request(app) + .get('/proposalVersions/555555555555555555555555555555') + .set(authHeader) + .expect(400); + expect(result.body).toMatchObject({ + name: 'InputValidationError', + details: expect.any(Array) as unknown[], + }); + }); + + it('returns 404 when id is not found', async () => { + await request(app) + .get('/proposalVersions/900000') + .set(authHeader) + .expect(404); + }); + }); + describe('POST /', () => { it('requires authentication', async () => { await request(app).post('/proposalVersions').expect(401); diff --git a/src/database/operations/proposalVersions/index.ts b/src/database/operations/proposalVersions/index.ts index 6da38a0a..3f4b9248 100644 --- a/src/database/operations/proposalVersions/index.ts +++ b/src/database/operations/proposalVersions/index.ts @@ -1 +1,2 @@ export * from './createProposalVersion'; +export * from './loadProposalVersion'; diff --git a/src/database/operations/proposalVersions/loadProposalVersion.ts b/src/database/operations/proposalVersions/loadProposalVersion.ts new file mode 100644 index 00000000..f098f3c3 --- /dev/null +++ b/src/database/operations/proposalVersions/loadProposalVersion.ts @@ -0,0 +1,22 @@ +import { db } from '../../db'; +import { NotFoundError } from '../../../errors'; +import type { JsonResultSet, ProposalVersion } from '../../../types'; + +export const loadProposalVersion = async ( + id: number, +): Promise => { + const result = await db.sql>( + 'proposalVersions.selectById', + { + id, + }, + ); + const proposalVersion = result.rows[0]?.object; + if (proposalVersion === undefined) { + throw new NotFoundError(`Entity not found`, { + entityType: 'ProposalVersion', + entityId: id, + }); + } + return proposalVersion; +}; diff --git a/src/database/queries/proposalVersions/selectById.sql b/src/database/queries/proposalVersions/selectById.sql new file mode 100644 index 00000000..a0a7d372 --- /dev/null +++ b/src/database/queries/proposalVersions/selectById.sql @@ -0,0 +1,3 @@ +SELECT proposal_version_to_json(proposal_versions.*) AS object +FROM proposal_versions +WHERE id = :id; diff --git a/src/handlers/proposalVersionsHandlers.ts b/src/handlers/proposalVersionsHandlers.ts index b703852e..c6a7caef 100644 --- a/src/handlers/proposalVersionsHandlers.ts +++ b/src/handlers/proposalVersionsHandlers.ts @@ -6,11 +6,13 @@ import { loadApplicationForm, loadApplicationFormField, loadProposal, + loadProposalVersion, } from '../database'; import { isAuthContext, isTinyPgErrorWithQueryContext, isWritableProposalVersionWithFieldValues, + isId, } from '../types'; import { DatabaseError, @@ -212,6 +214,30 @@ const postProposalVersion = ( }); }; +const getProposalVersion = ( + req: Request, + res: Response, + next: NextFunction, +): void => { + const { proposalVersionId } = req.params; + if (!isId(proposalVersionId)) { + next(new InputValidationError('Invalid request body.', isId.errors ?? [])); + return; + } + loadProposalVersion(proposalVersionId) + .then((item) => { + res.status(200).contentType('application/json').send(item); + }) + .catch((error: unknown) => { + if (isTinyPgErrorWithQueryContext(error)) { + next(new DatabaseError('Error retrieving item.', error)); + return; + } + next(error); + }); +}; + export const proposalVersionsHandlers = { postProposalVersion, + getProposalVersion, }; diff --git a/src/openapi.json b/src/openapi.json index 9a164ab3..b2b58359 100644 --- a/src/openapi.json +++ b/src/openapi.json @@ -1823,6 +1823,51 @@ } } }, + "/proposalVersions/{proposalVersionId}": { + "get": { + "operationId": "getProposalVersionById", + "summary": "Gets a specific proposalVersion.", + "tags": ["Proposals"], + "security": [ + { + "auth": [] + } + ], + "parameters": [ + { + "name": "proposalVersionId", + "description": "The PDC-generated ID of a proposalVersion.", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "The requested proposalVersion.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProposalVersion" + } + } + } + }, + "401": { + "description": "Authentication was not provided or was invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PdcError" + } + } + } + } + } + } + }, "/baseFields": { "get": { "operationId": "getBaseFields", diff --git a/src/routers/proposalVersionsRouter.ts b/src/routers/proposalVersionsRouter.ts index d27c6961..37ee67f8 100644 --- a/src/routers/proposalVersionsRouter.ts +++ b/src/routers/proposalVersionsRouter.ts @@ -10,4 +10,10 @@ proposalVersionsRouter.post( proposalVersionsHandlers.postProposalVersion, ); +proposalVersionsRouter.get( + '/:proposalVersionId', + requireAuthentication, + proposalVersionsHandlers.getProposalVersion, +); + export { proposalVersionsRouter };