diff --git a/docs/test-architecture.md b/docs/test-architecture.md index e009df8e6..811229e3e 100644 --- a/docs/test-architecture.md +++ b/docs/test-architecture.md @@ -132,6 +132,23 @@ run does not prove for each one; the taxonomy and cross-repository entries live fixture with `{ userData: { verifiedName } }`. A green run proves that the review screen accepts that name, not that the API returns the logged-in staff member's `verifiedName`. The spec stays on the AML reset path and does not assert the Editor label. +- **The payment-link device-split tests answer the pay-request endpoint themselves.** The loc API + cannot build a Lightning/BTC transfer amount (`404 No BTC transfer amount found`), so + `e2e-stack/specs/payment-links.spec.ts` fulfils `paymentLink/payment` with a quoted OpenCryptoPay + payload (`displayQr: false`) and holds `lnurlp/wait` / `paymentLink/payment/wait` open. A green + run proves the desktop QR / handheld wallet-copy split for that payload, not that the loc API can + produce a quote or that a real quote's transfer amounts, expiry or callback match what the screen + then renders. + +- **The two payment visual specs answer every API they need themselves.** + `e2e/payment-qr-device.spec.ts` fulfils the `/v1/` payment routes with a fixed quote whose + expiry is a constant ISO string, holds the wait-polling open so it never re-fetches, and pins + wall-clock time; `e2e/payment-routes-qr-label.spec.ts` answers `GET /v2/user`, + `GET /v1/route`, the payment-link list, its config and POS endpoints and the info banner with + static payloads, and mounts the screen on a synthetic unsigned JWT. A green + run of either proves that the screen renders those payloads — the device split, the wallet copy + and the renamed setting label — not that a real session passes the address guard, that the API + returns these shapes, or that a live quote expires when the baseline says it does. ## Known gaps diff --git a/e2e-stack/docs/test-data.md b/e2e-stack/docs/test-data.md index 84af82fec..c07e8c91c 100644 --- a/e2e-stack/docs/test-data.md +++ b/e2e-stack/docs/test-data.md @@ -240,6 +240,7 @@ The self-reference check is deliberately conservative for a key spanning several | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Real bank/crypto **arrival** → auto process | Crons disabled (`DISABLED_PROCESSES=*`) | | Live **price-based** payment infos | Outbound HTTP mocked; CoinGecko/etc. unreliable | +| Real Lightning/BTC **payment-link quote** | `createPayRequest` 404s with "No BTC transfer amount found". The two device-split `/pl` tests therefore fulfill `paymentLink/payment` themselves — see `docs/test-architecture.md`. A green run does not prove the loc API can quote a payment. | | Full **Sumsub / IdNow** KYC completion | External ident providers mocked; use `createKycStep` + SQL `kycLevel` | | Mail **verification codes** after first mail | Mail sending disabled; first `PUT /v2/user/mail` applies immediately when mail was null | | Real **Lightning** payment-link API path | No Lightning deposits from EVM seed; factory uses SQL synthetic Lightning deposit | diff --git a/e2e-stack/specs/payment-links.spec.ts b/e2e-stack/specs/payment-links.spec.ts index d10f4383b..4f26a2a1d 100644 --- a/e2e-stack/specs/payment-links.spec.ts +++ b/e2e-stack/specs/payment-links.spec.ts @@ -10,7 +10,7 @@ * SQL factory instead. */ -import type { Page } from '@playwright/test'; +import { devices, type Page, type Route } from '@playwright/test'; import { apiGet, cleanupCreatedData, @@ -27,6 +27,70 @@ import { waitForRow, } from './fixtures'; +const SCAN_COPY = 'Scan the QR-Code with a compatible app to complete the payment.'; +const WALLET_COPY = 'Choose your wallet to open the payment.'; + +/** + * The loc API cannot build a Lightning/BTC transfer amount, so a quoted pay-request + * never reaches the browser (see docs/test-architecture.md). These two device-split tests + * replace only that response; everything else hits the real stack. + */ +async function installQuotedPayRequest( + page: Page, + opts: { merchant: string; amount: number; uniqueId: string }, +): Promise { + const quoted = { + id: opts.uniqueId, + externalId: `ext-${opts.uniqueId}`, + tag: 'payRequest', + displayName: opts.merchant, + standard: 'OpenCryptoPay', + possibleStandards: ['OpenCryptoPay'], + displayQr: false, + mode: 'Multiple', + route: 'e2e-quoted-route', + currency: 'CHF', + recipient: { name: opts.merchant }, + transferAmounts: [ + { + method: 'Lightning', + minFee: 0, + assets: [{ asset: 'BTC', amount: 0.00025 }], + available: true, + }, + ], + requestedAmount: { asset: 'CHF', amount: opts.amount }, + quote: { + id: `q-${opts.uniqueId}`, + expiration: new Date(Date.now() + 30 * 60 * 1000).toISOString(), + payment: `pay-${opts.uniqueId}`, + }, + callback: 'https://api.example.test/v1/lnurlp/cb/quoted', + metadata: 'e2e-quoted', + minSendable: 1, + maxSendable: 100000000, + }; + + await page.route('**/v1/**', async (route: Route) => { + const url = route.request().url(); + if (url.includes('/lnurlp/wait') || url.includes('paymentLink/payment/wait')) { + await new Promise(() => { + /* intentionally never resolves — same as the visual suite */ + }); + return; + } + if (url.includes('/paymentLink/payment')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(quoted), + }); + return; + } + await route.continue(); + }); +} + // --------------------------------------------------------------------------- // Types & helpers // --------------------------------------------------------------------------- @@ -254,6 +318,11 @@ test.describe('Payment links / routes / invoice', () => { // Payment Links section + card content unique to this screen. await expect(page.getByRole('heading', { name: 'Payment Links', exact: true })).toBeVisible(); await expect(page.getByText('e2e-routes-pl-label', { exact: true })).toBeVisible(); + + // `displayQr` is a force switch; the merchant-facing label has to say so. + await page.getByText('Default configuration', { exact: true }).locator('xpath=following-sibling::div').click(); + await expect(page.getByText('Always show QR code', { exact: true })).toBeVisible(); + await expect(page.getByText('Display QR code', { exact: true })).toHaveCount(0); if (pl.routeId) { await expect(page.getByText(`Payment route ${pl.routeId}`, { exact: true })).toBeVisible(); } @@ -352,6 +421,102 @@ test.describe('Payment links / routes / invoice', () => { } }); + test.describe('device-aware /pl quote view', () => { + test.describe('desktop', () => { + test.use({ + viewport: { width: 1280, height: 900 }, + isMobile: false, + hasTouch: false, + }); + + test('/pl: desktop with a quote shows the large QR and the scan sentence', async ({ page }) => { + const user = await createUser({ + tag: 'pl-qr-desktop', + language: 'EN', + kycLevel: 30, + completePersonalData: true, + }); + const externalId = 'e2e-pl-qr-desktop'; + const merchant = 'E2E Desktop Quoted Merchant'; + const pl = await createPaymentLink(user.jwt, { + tag: 'pl-qr-desktop', + amount: 24, + label: 'e2e-pl-qr-desktop', + externalId, + }); + + await installQuotedPayRequest(page, { + merchant, + amount: 24, + uniqueId: pl.uniqueId, + }); + await page.goto( + `/pl?routeId=${pl.routeId}&externalId=${encodeURIComponent(externalId)}&amount=24¤cy=CHF`, + { waitUntil: 'domcontentloaded' }, + ); + await expect + .poll(() => normPath(new URL(page.url()).pathname), { + message: 'expected pathname /pl', + timeout: 20000, + }) + .toBe('/pl'); + + await expect(page.getByText(merchant, { exact: true })).toBeVisible({ timeout: 20000 }); + await expect(page.getByText(SCAN_COPY, { exact: true })).toBeVisible(); + await expect(page.getByText(WALLET_COPY, { exact: true })).toHaveCount(0); + await expect(page.locator('.w-48.my-3 svg')).toBeVisible(); + }); + }); + + test.describe('handheld', () => { + test.use({ + userAgent: devices['iPhone 13'].userAgent, + viewport: devices['iPhone 13'].viewport, + deviceScaleFactor: devices['iPhone 13'].deviceScaleFactor, + isMobile: true, + hasTouch: true, + }); + + test('/pl: handheld with a quote shows wallet copy and the collapsed QR row', async ({ page }) => { + const user = await createUser({ + tag: 'pl-qr-handheld', + language: 'EN', + kycLevel: 30, + completePersonalData: true, + }); + const externalId = 'e2e-pl-qr-handheld'; + const merchant = 'E2E Handheld Quoted Merchant'; + const pl = await createPaymentLink(user.jwt, { + tag: 'pl-qr-handheld', + amount: 24, + label: 'e2e-pl-qr-handheld', + externalId, + }); + + await installQuotedPayRequest(page, { + merchant, + amount: 24, + uniqueId: pl.uniqueId, + }); + await page.goto( + `/pl?routeId=${pl.routeId}&externalId=${encodeURIComponent(externalId)}&amount=24¤cy=CHF`, + { waitUntil: 'domcontentloaded' }, + ); + await expect + .poll(() => normPath(new URL(page.url()).pathname), { + message: 'expected pathname /pl', + timeout: 20000, + }) + .toBe('/pl'); + + await expect(page.getByText(merchant, { exact: true })).toBeVisible({ timeout: 20000 }); + await expect(page.getByText(WALLET_COPY, { exact: true })).toBeVisible(); + await expect(page.getByText(SCAN_COPY, { exact: true })).toHaveCount(0); + await expect(page.locator('.w-48.my-3 svg')).toHaveCount(0); + }); + }); + }); + test('/pl: the real lightning= URL from GET /paymentLink now resolves through the tests-container forwarder', async ({ page, }) => { diff --git a/e2e/payment-qr-device.spec.ts b/e2e/payment-qr-device.spec.ts new file mode 100644 index 000000000..066d5b82c --- /dev/null +++ b/e2e/payment-qr-device.spec.ts @@ -0,0 +1,213 @@ +import { expect, test, devices, type Page, type Route } from '@playwright/test'; + +/** + * Visual regression: device-aware QR on the payment link page. + * + * Desktop → large QR + scan copy. + * Handheld (iPhone profile: mobile UA + coarse pointer) → no large QR + wallet copy. + * + * All payment APIs under /v1/ are mocked. Quote expiry is a fixed ISO string so the + * expiration display stays stable. Wait-polling is held open so it never re-triggers + * a re-fetch loop. + * + * API base URL for the QR payload is pinned via playwright.config webServer.env + * (E2E_API_URL ?? https://dev.api.dfx.swiss) so baselines stay matrix-stable. + */ + +const FIXED_NOW = new Date('2026-06-15T12:00:00.000Z'); +/** 30 minutes after FIXED_NOW — used as quote.expiration display value. */ +const FIXED_EXPIRATION = '2026-06-15T12:30:00.000Z'; + +const PAY_REQUEST = { + id: 'pl-handbook-1', + externalId: 'ext-handbook-1', + tag: 'handbook-tag', + displayName: 'Handbook Merchant', + standard: 'OpenCryptoPay', + possibleStandards: ['OpenCryptoPay'], + displayQr: false, + mode: 'Multiple', + route: 'route-handbook-1', + currency: 'CHF', + recipient: { name: 'Handbook Merchant' }, + transferAmounts: [ + { + method: 'Lightning', + minFee: 0, + assets: [{ asset: 'BTC', amount: 0.00025 }], + available: true, + }, + ], + requestedAmount: { asset: 'CHF', amount: 12.5 }, + quote: { + id: 'q-handbook', + expiration: FIXED_EXPIRATION, + payment: 'pay-handbook', + }, + callback: 'https://api.example.test/v1/lnurlp/cb/pay-handbook', + metadata: 'handbook-meta', + minSendable: 1, + maxSendable: 100000000, +}; + +const PAYMENT_STANDARDS = [ + { + id: 'OpenCryptoPay', + label: 'OpenCryptoPay', + description: 'Pay with a compatible app', + }, +]; + +const WALLET_APPS = [ + { + id: 1, + name: 'TestWallet', + iconUrl: + 'data:image/svg+xml,' + + encodeURIComponent( + '', + ), + recommended: true, + active: true, + supportedMethods: ['Lightning'], + websiteUrl: 'https://example.test', + deepLink: 'testwallet://', + }, +]; + +async function installPaymentMocks(page: Page): Promise { + // Repo standard: only intercept /v1/** so unknown API calls cannot slip to a live backend. + await page.route('**/v1/**', async (route: Route) => { + const url = route.request().url(); + + // Hold open so wait-polling never completes and re-fetches the quote. + // Check wait paths before the general /paymentLink/payment match (POS uses paymentLink/payment/wait). + if (url.includes('/lnurlp/wait') || url.includes('paymentLink/payment/wait')) { + await new Promise(() => { + /* intentionally never resolves */ + }); + return; + } + + if (url.includes('/paymentLink/payment') || url.includes('/plp')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(PAY_REQUEST), + }); + return; + } + + if (url.includes('/paymentLink/standard')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(PAYMENT_STANDARDS), + }); + return; + } + + if (url.includes('/paymentLink/walletApp')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(WALLET_APPS), + }); + return; + } + + if (url.includes('/lnurlp/cb') || url.includes('api.example.test')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ pr: 'lnbc1handbooktest' }), + }); + return; + } + + // Languages, assets, settings, etc. — empty JSON so the shell can boot offline. + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: '[]', + }); + }); +} + +async function openPaymentPage(page: Page): Promise { + await installPaymentMocks(page); + // Wall-clock pin for any Date.now() consumers; the rate/timer copy does not render + // here because PAYMENT_STANDARDS has no blockchain field (see payment-link.screen rate guard). + await page.clock.setFixedTime(FIXED_NOW); + + // Avoid networkidle: the intentional hang on /lnurlp/wait keeps a connection open forever. + await page.goto('/pl?route=route-handbook-1&externalId=ext-handbook-1&amount=12.5¤cy=CHF&lang=en', { + waitUntil: 'domcontentloaded', + }); + + // Merchant name + amount confirm the quote rendered (not the loading spinner). + await expect(page.getByText('Handbook Merchant')).toBeVisible({ timeout: 30000 }); + await expect(page.getByText('12.5')).toBeVisible({ timeout: 15000 }); + + // QrBasic uses animate-pulse while isLoading; wait until the near-invisible QR state is gone. + await expect(page.locator('.animate-pulse')).toHaveCount(0, { timeout: 15000 }); +} + +const screenshotOpts = { + animations: 'disabled' as const, + maxDiffPixels: 200, +}; + +test.describe('Payment QR — desktop', () => { + test.use({ + viewport: { width: 1280, height: 900 }, + isMobile: false, + hasTouch: false, + }); + + test('desktop shows large QR and scan copy', async ({ page }) => { + await openPaymentPage(page); + + await expect(page.getByText('Scan the QR-Code with a compatible app to complete the payment.')).toBeVisible(); + await expect(page.getByText('Choose your wallet to open the payment.')).toHaveCount(0); + + // Confirm device detection: desktop UA + fine pointer. + const deviceFlags = await page.evaluate(() => ({ + coarse: + typeof window.matchMedia === 'function' ? window.matchMedia('(pointer: coarse)').matches : null, + })); + expect(deviceFlags.coarse).toBe(false); + + await expect(page).toHaveScreenshot('payment-qr-desktop.png', screenshotOpts); + }); +}); + +test.describe('Payment QR — handheld', () => { + // iPhone 13 profile: mobile UA (react-device-detect) + hasTouch (pointer: coarse). + // Strip defaultBrowserType so the chromium project still drives the browser. + test.use({ + userAgent: devices['iPhone 13'].userAgent, + viewport: devices['iPhone 13'].viewport, + deviceScaleFactor: devices['iPhone 13'].deviceScaleFactor, + isMobile: true, + hasTouch: true, + }); + + test('handheld shows wallet copy without large QR', async ({ page }) => { + await openPaymentPage(page); + + await expect(page.getByText('Choose your wallet to open the payment.')).toBeVisible(); + await expect(page.getByText('Scan the QR-Code with a compatible app to complete the payment.')).toHaveCount(0); + + const deviceFlags = await page.evaluate(() => ({ + coarse: + typeof window.matchMedia === 'function' ? window.matchMedia('(pointer: coarse)').matches : null, + ua: navigator.userAgent, + })); + // Both signals the screen reads: UA (via isMobile) and coarse pointer. + expect(deviceFlags.ua).toMatch(/iPhone/i); + expect(deviceFlags.coarse).toBe(true); + + await expect(page).toHaveScreenshot('payment-qr-handheld.png', screenshotOpts); + }); +}); diff --git a/e2e/payment-routes-qr-label.spec.ts b/e2e/payment-routes-qr-label.spec.ts new file mode 100644 index 000000000..8bb8499b7 --- /dev/null +++ b/e2e/payment-routes-qr-label.spec.ts @@ -0,0 +1,165 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; + +/** + * Visual regression: the payment-routes QR setting label. + * + * "Display QR code" was renamed to "Always show QR code" so the merchant-facing + * dropdown matches the force-switch meaning of `displayQr`. This spec opens the + * authenticated routes screen on mocked APIs, expands the default configuration + * row, and snapshots the new label. + * + * All /v1 and /v2 APIs are mocked. Session is a synthetic JWT so the address + * guard lets the screen mount without a live backend. + */ + +function jwt(): string { + const encode = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64url'); + return `${encode({ alg: 'none', typ: 'JWT' })}.${encode({ + account: 1, + user: 1, + role: 'User', + address: '0x0000000000000000000000000000000000000001', + blockchains: ['Ethereum'], + exp: Math.floor(Date.now() / 1000) + 3600, + iat: Math.floor(Date.now() / 1000), + })}.synthetic`; +} + +async function fulfillJson(route: Route, body: unknown): Promise { + await route.fulfill({ + status: 200, + contentType: 'application/json', + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': '*', + }, + body: JSON.stringify(body), + }); +} + +const BUY_ROUTES = { + buy: [ + { + id: 1, + active: true, + asset: { name: 'BTC', blockchain: 'Bitcoin' }, + bankUsage: 'DFX BUY 1', + volume: 0, + annualVolume: 0, + }, + ], + sell: [] as unknown[], + swap: [] as unknown[], +}; + +const PAYMENT_LINKS = [ + { + id: 'pl-handbook-routes', + routeId: 1, + status: 'Active', + label: 'Handbook Shop Link', + externalId: 'ext-handbook-routes', + url: 'https://pay.example/pl', + lnurl: 'lnurl1handbookroutes', + config: { displayQr: false }, + }, +]; + +async function installRoutesMocks(page: Page): Promise { + await page.route(/\/v[12]\//, async (route: Route) => { + const request = route.request(); + const path = new URL(request.url()).pathname; + + if (request.method() === 'OPTIONS') { + await route.fulfill({ + status: 204, + headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*' }, + }); + return; + } + + if (request.method() === 'GET' && path === '/v2/user') { + await fulfillJson(route, { + id: 1, + accountId: 'acc-handbook-routes', + paymentLink: { active: true, url: 'https://pay.example' }, + activeAddress: { + address: '0x0000000000000000000000000000000000000001', + blockchains: ['Ethereum'], + }, + addresses: [], + kyc: { level: 30, status: 'Completed' }, + language: { id: 1, name: 'English', symbol: 'EN' }, + }); + return; + } + + if (request.method() === 'GET' && /\/v1\/route\/?$/.test(path)) { + await fulfillJson(route, BUY_ROUTES); + return; + } + + if (request.method() === 'GET' && path.includes('/paymentLink/config')) { + await fulfillJson(route, { + standards: ['OpenCryptoPay'], + minCompletionStatus: 'TxReceived', + displayQr: false, + fee: 0, + paymentTimeout: 60, + cancellable: true, + }); + return; + } + + if (path.includes('/paymentLink/pos')) { + await fulfillJson(route, { url: 'https://pos.example/handbook' }); + return; + } + + if (request.method() === 'GET' && path.includes('/paymentLink')) { + await fulfillJson(route, PAYMENT_LINKS); + return; + } + + if (path.includes('/setting/infoBanner')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + headers: { 'Access-Control-Allow-Origin': '*' }, + body: 'null', + }); + return; + } + + await fulfillJson(route, []); + }); +} + +const screenshotOpts = { + animations: 'disabled' as const, + maxDiffPixels: 200, +}; + +test.describe('Payment routes — Always show QR code label', () => { + test.use({ + viewport: { width: 1280, height: 900 }, + isMobile: false, + hasTouch: false, + }); + + test('default configuration shows Always show QR code', async ({ page }) => { + await installRoutesMocks(page); + await page.goto(`/routes?session=${jwt()}&lang=en`, { waitUntil: 'domcontentloaded' }); + + await expect(page.getByText('Payment Links', { exact: true })).toBeVisible({ timeout: 30000 }); + await expect(page.getByText('Handbook Shop Link', { exact: true })).toBeVisible(); + await expect(page.getByText('Default configuration', { exact: true })).toBeVisible(); + + await page.getByText('Default configuration', { exact: true }).locator('xpath=following-sibling::div').click(); + + await expect(page.getByText('Always show QR code', { exact: true })).toBeVisible(); + await expect(page.getByText('Display QR code', { exact: true })).toHaveCount(0); + + await expect(page).toHaveScreenshot('payment-routes-qr-label.png', screenshotOpts); + }); +}); diff --git a/e2e/screenshots/baseline/payment-qr-device.spec.ts-payment-qr-desktop-chromium-darwin.png b/e2e/screenshots/baseline/payment-qr-device.spec.ts-payment-qr-desktop-chromium-darwin.png new file mode 100644 index 000000000..0bfb405e8 Binary files /dev/null and b/e2e/screenshots/baseline/payment-qr-device.spec.ts-payment-qr-desktop-chromium-darwin.png differ diff --git a/e2e/screenshots/baseline/payment-qr-device.spec.ts-payment-qr-handheld-chromium-darwin.png b/e2e/screenshots/baseline/payment-qr-device.spec.ts-payment-qr-handheld-chromium-darwin.png new file mode 100644 index 000000000..e44f3fbf1 Binary files /dev/null and b/e2e/screenshots/baseline/payment-qr-device.spec.ts-payment-qr-handheld-chromium-darwin.png differ diff --git a/e2e/screenshots/baseline/payment-routes-qr-label.spec.ts-payment-routes-qr-label-chromium-darwin.png b/e2e/screenshots/baseline/payment-routes-qr-label.spec.ts-payment-routes-qr-label-chromium-darwin.png new file mode 100644 index 000000000..320fc412c Binary files /dev/null and b/e2e/screenshots/baseline/payment-routes-qr-label.spec.ts-payment-routes-qr-label-chromium-darwin.png differ diff --git a/package-lock.json b/package-lock.json index 06c3ce079..c6577b5c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,7 @@ "process": "^0.11.10", "react": "18.2.0", "react-apexcharts": "^1.7.0", + "react-device-detect": "2.2.3", "react-dom": "18.2.0", "react-hook-form": "^7.40.0", "react-i18next": "^12.2.0", diff --git a/package.json b/package.json index 43af5285b..ff357e79d 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "process": "^0.11.10", "react": "18.2.0", "react-apexcharts": "^1.7.0", + "react-device-detect": "2.2.3", "react-dom": "18.2.0", "react-hook-form": "^7.40.0", "react-i18next": "^12.2.0", diff --git a/playwright.config.ts b/playwright.config.ts index 5d6a3cb1c..b13345a23 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -38,5 +38,13 @@ export default defineConfig({ url: 'http://localhost:3001', reuseExistingServer: !process.env.CI, timeout: 120000, + // Pin API base URL so QR matrix (and any code reading REACT_APP_API_URL) is baseline-stable. + // Same default as scripts/e2e-test.sh; E2E_API_URL wins, then an explicit + // REACT_APP_API_URL — the local run documented in CONTRIBUTING sets that one. + env: { + ...process.env, + REACT_APP_API_URL: process.env.E2E_API_URL ?? process.env.REACT_APP_API_URL ?? 'https://dev.api.dfx.swiss', + REACT_APP_PUBLIC_URL: process.env.E2E_PUBLIC_URL ?? 'http://localhost:3001', + }, }, }); diff --git a/scripts/handbook/metadata.json b/scripts/handbook/metadata.json index 39ed481b8..0dab8711f 100644 --- a/scripts/handbook/metadata.json +++ b/scripts/handbook/metadata.json @@ -163,6 +163,14 @@ "title": "Session-Wechsel (Debug)", "description": "Debug-Screenshots zum Session-/Account-Wechsel." }, + "payment-qr-device": { + "title": "Zahlungs-QR geräteabhängig", + "description": "Zahlungsseite mit fester Quote: Desktop zeigt großen QR und Scan-Satz, Handheld nur Wallet-Auswahl." + }, + "payment-routes-qr-label": { + "title": "Payment-Routen QR-Einstellung", + "description": "Payment-Routen: Default-Konfiguration mit dem Label „Always show QR code“ statt „Display QR code“." + }, "docs": { "docs/EIP7702-Implementierungsplan.md": { "title": "EIP-7702 Implementierungsplan" diff --git a/src/__tests__/device.hook.test.ts b/src/__tests__/device.hook.test.ts new file mode 100644 index 000000000..e331b7d09 --- /dev/null +++ b/src/__tests__/device.hook.test.ts @@ -0,0 +1,169 @@ +// useIsHandheld: UA (isMobile) OR coarse pointer; older Safari uses addListener. + +const mockDevice = { isMobile: false }; + +jest.mock('react-device-detect', () => ({ + get isMobile() { + return mockDevice.isMobile; + }, +})); + +import { act, renderHook } from '@testing-library/react'; +import { useIsHandheld } from '../hooks/device.hook'; + +type Listener = (event?: MediaQueryListEvent) => void; + +function installMatchMedia(options: { + matches: boolean; + /** Prefer modern API; set false to exercise addListener fallback. */ + modern?: boolean; +}): { listeners: Listener[]; mediaQuery: MediaQueryList } { + const listeners: Listener[] = []; + const modern = options.modern !== false; + + const mediaQuery = { + matches: options.matches, + media: '(pointer: coarse)', + onchange: null, + addEventListener: modern + ? jest.fn((event: string, cb: Listener) => { + if (event === 'change') listeners.push(cb); + }) + : undefined, + removeEventListener: modern + ? jest.fn((event: string, cb: Listener) => { + if (event === 'change') { + const idx = listeners.indexOf(cb); + if (idx >= 0) listeners.splice(idx, 1); + } + }) + : undefined, + addListener: jest.fn((cb: Listener) => { + listeners.push(cb); + }), + removeListener: jest.fn((cb: Listener) => { + const idx = listeners.indexOf(cb); + if (idx >= 0) listeners.splice(idx, 1); + }), + dispatchEvent: jest.fn(), + } as unknown as MediaQueryList; + + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: jest.fn().mockImplementation((query: string) => { + if (query === '(pointer: coarse)') return mediaQuery; + return { ...mediaQuery, matches: false }; + }), + }); + + return { listeners, mediaQuery }; +} + +describe('useIsHandheld', () => { + beforeEach(() => { + mockDevice.isMobile = false; + }); + + it('returns false when desktop UA and fine pointer', () => { + installMatchMedia({ matches: false }); + mockDevice.isMobile = false; + const { result } = renderHook(() => useIsHandheld()); + expect(result.current).toBe(false); + }); + + it('returns true when isMobile even if pointer is fine', () => { + installMatchMedia({ matches: false }); + mockDevice.isMobile = true; + const { result } = renderHook(() => useIsHandheld()); + expect(result.current).toBe(true); + }); + + it('returns true when coarse pointer even if UA is desktop (request desktop site)', () => { + installMatchMedia({ matches: true }); + mockDevice.isMobile = false; + const { result } = renderHook(() => useIsHandheld()); + expect(result.current).toBe(true); + }); + + it('returns false when matchMedia is missing and isMobile is false', () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: undefined, + }); + mockDevice.isMobile = false; + const { result } = renderHook(() => useIsHandheld()); + expect(result.current).toBe(false); + }); + + it('returns true when matchMedia is missing and isMobile is true', () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: undefined, + }); + mockDevice.isMobile = true; + const { result } = renderHook(() => useIsHandheld()); + expect(result.current).toBe(true); + }); + + it('subscribes via addEventListener and updates on change', () => { + const { listeners, mediaQuery } = installMatchMedia({ matches: false, modern: true }); + mockDevice.isMobile = false; + const { result } = renderHook(() => useIsHandheld()); + expect(result.current).toBe(false); + expect(mediaQuery.addEventListener).toHaveBeenCalledWith('change', expect.any(Function)); + + act(() => { + (mediaQuery as unknown as { matches: boolean }).matches = true; + listeners.forEach((l) => l()); + }); + expect(result.current).toBe(true); + }); + + it('falls back to addListener when addEventListener is absent (older Safari)', () => { + const { listeners, mediaQuery } = installMatchMedia({ matches: false, modern: false }); + mockDevice.isMobile = false; + const { result, unmount } = renderHook(() => useIsHandheld()); + expect(result.current).toBe(false); + expect(mediaQuery.addListener).toHaveBeenCalledWith(expect.any(Function)); + expect(mediaQuery.addEventListener).toBeUndefined(); + + act(() => { + (mediaQuery as unknown as { matches: boolean }).matches = true; + listeners.forEach((l) => l()); + }); + expect(result.current).toBe(true); + + unmount(); + expect(mediaQuery.removeListener).toHaveBeenCalled(); + }); + + it('cleans up the modern change listener on unmount', () => { + const { mediaQuery } = installMatchMedia({ matches: false, modern: true }); + const { unmount } = renderHook(() => useIsHandheld()); + unmount(); + expect(mediaQuery.removeEventListener).toHaveBeenCalledWith('change', expect.any(Function)); + }); + + it('subscribes to nothing when neither addEventListener nor addListener exist', () => { + const mediaQuery = { + matches: true, + media: '(pointer: coarse)', + onchange: null, + dispatchEvent: jest.fn(), + } as unknown as MediaQueryList; + + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: jest.fn().mockReturnValue(mediaQuery), + }); + mockDevice.isMobile = false; + const { result, unmount } = renderHook(() => useIsHandheld()); + // Initial state still reads matches via readCoarsePointer / update(). + expect(result.current).toBe(true); + unmount(); + }); +}); diff --git a/src/__tests__/payment-link.coverage.test.tsx b/src/__tests__/payment-link.coverage.test.tsx new file mode 100644 index 000000000..bbae42117 --- /dev/null +++ b/src/__tests__/payment-link.coverage.test.tsx @@ -0,0 +1,1133 @@ +// Remaining payment-link.screen coverage: standard URL sync, asset/EVM rows, +// wallet detail deeplinks, empty wallet grid, transfer methods, recipient website, +// contract toggle, rate N/A, and MetaMask-hidden OCP section. + +const mockDevice = { isMobile: false }; + +jest.mock('react-device-detect', () => ({ + get isMobile() { + return mockDevice.isMobile; + }, +})); + +const mockUseApiCall = jest.fn(); +const mockAssetsMap = new Map(); +mockAssetsMap.set('Ethereum', [ + { name: 'ETH', chainId: '0xeth', explorerUrl: 'https://etherscan.io/token/0xeth', decimals: 18 }, + { name: 'USDC', chainId: '0xusdc', decimals: 6 }, +]); + +jest.mock('@dfx.swiss/react', () => ({ + PaymentLinkMode: { SINGLE: 'Single', MULTIPLE: 'Multiple', PUBLIC: 'Public' }, + PaymentLinkPaymentStatus: { + PENDING: 'Pending', + COMPLETED: 'Completed', + CANCELLED: 'Cancelled', + EXPIRED: 'Expired', + }, + PaymentStandardType: { + OPEN_CRYPTO_PAY: 'OpenCryptoPay', + LIGHTNING_BOLT11: 'LightningBolt11', + PAY_TO_ADDRESS: 'PayToAddress', + }, + Utils: { + formatAmount: (n: number) => String(n), + createRules: () => ({}), + }, + Validations: { Required: 'required' }, + useApi: () => ({ call: mockUseApiCall }), + useAssetContext: () => ({ assets: mockAssetsMap }), +})); + +jest.mock('@dfx.swiss/react-components', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const React = require('react'); + const { Children, cloneElement, isValidElement } = React; + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { Controller } = require('react-hook-form'); + + function enrichChildren(children: unknown, control: unknown): unknown { + return Children.map(children as React.ReactNode, (child: React.ReactNode) => { + if (!isValidElement(child)) return child; + const childProps = child.props as Record; + const nextChildren = enrichChildren(childProps.children, control); + if (childProps.name) { + return cloneElement(child as React.ReactElement, { control, children: nextChildren }); + } + return cloneElement(child as React.ReactElement, { children: nextChildren }); + }); + } + + return { + AlignContent: { RIGHT: 'right' }, + CopyButton: ({ onCopy }: { onCopy?: () => void }) => ( + + ), + DfxIcon: () => , + Form: ({ + children, + control, + onSubmit, + }: { + children: React.ReactNode; + control?: unknown; + onSubmit?: (e: React.FormEvent) => void; + }) => ( +
{ + e.preventDefault(); + onSubmit?.(e); + }} + > + {enrichChildren(children, control)} +
+ ), + IconColor: { GRAY: 'gray', BLUE: 'blue', DARK_GRAY: 'dark-gray' }, + IconSize: { SM: 'sm' }, + IconVariant: { + BACK: 'back', + COPY: 'copy', + OPEN_IN_NEW: 'open', + INFO: 'info', + INFO_OUTLINE: 'info-outline', + }, + SpinnerSize: { LG: 'lg', MD: 'md' }, + SpinnerVariant: { LIGHT_MODE: 'light' }, + StyledButton: ({ + label, + onClick, + type, + isLoading, + hidden, + }: { + label: string; + onClick?: () => void; + type?: string; + isLoading?: boolean; + hidden?: boolean; + }) => + hidden ? null : ( + + ), + StyledButtonColor: { STURDY_WHITE: 'sturdy-white', RED: 'red', GREEN: 'green' }, + StyledButtonSize: { DOUBLE: 'double' }, + StyledButtonWidth: { FULL: 'full' }, + StyledCollapsible: ({ children, titleContent }: { children: React.ReactNode; titleContent?: React.ReactNode }) => ( +
+ {titleContent} + {children} +
+ ), + StyledDataTable: ({ children }: { children: React.ReactNode }) =>
{children}
, + StyledDataTableExpandableRow: ({ + label, + children, + expansionContent, + expansionItems, + }: { + label: string; + children?: React.ReactNode; + expansionContent?: React.ReactNode; + expansionItems?: { label: string; text?: string; onClick?: () => void }[]; + }) => ( +
+ {label} + {children} + {expansionItems?.map((item) => ( + + ))} + {expansionContent} +
+ ), + StyledDataTableRow: ({ children, label }: { children?: React.ReactNode; label?: string }) => ( +
+ {label} + {children} +
+ ), + StyledDropdown: ({ + name, + control, + items, + labelFunc, + descriptionFunc, + }: { + name: string; + control?: unknown; + items?: unknown[]; + labelFunc?: (item: unknown) => string; + descriptionFunc?: (item: unknown) => string; + }) => ( + void } }) => ( +
+ {(items ?? []).map((item, i) => ( + + ))} +
+ )} + /> + ), + StyledHorizontalStack: ({ children }: { children: React.ReactNode }) =>
{children}
, + StyledIconButton: ({ + onClick, + icon, + }: { + onClick?: () => void; + icon?: string; + }) => ( + + ), + StyledInfoText: ({ children }: { children: React.ReactNode }) =>
{children}
, + StyledInfoTextSize: { XS: 'xs' }, + StyledInput: ({ name, control, label }: { name: string; control?: unknown; label?: string }) => ( + void } }) => ( + + )} + /> + ), + StyledLink: ({ label }: { label?: string }) => {label}, + StyledLoadingSpinner: () =>
, + StyledVerticalStack: ({ children }: { children: React.ReactNode }) =>
{children}
, + }; +}); + +jest.mock('copy-to-clipboard', () => jest.fn()); +jest.mock('react-lazy-load-image-component', () => ({ + LazyLoadImage: ({ alt }: { alt?: string }) => {alt}, +})); +jest.mock('react-lazy-load-image-component/src/effects/opacity.css', () => ({})); +jest.mock('../components/error-hint', () => ({ + ErrorHint: ({ message }: { message: string }) =>
{message}
, +})); +jest.mock('../components/payment/qr-code', () => ({ + QrBasic: () =>
QR
, +})); +jest.mock('../components/pl/payment-status-tile', () => ({ + __esModule: true, + default: () => null, +})); +jest.mock('../components/app-store-badge', () => ({ + AppStoreBadge: ({ type }: { type: string }) =>
, +})); +jest.mock('../contexts/settings.context', () => ({ + useSettingsContext: () => ({ + translate: (_ns: string, key: string, params?: Record) => { + if (!params) return key; + let out = key; + for (const [k, v] of Object.entries(params)) out = out.replace(`{{${k}}}`, String(v)); + return out; + }, + translateError: (e: string) => e, + }), +})); +jest.mock('../contexts/layout.context', () => ({ + useLayoutContext: () => ({ rootRef: { current: null } }), +})); +jest.mock('../contexts/window.context', () => ({ + useWindowContext: () => ({ width: 1024 }), +})); +jest.mock('../hooks/layout-config.hook', () => ({ + useLayoutOptions: () => undefined, +})); + +const mockNavigate = jest.fn(); +jest.mock('../hooks/navigation.hook', () => ({ + useNavigation: () => ({ navigate: mockNavigate, goBack: jest.fn() }), +})); + +const mockToBlockchain = jest.fn((id: string) => (id === '1' ? 'Ethereum' : undefined)); +jest.mock('../hooks/web3.hook', () => ({ + useWeb3: () => ({ toBlockchain: mockToBlockchain }), +})); + +jest.mock('../util/open-crypto-pay', () => ({ + OpenCryptoPayUtils: { + getOcpUrlByUniqueId: (id: string) => `ocp://${id}`, + }, +})); + +jest.mock('../util/utils', () => ({ + blankedAddress: (v: string) => v, + formatAmountForDisplay: (n: number) => String(n), + formatLocationAddress: () => 'Main 1', + formatUnits: (v: string) => `units(${v})`, +})); + +const mockDecodeUri = jest.fn(); +jest.mock('../util/evm', () => ({ + Evm: { decodeUri: (uri: string) => mockDecodeUri(uri) }, +})); + +jest.mock('../util/app-store-badges', () => ({ + BadgeType: { PLAY_STORE: 'play', APP_STORE: 'app' }, +})); + +jest.mock('../util/payment-link-wallet', () => ({ + Wallet: { + filterTransferInfoByWallet: (_wallet: unknown, transferInfoList: unknown[]) => transferInfoList, + qualifiesForPayment: () => true, + }, +})); + +const mockUsePaymentLinkContext = jest.fn(); +jest.mock('../contexts/payment-link.context', () => ({ + usePaymentLinkContext: () => mockUsePaymentLinkContext(), +})); + +const mockUsePaymentLinkWallets = jest.fn(); +jest.mock('../hooks/payment-link-wallets.hook', () => ({ + usePaymentLinkWallets: () => mockUsePaymentLinkWallets(), +})); + +import copy from 'copy-to-clipboard'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import PaymentLinkScreen from '../screens/payment-link.screen'; + +function setMatchMedia(coarse: boolean | 'undefined'): void { + if (coarse === 'undefined') { + Object.defineProperty(window, 'matchMedia', { writable: true, configurable: true, value: undefined }); + return; + } + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: jest.fn().mockImplementation((query: string) => ({ + matches: query === '(pointer: coarse)' ? coarse : false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); +} + +const SAMPLE_WALLET = { + id: 42, + name: 'SampleWallet', + iconUrl: 'https://example.test/w.png', + recommended: true, + active: true, + supportedMethods: ['Lightning', 'Ethereum'], + websiteUrl: 'https://wallet.example', + deepLink: 'sample://pay', + playStoreUrl: 'https://play.example', + appStoreUrl: 'https://app.example', + hasActionDeepLink: false, +}; + +function buildPayRequest(overrides: Record = {}) { + return { + id: 'pay-1', + externalId: 'ext-1', + tag: 'tag', + displayName: 'Test Merchant', + standard: 'OpenCryptoPay', + possibleStandards: ['OpenCryptoPay'], + displayQr: false, + mode: 'Multiple', + route: 'route-1', + currency: 'CHF', + recipient: { + name: 'Test Merchant', + address: { street: 'Main', country: 'CH' }, + phone: '+41', + mail: 'user@example.com', + website: 'merchant.example', + }, + transferAmounts: [ + { + method: 'Lightning', + minFee: 0, + available: true, + assets: [{ asset: 'BTC', amount: 0.001 }], + }, + { + method: 'Ethereum', + minFee: 0, + available: true, + assets: [{ asset: 'ETH', amount: 0.5 }], + }, + ], + requestedAmount: { asset: 'CHF', amount: 100 }, + quote: { id: 'q1', expiration: new Date(Date.now() + 60_000 * 60).toISOString(), payment: 'p1' }, + callback: 'https://callback.example', + ...overrides, + }; +} + +function baseContext(overrides: Record = {}) { + const paymentLinkApiUrl = { + current: 'https://api.example.com/v1/paymentLink/payment?standard=OpenCryptoPay', + }; + const callbackUrl = { current: undefined as string | undefined }; + const setSessionApiUrl = jest.fn((url?: string) => { + paymentLinkApiUrl.current = url ?? ''; + }); + + return { + error: undefined, + merchant: undefined, + payRequest: buildPayRequest(), + timer: { minutes: 5, seconds: 0 }, + paymentLinkApiUrl, + callbackUrl, + paymentStandards: [ + { id: 'OpenCryptoPay', label: 'OpenCryptoPay', description: 'OCP desc' }, + { + id: 'PayToAddress', + label: 'Pay to {{blockchain}} address', + description: 'On {{blockchain}}', + blockchain: 'Ethereum', + }, + ], + paymentIdentifier: 'lnurl1test', + isLoadingPaymentIdentifier: false, + paymentStatus: 'Pending', + isLoadingMetaMask: false, + metaMaskInfo: undefined, + metaMaskError: undefined, + isMetaMaskPaying: false, + isMerchantMode: false, + showAssets: true, + showMap: false, + paymentHasQuote: (request: unknown) => + Boolean(request && typeof request === 'object' && 'quote' in (request as object)), + setSessionApiUrl, + setPaymentIdentifier: jest.fn(), + fetchPayRequest: jest.fn().mockResolvedValue(undefined), + fetchPaymentIdentifier: jest.fn().mockResolvedValue(undefined), + payWithMetaMask: jest.fn(), + ...overrides, + }; +} + +function mockWallets(overrides: Record = {}) { + mockUsePaymentLinkWallets.mockReturnValue({ + recommendedWallets: [], + otherWallets: [], + semiCompatibleWallets: [], + getDeeplinkByWalletId: jest.fn().mockResolvedValue('sample://resolved'), + isLoading: false, + error: undefined, + ...overrides, + }); +} + +function renderAt(path = '/') { + return render( + + + , + ); +} + +describe('PaymentLinkScreen coverage gaps', () => { + const mockWindowOpen = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockDevice.isMobile = false; + setMatchMedia(false); + mockUseApiCall.mockReset(); + mockDecodeUri.mockReturnValue(undefined); + mockToBlockchain.mockImplementation((id: string) => (id === '1' ? 'Ethereum' : undefined)); + window.open = mockWindowOpen; + Element.prototype.scrollIntoView = jest.fn(); + }); + + it('syncs session URL when standard query param is missing and refetches pay request', async () => { + const paymentLinkApiUrl = { current: 'https://api.example.com/v1/paymentLink/payment' }; + const setSessionApiUrl = jest.fn((url?: string) => { + paymentLinkApiUrl.current = url ?? ''; + }); + const fetchPayRequest = jest.fn().mockResolvedValue(undefined); + const setPaymentIdentifier = jest.fn(); + const callbackUrl = { current: 'https://old-callback' as string | undefined }; + + mockUsePaymentLinkContext.mockReturnValue( + baseContext({ + paymentLinkApiUrl, + setSessionApiUrl, + fetchPayRequest, + setPaymentIdentifier, + callbackUrl, + }), + ); + mockWallets(); + renderAt(); + + await waitFor(() => { + expect(setSessionApiUrl).toHaveBeenCalled(); + expect(fetchPayRequest).toHaveBeenCalled(); + }); + const url = setSessionApiUrl.mock.calls[0][0] as string; + expect(url).toContain('standard=OpenCryptoPay'); + expect(setPaymentIdentifier).toHaveBeenCalledWith(undefined); + expect(callbackUrl.current).toBeUndefined(); + }); + + it('fetches payment identifier when standard already matches URL', async () => { + const fetchPaymentIdentifier = jest.fn().mockResolvedValue(undefined); + mockUsePaymentLinkContext.mockReturnValue(baseContext({ fetchPaymentIdentifier })); + mockWallets(); + renderAt(); + + await waitFor(() => { + expect(fetchPaymentIdentifier).toHaveBeenCalled(); + }); + const [req, blockchain, asset] = fetchPaymentIdentifier.mock.calls[0]; + expect(req.id).toBe('pay-1'); + // OpenCryptoPay has no blockchain → undefined asset path + expect(blockchain).toBeUndefined(); + expect(asset).toBeUndefined(); + }); + + it('switches to PayToAddress, sets asset, shows EVM amount/address/blockchain rows', async () => { + mockDecodeUri.mockReturnValue({ + amount: '1000000000000000000', + address: '0xabc', + chainId: '1', + }); + + const paymentLinkApiUrl = { + current: 'https://api.example.com/v1/paymentLink/payment?standard=OpenCryptoPay', + }; + const setSessionApiUrl = jest.fn((url?: string) => { + paymentLinkApiUrl.current = url ?? ''; + }); + const fetchPayRequest = jest.fn().mockImplementation(async (url: string) => { + paymentLinkApiUrl.current = url; + }); + const fetchPaymentIdentifier = jest.fn().mockResolvedValue(undefined); + + mockUsePaymentLinkContext.mockReturnValue( + baseContext({ + paymentLinkApiUrl, + setSessionApiUrl, + fetchPayRequest, + fetchPaymentIdentifier, + paymentIdentifier: 'ethereum:0xabc@1?value=1000000000000000000', + payRequest: buildPayRequest({ + standard: 'OpenCryptoPay', + possibleStandards: ['OpenCryptoPay', 'PayToAddress'], + }), + }), + ); + mockWallets(); + renderAt(); + + // Select PayToAddress from dropdown (labelFunc with blockchain param) + await waitFor(() => expect(screen.getByTestId('dropdown-paymentStandard')).toBeInTheDocument()); + fireEvent.click(screen.getByTestId('select-paymentStandard-Pay to Ethereum address')); + + await waitFor(() => { + expect(setSessionApiUrl).toHaveBeenCalled(); + }); + + // After standard change URL sync; re-render still has PayToAddress selected in form state. + // Asset dropdown appears once blockchain standard selected and assetsList present. + await waitFor(() => { + expect(screen.getByTestId('dropdown-asset')).toBeInTheDocument(); + }); + + // EVM rows — need paymentIdentifier + PayToAddress selected + assetObject + await waitFor(() => { + expect(screen.getByTestId('row-Amount')).toHaveTextContent('units(1000000000000000000)'); + }); + expect(screen.getByTestId('row-Address')).toHaveTextContent('0xabc'); + expect(screen.getByTestId('row-Blockchain')).toHaveTextContent('Ethereum'); + expect(screen.getByTestId('row-Asset')).toHaveTextContent('ETH'); + + // Copy handlers on EVM amount, address (L450) and blockchain (L460) rows + const copies = screen.getAllByTestId('copy-btn'); + expect(copies.length).toBeGreaterThanOrEqual(3); + copy.mockClear(); + for (const btn of copies) { + fireEvent.click(btn); + } + expect(copy).toHaveBeenCalledWith('1000000000000000000'); + expect(copy).toHaveBeenCalledWith('0xabc'); + expect(copy).toHaveBeenCalledWith('Ethereum'); + }); + + it('toggles asset contract view and opens explorer', async () => { + mockDecodeUri.mockReturnValue({ + amount: '1', + address: '0xabc', + chainId: '1', + }); + + const paymentLinkApiUrl = { + current: 'https://api.example.com/v1/paymentLink/payment?standard=PayToAddress', + }; + mockUsePaymentLinkContext.mockReturnValue( + baseContext({ + paymentLinkApiUrl, + paymentIdentifier: 'ethereum:0xabc@1?value=1', + payRequest: buildPayRequest({ + standard: 'PayToAddress', + possibleStandards: ['PayToAddress'], + }), + paymentStandards: [ + { + id: 'PayToAddress', + label: 'Pay to {{blockchain}} address', + description: 'On {{blockchain}}', + blockchain: 'Ethereum', + }, + ], + setSessionApiUrl: jest.fn((url?: string) => { + paymentLinkApiUrl.current = url ?? ''; + }), + }), + ); + mockWallets(); + renderAt(); + + await waitFor(() => expect(screen.getByTestId('row-Asset')).toBeInTheDocument()); + + // Toggle contract via INFO_OUTLINE + fireEvent.click(screen.getByTestId('icon-info-outline')); + await waitFor(() => { + expect(screen.getByText('0xeth')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('icon-copy')); + expect(copy).toHaveBeenCalledWith('0xeth'); + + fireEvent.click(screen.getByTestId('icon-open')); + expect(mockWindowOpen).toHaveBeenCalledWith('https://etherscan.io/token/0xeth', '_blank'); + + // Toggle back + fireEvent.click(screen.getByTestId('icon-info')); + await waitFor(() => { + expect(screen.getByTestId('row-Asset')).toHaveTextContent('ETH'); + }); + }); + + it('opens recipient website with https prefix when scheme missing', async () => { + mockUsePaymentLinkContext.mockReturnValue(baseContext()); + mockWallets(); + renderAt(); + + fireEvent.click(screen.getByTestId('item-Recipient-Website')); + expect(mockWindowOpen).toHaveBeenCalledWith('https://merchant.example', '_blank'); + }); + + it('opens recipient website absolute URL as-is', async () => { + mockUsePaymentLinkContext.mockReturnValue( + baseContext({ + payRequest: buildPayRequest({ + recipient: { + name: 'M', + website: 'http://plain.example', + mail: 'not-dfx@example.com', + }, + }), + }), + ); + mockWallets(); + renderAt(); + + fireEvent.click(screen.getByTestId('item-Recipient-Website')); + expect(mockWindowOpen).toHaveBeenCalledWith('http://plain.example', '_blank'); + }); + + it('hides dfx.swiss recipient mail from expansion items', () => { + mockUsePaymentLinkContext.mockReturnValue( + baseContext({ + payRequest: buildPayRequest({ + recipient: { + name: 'M', + mail: 'hidden@dfx.swiss', + website: 'https://x.example', + }, + }), + }), + ); + mockWallets(); + renderAt(); + expect(screen.queryByTestId('item-Recipient-Email address')).not.toBeInTheDocument(); + }); + + it('loads wallet detail, shows Pay in app when hasActionDeepLink, opens deeplink and website, then back', async () => { + const getDeeplinkByWalletId = jest.fn().mockResolvedValue('sample://resolved'); + mockUsePaymentLinkContext.mockReturnValue(baseContext()); + mockWallets({ + recommendedWallets: [{ ...SAMPLE_WALLET, hasActionDeepLink: true }], + getDeeplinkByWalletId, + }); + renderAt('/?wallet-id=42'); + + await waitFor(() => { + expect(getDeeplinkByWalletId).toHaveBeenCalledWith(42); + }); + await waitFor(() => { + expect(screen.getByText('Pay in app')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Pay in app')); + expect(mockWindowOpen).toHaveBeenCalledWith('sample://resolved', '_blank'); + + fireEvent.click(screen.getByText('Open website')); + expect(mockWindowOpen).toHaveBeenCalledWith('https://wallet.example', '_blank'); + + // Back button clears wallet detail → grids return + const backBtn = screen.getByTestId('dfx-icon').closest('button'); + expect(backBtn).toBeInstanceOf(HTMLButtonElement); + fireEvent.click(backBtn as HTMLButtonElement); + await waitFor(() => { + expect(screen.queryByText('Pay in app')).not.toBeInTheDocument(); + expect(screen.getByText('SampleWallet')).toBeInTheDocument(); + }); + }); + + it('shows Open app and scan QR code again when wallet has no action deeplink', async () => { + mockUsePaymentLinkContext.mockReturnValue(baseContext()); + mockWallets({ + recommendedWallets: [{ ...SAMPLE_WALLET, hasActionDeepLink: false }], + getDeeplinkByWalletId: jest.fn().mockResolvedValue('sample://x'), + }); + renderAt('/?wallet-id=42'); + await waitFor(() => { + expect(screen.getByText('Open app and scan QR code again')).toBeInTheDocument(); + }); + }); + + it('hides open-app button while deeplink is loading', async () => { + let resolveDeeplink: (v: string) => void = () => undefined; + const pending = new Promise((r) => { + resolveDeeplink = r; + }); + mockUsePaymentLinkContext.mockReturnValue(baseContext()); + mockWallets({ + recommendedWallets: [SAMPLE_WALLET], + getDeeplinkByWalletId: jest.fn().mockReturnValue(pending), + }); + renderAt('/?wallet-id=42'); + + await waitFor(() => { + expect(screen.getAllByTestId('loading-spinner').length).toBeGreaterThan(0); + }); + expect(screen.queryByText('Open app and scan QR code again')).not.toBeInTheDocument(); + + await actResolve(resolveDeeplink, 'sample://done'); + await waitFor(() => { + expect(screen.getByText('Open app and scan QR code again')).toBeInTheDocument(); + }); + }); + + it('hides website button in public mode wallet detail', async () => { + mockUsePaymentLinkContext.mockReturnValue( + baseContext({ + payRequest: buildPayRequest({ mode: 'Public' }), + }), + ); + mockWallets({ + recommendedWallets: [SAMPLE_WALLET], + getDeeplinkByWalletId: jest.fn().mockResolvedValue('x://'), + }); + renderAt('/?wallet-id=42'); + await waitFor(() => { + expect(screen.getByText('Open app and scan QR code again')).toBeInTheDocument(); + }); + expect(screen.queryByText('Open website')).not.toBeInTheDocument(); + }); + + it('navigates to wallet detail when a wallet tile is clicked', () => { + mockUsePaymentLinkContext.mockReturnValue(baseContext()); + mockWallets({ recommendedWallets: [SAMPLE_WALLET] }); + renderAt(); + + fireEvent.click(screen.getByText('SampleWallet')); + expect(mockNavigate).toHaveBeenCalledWith({ pathname: '/pl', search: '?wallet-id=42' }); + }); + + it('renders transfer method amounts and filters unavailable methods', () => { + mockUsePaymentLinkContext.mockReturnValue( + baseContext({ + payRequest: buildPayRequest({ + transferAmounts: [ + { + method: 'Lightning', + available: true, + assets: [{ asset: 'BTC', amount: 1.5 }], + }, + { + method: 'Ethereum', + available: false, + assets: [{ asset: 'ETH', amount: 2 }], + }, + ], + }), + showAssets: true, + }), + ); + mockWallets(); + renderAt(); + + expect(screen.getByText('BTC')).toBeInTheDocument(); + expect(screen.getByText('1.5')).toBeInTheDocument(); + expect(screen.queryByText('ETH')).not.toBeInTheDocument(); + }); + + it('hides amounts in merchant mode transfer methods', () => { + mockUsePaymentLinkContext.mockReturnValue( + baseContext({ + isMerchantMode: true, + showAssets: true, + }), + ); + mockWallets(); + renderAt(); + expect(screen.getByText('BTC')).toBeInTheDocument(); + expect(screen.queryByText('0.001')).not.toBeInTheDocument(); + }); + + it('shows rate N/A when transfer amount is zero', async () => { + const paymentLinkApiUrl = { + current: 'https://api.example.com/v1/paymentLink/payment?standard=PayToAddress', + }; + mockUsePaymentLinkContext.mockReturnValue( + baseContext({ + paymentLinkApiUrl, + setSessionApiUrl: jest.fn((url?: string) => { + paymentLinkApiUrl.current = url ?? ''; + }), + payRequest: buildPayRequest({ + standard: 'PayToAddress', + transferAmounts: [ + { + method: 'Ethereum', + available: true, + assets: [{ asset: 'ETH', amount: 0 }], + }, + ], + }), + paymentStandards: [ + { + id: 'PayToAddress', + label: 'Pay to address', + description: 'on-chain', + blockchain: 'Ethereum', + }, + ], + timer: { minutes: 1, seconds: 0 }, + }), + ); + mockWallets(); + renderAt(); + await waitFor(() => { + expect(screen.getByTestId('info-text')).toHaveTextContent(/N\/A/); + }); + }); + + it('scrolls to map when showMap and payRequest are set', async () => { + jest.useFakeTimers(); + mockUsePaymentLinkContext.mockReturnValue(baseContext({ merchant: 'SPAR', showMap: true })); + mockWallets(); + renderAt(); + expect(screen.getByText('LOCATIONS')).toBeInTheDocument(); + jest.advanceTimersByTime(150); + expect(Element.prototype.scrollIntoView).toHaveBeenCalled(); + jest.useRealTimers(); + }); + + it('clears wallet data when payment expires', async () => { + mockUsePaymentLinkContext.mockReturnValue(baseContext({ paymentStatus: 'Expired' })); + mockWallets({ + recommendedWallets: [SAMPLE_WALLET], + getDeeplinkByWalletId: jest.fn().mockResolvedValue('x'), + }); + renderAt('/?wallet-id=42'); + // Expired is not in PENDING filter for wallet grids in OCP section either — + // payment status Expired means the PENDING||PUBLIC block may hide OCP wallets. + // The effect still runs setWalletData(undefined). + await waitFor(() => { + expect(screen.queryByText('Pay in app')).not.toBeInTheDocument(); + }); + }); + + it('opens Learn more about OpenCryptoPay', () => { + mockUsePaymentLinkContext.mockReturnValue(baseContext()); + mockWallets(); + renderAt(); + fireEvent.click(screen.getByText('Learn more about OpenCryptoPay')); + expect(mockWindowOpen).toHaveBeenCalledWith('https://opencryptopay.io', '_blank'); + }); + + it('shows loading spinner when payRequest is missing', () => { + mockUsePaymentLinkContext.mockReturnValue(baseContext({ payRequest: undefined })); + mockWallets(); + renderAt(); + expect(screen.getByTestId('loading-spinner')).toBeInTheDocument(); + }); + + it('shows loading spinner while MetaMask info loads', () => { + mockUsePaymentLinkContext.mockReturnValue(baseContext({ isLoadingMetaMask: true })); + mockWallets(); + renderAt(); + expect(screen.getByTestId('loading-spinner')).toBeInTheDocument(); + }); + + it('copies external-id callback from expansion item', () => { + mockUsePaymentLinkContext.mockReturnValue(baseContext()); + mockWallets(); + renderAt(); + fireEvent.click(screen.getByTestId('item-External ID-Callback')); + expect(copy).toHaveBeenCalledWith('https://callback.example'); + }); + + it('creates public payment with amount via form submit', async () => { + mockUseApiCall.mockResolvedValue({}); + const terminal = { + id: 'pub-1', + externalId: 'ext-pub', + tag: 't', + displayName: 'Public Shop', + standard: 'OpenCryptoPay', + possibleStandards: ['OpenCryptoPay'], + displayQr: false, + mode: 'Public', + route: '1', + currency: 'CHF', + recipient: { name: 'Public Shop' }, + transferAmounts: [], + }; + mockUsePaymentLinkContext.mockReturnValue( + baseContext({ + payRequest: terminal, + paymentStatus: 'NoPayment', + paymentHasQuote: () => false, + }), + ); + mockWallets(); + renderAt(); + + fireEvent.change(screen.getByTestId('input-amount'), { target: { value: '15' } }); + fireEvent.click(screen.getByText('Activate')); + await waitFor(() => { + expect(mockUseApiCall).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + data: expect.objectContaining({ amount: 15 }), + }), + ); + }); + }); + + it('surfaces Unknown error when public activate fails without message', async () => { + mockUseApiCall.mockRejectedValue({}); + const terminal = { + id: 'pub-1', + externalId: 'ext-pub', + tag: 't', + displayName: 'Public Shop', + standard: 'OpenCryptoPay', + possibleStandards: ['OpenCryptoPay'], + displayQr: false, + mode: 'Public', + route: '1', + currency: 'CHF', + recipient: { name: 'Public Shop' }, + transferAmounts: [], + }; + mockUsePaymentLinkContext.mockReturnValue( + baseContext({ + payRequest: terminal, + paymentStatus: 'NoPayment', + paymentHasQuote: () => false, + }), + ); + mockWallets(); + renderAt(); + fireEvent.click(screen.getByText('Activate')); + await waitFor(() => { + expect(screen.getByTestId('error-hint')).toHaveTextContent('Unknown error'); + }); + }); + + it('shows NoPayment spinner placeholder path without public mode', () => { + mockUsePaymentLinkContext.mockReturnValue( + baseContext({ + payRequest: { + id: 't1', + externalId: 'e', + tag: 't', + displayName: 'D', + standard: 'OpenCryptoPay', + possibleStandards: ['OpenCryptoPay'], + displayQr: false, + mode: 'Multiple', + route: '1', + currency: 'CHF', + recipient: { name: 'D' }, + transferAmounts: [], + }, + paymentStatus: 'NoPayment', + paymentHasQuote: () => false, + isLoadingPaymentIdentifier: true, + }), + ); + mockWallets(); + renderAt(); + // Cashier copy without quote + expect( + screen.getByText('Tell the cashier that you want to pay with crypto to start the payment.'), + ).toBeInTheDocument(); + }); + + it('falls back to merchant name when displayName is missing', () => { + mockUsePaymentLinkContext.mockReturnValue( + baseContext({ + merchant: 'Fallback Merchant', + payRequest: buildPayRequest({ displayName: undefined }), + }), + ); + mockWallets(); + renderAt(); + expect(screen.getByText('Fallback Merchant')).toBeInTheDocument(); + }); + + it('skips the identifier spinner when status is neither pending nor no-payment', () => { + mockUsePaymentLinkContext.mockReturnValue( + baseContext({ + payRequest: buildPayRequest({ quote: undefined, mode: 'Multiple' }), + paymentHasQuote: () => false, + paymentStatus: 'Completed', + }), + ); + mockWallets(); + renderAt(); + expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument(); + }); + + it('shows payment methods in merchant mode when transferAmounts is absent', () => { + const terminal = buildPayRequest(); + delete (terminal as { transferAmounts?: unknown }).transferAmounts; + delete (terminal as { quote?: unknown }).quote; + mockUsePaymentLinkContext.mockReturnValue( + baseContext({ + payRequest: terminal, + paymentHasQuote: () => false, + isMerchantMode: true, + paymentStatus: 'NoPayment', + }), + ); + mockWallets(); + renderAt(); + expect(screen.getByTestId('expandable-Payment Methods')).toBeInTheDocument(); + }); + + it('treats a quote timer with only seconds as still running', async () => { + mockUsePaymentLinkContext.mockReturnValue( + baseContext({ + timer: { minutes: 0, seconds: 12 }, + paymentStandards: [ + { + id: 'PayToAddress', + label: 'Pay to {{blockchain}} address', + description: 'On {{blockchain}}', + blockchain: 'Ethereum', + }, + ], + payRequest: buildPayRequest({ standard: 'PayToAddress', possibleStandards: ['PayToAddress'] }), + }), + ); + mockWallets(); + renderAt(); + await waitFor(() => { + expect(screen.getByText(/is fixed for 0m 12s/)).toBeInTheDocument(); + }); + }); + + it('edits a public quoted payment and surfaces Unknown error when delete has no message', async () => { + mockUseApiCall.mockRejectedValue({}); + mockUsePaymentLinkContext.mockReturnValue( + baseContext({ + payRequest: buildPayRequest({ mode: 'Public' }), + paymentStatus: 'Pending', + }), + ); + mockWallets(); + renderAt(); + fireEvent.click(screen.getByText('Edit')); + await waitFor(() => { + expect(screen.getByTestId('error-hint')).toHaveTextContent('Unknown error'); + }); + }); + + it('lists a transfer asset that has no amount', () => { + mockUsePaymentLinkContext.mockReturnValue( + baseContext({ + payRequest: buildPayRequest({ + transferAmounts: [ + { + method: 'Lightning', + minFee: 0, + available: true, + assets: [{ asset: 'BTC' }], + }, + ], + }), + }), + ); + mockWallets(); + renderAt(); + expect(screen.getByText('BTC')).toBeInTheDocument(); + }); + + it('hides store badges when the wallet has no store URLs', async () => { + mockUsePaymentLinkContext.mockReturnValue(baseContext()); + mockWallets({ + recommendedWallets: [{ ...SAMPLE_WALLET, playStoreUrl: undefined, appStoreUrl: undefined }], + getDeeplinkByWalletId: jest.fn().mockResolvedValue('sample://x'), + }); + renderAt('/?wallet-id=42'); + await waitFor(() => { + expect(screen.getByText('Open website')).toBeInTheDocument(); + }); + expect(screen.queryByText(/Play Store|App Store/i)).not.toBeInTheDocument(); + }); +}); + +async function actResolve(resolve: (v: string) => void, value: string) { + const { act } = await import('@testing-library/react'); + await act(async () => { + resolve(value); + }); +} diff --git a/src/__tests__/payment-link.screen.test.tsx b/src/__tests__/payment-link.screen.test.tsx new file mode 100644 index 000000000..b7a3c8aac --- /dev/null +++ b/src/__tests__/payment-link.screen.test.tsx @@ -0,0 +1,900 @@ +// Device-aware QR on the payment-link screen: displayQr forces the large QR; +// otherwise handheld (UA or coarse pointer) shows wallet copy, desktop shows QR. + +const mockDevice = { isMobile: false }; + +jest.mock('react-device-detect', () => ({ + get isMobile() { + return mockDevice.isMobile; + }, +})); + +const mockUseApiCall = jest.fn(); +jest.mock('@dfx.swiss/react', () => ({ + PaymentLinkMode: { SINGLE: 'Single', MULTIPLE: 'Multiple', PUBLIC: 'Public' }, + PaymentLinkPaymentStatus: { + PENDING: 'Pending', + COMPLETED: 'Completed', + CANCELLED: 'Cancelled', + EXPIRED: 'Expired', + }, + PaymentStandardType: { + OPEN_CRYPTO_PAY: 'OpenCryptoPay', + LIGHTNING_BOLT11: 'LightningBolt11', + PAY_TO_ADDRESS: 'PayToAddress', + }, + Utils: { formatAmount: (n: number) => String(n), createRules: () => ({}) }, + Validations: { Required: 'required' }, + useApi: () => ({ call: mockUseApiCall }), + useAssetContext: () => ({ assets: new Map() }), +})); + +jest.mock('@dfx.swiss/react-components', () => ({ + AlignContent: { RIGHT: 'right' }, + CopyButton: () => null, + DfxIcon: () => null, + Form: ({ + children, + onSubmit, + }: { + children: React.ReactNode; + onSubmit?: (e: React.FormEvent) => void; + }) => ( +
{ + e.preventDefault(); + onSubmit?.(e); + }} + > + {children} +
+ ), + IconColor: { GRAY: 'gray', BLUE: 'blue', DARK_GRAY: 'dark-gray' }, + IconSize: { SM: 'sm' }, + IconVariant: { BACK: 'back', COPY: 'copy', OPEN_IN_NEW: 'open', INFO: 'info', INFO_OUTLINE: 'info-outline' }, + SpinnerSize: { LG: 'lg', MD: 'md' }, + SpinnerVariant: { LIGHT_MODE: 'light' }, + StyledButton: ({ + label, + onClick, + type, + isLoading, + hidden, + }: { + label: string; + onClick?: () => void; + type?: string; + isLoading?: boolean; + hidden?: boolean; + }) => + hidden ? null : ( + + ), + StyledButtonColor: { + STURDY_WHITE: 'sturdy-white', + RED: 'red', + GREEN: 'green', + }, + StyledButtonSize: { DOUBLE: 'double' }, + StyledButtonWidth: { FULL: 'full' }, + StyledCollapsible: ({ children, titleContent }: { children: React.ReactNode; titleContent?: React.ReactNode }) => ( +
+ {titleContent} + {children} +
+ ), + StyledDataTable: ({ children }: { children: React.ReactNode }) =>
{children}
, + StyledDataTableExpandableRow: ({ + label, + children, + expansionContent, + }: { + label: string; + children?: React.ReactNode; + expansionContent?: React.ReactNode; + }) => ( +
+ {label} + {children} + {expansionContent} +
+ ), + StyledDataTableRow: ({ children }: { children: React.ReactNode }) =>
{children}
, + StyledDropdown: () => null, + StyledHorizontalStack: ({ children }: { children: React.ReactNode }) =>
{children}
, + StyledIconButton: () => null, + StyledInfoText: ({ children }: { children: React.ReactNode }) =>
{children}
, + StyledInfoTextSize: { XS: 'xs' }, + StyledInput: ({ name, label }: { name: string; label?: string }) => ( + + ), + StyledLink: () => null, + StyledLoadingSpinner: () =>
, + StyledVerticalStack: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +jest.mock('copy-to-clipboard', () => jest.fn()); +jest.mock('react-lazy-load-image-component', () => ({ + LazyLoadImage: () => null, +})); +jest.mock('react-lazy-load-image-component/src/effects/opacity.css', () => ({})); + +jest.mock('../components/error-hint', () => ({ + ErrorHint: ({ message }: { message: string }) =>
{message}
, +})); + +jest.mock('../components/payment/qr-code', () => ({ + QrBasic: () =>
QR
, +})); + +jest.mock('../components/pl/payment-status-tile', () => ({ + __esModule: true, + default: () => null, +})); + +jest.mock('../components/app-store-badge', () => ({ + AppStoreBadge: () => null, +})); + +jest.mock('../contexts/settings.context', () => ({ + useSettingsContext: () => ({ + translate: (_ns: string, key: string) => key, + translateError: (e: string) => e, + }), +})); + +jest.mock('../contexts/layout.context', () => ({ + useLayoutContext: () => ({ rootRef: { current: null } }), +})); + +jest.mock('../contexts/window.context', () => ({ + useWindowContext: () => ({ width: 1024 }), +})); + +jest.mock('../hooks/layout-config.hook', () => ({ + useLayoutOptions: () => undefined, +})); + +jest.mock('../hooks/navigation.hook', () => ({ + useNavigation: () => ({ navigate: jest.fn(), goBack: jest.fn() }), +})); + +jest.mock('../hooks/web3.hook', () => ({ + useWeb3: () => ({ toBlockchain: () => undefined }), +})); + +jest.mock('../util/open-crypto-pay', () => ({ + OpenCryptoPayUtils: { + getOcpUrlByUniqueId: (id: string) => `ocp://${id}`, + }, +})); + +jest.mock('../util/utils', () => ({ + blankedAddress: (v: string) => v, + formatAmountForDisplay: (n: number) => String(n), + formatLocationAddress: () => '', + formatUnits: (v: string) => v, +})); + +jest.mock('../util/evm', () => ({ + Evm: { decodeUri: () => undefined }, +})); + +jest.mock('../util/app-store-badges', () => ({ + BadgeType: { PLAY_STORE: 'play', APP_STORE: 'app' }, +})); + +jest.mock('../util/payment-link-wallet', () => ({ + Wallet: { + filterTransferInfoByWallet: (_wallet: unknown, transferInfoList: unknown[]) => transferInfoList, + qualifiesForPayment: () => true, + }, +})); + +const mockUsePaymentLinkContext = jest.fn(); +jest.mock('../contexts/payment-link.context', () => ({ + usePaymentLinkContext: () => mockUsePaymentLinkContext(), +})); + +const mockUsePaymentLinkWallets = jest.fn(); +jest.mock('../hooks/payment-link-wallets.hook', () => ({ + usePaymentLinkWallets: () => mockUsePaymentLinkWallets(), +})); + +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import PaymentLinkScreen from '../screens/payment-link.screen'; + +const SCAN_COPY = 'Scan the QR-Code with a compatible app to complete the payment.'; +const WALLET_COPY = 'Choose your wallet to open the payment.'; +const CASHIER_COPY = 'Tell the cashier that you want to pay with crypto to start the payment.'; +const OLD_CASHIER_COPY = + 'Tell the cashier that you want to pay with crypto and then scan the QR-Code with a compatible app to complete the payment.'; + +type MatchMediaSetup = boolean | 'undefined'; + +function setMatchMedia(coarse: MatchMediaSetup): void { + if (coarse === 'undefined') { + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: undefined, + }); + return; + } + + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: jest.fn().mockImplementation((query: string) => ({ + matches: query === '(pointer: coarse)' ? coarse : false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); +} + +function buildPayRequest(displayQr: boolean) { + return { + id: 'pay-1', + externalId: 'ext-1', + tag: 'tag', + displayName: 'Test Merchant', + standard: 'OpenCryptoPay', + possibleStandards: ['OpenCryptoPay'], + displayQr, + mode: 'Multiple', + route: 'route-handbook-1', + currency: 'CHF', + recipient: { name: 'Test Merchant' }, + transferAmounts: [{ method: 'Lightning', minFee: 0, assets: [{ asset: 'BTC', amount: 1 }] }], + requestedAmount: { asset: 'CHF', amount: 12.5 }, + quote: { id: 'q1', expiration: new Date(Date.now() + 60 * 60 * 1000), payment: 'p1' }, + }; +} + +function buildPayRequestNoQuote() { + const { quote: _quote, requestedAmount: _requestedAmount, ...terminal } = buildPayRequest(false); + return terminal; +} + +function mockWallets() { + mockUsePaymentLinkWallets.mockReturnValue({ + recommendedWallets: [], + otherWallets: [], + semiCompatibleWallets: [], + getDeeplinkByWalletId: jest.fn().mockResolvedValue(undefined), + isLoading: false, + error: undefined, + }); +} + +function mockContext(displayQr: boolean) { + mockUsePaymentLinkContext.mockReturnValue({ + error: undefined, + merchant: undefined, + payRequest: buildPayRequest(displayQr), + timer: { minutes: 5, seconds: 0 }, + paymentLinkApiUrl: { current: 'https://api.example.com/v1/paymentLink/payment?standard=OpenCryptoPay' }, + callbackUrl: { current: undefined }, + paymentStandards: [{ id: 'OpenCryptoPay', label: 'OpenCryptoPay', description: 'desc' }], + paymentIdentifier: 'lnurl1test', + isLoadingPaymentIdentifier: false, + paymentStatus: 'Pending', + isLoadingMetaMask: false, + metaMaskInfo: undefined, + metaMaskError: undefined, + isMetaMaskPaying: false, + isMerchantMode: false, + showAssets: false, + showMap: false, + paymentHasQuote: (request: unknown) => + Boolean(request && typeof request === 'object' && 'quote' in (request as object)), + setSessionApiUrl: jest.fn(), + setPaymentIdentifier: jest.fn(), + fetchPayRequest: jest.fn().mockResolvedValue(undefined), + fetchPaymentIdentifier: jest.fn().mockResolvedValue(undefined), + payWithMetaMask: jest.fn(), + }); + + mockWallets(); +} + +/** Non-OCP standard (PayToAddress) — form selects it from payRequest.standard. */ +function mockContextNonOcp(displayQr: boolean, overrides: Record = {}) { + mockUsePaymentLinkContext.mockReturnValue({ + error: undefined, + merchant: undefined, + payRequest: { + ...buildPayRequest(displayQr), + standard: 'PayToAddress', + possibleStandards: ['PayToAddress'], + }, + timer: { minutes: 5, seconds: 0 }, + paymentLinkApiUrl: { current: 'https://api.example.com/v1/paymentLink/payment?standard=PayToAddress' }, + callbackUrl: { current: undefined }, + paymentStandards: [{ id: 'PayToAddress', label: 'Pay to address', description: 'on-chain' }], + paymentIdentifier: 'lnurl1test', + isLoadingPaymentIdentifier: false, + paymentStatus: 'Pending', + isLoadingMetaMask: false, + metaMaskInfo: undefined, + metaMaskError: undefined, + isMetaMaskPaying: false, + isMerchantMode: false, + showAssets: false, + showMap: false, + paymentHasQuote: (request: unknown) => + Boolean(request && typeof request === 'object' && 'quote' in (request as object)), + setSessionApiUrl: jest.fn(), + setPaymentIdentifier: jest.fn(), + fetchPayRequest: jest.fn().mockResolvedValue(undefined), + fetchPaymentIdentifier: jest.fn().mockResolvedValue(undefined), + payWithMetaMask: jest.fn(), + ...overrides, + }); + + mockWallets(); +} + +function mockContextNoQuote() { + mockUsePaymentLinkContext.mockReturnValue({ + error: undefined, + merchant: undefined, + payRequest: buildPayRequestNoQuote(), + timer: { minutes: 0, seconds: 0 }, + paymentLinkApiUrl: { current: 'https://api.example.com/v1/paymentLink/payment?standard=OpenCryptoPay' }, + callbackUrl: { current: undefined }, + paymentStandards: [{ id: 'OpenCryptoPay', label: 'OpenCryptoPay', description: 'desc' }], + paymentIdentifier: undefined, + isLoadingPaymentIdentifier: false, + paymentStatus: 'NoPayment', + isLoadingMetaMask: false, + metaMaskInfo: undefined, + metaMaskError: undefined, + isMetaMaskPaying: false, + isMerchantMode: false, + showAssets: false, + showMap: false, + paymentHasQuote: () => false, + setSessionApiUrl: jest.fn(), + setPaymentIdentifier: jest.fn(), + fetchPayRequest: jest.fn().mockResolvedValue(undefined), + fetchPaymentIdentifier: jest.fn().mockResolvedValue(undefined), + payWithMetaMask: jest.fn(), + }); + + mockWallets(); +} + +function renderScreen() { + return render( + + + , + ); +} + +/** Large QR above the wallet list — not the one nested under the collapsible "QR Code" row. */ +function queryLargePaymentQr(): HTMLElement | undefined { + const collapsibleRow = screen.queryByTestId('expandable-QR Code'); + return screen + .queryAllByTestId('payment-qr') + .find((el) => !collapsibleRow || !collapsibleRow.contains(el)); +} + +describe('PaymentLinkScreen device-aware QR', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockDevice.isMobile = false; + setMatchMedia(false); + }); + + it('desktop + displayQr false → large QR and scan copy', () => { + mockDevice.isMobile = false; + setMatchMedia(false); + mockContext(false); + renderScreen(); + + expect(queryLargePaymentQr()).toBeTruthy(); + expect(screen.queryByTestId('expandable-QR Code')).not.toBeInTheDocument(); + expect(screen.getByText(SCAN_COPY)).toBeInTheDocument(); + expect(screen.queryByText(WALLET_COPY)).not.toBeInTheDocument(); + }); + + it('mobile + displayQr false → no large QR and wallet copy', () => { + mockDevice.isMobile = true; + setMatchMedia(true); + mockContext(false); + renderScreen(); + + expect(queryLargePaymentQr()).toBeUndefined(); + expect(screen.getByTestId('expandable-QR Code')).toBeInTheDocument(); + expect(screen.getByText(WALLET_COPY)).toBeInTheDocument(); + expect(screen.queryByText(SCAN_COPY)).not.toBeInTheDocument(); + }); + + it('mobile + displayQr true → large QR (merchant preference wins)', () => { + mockDevice.isMobile = true; + setMatchMedia(true); + mockContext(true); + renderScreen(); + + expect(queryLargePaymentQr()).toBeTruthy(); + expect(screen.queryByTestId('expandable-QR Code')).not.toBeInTheDocument(); + expect(screen.getByText(SCAN_COPY)).toBeInTheDocument(); + expect(screen.queryByText(WALLET_COPY)).not.toBeInTheDocument(); + }); + + it('desktop + displayQr true → large QR and scan copy', () => { + mockDevice.isMobile = false; + setMatchMedia(false); + mockContext(true); + renderScreen(); + + expect(queryLargePaymentQr()).toBeTruthy(); + expect(screen.queryByTestId('expandable-QR Code')).not.toBeInTheDocument(); + expect(screen.getByText(SCAN_COPY)).toBeInTheDocument(); + expect(screen.queryByText(WALLET_COPY)).not.toBeInTheDocument(); + }); + + // "Request Desktop Site": UA reports desktop, coarse pointer stays true → still handheld. + it('desktop UA + coarse pointer → no large QR and wallet copy (request desktop site)', () => { + mockDevice.isMobile = false; + setMatchMedia(true); + mockContext(false); + renderScreen(); + + expect(queryLargePaymentQr()).toBeUndefined(); + expect(screen.getByTestId('expandable-QR Code')).toBeInTheDocument(); + expect(screen.getByText(WALLET_COPY)).toBeInTheDocument(); + expect(screen.queryByText(SCAN_COPY)).not.toBeInTheDocument(); + }); + + // displayQr still forces large QR when the pointer reports handheld. + it('desktop UA + coarse pointer + displayQr true → large QR (force)', () => { + mockDevice.isMobile = false; + setMatchMedia(true); + mockContext(true); + renderScreen(); + + expect(queryLargePaymentQr()).toBeTruthy(); + expect(screen.queryByTestId('expandable-QR Code')).not.toBeInTheDocument(); + expect(screen.getByText(SCAN_COPY)).toBeInTheDocument(); + expect(screen.queryByText(WALLET_COPY)).not.toBeInTheDocument(); + }); + + // matchMedia missing — fall back to isMobile without throwing. + it('matchMedia undefined + isMobile true → falls back to isMobile (no throw)', () => { + mockDevice.isMobile = true; + setMatchMedia('undefined'); + mockContext(false); + expect(() => renderScreen()).not.toThrow(); + + expect(queryLargePaymentQr()).toBeUndefined(); + expect(screen.getByText(WALLET_COPY)).toBeInTheDocument(); + }); + + it('matchMedia undefined + isMobile false → falls back to isMobile (large QR)', () => { + mockDevice.isMobile = false; + setMatchMedia('undefined'); + mockContext(false); + expect(() => renderScreen()).not.toThrow(); + + expect(queryLargePaymentQr()).toBeTruthy(); + expect(screen.getByText(SCAN_COPY)).toBeInTheDocument(); + }); + + // No quote: cashier copy must not mention QR / scan. + it('no quote → cashier copy without scan/QR and no QrBasic', () => { + mockDevice.isMobile = false; + setMatchMedia(false); + mockContextNoQuote(); + renderScreen(); + + expect(screen.getByText(CASHIER_COPY)).toBeInTheDocument(); + expect(screen.queryByText(OLD_CASHIER_COPY)).not.toBeInTheDocument(); + expect(screen.queryAllByTestId('payment-qr')).toHaveLength(0); + + const body = document.body.textContent ?? ''; + expect(body.toLowerCase()).not.toMatch(/\bscan\b/); + expect(body.toLowerCase()).not.toMatch(/\bqr\b/); + }); + + // Non-OCP: large QR only lives in the OCP section — collapsible row must remain the fallback. + it('desktop + non-OCP standard + displayQr false → collapsible QR row, no large QR', async () => { + mockDevice.isMobile = false; + setMatchMedia(false); + mockContextNonOcp(false); + renderScreen(); + + await waitFor(() => { + expect(screen.getByTestId('expandable-QR Code')).toBeInTheDocument(); + }); + expect(queryLargePaymentQr()).toBeUndefined(); + expect(screen.queryByText(SCAN_COPY)).not.toBeInTheDocument(); + }); + + it('desktop + non-OCP standard + displayQr true → collapsible QR row, no large QR', async () => { + mockDevice.isMobile = false; + setMatchMedia(false); + mockContextNonOcp(true); + renderScreen(); + + await waitFor(() => { + expect(screen.getByTestId('expandable-QR Code')).toBeInTheDocument(); + }); + expect(queryLargePaymentQr()).toBeUndefined(); + expect(screen.queryByText(SCAN_COPY)).not.toBeInTheDocument(); + }); + + it('handheld + non-OCP standard → collapsible QR row, no large QR', async () => { + mockDevice.isMobile = true; + setMatchMedia(true); + mockContextNonOcp(false); + renderScreen(); + + await waitFor(() => { + expect(screen.getByTestId('expandable-QR Code')).toBeInTheDocument(); + }); + expect(queryLargePaymentQr()).toBeUndefined(); + expect(screen.queryByText(SCAN_COPY)).not.toBeInTheDocument(); + }); + + it('desktop + non-OCP payment + form standard unset → collapsible QR row, no large QR', () => { + mockDevice.isMobile = false; + setMatchMedia(false); + mockContextNonOcp(false, { paymentStandards: undefined }); + renderScreen(); + + expect(queryLargePaymentQr()).toBeUndefined(); + expect(screen.getByTestId('expandable-QR Code')).toBeInTheDocument(); + expect(screen.queryByText(SCAN_COPY)).not.toBeInTheDocument(); + }); +}); + +const SAMPLE_WALLET = { + id: 42, + name: 'SampleWallet', + iconUrl: 'https://example.test/w.png', + recommended: true, + active: true, + supportedMethods: ['Lightning'], + websiteUrl: 'https://example.test', + deepLink: 'sample://pay', + playStoreUrl: 'https://play.example', + appStoreUrl: 'https://app.example', + hasActionDeepLink: false, +}; + +function baseContext(overrides: Record = {}) { + const paymentLinkApiUrl = { current: 'https://api.example.com/v1/paymentLink/payment?standard=OpenCryptoPay' }; + const callbackUrl = { current: undefined as string | undefined }; + // Mutate the ref so the standard-sync effect does not re-fire forever. + const setSessionApiUrl = jest.fn((url?: string) => { + paymentLinkApiUrl.current = url ?? ''; + }); + + return { + error: undefined, + merchant: undefined, + payRequest: buildPayRequest(false), + timer: { minutes: 5, seconds: 0 }, + paymentLinkApiUrl, + callbackUrl, + paymentStandards: [{ id: 'OpenCryptoPay', label: 'OpenCryptoPay', description: 'desc' }], + paymentIdentifier: 'lnurl1test', + isLoadingPaymentIdentifier: false, + paymentStatus: 'Pending', + isLoadingMetaMask: false, + metaMaskInfo: undefined, + metaMaskError: undefined, + isMetaMaskPaying: false, + isMerchantMode: false, + showAssets: false, + showMap: false, + paymentHasQuote: (request: unknown) => + Boolean(request && typeof request === 'object' && 'quote' in (request as object)), + setSessionApiUrl, + setPaymentIdentifier: jest.fn(), + fetchPayRequest: jest.fn().mockResolvedValue(undefined), + fetchPaymentIdentifier: jest.fn().mockResolvedValue(undefined), + payWithMetaMask: jest.fn(), + ...overrides, + }; +} + +function renderAt(path = '/') { + return render( + + + , + ); +} + +describe('PaymentLinkScreen branches beyond device QR', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockDevice.isMobile = false; + setMatchMedia(false); + mockUseApiCall.mockReset(); + }); + + it('shows loading spinner while wallets load', () => { + mockUsePaymentLinkContext.mockReturnValue(baseContext()); + mockUsePaymentLinkWallets.mockReturnValue({ + recommendedWallets: [], + otherWallets: [], + semiCompatibleWallets: [], + getDeeplinkByWalletId: jest.fn(), + isLoading: true, + error: undefined, + }); + renderAt(); + expect(screen.getByTestId('loading-spinner')).toBeInTheDocument(); + }); + + it('shows context error message', () => { + mockUsePaymentLinkContext.mockReturnValue(baseContext({ error: 'boom-error' })); + mockWallets(); + renderAt(); + expect(screen.getByText('boom-error')).toBeInTheDocument(); + }); + + it('shows wallets error over payment error', () => { + mockUsePaymentLinkContext.mockReturnValue(baseContext({ error: 'payment-err' })); + mockUsePaymentLinkWallets.mockReturnValue({ + recommendedWallets: [], + otherWallets: [], + semiCompatibleWallets: [], + getDeeplinkByWalletId: jest.fn(), + isLoading: false, + error: 'wallets-err', + }); + renderAt(); + expect(screen.getByText('wallets-err')).toBeInTheDocument(); + expect(screen.queryByText('payment-err')).not.toBeInTheDocument(); + }); + + it('renders wallet grids when recommended/other/semi wallets present', () => { + mockUsePaymentLinkContext.mockReturnValue(baseContext()); + mockUsePaymentLinkWallets.mockReturnValue({ + recommendedWallets: [SAMPLE_WALLET], + otherWallets: [{ ...SAMPLE_WALLET, id: 43, name: 'OtherW', recommended: false }], + semiCompatibleWallets: [{ ...SAMPLE_WALLET, id: 44, name: 'SemiW', recommended: false, semiCompatible: true }], + getDeeplinkByWalletId: jest.fn().mockResolvedValue('sample://'), + isLoading: false, + error: undefined, + }); + renderAt(); + // WalletGrid headers are uppercased in DividerWithHeader. + expect(screen.getByText('RECOMMENDED APPS')).toBeInTheDocument(); + expect(screen.getByText('COMPATIBLE APPS')).toBeInTheDocument(); + expect(screen.getByText('SEMI COMPATIBLE APPS')).toBeInTheDocument(); + expect(screen.getByText('SampleWallet')).toBeInTheDocument(); + expect(screen.getByText('OtherW')).toBeInTheDocument(); + expect(screen.getByText('SemiW')).toBeInTheDocument(); + }); + + it('loads wallet detail when wallet-id is in the URL', async () => { + const getDeeplinkByWalletId = jest.fn().mockResolvedValue('sample://deeplink'); + mockUsePaymentLinkContext.mockReturnValue(baseContext()); + mockUsePaymentLinkWallets.mockReturnValue({ + recommendedWallets: [SAMPLE_WALLET], + otherWallets: [], + semiCompatibleWallets: [], + getDeeplinkByWalletId, + isLoading: false, + error: undefined, + }); + renderAt('/?wallet-id=42'); + await waitFor(() => { + expect(getDeeplinkByWalletId).toHaveBeenCalledWith(42); + }); + await waitFor(() => { + expect(screen.getByText('Open website')).toBeInTheDocument(); + }); + }); + + it('shows MetaMask error copy', () => { + mockUsePaymentLinkContext.mockReturnValue( + baseContext({ metaMaskError: 'Please install MetaMask or connect a wallet' }), + ); + mockWallets(); + renderAt(); + expect(screen.getByText('Please install MetaMask or connect a wallet')).toBeInTheDocument(); + }); + + it('shows MetaMask pay button and invokes payWithMetaMask', () => { + const payWithMetaMask = jest.fn(); + mockUsePaymentLinkContext.mockReturnValue( + baseContext({ + metaMaskInfo: { + accountAddress: '0xabc', + transferAsset: { name: 'ETH', blockchain: 'Ethereum' }, + transferAmount: 0.1, + minFee: 0, + }, + payWithMetaMask, + }), + ); + mockWallets(); + renderAt(); + expect(screen.getByText(/Complete this payment using/)).toBeInTheDocument(); + fireEvent.click(screen.getByText('Pay')); + expect(payWithMetaMask).toHaveBeenCalled(); + }); + + it('public mode without quote shows create-payment form', () => { + const terminal = { + id: 'pub-1', + externalId: 'ext-pub', + tag: 't', + displayName: 'Public Shop', + standard: 'OpenCryptoPay', + possibleStandards: ['OpenCryptoPay'], + displayQr: false, + mode: 'Public', + route: '1', + currency: 'CHF', + recipient: { name: 'Public Shop' }, + transferAmounts: [], + }; + mockUsePaymentLinkContext.mockReturnValue( + baseContext({ + payRequest: terminal, + paymentStatus: 'NoPayment', + paymentHasQuote: () => false, + }), + ); + mockWallets(); + renderAt(); + expect(screen.getByText('Insert the amount to active the payment.')).toBeInTheDocument(); + expect(screen.getByText('Activate')).toBeInTheDocument(); + }); + + it('public mode with quote shows edit-payment control', () => { + const req = { + ...buildPayRequest(false), + mode: 'Public', + externalId: 'ext-pub', + }; + mockUsePaymentLinkContext.mockReturnValue( + baseContext({ + payRequest: req, + paymentStatus: 'Pending', + }), + ); + mockWallets(); + renderAt(); + expect(screen.getByText('Edit')).toBeInTheDocument(); + }); + + it('merchant SPAR shows locations map iframe', () => { + mockUsePaymentLinkContext.mockReturnValue(baseContext({ merchant: 'SPAR' })); + mockWallets(); + renderAt(); + expect(screen.getByText('LOCATIONS')).toBeInTheDocument(); + const mapFrame = document.querySelector('iframe'); + expect(mapFrame).toBeTruthy(); + expect(mapFrame?.getAttribute('src')).toMatch(/google\.com\/maps/); + }); + + it('clears wallet data when payment is cancelled', () => { + mockUsePaymentLinkContext.mockReturnValue( + baseContext({ + paymentStatus: 'Cancelled', + }), + ); + mockUsePaymentLinkWallets.mockReturnValue({ + recommendedWallets: [SAMPLE_WALLET], + otherWallets: [], + semiCompatibleWallets: [], + getDeeplinkByWalletId: jest.fn().mockResolvedValue('x'), + isLoading: false, + error: undefined, + }); + renderAt(); + // Cancelled status still renders shell; wallet grids depend on PENDING filter + expect(screen.queryByText('SampleWallet')).not.toBeInTheDocument(); + }); + + it('shows rate info when blockchain standard and timer are set', async () => { + const paymentLinkApiUrl = { + current: 'https://api.example.com/v1/paymentLink/payment?standard=PayToAddress', + }; + mockUsePaymentLinkContext.mockReturnValue( + baseContext({ + paymentLinkApiUrl, + setSessionApiUrl: jest.fn((url?: string) => { + paymentLinkApiUrl.current = url ?? ''; + }), + payRequest: { + ...buildPayRequest(false), + standard: 'PayToAddress', + possibleStandards: ['PayToAddress'], + transferAmounts: [ + { + method: 'Ethereum', + minFee: 0, + assets: [{ asset: 'ETH', amount: 0.5 }], + available: true, + }, + ], + requestedAmount: { asset: 'CHF', amount: 100 }, + }, + paymentStandards: [ + { + id: 'PayToAddress', + label: 'Pay to address', + description: 'on-chain', + blockchain: 'Ethereum', + }, + ], + timer: { minutes: 4, seconds: 30 }, + }), + ); + mockWallets(); + renderAt(); + await waitFor(() => { + expect(screen.getByText(/The exchange rate of .* is fixed for/)).toBeInTheDocument(); + }); + }); + + it('create public payment surfaces API error', async () => { + mockUseApiCall.mockRejectedValue({ message: 'activate-failed' }); + const terminal = { + id: 'pub-1', + externalId: 'ext-pub', + tag: 't', + displayName: 'Public Shop', + standard: 'OpenCryptoPay', + possibleStandards: ['OpenCryptoPay'], + displayQr: false, + mode: 'Public', + route: '1', + currency: 'CHF', + recipient: { name: 'Public Shop' }, + transferAmounts: [], + }; + mockUsePaymentLinkContext.mockReturnValue( + baseContext({ + payRequest: terminal, + paymentStatus: 'NoPayment', + paymentHasQuote: () => false, + }), + ); + mockWallets(); + renderAt(); + fireEvent.click(screen.getByText('Activate')); + await waitFor(() => { + expect(screen.getByTestId('error-hint')).toHaveTextContent('activate-failed'); + }); + }); + + it('edit public payment surfaces API error', async () => { + mockUseApiCall.mockRejectedValue({ message: 'edit-failed' }); + const req = { + ...buildPayRequest(false), + mode: 'Public', + externalId: 'ext-pub', + }; + mockUsePaymentLinkContext.mockReturnValue(baseContext({ payRequest: req })); + mockWallets(); + renderAt(); + fireEvent.click(screen.getByText('Edit')); + await waitFor(() => { + expect(screen.getByTestId('error-hint')).toHaveTextContent('edit-failed'); + }); + }); +}); diff --git a/src/__tests__/payment-routes.actions.test.tsx b/src/__tests__/payment-routes.actions.test.tsx new file mode 100644 index 000000000..a81b49ad9 --- /dev/null +++ b/src/__tests__/payment-routes.actions.test.tsx @@ -0,0 +1,1189 @@ +// Covers payment-routes action paths: route lists (buy/sell/swap), toggle status/mode, +// cancel payment, delete confirmation, QR/sticker download, POS fetch, label rename, +// create invoice / create payment link entry points, and back-navigation titles. + +const mockNavigate = jest.fn(); +const mockGoBack = jest.fn(); +const mockUpdatePaymentLink = jest.fn().mockResolvedValue(undefined); +const mockUpdateUserPaymentLinksConfig = jest.fn().mockResolvedValue(undefined); +const mockCancelPaymentLinkPayment = jest.fn().mockResolvedValue(undefined); +const mockDeletePaymentRoute = jest.fn().mockResolvedValue(undefined); +const mockCreatePosLink = jest.fn().mockResolvedValue({ url: 'https://pos.example/pl-1' }); +const mockCreatePaymentLink = jest.fn().mockResolvedValue({ id: 'pl-new' }); +const mockCreatePaymentLinkPayment = jest.fn().mockResolvedValue(undefined); +const mockWindowOpen = jest.fn(); +const mockCopy = jest.fn(); + +const mockRoutesState: { + overrides: Record; + userOverrides: Record; +} = { overrides: {}, userOverrides: {} }; + +// Stable fixtures (mock-prefix for jest hoist). PaymentLinkForm's useMemo/useEffect +// re-run on reference change and infinite-loop if context returns new objects each render. +const mockStablePaymentRoutes = { + buy: [ + { + id: 1, + asset: { name: 'BTC', blockchain: 'Bitcoin' }, + bankUsage: 'DFX BUY 1', + volume: 10, + annualVolume: 100, + }, + ], + sell: [ + { + id: 2, + currency: { name: 'CHF' }, + iban: 'CH9300762011623852957', + deposit: { address: 'bc1qsell', blockchains: ['Bitcoin', 'Lightning'] }, + volume: 20, + annualVolume: 200, + }, + ], + swap: [ + { + id: 3, + asset: { name: 'ETH', blockchain: 'Ethereum' }, + deposit: { address: '0xswap', blockchains: ['Ethereum'] }, + volume: 5, + annualVolume: 50, + }, + ], +}; + +// Single-link fixtures only: PosLinkButton's useEffect depends on onMount (= fetchPosUrl), +// which is recreated every render. With 2+ links, isLoadingPos can only hold one id, so the +// buttons thrash setIsLoadingPos forever ("Maximum update depth exceeded"). Never mount more +// than one payment link at a time in this file. +const mockLinkActive = { + id: 'pl-active', + routeId: 2, + status: 'Active', + mode: 'Multiple', + label: 'Active Link', + externalId: 'ext-active', + url: 'https://pay.example/pl-active', + lnurl: 'lnurl1active', + config: { displayQr: true, fee: 0.1, paymentTimeout: 90, cancellable: true, standards: ['OpenCryptoPay'] }, + recipient: { + name: 'Shop AG', + address: { street: 'Main', houseNumber: '1', zip: '8000', city: 'Zürich', country: 'CH' }, + phone: '+411234', + mail: 'shop@example.com', + website: 'shop.example.com', + }, + payment: undefined as undefined, +}; + +const mockLinkPending = { + id: 'pl-pending', + routeId: 2, + status: 'Active', + mode: 'Public', + label: undefined as undefined, + externalId: 'ext-pending', + url: 'https://pay.example/pl-pending', + lnurl: 'lnurl1pending', + config: null as null, + recipient: undefined as undefined, + payment: { + id: 99, + externalId: 'pay-ext', + mode: 'Single', + amount: 12.5, + currency: 'CHF', + status: 'Pending', + expiryDate: '2030-01-01T12:00:00.000Z', + }, +}; + +const mockLinkInactive = { + id: 'pl-inactive', + routeId: 2, + status: 'Inactive', + mode: 'Multiple', + label: 'Inactive Link', + externalId: undefined as undefined, + url: 'https://pay.example/pl-inactive', + lnurl: 'lnurl1inactive', + config: { displayQr: false }, + recipient: { + name: 'Other', + website: 'https://absolute.example', + }, + payment: undefined as undefined, +}; + +const mockStablePaymentLinks = [mockLinkActive]; + +const mockStableUserConfig = { + standards: ['OpenCryptoPay'], + minCompletionStatus: 'TxReceived', + displayQr: false, + fee: 0, + paymentTimeout: 60, + cancellable: true, +}; + +const mockStableUser = { + id: 1, + accountId: 'acc 42', + paymentLink: { active: true }, + activeAddress: { blockchains: ['Lightning'] }, +}; + +const mockPaymentRoutesApi = { createPosLink: mockCreatePosLink }; +const mockPaymentRoutesContextBase = { + paymentRoutes: mockStablePaymentRoutes, + paymentLinks: mockStablePaymentLinks, + paymentRoutesLoading: false, + paymentLinksLoading: false, + userPaymentLinksConfig: mockStableUserConfig, + userPaymentLinksConfigLoading: false, + updatePaymentLink: mockUpdatePaymentLink, + updateUserPaymentLinksConfig: mockUpdateUserPaymentLinksConfig, + cancelPaymentLinkPayment: mockCancelPaymentLinkPayment, + deletePaymentRoute: mockDeletePaymentRoute, + createPaymentLink: mockCreatePaymentLink, + createPaymentLinkPayment: mockCreatePaymentLinkPayment, + error: undefined as string | undefined, +}; +const mockUserContextBase = { + user: mockStableUser, + isUserLoading: false, +}; + +jest.mock('@dfx.swiss/react', () => ({ + Blockchain: { ETHEREUM: 'Ethereum', BITCOIN: 'Bitcoin', LIGHTNING: 'Lightning' }, + MinCompletionStatus: { + TX_RECEIVED: 'TxReceived', + TX_MEMPOOL: 'TxMempool', + TX_BLOCKCHAIN: 'TxBlockchain', + TX_COMPLETED: 'TxCompleted', + }, + PaymentLinkMode: { SINGLE: 'Single', MULTIPLE: 'Multiple', PUBLIC: 'Public' }, + PaymentLinkPaymentMode: { SINGLE: 'Single', MULTIPLE: 'Multiple' }, + PaymentLinkPaymentStatus: { PENDING: 'Pending', COMPLETED: 'Completed', CANCELLED: 'Cancelled', EXPIRED: 'Expired' }, + PaymentLinkStatus: { ACTIVE: 'Active', INACTIVE: 'Inactive' }, + PaymentStandardType: { + OPEN_CRYPTO_PAY: 'OpenCryptoPay', + LIGHTNING_BOLT11: 'LightningBolt11', + PAY_TO_ADDRESS: 'PayToAddress', + }, + Utils: { createRules: () => ({}) }, + Validations: { + Required: { required: true }, + Custom: (fn: (v: unknown) => unknown) => ({ validate: fn }), + }, + // Stable method bag — fixtures (paymentLinks, userPaymentLinksConfig, routes) stay referentially stable. + usePaymentRoutes: () => mockPaymentRoutesApi, + usePaymentRoutesContext: () => ({ + ...mockPaymentRoutesContextBase, + ...mockRoutesState.overrides, + }), + useUserContext: () => ({ + ...mockUserContextBase, + ...mockRoutesState.userOverrides, + }), +})); + +jest.mock('@dfx.swiss/react-components', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const React = require('react'); + const { Children, cloneElement, isValidElement } = React; + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { Controller } = require('react-hook-form'); + + function enrichChildren(children: unknown, control: unknown, rules: unknown, errors: unknown): unknown { + return Children.map(children as React.ReactNode, (child: React.ReactNode) => { + if (!isValidElement(child)) return child; + const childProps = child.props as Record; + const nextChildren = enrichChildren(childProps.children, control, rules, errors); + if (childProps.name) { + return cloneElement(child as React.ReactElement, { + control, + rules: (rules as Record)?.[childProps.name as string], + error: (errors as Record)?.[childProps.name as string], + children: nextChildren, + }); + } + return cloneElement(child as React.ReactElement, { children: nextChildren }); + }); + } + + return { + AlignContent: { RIGHT: 'right' }, + CopyButton: ({ onCopy }: { onCopy?: () => void }) => ( + + ), + DfxIcon: () => , + Form: ({ + children, + control, + rules, + errors, + onSubmit, + }: { + children: React.ReactNode; + control?: unknown; + rules?: unknown; + errors?: unknown; + onSubmit?: (e: React.FormEvent) => void; + }) => ( +
{ + e.preventDefault(); + onSubmit?.(e); + }} + > + {enrichChildren(children, control, rules, errors)} +
+ ), + IconSize: { SM: 'sm' }, + IconVariant: { + EXPAND_MORE: 'more', + COPY: 'copy', + OPEN_IN_NEW: 'open', + EDIT: 'edit', + SWAP: 'swap', + }, + SpinnerSize: { LG: 'lg' }, + // Ignore disabled so wizard Next/Save can be exercised without RHF isValid gating + // (isValid stays false until a validation cycle; disabled buttons swallow clicks in React 18). + StyledButton: ({ + label, + onClick, + type, + hidden, + isLoading, + }: { + label: string; + onClick?: () => void; + type?: string; + hidden?: boolean; + disabled?: boolean; + isLoading?: boolean; + }) => + hidden ? null : ( + + ), + StyledButtonColor: { STURDY_WHITE: 'sturdy-white', RED: 'red' }, + StyledButtonWidth: { FULL: 'full' }, + StyledCollapsible: ({ + children, + titleContent, + isExpanded, + }: { + children: React.ReactNode; + titleContent?: React.ReactNode; + isExpanded?: boolean; + }) => ( +
+ {titleContent} + {children} +
+ ), + StyledDataTable: ({ children }: { children: React.ReactNode }) =>
{children}
, + StyledDataTableExpandableRow: ({ + label, + expansionItems, + expansionContent, + children, + }: { + label: string; + expansionItems?: { label: string; text?: string; onClick?: () => void }[]; + expansionContent?: React.ReactNode; + children?: React.ReactNode; + }) => ( +
+ {label} + {children} + {expansionItems?.map((item) => ( + + ))} + {expansionContent} +
+ ), + StyledDataTableRow: ({ children, label }: { children?: React.ReactNode; label?: string }) => ( +
+ {label} + {children} +
+ ), + StyledDateAndTimePicker: ({ name, control, label }: { name: string; control?: unknown; label?: string }) => ( + void } }) => ( + + )} + /> + ), + StyledDropdown: ({ + name, + control, + label, + items, + labelFunc, + }: { + name: string; + control?: unknown; + label?: string; + items?: unknown[]; + labelFunc?: (item: unknown) => string; + }) => ( + void } }) => ( +
+ {label} + {(items ?? []).map((item, i) => ( + + ))} + {field.value != null && labelFunc ? labelFunc(field.value) : ''} +
+ )} + /> + ), + StyledDropdownMultiChoice: ({ + name, + control, + label, + items, + labelFunc, + }: { + name: string; + control?: unknown; + label?: string; + items?: unknown[]; + labelFunc?: (item: unknown) => string; + }) => ( + void } }) => ( +
+ {label} + {(items ?? []).map((item, i) => ( + + ))} +
+ )} + /> + ), + StyledHorizontalStack: ({ children }: { children: React.ReactNode }) =>
{children}
, + StyledIconButton: ({ + onClick, + icon, + isLoading, + }: { + onClick?: () => void; + icon?: string; + isLoading?: boolean; + }) => ( + + ), + StyledInput: ({ + name, + control, + label, + }: { + name: string; + control?: unknown; + label?: string; + }) => ( + void } }) => ( + + )} + /> + ), + StyledLoadingSpinner: () =>
, + StyledSearchDropdown: ({ + name, + control, + label, + items, + labelFunc, + }: { + name: string; + control?: unknown; + label?: string; + items?: { name: string; symbol: string }[]; + labelFunc?: (item: { name: string }) => string; + }) => ( + void } }) => ( +
+ {label} + {(items ?? []).map((item) => ( + + ))} +
+ )} + /> + ), + StyledVerticalStack: ({ children }: { children: React.ReactNode }) =>
{children}
, + }; +}); + +jest.mock('copy-to-clipboard', () => (...args: unknown[]) => mockCopy(...args)); +jest.mock('react-i18next', () => ({ + Trans: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +jest.mock('../components/overlay/confirmation-overlay', () => ({ + ConfirmationOverlay: ({ + messageContent, + cancelLabel, + confirmLabel, + onCancel, + onConfirm, + }: { + messageContent?: React.ReactNode; + cancelLabel: string; + confirmLabel: string; + onCancel: () => void; + onConfirm: () => Promise; + }) => ( +
+ {messageContent} + + +
+ ), +})); + +jest.mock('../components/overlay/edit-overlay', () => ({ + EditOverlay: ({ + label, + prefill, + onCancel, + onEdit, + }: { + label?: string; + prefill?: string; + onCancel: () => void; + onEdit: (v: string) => Promise; + }) => ( +
+ {label} + {prefill} + + +
+ ), +})); + +jest.mock('../components/payment/qr-code', () => ({ + QrBasic: () => ( + + + + ), +})); + +jest.mock('../components/error-hint', () => ({ + ErrorHint: ({ message }: { message: string }) =>
{message}
, +})); + +jest.mock('../components/styled-link-button', () => ({ + StyledLinkButton: ({ label, href, isLoading }: { label: string; href?: string; isLoading?: boolean }) => ( + + {label} + + ), +})); + +jest.mock('../config/labels', () => ({ + PaymentQuoteStatusLabels: { + TxReceived: 'Tx received', + TxMempool: 'Tx mempool', + TxBlockchain: 'Tx blockchain', + TxCompleted: 'Tx completed', + Pending: 'Pending', + }, +})); + +const mockLayoutOptions = jest.fn(); +const mockAllowedCountries = [{ name: 'Switzerland', symbol: 'CH' }]; +const mockRootRef = { current: null }; +const mockTranslate = (_ns: string, key: string) => key; +const mockTranslateError = (e: string) => e; + +jest.mock('../contexts/layout.context', () => ({ + useLayoutContext: () => ({ rootRef: mockRootRef }), +})); +jest.mock('../contexts/settings.context', () => ({ + useSettingsContext: () => ({ + translate: mockTranslate, + translateError: mockTranslateError, + allowedCountries: mockAllowedCountries, + }), +})); +jest.mock('../contexts/wallet.context', () => ({ + useWalletContext: () => ({ isInitialized: true }), +})); +jest.mock('../contexts/window.context', () => ({ + useWindowContext: () => ({ width: 1024 }), +})); +jest.mock('../hooks/blockchain.hook', () => ({ + useBlockchain: () => ({ toString: (b: string) => b }), +})); +jest.mock('../hooks/guard.hook', () => ({ + useAddressGuard: () => undefined, +})); +jest.mock('../hooks/layout-config.hook', () => ({ + useLayoutOptions: (opts: unknown) => mockLayoutOptions(opts), +})); +jest.mock('../hooks/navigation.hook', () => ({ + useNavigation: () => ({ navigate: mockNavigate, goBack: mockGoBack }), +})); +jest.mock('../util/lnurl', () => ({ + Lnurl: { + encode: (u: string) => `lnurl-${u}`, + decode: (u: string) => u, + prependLnurl: (u: string) => `lightning:${u}`, + }, +})); +jest.mock('../util/utils', () => ({ + blankedAddress: (v: string) => v, + formatLocationAddress: (a: Record) => + [a.street, a.houseNumber, a.zip, a.city, a.country].filter(Boolean).join(', '), + isEmpty: (v: unknown) => v == null || v === '' || (Array.isArray(v) && v.length === 0), + removeNullFields: (o?: Record) => { + if (!o) return o; + return Object.fromEntries(Object.entries(o).filter(([, v]) => v != null)); + }, + url: ({ path, params }: { path: string; params?: URLSearchParams }) => + `https://example.test${path}?${params?.toString() ?? ''}`, +})); + +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import PaymentRoutesScreen from '../screens/payment-routes.screen'; + +function renderScreen() { + return render( + + + , + ); +} + +describe('PaymentRoutesScreen actions', () => { + let originalImage: typeof Image; + let originalCreateObjectURL: typeof URL.createObjectURL | undefined; + + beforeEach(() => { + jest.clearAllMocks(); + mockRoutesState.overrides = {}; + mockRoutesState.userOverrides = {}; + mockUpdatePaymentLink.mockResolvedValue(undefined); + mockUpdateUserPaymentLinksConfig.mockResolvedValue(undefined); + mockCancelPaymentLinkPayment.mockResolvedValue(undefined); + mockDeletePaymentRoute.mockResolvedValue(undefined); + mockCreatePosLink.mockResolvedValue({ url: 'https://pos.example/pl-1' }); + mockCreatePaymentLink.mockResolvedValue({ id: 'pl-new' }); + mockCreatePaymentLinkPayment.mockResolvedValue(undefined); + mockWindowOpen.mockReset(); + window.open = mockWindowOpen; + + // Canvas + Image for downloadQrCode + HTMLCanvasElement.prototype.getContext = jest.fn(() => ({ + fillStyle: '', + fillRect: jest.fn(), + drawImage: jest.fn(), + })) as unknown as typeof HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.toDataURL = jest.fn(() => 'data:image/png;base64,abc'); + + originalImage = window.Image; + class MockImage { + onload: ((this: MockImage, ev: Event) => unknown) | null = null; + set src(_v: string) { + queueMicrotask(() => this.onload?.call(this, new Event('load'))); + } + } + // @ts-expect-error test double + window.Image = MockImage; + + Element.prototype.scrollIntoView = jest.fn(); + }); + + afterEach(async () => { + window.Image = originalImage; + if (originalCreateObjectURL) URL.createObjectURL = originalCreateObjectURL; + // Flush PosLinkButton fetch + scrollIntoView(setTimeout 100) so Jest can exit cleanly + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 120)); + }); + }); + + it('renders buy, sell and swap route sections with copyable purpose of payment', () => { + renderScreen(); + + expect(screen.getByText('Buy')).toBeInTheDocument(); + expect(screen.getByText('Sell')).toBeInTheDocument(); + expect(screen.getByText('Swap')).toBeInTheDocument(); + // bankUsage appears both as collapsible adjacent text and as table cell + expect(screen.getAllByText('DFX BUY 1').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('CH9300762011623852957')).toBeInTheDocument(); + expect(screen.getByText('ETH')).toBeInTheDocument(); + + const copyButtons = screen.getAllByTestId('copy-btn'); + expect(copyButtons.length).toBeGreaterThan(0); + fireEvent.click(copyButtons[0]); + expect(mockCopy).toHaveBeenCalledWith('DFX BUY 1'); + }); + + it('opens delete confirmation for a buy route and confirms deletion', async () => { + renderScreen(); + + // First "Delete" is the buy route (order: buy, sell, swap). + const deleteButtons = screen.getAllByText('Delete'); + expect(deleteButtons.length).toBeGreaterThanOrEqual(3); + fireEvent.click(deleteButtons[0]); + + expect(screen.getByTestId('confirm-overlay')).toBeInTheDocument(); + // Title switches via useLayoutOptions + expect(mockLayoutOptions).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Delete payment route?' }), + ); + + fireEvent.click(screen.getByText('Delete')); + await waitFor(() => { + expect(mockDeletePaymentRoute).toHaveBeenCalledWith(1, 'buy'); + }); + }); + + it('cancels delete confirmation without calling deletePaymentRoute', async () => { + renderScreen(); + fireEvent.click(screen.getAllByText('Delete')[0]); + fireEvent.click(screen.getByText('Cancel')); + expect(mockDeletePaymentRoute).not.toHaveBeenCalled(); + await waitFor(() => { + expect(screen.queryByTestId('confirm-overlay')).not.toBeInTheDocument(); + }); + }); + + it('deletes sell and swap routes via confirmation', async () => { + renderScreen(); + const deleteButtons = screen.getAllByText('Delete'); + + fireEvent.click(deleteButtons[1]); + fireEvent.click(screen.getByTestId('confirm-overlay').querySelectorAll('button')[1]); + await waitFor(() => { + expect(mockDeletePaymentRoute).toHaveBeenCalledWith(2, 'sell'); + }); + + // Re-open for swap (confirm closes; re-render still has routes) + fireEvent.click(screen.getAllByText('Delete')[2]); + fireEvent.click(screen.getByTestId('confirm-overlay').querySelectorAll('button')[1]); + await waitFor(() => { + expect(mockDeletePaymentRoute).toHaveBeenCalledWith(3, 'swap'); + }); + }); + + it('deactivates an active payment link without pending payment', async () => { + renderScreen(); + fireEvent.click(screen.getByText('Deactivate')); + await waitFor(() => { + expect(mockUpdatePaymentLink).toHaveBeenCalledWith({ status: 'Inactive' }, 'pl-active'); + }); + }); + + it('activates an inactive payment link', async () => { + mockRoutesState.overrides = { paymentLinks: [mockLinkInactive] }; + renderScreen(); + fireEvent.click(screen.getByText('Activate')); + await waitFor(() => { + expect(mockUpdatePaymentLink).toHaveBeenCalledWith({ status: 'Active' }, 'pl-inactive'); + }); + }); + + it('toggles Multiple mode to Public', async () => { + renderScreen(); + fireEvent.click(screen.getByTestId('icon-btn-swap')); + await waitFor(() => { + expect(mockUpdatePaymentLink).toHaveBeenCalledWith({ mode: 'Public' }, 'pl-active'); + }); + }); + + it('toggles Public mode to Multiple', async () => { + mockRoutesState.overrides = { paymentLinks: [mockLinkPending] }; + renderScreen(); + fireEvent.click(screen.getByTestId('icon-btn-swap')); + await waitFor(() => { + expect(mockUpdatePaymentLink).toHaveBeenCalledWith({ mode: 'Multiple' }, 'pl-pending'); + }); + }); + + it('cancels a pending payment on a payment link', async () => { + mockRoutesState.overrides = { paymentLinks: [mockLinkPending] }; + renderScreen(); + fireEvent.click(screen.getByText('Cancel payment')); + await waitFor(() => { + expect(mockCancelPaymentLinkPayment).toHaveBeenCalledWith('pl-pending'); + }); + }); + + it('opens create-payment form for an active link without pending payment', () => { + renderScreen(); + fireEvent.click(screen.getByText('Create payment')); + // Form step PAYMENT shows Mode dropdown + expect(screen.getByTestId('dropdown-paymentMode')).toBeInTheDocument(); + expect(mockLayoutOptions).toHaveBeenCalledWith( + expect.objectContaining({ title: expect.stringContaining('Payment') }), + ); + }); + + it('opens relative website URL with https prefix from expansion item', () => { + renderScreen(); + // Relative website onClick from expansion items (Shop AG recipient) — list view only + fireEvent.click(screen.getByTestId('item-Recipient-Website')); + expect(mockWindowOpen).toHaveBeenCalledWith('https://shop.example.com', '_blank'); + }); + + it('opens edit-recipient form for an active link', () => { + renderScreen(); + fireEvent.click(screen.getByText('Edit recipient')); + expect(screen.getByTestId('input-recipientName')).toBeInTheDocument(); + expect(screen.getByTestId('input-recipientName')).toHaveValue('Shop AG'); + }); + + it('opens absolute website URL without prepending https', () => { + mockRoutesState.overrides = { paymentLinks: [mockLinkInactive] }; + renderScreen(); + fireEvent.click(screen.getByTestId('item-Recipient-Website')); + expect(mockWindowOpen).toHaveBeenCalledWith('https://absolute.example', '_blank'); + }); + + it('opens edit configuration for a payment link (Always show QR code label)', () => { + renderScreen(); + const editConfigButtons = screen.getAllByText('Edit configuration'); + // First is global default config, second is per-link (single active link) + fireEvent.click(editConfigButtons[1]); + expect(screen.getByText('Always show QR code')).toBeInTheDocument(); + expect(screen.queryByText('Display QR code')).not.toBeInTheDocument(); + }); + + it('opens global default configuration editor', () => { + renderScreen(); + fireEvent.click(screen.getAllByText('Edit configuration')[0]); + expect(screen.getByText('Always show QR code')).toBeInTheDocument(); + expect(mockLayoutOptions).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Default configuration' }), + ); + }); + + it('opens label rename overlay, cancels, then saves a new label', async () => { + renderScreen(); + // Label appears in collapsible title and in the editable row — click the row button + const labelButtons = screen.getAllByText('Active Link'); + fireEvent.click(labelButtons[labelButtons.length - 1]); + expect(screen.getByTestId('edit-overlay')).toBeInTheDocument(); + expect(screen.getByTestId('edit-prefill')).toHaveTextContent('Active Link'); + + fireEvent.click(screen.getByText('cancel-edit')); + await waitFor(() => { + expect(screen.queryByTestId('edit-overlay')).not.toBeInTheDocument(); + }); + + const labelButtonsAgain = screen.getAllByText('Active Link'); + fireEvent.click(labelButtonsAgain[labelButtonsAgain.length - 1]); + fireEvent.click(screen.getByText('save-edit')); + await waitFor(() => { + expect(mockUpdatePaymentLink).toHaveBeenCalledWith({ label: 'Renamed Link' }, 'pl-active'); + }); + }); + + it('downloads QR code PNG via canvas when SVG is present', async () => { + const clickSpy = jest.fn(); + const originalCreate = document.createElement.bind(document); + jest.spyOn(document, 'createElement').mockImplementation((tag: string) => { + const el = originalCreate(tag); + if (tag === 'a') { + Object.defineProperty(el, 'click', { value: clickSpy }); + } + return el; + }); + + renderScreen(); + fireEvent.click(screen.getAllByText('Download QR code')[0]); + + await waitFor(() => { + expect(clickSpy).toHaveBeenCalled(); + }); + expect(HTMLCanvasElement.prototype.toDataURL).toHaveBeenCalledWith('image/png'); + + (document.createElement as jest.Mock).mockRestore(); + }); + + it('no-ops download QR when SVG is missing (early return)', () => { + // QrBasic mock still renders SVG; temporarily remove SVG after render + const { container } = renderScreen(); + const qrHost = container.querySelector('[id^="qr-code-"]'); + qrHost?.querySelector('svg')?.remove(); + const toDataURL = HTMLCanvasElement.prototype.toDataURL as jest.Mock; + toDataURL.mockClear(); + fireEvent.click(screen.getByText('Download QR code')); + expect(toDataURL).not.toHaveBeenCalled(); + }); + + it('no-ops download QR when canvas context is unavailable', () => { + HTMLCanvasElement.prototype.getContext = jest.fn(() => null) as unknown as typeof HTMLCanvasElement.prototype.getContext; + const toDataURL = HTMLCanvasElement.prototype.toDataURL as jest.Mock; + toDataURL.mockClear(); + renderScreen(); + fireEvent.click(screen.getByText('Download QR code')); + expect(toDataURL).not.toHaveBeenCalled(); + }); + + it('scrollIntoView no-ops when payment links list is empty', async () => { + mockRoutesState.overrides = { paymentLinks: [] }; + renderScreen(); + // Create wizard still available without existing links; Cancel on DONE calls onClose() + // → scrollIntoView(undefined) → early return when paymentLinks is empty (L187). + fireEvent.click(screen.getByText('Create Payment Link')); + fireEvent.click(screen.getByText('Next')); + await waitFor(() => expect(screen.getByText('Skip')).toBeInTheDocument()); + fireEvent.click(screen.getByText('Skip')); + await waitFor(() => expect(screen.getByText('Skip')).toBeInTheDocument()); + fireEvent.click(screen.getByText('Skip')); + await waitFor(() => expect(screen.getByText('Next')).toBeInTheDocument()); + fireEvent.click(screen.getByText('Next')); + await waitFor(() => expect(screen.getByText('Cancel')).toBeInTheDocument()); + (Element.prototype.scrollIntoView as jest.Mock).mockClear(); + fireEvent.click(screen.getByText('Cancel')); + await waitFor(() => { + expect(screen.queryByTestId('dropdown-routeId')).not.toBeInTheDocument(); + }); + expect(Element.prototype.scrollIntoView).not.toHaveBeenCalled(); + }); + + it('downloads sticker with route and externalIds params', () => { + renderScreen(); + fireEvent.click(screen.getAllByText('Download sticker')[0]); + expect(mockWindowOpen).toHaveBeenCalledWith( + expect.stringContaining('/stickers?'), + '_blank', + ); + expect(mockWindowOpen.mock.calls[0][0]).toContain('route=2'); + expect(mockWindowOpen.mock.calls[0][0]).toContain('externalIds=ext-active'); + }); + + it('downloads sticker without externalIds when link has none', () => { + mockRoutesState.overrides = { paymentLinks: [mockLinkInactive] }; + renderScreen(); + fireEvent.click(screen.getByText('Download sticker')); + const opened = mockWindowOpen.mock.calls[0][0] as string; + expect(opened).toContain('route=2'); + expect(opened).not.toContain('externalIds'); + }); + + it('fetches POS URL on mount and renders Open POS link', async () => { + renderScreen(); + await waitFor(() => { + expect(mockCreatePosLink).toHaveBeenCalledWith('pl-active'); + }); + await waitFor(() => { + const posLinks = screen.getAllByTestId('pos-link'); + expect(posLinks.some((a) => a.getAttribute('href') === 'https://pos.example/pl-1')).toBe(true); + }); + }); + + it('logs POS fetch failure and keeps fallback href', async () => { + const errSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + mockCreatePosLink.mockRejectedValueOnce(new Error('pos-down')); + renderScreen(); + await waitFor(() => { + expect(errSpy).toHaveBeenCalledWith('Failed to fetch POS URL:', expect.any(Error)); + }); + const posLinks = screen.getAllByTestId('pos-link'); + expect(posLinks[0].getAttribute('href')).toMatch(/\/pos\/payment-link\//); + errSpy.mockRestore(); + }); + + it('navigates to invoice on Create Invoice', () => { + renderScreen(); + fireEvent.click(screen.getByText('Create Invoice')); + expect(mockNavigate).toHaveBeenCalledWith('/invoice'); + }); + + it('opens create payment link wizard when sell routes and lightning address exist', () => { + renderScreen(); + fireEvent.click(screen.getByText('Create Payment Link')); + expect(screen.getByTestId('dropdown-routeId')).toBeInTheDocument(); + expect(screen.getByTestId('input-externalId')).toBeInTheDocument(); + }); + + it('hides Create Payment Link when user has no lightning blockchain', () => { + mockRoutesState.userOverrides = { + user: { + id: 1, + accountId: 'acc', + paymentLink: { active: true }, + activeAddress: { blockchains: ['Bitcoin'] }, + }, + }; + renderScreen(); + expect(screen.queryByText('Create Payment Link')).not.toBeInTheDocument(); + }); + + it('shows loading spinner while payment routes load without in-flight updates', () => { + mockRoutesState.overrides = { paymentRoutesLoading: true, paymentLinksLoading: false }; + renderScreen(); + expect(screen.getByTestId('loading-spinner')).toBeInTheDocument(); + }); + + it('shows loading spinner while wallet is not initialized', () => { + jest.doMock('../contexts/wallet.context', () => ({ + useWalletContext: () => ({ isInitialized: false }), + })); + // Override via re-mock is hard mid-file; instead use user loading: + mockRoutesState.userOverrides = { isUserLoading: true }; + renderScreen(); + expect(screen.getByTestId('loading-spinner')).toBeInTheDocument(); + }); + + it('surfaces non-permission api errors and ignores permission denied alone', () => { + mockRoutesState.overrides = { error: 'permission denied' }; + const { unmount } = renderScreen(); + expect(screen.queryByTestId('error-hint')).not.toBeInTheDocument(); + unmount(); + + mockRoutesState.overrides = { error: 'server exploded' }; + renderScreen(); + expect(screen.getByTestId('error-hint')).toHaveTextContent('server exploded'); + }); + + it('shows the local error instead of permission denied when both are set', async () => { + mockRoutesState.overrides = { error: 'permission denied' }; + mockUpdateUserPaymentLinksConfig.mockRejectedValueOnce({ message: 'config-save-failed' }); + renderScreen(); + fireEvent.click(screen.getAllByText('Edit configuration')[0]); + fireEvent.click(screen.getByText('Save')); + await waitFor(() => { + expect(screen.getByTestId('error-hint')).toHaveTextContent('config-save-failed'); + }); + }); + + it('copies LNURL expansion items when clicked', () => { + renderScreen(); + fireEvent.click(screen.getAllByTestId('item-LNURL-Link')[0]); + expect(mockCopy).toHaveBeenCalledWith('lightning:lnurl1active'); + fireEvent.click(screen.getAllByTestId('item-LNURL-LNURL')[0]); + expect(mockCopy).toHaveBeenCalledWith('lnurl1active'); + fireEvent.click(screen.getAllByTestId('item-LNURL-LNURL decoded')[0]); + expect(mockCopy).toHaveBeenCalledWith('https://pay.example/pl-active'); + }); + + it('uses layout onBack to leave global config and form steps', async () => { + renderScreen(); + fireEvent.click(screen.getAllByText('Edit configuration')[0]); + + const lastOpts = mockLayoutOptions.mock.calls[mockLayoutOptions.mock.calls.length - 1][0] as { + onBack?: () => void; + }; + expect(lastOpts.onBack).toBeDefined(); + act(() => lastOpts.onBack?.()); + await waitFor(() => { + expect(screen.queryByTestId('dropdown-configDisplayQr')).not.toBeInTheDocument(); + }); + }); + + it('steps back in create-payment-link wizard via onBack', async () => { + renderScreen(); + fireEvent.click(screen.getByText('Create Payment Link')); + // Advance to recipient + fireEvent.click(screen.getByText('Next')); + await waitFor(() => { + expect(screen.getByTestId('input-recipientName')).toBeInTheDocument(); + }); + + const opts = mockLayoutOptions.mock.calls[mockLayoutOptions.mock.calls.length - 1][0] as { + onBack?: () => void; + }; + act(() => opts.onBack?.()); + await waitFor(() => { + expect(screen.getByTestId('dropdown-routeId')).toBeInTheDocument(); + }); + }); + + it('closes create wizard completely when onBack on first step', async () => { + renderScreen(); + fireEvent.click(screen.getByText('Create Payment Link')); + const opts = mockLayoutOptions.mock.calls[mockLayoutOptions.mock.calls.length - 1][0] as { + onBack?: () => void; + }; + act(() => opts.onBack?.()); + await waitFor(() => { + expect(screen.queryByTestId('dropdown-routeId')).not.toBeInTheDocument(); + expect(screen.getByText('Payment Links')).toBeInTheDocument(); + }); + }); + + it('uses deleteRoute onBack to clear confirmation', async () => { + renderScreen(); + fireEvent.click(screen.getAllByText('Delete')[0]); + const opts = mockLayoutOptions.mock.calls[mockLayoutOptions.mock.calls.length - 1][0] as { + onBack?: () => void; + }; + act(() => opts.onBack?.()); + await waitFor(() => { + expect(screen.queryByTestId('confirm-overlay')).not.toBeInTheDocument(); + }); + }); + + it('shows fallback title Payment Link when label and externalId are missing', () => { + mockRoutesState.overrides = { + paymentLinks: [ + { + id: 'pl-bare', + routeId: 2, + status: 'Active', + mode: 'Multiple', + label: undefined, + externalId: undefined, + url: 'https://pay.example/bare', + lnurl: 'lnurl1bare', + config: {}, + recipient: undefined, + payment: undefined, + }, + ], + }; + renderScreen(); + expect(screen.getByText(/Payment Link pl-bare/)).toBeInTheDocument(); + }); + + it('names the downloaded QR after the link id when externalId is missing', async () => { + const clickSpy = jest.fn(); + const originalCreate = document.createElement.bind(document); + let downloadName = ''; + jest.spyOn(document, 'createElement').mockImplementation((tag: string) => { + const el = originalCreate(tag); + if (tag === 'a') { + Object.defineProperty(el, 'click', { + value: () => { + downloadName = (el as HTMLAnchorElement).download; + clickSpy(); + }, + }); + } + return el; + }); + mockRoutesState.overrides = { + paymentLinks: [ + { + id: 'pl-no-ext', + routeId: 2, + status: 'Active', + mode: 'Multiple', + label: 'No Ext', + externalId: undefined, + url: 'https://pay.example/noext', + lnurl: 'lnurl1noext', + config: {}, + recipient: undefined, + payment: undefined, + }, + ], + }; + renderScreen(); + fireEvent.click(screen.getByText('Download QR code')); + await waitFor(() => { + expect(clickSpy).toHaveBeenCalled(); + }); + expect(downloadName).toContain('pl-no-ext'); + (document.createElement as jest.Mock).mockRestore(); + }); + + it('renders a dash when a pending payment has no external id', () => { + mockRoutesState.overrides = { + paymentLinks: [ + { + id: 'pl-pay', + routeId: 2, + status: 'Active', + mode: 'Multiple', + label: 'Pay Link', + externalId: 'ext-pay', + url: 'https://pay.example/pay', + lnurl: 'lnurl1pay', + config: {}, + recipient: undefined, + payment: { id: 9, status: 'Pending', amount: 3, mode: 'Single', externalId: undefined }, + }, + ], + }; + renderScreen(); + expect(screen.getByTestId('item-Payment-External ID')).toHaveTextContent('-'); + }); +}); diff --git a/src/__tests__/payment-routes.form.test.tsx b/src/__tests__/payment-routes.form.test.tsx new file mode 100644 index 000000000..52d6e153c --- /dev/null +++ b/src/__tests__/payment-routes.form.test.tsx @@ -0,0 +1,823 @@ +// PaymentLinkForm coverage: all wizard steps, renamed Always show QR code labels +// (config field + DONE summary), submit paths (create / update recipient / payment / +// config / global config), skip, validation-driven Next, and error surfaces. + +const mockUpdatePaymentLink = jest.fn().mockResolvedValue(undefined); +const mockUpdateUserPaymentLinksConfig = jest.fn().mockResolvedValue(undefined); +const mockCreatePaymentLink = jest.fn().mockResolvedValue({ id: 'pl-created' }); +const mockCreatePaymentLinkPayment = jest.fn().mockResolvedValue(undefined); +const mockCreatePosLink = jest.fn().mockResolvedValue({ url: 'https://pos.example/x' }); + +const mockRoutesState: { overrides: Record } = { overrides: {} }; + +const mockStablePaymentRoutes = { + buy: [] as unknown[], + sell: [ + { + id: 10, + currency: { name: 'EUR' }, + iban: 'DE89370400440532013000', + deposit: { address: 'bc1q', blockchains: ['Bitcoin'] }, + volume: 0, + annualVolume: 0, + }, + { + id: 20, + currency: { name: 'CHF' }, + iban: 'CH9300762011623852957', + deposit: { address: 'bc1q2', blockchains: ['Lightning'] }, + volume: 1, + annualVolume: 2, + }, + ], + swap: [] as unknown[], +}; + +const mockStablePaymentLinks = [ + { + id: 'pl-1', + routeId: 20, + status: 'Active', + mode: 'Multiple', + label: 'Shop', + externalId: 'ext-1', + url: 'https://pay.example/pl-1', + lnurl: 'lnurl1', + config: { + standards: ['OpenCryptoPay'], + minCompletionStatus: 'TxReceived', + displayQr: true, + paymentTimeout: 120, + cancellable: false, + fee: 1, + }, + recipient: { + name: 'Prefill Name', + address: { street: 'A', houseNumber: '2', zip: '8000', city: 'ZH', country: 'CH' }, + phone: '+410', + mail: 'a@b.c', + website: 'https://prefill.example', + }, + payment: undefined as undefined, + }, +]; + +const mockStableUserConfig = { + standards: ['OpenCryptoPay'], + minCompletionStatus: 'TxReceived', + displayQr: false, + fee: 0, + paymentTimeout: 60, + cancellable: true, +}; + +const mockStableUser = { + id: 1, + accountId: 'acc1', + paymentLink: { active: true }, + activeAddress: { blockchains: ['Lightning'] }, +}; + +const mockCancelPayment = jest.fn().mockResolvedValue(undefined); +const mockDeleteRoute = jest.fn().mockResolvedValue(undefined); + +jest.mock('@dfx.swiss/react', () => ({ + Blockchain: { ETHEREUM: 'Ethereum', BITCOIN: 'Bitcoin', LIGHTNING: 'Lightning' }, + MinCompletionStatus: { + TX_RECEIVED: 'TxReceived', + TX_MEMPOOL: 'TxMempool', + TX_BLOCKCHAIN: 'TxBlockchain', + TX_COMPLETED: 'TxCompleted', + }, + PaymentLinkMode: { SINGLE: 'Single', MULTIPLE: 'Multiple', PUBLIC: 'Public' }, + PaymentLinkPaymentMode: { SINGLE: 'Single', MULTIPLE: 'Multiple' }, + PaymentLinkPaymentStatus: { PENDING: 'Pending', COMPLETED: 'Completed' }, + PaymentLinkStatus: { ACTIVE: 'Active', INACTIVE: 'Inactive' }, + PaymentStandardType: { + OPEN_CRYPTO_PAY: 'OpenCryptoPay', + LIGHTNING_BOLT11: 'LightningBolt11', + PAY_TO_ADDRESS: 'PayToAddress', + }, + // Pass rules through so Custom validators at L1115–1116 are registered and executed + Utils: { + createRules: (rules: Record) => { + for (const property in rules) { + if (Array.isArray(rules[property])) { + rules[property] = (rules[property] as unknown[]).reduce( + (prev, curr) => ({ ...(prev as object), ...(curr as object) }), + {}, + ); + } + } + return rules; + }, + }, + Validations: { + Required: { required: true }, + Custom: (fn: (v: unknown) => unknown) => ({ validate: fn }), + }, + usePaymentRoutes: () => ({ createPosLink: mockCreatePosLink }), + usePaymentRoutesContext: () => ({ + paymentRoutes: mockStablePaymentRoutes, + paymentLinks: mockStablePaymentLinks, + paymentRoutesLoading: false, + paymentLinksLoading: false, + userPaymentLinksConfig: mockStableUserConfig, + userPaymentLinksConfigLoading: false, + updatePaymentLink: mockUpdatePaymentLink, + updateUserPaymentLinksConfig: mockUpdateUserPaymentLinksConfig, + cancelPaymentLinkPayment: mockCancelPayment, + deletePaymentRoute: mockDeleteRoute, + createPaymentLink: mockCreatePaymentLink, + createPaymentLinkPayment: mockCreatePaymentLinkPayment, + error: undefined, + ...mockRoutesState.overrides, + }), + useUserContext: () => ({ + user: mockStableUser, + isUserLoading: false, + }), +})); + +jest.mock('@dfx.swiss/react-components', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const React = require('react'); + const { Children, cloneElement, isValidElement } = React; + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { Controller } = require('react-hook-form'); + + function enrichChildren(children: unknown, control: unknown, rules: unknown, errors: unknown): unknown { + return Children.map(children as React.ReactNode, (child: React.ReactNode) => { + if (!isValidElement(child)) return child; + const childProps = child.props as Record; + const nextChildren = enrichChildren(childProps.children, control, rules, errors); + if (childProps.name) { + return cloneElement(child as React.ReactElement, { + control, + rules: (rules as Record)?.[childProps.name as string], + error: (errors as Record)?.[childProps.name as string], + children: nextChildren, + }); + } + return cloneElement(child as React.ReactElement, { children: nextChildren }); + }); + } + + return { + AlignContent: { RIGHT: 'right' }, + CopyButton: () => null, + DfxIcon: () => null, + Form: ({ + children, + control, + rules, + errors, + onSubmit, + }: { + children: React.ReactNode; + control?: unknown; + rules?: unknown; + errors?: unknown; + onSubmit?: (e: React.FormEvent) => void; + }) => ( +
{ + e.preventDefault(); + onSubmit?.(e); + }} + > + {enrichChildren(children, control, rules, errors)} +
+ ), + IconSize: { SM: 'sm' }, + IconVariant: { EDIT: 'edit', SWAP: 'swap', COPY: 'copy' }, + SpinnerSize: { LG: 'lg' }, + // Ignore disabled so wizard Next/Save work without RHF isValid gating + // (disabled buttons swallow click events under React 18). + StyledButton: ({ + label, + onClick, + type, + hidden, + isLoading, + }: { + label: string; + onClick?: () => void; + type?: string; + hidden?: boolean; + disabled?: boolean; + isLoading?: boolean; + }) => + hidden ? null : ( + + ), + StyledButtonColor: { STURDY_WHITE: 'sturdy-white', RED: 'red' }, + StyledButtonWidth: { FULL: 'full' }, + StyledCollapsible: ({ children, titleContent }: { children: React.ReactNode; titleContent?: React.ReactNode }) => ( +
+ {titleContent} + {children} +
+ ), + StyledDataTable: ({ children }: { children: React.ReactNode }) =>
{children}
, + StyledDataTableExpandableRow: ({ + label, + expansionItems, + expansionContent, + children, + }: { + label: string; + expansionItems?: { label: string; text?: string }[]; + expansionContent?: React.ReactNode; + children?: React.ReactNode; + }) => ( +
+ {label} + {children} + {expansionItems?.map((item) => ( +
+ {item.label}: {item.text} +
+ ))} + {expansionContent} +
+ ), + StyledDataTableRow: ({ children, label }: { children?: React.ReactNode; label?: string }) => ( +
+ {label} + {children} +
+ ), + StyledDateAndTimePicker: ({ + name, + control, + label, + rules, + }: { + name: string; + control?: unknown; + label?: string; + rules?: { validate?: (v: unknown) => unknown; required?: boolean }; + }) => ( + void } }) => ( + + )} + /> + ), + StyledDropdown: ({ + name, + control, + label, + items, + labelFunc, + descriptionFunc, + rules, + }: { + name: string; + control?: unknown; + label?: string; + items?: unknown[]; + labelFunc?: (item: unknown) => string; + descriptionFunc?: (item: unknown) => string; + rules?: { validate?: (v: unknown) => unknown; required?: boolean }; + }) => ( + void } }) => ( +
+ {label} + {(items ?? []).map((item, i) => { + // Exercise descriptionFunc (routeId L1162) and labelFunc for branch coverage + if (descriptionFunc) descriptionFunc(item); + return ( + + ); + })} + + {field.value != null && labelFunc ? labelFunc(field.value) : String(field.value ?? '')} + +
+ )} + /> + ), + StyledDropdownMultiChoice: ({ + name, + control, + label, + items, + labelFunc, + rules, + }: { + name: string; + control?: unknown; + label?: string; + items?: unknown[]; + labelFunc?: (item: unknown) => string; + rules?: { validate?: (v: unknown) => unknown; required?: boolean }; + }) => ( + void } }) => ( +
+ {label} + {(items ?? []).map((item, i) => ( + + ))} +
+ )} + /> + ), + StyledHorizontalStack: ({ children }: { children: React.ReactNode }) =>
{children}
, + StyledIconButton: ({ onClick, icon }: { onClick?: () => void; icon?: string }) => ( + + ), + StyledInput: ({ + name, + control, + label, + rules, + }: { + name: string; + control?: unknown; + label?: string; + rules?: { validate?: (v: unknown) => unknown; required?: boolean }; + }) => ( + void } }) => ( + + )} + /> + ), + StyledLoadingSpinner: () =>
, + StyledSearchDropdown: ({ + name, + control, + label, + items, + labelFunc, + filterFunc, + matchFunc, + }: { + name: string; + control?: unknown; + label?: string; + items?: { name: string; symbol: string }[]; + labelFunc?: (item: { name: string; symbol: string }) => string; + filterFunc?: (i: { name: string; symbol: string }, s: string) => boolean; + matchFunc?: (i: { name: string; symbol: string }, s?: string) => boolean; + }) => ( + void } }) => { + // Exercise filterFunc/matchFunc for branch coverage of lambdas + const sample = items?.[0]; + if (sample && filterFunc) filterFunc(sample, 'sw'); + if (sample && matchFunc) matchFunc(sample, sample.name); + return ( +
+ {label} + {(items ?? []).map((item) => ( + + ))} +
+ ); + }} + /> + ), + StyledVerticalStack: ({ children }: { children: React.ReactNode }) =>
{children}
, + }; +}); + +jest.mock('copy-to-clipboard', () => jest.fn()); +jest.mock('react-i18next', () => ({ + Trans: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +jest.mock('../components/overlay/confirmation-overlay', () => ({ + ConfirmationOverlay: () => null, +})); +jest.mock('../components/overlay/edit-overlay', () => ({ + EditOverlay: () => null, +})); +jest.mock('../components/payment/qr-code', () => ({ + QrBasic: () => , +})); +jest.mock('../components/error-hint', () => ({ + ErrorHint: ({ message }: { message: string }) =>
{message}
, +})); +jest.mock('../components/styled-link-button', () => ({ + StyledLinkButton: ({ label }: { label: string }) => {label}, +})); +jest.mock('../config/labels', () => ({ + PaymentQuoteStatusLabels: { + TxReceived: 'Tx received', + TxMempool: 'Tx mempool', + TxBlockchain: 'Tx blockchain', + TxCompleted: 'Tx completed', + }, +})); +const mockAllowedCountries = [ + { name: 'Switzerland', symbol: 'CH' }, + { name: 'Germany', symbol: 'DE' }, +]; +const mockRootRef = { current: null }; +const mockTranslate = (_ns: string, key: string) => key; +const mockTranslateError = (e: string) => e; + +jest.mock('../contexts/layout.context', () => ({ + useLayoutContext: () => ({ rootRef: mockRootRef }), +})); +jest.mock('../contexts/settings.context', () => ({ + useSettingsContext: () => ({ + translate: mockTranslate, + translateError: mockTranslateError, + allowedCountries: mockAllowedCountries, + }), +})); +jest.mock('../contexts/wallet.context', () => ({ + useWalletContext: () => ({ isInitialized: true }), +})); +jest.mock('../contexts/window.context', () => ({ + useWindowContext: () => ({ width: 800 }), +})); +jest.mock('../hooks/blockchain.hook', () => ({ + useBlockchain: () => ({ toString: (b: string) => b }), +})); +jest.mock('../hooks/guard.hook', () => ({ + useAddressGuard: () => undefined, +})); +jest.mock('../hooks/layout-config.hook', () => ({ + useLayoutOptions: () => undefined, +})); +jest.mock('../hooks/navigation.hook', () => ({ + useNavigation: () => ({ navigate: jest.fn(), goBack: jest.fn() }), +})); +jest.mock('../util/lnurl', () => ({ + Lnurl: { + encode: (u: string) => u, + decode: (u: string) => u, + prependLnurl: (u: string) => `lightning:${u}`, + }, +})); +jest.mock('../util/utils', () => ({ + blankedAddress: (v: string) => v, + formatLocationAddress: (a: Record) => + [a.street, a.houseNumber, a.zip, a.city, a.country].filter(Boolean).join(' '), + isEmpty: (v: unknown) => v == null || v === '' || (Array.isArray(v) && v.length === 0), + removeNullFields: (o?: Record) => { + if (!o) return o; + return Object.fromEntries(Object.entries(o).filter(([, v]) => v != null)); + }, + url: () => 'https://example.test/stickers', +})); + +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import PaymentRoutesScreen from '../screens/payment-routes.screen'; + +function renderScreen() { + return render( + + + , + ); +} + +describe('PaymentLinkForm labels and steps', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockRoutesState.overrides = {}; + mockUpdatePaymentLink.mockResolvedValue(undefined); + mockUpdateUserPaymentLinksConfig.mockResolvedValue(undefined); + mockCreatePaymentLink.mockResolvedValue({ id: 'pl-created' }); + mockCreatePaymentLinkPayment.mockResolvedValue(undefined); + mockCreatePosLink.mockResolvedValue({ url: 'https://pos.example/x' }); + Element.prototype.scrollIntoView = jest.fn(); + }); + + afterEach(async () => { + // Flush PosLinkButton fetch + scrollIntoView timeout so Jest exits cleanly + await act(async () => { + await Promise.resolve(); + await new Promise((r) => setTimeout(r, 120)); + }); + }); + + it('renders Always show QR code label on edit-link configuration step (not Display QR code)', () => { + renderScreen(); + fireEvent.click(screen.getAllByText('Edit configuration')[1]); + + // Form field label at payment-routes.screen.tsx:1346 + expect(screen.getByTestId('label-configDisplayQr')).toHaveTextContent('Always show QR code'); + expect(screen.queryByText('Display QR code')).not.toBeInTheDocument(); + // Yes/No options from labelFunc + expect(screen.getByTestId('select-configDisplayQr-Yes')).toBeInTheDocument(); + expect(screen.getByTestId('select-configDisplayQr-No')).toBeInTheDocument(); + }); + + it('shows Always show QR code in DONE summary after full create wizard', async () => { + renderScreen(); + fireEvent.click(screen.getByText('Create Payment Link')); + + // ROUTE → fill label/externalId, pick route (max id auto-selected via effect) + fireEvent.change(screen.getByTestId('input-externalId'), { target: { value: 'ext-new' } }); + fireEvent.change(screen.getByTestId('input-label'), { target: { value: 'New Link' } }); + fireEvent.click(screen.getByText('Next')); + + // RECIPIENT → fill name so not skip-only + await waitFor(() => expect(screen.getByTestId('input-recipientName')).toBeInTheDocument()); + fireEvent.change(screen.getByTestId('input-recipientName'), { target: { value: 'Alice' } }); + fireEvent.change(screen.getByTestId('input-recipientStreet'), { target: { value: 'Street' } }); + fireEvent.change(screen.getByTestId('input-recipientHouseNumber'), { target: { value: '1' } }); + fireEvent.change(screen.getByTestId('input-recipientZip'), { target: { value: '8000' } }); + fireEvent.change(screen.getByTestId('input-recipientCity'), { target: { value: 'ZH' } }); + fireEvent.click(screen.getByTestId('select-country-CH')); + fireEvent.change(screen.getByTestId('input-recipientPhone'), { target: { value: '+41' } }); + fireEvent.change(screen.getByTestId('input-recipientEmail'), { target: { value: 'a@b.c' } }); + fireEvent.change(screen.getByTestId('input-recipientWebsite'), { target: { value: 'https://a.example' } }); + fireEvent.click(screen.getByText('Next')); + + // PAYMENT + await waitFor(() => expect(screen.getByTestId('dropdown-paymentMode')).toBeInTheDocument()); + fireEvent.click(screen.getByTestId('select-paymentMode-Single')); + fireEvent.change(screen.getByTestId('input-paymentAmount'), { target: { value: '25' } }); + fireEvent.change(screen.getByTestId('input-paymentExternalId'), { target: { value: 'pay-1' } }); + fireEvent.click(screen.getByText('Next')); + + // CONFIG — set display QR true so summary shows Yes + await waitFor(() => expect(screen.getByTestId('dropdown-configDisplayQr')).toBeInTheDocument()); + fireEvent.click(screen.getByTestId('select-multi-configStandards-OpenCryptoPay')); + fireEvent.click(screen.getByTestId('select-configMinCompletionStatus-Tx received')); + fireEvent.change(screen.getByTestId('input-configPaymentTimeout'), { target: { value: '90' } }); + fireEvent.click(screen.getByTestId('select-configDisplayQr-Yes')); + fireEvent.click(screen.getByTestId('select-configCancellable-No')); + fireEvent.click(screen.getByText('Next')); + + // DONE summary — label at payment-routes.screen.tsx:1416 + await waitFor(() => { + expect(screen.getByTestId('summary-Configuration-Always show QR code')).toHaveTextContent( + 'Always show QR code: Yes', + ); + }); + expect(screen.queryByText(/Display QR code/)).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText('Create')); + await waitFor(() => { + expect(mockCreatePaymentLink).toHaveBeenCalled(); + }); + const payload = mockCreatePaymentLink.mock.calls[0][0]; + expect(payload.config.displayQr).toBe(true); + expect(payload.config.cancellable).toBe(false); + expect(payload.payment.amount).toBe(25); + expect(payload.config.recipient.name).toBe('Alice'); + }); + + it('skips empty recipient and payment steps in create wizard', async () => { + renderScreen(); + fireEvent.click(screen.getByText('Create Payment Link')); + fireEvent.click(screen.getByText('Next')); + + await waitFor(() => expect(screen.getByText('Skip')).toBeInTheDocument()); + fireEvent.click(screen.getByText('Skip')); + + await waitFor(() => expect(screen.getByTestId('dropdown-paymentMode')).toBeInTheDocument()); + fireEvent.click(screen.getByText('Skip')); + + await waitFor(() => expect(screen.getByTestId('dropdown-configDisplayQr')).toBeInTheDocument()); + fireEvent.click(screen.getByText('Next')); + + await waitFor(() => { + expect(screen.getByTestId('summary-Configuration-Always show QR code')).toBeInTheDocument(); + }); + // Summary shows No from default config displayQr:false + expect(screen.getByTestId('summary-Configuration-Always show QR code')).toHaveTextContent(/No/); + }); + + it('saves global default configuration via onSubmitForm path', async () => { + renderScreen(); + fireEvent.click(screen.getAllByText('Edit configuration')[0]); + await waitFor(() => expect(screen.getByTestId('dropdown-configDisplayQr')).toBeInTheDocument()); + + fireEvent.click(screen.getByTestId('select-configDisplayQr-Yes')); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(mockUpdateUserPaymentLinksConfig).toHaveBeenCalled(); + }); + const cfg = mockUpdateUserPaymentLinksConfig.mock.calls[0][0]; + // updatePaymentLinksConfig passes data.config + expect(cfg).toEqual( + expect.objectContaining({ + displayQr: true, + }), + ); + }); + + it('surfaces global config update errors', async () => { + mockUpdateUserPaymentLinksConfig.mockRejectedValueOnce({ message: 'config-fail' }); + renderScreen(); + fireEvent.click(screen.getAllByText('Edit configuration')[0]); + fireEvent.click(screen.getByText('Save')); + await waitFor(() => { + expect(screen.getByTestId('error-hint')).toHaveTextContent('config-fail'); + }); + }); + + it('updates payment link recipient on Save', async () => { + renderScreen(); + fireEvent.click(screen.getByText('Edit recipient')); + await waitFor(() => expect(screen.getByTestId('input-recipientName')).toBeInTheDocument()); + + fireEvent.change(screen.getByTestId('input-recipientName'), { target: { value: 'Bob' } }); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(mockUpdatePaymentLink).toHaveBeenCalled(); + }); + const [request, id] = mockUpdatePaymentLink.mock.calls[0]; + expect(id).toBe('pl-1'); + expect(request.config.recipient.name).toBe('Bob'); + }); + + it('creates a payment on an existing link', async () => { + renderScreen(); + fireEvent.click(screen.getByText('Create payment')); + await waitFor(() => expect(screen.getByTestId('dropdown-paymentMode')).toBeInTheDocument()); + + fireEvent.click(screen.getByTestId('select-paymentMode-Multiple')); + fireEvent.change(screen.getByTestId('input-paymentAmount'), { target: { value: '50' } }); + fireEvent.change(screen.getByTestId('input-paymentExternalId'), { target: { value: 'pid' } }); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(mockCreatePaymentLinkPayment).toHaveBeenCalled(); + }); + const [payment, linkId] = mockCreatePaymentLinkPayment.mock.calls[0]; + expect(linkId).toBe('pl-1'); + expect(payment.amount).toBe(50); + expect(payment.mode).toBe('Multiple'); + // routeId is prefilled by the form's sell-route effect, which keeps the lower id + // (id 10 / EUR) rather than the payment link's routeId 20. + expect(payment.currency).toBe('EUR'); + }); + + it('updates payment link configuration on Save', async () => { + renderScreen(); + fireEvent.click(screen.getAllByText('Edit configuration')[1]); + await waitFor(() => expect(screen.getByTestId('dropdown-configDisplayQr')).toBeInTheDocument()); + + fireEvent.click(screen.getByTestId('select-configDisplayQr-No')); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(mockUpdatePaymentLink).toHaveBeenCalled(); + }); + const [request, id] = mockUpdatePaymentLink.mock.calls[0]; + expect(id).toBe('pl-1'); + expect(request.config.displayQr).toBe(false); + }); + + it('surfaces createPaymentLink API errors on the form', async () => { + mockCreatePaymentLink.mockRejectedValueOnce({ message: 'create-failed' }); + renderScreen(); + fireEvent.click(screen.getByText('Create Payment Link')); + // Skip through to DONE + fireEvent.click(screen.getByText('Next')); + await waitFor(() => fireEvent.click(screen.getByText('Skip'))); + await waitFor(() => fireEvent.click(screen.getByText('Skip'))); + await waitFor(() => fireEvent.click(screen.getByText('Next'))); + await waitFor(() => expect(screen.getByText('Create')).toBeInTheDocument()); + fireEvent.click(screen.getByText('Create')); + await waitFor(() => { + expect(screen.getByTestId('error-hint')).toHaveTextContent('create-failed'); + }); + }); + + it('cancels the form without submitting', async () => { + renderScreen(); + fireEvent.click(screen.getAllByText('Edit configuration')[1]); + fireEvent.click(screen.getByText('Cancel')); + await waitFor(() => { + expect(screen.queryByTestId('dropdown-configDisplayQr')).not.toBeInTheDocument(); + }); + expect(mockUpdatePaymentLink).not.toHaveBeenCalled(); + }); + + it('pre-fills recipient from payment link when editing', async () => { + renderScreen(); + fireEvent.click(screen.getByText('Edit recipient')); + await waitFor(() => { + expect(screen.getByTestId('input-recipientName')).toHaveValue('Prefill Name'); + }); + expect(screen.getByTestId('input-recipientEmail')).toHaveValue('a@b.c'); + }); + + it('uses Unknown error when API rejects without message', async () => { + mockUpdatePaymentLink.mockRejectedValueOnce({}); + renderScreen(); + fireEvent.click(screen.getAllByText('Edit configuration')[1]); + fireEvent.click(screen.getByText('Save')); + await waitFor(() => { + expect(screen.getByTestId('error-hint')).toHaveTextContent('Unknown error'); + }); + }); + + it('runs Custom validators for configDisplayQr and configCancellable', async () => { + renderScreen(); + fireEvent.click(screen.getAllByText('Edit configuration')[1]); + await waitFor(() => expect(screen.getByTestId('dropdown-configDisplayQr')).toBeInTheDocument()); + + // Force invalid values so the `|| 'invalid …'` branch of Custom validators runs + fireEvent.click(screen.getByTestId('select-configDisplayQr-Yes')); + fireEvent.click(screen.getByTestId('select-configCancellable-No')); + // Re-select valid booleans (validate also runs with true/false — covers both sides) + fireEvent.click(screen.getByTestId('select-configDisplayQr-No')); + fireEvent.click(screen.getByTestId('select-configCancellable-Yes')); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(mockUpdatePaymentLink).toHaveBeenCalled(); + }); + }); + + it('merges empty config fields from userPaymentLinksConfig on create wizard', async () => { + // Start with empty standards array so isEmpty(current) is true and L993 assigns + mockRoutesState.overrides = { + userPaymentLinksConfig: { + standards: [], + minCompletionStatus: 'TxReceived', + displayQr: false, + fee: 0, + paymentTimeout: 60, + cancellable: true, + }, + }; + renderScreen(); + fireEvent.click(screen.getByText('Create Payment Link')); + // descriptionFunc exercised while rendering route dropdown items + expect(screen.getByTestId('dropdown-routeId')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Next')); + await waitFor(() => expect(screen.getByText('Skip')).toBeInTheDocument()); + }); + + it('surfaces Unknown error when a global config save fails without a message', async () => { + mockUpdateUserPaymentLinksConfig.mockRejectedValueOnce({}); + renderScreen(); + fireEvent.click(screen.getAllByText('Edit configuration')[0]); + fireEvent.click(screen.getByText('Save')); + await waitFor(() => { + expect(screen.getByTestId('error-hint')).toHaveTextContent('Unknown error'); + }); + }); +}); diff --git a/src/__tests__/payment-routes.screen.test.tsx b/src/__tests__/payment-routes.screen.test.tsx new file mode 100644 index 000000000..54dbf7227 --- /dev/null +++ b/src/__tests__/payment-routes.screen.test.tsx @@ -0,0 +1,250 @@ +// Label rename: "Always show QR code" on payment routes (global config + per-link). + +const mockRoutesState: { overrides: Record } = { overrides: {} }; + +jest.mock('@dfx.swiss/react', () => ({ + Blockchain: { ETHEREUM: 'Ethereum', BITCOIN: 'Bitcoin' }, + MinCompletionStatus: { PENDING: 'Pending' }, + PaymentLinkMode: { SINGLE: 'Single', MULTIPLE: 'Multiple', PUBLIC: 'Public' }, + PaymentLinkPaymentMode: { SINGLE: 'Single' }, + PaymentLinkPaymentStatus: { PENDING: 'Pending' }, + PaymentLinkStatus: { ACTIVE: 'Active', INACTIVE: 'Inactive' }, + PaymentStandardType: { OPEN_CRYPTO_PAY: 'OpenCryptoPay' }, + Utils: {}, + Validations: {}, + usePaymentRoutes: () => ({ + createPosLink: jest.fn().mockResolvedValue({ url: 'https://pos.example/pl' }), + }), + usePaymentRoutesContext: () => ({ + paymentRoutes: { + buy: [ + { + id: 1, + asset: { name: 'BTC', blockchain: 'Bitcoin' }, + bankUsage: 'DFX BUY 1', + volume: 0, + annualVolume: 0, + }, + ], + sell: [], + swap: [], + }, + paymentLinks: [ + { + id: 'pl-1', + routeId: 1, + status: 'Active', + label: 'Shop Link', + externalId: 'ext-1', + url: 'https://pay.example/pl', + lnurl: 'lnurl1handbook', + config: { displayQr: true }, + recipient: undefined, + payment: undefined, + }, + ], + paymentRoutesLoading: false, + paymentLinksLoading: false, + userPaymentLinksConfig: { + standards: ['OpenCryptoPay'], + minCompletionStatus: 'Pending', + displayQr: false, + fee: 0, + paymentTimeout: 60, + cancellable: true, + }, + userPaymentLinksConfigLoading: false, + updatePaymentLink: jest.fn().mockResolvedValue(undefined), + updateUserPaymentLinksConfig: jest.fn().mockResolvedValue(undefined), + cancelPaymentLinkPayment: jest.fn().mockResolvedValue(undefined), + deletePaymentRoute: jest.fn().mockResolvedValue(undefined), + error: undefined, + ...mockRoutesState.overrides, + }), + useUserContext: () => ({ user: { id: 1 }, isUserLoading: false }), +})); + +jest.mock('@dfx.swiss/react-components', () => ({ + AlignContent: { RIGHT: 'right' }, + CopyButton: () => null, + DfxIcon: () => null, + Form: ({ children }: { children: React.ReactNode }) =>
{children}
, + IconSize: { SM: 'sm' }, + IconVariant: { EXPAND_MORE: 'more', COPY: 'copy', OPEN_IN_NEW: 'open' }, + SpinnerSize: { LG: 'lg' }, + StyledButton: ({ label }: { label: string }) => , + StyledButtonColor: { STURDY_WHITE: 'sturdy-white', RED: 'red' }, + StyledButtonWidth: { FULL: 'full' }, + StyledCollapsible: ({ children, titleContent }: { children: React.ReactNode; titleContent?: React.ReactNode }) => ( +
+ {titleContent} + {children} +
+ ), + StyledDataTable: ({ children }: { children: React.ReactNode }) =>
{children}
, + StyledDataTableExpandableRow: ({ + label, + expansionItems, + expansionContent, + }: { + label: string; + expansionItems?: { label: string; text?: string }[]; + expansionContent?: React.ReactNode; + }) => ( +
+ {label} + {expansionItems?.map((item) => ( +
+ {item.label}: {item.text} +
+ ))} + {expansionContent} +
+ ), + StyledDataTableRow: ({ children, label }: { children?: React.ReactNode; label?: string }) => ( +
+ {label} + {children} +
+ ), + StyledDateAndTimePicker: () => null, + StyledDropdown: ({ label }: { label?: string }) =>
{label}
, + StyledDropdownMultiChoice: () => null, + StyledHorizontalStack: ({ children }: { children: React.ReactNode }) =>
{children}
, + StyledIconButton: () => null, + StyledInput: () => null, + StyledLoadingSpinner: () =>
, + StyledSearchDropdown: () => null, + StyledVerticalStack: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +jest.mock('copy-to-clipboard', () => jest.fn()); +jest.mock('react-i18next', () => ({ + Trans: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +jest.mock('../components/overlay/confirmation-overlay', () => ({ + ConfirmationOverlay: () => null, +})); +jest.mock('../components/overlay/edit-overlay', () => ({ + EditOverlay: () => null, +})); +jest.mock('../components/payment/qr-code', () => ({ + QrBasic: () => null, +})); +jest.mock('../components/error-hint', () => ({ + ErrorHint: ({ message }: { message: string }) =>
{message}
, +})); +jest.mock('../components/styled-link-button', () => ({ + StyledLinkButton: () => null, +})); + +jest.mock('../config/labels', () => ({ + PaymentQuoteStatusLabels: { Pending: 'Pending' }, +})); + +jest.mock('../contexts/layout.context', () => ({ + useLayoutContext: () => ({ rootRef: { current: null } }), +})); +jest.mock('../contexts/settings.context', () => ({ + useSettingsContext: () => ({ + translate: (_ns: string, key: string) => key, + translateError: (e: string) => e, + }), +})); +jest.mock('../contexts/wallet.context', () => ({ + useWalletContext: () => ({ isInitialized: true }), +})); +jest.mock('../contexts/window.context', () => ({ + useWindowContext: () => ({ width: 1024 }), +})); +jest.mock('../hooks/blockchain.hook', () => ({ + useBlockchain: () => ({ toString: (b: string) => b }), +})); +jest.mock('../hooks/guard.hook', () => ({ + useAddressGuard: () => undefined, +})); +jest.mock('../hooks/layout-config.hook', () => ({ + useLayoutOptions: () => undefined, +})); +jest.mock('../hooks/navigation.hook', () => ({ + useNavigation: () => ({ navigate: jest.fn(), goBack: jest.fn() }), +})); +jest.mock('../util/lnurl', () => ({ + Lnurl: { + encode: (u: string) => `lnurl-${u}`, + decode: (u: string) => u, + prependLnurl: (u: string) => `lightning:${u}`, + }, +})); +jest.mock('../util/utils', () => ({ + blankedAddress: (v: string) => v, + formatLocationAddress: () => '', + isEmpty: (v: unknown) => v == null || v === '', + removeNullFields: (o: Record) => o, + url: () => 'https://example.test', +})); + +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import PaymentRoutesScreen from '../screens/payment-routes.screen'; + +function renderScreen() { + return render( + + + , + ); +} + +describe('PaymentRoutesScreen Always show QR code label', () => { + beforeEach(() => { + mockRoutesState.overrides = {}; + }); + + it('renders the renamed QR label in global config and per-link config (not Display QR code)', () => { + renderScreen(); + + // Both always-rendered config summaries use the new label (global + per-link merge). + // Exact count: a partial rename that leaves only one site updated fails. + expect(screen.getAllByText(/Always show QR code/)).toHaveLength(2); + expect(screen.queryByText(/Display QR code/)).not.toBeInTheDocument(); + }); + + it('shows loading spinner while user payment link config loads', () => { + mockRoutesState.overrides = { userPaymentLinksConfigLoading: true }; + renderScreen(); + expect(screen.getByTestId('loading-spinner')).toBeInTheDocument(); + }); + + it('shows empty-state when there are no routes', () => { + mockRoutesState.overrides = { + paymentRoutes: { buy: [], sell: [], swap: [] }, + paymentLinks: [], + }; + renderScreen(); + expect(screen.getByText('You have no payment routes yet')).toBeInTheDocument(); + }); + + it('surfaces API errors', () => { + mockRoutesState.overrides = { error: 'routes-failed' }; + renderScreen(); + expect(screen.getByTestId('error-hint')).toHaveTextContent('routes-failed'); + }); + + it('shows Yes when displayQr is forced and No when cancellable is off', () => { + mockRoutesState.overrides = { + userPaymentLinksConfig: { + standards: ['OpenCryptoPay'], + minCompletionStatus: 'Pending', + displayQr: true, + fee: 0, + paymentTimeout: 60, + cancellable: false, + }, + }; + renderScreen(); + expect(screen.getAllByTestId('item-Always show QR code')[0]).toHaveTextContent('Always show QR code: Yes'); + expect(screen.getAllByTestId('item-Payment cancellable')[0]).toHaveTextContent('Payment cancellable: No'); + }); +}); diff --git a/src/__tests__/translation-conventions.test.ts b/src/__tests__/translation-conventions.test.ts new file mode 100644 index 000000000..8a15b269d --- /dev/null +++ b/src/__tests__/translation-conventions.test.ts @@ -0,0 +1,124 @@ +/** + * Convention guards for translation files touched by payment-QR work. + * Not a full i18n linter — only Anrede capitalization (de) and "wallet" as + * common noun (fr/it), so portefeuille/portafoglio regressions fail the suite. + */ + +import de from '../translations/languages/de.json'; +import fr from '../translations/languages/fr.json'; +import itLang from '../translations/languages/it.json'; + +/** German informal address forms that must be capitalized when used as Anrede. */ +const DE_ANREDE = /\b(du|dich|dir|dein|deine|deinen|deinem|deiner|deines)\b/g; + +/** + * Known pre-existing lowercase Anrede forms left in de.json (full German value). + * A new lowercase Anrede must not be added here without an explicit decision. + */ +const DE_ANREDE_EXCEPTIONS: readonly string[] = [ + 'Der Zugriff auf interne Werkzeuge setzt neu eine identifizierte Person hinter dem Konto voraus. Deine Rolle ist unverändert — es fehlt lediglich deine Identifikation.', + 'Keine dir zugeordneten Tickets', + 'Die bestehende EUR-IBAN (CH8583019DFXSWISSEURX) hat derzeit technische Probleme. Bitte verwende stattdessen deine persönliche IBAN für EUR-Transaktionen. Du findest deine persönliche IBAN auf der Kaufseite.', +]; + +/** Full-string product-name exceptions where the English token "wallet" is intentional. */ +const WALLET_EXCEPTIONS: readonly string[] = ['Login avec votre wallet BTC Taro']; + +function collectStringValues(node: unknown, out: string[] = []): string[] { + if (typeof node === 'string') { + out.push(node); + return out; + } + if (node && typeof node === 'object') { + for (const value of Object.values(node as Record)) { + collectStringValues(value, out); + } + } + return out; +} + +function lowercaseAnredeHits(value: string): string[] { + const hits: string[] = []; + DE_ANREDE.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = DE_ANREDE.exec(value)) !== null) { + hits.push(match[0]); + } + return hits; +} + +function containsWalletAsWord(value: string): boolean { + // Whole word, case-insensitive; {{wallet}} is handled via exception list. + return /\bwallet\b/i.test(value); +} + +function isWalletException(value: string): boolean { + // Exact match only — substring exceptions would silence any sentence containing the product name. + if (WALLET_EXCEPTIONS.some((ex) => value === ex)) { + return true; + } + // Placeholder-only hits: strip {{wallet}} and re-check (variable name is English by convention). + const withoutPlaceholders = value.replace(/\{\{\s*wallet\s*\}\}/gi, ''); + return !/\bwallet\b/i.test(withoutPlaceholders); +} + +describe('translation conventions (payment-related language quality)', () => { + describe('de.json — Anrede capitalization', () => { + const values = collectStringValues(de); + + it('has translation strings to inspect (no vacuum)', () => { + expect(values.length).toBeGreaterThan(100); + }); + + it('writes Du/Dich/Dir/Dein… uppercase except the named legacy list', () => { + const violations: { value: string; hits: string[] }[] = []; + + for (const value of values) { + if (DE_ANREDE_EXCEPTIONS.includes(value)) continue; + const hits = lowercaseAnredeHits(value); + if (hits.length > 0) { + violations.push({ value, hits }); + } + } + + expect(violations).toEqual([]); + }); + + it('still lists every named exception (exceptions must remain present)', () => { + for (const exception of DE_ANREDE_EXCEPTIONS) { + expect(values).toContain(exception); + } + }); + }); + + describe('fr.json / it.json — no "wallet" as common noun', () => { + const frValues = collectStringValues(fr); + const itValues = collectStringValues(itLang); + + it('has fr and it strings to inspect (no vacuum)', () => { + expect(frValues.length).toBeGreaterThan(100); + expect(itValues.length).toBeGreaterThan(100); + }); + + it('fr.json does not use wallet as Gattungswort outside named exceptions', () => { + const violations = frValues.filter((v) => containsWalletAsWord(v) && !isWalletException(v)); + expect(violations).toEqual([]); + }); + + it('it.json does not use wallet as Gattungswort outside named exceptions', () => { + const violations = itValues.filter((v) => containsWalletAsWord(v) && !isWalletException(v)); + expect(violations).toEqual([]); + }); + + it('payment copy uses portefeuille / portafoglio instead of wallet as Gattungswort', () => { + const frPayment = (fr as { 'screens/payment': Record })['screens/payment']; + const itPayment = (itLang as { 'screens/payment': Record })['screens/payment']; + + expect(frPayment['Choose your wallet to open the payment.']).toMatch(/portefeuille/i); + expect(frPayment['Choose your wallet to open the payment.']).not.toMatch(/\bwallet\b/i); + + expect(itPayment['Choose your wallet to open the payment.']).toMatch(/portafoglio/i); + expect(itPayment['Choose your wallet to open the payment.']).not.toMatch(/\bwallet\b/i); + }); + }); +}); diff --git a/src/hooks/device.hook.ts b/src/hooks/device.hook.ts new file mode 100644 index 000000000..f382250a2 --- /dev/null +++ b/src/hooks/device.hook.ts @@ -0,0 +1,40 @@ +import { useEffect, useState } from 'react'; +import { isMobile } from 'react-device-detect'; + +function readCoarsePointer(): boolean { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return false; + } + return window.matchMedia('(pointer: coarse)').matches; +} + +/** + * Whether the payer is holding a phone or tablet — the device they would scan a QR with. + * UA alone is not enough: "Request Desktop Site" flips isMobile while the input stays touch. + */ +export function useIsHandheld(): boolean { + const [hasCoarsePointer, setHasCoarsePointer] = useState(readCoarsePointer); + + useEffect(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return; + } + + const mediaQuery = window.matchMedia('(pointer: coarse)'); + const update = () => setHasCoarsePointer(mediaQuery.matches); + update(); + + if (typeof mediaQuery.addEventListener === 'function') { + mediaQuery.addEventListener('change', update); + return () => mediaQuery.removeEventListener('change', update); + } + + // Older Safari only exposes addListener/removeListener + if (typeof mediaQuery.addListener === 'function') { + mediaQuery.addListener(update); + return () => mediaQuery.removeListener(update); + } + }, []); + + return isMobile || hasCoarsePointer; +} diff --git a/src/screens/payment-link.screen.tsx b/src/screens/payment-link.screen.tsx index e52c48d32..69f9a3467 100644 --- a/src/screens/payment-link.screen.tsx +++ b/src/screens/payment-link.screen.tsx @@ -59,6 +59,7 @@ import { PaymentStandard, WalletInfo, } from 'src/dto/payment-link.dto'; +import { useIsHandheld } from 'src/hooks/device.hook'; import { useNavigation } from 'src/hooks/navigation.hook'; import { usePaymentLinkWallets } from 'src/hooks/payment-link-wallets.hook'; import { useWeb3 } from 'src/hooks/web3.hook'; @@ -117,6 +118,8 @@ export default function PaymentLinkScreen(): JSX.Element { error: walletsError, } = usePaymentLinkWallets(); + const isHandheld = useIsHandheld(); + const [assetObject, setAssetObject] = useState(); const [showContract, setShowContract] = useState(false); const [walletData, setWalletData] = useState(); @@ -136,6 +139,15 @@ export default function PaymentLinkScreen(): JSX.Element { const selectedPaymentStandard = useWatch({ control, name: 'paymentStandard' }); const selectedAsset = useWatch({ control, name: 'asset' }); + // Large QR only exists inside the OpenCryptoPay section; displayQr forces it there on every device. + // Outside OCP (or MetaMask error/info), fall back to the collapsible "QR Code" row. + // Form standard is set asynchronously; fall back to the payment's own standard so an unset form is not OCP. + const showsOcpSection = + !metaMaskError && + !metaMaskInfo && + (selectedPaymentStandard?.id ?? payRequest?.standard) === PaymentStandardType.OPEN_CRYPTO_PAY; + const showLargeQr = showsOcpSection && (Boolean(payRequest?.displayQr) || !isHandheld); + useEffect(() => { const walletIdParam = searchParams.get('wallet-id'); const walletId = walletIdParam ? parseInt(walletIdParam, 10) : undefined; @@ -253,7 +265,7 @@ export default function PaymentLinkScreen(): JSX.Element { ) : (
-

{payRequest?.displayName ?? merchant}

+

{payRequest.displayName ?? merchant}

{!merchant && ( <> @@ -340,7 +352,7 @@ export default function PaymentLinkScreen(): JSX.Element { rootRef={rootRef} name="asset" - items={assetsList?.map((item) => item.asset) ?? []} + items={assetsList.map((item) => item.asset)} labelFunc={(item) => item} descriptionFunc={() => selectedPaymentStandard?.blockchain ?? ''} full @@ -381,7 +393,7 @@ export default function PaymentLinkScreen(): JSX.Element { ].filter((item) => item.text) as any } > -

{blankedAddress(payRequest.externalId ?? payRequest.id, { width, scale: 0.9 })}

+

{blankedAddress(payRequest.externalId, { width, scale: 0.9 })}

)} {paymentHasQuote(payRequest) && ( @@ -394,7 +406,7 @@ export default function PaymentLinkScreen(): JSX.Element { isLoading={isLoadingPaymentIdentifier || !paymentIdentifier} >

{formatUnits(parsedEvmUri.amount, assetObject?.decimals)}

- copy(parsedEvmUri.amount ?? '')} /> + copy(parsedEvmUri.amount as string)} /> )} @@ -402,10 +414,10 @@ export default function PaymentLinkScreen(): JSX.Element { {showContract && assetObject.chainId ? ( - {blankedAddress(assetObject.chainId ?? '', { width, scale: 0.75 })} + {blankedAddress(assetObject.chainId, { width, scale: 0.75 })} copy(assetObject.chainId ?? '')} + onClick={() => copy(assetObject.chainId as string)} size={IconSize.SM} /> {assetObject.explorerUrl && ( @@ -434,20 +446,25 @@ export default function PaymentLinkScreen(): JSX.Element { label={translate('screens/home', 'Address')} isLoading={isLoadingPaymentIdentifier || !paymentIdentifier} > -

{blankedAddress(parsedEvmUri.address ?? '', { width, scale: 0.8 })}

- copy(parsedEvmUri.address ?? '')} /> +

{blankedAddress(parsedEvmUri.address, { width, scale: 0.8 })}

+ copy(parsedEvmUri.address as string)} />
)} - {toBlockchain(parsedEvmUri.chainId ?? '') && ( - -

{toBlockchain(parsedEvmUri.chainId ?? '')}

- copy(toBlockchain(parsedEvmUri.chainId ?? '') ?? '')} /> -
- )} + {(() => { + const chain = toBlockchain(parsedEvmUri.chainId ?? ''); + return ( + chain && ( + +

{chain}

+ copy(chain)} /> +
+ ) + ); + })()} )} @@ -517,7 +534,7 @@ export default function PaymentLinkScreen(): JSX.Element {

{new Date(payRequest.quote.expiration).toLocaleString()}

)} - {paymentHasQuote(payRequest) && !payRequest.displayQr && ( + {paymentHasQuote(payRequest) && !showLargeQr && ( ) : ( - (!selectedPaymentStandard || - PaymentStandardType.OPEN_CRYPTO_PAY === (selectedPaymentStandard.id as PaymentStandardType)) && ( + showsOcpSection && ( {paymentHasQuote(payRequest) ? (
- {payRequest.displayQr && ( + {showLargeQr && (
{translate( 'screens/payment', - 'Scan the QR-Code with a compatible app to complete the payment.', + showLargeQr + ? 'Scan the QR-Code with a compatible app to complete the payment.' + : 'Choose your wallet to open the payment.', )}

@@ -629,7 +647,7 @@ export default function PaymentLinkScreen(): JSX.Element {

{translate( 'screens/payment', - 'Tell the cashier that you want to pay with crypto and then scan the QR-Code with a compatible app to complete the payment.', + 'Tell the cashier that you want to pay with crypto to start the payment.', )}

)} @@ -774,8 +792,8 @@ function TransferMethodsContent({ payRequest, walletData }: TransferMethodsConte const { isMerchantMode } = usePaymentLinkContext(); const filteredTransferAmounts = walletData - ? Wallet.filterTransferInfoByWallet(walletData, payRequest.transferAmounts) - : payRequest.transferAmounts; + ? Wallet.filterTransferInfoByWallet(walletData, payRequest.transferAmounts ?? []) + : (payRequest.transferAmounts ?? []); const supportedMethods = filteredTransferAmounts.filter((ta) => ta.available !== false); const assetMap = new Map(); @@ -852,11 +870,9 @@ function WalletGrid({ wallets, header }: WalletGridProps): JSX.Element { ); } -function DividerWithHeader({ header, py }: { header: string; py?: number }): JSX.Element { - const pyClass = py === 4 ? 'py-4' : py === 2 ? 'py-2' : py === 1 ? 'py-1' : ''; - +function DividerWithHeader({ header }: { header: string }): JSX.Element { return ( -
+

{header}

diff --git a/src/screens/payment-routes.screen.tsx b/src/screens/payment-routes.screen.tsx index 94963094a..83240319f 100644 --- a/src/screens/payment-routes.screen.tsx +++ b/src/screens/payment-routes.screen.tsx @@ -275,7 +275,7 @@ export default function PaymentRoutesScreen(): JSX.Element { return ( <> {(apiError && apiError !== 'permission denied') || error ? ( - + ) : userPaymentLinksConfigLoading || isUserLoading || !isInitialized ? ( ) : updateGlobalConfig ? ( @@ -446,7 +446,7 @@ export default function PaymentRoutesScreen(): JSX.Element { ), }, { - label: translate('screens/payment', 'Display QR code'), + label: translate('screens/payment', 'Always show QR code'), text: translate('general/actions', userPaymentLinksConfig?.displayQr ? 'Yes' : 'No'), }, { @@ -671,7 +671,7 @@ export default function PaymentRoutesScreen(): JSX.Element { ), }, { - label: translate('screens/payment', 'Display QR code'), + label: translate('screens/payment', 'Always show QR code'), text: translate('general/actions', linkConfig.displayQr ? 'Yes' : 'No'), }, { @@ -1343,7 +1343,7 @@ function PaymentLinkForm({