@@ -151,7 +151,16 @@ describe('/proposals', () => {
151151 await agent
152152 . get ( '/proposals/9001' )
153153 . set ( dummyApiKey )
154- . expect ( 404 , { message : 'Not found. Find existing proposals by calling with no parameters.' } ) ;
154+ . expect ( 404 , {
155+ name : 'NotFoundError' ,
156+ message : 'Not found. Find existing proposals by calling with no parameters.' ,
157+ details : [
158+ {
159+ name : 'NotFoundError' ,
160+ status : 404 ,
161+ } ,
162+ ] ,
163+ } ) ;
155164 } ) ;
156165
157166 it ( 'returns the one proposal asked for' , async ( ) => {
@@ -199,6 +208,199 @@ describe('/proposals', () => {
199208 ) ;
200209 } ) ;
201210
211+ it ( 'returns one proposal with deep fields when includeFieldsAndValues=true' , async ( ) => {
212+ // Needs canonical fields,
213+ // opportunity,
214+ // an applicant,
215+ // application form,
216+ // application form fields,
217+ // proposal,
218+ // proposal versions, and
219+ // proposal field values.
220+ await db . query ( `
221+ INSERT INTO canonical_fields (
222+ label,
223+ short_code,
224+ data_type,
225+ created_at
226+ )
227+ VALUES
228+ ( 'Summary', 'summary', '{ type: "string" }', '2023-01-06T16:22:00+0000' ),
229+ ( 'Title', 'title', '{ type: "string" }', '2023-01-06T16:24:00+0000' );
230+ ` ) ;
231+ await db . query ( `
232+ INSERT INTO opportunities (
233+ title,
234+ created_at
235+ )
236+ VALUES
237+ ( '🌎', '2525-01-04T00:00:01Z' )
238+ ` ) ;
239+ await db . query ( `
240+ INSERT INTO applicants (
241+ external_id,
242+ opted_in,
243+ created_at
244+ )
245+ VALUES
246+ ( '🐯', 'true', '2525-01-04T00:00:02Z' ),
247+ ( '🐅', 'false', '2525-01-04T00:00:03Z' );
248+ ` ) ;
249+ await db . query ( `
250+ INSERT INTO application_forms (
251+ opportunity_id,
252+ version,
253+ created_at
254+ )
255+ VALUES
256+ ( 1, 1, '2525-01-04T00:00:04Z' )
257+ ` ) ;
258+ await db . query ( `
259+ INSERT INTO application_form_fields (
260+ application_form_id,
261+ canonical_field_id,
262+ position,
263+ label,
264+ created_at
265+ )
266+ VALUES
267+ ( 1, 2, 1, 'Short summary or title', '2525-01-04T00:00:05Z' ),
268+ ( 1, 1, 2, 'Long summary or abstract', '2525-01-04T00:00:06Z' );
269+ ` ) ;
270+ await db . query ( `
271+ INSERT INTO proposals (
272+ applicant_id,
273+ external_id,
274+ opportunity_id,
275+ created_at
276+ )
277+ VALUES
278+ ( 2, 'proposal-2525-01-04T00Z', 1, '2525-01-04T00:00:07Z' );
279+ ` ) ;
280+ await db . query ( `
281+ INSERT INTO proposal_versions (
282+ proposal_id,
283+ application_form_id,
284+ version,
285+ created_at
286+ )
287+ VALUES
288+ ( 1, 1, 1, '2525-01-04T00:00:08Z' ),
289+ ( 1, 1, 2, '2525-01-04T00:00:09Z' );
290+ ` ) ;
291+ await db . query ( `
292+ INSERT INTO proposal_field_values (
293+ proposal_version_id,
294+ application_form_field_id,
295+ position,
296+ value,
297+ created_at
298+ )
299+ VALUES
300+ ( 1, 1, 1, 'Title for version 1 from 2525-01-04', '2525-01-04T00:00:10Z' ),
301+ ( 1, 2, 2, 'Abstract for version 1 from 2525-01-04', '2525-01-04T00:00:11Z' ),
302+ ( 2, 1, 1, 'Title for version 2 from 2525-01-04', '2525-01-04T00:00:12Z' ),
303+ ( 2, 2, 2, 'Abstract for version 2 from 2525-01-04', '2525-01-04T00:00:13Z' );
304+ ` ) ;
305+ await agent
306+ . get ( '/proposals/1/?includeFieldsAndValues=true' )
307+ . set ( dummyApiKey )
308+ . expect (
309+ 200 ,
310+ {
311+ id : 1 ,
312+ applicantId : 2 ,
313+ opportunityId : 1 ,
314+ externalId : 'proposal-2525-01-04T00Z' ,
315+ createdAt : '2525-01-04T00:00:07.000Z' ,
316+ versions : [
317+ {
318+ id : 2 ,
319+ proposalId : 1 ,
320+ applicationFormId : 1 ,
321+ version : 2 ,
322+ createdAt : '2525-01-04T00:00:09.000Z' ,
323+ fieldValues : [
324+ {
325+ id : 3 ,
326+ proposalVersionId : 2 ,
327+ applicationFormFieldId : 1 ,
328+ position : 1 ,
329+ value : 'Title for version 2 from 2525-01-04' ,
330+ createdAt : '2525-01-04T00:00:12.000Z' ,
331+ applicationFormField : {
332+ id : 1 ,
333+ applicationFormId : 1 ,
334+ canonicalFieldId : 2 ,
335+ position : 1 ,
336+ label : 'Short summary or title' ,
337+ createdAt : '2525-01-04T00:00:05.000Z' ,
338+ } ,
339+ } ,
340+ {
341+ id : 4 ,
342+ proposalVersionId : 2 ,
343+ applicationFormFieldId : 2 ,
344+ position : 2 ,
345+ value : 'Abstract for version 2 from 2525-01-04' ,
346+ createdAt : '2525-01-04T00:00:13.000Z' ,
347+ applicationFormField : {
348+ id : 2 ,
349+ applicationFormId : 1 ,
350+ canonicalFieldId : 1 ,
351+ position : 2 ,
352+ label : 'Long summary or abstract' ,
353+ createdAt : '2525-01-04T00:00:06.000Z' ,
354+ } ,
355+ } ,
356+ ] ,
357+ } ,
358+ {
359+ id : 1 ,
360+ proposalId : 1 ,
361+ applicationFormId : 1 ,
362+ version : 1 ,
363+ createdAt : '2525-01-04T00:00:08.000Z' ,
364+ fieldValues : [
365+ {
366+ id : 1 ,
367+ proposalVersionId : 1 ,
368+ applicationFormFieldId : 1 ,
369+ position : 1 ,
370+ value : 'Title for version 1 from 2525-01-04' ,
371+ createdAt : '2525-01-04T00:00:10.000Z' ,
372+ applicationFormField : {
373+ id : 1 ,
374+ applicationFormId : 1 ,
375+ canonicalFieldId : 2 ,
376+ position : 1 ,
377+ label : 'Short summary or title' ,
378+ createdAt : '2525-01-04T00:00:05.000Z' ,
379+ } ,
380+ } ,
381+ {
382+ id : 2 ,
383+ proposalVersionId : 1 ,
384+ applicationFormFieldId : 2 ,
385+ position : 2 ,
386+ value : 'Abstract for version 1 from 2525-01-04' ,
387+ createdAt : '2525-01-04T00:00:11.000Z' ,
388+ applicationFormField : {
389+ id : 2 ,
390+ applicationFormId : 1 ,
391+ canonicalFieldId : 1 ,
392+ position : 2 ,
393+ label : 'Long summary or abstract' ,
394+ createdAt : '2525-01-04T00:00:06.000Z' ,
395+ } ,
396+ } ,
397+ ] ,
398+ } ,
399+ ] ,
400+ } ,
401+ ) ;
402+ } ) ;
403+
202404 it ( 'should error if the database returns an unexpected data structure' , async ( ) => {
203405 jest . spyOn ( db , 'sql' )
204406 . mockImplementationOnce ( async ( ) => ( {
@@ -255,6 +457,105 @@ describe('/proposals', () => {
255457 } ) ;
256458 } ) ;
257459
460+ it ( 'returns 404 when given id is not present and includeFieldsAndValues=true' , async ( ) => {
461+ await agent
462+ . get ( '/proposals/9002?includeFieldsAndValues=true' )
463+ . set ( dummyApiKey )
464+ . expect ( 404 , {
465+ name : 'NotFoundError' ,
466+ message : 'Not found. Find existing proposals by calling with no parameters.' ,
467+ details : [
468+ {
469+ name : 'NotFoundError' ,
470+ status : 404 ,
471+ } ,
472+ ] ,
473+ } ) ;
474+ } ) ;
475+
476+ it ( 'should error if the database returns an unexpected data structure when includeFieldsAndValues=true' , async ( ) => {
477+ jest . spyOn ( db , 'sql' )
478+ . mockImplementationOnce ( async ( ) => ( {
479+ rows : [ { foo : 'not a valid result' } ] ,
480+ } ) as Result < object > ) ;
481+ const result = await agent
482+ . get ( '/proposals/9003?includeFieldsAndValues=true' )
483+ . set ( dummyApiKey )
484+ . expect ( 500 ) ;
485+ expect ( result . body ) . toMatchObject ( {
486+ name : 'InternalValidationError' ,
487+ details : expect . any ( Array ) as unknown [ ] ,
488+ } ) ;
489+ } ) ;
490+
491+ it ( 'returns 500 UnknownError if a generic Error is thrown when selecting and includeFieldsAndValues=true' , async ( ) => {
492+ jest . spyOn ( db , 'sql' )
493+ . mockImplementationOnce ( async ( ) => {
494+ throw new Error ( 'This is unexpected' ) ;
495+ } ) ;
496+ const result = await agent
497+ . get ( '/proposals/9004?includeFieldsAndValues=true' )
498+ . set ( dummyApiKey )
499+ . expect ( 500 ) ;
500+ expect ( result . body ) . toMatchObject ( {
501+ name : 'UnknownError' ,
502+ details : expect . any ( Array ) as unknown [ ] ,
503+ } ) ;
504+ } ) ;
505+
506+ it ( 'returns 503 DatabaseError if db error is thrown when includeFieldsAndValues=true' , async ( ) => {
507+ await db . query ( `
508+ INSERT INTO opportunities (
509+ title,
510+ created_at
511+ )
512+ VALUES
513+ ( '🧳', '2525-01-04T00:00:14Z' )
514+ ` ) ;
515+ await db . query ( `
516+ INSERT INTO applicants (
517+ external_id,
518+ opted_in,
519+ created_at
520+ )
521+ VALUES
522+ ( '🐴', 'true', '2525-01-04T00:00:15Z' );
523+ ` ) ;
524+ await db . query ( `
525+ INSERT INTO proposals (
526+ applicant_id,
527+ external_id,
528+ opportunity_id,
529+ created_at
530+ )
531+ VALUES
532+ ( 1, 'proposal-🧳-🐴', 1, '2525-01-04T00:00:16Z' );
533+ ` ) ;
534+ jest . spyOn ( db , 'sql' )
535+ . mockImplementationOnce ( async ( ) => {
536+ throw new TinyPgError (
537+ 'Something went wrong' ,
538+ undefined ,
539+ {
540+ error : {
541+ code : PostgresErrorCode . INSUFFICIENT_RESOURCES ,
542+ } ,
543+ } ,
544+ ) ;
545+ } ) ;
546+ const result = await agent
547+ . get ( '/proposals/1?includeFieldsAndValues=true' )
548+ . type ( 'application/json' )
549+ . set ( dummyApiKey )
550+ . expect ( 503 ) ;
551+ expect ( result . body ) . toMatchObject ( {
552+ name : 'DatabaseError' ,
553+ details : [ {
554+ code : PostgresErrorCode . INSUFFICIENT_RESOURCES ,
555+ } ] ,
556+ } ) ;
557+ } ) ;
558+
258559 describe ( 'POST /' , ( ) => {
259560 it ( 'creates exactly one proposal' , async ( ) => {
260561 await db . query ( `
0 commit comments