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
122 changes: 122 additions & 0 deletions src/__tests__/call-queue-outcome-form.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// Component tests for the call-queue outcome form: the AmlCheck action must be offered for
// transaction-based queue items on ALL outcomes (queues like ManualCheckIpCountryPhone are excluded
// from the AML recheck cron, so a completed call has to act on the transaction explicitly) and must
// default to Reset when the call was completed (clears amlCheck/amlReason so the cron re-runs the
// full AML check instead of force-passing). Heavy transitive deps are mocked so the form can
// render under @testing-library/react without the full app shell.

jest.mock('@dfx.swiss/react-components', () => ({
StyledButton: ({ label, onClick, disabled }: any) => (
<button disabled={disabled} onClick={onClick}>
{label}
</button>
),
StyledButtonWidth: { FULL: 'full' },
}));
jest.mock('src/components/error-hint', () => ({ ErrorHint: () => null }));
jest.mock('src/contexts/settings.context', () => ({
useSettingsContext: () => ({ translate: (_ns: string, key: string) => key }),
}));

const mockSaveCallOutcome = jest.fn();
jest.mock('src/hooks/compliance.hook', () => ({
CallOutcome: {
COMPLETED: 'Completed',
UNAVAILABLE: 'Unavailable',
SUSPICIOUS: 'Suspicious',
FAILED: 'Failed',
REPEAT: 'Repeat',
},
useCompliance: () => ({ saveCallOutcome: mockSaveCallOutcome }),
}));

import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { CallQueueOutcomeForm } from 'src/components/compliance/call-queue/call-queue-outcome-form';
import { CallOutcome } from 'src/hooks/compliance.hook';

const OUTCOMES = [
CallOutcome.COMPLETED,
CallOutcome.UNAVAILABLE,
CallOutcome.SUSPICIOUS,
CallOutcome.FAILED,
CallOutcome.REPEAT,
];

const TX_CONTEXT = { queue: 'ManualCheckIpCountryPhone', userDataId: 1, txId: 42, sourceType: 'BuyCrypto' } as any;
const USER_CONTEXT = { queue: 'UnavailableSuspicious', userDataId: 1 } as any;

function renderForm(context: any) {
return render(
<CallQueueOutcomeForm
context={context}
availableOutcomes={OUTCOMES}
clerks={['JR']}
onSaved={jest.fn()}
title="Save Outcome"
/>,
);
}

function fillAndSubmit(outcome: CallOutcome, amlAction?: string) {
const selects = screen.getAllByRole('combobox');
fireEvent.change(selects[1], { target: { value: outcome } });
if (amlAction !== undefined) fireEvent.change(screen.getAllByRole('combobox')[2], { target: { value: amlAction } });
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'called' } });
fireEvent.click(screen.getByRole('button', { name: 'Save Outcome' }));
}

describe('CallQueueOutcomeForm AmlCheck action', () => {
beforeEach(() => {
jest.clearAllMocks();
mockSaveCallOutcome.mockResolvedValue({ success: true, completedSteps: ['transaction', 'userData', 'log'] });
});

it('offers the AmlCheck action for transaction items and defaults to Reset on Completed', async () => {
renderForm(TX_CONTEXT);
expect(screen.getAllByRole('combobox')).toHaveLength(3);

fillAndSubmit(CallOutcome.COMPLETED);

await waitFor(() => expect(mockSaveCallOutcome).toHaveBeenCalledTimes(1));
expect(mockSaveCallOutcome).toHaveBeenCalledWith(TX_CONTEXT, CallOutcome.COMPLETED, {
signature: 'JR',
comment: 'called',
amlAction: 'Reset',
});
});

it('keeps the Reset default overridable', async () => {
renderForm(TX_CONTEXT);

fillAndSubmit(CallOutcome.COMPLETED, '');

await waitFor(() => expect(mockSaveCallOutcome).toHaveBeenCalledTimes(1));
expect(mockSaveCallOutcome.mock.calls[0][2].amlAction).toBeUndefined();
});

it('resets the AmlCheck action to no change for other outcomes', async () => {
renderForm(TX_CONTEXT);
const selects = screen.getAllByRole('combobox');
fireEvent.change(selects[1], { target: { value: CallOutcome.COMPLETED } });
expect((screen.getAllByRole('combobox')[2] as HTMLSelectElement).value).toBe('Reset');

fillAndSubmit(CallOutcome.UNAVAILABLE);

await waitFor(() => expect(mockSaveCallOutcome).toHaveBeenCalledTimes(1));
expect(mockSaveCallOutcome.mock.calls[0][1]).toBe(CallOutcome.UNAVAILABLE);
expect(mockSaveCallOutcome.mock.calls[0][2].amlAction).toBeUndefined();
});

it('does not offer an AmlCheck action for user-based queue items', async () => {
renderForm(USER_CONTEXT);
expect(screen.getAllByRole('combobox')).toHaveLength(2);

const selects = screen.getAllByRole('combobox');
fireEvent.change(selects[1], { target: { value: CallOutcome.COMPLETED } });
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'called' } });
fireEvent.click(screen.getByRole('button', { name: 'Save Outcome' }));

await waitFor(() => expect(mockSaveCallOutcome).toHaveBeenCalledTimes(1));
expect(mockSaveCallOutcome.mock.calls[0][2].amlAction).toBeUndefined();
});
});
76 changes: 76 additions & 0 deletions src/__tests__/compliance-call-outcome.hook.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Hook tests for saveCallOutcome: the userData update (phoneCallStatus + queue-specific check date)
// must be written BEFORE the transaction step. A reset transaction is re-evaluated from scratch by
// the AML cron; if the check date is not visible by then, the tx re-pends into its (possibly
// recheck-blocked) queue reason and gets stuck again.

