Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add option to deep GET /proposals/{proposalId} #210

Merged
merged 1 commit into from
Feb 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
316 changes: 315 additions & 1 deletion src/__tests__/proposals.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,15 @@ describe('/proposals', () => {
await agent
.get('/proposals/9001')
.set(dummyApiKey)
.expect(404, { message: 'Not found. Find existing proposals by calling with no parameters.' });
.expect(404, {
name: 'NotFoundError',
message: 'Not found. Find existing proposals by calling with no parameters.',
details: [
{
name: 'NotFoundError',
},
],
});
});

it('returns the one proposal asked for', async () => {
Expand Down Expand Up @@ -199,6 +207,199 @@ describe('/proposals', () => {
);
});

it('returns one proposal with deep fields when includeFieldsAndValues=true', async () => {
// Needs canonical fields,
// opportunity,
// an applicant,
// application form,
// application form fields,
// proposal,
// proposal versions, and
// proposal field values.
await db.query(`
INSERT INTO canonical_fields (
label,
short_code,
data_type,
created_at
)
VALUES
( 'Summary', 'summary', '{ type: "string" }', '2023-01-06T16:22:00+0000' ),
( 'Title', 'title', '{ type: "string" }', '2023-01-06T16:24:00+0000' );
`);
await db.query(`
INSERT INTO opportunities (
title,
created_at
)
VALUES
( '🌎', '2525-01-04T00:00:01Z' )
`);
await db.query(`
INSERT INTO applicants (
external_id,
opted_in,
created_at
)
VALUES
( '🐯', 'true', '2525-01-04T00:00:02Z' ),
( '🐅', 'false', '2525-01-04T00:00:03Z' );
`);
await db.query(`
INSERT INTO application_forms (
opportunity_id,
version,
created_at
)
VALUES
( 1, 1, '2525-01-04T00:00:04Z' )
`);
await db.query(`
INSERT INTO application_form_fields (
application_form_id,
canonical_field_id,
position,
label,
created_at
)
VALUES
( 1, 2, 1, 'Short summary or title', '2525-01-04T00:00:05Z' ),
( 1, 1, 2, 'Long summary or abstract', '2525-01-04T00:00:06Z' );
`);
await db.query(`
INSERT INTO proposals (
applicant_id,
external_id,
opportunity_id,
created_at
)
VALUES
( 2, 'proposal-2525-01-04T00Z', 1, '2525-01-04T00:00:07Z' );
`);
await db.query(`
INSERT INTO proposal_versions (
proposal_id,
application_form_id,
version,
created_at
)
VALUES
( 1, 1, 1, '2525-01-04T00:00:08Z' ),
( 1, 1, 2, '2525-01-04T00:00:09Z' );
`);
await db.query(`
INSERT INTO proposal_field_values (
proposal_version_id,
application_form_field_id,
position,
value,
created_at
)
VALUES
( 1, 1, 1, 'Title for version 1 from 2525-01-04', '2525-01-04T00:00:10Z' ),
( 1, 2, 2, 'Abstract for version 1 from 2525-01-04', '2525-01-04T00:00:11Z' ),
( 2, 1, 1, 'Title for version 2 from 2525-01-04', '2525-01-04T00:00:12Z' ),
( 2, 2, 2, 'Abstract for version 2 from 2525-01-04', '2525-01-04T00:00:13Z' );
`);
await agent
.get('/proposals/1/?includeFieldsAndValues=true')
.set(dummyApiKey)
.expect(
200,
{
id: 1,
applicantId: 2,
opportunityId: 1,
externalId: 'proposal-2525-01-04T00Z',
createdAt: '2525-01-04T00:00:07.000Z',
versions: [
{
id: 2,
proposalId: 1,
applicationFormId: 1,
version: 2,
createdAt: '2525-01-04T00:00:09.000Z',
fieldValues: [
{
id: 3,
proposalVersionId: 2,
applicationFormFieldId: 1,
position: 1,
value: 'Title for version 2 from 2525-01-04',
createdAt: '2525-01-04T00:00:12.000Z',
applicationFormField: {
id: 1,
applicationFormId: 1,
canonicalFieldId: 2,
position: 1,
label: 'Short summary or title',
createdAt: '2525-01-04T00:00:05.000Z',
},
},
{
id: 4,
proposalVersionId: 2,
applicationFormFieldId: 2,
position: 2,
value: 'Abstract for version 2 from 2525-01-04',
createdAt: '2525-01-04T00:00:13.000Z',
applicationFormField: {
id: 2,
applicationFormId: 1,
canonicalFieldId: 1,
position: 2,
label: 'Long summary or abstract',
createdAt: '2525-01-04T00:00:06.000Z',
},
},
],
},
{
id: 1,
proposalId: 1,
applicationFormId: 1,
version: 1,
createdAt: '2525-01-04T00:00:08.000Z',
fieldValues: [
{
id: 1,
proposalVersionId: 1,
applicationFormFieldId: 1,
position: 1,
value: 'Title for version 1 from 2525-01-04',
createdAt: '2525-01-04T00:00:10.000Z',
applicationFormField: {
id: 1,
applicationFormId: 1,
canonicalFieldId: 2,
position: 1,
label: 'Short summary or title',
createdAt: '2525-01-04T00:00:05.000Z',
},
},
{
id: 2,
proposalVersionId: 1,
applicationFormFieldId: 2,
position: 2,
value: 'Abstract for version 1 from 2525-01-04',
createdAt: '2525-01-04T00:00:11.000Z',
applicationFormField: {
id: 2,
applicationFormId: 1,
canonicalFieldId: 1,
position: 2,
label: 'Long summary or abstract',
createdAt: '2525-01-04T00:00:06.000Z',
},
},
],
},
],
},
);
});

it('should error if the database returns an unexpected data structure', async () => {
jest.spyOn(db, 'sql')
.mockImplementationOnce(async () => ({
Expand Down Expand Up @@ -255,6 +456,119 @@ describe('/proposals', () => {
});
});

it('returns 404 when given id is not present and includeFieldsAndValues=true', async () => {
await agent
.get('/proposals/9002?includeFieldsAndValues=true')
.set(dummyApiKey)
.expect(404, {
name: 'NotFoundError',
message: 'Not found. Find existing proposals by calling with no parameters.',
details: [
{
name: 'NotFoundError',
},
],
});
});

it('should error if the database returns an unexpected data structure when includeFieldsAndValues=true', async () => {
jest.spyOn(db, 'sql')
.mockImplementationOnce(async () => ({
rows: [{ foo: 'not a valid result' }],
}) as Result<object>);
const result = await agent
.get('/proposals/9003?includeFieldsAndValues=true')
.set(dummyApiKey)
.expect(500);
expect(result.body).toMatchObject({
name: 'InternalValidationError',
details: expect.any(Array) as unknown[],
});
});

it('returns 500 UnknownError if a generic Error is thrown when selecting and includeFieldsAndValues=true', async () => {
jest.spyOn(db, 'sql')
.mockImplementationOnce(async () => {
throw new Error('This is unexpected');
});
const result = await agent
.get('/proposals/9004?includeFieldsAndValues=true')
.set(dummyApiKey)
.expect(500);
expect(result.body).toMatchObject({
name: 'UnknownError',
details: expect.any(Array) as unknown[],
});
});

it('returns 503 DatabaseError if db error is thrown when includeFieldsAndValues=true', async () => {
await db.query(`
INSERT INTO opportunities (
title,
created_at
)
VALUES
( '🧳', '2525-01-04T00:00:14Z' )
`);
await db.query(`
INSERT INTO applicants (
external_id,
opted_in,
created_at
)
VALUES
( '🐴', 'true', '2525-01-04T00:00:15Z' );
`);
await db.query(`
INSERT INTO proposals (
applicant_id,
external_id,
opportunity_id,
created_at
)
VALUES
( 1, 'proposal-🧳-🐴', 1, '2525-01-04T00:00:16Z' );
`);
jest.spyOn(db, 'sql')
.mockImplementationOnce(async () => {
throw new TinyPgError(
'Something went wrong',
undefined,
{
error: {
code: PostgresErrorCode.INSUFFICIENT_RESOURCES,
},
},
);
});
const result = await agent
.get('/proposals/1?includeFieldsAndValues=true')
.type('application/json')
.set(dummyApiKey)
.expect(503);
expect(result.body).toMatchObject({
name: 'DatabaseError',
details: [{
code: PostgresErrorCode.INSUFFICIENT_RESOURCES,
}],
});
});

it('returns 500 UnknownError if a generic Error is thrown when selecting and includeFieldsAndValues=true', async () => {
jest.spyOn(db, 'sql')
.mockImplementationOnce(async () => {
throw new Error('This is unexpected');
});
const result = await agent
.get('/proposals/9004?includeFieldsAndValues=true')
.set(dummyApiKey)
.expect(500);
expect(result.body).toMatchObject({
name: 'UnknownError',
details: expect.any(Array) as unknown[],
});
});

describe('POST /', () => {
it('creates exactly one proposal', async () => {
await db.query(`
Expand Down
13 changes: 13 additions & 0 deletions src/database/queries/applicationFormFields/selectByProposalId.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
SELECT aff.id AS "id",
aff.application_form_id AS "applicationFormId",
aff.canonical_field_id AS "canonicalFieldId",
aff.position AS "position",
aff.label AS "label",
aff.created_at AS "createdAt"
FROM application_form_fields aff
INNER JOIN proposal_field_values pfv
ON pfv.application_form_field_id = aff.id
INNER JOIN proposal_versions pv
ON pv.id = pfv.proposal_version_id
WHERE pv.proposal_id = :proposalId
ORDER BY pv.version DESC, pfv.position;
11 changes: 11 additions & 0 deletions src/database/queries/proposalFieldValues/selectByProposalId.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
SELECT pfv.id AS "id",
pfv.proposal_version_id AS "proposalVersionId",
pfv.application_form_field_id AS "applicationFormFieldId",
pfv.value AS "value",
pfv.position AS "position",
pfv.created_at AS "createdAt"
FROM proposal_field_values pfv
INNER JOIN proposal_versions pv
ON pv.id = pfv.proposal_version_id
WHERE pv.proposal_id = :proposalId
ORDER BY pv.version DESC, pfv.position;
8 changes: 8 additions & 0 deletions src/database/queries/proposalVersions/selectByProposalId.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
SELECT pv.id AS "id",
pv.proposal_id AS "proposalId",
pv.application_form_id AS "applicationFormId",
pv.version AS "version",
pv.created_at AS "createdAt"
FROM proposal_versions pv
WHERE pv.proposal_id = :proposalId
ORDER BY pv.version DESC;
8 changes: 8 additions & 0 deletions src/errors/NotFoundError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export class NotFoundError extends Error {
public constructor(
message: string,
) {
super(message);
this.name = this.constructor.name;
}
}
1 change: 1 addition & 0 deletions src/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './DatabaseError';
export * from './InputConflictError';
export * from './InputValidationError';
export * from './InternalValidationError';
export * from './NotFoundError';