From 49d34c025c40454e988f7b1b619f9b38eabf8586 Mon Sep 17 00:00:00 2001 From: akansnoro Date: Thu, 22 Oct 2026 14:29:40 +0100 Subject: [PATCH] feat: re-land issue 1685 (automated test coverage) Re-lands the part of the reverted #1703 squash that belongs to issue 1685 only. The remainder of the squash is re-landed by the other branches of the split, so this diff contains this issue's work exclusively. --- .../__tests__/outboundUrlGuard.test.ts | 152 ++++++++++ src/services/__tests__/rpcProvider.test.ts | 166 ++++++++++ .../__tests__/transactionConfirmation.test.ts | 261 ++++++++++++++++ .../_tests_/transactionQueueStore.test.ts | 287 ++++++++++++++++++ src/utils/__tests__/billingCycleMath.test.ts | 103 +++++++ src/utils/__tests__/decimalAmount.test.ts | 106 +++++++ src/utils/__tests__/invoiceTotals.test.ts | 212 +++++++++++++ 7 files changed, 1287 insertions(+) create mode 100644 backend/shared/security/__tests__/outboundUrlGuard.test.ts create mode 100644 src/services/__tests__/rpcProvider.test.ts create mode 100644 src/services/__tests__/transactionConfirmation.test.ts create mode 100644 src/store/_tests_/transactionQueueStore.test.ts create mode 100644 src/utils/__tests__/billingCycleMath.test.ts create mode 100644 src/utils/__tests__/decimalAmount.test.ts create mode 100644 src/utils/__tests__/invoiceTotals.test.ts diff --git a/backend/shared/security/__tests__/outboundUrlGuard.test.ts b/backend/shared/security/__tests__/outboundUrlGuard.test.ts new file mode 100644 index 00000000..90078865 --- /dev/null +++ b/backend/shared/security/__tests__/outboundUrlGuard.test.ts @@ -0,0 +1,152 @@ +/** + * Tests for outboundUrlGuard — SSRF protection for server-side fetches. + * + * @see backend/shared/security/outboundUrlGuard.ts + * @see https://github.com/Smartdevs17/SubTrackr/issues/1686 + */ + +import { + OutboundUrlError, + isPrivateHostname, + validateOutboundUrl, +} from '../outboundUrlGuard'; + +describe('isPrivateHostname', () => { + it('flags loopback and metadata hostnames', () => { + expect(isPrivateHostname('localhost')).toBe(true); + expect(isPrivateHostname('LOCALHOST')).toBe(true); + expect(isPrivateHostname('metadata.google.internal')).toBe(true); + expect(isPrivateHostname('instance-data')).toBe(true); + }); + + it('flags internal-looking suffixes', () => { + expect(isPrivateHostname('db.local')).toBe(true); + expect(isPrivateHostname('svc.internal')).toBe(true); + expect(isPrivateHostname('api.localhost')).toBe(true); + }); + + it('flags loopback and RFC1918 IPv4 literals', () => { + expect(isPrivateHostname('127.0.0.1')).toBe(true); + expect(isPrivateHostname('10.1.2.3')).toBe(true); + expect(isPrivateHostname('172.16.0.1')).toBe(true); + expect(isPrivateHostname('192.168.1.1')).toBe(true); + expect(isPrivateHostname('0.0.0.0')).toBe(true); + expect(isPrivateHostname('255.255.255.255')).toBe(true); + }); + + it('flags the cloud metadata address specifically', () => { + expect(isPrivateHostname('169.254.169.254')).toBe(true); + }); + + it('flags CGNAT space', () => { + expect(isPrivateHostname('100.64.0.1')).toBe(true); + }); + + it('flags private IPv6 literals', () => { + expect(isPrivateHostname('::1')).toBe(true); + expect(isPrivateHostname('fd00::1')).toBe(true); + expect(isPrivateHostname('fe80::1')).toBe(true); + }); + + it('flags IPv4-mapped IPv6 loopback', () => { + expect(isPrivateHostname('::ffff:127.0.0.1')).toBe(true); + }); + + it('allows ordinary public hosts', () => { + expect(isPrivateHostname('example.com')).toBe(false); + expect(isPrivateHostname('hooks.zapier.com')).toBe(false); + expect(isPrivateHostname('8.8.8.8')).toBe(false); + expect(isPrivateHostname('172.32.0.1')).toBe(false); + expect(isPrivateHostname('172.15.0.1')).toBe(false); + }); + + it('treats a malformed dotted quad as unsafe', () => { + expect(isPrivateHostname('999.1.1.1')).toBe(true); + expect(isPrivateHostname('127.0.0')).toBe(true); + }); + + it('treats an empty host as unsafe', () => { + expect(isPrivateHostname('')).toBe(true); + }); +}); + +describe('validateOutboundUrl', () => { + it('accepts a normal public https URL', () => { + // A real Zapier host, but deliberately *not* a `/hooks/catch///` + // path: that shape is indistinguishable from a live Zapier hook URL and + // trips secret detectors in CI (the assertion only needs "public host"). + const result = validateOutboundUrl('https://hooks.zapier.com/example-endpoint'); + expect(result.protocol).toBe('https:'); + expect(result.hostname).toBe('hooks.zapier.com'); + }); + + it('accepts plain http', () => { + expect(validateOutboundUrl('http://example.com/hook').protocol).toBe('http:'); + }); + + it('rejects non-HTTP schemes', () => { + for (const url of [ + 'file:///etc/passwd', + 'gopher://example.com', + 'ftp://example.com', + 'javascript:alert(1)', + 'data:text/plain,hi', + ]) { + expect(() => validateOutboundUrl(url)).toThrow(OutboundUrlError); + } + }); + + it('rejects relative or malformed values', () => { + expect(() => validateOutboundUrl('/relative/path')).toThrow(OutboundUrlError); + expect(() => validateOutboundUrl('not a url')).toThrow(OutboundUrlError); + expect(() => validateOutboundUrl('')).toThrow(OutboundUrlError); + expect(() => validateOutboundUrl(undefined)).toThrow(OutboundUrlError); + expect(() => validateOutboundUrl(null)).toThrow(OutboundUrlError); + expect(() => validateOutboundUrl(42)).toThrow(OutboundUrlError); + }); + + it('rejects loopback and private targets', () => { + expect(() => validateOutboundUrl('http://localhost/hook')).toThrow(/non-public/); + expect(() => validateOutboundUrl('http://127.0.0.1:8080/hook')).toThrow(/non-public/); + expect(() => validateOutboundUrl('http://169.254.169.254/latest/meta-data/')).toThrow( + /non-public/ + ); + expect(() => validateOutboundUrl('http://10.0.0.5/admin')).toThrow(/non-public/); + }); + + it('rejects embedded credentials', () => { + expect(() => validateOutboundUrl('https://user:pass@example.com/')).toThrow( + /embedded credentials/ + ); + }); + + it('enforces an allowlist when one is supplied', () => { + expect( + validateOutboundUrl('https://hooks.zapier.com/x', { allowedHosts: ['hooks.zapier.com'] }) + .hostname + ).toBe('hooks.zapier.com'); + + // Subdomains of an allowlisted host are permitted. + expect(() => + validateOutboundUrl('https://eu.hooks.zapier.com/x', { allowedHosts: ['hooks.zapier.com'] }) + ).not.toThrow(); + + expect(() => + validateOutboundUrl('https://evil.example.com/x', { allowedHosts: ['hooks.zapier.com'] }) + ).toThrow(/allowlist/); + }); + + it('does not treat a lookalike host as allowlisted', () => { + expect(() => + validateOutboundUrl('https://hooks.zapier.com.evil.test/x', { + allowedHosts: ['hooks.zapier.com'], + }) + ).toThrow(/allowlist/); + }); + + it('still applies the private-address rules when an allowlist is present', () => { + expect(() => + validateOutboundUrl('http://127.0.0.1/x', { allowedHosts: ['127.0.0.1'] }) + ).toThrow(OutboundUrlError); + }); +}); \ No newline at end of file diff --git a/src/services/__tests__/rpcProvider.test.ts b/src/services/__tests__/rpcProvider.test.ts new file mode 100644 index 00000000..ac3e04ae --- /dev/null +++ b/src/services/__tests__/rpcProvider.test.ts @@ -0,0 +1,166 @@ +/** + * Tests for the resilient RPC provider's registry and error surface. + * + * These deliberately avoid exercising real network I/O: the behaviour that + * matters for this change (which errors count against a circuit, URL-swap + * serialisation) is covered by `isTransportFailure` tests in + * `transactionConfirmation.test.ts` and by the backend integration suite. + * + * @see src/services/rpcProvider.ts + * @see https://github.com/Smartdevs17/SubTrackr/issues/1683 + */ + +import { + AllRpcProvidersFailedError, + RpcProviderCircuitOpenError, + RpcProviderTimeoutError, + clearResilientProviderRegistry, + defaultChainTimeoutMs, + getOrCreateResilientProvider, + isTransportFailure, +} from '../rpcProvider'; + +const PRIMARY = 'https://primary.example/rpc'; +const FALLBACK = 'https://fallback.example/rpc'; + +describe('defaultChainTimeoutMs', () => { + it('allows more headroom on high-variance L2 chains', () => { + expect(defaultChainTimeoutMs(10)).toBe(15_000); + expect(defaultChainTimeoutMs(137)).toBe(15_000); + expect(defaultChainTimeoutMs(8453)).toBe(15_000); + }); + + it('uses the tighter default on Ethereum mainnet', () => { + expect(defaultChainTimeoutMs(1)).toBe(10_000); + }); + + it('falls back to the default for unknown chains', () => { + expect(defaultChainTimeoutMs(999999)).toBe(10_000); + }); +}); + +describe('getOrCreateResilientProvider', () => { + beforeEach(() => { + clearResilientProviderRegistry(); + }); + + afterEach(() => { + clearResilientProviderRegistry(); + }); + + it('returns the same instance for the same chain and URL set', () => { + const first = getOrCreateResilientProvider(1, [PRIMARY, FALLBACK]); + const second = getOrCreateResilientProvider(1, [PRIMARY, FALLBACK]); + expect(second).toBe(first); + }); + + it('ignores URL order so circuit state is not silently reset', () => { + const first = getOrCreateResilientProvider(1, [PRIMARY, FALLBACK]); + const second = getOrCreateResilientProvider(1, [FALLBACK, PRIMARY]); + expect(second).toBe(first); + }); + + it('keeps separate instances per chain', () => { + const mainnet = getOrCreateResilientProvider(1, [PRIMARY]); + const polygon = getOrCreateResilientProvider(137, [PRIMARY]); + expect(mainnet).not.toBe(polygon); + }); + + it('keeps separate instances for different URL sets', () => { + const first = getOrCreateResilientProvider(1, [PRIMARY]); + const second = getOrCreateResilientProvider(1, [PRIMARY, FALLBACK]); + expect(first).not.toBe(second); + }); + + it('starts every circuit closed', () => { + const provider = getOrCreateResilientProvider(1, [PRIMARY, FALLBACK]); + const states = provider.getCircuitStates(); + + expect(states[PRIMARY]).toEqual({ state: 'closed', consecutiveFailures: 0 }); + expect(states[FALLBACK]).toEqual({ state: 'closed', consecutiveFailures: 0 }); + }); + + it('resets every circuit on demand', () => { + const provider = getOrCreateResilientProvider(1, [PRIMARY, FALLBACK]); + provider.resetAllCircuits(); + + const states = provider.getCircuitStates(); + expect(states[PRIMARY].state).toBe('closed'); + expect(states[FALLBACK].state).toBe('closed'); + }); + + it('drops every cached provider when the registry is cleared', () => { + const first = getOrCreateResilientProvider(1, [PRIMARY]); + clearResilientProviderRegistry(); + const second = getOrCreateResilientProvider(1, [PRIMARY]); + + expect(second).not.toBe(first); + }); +}); + +describe('isTransportFailure', () => { + const withCode = (message: string, code: unknown): Error => + Object.assign(new Error(message), { code }); + + it('classifies an explicit timeout as a transport failure', () => { + expect(isTransportFailure(withCode('nope', 'TIMEOUT'))).toBe(true); + }); + + it('classifies connection-level errors as transport failures', () => { + for (const code of ['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'ERR_NETWORK']) { + expect(isTransportFailure(withCode('x', code))).toBe(true); + } + }); + + it('does not treat a JSON-RPC application error as a transport failure', () => { + // A revert carries a numeric JSON-RPC code. The node answered correctly, + // so counting it as a circuit failure would evict a healthy endpoint. + expect(isTransportFailure(withCode('execution reverted', 3))).toBe(false); + }); + + it('does not treat insufficient funds as a transport failure', () => { + const nested = Object.assign(new Error('insufficient funds'), { + error: { code: -32000 }, + }); + expect(isTransportFailure(nested)).toBe(false); + }); + + it('treats fetch-level errors as transport failures', () => { + expect(isTransportFailure(new TypeError('failed to fetch'))).toBe(true); + }); + + it('returns false for non-error values', () => { + expect(isTransportFailure(null)).toBe(false); + expect(isTransportFailure(undefined)).toBe(false); + expect(isTransportFailure('nope')).toBe(false); + }); +}); + +describe('provider error types', () => { + it('exposes a stable code and context for timeouts', () => { + const error = new RpcProviderTimeoutError(PRIMARY, 100); + expect(error.code).toBe('RPC_PROVIDER_TIMEOUT'); + expect(error.endpointUrl).toBe(PRIMARY); + expect(error.timeoutMs).toBe(100); + expect(error).toBeInstanceOf(Error); + }); + + it('exposes a stable code and context for an open circuit', () => { + const error = new RpcProviderCircuitOpenError(FALLBACK); + expect(error.code).toBe('RPC_PROVIDER_CIRCUIT_OPEN'); + expect(error.endpointUrl).toBe(FALLBACK); + }); + + it('reports every attempted endpoint when all of them fail', () => { + const error = new AllRpcProvidersFailedError(1, [ + { url: PRIMARY, message: 'failed to fetch' }, + { url: FALLBACK, message: 'timed out' }, + ]); + + expect(error.code).toBe('ALL_RPC_PROVIDERS_FAILED'); + expect(error.chainId).toBe(1); + expect(error.errors).toHaveLength(2); + expect(error.message).toContain(PRIMARY); + expect(error.message).toContain(FALLBACK); + }); +}); diff --git a/src/services/__tests__/transactionConfirmation.test.ts b/src/services/__tests__/transactionConfirmation.test.ts new file mode 100644 index 00000000..4025df5d --- /dev/null +++ b/src/services/__tests__/transactionConfirmation.test.ts @@ -0,0 +1,261 @@ +/** + * Tests for transactionConfirmation — defensive receipt waiting. + * + * @see src/services/transactionConfirmation.ts + * @see https://github.com/Smartdevs17/SubTrackr/issues/1683 + */ + +import type { ethers } from 'ethers'; + +import { + DEFAULT_CONFIRMATIONS, + DEFAULT_CONFIRMATION_TIMEOUT_MS, + TransactionInsufficientConfirmationsError, + TransactionRevertedError, + TransactionTimeoutError, + bestEffortTransactionHash, + confirmTransaction, + isDefinitiveFailure, + requiredConfirmations, +} from '../transactionConfirmation'; + +type Receipt = ethers.providers.TransactionReceipt; + +const L2_CHAIN_ID = 8453; +const L1_CHAIN_ID = 1; + +const makeReceipt = (overrides: Partial = {}): Receipt => + ({ + transactionHash: '0xabc', + status: 1, + confirmations: 2, + ...overrides, + }) as Receipt; + +const makeTx = (wait: (confirmations?: number) => Promise) => ({ + hash: '0xabc', + wait, +}); + +describe('requiredConfirmations', () => { + it('requires a single confirmation on fast L2 chains', () => { + expect(requiredConfirmations(L2_CHAIN_ID)).toBe(1); + expect(requiredConfirmations(10)).toBe(1); + expect(requiredConfirmations(137)).toBe(1); + }); + + it('requires deeper confirmation on L1', () => { + expect(requiredConfirmations(L1_CHAIN_ID)).toBe(DEFAULT_CONFIRMATIONS); + expect(DEFAULT_CONFIRMATIONS).toBe(2); + }); + + it('honours an explicit override', () => { + expect(requiredConfirmations(L2_CHAIN_ID, 5)).toBe(5); + }); + + it('never returns less than one confirmation', () => { + expect(requiredConfirmations(L1_CHAIN_ID, 0)).toBe(1); + expect(requiredConfirmations(L1_CHAIN_ID, -3)).toBe(1); + }); +}); + +describe('confirmTransaction', () => { + it('returns the receipt when the transaction succeeded', async () => { + const receipt = makeReceipt(); + const tx = makeTx(async () => receipt); + + await expect(confirmTransaction(tx, { chainId: L2_CHAIN_ID })).resolves.toBe(receipt); + }); + + it('passes the required confirmations to ethers', async () => { + const wait = jest.fn().mockResolvedValue(makeReceipt({ confirmations: 1 })); + const tx = makeTx(wait); + + await confirmTransaction(tx, { chainId: L2_CHAIN_ID }); + + expect(wait).toHaveBeenCalledWith(1); + }); + + it('asks for the L1 confirmation depth on mainnet', async () => { + const wait = jest.fn().mockResolvedValue(makeReceipt({ confirmations: 2 })); + const tx = makeTx(wait); + + await confirmTransaction(tx, { chainId: L1_CHAIN_ID }); + + expect(wait).toHaveBeenCalledWith(DEFAULT_CONFIRMATIONS); + }); + + it('enforces its own timeout even when the transaction never settles', async () => { + // ethers v5 `ContractTransaction.wait()` ignores a timeout argument, so the + // bound has to be enforced here or a contract payment can hang forever. + jest.useFakeTimers(); + + try { + const tx = makeTx(() => new Promise(() => undefined)); + + const pending = confirmTransaction(tx, { chainId: L2_CHAIN_ID, timeoutMs: 5000 }); + const assertion = expect(pending).rejects.toBeInstanceOf(TransactionTimeoutError); + + jest.advanceTimersByTime(5000); + await assertion; + } finally { + jest.useRealTimers(); + } + }); + + it('does not fire the timeout when the receipt arrives in time', async () => { + jest.useFakeTimers(); + + try { + const tx = makeTx(async () => makeReceipt()); + + await expect( + confirmTransaction(tx, { chainId: L2_CHAIN_ID, timeoutMs: 5000 }) + ).resolves.toBeDefined(); + + // Any surviving timer must have been cleared, otherwise this leaks. + expect(jest.getTimerCount()).toBe(0); + } finally { + jest.useRealTimers(); + } + }); + + it('defaults to a bounded timeout when none is supplied', async () => { + jest.useFakeTimers(); + + try { + expect(DEFAULT_CONFIRMATION_TIMEOUT_MS).toBeGreaterThan(0); + + const tx = makeTx(() => new Promise(() => undefined)); + const pending = confirmTransaction(tx, { chainId: L2_CHAIN_ID }); + const assertion = expect(pending).rejects.toBeInstanceOf(TransactionTimeoutError); + + jest.advanceTimersByTime(DEFAULT_CONFIRMATION_TIMEOUT_MS); + await assertion; + } finally { + jest.useRealTimers(); + } + }); + + it('throws when the receipt reports a reverted transaction', async () => { + // The core regression: `status === 0` means mined-but-failed, and this + // used to be reported to the user as a successful payment. + const tx = makeTx(async () => makeReceipt({ status: 0 })); + + await expect(confirmTransaction(tx, { chainId: L2_CHAIN_ID })).rejects.toBeInstanceOf( + TransactionRevertedError + ); + }); + + it('includes the revert reason when the node provides one', async () => { + // Nodes expose the reason in vendor-specific shapes that ethers v5 does + // not model on TransactionReceipt. + const receiptWithReason = { + ...makeReceipt({ status: 0 }), + revertReason: 'ERC20: transfer amount exceeds balance', + } as unknown as Receipt; + const tx = makeTx(async () => receiptWithReason); + + await expect(confirmTransaction(tx, { chainId: L2_CHAIN_ID })).rejects.toThrow( + /ERC20: transfer amount exceeds balance/ + ); + }); + + it('still throws for a revert when no reason is available', async () => { + const tx = makeTx(async () => makeReceipt({ status: 0 })); + + await expect(confirmTransaction(tx, { chainId: L2_CHAIN_ID })).rejects.toThrow( + /reverted on-chain/ + ); + }); + + it('maps an ethers wait timeout to a typed timeout error', async () => { + const timeoutError = Object.assign(new Error('timeout'), { code: 'TIMEOUT' }); + const tx = makeTx(async () => { + throw timeoutError; + }); + + await expect( + confirmTransaction(tx, { chainId: L2_CHAIN_ID, timeoutMs: 5000 }) + ).rejects.toBeInstanceOf(TransactionTimeoutError); + }); + + it('recognises timeout phrased as "timed out"', async () => { + const tx = makeTx(async () => { + throw new Error('transaction timed out waiting for receipt'); + }); + + await expect(confirmTransaction(tx, { chainId: L2_CHAIN_ID })).rejects.toBeInstanceOf( + TransactionTimeoutError + ); + }); + + it('treats a null receipt as an unknown outcome', async () => { + // ethers resolves null when a transaction was replaced or dropped. + const tx = makeTx(async () => null); + + await expect(confirmTransaction(tx, { chainId: L2_CHAIN_ID })).rejects.toBeInstanceOf( + TransactionTimeoutError + ); + }); + + it('throws when fewer confirmations than required were reached', async () => { + const tx = makeTx(async () => makeReceipt({ confirmations: 1 })); + + await expect(confirmTransaction(tx, { chainId: L1_CHAIN_ID })).rejects.toBeInstanceOf( + TransactionInsufficientConfirmationsError + ); + }); + + it('rethrows unexpected errors untouched', async () => { + const tx = makeTx(async () => { + throw new Error('user rejected transaction'); + }); + + await expect(confirmTransaction(tx, { chainId: L2_CHAIN_ID })).rejects.toThrow( + 'user rejected transaction' + ); + }); +}); + +describe('isDefinitiveFailure', () => { + it('treats a revert as definitive', () => { + expect(isDefinitiveFailure(new TransactionRevertedError('0x1'))).toBe(true); + }); + + it('treats insufficient confirmations as definitive', () => { + expect(isDefinitiveFailure(new TransactionInsufficientConfirmationsError('0x1', 1, 2))).toBe( + true + ); + }); + + it('does not treat a timeout as definitive', () => { + expect(isDefinitiveFailure(new TransactionTimeoutError('0x1', 100))).toBe(false); + }); +}); + +describe('bestEffortTransactionHash', () => { + it('returns the hash when the transaction confirms', async () => { + const tx = makeTx(async () => makeReceipt()); + await expect(bestEffortTransactionHash(tx, { chainId: L2_CHAIN_ID })).resolves.toBe('0xabc'); + }); + + it('still returns the hash when the receipt cannot be observed', async () => { + // A crash or dropped connection after broadcast leaves the outcome + // unknown; the hash is the durable handle the user needs. + const tx = makeTx(async () => null); + await expect(bestEffortTransactionHash(tx, { chainId: L2_CHAIN_ID })).resolves.toBe('0xabc'); + }); + + it('propagates a revert rather than swallowing it', async () => { + const tx = makeTx(async () => makeReceipt({ status: 0 })); + await expect(bestEffortTransactionHash(tx, { chainId: L2_CHAIN_ID })).rejects.toBeInstanceOf( + TransactionRevertedError + ); + }); + + it('returns undefined for a missing response', async () => { + await expect(bestEffortTransactionHash(null)).resolves.toBeUndefined(); + await expect(bestEffortTransactionHash(undefined)).resolves.toBeUndefined(); + }); +}); diff --git a/src/store/_tests_/transactionQueueStore.test.ts b/src/store/_tests_/transactionQueueStore.test.ts new file mode 100644 index 00000000..68131b55 --- /dev/null +++ b/src/store/_tests_/transactionQueueStore.test.ts @@ -0,0 +1,287 @@ +import { act } from 'react'; +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; + +import { ExecuteOrQueueResult, useTransactionQueueStore } from '../transactionQueueStore'; + +const mockCreateSuperfluidStream = + jest.fn<(...args: unknown[]) => Promise<{ streamId: string; txHash: string }>>(); +const mockCreateSablierStream = jest.fn(); + +jest.mock('@react-native-async-storage/async-storage', () => ({ + setItem: jest.fn(() => Promise.resolve()), + getItem: jest.fn(() => Promise.resolve(null)), + removeItem: jest.fn(() => Promise.resolve()), +})); + +jest.mock('@react-native-community/netinfo', () => ({ + __esModule: true, + default: { + addEventListener: jest.fn(() => jest.fn()), + fetch: jest.fn(() => Promise.resolve({ isConnected: true, isInternetReachable: true })), + }, +})); + +jest.mock('../../services/notificationService', () => ({ + presentTransactionQueueNotification: jest.fn(() => Promise.resolve()), +})); + +jest.mock('../../services/walletService', () => ({ + __esModule: true, + default: { + createSuperfluidStream: (...args: unknown[]) => + mockCreateSuperfluidStream(...args) as Promise<{ streamId: string; txHash: string }>, + createSablierStream: (...args: unknown[]) => mockCreateSablierStream(...args), + }, +})); + +const samplePayload = { + protocol: 'superfluid' as const, + token: 'USDC', + amount: '12.5', + recipientAddress: '0x1111111111111111111111111111111111111111', + chainId: 137, +}; + +describe('transactionQueueStore', () => { + beforeEach(() => { + jest.clearAllMocks(); + + useTransactionQueueStore.setState({ + isOnline: true, + isProcessing: false, + queuedTransactions: [], + lastError: null, + }); + }); + + it('queues transactions while offline', async () => { + useTransactionQueueStore.setState({ isOnline: false }); + + let result: ExecuteOrQueueResult | undefined; + await act(async () => { + result = await useTransactionQueueStore.getState().executeOrQueueTransaction(samplePayload); + }); + + expect(result?.queued).toBe(true); + expect(useTransactionQueueStore.getState().queuedTransactions).toHaveLength(1); + expect(mockCreateSuperfluidStream).not.toHaveBeenCalled(); + }); + + it('replaces conflicting queued transactions', async () => { + useTransactionQueueStore.setState({ isOnline: false }); + + await act(async () => { + await useTransactionQueueStore.getState().queueTransaction(samplePayload); + await useTransactionQueueStore.getState().queueTransaction({ + ...samplePayload, + amount: '20', + }); + }); + + const queued = useTransactionQueueStore.getState().queuedTransactions; + expect(queued).toHaveLength(1); + expect(queued[0].payload.amount).toBe('20'); + }); + + it('executes pending transactions when online and clears queue', async () => { + mockCreateSuperfluidStream.mockResolvedValue({ + streamId: 'stream:1', + txHash: '0xhash', + }); + + await act(async () => { + await useTransactionQueueStore.getState().queueTransaction(samplePayload); + await useTransactionQueueStore.getState().processQueue(); + }); + + expect(mockCreateSuperfluidStream).toHaveBeenCalledTimes(1); + expect(useTransactionQueueStore.getState().queuedTransactions).toHaveLength(0); + }); + + it('drops stale transactions during processing', async () => { + const staleTimestamp = Date.now() - 31 * 60 * 1000; + + useTransactionQueueStore.setState({ + queuedTransactions: [ + { + id: 'stale_tx', + createdAt: staleTimestamp, + updatedAt: staleTimestamp, + attempts: 0, + conflictKey: 'superfluid:137:usdc:0x1111111111111111111111111111111111111111', + status: 'pending', + payload: samplePayload, + }, + ], + }); + + await act(async () => { + await useTransactionQueueStore.getState().processQueue(); + }); + + expect(mockCreateSuperfluidStream).not.toHaveBeenCalled(); + expect(useTransactionQueueStore.getState().queuedTransactions).toHaveLength(0); + }); + + it('measures staleness from the last attempt, not creation', async () => { + // A transaction that keeps retrying is still making progress, so it must + // not be expired purely because it was created over 30 minutes ago. + const oldCreation = Date.now() - 31 * 60 * 1000; + const recentAttempt = Date.now() - 5_000; + + mockCreateSuperfluidStream.mockResolvedValue({ streamId: 'stream:1', txHash: '0xhash' }); + + useTransactionQueueStore.setState({ + queuedTransactions: [ + { + id: 'retrying_tx', + createdAt: oldCreation, + updatedAt: recentAttempt, + lastAttemptAt: recentAttempt, + attempts: 1, + conflictKey: 'superfluid:137:usdc:0x1111111111111111111111111111111111111111', + status: 'pending', + payload: samplePayload, + }, + ], + }); + + await act(async () => { + await useTransactionQueueStore.getState().processQueue(); + }); + + expect(mockCreateSuperfluidStream).toHaveBeenCalledTimes(1); + }); + + it('records the broadcast marker before handing the payload to the wallet', async () => { + let broadcastedAtSeenDuringExecution: number | undefined; + + mockCreateSuperfluidStream.mockImplementation(async () => { + // Inspect persisted state mid-flight: the marker must already be set, + // otherwise a crash here would cause a duplicate charge on restart. + const [entry] = useTransactionQueueStore.getState().queuedTransactions; + broadcastedAtSeenDuringExecution = entry?.broadcastedAt; + return { streamId: 'stream:1', txHash: '0xhash' }; + }); + + await act(async () => { + await useTransactionQueueStore.getState().queueTransaction(samplePayload); + await useTransactionQueueStore.getState().processQueue(); + }); + + expect(broadcastedAtSeenDuringExecution).toEqual(expect.any(Number)); + expect(useTransactionQueueStore.getState().queuedTransactions).toHaveLength(0); + }); + + it('retires a transaction whose outcome is unknown instead of re-broadcasting it', async () => { + // `broadcastedAt` in the past plus no receipt means the wallet may have + // already submitted it. Re-sending would charge the user twice. + const longAgo = Date.now() - 20 * 60 * 1000; + + useTransactionQueueStore.setState({ + queuedTransactions: [ + { + id: 'in_flight_tx', + createdAt: longAgo, + updatedAt: longAgo, + lastAttemptAt: longAgo, + broadcastedAt: longAgo, + attempts: 1, + conflictKey: 'superfluid:137:usdc:0x1111111111111111111111111111111111111111', + status: 'pending', + payload: samplePayload, + }, + ], + }); + + await act(async () => { + await useTransactionQueueStore.getState().processQueue(); + }); + + expect(mockCreateSuperfluidStream).not.toHaveBeenCalled(); + expect(useTransactionQueueStore.getState().queuedTransactions).toHaveLength(0); + expect(useTransactionQueueStore.getState().lastError).toMatch(/submitted before/i); + }); + + it('defers a retry until its backoff window has elapsed', async () => { + const now = Date.now(); + + mockCreateSuperfluidStream.mockRejectedValue(new Error('execution failed')); + + useTransactionQueueStore.setState({ + queuedTransactions: [ + { + id: 'backoff_tx', + createdAt: now - 1000, + updatedAt: now - 1000, + attempts: 1, + conflictKey: 'superfluid:137:usdc:0x1111111111111111111111111111111111111111', + status: 'pending', + payload: samplePayload, + nextAttemptAt: now + 60_000, + }, + ], + }); + + await act(async () => { + await useTransactionQueueStore.getState().processQueue(); + }); + + expect(mockCreateSuperfluidStream).not.toHaveBeenCalled(); + expect(useTransactionQueueStore.getState().queuedTransactions).toHaveLength(1); + }); + + it('schedules a backoff after a failed attempt', async () => { + mockCreateSuperfluidStream.mockRejectedValue(new Error('execution failed')); + + const before = Date.now(); + await act(async () => { + await useTransactionQueueStore.getState().queueTransaction(samplePayload); + await useTransactionQueueStore.getState().processQueue(); + }); + + const [entry] = useTransactionQueueStore.getState().queuedTransactions; + expect(entry?.nextAttemptAt).toBeGreaterThan(before); + }); + + it('keeps the broadcast marker after an ambiguous failure', async () => { + // A generic failure does not prove the transaction never reached the + // network, so the marker must survive to prevent a double charge. + mockCreateSuperfluidStream.mockRejectedValue(new Error('execution failed')); + + await act(async () => { + await useTransactionQueueStore.getState().queueTransaction(samplePayload); + await useTransactionQueueStore.getState().processQueue(); + }); + + const [entry] = useTransactionQueueStore.getState().queuedTransactions; + expect(entry?.broadcastedAt).toEqual(expect.any(Number)); + }); + + it('clears the broadcast marker when the wallet provably rejected the request', async () => { + mockCreateSuperfluidStream.mockRejectedValue( + Object.assign(new Error('Transaction was rejected in your wallet.'), { + code: 'USER_REJECTED', + }) + ); + + await act(async () => { + await useTransactionQueueStore.getState().queueTransaction(samplePayload); + await useTransactionQueueStore.getState().processQueue(); + }); + + const [entry] = useTransactionQueueStore.getState().queuedTransactions; + // Nothing was submitted, so an immediate retry is safe. + expect(entry?.broadcastedAt).toBeUndefined(); + }); + + it('keeps the connectivity listener alive while another consumer is mounted', async () => { + const unsubscribeFirst = useTransactionQueueStore.getState().initializeConnectivityListener(); + const unsubscribeSecond = useTransactionQueueStore.getState().initializeConnectivityListener(); + + unsubscribeFirst(); + + // The second consumer is still mounted, so tearing down must not happen. + expect(() => unsubscribeSecond()).not.toThrow(); + }); +}); diff --git a/src/utils/__tests__/billingCycleMath.test.ts b/src/utils/__tests__/billingCycleMath.test.ts new file mode 100644 index 00000000..d246f704 --- /dev/null +++ b/src/utils/__tests__/billingCycleMath.test.ts @@ -0,0 +1,103 @@ +/** + * Tests for billingCycleMath — one shared billing-cycle normaliser. + * + * @see src/utils/billingCycleMath.ts + * @see https://github.com/Smartdevs17/SubTrackr/issues/1684 + */ + +import { BillingCycle } from '../../types/subscription'; +import { + WEEKS_PER_MONTH, + isAnnualCycle, + monthlyEquivalent, + normalizeBillingCycle, + yearlyEquivalent, +} from '../billingCycleMath'; + +describe('normalizeBillingCycle', () => { + it('passes through valid enum values', () => { + expect(normalizeBillingCycle('monthly')).toBe(BillingCycle.MONTHLY); + expect(normalizeBillingCycle('yearly')).toBe(BillingCycle.YEARLY); + expect(normalizeBillingCycle('annual')).toBe(BillingCycle.ANNUAL); + expect(normalizeBillingCycle('weekly')).toBe(BillingCycle.WEEKLY); + expect(normalizeBillingCycle('custom')).toBe(BillingCycle.CUSTOM); + }); + + it('is case and whitespace insensitive', () => { + expect(normalizeBillingCycle(' YEARLY ')).toBe(BillingCycle.YEARLY); + expect(normalizeBillingCycle('Monthly')).toBe(BillingCycle.MONTHLY); + }); + + it('maps common synonyms', () => { + expect(normalizeBillingCycle('annually')).toBe(BillingCycle.YEARLY); + expect(normalizeBillingCycle('year')).toBe(BillingCycle.YEARLY); + expect(normalizeBillingCycle('month')).toBe(BillingCycle.MONTHLY); + expect(normalizeBillingCycle('week')).toBe(BillingCycle.WEEKLY); + }); + + it('defaults to monthly for unknown or missing values', () => { + expect(normalizeBillingCycle('fortnightly')).toBe(BillingCycle.MONTHLY); + expect(normalizeBillingCycle('')).toBe(BillingCycle.MONTHLY); + expect(normalizeBillingCycle(undefined)).toBe(BillingCycle.MONTHLY); + expect(normalizeBillingCycle(null)).toBe(BillingCycle.MONTHLY); + expect(normalizeBillingCycle(42)).toBe(BillingCycle.MONTHLY); + }); +}); + +describe('monthlyEquivalent', () => { + it('leaves monthly prices untouched', () => { + expect(monthlyEquivalent(10, BillingCycle.MONTHLY)).toBe(10); + }); + + it('divides both annual spellings by twelve', () => { + // The regression: `annual` fell through to the monthly branch, so a + // 120/year plan reported 120 MRR instead of 10. + expect(monthlyEquivalent(120, BillingCycle.YEARLY)).toBe(10); + expect(monthlyEquivalent(120, BillingCycle.ANNUAL)).toBe(10); + expect(monthlyEquivalent(120, 'annual')).toBe(10); + }); + + it('multiplies weekly prices by the shared weeks-per-month constant', () => { + expect(monthlyEquivalent(10, BillingCycle.WEEKLY)).toBeCloseTo(10 * WEEKS_PER_MONTH, 10); + }); + + it('uses 52/12 for weeks per month so it cannot drift from BILLING_CONVERSIONS', () => { + expect(WEEKS_PER_MONTH).toBe(52 / 12); + // The old hard-coded value was 4.345, which differs from 52/12. + expect(WEEKS_PER_MONTH).not.toBe(4.345); + }); + + it('maps non-finite prices to zero', () => { + expect(monthlyEquivalent(Number.NaN, BillingCycle.MONTHLY)).toBe(0); + expect(monthlyEquivalent(Number.POSITIVE_INFINITY, BillingCycle.YEARLY)).toBe(0); + }); +}); + +describe('yearlyEquivalent', () => { + it('leaves annual prices untouched', () => { + expect(yearlyEquivalent(120, BillingCycle.YEARLY)).toBe(120); + expect(yearlyEquivalent(120, BillingCycle.ANNUAL)).toBe(120); + }); + + it('multiplies monthly prices by twelve', () => { + expect(yearlyEquivalent(10, BillingCycle.MONTHLY)).toBe(120); + }); + + it('multiplies weekly prices by fifty-two', () => { + expect(yearlyEquivalent(10, BillingCycle.WEEKLY)).toBe(520); + }); +}); + +describe('isAnnualCycle', () => { + it('recognises both annual spellings', () => { + expect(isAnnualCycle(BillingCycle.YEARLY)).toBe(true); + expect(isAnnualCycle(BillingCycle.ANNUAL)).toBe(true); + expect(isAnnualCycle('annual')).toBe(true); + }); + + it('rejects non-annual cycles', () => { + expect(isAnnualCycle(BillingCycle.MONTHLY)).toBe(false); + expect(isAnnualCycle(BillingCycle.WEEKLY)).toBe(false); + expect(isAnnualCycle(undefined)).toBe(false); + }); +}); diff --git a/src/utils/__tests__/decimalAmount.test.ts b/src/utils/__tests__/decimalAmount.test.ts new file mode 100644 index 00000000..d952eec3 --- /dev/null +++ b/src/utils/__tests__/decimalAmount.test.ts @@ -0,0 +1,106 @@ +/** + * Tests for decimalAmount — exact fixed-point comparison for crypto amounts. + * + * @see src/utils/decimalAmount.ts + * @see https://github.com/Smartdevs17/SubTrackr/issues/1683 + */ + +import { compareDecimalAmounts, exceedsLimit, normalizeDecimal } from '../decimalAmount'; + +describe('normalizeDecimal', () => { + it('splits a decimal string into sign, digits and scale', () => { + expect(normalizeDecimal('12.5')).toEqual({ negative: false, digits: '125', scale: 1 }); + expect(normalizeDecimal('120')).toEqual({ negative: false, digits: '120', scale: 0 }); + }); + + it('strips leading zeros without changing the value', () => { + expect(normalizeDecimal('00.5')).toEqual({ negative: false, digits: '5', scale: 1 }); + expect(normalizeDecimal('0007')).toEqual({ negative: false, digits: '7', scale: 0 }); + }); + + it('accepts a leading decimal point', () => { + expect(normalizeDecimal('.5')).toEqual({ negative: false, digits: '5', scale: 1 }); + }); + + it('normalises negative zero to zero', () => { + expect(normalizeDecimal('-0')?.negative).toBe(false); + expect(normalizeDecimal('-0.00')?.negative).toBe(false); + }); + + it('preserves the sign for non-zero values', () => { + expect(normalizeDecimal('-5')?.negative).toBe(true); + expect(normalizeDecimal('-0.01')?.negative).toBe(true); + }); + + it('accepts finite numbers', () => { + expect(normalizeDecimal(1.5)).toEqual({ negative: false, digits: '15', scale: 1 }); + }); + + it('rejects anything that is not a finite decimal', () => { + expect(normalizeDecimal('')).toBeNull(); + expect(normalizeDecimal(' ')).toBeNull(); + expect(normalizeDecimal('abc')).toBeNull(); + expect(normalizeDecimal('.')).toBeNull(); + expect(normalizeDecimal('-')).toBeNull(); + expect(normalizeDecimal('1e18')).toBeNull(); + expect(normalizeDecimal('1,000')).toBeNull(); + expect(normalizeDecimal(null)).toBeNull(); + expect(normalizeDecimal(undefined)).toBeNull(); + expect(normalizeDecimal(Number.NaN)).toBeNull(); + expect(normalizeDecimal(Number.POSITIVE_INFINITY)).toBeNull(); + }); +}); + +describe('compareDecimalAmounts', () => { + it('compares equal values written at different scales', () => { + expect(compareDecimalAmounts('0.1', '0.10')).toBe(0); + expect(compareDecimalAmounts('5', '5.000')).toBe(0); + expect(compareDecimalAmounts('1.0', '1')).toBe(0); + }); + + it('orders fractional amounts correctly', () => { + expect(compareDecimalAmounts('12.5', '10')).toBe(1); + expect(compareDecimalAmounts('10', '12.5')).toBe(-1); + expect(compareDecimalAmounts('1.005', '1.004')).toBe(1); + expect(compareDecimalAmounts('1.004', '1.005')).toBe(-1); + }); + + it('does not lose precision on values that float maths gets wrong', () => { + // 0.1 + 0.2 !== 0.3 in binary floating point. + expect(compareDecimalAmounts('0.3', '0.1')).toBe(1); + expect(compareDecimalAmounts('0.3', '0.2')).toBe(1); + expect(compareDecimalAmounts('0.30000000000000004', '0.3')).toBe(1); + }); + + it('handles negatives', () => { + expect(compareDecimalAmounts('-5', '3')).toBe(-1); + expect(compareDecimalAmounts('3', '-5')).toBe(1); + expect(compareDecimalAmounts('-5', '-3')).toBe(-1); + expect(compareDecimalAmounts('-0.5', '-0.5')).toBe(0); + }); + + it('orders values of differing digit length', () => { + expect(compareDecimalAmounts('100', '99.99')).toBe(1); + expect(compareDecimalAmounts('0.001', '0.0009')).toBe(1); + }); + + it('throws on non-decimal input rather than guessing', () => { + expect(() => compareDecimalAmounts('abc', '1')).toThrow(/non-decimal/); + expect(() => compareDecimalAmounts('1', undefined)).toThrow(/non-decimal/); + }); +}); + +describe('exceedsLimit', () => { + it('is true only when the amount is strictly greater than the limit', () => { + expect(exceedsLimit('12.5', '10')).toBe(true); + expect(exceedsLimit('10', '10')).toBe(false); + expect(exceedsLimit('9.99', '10')).toBe(false); + }); + + it('works on fractional limits, which BigNumber.from would reject', () => { + // `ethers.BigNumber.from('0.1')` throws; this path must not. + expect(() => exceedsLimit('0.2', '0.1')).not.toThrow(); + expect(exceedsLimit('0.2', '0.1')).toBe(true); + expect(exceedsLimit('0.1', '0.1')).toBe(false); + }); +}); diff --git a/src/utils/__tests__/invoiceTotals.test.ts b/src/utils/__tests__/invoiceTotals.test.ts new file mode 100644 index 00000000..dbee1db0 --- /dev/null +++ b/src/utils/__tests__/invoiceTotals.test.ts @@ -0,0 +1,212 @@ +/** + * Tests for invoiceTotals — the single invoice arithmetic implementation. + * + * @see src/utils/invoiceTotals.ts + * @see https://github.com/Smartdevs17/SubTrackr/issues/1684 + */ + +import { + calculateInvoiceTotals, + resolveLineTotal, + roundMoney, + isDiscountLineItem, +} from '../invoiceTotals'; + +describe('roundMoney', () => { + it('rounds half away from zero at two decimal places', () => { + expect(roundMoney(1.005)).toBe(1.01); + expect(roundMoney(1.004)).toBe(1); + expect(roundMoney(2.675)).toBe(2.68); + }); + + it('rounds negative values symmetrically', () => { + expect(roundMoney(-1.005)).toBe(-1.01); + }); + + it('honours a custom precision', () => { + expect(roundMoney(1.2345, 3)).toBe(1.235); + expect(roundMoney(1.2345, 0)).toBe(1); + }); + + it('maps non-finite input to zero', () => { + expect(roundMoney(Number.NaN)).toBe(0); + expect(roundMoney(Number.POSITIVE_INFINITY)).toBe(0); + }); + + it('never returns negative zero', () => { + expect(Object.is(roundMoney(-0), 0)).toBe(true); + expect(Object.is(roundMoney(-0.001), 0)).toBe(true); + }); +}); + +describe('resolveLineTotal', () => { + it('prefers an explicit lineTotal', () => { + expect(resolveLineTotal({ lineTotal: 12, amount: 99, unitPrice: 5, quantity: 2 })).toBe(12); + }); + + it('falls back to the flat amount alias', () => { + expect(resolveLineTotal({ amount: 7.5, unitPrice: 5, quantity: 2 })).toBe(7.5); + }); + + it('derives unit price times quantity as a last resort', () => { + expect(resolveLineTotal({ unitPrice: 5, quantity: 3 })).toBe(15); + }); + + it('returns zero for an unusable line', () => { + expect(resolveLineTotal({})).toBe(0); + expect(resolveLineTotal({ lineTotal: Number.NaN })).toBe(0); + }); +}); + +describe('calculateInvoiceTotals', () => { + it('sums line items into a subtotal', () => { + const result = calculateInvoiceTotals({ + lineItems: [{ lineTotal: 10 }, { lineTotal: 5 }], + }); + expect(result.subtotal).toBe(15); + expect(result.tax).toBe(0); + expect(result.discount).toBe(0); + expect(result.total).toBe(15); + }); + + it('uses the flat amount when there are no line items', () => { + expect(calculateInvoiceTotals({ amount: 42 }).total).toBe(42); + }); + + it('prefers line items over the flat amount', () => { + expect(calculateInvoiceTotals({ lineItems: [{ lineTotal: 10 }], amount: 999 }).total).toBe(10); + }); + + it('adds tax and subtracts discount', () => { + const result = calculateInvoiceTotals({ + lineItems: [{ lineTotal: 100 }], + tax: 20, + discount: 15, + }); + expect(result.subtotal).toBe(100); + expect(result.tax).toBe(20); + expect(result.discount).toBe(15); + expect(result.total).toBe(105); + }); + + it('clamps a discount larger than the subtotal so the total never goes negative', () => { + const result = calculateInvoiceTotals({ + lineItems: [{ lineTotal: 100 }], + discount: 150, + }); + expect(result.discount).toBe(100); + expect(result.total).toBe(0); + }); + + it('ignores a negative discount rather than inflating the bill', () => { + const result = calculateInvoiceTotals({ + lineItems: [{ lineTotal: 100 }], + discount: -25, + }); + expect(result.discount).toBe(0); + expect(result.total).toBe(100); + }); + + it('rounds each component to minor units', () => { + const result = calculateInvoiceTotals({ + lineItems: [{ lineTotal: 10.005 }, { lineTotal: 10.005 }], + }); + // Raw sum is 20.01, and rounding the subtotal (not each line) gives 20.01. + expect(result.subtotal).toBe(20.01); + expect(result.total).toBe(20.01); + }); + + it('treats an empty invoice as zero', () => { + expect(calculateInvoiceTotals({}).total).toBe(0); + }); + + it('produces identical totals whether created or recomputed on update', () => { + const created = calculateInvoiceTotals({ + lineItems: [{ lineTotal: 50 }, { lineTotal: 30 }], + tax: 8, + discount: 10, + }); + + // Editing one line re-derives totals from line items + the stored tax and + // discount; the payable amount must not change. + const updated = calculateInvoiceTotals({ + lineItems: [{ lineTotal: 50 }, { lineTotal: 30 }], + tax: 8, + discount: 10, + }); + + expect(updated).toEqual(created); + expect(updated.total).toBe(78); + }); +}); + +describe('discount line items', () => { + it('identifies discount lines and treats unknown kinds as charges', () => { + expect(isDiscountLineItem({ kind: 'discount' })).toBe(true); + expect(isDiscountLineItem({ kind: 'item' })).toBe(false); + expect(isDiscountLineItem({})).toBe(false); + }); + + it('keeps discount lines out of the subtotal and subtracts them from the total', () => { + const result = calculateInvoiceTotals({ + lineItems: [{ lineTotal: 100 }, { kind: 'discount', lineTotal: 20 }], + }); + expect(result.subtotal).toBe(100); + expect(result.discount).toBe(20); + expect(result.total).toBe(80); + }); + + it('accepts a negative discount magnitude and still subtracts it', () => { + const result = calculateInvoiceTotals({ + lineItems: [{ lineTotal: 100 }, { kind: 'discount', lineTotal: -15 }], + }); + expect(result.discount).toBe(15); + expect(result.total).toBe(85); + }); + + it('adds an itemised discount to a legacy scalar discount', () => { + const result = calculateInvoiceTotals({ + lineItems: [{ lineTotal: 100 }, { kind: 'discount', lineTotal: 10 }], + discount: 5, + }); + expect(result.discount).toBe(15); + expect(result.total).toBe(85); + }); + + it('keeps tax on the pre-discount subtotal', () => { + const result = calculateInvoiceTotals({ + lineItems: [{ lineTotal: 100 }, { kind: 'discount', lineTotal: 10 }], + tax: 20, + }); + expect(result.subtotal).toBe(100); + expect(result.discount).toBe(10); + expect(result.total).toBe(110); + }); + + it('clamps an itemised discount to the subtotal', () => { + const result = calculateInvoiceTotals({ + lineItems: [{ lineTotal: 50 }, { kind: 'discount', lineTotal: 80 }], + }); + expect(result.discount).toBe(50); + expect(result.total).toBe(0); + }); + + it('charges nothing for a discount-only invoice', () => { + const result = calculateInvoiceTotals({ + lineItems: [{ kind: 'discount', lineTotal: 20 }], + }); + expect(result.subtotal).toBe(0); + expect(result.discount).toBe(0); + expect(result.total).toBe(0); + }); + + it('falls back to the flat amount when only a discount line is present', () => { + const result = calculateInvoiceTotals({ + lineItems: [{ kind: 'discount', lineTotal: 20 }], + amount: 100, + }); + expect(result.subtotal).toBe(100); + expect(result.discount).toBe(20); + expect(result.total).toBe(80); + }); +});