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
76 changes: 76 additions & 0 deletions __tests__/hooks/useCardSessionRelease.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import {renderHook} from '@testing-library/react-native';

import {useCardSessionRelease} from '../../src/hooks/useCardSessionRelease';
import {navigationStub} from '../support/navigationStub';

const mockCancelCardSession = jest.fn();

jest.mock('../../src/services/cashuCardNfc', () => ({
cancelCardSession: (...args: unknown[]) => mockCancelCardSession(...args),
}));

beforeEach(() => {
jest.clearAllMocks();
mockCancelCardSession.mockResolvedValue(undefined);
});

describe('useCardSessionRelease (ENG-704)', () => {
it('cancels as the stack lets the screen go, and not again at unmount', () => {
// The unmount lands at the end of the close transition, when the keypad
// underneath has re-armed and may be mid-read: a second cancel there
// would close the keypad's tag, not ours.
const navigation = navigationStub();
const {unmount} = renderHook(() => useCardSessionRelease({navigation}));

expect(mockCancelCardSession).not.toHaveBeenCalled();
expect(navigation.emitBeforeRemove()).toBe(false);
expect(mockCancelCardSession).toHaveBeenCalledTimes(1);

unmount();
expect(mockCancelCardSession).toHaveBeenCalledTimes(1);
});

it('still cancels at unmount when the screen leaves without a beforeRemove', () => {
const {unmount} = renderHook(() =>
useCardSessionRelease({navigation: navigationStub()}),
);
unmount();
expect(mockCancelCardSession).toHaveBeenCalledTimes(1);
});

it("marks the cut as the screen's own before cancelling", () => {
const order: string[] = [];
mockCancelCardSession.mockImplementation(async () => {
order.push('cancel');
});
const navigation = navigationStub();
renderHook(() =>
useCardSessionRelease({
navigation,
onRelease: () => order.push('onRelease'),
}),
);
navigation.emitBeforeRemove();
expect(order).toEqual(['onRelease', 'cancel']);
});

it('without a navigation, hands release to the screen and installs no listener', () => {
const {result, unmount} = renderHook(() => useCardSessionRelease());

result.current();
expect(mockCancelCardSession).toHaveBeenCalledTimes(1);

unmount();
expect(mockCancelCardSession).toHaveBeenCalledTimes(1);
});

it('stops listening once unmounted', () => {
const navigation = navigationStub();
const {unmount} = renderHook(() => useCardSessionRelease({navigation}));
unmount();
mockCancelCardSession.mockClear();

navigation.emitBeforeRemove();
expect(mockCancelCardSession).not.toHaveBeenCalled();
});
});
121 changes: 118 additions & 3 deletions __tests__/hooks/useKeypadCardReader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
type CardSummary,
} from '../../src/services/cashuCard';
import {BRIDGE_TAG, setCardBridge} from '../../src/services/cardBridge';
import {MULTI_REQUEST_MESSAGE} from '../../src/services/cashuCardNfc';

const actualNfc = jest.requireActual('../../src/services/cashuCardNfc');

Expand Down Expand Up @@ -410,6 +411,29 @@ describe('useKeypadCardReader on Android', () => {
);
});

it('ERR_MULTI_REQ on arm is not a card failure: no toast, re-arms after a breather', async () => {
renderHook(() => useKeypadCardReader());
focus();
await flush();

// Another screen's request was still pending when the keypad armed: the
// native module refuses with its bare ERR_MULTI_REQ string.
await act(async () => {
pendingRequest()!.reject(
new NfcError.NfcErrorBase(MULTI_REQUEST_MESSAGE),
);
});
await flush();

expect(mockToastShow).not.toHaveBeenCalled();
expect(mockSetNfcBusy).toHaveBeenLastCalledWith(false);
await waitFor(
() => expect(mockNfc.requestTechnology).toHaveBeenCalledTimes(2),
{timeout: REARM_AFTER_ERROR_MS * 4},
);
expect(mockToastShow).not.toHaveBeenCalled();
});

