diff --git a/src/flows/Onboarding/hooks/useJobTitleEligibilityCheck.tsx b/src/flows/Onboarding/hooks/useJobTitleEligibilityCheck.tsx index e2c96b63f..eac4d3b98 100644 --- a/src/flows/Onboarding/hooks/useJobTitleEligibilityCheck.tsx +++ b/src/flows/Onboarding/hooks/useJobTitleEligibilityCheck.tsx @@ -103,11 +103,10 @@ export const useJobTitleEligibilityCheck = ({ validation?.formErrors, jobTitle, ); - const paramsChanged = !equal(paramsRef.current, nextParams); - if (paramsChanged) { + if (!equal(paramsRef.current, nextParams)) { setParams(nextParams); } - if (nextParams && paramsChanged) { + if (nextParams) { await queryClient .query(getOptions(nextParams)) .catch(() => diff --git a/src/flows/Onboarding/tests/OnboardingFlowJobTitleEligibility.test.tsx b/src/flows/Onboarding/tests/OnboardingFlowJobTitleEligibility.test.tsx new file mode 100644 index 000000000..95f3d3301 --- /dev/null +++ b/src/flows/Onboarding/tests/OnboardingFlowJobTitleEligibility.test.tsx @@ -0,0 +1,207 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { OnboardingFlow } from '@/src/flows/Onboarding/OnboardingFlow'; +import { + contractDetailsSchemaV1JobTitleEligibility, + employmentDefaultResponse, +} from '@/src/flows/Onboarding/tests/fixtures'; +import { OnboardingRenderProps } from '@/src/flows/Onboarding/types'; +import { fillRadio, queryClient, TestProviders } from '@/src/tests/testHelpers'; +import { server } from '@/src/tests/server'; +import { $TSFixMe } from '@/src/types/remoteFlows'; + +describe('OnboardingFlow - job title eligibility check on blur', () => { + let latestOnboardingBag: $TSFixMe; + + const mockRender = vi.fn( + ({ onboardingBag, components }: OnboardingRenderProps) => { + latestOnboardingBag = onboardingBag; + const { ContractDetailsStep } = components; + + if (onboardingBag.stepState.currentStep.name !== 'contract_details') { + return null; + } + + return ; + }, + ); + + const jobTitleEligibilityCheckSpy = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockRender.mockReset(); + queryClient.clear(); + + server.use( + http.get('*/v1/employments/:id', ({ params }) => { + return HttpResponse.json({ + ...employmentDefaultResponse, + data: { + ...employmentDefaultResponse.data, + employment: { + ...employmentDefaultResponse.data.employment, + id: params?.id, + country: { + code: 'ITA', + name: 'Italy', + alpha_2_code: 'IT', + supported_json_schemas: ['employment_basic_information'], + }, + }, + }, + }); + }), + http.get('*/v1/countries/ITA/employment_basic_information*', () => { + return HttpResponse.json({ + data: { properties: { name: { type: 'string', title: 'Name' } } }, + }); + }), + http.get('*/v1/countries/ITA/contract_details*', () => { + return HttpResponse.json(contractDetailsSchemaV1JobTitleEligibility); + }), + http.post( + '*/v2/employments/:id/job-title-eligibility-check', + async ({ request }) => { + jobTitleEligibilityCheckSpy(await request.json()); + return HttpResponse.json({ + data: { + job_title_eligibility_check: { + slug: 'job-title-eligibility-check-slug', + result: 'yes', + }, + }, + }); + }, + ), + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const renderContractDetailsStep = async () => { + render( + , + { wrapper: TestProviders }, + ); + + await waitFor(() => expect(latestOnboardingBag.isLoading).toBe(false)); + + act(() => { + latestOnboardingBag.goTo('contract_details'); + }); + + await screen.findByLabelText(/Role description/i); + }; + + const fillRoleFields = async () => { + const user = userEvent.setup(); + const roleDescription = screen.getByLabelText(/Role description/i); + + await user.clear(roleDescription); + await user.type(roleDescription, 'Backend engineer responsibilities'); + await fillRadio('Will this role require working onsite', 'no'); + await fillRadio('Does this role require a professional license', 'no'); + // Blur the last field touched so the values captured on blur include + // all three role fields already committed. + await user.tab(); + }; + + it('calls the job title eligibility check once the role fields are filled and blurred', async () => { + await renderContractDetailsStep(); + + await fillRoleFields(); + + await waitFor(() => + expect(jobTitleEligibilityCheckSpy).toHaveBeenCalledTimes(1), + ); + expect(jobTitleEligibilityCheckSpy).toHaveBeenCalledWith({ + job_title: 'pm', + role_description: 'Backend engineer responsibilities', + role_is_onsite: 'no', + role_requires_license: 'no', + }); + }); + + it('does not call the check again when a later blur carries the same values', async () => { + await renderContractDetailsStep(); + + await fillRoleFields(); + await waitFor(() => + expect(jobTitleEligibilityCheckSpy).toHaveBeenCalledTimes(1), + ); + + const user = userEvent.setup(); + const roleDescription = screen.getByLabelText(/Role description/i); + await user.click(roleDescription); + await user.tab(); + + // user.tab() doesn't wait for checkJobTitleEligibility - the onBlur handler + // doesn't await it, so a duplicate request (if the dedup were ever broken) + // could still be in flight right now. We wait up to 200ms to give it a + // chance to show up before checking that it didn't. waitFor normally throws + // if its condition never comes true, but here that's the outcome we want, + // so we swallow it with .catch and let the assertion below do the real check. + await waitFor( + () => { + expect(jobTitleEligibilityCheckSpy.mock.calls.length).toBeGreaterThan( + 1, + ); + }, + { timeout: 200 }, + ).catch(() => undefined); + + expect(jobTitleEligibilityCheckSpy).toHaveBeenCalledTimes(1); + }); + + it('retries the check on a later blur when the previous request failed', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + let failing = true; + const successSpy = vi.fn(); + server.use( + http.post('*/v2/employments/:id/job-title-eligibility-check', () => { + if (failing) { + return HttpResponse.json( + { message: 'Internal error' }, + { status: 500 }, + ); + } + successSpy(); + return HttpResponse.json({ + data: { + job_title_eligibility_check: { + slug: 'job-title-eligibility-check-slug', + result: 'yes', + }, + }, + }); + }), + ); + + await renderContractDetailsStep(); + + await fillRoleFields(); + await waitFor(() => + expect(console.error).toHaveBeenCalledWith( + 'Failed to fetch job title eligibility check', + ), + ); + expect(successSpy).not.toHaveBeenCalled(); + + failing = false; + const user = userEvent.setup(); + await user.click(screen.getByLabelText(/Role description/i)); + await user.tab(); + + await waitFor(() => expect(successSpy).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/src/flows/Onboarding/tests/fixtures/contractDetails/index.ts b/src/flows/Onboarding/tests/fixtures/contractDetails/index.ts index bfef88c11..14f25a20c 100644 --- a/src/flows/Onboarding/tests/fixtures/contractDetails/index.ts +++ b/src/flows/Onboarding/tests/fixtures/contractDetails/index.ts @@ -3,3 +3,4 @@ export * from './v1-southKorea'; export * from './v1-germany'; export * from './v1-france'; export * from './v1-italy-apl'; +export * from './v1-job-title-eligibility'; diff --git a/src/flows/Onboarding/tests/fixtures/contractDetails/v1-job-title-eligibility.ts b/src/flows/Onboarding/tests/fixtures/contractDetails/v1-job-title-eligibility.ts new file mode 100644 index 000000000..f7e453fe8 --- /dev/null +++ b/src/flows/Onboarding/tests/fixtures/contractDetails/v1-job-title-eligibility.ts @@ -0,0 +1,66 @@ +/** + * Trimmed down jsf v1 contract details schema exposing only the fields the + * job title eligibility check cares about: role_description, role_is_onsite, + * role_requires_license, and the additional_job_title_eligibility_check_slug + * field whose presence gates the check. + */ +export const contractDetailsSchemaV1JobTitleEligibility = { + data: { + additionalProperties: false, + properties: { + role_description: { + title: 'Role description', + type: 'string', + 'x-jsf-presentation': { + inputType: 'textarea', + }, + }, + role_is_onsite: { + oneOf: [ + { + const: 'yes', + title: 'Yes', + }, + { + const: 'no', + title: 'No', + }, + ], + title: 'Will this role require working onsite?', + type: 'string', + 'x-jsf-presentation': { + direction: 'row', + inputType: 'radio', + }, + }, + role_requires_license: { + oneOf: [ + { + const: 'yes', + title: 'Yes', + }, + { + const: 'no', + title: 'No', + }, + ], + title: 'Does this role require a professional license?', + type: 'string', + 'x-jsf-presentation': { + direction: 'row', + inputType: 'radio', + }, + }, + additional_job_title_eligibility_check_slug: { + type: ['string', 'null'], + 'x-jsf-presentation': { + inputType: 'hidden', + }, + }, + }, + type: 'object', + 'x-rmt-meta': { + jsfVersion: '1', + }, + }, +}; diff --git a/src/flows/Onboarding/tests/utils.test.ts b/src/flows/Onboarding/tests/utils.test.ts index a740e7830..cbc358dc2 100644 --- a/src/flows/Onboarding/tests/utils.test.ts +++ b/src/flows/Onboarding/tests/utils.test.ts @@ -1,8 +1,11 @@ +import { $TSFixMe } from '@/src/types/remoteFlows'; import { buildSteps, getBasicInformationSchemaVersion, getBenefitOffersSchemaVersion, getContractDetailsSchemaVersion, + getJobTitleEligibilityParams, + JOB_TITLE_ELIGIBILITY_SLUG_FIELD, usesJsfV1ContractDetails, } from '../utils'; @@ -267,3 +270,88 @@ describe('buildSteps', () => { }); }); }); + +describe('getJobTitleEligibilityParams', () => { + const buildField = (name: string, isVisible = true): $TSFixMe => ({ + name, + isVisible, + }); + + const roleFields = [ + buildField(JOB_TITLE_ELIGIBILITY_SLUG_FIELD), + buildField('role_description'), + buildField('role_is_onsite'), + buildField('role_requires_license'), + ]; + + const filledValues = { + role_description: 'Backend engineer', + role_is_onsite: 'no', + role_requires_license: 'no', + }; + + it('returns null when the slug field is not part of the schema', () => { + const fields = roleFields.filter( + (field) => field.name !== JOB_TITLE_ELIGIBILITY_SLUG_FIELD, + ); + + const result = getJobTitleEligibilityParams(fields, filledValues); + + expect(result).toBeNull(); + }); + + it('returns null when a role field is missing a value', () => { + const result = getJobTitleEligibilityParams(roleFields, { + ...filledValues, + role_description: '', + }); + + expect(result).toBeNull(); + }); + + it('returns null when a role field has a validation error', () => { + const result = getJobTitleEligibilityParams(roleFields, filledValues, { + role_description: 'Too short', + }); + + expect(result).toBeNull(); + }); + + it('ignores a role field that is hidden by conditional logic', () => { + const fields = [ + buildField(JOB_TITLE_ELIGIBILITY_SLUG_FIELD), + buildField('role_description'), + buildField('role_is_onsite', false), + buildField('role_requires_license'), + ]; + + const result = getJobTitleEligibilityParams(fields, filledValues); + + expect(result).toEqual({ + role_description: 'Backend engineer', + role_requires_license: 'no', + }); + }); + + it('returns the params with job_title when all role fields are filled and valid', () => { + const result = getJobTitleEligibilityParams( + roleFields, + filledValues, + null, + 'Software Engineer', + ); + + expect(result).toEqual({ + job_title: 'Software Engineer', + role_description: 'Backend engineer', + role_is_onsite: 'no', + role_requires_license: 'no', + }); + }); + + it('omits job_title when none is available', () => { + const result = getJobTitleEligibilityParams(roleFields, filledValues); + + expect(result).toEqual(filledValues); + }); +});