Skip to content

Commit

Permalink
Merge pull request #210 from PhilanthropyDataCommons/101-deep-proposa…
Browse files Browse the repository at this point in the history
…l-by-id-2

Add option to deep GET /proposals/{proposalId}
  • Loading branch information
bickelj committed Feb 7, 2023
2 parents 9614dc6 + 551be26 commit 7ea7688
Show file tree
Hide file tree
Showing 10 changed files with 822 additions and 7 deletions.
316 changes: 315 additions & 1 deletion src/__tests__/proposals.int.test.ts
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
@@ -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
@@ -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
@@ -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
@@ -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
Expand Up @@ -3,3 +3,4 @@ export * from './DatabaseError';
export * from './InputConflictError';
export * from './InputValidationError';
export * from './InternalValidationError';
export * from './NotFoundError';

0 comments on commit 7ea7688

Please sign in to comment.