From 9f0f5988a5486cac9be1bc03464f10c7c949a300 Mon Sep 17 00:00:00 2001 From: Peng Ying Date: Fri, 27 Mar 2026 15:01:27 -0700 Subject: [PATCH 1/4] feat: multi-currency support, E2E tests for Kotlin sample app - Fix SDK type mismatches (Config field names, QuoteSourceOneOf, AccountType enums) - Add multi-currency external account support (MXN, BRL, INR, GBP, PHP, EUR) - Add country selector dropdown and source currency dropdown (USD/USDC) - Add "Start New Payment" button to restart from external account step - Extract shared optText/requireText to JsonUtils, deduplicate across route files - Extract BeneficiaryFields to reduce copy-paste across beneficiary builders - Add Ktor server E2E tests (7 tests hitting Grid sandbox API) - Add Playwright browser E2E tests (8 tests driving the full React UI) Co-Authored-By: Claude Opus 4.6 (1M context) --- samples/frontend/e2e/payout-flow.spec.ts | 153 ++++++++ samples/frontend/package-lock.json | 64 ++++ samples/frontend/package.json | 5 +- samples/frontend/playwright.config.ts | 15 + samples/frontend/src/App.tsx | 20 +- .../src/steps/CreateExternalAccount.tsx | 214 +++++++++-- samples/frontend/src/steps/CreateQuote.tsx | 29 +- samples/kotlin/build.gradle.kts | 6 + .../com/grid/sample/GridClientBuilder.kt | 4 +- .../main/kotlin/com/grid/sample/JsonUtils.kt | 7 + .../com/grid/sample/routes/Customers.kt | 9 +- .../grid/sample/routes/ExternalAccounts.kt | 269 ++++++++------ .../kotlin/com/grid/sample/routes/Quotes.kt | 16 +- .../kotlin/com/grid/sample/routes/Sandbox.kt | 4 +- .../kotlin/com/grid/sample/routes/Webhooks.kt | 2 +- .../kotlin/com/grid/sample/EndToEndTest.kt | 343 ++++++++++++++++++ 16 files changed, 990 insertions(+), 170 deletions(-) create mode 100644 samples/frontend/e2e/payout-flow.spec.ts create mode 100644 samples/frontend/playwright.config.ts create mode 100644 samples/kotlin/src/test/kotlin/com/grid/sample/EndToEndTest.kt diff --git a/samples/frontend/e2e/payout-flow.spec.ts b/samples/frontend/e2e/payout-flow.spec.ts new file mode 100644 index 00000000..94534933 --- /dev/null +++ b/samples/frontend/e2e/payout-flow.spec.ts @@ -0,0 +1,153 @@ +import { test, expect } from '@playwright/test' + +// These tests drive the full React UI against the running Kotlin backend + Grid sandbox API. +// Prerequisites: `cd samples/kotlin && ./gradlew run` must be running on port 8080. + +test.describe('Payout Flow', () => { + + test.beforeEach(async ({ page }) => { + await page.goto('/') + await expect(page.locator('h1')).toHaveText('Grid API Sample') + }) + + test('page loads with step 1 active', async ({ page }) => { + const step1 = page.locator('h3', { hasText: '1. Create Customer' }) + await expect(step1).toBeVisible() + + // Create Customer button should be enabled + await expect(page.getByRole('button', { name: 'Create Customer' })).toBeEnabled() + }) + + test('create customer advances to step 2', async ({ page }) => { + await page.getByRole('button', { name: 'Create Customer' }).click() + + // Wait for step 2 to become active + await expect(page.getByRole('button', { name: 'Create External Account' })).toBeEnabled({ timeout: 15_000 }) + + // Step 1 should show a green checkmark summary with ID + const step1Summary = page.locator('span.text-green-400.font-mono') + await expect(step1Summary.first()).toContainText('ID:') + + // Country dropdown should be visible + await expect(page.locator('#destination-country')).toBeVisible() + }) + + test('country dropdown changes the JSON body', async ({ page }) => { + await page.getByRole('button', { name: 'Create Customer' }).click() + await expect(page.getByRole('button', { name: 'Create External Account' })).toBeEnabled({ timeout: 15_000 }) + + // Default country (IN) should populate the textarea + const textarea = page.locator('textarea').nth(1) + + // Switch to India + await page.locator('#destination-country').selectOption('IN') + await expect(textarea).toHaveValue(/INR_ACCOUNT/, { timeout: 3_000 }) + await expect(textarea).toHaveValue(/vpa/) + + // Switch to Brazil + await page.locator('#destination-country').selectOption('BR') + await expect(textarea).toHaveValue(/BRL_ACCOUNT/, { timeout: 3_000 }) + await expect(textarea).toHaveValue(/pixKey/) + }) + + test('full flow: customer → external account → quote', async ({ page }) => { + // Step 1: Create Customer + await page.getByRole('button', { name: 'Create Customer' }).click() + await expect(page.getByRole('button', { name: 'Create External Account' })).toBeEnabled({ timeout: 15_000 }) + + // Step 2: Create External Account (default country) + await page.getByRole('button', { name: 'Create External Account' }).click() + await expect(page.getByRole('button', { name: 'Create Quote' })).toBeEnabled({ timeout: 15_000 }) + + // Step 2 summary should show ID + const summaries = page.locator('span.text-green-400.font-mono') + await expect(summaries.nth(1)).toContainText('ID:') + + // Step 3: Source currency dropdown should be visible + await expect(page.locator('#source-currency')).toBeVisible() + + // Verify quote JSON body has the right structure + const quoteTextarea = page.locator('textarea').last() + await expect(quoteTextarea).toHaveValue(/REALTIME_FUNDING/) + await expect(quoteTextarea).toHaveValue(/"currency": "USD"/) + + // Create Quote + await page.getByRole('button', { name: 'Create Quote' }).click() + await expect(page.getByRole('button', { name: 'Send Sandbox Funds' })).toBeEnabled({ timeout: 15_000 }) + }) + + test('full flow with India INR', async ({ page }) => { + // Step 1: Create Customer + await page.getByRole('button', { name: 'Create Customer' }).click() + await expect(page.getByRole('button', { name: 'Create External Account' })).toBeEnabled({ timeout: 15_000 }) + + // Step 2: Switch to India and create account + await page.locator('#destination-country').selectOption('IN') + await expect(page.locator('textarea').nth(1)).toHaveValue(/INR_ACCOUNT/, { timeout: 3_000 }) + await page.getByRole('button', { name: 'Create External Account' }).click() + await expect(page.getByRole('button', { name: 'Create Quote' })).toBeEnabled({ timeout: 15_000 }) + + // Step 3: Create Quote + await page.getByRole('button', { name: 'Create Quote' }).click() + await expect(page.getByRole('button', { name: 'Send Sandbox Funds' })).toBeEnabled({ timeout: 15_000 }) + }) + + test('source currency dropdown updates quote body', async ({ page }) => { + // Step 1 + 2: Get to quote step + await page.getByRole('button', { name: 'Create Customer' }).click() + await expect(page.getByRole('button', { name: 'Create External Account' })).toBeEnabled({ timeout: 15_000 }) + await page.getByRole('button', { name: 'Create External Account' }).click() + await expect(page.getByRole('button', { name: 'Create Quote' })).toBeEnabled({ timeout: 15_000 }) + + // Default should be USD + const quoteTextarea = page.locator('textarea').last() + await expect(quoteTextarea).toHaveValue(/"currency": "USD"/) + + // Switch to USDC + await page.locator('#source-currency').selectOption('USDC') + await expect(quoteTextarea).toHaveValue(/"currency": "USDC"/, { timeout: 3_000 }) + + // Description text should update + await expect(page.getByText('USDC →')).toBeVisible() + }) + + test('Start New Payment resets to step 2', async ({ page }) => { + // Step 1: Create Customer + await page.getByRole('button', { name: 'Create Customer' }).click() + await expect(page.getByRole('button', { name: 'Create External Account' })).toBeEnabled({ timeout: 15_000 }) + + // Step 2: Create External Account + await page.getByRole('button', { name: 'Create External Account' }).click() + await expect(page.getByRole('button', { name: 'Create Quote' })).toBeEnabled({ timeout: 15_000 }) + + // Click Start New Payment + await page.getByRole('button', { name: 'Start New Payment' }).click() + + // Should be back at step 2 with external account button enabled + await expect(page.getByRole('button', { name: 'Create External Account' })).toBeEnabled() + + // Step 3 should be future (dimmed), quote button should not be visible + await expect(page.getByRole('button', { name: 'Create Quote' })).not.toBeVisible() + }) + + test('full payout flow including sandbox funding', async ({ page }) => { + // Step 1: Create Customer + await page.getByRole('button', { name: 'Create Customer' }).click() + await expect(page.getByRole('button', { name: 'Create External Account' })).toBeEnabled({ timeout: 15_000 }) + + // Step 2: Create External Account (default country) + await page.getByRole('button', { name: 'Create External Account' }).click() + await expect(page.getByRole('button', { name: 'Create Quote' })).toBeEnabled({ timeout: 15_000 }) + + // Step 3: Create Quote + await page.getByRole('button', { name: 'Create Quote' }).click() + await expect(page.getByRole('button', { name: 'Send Sandbox Funds' })).toBeEnabled({ timeout: 15_000 }) + + // Step 4: Send Sandbox Funds + await page.getByRole('button', { name: 'Send Sandbox Funds' }).click() + + // After funding, all 4 steps should be completed (4 green checkmarks) + const checkmarks = page.locator('text=✓') + await expect(checkmarks).toHaveCount(4, { timeout: 15_000 }) + }) +}) diff --git a/samples/frontend/package-lock.json b/samples/frontend/package-lock.json index c5895cd7..c89d0a4d 100644 --- a/samples/frontend/package-lock.json +++ b/samples/frontend/package-lock.json @@ -12,6 +12,7 @@ "react-dom": "^18.3.1" }, "devDependencies": { + "@playwright/test": "^1.58.2", "@tailwindcss/vite": "^4.1.10", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", @@ -795,6 +796,22 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -2143,6 +2160,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", diff --git a/samples/frontend/package.json b/samples/frontend/package.json index d5348dc6..548475f0 100644 --- a/samples/frontend/package.json +++ b/samples/frontend/package.json @@ -6,13 +6,16 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test:e2e": "playwright test", + "test:e2e:headed": "playwright test --headed" }, "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" }, "devDependencies": { + "@playwright/test": "^1.58.2", "@tailwindcss/vite": "^4.1.10", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", diff --git a/samples/frontend/playwright.config.ts b/samples/frontend/playwright.config.ts new file mode 100644 index 00000000..e7614a9b --- /dev/null +++ b/samples/frontend/playwright.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + testDir: './e2e', + timeout: 60_000, + expect: { timeout: 15_000 }, + use: { + baseURL: 'http://localhost:8080', + trace: 'on-first-retry', + }, + retries: 0, + projects: [ + { name: 'chromium', use: { browserName: 'chromium' } }, + ], +}) diff --git a/samples/frontend/src/App.tsx b/samples/frontend/src/App.tsx index db1d3836..aa3d707e 100644 --- a/samples/frontend/src/App.tsx +++ b/samples/frontend/src/App.tsx @@ -11,9 +11,16 @@ export default function App() { const [customerId, setCustomerId] = useState(null) const [externalAccountId, setExternalAccountId] = useState(null) const [quoteId, setQuoteId] = useState(null) + const [selectedCountry, setSelectedCountry] = useState('MX') const advance = () => setActiveStep((s) => s + 1) + const restartFromExternalAccount = () => { + setExternalAccountId(null) + setQuoteId(null) + setActiveStep(1) + } + const steps = [ { title: '1. Create Customer', @@ -35,6 +42,8 @@ export default function App() { { setExternalAccountId(data.id as string) advance() @@ -49,6 +58,7 @@ export default function App() { { setQuoteId((data.quoteId ?? data.id) as string) @@ -74,11 +84,19 @@ export default function App() {

Grid API Sample

-

Send a real time payment to a US bank account funded with USDC

+

Send a real time payment funded with USDC

+ {activeStep >= 1 && ( + + )}