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);
+ });
+});