it('says quietly that a card without the applet is not a Flash card, and re-arms', async () => {
mockReadCard.mockRejectedValue(new CardError(0x6a82, 'SELECT'));
renderHook(() => useKeypadCardReader());
Expand Down Expand Up @@ -504,6 +528,91 @@ describe('useKeypadCardReader on Android', () => {
expect(pendingRequest()).toBeDefined();
});

it('a focus that lands on a stray request flushes it and arms without a toast', async () => {
// Native-faithful for this test only: a request while another is pending
// is refused with ERR_MULTI_REQ (NfcManager.java:130-145).
mockNfc.requestTechnology.mockImplementation(() => {
if (pendingRequest()) {
return Promise.reject(new NfcError.NfcErrorBase(MULTI_REQUEST_MESSAGE));
}
const request = deferred<void>();
requests.push(request);
return request.promise;
});
// A card screen's request, still pending as the keypad regains focus:
// one removed without a `beforeRemove` (card screens release there,
// see useCardSessionRelease) cancels only on unmount, after its close
// transition.
mockNfc.requestTechnology(['IsoDep']).catch(() => {});

renderHook(() => useKeypadCardReader());
focus();
await flush();

// The keypad flushed the stray request before arming its own.
const firstCancel =
mockNfc.cancelTechnologyRequest.mock.invocationCallOrder[0];
const keypadArm = mockNfc.requestTechnology.mock.invocationCallOrder[1];
expect(firstCancel).toBeDefined();
expect(keypadArm).toBeDefined();
expect(firstCancel).toBeLessThan(keypadArm);
expect(mockNfc.requestTechnology).toHaveBeenCalledTimes(2);
expect(pendingRequest()).toBeDefined();
expect(mockToastShow).not.toHaveBeenCalled();

// That screen's late unmount cleanup cancels whatever is pending, which
// is now the keypad's request. While no card is in the field it is
// silent, and re-armed at once.
await act(() => actualNfc.cancelCardSession());
await flush();

expect(mockNfc.requestTechnology).toHaveBeenCalledTimes(3);
expect(pendingRequest()).toBeDefined();
expect(mockToastShow).not.toHaveBeenCalled();
});

it('a foreign cancel that cuts a read mid-flight is reported as a failed read', async () => {
// Decided (ENG-704): the keypad cannot tell another screen's late cancel
// from a card that left the field. Both close the tag under the
// transceive, so the merchant sees the read failed and taps again. Card
// screens release from `beforeRemove` so this cannot come from them.
const read = deferred<CardSummary>();
mockReadCard.mockReturnValue(read.promise);
renderHook(() => useKeypadCardReader());
focus();
await flush();
await tap(V2_TAG);
await flush();
expect(mockReadCard).toHaveBeenCalledTimes(1);

// Native: cancelTechnologyRequest closes the connected tag, and the
// in-flight transceive fails with an I/O error, not UserCancel.
await act(() => actualNfc.cancelCardSession());
const cut = new Error('transceive failed: tag connection lost');
await act(async () => {
read.reject(cut);
});
await flush();

expect(mockNavigate).not.toHaveBeenCalled();
expect(mockToastShow).toHaveBeenCalledTimes(1);
expect(mockToastShow).toHaveBeenCalledWith({
message: actualNfc.describeCardFailure(cut),
type: 'error',
});
// Then it re-arms after the breather, ready for the next tap.
const arms = mockNfc.requestTechnology.mock.calls.length;
await waitFor(
() =>
expect(mockNfc.requestTechnology.mock.calls.length).toBeGreaterThan(
arms,
),
{timeout: REARM_AFTER_ERROR_MS * 4},
);
blur();
await flush();
});

it('never arms twice while one request is pending', async () => {
const {result} = renderHook(() => useKeypadCardReader());
focus();
Expand Down Expand Up @@ -750,11 +859,17 @@ describe('useKeypadCardReader: change owed from an earlier charge (ENG-630)', ()
summary: after,
changeAddedSat: 2,
});
// The write happened before the session closed.
// The write happened before the session closed. The session's close is
// the first cancel after the keypad armed; the one before it is the
// focus flush of any stray request (ENG-704).
const armOrder = mockNfc.requestTechnology.mock.invocationCallOrder[0];
const writeOrder = mockWriteOwedChange.mock.invocationCallOrder[0];
const cancelOrder =
mockNfc.cancelTechnologyRequest.mock.invocationCallOrder[0];
expect(writeOrder).toBeLessThan(cancelOrder);
mockNfc.cancelTechnologyRequest.mock.invocationCallOrder.find(
order => order > armOrder,
);
expect(cancelOrder).toBeDefined();
expect(writeOrder).toBeLessThan(cancelOrder!);
});

it('a write the store cut short before any LOAD claims nothing added: the screen opens with all of it still waiting', async () => {
Expand Down
60 changes: 60 additions & 0 deletions __tests__/screens/CashuCardCharge.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1174,6 +1174,19 @@ describe('CashuCardCharge', () => {
expect(navigation.emitBeforeRemove('RESET')).toBe(false);
});

it("releases only on Done's own reset, never on the pop it refused, and not again at unmount (ENG-704)", async () => {
const {navigation, unmount} = await settleViaRouter();
const before = mockCancelCardSession.mock.calls.length;
await act(async () => {
expect(navigation.emitBeforeRemove('POP')).toBe(true);
});
expect(mockCancelCardSession).toHaveBeenCalledTimes(before);
expect(navigation.emitBeforeRemove('RESET')).toBe(false);
expect(mockCancelCardSession).toHaveBeenCalledTimes(before + 1);
unmount();
expect(mockCancelCardSession).toHaveBeenCalledTimes(before + 1);
});

it('before the swap, Back is still a plain pop', async () => {
jest.useFakeTimers(FAKE_TIMERS);
mockExecuteCharge.mockRejectedValue(
Expand Down Expand Up @@ -1690,6 +1703,21 @@ describe('CashuCardCharge', () => {
}
});

it('a refused pop releases nothing: the charge keeps its session', async () => {
const {navigation, ctl, navigatorBack} = await inFlight();
try {
mockCancelCardSession.mockClear();
expect(navigation.emitBeforeRemove('POP')).toBe(true);
expect(mockCancelCardSession).not.toHaveBeenCalled();
await act(async () => {
ctl.succeed();
});
await expectHandedOffToSuccess(navigation);
} finally {
navigatorBack.remove();
}
});

it('a failure after the burn that is NOT paid releases the hold: Back leaves as before', async () => {
const utils = await inFlight();
const {getByTestId, getByText, navigation, ctl, navigatorBack} = utils;
Expand All @@ -1709,6 +1737,38 @@ describe('CashuCardCharge', () => {
});
});

// ENG-704: the unmount lands at the end of the close transition, when the
// keypad underneath has re-armed and a card may be mid-read there; a cancel
// at unmount would close the keypad's tag and toast a card failure.
it('a pop before the burn releases the session as the stack lets it go, quietly, and the unmount cancels nothing more', async () => {
let cut: (error: Error) => void = () => {};
mockWithCardSession.mockImplementation(
() =>
new Promise((_, reject) => {
cut = reject;
}),
);
const {getByText, queryByTestId, navigation, unmount} =
await renderScreen();
await act(async () => {
fireEvent.press(getByText('Tap card to charge'));
});
expect(mockCancelCardSession).not.toHaveBeenCalled();

expect(navigation.emitBeforeRemove('POP')).toBe(false);
expect(mockCancelCardSession).toHaveBeenCalledTimes(1);

// The cancel cuts the read mid-APDU: a transceive error, not UserCancel.
// Still the merchant's own doing, never an error on the way out.
await act(async () => {
cut(new Error('transceive failed: tag connection lost'));
});
expect(queryByTestId('error-pill')).toBeNull();

unmount();
expect(mockCancelCardSession).toHaveBeenCalledTimes(1);
});

it('cancels the NFC session on Cancel and on unmount', async () => {
mockWithCardSession.mockImplementation(() => new Promise(() => {}));
const {getByText, unmount} = await renderScreen();
Expand Down
37 changes: 35 additions & 2 deletions __tests__/screens/CashuCardDebug.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {NfcError} from 'react-native-nfc-manager';
import CashuCardDebug from '../../src/screens/CashuCardDebug';
import {CardError, type CardSummary} from '../../src/services/cashuCard';
import {getCardBridge, setCardBridge} from '../../src/services/cardBridge';
import {navigationStub} from '../support/navigationStub';

const mockCancelCardSession = jest.fn();
const mockIsCardReadingSupported = jest.fn();
Expand Down Expand Up @@ -73,9 +74,12 @@ beforeEach(() => {

/** Renders and drains the `isCardReadingSupported` effect. */
async function renderScreen() {
const utils = render(<CashuCardDebug />);
const navigation = navigationStub();
const utils = render(
<CashuCardDebug navigation={navigation as never} route={{} as never} />,
);
await act(async () => {});
return utils;
return {...utils, navigation};
}

describe('CashuCardDebug session lifecycle', () => {
Expand Down Expand Up @@ -218,6 +222,35 @@ describe('CashuCardDebug session lifecycle', () => {
await waitFor(() => expect(getByText('NFC is turned off')).toBeTruthy());
});

// ENG-704: the unmount lands at the end of the close transition, when the
// screen underneath (the keypad) may have armed its own request and be
// mid-read. The cancel must happen as the stack lets this screen go.
it('cancels an in-flight read as the stack lets the screen go, and not again at unmount', async () => {
const pending = deferred<CardSummary>();
mockReadCardOverNfc.mockReturnValue(pending.promise);

const {getByText, queryByText, navigation, unmount} = await renderScreen();
fireEvent.press(getByText('Read card'));
await act(async () => {});

expect(navigation.emitBeforeRemove()).toBe(false);
expect(mockCancelCardSession).toHaveBeenCalledTimes(1);

// The cut read surfaces as a transceive error, not UserCancel: still the
// merchant's own doing, never a red box on the way out.
const cut = new Error('transceive failed: tag connection lost');
await act(async () => {
pending.reject(cut);
});
const {describeCardFailure} = jest.requireActual(
'../../src/services/cashuCardNfc',
);
expect(queryByText(describeCardFailure(cut))).toBeNull();

unmount();
expect(mockCancelCardSession).toHaveBeenCalledTimes(1);
});

it('cancels once on unmount even when no read was ever started', async () => {
const {unmount} = await renderScreen();
unmount();
Expand Down
Loading
Loading