Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions __tests__/fixture/opportunity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
SocialMediaType,
} from '../../src/common/schema/organizations';
import type { QuestionScreening } from '../../src/entity/questions/QuestionScreening';
import type { QuestionFeedback } from '../../src/entity/questions/QuestionFeedback';
import { demoCompany } from '../../src/common';

export const organizationsFixture: DeepPartial<Organization>[] = [
Expand Down Expand Up @@ -313,3 +314,21 @@ export const opportunityMatchesFixture: DeepPartial<OpportunityMatch>[] = [
updatedAt: new Date('2023-01-03'),
},
];

export const opportunityFeedbackQuestionsFixture: DeepPartial<QuestionFeedback>[] =
[
{
id: '850e8400-e29b-41d4-a716-446655440001',
title: 'How did you hear about this opportunity?',
placeholder: 'e.g., LinkedIn, friend, etc.',
opportunityId: opportunitiesFixture[0].id,
questionOrder: 0,
},
{
id: '850e8400-e29b-41d4-a716-446655440002',
title: 'What interests you most about this role?',
placeholder: 'Your answer here...',
opportunityId: opportunitiesFixture[0].id,
questionOrder: 1,
},
];
268 changes: 268 additions & 0 deletions __tests__/schema/opportunity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
opportunityKeywordsFixture,
opportunityMatchesFixture,
opportunityQuestionsFixture,
opportunityFeedbackQuestionsFixture,
organizationsFixture,
} from '../fixture/opportunity';
import { OpportunityUser } from '../../src/entity/opportunities/user';
Expand Down Expand Up @@ -57,6 +58,7 @@ import { fileTypeFromBuffer } from '../setup';
import { EMPLOYMENT_AGREEMENT_BUCKET_NAME } from '../../src/config';
import { RoleType } from '../../src/common/schema/userCandidate';
import { QuestionType } from '../../src/entity/questions/types';
import { QuestionFeedback } from '../../src/entity/questions/QuestionFeedback';
import type { FastifyInstance } from 'fastify';
import type { Context } from '../../src/Context';
import { createMockGondulTransport } from '../helpers';
Expand Down Expand Up @@ -105,6 +107,11 @@ beforeEach(async () => {
await saveFixtures(con, Organization, organizationsFixture);
await saveFixtures(con, Opportunity, opportunitiesFixture);
await saveFixtures(con, QuestionScreening, opportunityQuestionsFixture);
await saveFixtures(
con,
QuestionFeedback,
opportunityFeedbackQuestionsFixture,
);
await saveFixtures(con, OpportunityKeyword, opportunityKeywordsFixture);
await saveFixtures(con, OpportunityMatch, opportunityMatchesFixture);
await saveFixtures(con, OpportunityUser, [
Expand Down Expand Up @@ -189,6 +196,13 @@ describe('query opportunityById', () => {
placeholder
opportunityId
}
feedbackQuestions {
id
title
order
placeholder
opportunityId
}
}
}

Expand Down Expand Up @@ -300,7 +314,58 @@ describe('query opportunityById', () => {
order: 0,
},
]),
feedbackQuestions: expect.arrayContaining([
{
id: '850e8400-e29b-41d4-a716-446655440001',
title: 'How did you hear about this opportunity?',
placeholder: 'e.g., LinkedIn, friend, etc.',
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
order: 0,
},
{
id: '850e8400-e29b-41d4-a716-446655440002',
title: 'What interests you most about this role?',
placeholder: 'Your answer here...',
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
order: 1,
},
]),
});
});

