@@ -26,6 +26,7 @@ import {
2626 opportunityKeywordsFixture ,
2727 opportunityMatchesFixture ,
2828 opportunityQuestionsFixture ,
29+ opportunityFeedbackQuestionsFixture ,
2930 organizationsFixture ,
3031} from '../fixture/opportunity' ;
3132import { OpportunityUser } from '../../src/entity/opportunities/user' ;
@@ -57,6 +58,7 @@ import { fileTypeFromBuffer } from '../setup';
5758import { EMPLOYMENT_AGREEMENT_BUCKET_NAME } from '../../src/config' ;
5859import { RoleType } from '../../src/common/schema/userCandidate' ;
5960import { QuestionType } from '../../src/entity/questions/types' ;
61+ import { QuestionFeedback } from '../../src/entity/questions/QuestionFeedback' ;
6062import type { FastifyInstance } from 'fastify' ;
6163import type { Context } from '../../src/Context' ;
6264import { createMockGondulTransport } from '../helpers' ;
@@ -105,6 +107,11 @@ beforeEach(async () => {
105107 await saveFixtures ( con , Organization , organizationsFixture ) ;
106108 await saveFixtures ( con , Opportunity , opportunitiesFixture ) ;
107109 await saveFixtures ( con , QuestionScreening , opportunityQuestionsFixture ) ;
110+ await saveFixtures (
111+ con ,
112+ QuestionFeedback ,
113+ opportunityFeedbackQuestionsFixture ,
114+ ) ;
108115 await saveFixtures ( con , OpportunityKeyword , opportunityKeywordsFixture ) ;
109116 await saveFixtures ( con , OpportunityMatch , opportunityMatchesFixture ) ;
110117 await saveFixtures ( con , OpportunityUser , [
@@ -189,6 +196,13 @@ describe('query opportunityById', () => {
189196 placeholder
190197 opportunityId
191198 }
199+ feedbackQuestions {
200+ id
201+ title
202+ order
203+ placeholder
204+ opportunityId
205+ }
192206 }
193207 }
194208
@@ -300,7 +314,58 @@ describe('query opportunityById', () => {
300314 order : 0 ,
301315 } ,
302316 ] ) ,
317+ feedbackQuestions : expect . arrayContaining ( [
318+ {
319+ id : '850e8400-e29b-41d4-a716-446655440001' ,
320+ title : 'How did you hear about this opportunity?' ,
321+ placeholder : 'e.g., LinkedIn, friend, etc.' ,
322+ opportunityId : '550e8400-e29b-41d4-a716-446655440001' ,
323+ order : 0 ,
324+ } ,
325+ {
326+ id : '850e8400-e29b-41d4-a716-446655440002' ,
327+ title : 'What interests you most about this role?' ,
328+ placeholder : 'Your answer here...' ,
329+ opportunityId : '550e8400-e29b-41d4-a716-446655440001' ,
330+ order : 1 ,
331+ } ,
332+ ] ) ,
333+ } ) ;
334+ } ) ;
335+
336+ it ( 'should correctly separate screening and feedback questions by type' , async ( ) => {
337+ // This test ensures that questions and feedbackQuestions
338+ // are properly filtered by their type discriminator
339+ const res = await client . query <
340+ { opportunityById : GQLOpportunity } ,
341+ { id : string }
342+ > ( OPPORTUNITY_BY_ID_QUERY , {
343+ variables : { id : '550e8400-e29b-41d4-a716-446655440001' } ,
303344 } ) ;
345+
346+ expect ( res . errors ) . toBeFalsy ( ) ;
347+
348+ // Verify screening questions only contain screening type (IDs starting with 750e)
349+ expect ( res . data . opportunityById . questions ) . toHaveLength ( 2 ) ;
350+ expect (
351+ res . data . opportunityById . questions . every ( ( q ) => q . id . startsWith ( '750e' ) ) ,
352+ ) . toBe ( true ) ;
353+
354+ // Verify feedback questions only contain feedback type (IDs starting with 850e)
355+ expect ( res . data . opportunityById . feedbackQuestions ) . toHaveLength ( 2 ) ;
356+ expect (
357+ res . data . opportunityById . feedbackQuestions . every ( ( q ) =>
358+ q . id . startsWith ( '850e' ) ,
359+ ) ,
360+ ) . toBe ( true ) ;
361+
362+ // Verify no overlap - screening questions should not appear in feedback
363+ const screeningIds = res . data . opportunityById . questions . map ( ( q ) => q . id ) ;
364+ const feedbackIds = res . data . opportunityById . feedbackQuestions . map (
365+ ( q ) => q . id ,
366+ ) ;
367+ const hasOverlap = screeningIds . some ( ( id ) => feedbackIds . includes ( id ) ) ;
368+ expect ( hasOverlap ) . toBe ( false ) ;
304369 } ) ;
305370
306371 it ( 'should return UNEXPECTED for false UUID opportunity' , async ( ) => {
@@ -1062,6 +1127,209 @@ describe('mutation saveOpportunityScreeningAnswers', () => {
10621127 } ) ;
10631128} ) ;
10641129
1130+ describe ( 'mutation saveOpportunityFeedbackAnswers' , ( ) => {
1131+ const MUTATION = /* GraphQL */ `
1132+ mutation SaveOpportunityFeedbackAnswers(
1133+ $id: ID!
1134+ $answers: [OpportunityScreeningAnswerInput!]!
1135+ ) {
1136+ saveOpportunityFeedbackAnswers(id: $id, answers: $answers) {
1137+ _
1138+ }
1139+ }
1140+ ` ;
1141+
1142+ it ( 'should require authentication' , async ( ) => {
1143+ await testMutationErrorCode (
1144+ client ,
1145+ {
1146+ mutation : MUTATION ,
1147+ variables : {
1148+ id : '550e8400-e29b-41d4-a716-446655440001' ,
1149+ answers : [
1150+ {
1151+ questionId : '850e8400-e29b-41d4-a716-446655440001' ,
1152+ answer : 'From a friend' ,
1153+ } ,
1154+ ] ,
1155+ } ,
1156+ } ,
1157+ 'UNAUTHENTICATED' ,
1158+ ) ;
1159+ } ) ;
1160+
1161+ it ( 'should save feedback answers for authenticated user' , async ( ) => {
1162+ loggedUser = '1' ;
1163+
1164+ const res = await client . mutate ( MUTATION , {
1165+ variables : {
1166+ id : '550e8400-e29b-41d4-a716-446655440001' ,
1167+ answers : [
1168+ {
1169+ questionId : '850e8400-e29b-41d4-a716-446655440001' ,
1170+ answer : 'From a friend' ,
1171+ } ,
1172+ {
1173+ questionId : '850e8400-e29b-41d4-a716-446655440002' ,
1174+ answer : 'The company culture' ,
1175+ } ,
1176+ ] ,
1177+ } ,
1178+ } ) ;
1179+
1180+ expect ( res . errors ) . toBeFalsy ( ) ;
1181+ expect ( res . data . saveOpportunityFeedbackAnswers ) . toEqual ( { _ : true } ) ;
1182+
1183+ const match = await con . getRepository ( OpportunityMatch ) . findOneByOrFail ( {
1184+ opportunityId : '550e8400-e29b-41d4-a716-446655440001' ,
1185+ userId : '1' ,
1186+ } ) ;
1187+
1188+ expect ( match . feedback ) . toEqual (
1189+ expect . arrayContaining ( [
1190+ {
1191+ screening : 'How did you hear about this opportunity?' ,
1192+ answer : 'From a friend' ,
1193+ } ,
1194+ {
1195+ screening : 'What interests you most about this role?' ,
1196+ answer : 'The company culture' ,
1197+ } ,
1198+ ] ) ,
1199+ ) ;
1200+ } ) ;
1201+
1202+ it ( 'should allow partial feedback answers since they are optional' , async ( ) => {
1203+ loggedUser = '1' ;
1204+
1205+ const res = await client . mutate ( MUTATION , {
1206+ variables : {
1207+ id : '550e8400-e29b-41d4-a716-446655440001' ,
1208+ answers : [
1209+ {
1210+ questionId : '850e8400-e29b-41d4-a716-446655440001' ,
1211+ answer : 'From LinkedIn' ,
1212+ } ,
1213+ ] ,
1214+ } ,
1215+ } ) ;
1216+
1217+ expect ( res . errors ) . toBeFalsy ( ) ;
1218+ expect ( res . data . saveOpportunityFeedbackAnswers ) . toEqual ( { _ : true } ) ;
1219+
1220+ const match = await con . getRepository ( OpportunityMatch ) . findOneByOrFail ( {
1221+ opportunityId : '550e8400-e29b-41d4-a716-446655440001' ,
1222+ userId : '1' ,
1223+ } ) ;
1224+
1225+ expect ( match . feedback ) . toEqual ( [
1226+ {
1227+ screening : 'How did you hear about this opportunity?' ,
1228+ answer : 'From LinkedIn' ,
1229+ } ,
1230+ ] ) ;
1231+ } ) ;
1232+
1233+ it ( 'should allow empty feedback answers since they are optional' , async ( ) => {
1234+ loggedUser = '1' ;
1235+
1236+ const res = await client . mutate ( MUTATION , {
1237+ variables : {
1238+ id : '550e8400-e29b-41d4-a716-446655440001' ,
1239+ answers : [ ] ,
1240+ } ,
1241+ } ) ;
1242+
1243+ expect ( res . errors ) . toBeFalsy ( ) ;
1244+ expect ( res . data . saveOpportunityFeedbackAnswers ) . toEqual ( { _ : true } ) ;
1245+
1246+ const match = await con . getRepository ( OpportunityMatch ) . findOneByOrFail ( {
1247+ opportunityId : '550e8400-e29b-41d4-a716-446655440001' ,
1248+ userId : '1' ,
1249+ } ) ;
1250+
1251+ expect ( match . feedback ) . toEqual ( [ ] ) ;
1252+ } ) ;
1253+
1254+ it ( 'should return FORBIDDEN when match does not exist' , async ( ) => {
1255+ loggedUser = '3' ;
1256+
1257+ await testMutationErrorCode (
1258+ client ,
1259+ {
1260+ mutation : MUTATION ,
1261+ variables : {
1262+ id : '550e8400-e29b-41d4-a716-446655440001' ,
1263+ answers : [
1264+ {
1265+ questionId : '850e8400-e29b-41d4-a716-446655440001' ,
1266+ answer : 'From a friend' ,
1267+ } ,
1268+ ] ,
1269+ } ,
1270+ } ,
1271+ 'FORBIDDEN' ,
1272+ 'Access denied! No match found' ,
1273+ ) ;
1274+ } ) ;
1275+
1276+ it ( 'should return error when there are duplicate answers by questionId' , async ( ) => {
1277+ loggedUser = '1' ;
1278+
1279+ await testMutationErrorCode (
1280+ client ,
1281+ {
1282+ mutation : MUTATION ,
1283+ variables : {
1284+ id : '550e8400-e29b-41d4-a716-446655440001' ,
1285+ answers : [
1286+ {
1287+ questionId : '850e8400-e29b-41d4-a716-446655440001' ,
1288+ answer : 'From a friend' ,
1289+ } ,
1290+ {
1291+ questionId : '850e8400-e29b-41d4-a716-446655440001' ,
1292+ answer : 'From LinkedIn' ,
1293+ } ,
1294+ ] ,
1295+ } ,
1296+ } ,
1297+ 'ZOD_VALIDATION_ERROR' ,
1298+ 'Validation error' ,
1299+ ( errors ) => {
1300+ const extensions = errors [ 0 ] . extensions as unknown as ZodError ;
1301+ expect ( extensions . issues . length ) . toEqual ( 1 ) ;
1302+ expect ( extensions . issues [ 0 ] . code ) . toEqual ( 'custom' ) ;
1303+ expect ( extensions . issues [ 0 ] . message ) . toEqual (
1304+ 'Duplicate questionId 850e8400-e29b-41d4-a716-446655440001' ,
1305+ ) ;
1306+ } ,
1307+ ) ;
1308+ } ) ;
1309+
1310+ it ( 'should return error when the questionId does not belong to opportunity' , async ( ) => {
1311+ loggedUser = '1' ;
1312+
1313+ await testMutationErrorCode (
1314+ client ,
1315+ {
1316+ mutation : MUTATION ,
1317+ variables : {
1318+ id : '550e8400-e29b-41d4-a716-446655440001' ,
1319+ answers : [
1320+ {
1321+ questionId : '750e8400-e29b-41d4-a716-446655440003' ,
1322+ answer : 'Invalid question' ,
1323+ } ,
1324+ ] ,
1325+ } ,
1326+ } ,
1327+ 'CONFLICT' ,
1328+ 'Question 750e8400-e29b-41d4-a716-446655440003 not found for opportunity' ,
1329+ ) ;
1330+ } ) ;
1331+ } ) ;
1332+
10651333describe ( 'mutation acceptOpportunityMatch' , ( ) => {
10661334 const MUTATION = /* GraphQL */ `
10671335 mutation AcceptOpportunityMatch($id: ID!) {
0 commit comments