const mockCalls: { method: string; url: string; data?: any }[] = [];
const mockCall = jest.fn();
jest.mock('src/hooks/guarded-api.hook', () => ({ useGuardedApi: () => ({ call: mockCall }) }));
jest.mock('@dfx.swiss/react', () => ({
AmlReason: { MANUAL_CHECK_PHONE_FAILED: 'ManualCheckPhoneFailed' },
CheckStatus: { PASS: 'Pass', FAIL: 'Fail' },
PhoneCallStatus: {
COMPLETED: 'Completed',
UNAVAILABLE: 'Unavailable',
SUSPICIOUS: 'Suspicious',
FAILED: 'Failed',
REPEAT: 'Repeat',
USER_REJECTED: 'UserRejected',
},
CallQueue: {
MANUAL_CHECK_PHONE: 'ManualCheckPhone',
MANUAL_CHECK_IP_PHONE: 'ManualCheckIpPhone',
MANUAL_CHECK_IP_COUNTRY_PHONE: 'ManualCheckIpCountryPhone',
MANUAL_CHECK_EXTERNAL_ACCOUNT_PHONE: 'ManualCheckExternalAccountPhone',
UNAVAILABLE_SUSPICIOUS: 'UnavailableSuspicious',
},
}));

import { renderHook } from '@testing-library/react';
import { CallOutcome, useCompliance } from 'src/hooks/compliance.hook';

const TX_CONTEXT = { queue: 'ManualCheckIpCountryPhone', userDataId: 7, txId: 42, sourceType: 'BuyCrypto' } as any;