it('should correctly separate screening and feedback questions by type', async () => {
// This test ensures that questions and feedbackQuestions
// are properly filtered by their type discriminator
const res = await client.query<
{ opportunityById: GQLOpportunity },
{ id: string }
>(OPPORTUNITY_BY_ID_QUERY, {
variables: { id: '550e8400-e29b-41d4-a716-446655440001' },
});

expect(res.errors).toBeFalsy();

// Verify screening questions only contain screening type (IDs starting with 750e)
expect(res.data.opportunityById.questions).toHaveLength(2);
expect(
res.data.opportunityById.questions.every((q) => q.id.startsWith('750e')),
).toBe(true);

// Verify feedback questions only contain feedback type (IDs starting with 850e)
expect(res.data.opportunityById.feedbackQuestions).toHaveLength(2);
expect(
res.data.opportunityById.feedbackQuestions.every((q) =>
q.id.startsWith('850e'),
),
).toBe(true);

// Verify no overlap - screening questions should not appear in feedback
const screeningIds = res.data.opportunityById.questions.map((q) => q.id);
const feedbackIds = res.data.opportunityById.feedbackQuestions.map(
(q) => q.id,
);
const hasOverlap = screeningIds.some((id) => feedbackIds.includes(id));
expect(hasOverlap).toBe(false);
});

it('should return UNEXPECTED for false UUID opportunity', async () => {
Expand Down Expand Up @@ -1062,6 +1127,209 @@ describe('mutation saveOpportunityScreeningAnswers', () => {
});
});

describe('mutation saveOpportunityFeedbackAnswers', () => {
const MUTATION = /* GraphQL */ `
mutation SaveOpportunityFeedbackAnswers(
$id: ID!
$answers: [OpportunityScreeningAnswerInput!]!
) {
saveOpportunityFeedbackAnswers(id: $id, answers: $answers) {
_
}
}
`;

it('should require authentication', async () => {
await testMutationErrorCode(
client,
{
mutation: MUTATION,
variables: {
id: '550e8400-e29b-41d4-a716-446655440001',
answers: [
{
questionId: '850e8400-e29b-41d4-a716-446655440001',
answer: 'From a friend',
},
],
},
},
'UNAUTHENTICATED',
);
});

it('should save feedback answers for authenticated user', async () => {
loggedUser = '1';

const res = await client.mutate(MUTATION, {
variables: {
id: '550e8400-e29b-41d4-a716-446655440001',
answers: [
{
questionId: '850e8400-e29b-41d4-a716-446655440001',
answer: 'From a friend',
},
{
questionId: '850e8400-e29b-41d4-a716-446655440002',
answer: 'The company culture',
},
],
},
});

expect(res.errors).toBeFalsy();
expect(res.data.saveOpportunityFeedbackAnswers).toEqual({ _: true });

const match = await con.getRepository(OpportunityMatch).findOneByOrFail({
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
userId: '1',
});

expect(match.feedback).toEqual(
expect.arrayContaining([
{
screening: 'How did you hear about this opportunity?',
answer: 'From a friend',
},
{
screening: 'What interests you most about this role?',
answer: 'The company culture',
},
]),
);
});

it('should allow partial feedback answers since they are optional', async () => {
loggedUser = '1';

const res = await client.mutate(MUTATION, {
variables: {
id: '550e8400-e29b-41d4-a716-446655440001',
answers: [
{
questionId: '850e8400-e29b-41d4-a716-446655440001',
answer: 'From LinkedIn',
},
],
},
});

expect(res.errors).toBeFalsy();
expect(res.data.saveOpportunityFeedbackAnswers).toEqual({ _: true });

const match = await con.getRepository(OpportunityMatch).findOneByOrFail({
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
userId: '1',
});

expect(match.feedback).toEqual([
{
screening: 'How did you hear about this opportunity?',
answer: 'From LinkedIn',
},
]);
});

it('should allow empty feedback answers since they are optional', async () => {
loggedUser = '1';

const res = await client.mutate(MUTATION, {
variables: {
id: '550e8400-e29b-41d4-a716-446655440001',
answers: [],
},
});

expect(res.errors).toBeFalsy();
expect(res.data.saveOpportunityFeedbackAnswers).toEqual({ _: true });

const match = await con.getRepository(OpportunityMatch).findOneByOrFail({
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
userId: '1',
});

expect(match.feedback).toEqual([]);
});

it('should return FORBIDDEN when match does not exist', async () => {
loggedUser = '3';

await testMutationErrorCode(
client,
{
mutation: MUTATION,
variables: {
id: '550e8400-e29b-41d4-a716-446655440001',
answers: [
{
questionId: '850e8400-e29b-41d4-a716-446655440001',
answer: 'From a friend',
},
],
},
},
'FORBIDDEN',
'Access denied! No match found',
);
});

it('should return error when there are duplicate answers by questionId', async () => {
loggedUser = '1';

await testMutationErrorCode(
client,
{
mutation: MUTATION,
variables: {
id: '550e8400-e29b-41d4-a716-446655440001',
answers: [
{
questionId: '850e8400-e29b-41d4-a716-446655440001',
answer: 'From a friend',
},
{
questionId: '850e8400-e29b-41d4-a716-446655440001',
answer: 'From LinkedIn',
},
],
},
},
'ZOD_VALIDATION_ERROR',
'Validation error',
(errors) => {
const extensions = errors[0].extensions as unknown as ZodError;
expect(extensions.issues.length).toEqual(1);
expect(extensions.issues[0].code).toEqual('custom');
expect(extensions.issues[0].message).toEqual(
'Duplicate questionId 850e8400-e29b-41d4-a716-446655440001',
);
},
);
});

it('should return error when the questionId does not belong to opportunity', async () => {
loggedUser = '1';

await testMutationErrorCode(
client,
{
mutation: MUTATION,
variables: {
id: '550e8400-e29b-41d4-a716-446655440001',
answers: [
{
questionId: '750e8400-e29b-41d4-a716-446655440003',
answer: 'Invalid question',
},
],
},
},
'CONFLICT',
'Question 750e8400-e29b-41d4-a716-446655440003 not found for opportunity',
);
});
});

