Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions samples/frontend/e2e/payout-flow.spec.ts
Original file line number Diff line number Diff line change
@@ -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 })
})
})
64 changes: 64 additions & 0 deletions samples/frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion samples/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions samples/frontend/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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' } },
],
})
20 changes: 19 additions & 1 deletion samples/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,16 @@ export default function App() {
const [customerId, setCustomerId] = useState<string | null>(null)
const [externalAccountId, setExternalAccountId] = useState<string | null>(null)
const [quoteId, setQuoteId] = useState<string | null>(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',
Expand All @@ -35,6 +42,8 @@ export default function App() {
<CreateExternalAccount
customerId={customerId}
disabled={activeStep !== 1}
selectedCountry={selectedCountry}
onCountryChange={setSelectedCountry}
onComplete={(data) => {
setExternalAccountId(data.id as string)
advance()
Expand All @@ -49,6 +58,7 @@ export default function App() {
<CreateQuote
customerId={customerId}
externalAccountId={externalAccountId}
selectedCountry={selectedCountry}
disabled={activeStep !== 2}
onComplete={(data) => {
setQuoteId((data.quoteId ?? data.id) as string)
Expand All @@ -74,11 +84,19 @@ export default function App() {
<div className="min-h-screen bg-gray-950 text-gray-100">
<header className="border-b border-gray-800 px-6 py-4">
<h1 className="text-xl font-bold">Grid API Sample</h1>
<p className="text-sm text-gray-400">Send a real time payment to a US bank account funded with USDC</p>
<p className="text-sm text-gray-400">Send a real time payment funded with USDC</p>
</header>
<div className="flex">
<main className="w-3/5 p-6 border-r border-gray-800 min-h-[calc(100vh-73px)]">
<StepWizard steps={steps} activeStep={activeStep} />
{activeStep >= 1 && (
<button
onClick={restartFromExternalAccount}
className="mt-6 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm font-medium text-gray-300"
>
Start New Payment
</button>
)}
</main>
<aside className="w-2/5 p-6 min-h-[calc(100vh-73px)]">
<WebhookStream />
Expand Down
Loading
Loading