describe('saveCallOutcome write order', () => {
beforeEach(() => {
mockCalls.length = 0;
// resetMocks is on (CRA default), so the recording implementation must be (re)set per test
mockCall.mockImplementation(async (cfg: any) => {
mockCalls.push({ method: cfg.method, url: cfg.url, data: cfg.data });
return {};
});
});

it('writes the userData check date before resetting the transaction', async () => {
const { result } = renderHook(() => useCompliance());

const res = await result.current.saveCallOutcome(TX_CONTEXT, CallOutcome.COMPLETED, {
signature: 'JR',
comment: 'called',
amlAction: 'Reset',
});

expect(res.success).toBe(true);
expect(mockCalls.map((c) => `${c.method} ${c.url}`)).toEqual([
'PUT userData/7',
'DELETE buyCrypto/42/amlCheck',
'POST kyc/admin/log',
]);
const userDataCall = mockCalls[0];
expect(userDataCall.data.phoneCallStatus).toBe('Completed');
expect(userDataCall.data.phoneCallIpCountryCheckDate).toBeDefined();
});

it('does not touch the transaction without an AmlCheck action', async () => {
const { result } = renderHook(() => useCompliance());

const res = await result.current.saveCallOutcome(TX_CONTEXT, CallOutcome.UNAVAILABLE, {
signature: 'JR',
comment: 'no answer',
});

expect(res.success).toBe(true);
expect(mockCalls.map((c) => `${c.method} ${c.url}`)).toEqual(['PUT userData/7', 'POST kyc/admin/log']);
expect(mockCalls[0].data.phoneCallIpCountryCheckDate).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,18 @@ export function CallQueueOutcomeForm({ context, availableOutcomes, clerks, onSav
}, [clerks]);

const hasTx = context.txId != null && context.sourceType != null;
const showAmlCheck = hasTx && outcome !== CallOutcome.COMPLETED;
const showAmlCheck = hasTx;
const canSubmit = !!signature && !!outcome && !!comment.trim() && !isSaving;

// Some queue reasons (e.g. ManualCheckIpCountryPhone) are excluded from the AML recheck cron, so a
// completed call must act on the transaction explicitly. Default to Reset (not Pass): it clears
// amlCheck + amlReason so the cron re-runs the FULL AML check, which only passes the tx if no
// other errors remain. Overridable.
function handleOutcomeChange(value: CallOutcome | '') {
setOutcome(value);
if (hasTx) setAmlAction(value === CallOutcome.COMPLETED ? 'Reset' : '');
}

async function handleSubmit() {
if (!outcome || !signature || !comment.trim()) return;
setIsSaving(true);
Expand Down Expand Up @@ -75,7 +84,7 @@ export function CallQueueOutcomeForm({ context, availableOutcomes, clerks, onSav
<select
className="w-full px-3 py-2 text-sm bg-white border border-dfxGray-300 rounded text-dfxBlue-800"
value={outcome}
onChange={(e) => setOutcome(e.target.value as CallOutcome | '')}
onChange={(e) => handleOutcomeChange(e.target.value as CallOutcome | '')}
>
<option value="">—</option>
{availableOutcomes.map((o) => (
Expand Down
45 changes: 24 additions & 21 deletions src/hooks/compliance.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -931,7 +931,30 @@ export function useCompliance() {
const tx = context.txId != null && context.sourceType ? { id: context.txId, sourceType: context.sourceType } : null;
const results: KycLogResult[] = [];

// 1) Transaction update (if applicable)
// 1) UserData update (phoneCallStatus + check date on completion). Must run BEFORE the
// transaction step: a reset transaction is re-evaluated from scratch by the AML cron, so the
// check date has to be visible by then or the tx re-pends into its (possibly recheck-blocked)
// queue reason and gets stuck again.
const phoneStatus = callOutcomeToPhoneStatus[outcome];
const skipUserData = outcome === CallOutcome.REPEAT && context.queue === CallQueue.UNAVAILABLE_SUSPICIOUS;
if (phoneStatus && !skipUserData) {
try {
const udData: Record<string, unknown> = { phoneCallStatus: phoneStatus };
results.push({ table: 'userData', column: 'phoneCallStatus', value: phoneStatus });
if (outcome === CallOutcome.COMPLETED) {
const checkDateField = checkDateFieldForQueue(context.queue);
const checkDateValue = new Date().toISOString();
udData[checkDateField] = checkDateValue;
results.push({ table: 'userData', column: checkDateField, value: checkDateValue });
}
await updateUserData(context.userDataId, udData);
completedSteps.push('userData');
} catch (e) {
return fail('userData', e);
}
}

// 2) Transaction update (if applicable)
try {
if (tx && options.amlAction) {
const signature = options.signature.trim();
Expand Down Expand Up @@ -962,26 +985,6 @@ export function useCompliance() {
return fail('transaction', e);
}

// 2) UserData update (phoneCallStatus + check date on completion)
const phoneStatus = callOutcomeToPhoneStatus[outcome];
const skipUserData = outcome === CallOutcome.REPEAT && context.queue === CallQueue.UNAVAILABLE_SUSPICIOUS;
if (phoneStatus && !skipUserData) {
try {
const udData: Record<string, unknown> = { phoneCallStatus: phoneStatus };
results.push({ table: 'userData', column: 'phoneCallStatus', value: phoneStatus });
if (outcome === CallOutcome.COMPLETED) {
const checkDateField = checkDateFieldForQueue(context.queue);
const checkDateValue = new Date().toISOString();
udData[checkDateField] = checkDateValue;
results.push({ table: 'userData', column: checkDateField, value: checkDateValue });
}
await updateUserData(context.userDataId, udData);
completedSteps.push('userData');
} catch (e) {
return fail('userData', e);
}
}

// 3) KYC log entry (always)
try {
const logMessage = buildKycLogMessage({
Expand Down
Loading