describe('mutation acceptOpportunityMatch', () => {
const MUTATION = /* GraphQL */ `
mutation AcceptOpportunityMatch($id: ID!) {
Expand Down
25 changes: 25 additions & 0 deletions src/common/schema/opportunityMatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,28 @@ export const opportunityScreeningAnswersSchema = z.object({
});
}),
});

export const opportunityFeedbackAnswersSchema = z.object({
id: z.uuid(),
answers: z
.array(
z.object({
questionId: z.uuid(),
answer: z.string().max(500),
}),
)
.superRefine((answers, ctx) => {
const seen = new Map();
answers.forEach((answer, i) => {
if (seen.has(answer.questionId)) {
ctx.addIssue({
code: 'custom',
message: `Duplicate questionId ${answer.questionId}`,
path: [i],
});
} else {
seen.set(answer.questionId, i);
}
});
}),
});
3 changes: 3 additions & 0 deletions src/entity/OpportunityMatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ export class OpportunityMatch {
@Column({ type: 'jsonb', default: '[]' })
screening: Array<Screening>;

@Column({ type: 'jsonb', default: '[]' })
feedback: Array<Screening>;

@Column({ type: 'jsonb', default: '{}' })
applicationRank: z.infer<typeof applicationScoreSchema>;

Expand Down
8 changes: 8 additions & 0 deletions src/entity/opportunities/Opportunity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { OpportunityUser } from './user';
import type { OpportunityKeyword } from '../OpportunityKeyword';
import type { OpportunityMatch } from '../OpportunityMatch';
import type { QuestionScreening } from '../questions/QuestionScreening';
import type { QuestionFeedback } from '../questions/QuestionFeedback';

@Entity()
@TableInheritance({ column: { type: 'text', name: 'type' } })
Expand Down Expand Up @@ -85,4 +86,11 @@ export class Opportunity {
{ lazy: true },
)
questions: Promise<QuestionScreening[]>;

@OneToMany(
'QuestionFeedback',
(question: QuestionFeedback) => question.opportunity,
{ lazy: true },
)
feedbackQuestions: Promise<QuestionFeedback[]>;
}
Loading
Loading