Skip to content

Commit 1afee33

Browse files
authored
Merge pull request #310 from morapay-app/fix/295-e2e-vault-deposit-withdraw
fix(frontend): E2E vault deposit/withdraw edges and mutation payloads (#295)
2 parents 794f93f + 5aeb6e3 commit 1afee33

12 files changed

Lines changed: 179 additions & 160 deletions

.github/workflows/e2e.yml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,6 @@ jobs:
3131
- name: Install Playwright browsers
3232
run: npx playwright install chromium --with-deps
3333

34-
- name: Build app
35-
run: npm run build
36-
3734
- name: Run E2E tests
3835
run: npm run test:e2e
3936
env:

frontend/e2e/deposit-withdraw.spec.ts

Lines changed: 82 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,26 @@
11
/**
22
* Flow 2: Deposit & Withdraw Transaction
33
*/
4-
import { test, expect, interceptApiRoutes, stubFreighterConnected, stubFreighterDisconnected } from './fixtures';
5-
6-
const MOCK_ADDRESS = 'GABC1TEST2STELLAR3ADDRESS4FAKE5XYZ6ABCDEFGHIJKLMNOPQRSTU';
4+
import type { Page } from '@playwright/test';
5+
import {
6+
test,
7+
expect,
8+
interceptApiRoutes,
9+
stubFreighterConnected,
10+
stubFreighterDisconnected,
11+
vaultSummaryAtCapacity,
12+
} from './fixtures';
13+
14+
/** Valid Stellar public key (G + 55 base32 chars) for API validation in submitDeposit / submitWithdrawal. */
15+
const MOCK_ADDRESS = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5';
716
const SHORT_ADDR = `${MOCK_ADDRESS.substring(0, 5)}...${MOCK_ADDRESS.substring(MOCK_ADDRESS.length - 4)}`;
817

18+
async function goToConnectedVault(page: Page) {
19+
await page.goto('/');
20+
await expect(page.getByText(SHORT_ADDR)).toBeVisible({ timeout: 5000 });
21+
await expect(page.getByLabel('USDC wallet balance')).toContainText('1250.50', { timeout: 20_000 });
22+
}
23+
924
// Tests that verify unauthenticated UI no Freighter stub injected
1025
test.describe('Deposit panel no wallet', () => {
1126
test.beforeEach(async ({ page }) => {
@@ -43,22 +58,19 @@ test.describe('Deposit & Withdraw connected wallet', () => {
4358
});
4459

4560
test('auto-connects wallet on mount when Freighter is already allowed', async ({ page }) => {
46-
await page.goto('/');
47-
await expect(page.getByText(SHORT_ADDR)).toBeVisible({ timeout: 5000 });
61+
await goToConnectedVault(page);
4862
});
4963

5064
test('deposit overlay is removed after wallet connects', async ({ page }) => {
51-
await page.goto('/');
52-
await expect(page.getByText(SHORT_ADDR)).toBeVisible({ timeout: 5000 });
65+
await goToConnectedVault(page);
5366
await expect(page.getByText('Wallet Not Connected')).not.toBeVisible();
5467
});
5568

5669
test('deposit tab is active by default and can switch to withdraw', async ({ page }) => {
57-
await page.goto('/');
58-
await expect(page.getByText(SHORT_ADDR)).toBeVisible({ timeout: 5000 });
70+
await goToConnectedVault(page);
5971

60-
const depositTab = page.getByRole('button', { name: 'Deposit', exact: true });
61-
const withdrawTab = page.getByRole('button', { name: 'Withdraw', exact: true });
72+
const depositTab = page.getByRole('tab', { name: 'Deposit', exact: true });
73+
const withdrawTab = page.getByRole('tab', { name: 'Withdraw', exact: true });
6274

6375
await expect(page.getByText('Amount to deposit')).toBeVisible();
6476
await withdrawTab.click();
@@ -67,51 +79,90 @@ test.describe('Deposit & Withdraw connected wallet', () => {
6779
await expect(page.getByText('Amount to deposit')).toBeVisible();
6880
});
6981

70-
test('MAX button fills the amount input with the current balance', async ({ page }) => {
71-
await page.goto('/');
72-
await expect(page.getByText(SHORT_ADDR)).toBeVisible({ timeout: 5000 });
82+
test('MAX button pre-fills the deposit field with the displayed wallet balance', async ({ page }) => {
83+
await goToConnectedVault(page);
84+
const walletBanner = page.getByLabel('USDC wallet balance');
85+
await expect(walletBanner).toBeVisible();
86+
const bannerText = (await walletBanner.textContent()) ?? '';
87+
const match = bannerText.match(/USDC:\s*([\d.]+)/);
88+
expect(match, 'expected USDC balance in wallet banner').toBeTruthy();
89+
const expectedBalance = match![1];
7390
await page.getByRole('button', { name: 'MAX' }).click();
74-
const value = await page.getByPlaceholder('0.00').inputValue();
75-
expect(value).toBeTruthy();
91+
await expect(page.getByLabel('Deposit amount')).toHaveValue(expectedBalance);
7692
});
7793

7894
test('performs a deposit and updates the balance', async ({ page }) => {
79-
await page.goto('/');
80-
await expect(page.getByText(SHORT_ADDR)).toBeVisible({ timeout: 5000 });
95+
await goToConnectedVault(page);
8196

82-
const amountInput = page.getByPlaceholder('0.00');
97+
const amountInput = page.getByLabel('Deposit amount');
8398
const submitBtn = page.getByRole('button', { name: /Approve & Deposit/i });
8499

85100
await amountInput.fill('100');
86101
await expect(submitBtn).toBeEnabled();
87102
await submitBtn.click();
88103

89-
await expect(page.getByRole('button', { name: /Processing Transaction/i })).toBeVisible();
90-
// Initial balance 1250.50 + 100 = 1350.50
91-
await expect(page.getByText('1350.50')).toBeVisible({ timeout: 5000 });
104+
await expect(page.getByRole('button', { name: /Dismiss Deposit Successful/i })).toBeVisible({
105+
timeout: 15_000,
106+
});
92107
await expect(page.getByRole('button', { name: /Approve & Deposit/i })).toBeVisible();
93108
});
94109

95110
test('performs a withdrawal and updates the balance', async ({ page }) => {
96-
await page.goto('/');
97-
await expect(page.getByText(SHORT_ADDR)).toBeVisible({ timeout: 5000 });
111+
await goToConnectedVault(page);
98112

99-
await page.getByRole('button', { name: 'Withdraw', exact: true }).click();
113+
await page.getByRole('tab', { name: 'Withdraw', exact: true }).click();
100114
await expect(page.getByText('Amount to withdraw')).toBeVisible();
101115

102-
await page.getByPlaceholder('0.00').fill('50');
116+
await page.getByLabel('Withdrawal amount').fill('50');
103117
const submitBtn = page.getByRole('button', { name: /Withdraw Funds/i });
104118
await expect(submitBtn).toBeEnabled();
105119
await submitBtn.click();
106120

107-
await expect(page.getByRole('button', { name: /Processing Transaction/i })).toBeVisible();
108-
// 1250.50 - 50 = 1200.50
109-
await expect(page.getByText('1200.50')).toBeVisible({ timeout: 5000 });
121+
await expect(page.getByRole('button', { name: /Dismiss Withdrawal Successful/i })).toBeVisible({
122+
timeout: 15_000,
123+
});
124+
});
125+
126+
test('deposit submit stays disabled with an empty amount field', async ({ page }) => {
127+
await goToConnectedVault(page);
128+
const depositInput = page.getByLabel('Deposit amount');
129+
await expect(depositInput).toHaveValue('');
130+
await expect(page.getByRole('button', { name: /Approve & Deposit/i })).toBeDisabled();
131+
});
132+
133+
test('deposit submit stays disabled when amount exceeds available USDC balance', async ({ page }) => {
134+
await goToConnectedVault(page);
135+
await page.getByLabel('Deposit amount').fill('999999');
136+
await expect(page.getByRole('button', { name: /Approve & Deposit/i })).toBeDisabled();
137+
await expect(page.getByRole('alert')).toContainText(/exceed/i);
138+
});
139+
140+
test('deposit is blocked when the vault is at capacity', async ({ page }) => {
141+
await page.route('**/mock-api/vault-summary.json', async (route) => {
142+
await route.fulfill({
143+
status: 200,
144+
contentType: 'application/json',
145+
body: JSON.stringify(vaultSummaryAtCapacity),
146+
});
147+
});
148+
await goToConnectedVault(page);
149+
await expect(page.getByText('Vault Capacity Reached')).toBeVisible();
150+
await expect(page.getByLabel('Deposit amount')).toBeDisabled();
151+
await expect(page.getByRole('button', { name: 'MAX' })).toBeDisabled();
152+
await expect(page.getByRole('button', { name: 'Vault is full' })).toBeDisabled();
153+
});
154+
155+
test('switching deposit/withdraw tabs clears the amount field', async ({ page }) => {
156+
await goToConnectedVault(page);
157+
await page.getByLabel('Deposit amount').fill('123.45');
158+
await page.getByRole('tab', { name: 'Withdraw', exact: true }).click();
159+
await expect(page.getByLabel('Withdrawal amount')).toHaveValue('');
160+
await page.getByRole('tab', { name: 'Deposit', exact: true }).click();
161+
await expect(page.getByLabel('Deposit amount')).toHaveValue('');
110162
});
111163

112164
test('disconnect button clears wallet state and shows connect button', async ({ page }) => {
113-
await page.goto('/');
114-
await expect(page.getByText(SHORT_ADDR)).toBeVisible({ timeout: 5000 });
165+
await goToConnectedVault(page);
115166

116167
// Disable the stub so the auto-connect effect does not re-fire after disconnect
117168
await page.evaluate(() => {

frontend/e2e/fixtures.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { test as base, type Page } from '@playwright/test';
22

33
// Inline fixture data — avoids JSON import attribute requirements across Node versions
4-
const vaultSummary = {
4+
export const vaultSummary = {
55
tvl: 12450800,
6+
depositCap: 15_000_000,
67
apy: 8.45,
78
participantCount: 1248,
89
monthlyGrowthPct: 12.5,
@@ -23,6 +24,12 @@ const vaultSummary = {
2324
},
2425
};
2526

27+
/** TVL at deposit cap — drives `isCapReached` in VaultContext (utilization >= 1). */
28+
export const vaultSummaryAtCapacity = {
29+
...vaultSummary,
30+
tvl: vaultSummary.depositCap,
31+
};
32+
2633
const portfolioHoldings = [
2734
{
2835
id: 'hold-1',
@@ -116,6 +123,34 @@ export async function interceptApiRoutes(page: Page) {
116123
body: JSON.stringify(portfolioHoldings),
117124
}),
118125
);
126+
127+
await page.route('**/horizon-testnet.stellar.org/accounts/**', async (route) => {
128+
if (route.request().method() !== 'GET') {
129+
await route.continue();
130+
return;
131+
}
132+
const pathname = new URL(route.request().url()).pathname;
133+
const accountId = pathname.split('/').filter(Boolean).pop() ?? 'unknown';
134+
await route.fulfill({
135+
status: 200,
136+
contentType: 'application/json',
137+
body: JSON.stringify({
138+
id: accountId,
139+
account_id: accountId,
140+
sequence: '12884901882',
141+
subentry_count: 0,
142+
balances: [
143+
{ asset_type: 'native', balance: '5.0000000' },
144+
{
145+
asset_type: 'credit_alphanum4',
146+
asset_code: 'USDC',
147+
asset_issuer: 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQLE2KKWY3NO',
148+
balance: '1250.5000000',
149+
},
150+
],
151+
}),
152+
});
153+
});
119154
}
120155

121156
/**

frontend/e2e/portfolio.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44
import { test, expect, interceptApiRoutes, stubFreighterConnected } from './fixtures';
55

6-
const MOCK_ADDRESS = 'GABC1TEST2STELLAR3ADDRESS4FAKE5XYZ6ABCDEFGHIJKLMNOPQRSTU';
6+
const MOCK_ADDRESS = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5';
77
const SHORT_ADDR = `${MOCK_ADDRESS.substring(0, 5)}...${MOCK_ADDRESS.substring(MOCK_ADDRESS.length - 4)}`;
88

99
test.describe('Portfolio page unauthenticated', () => {

frontend/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@
1111
"test": "vitest",
1212
"test:run": "vitest --run",
1313
"test:ui": "vitest --ui",
14-
"test:e2e": "playwright test",
15-
"test:e2e:ui": "playwright test --ui",
16-
"test:e2e:debug": "playwright test --debug",
14+
"test:e2e": "npm run build && playwright test",
15+
"test:e2e:ui": "npm run build && playwright test --ui",
16+
"test:e2e:debug": "npm run build && playwright test --debug",
1717
"docs:api": "typedoc --entryPointStrategy expand --out ../docs/api/frontend src",
1818
"check-size": "bundlesize"
1919
},

frontend/playwright.config.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ export default defineConfig({
2323
},
2424
],
2525
webServer: {
26-
// In CI the build step runs separately; here we only start the preview server.
2726
command: 'npm run preview',
2827
url: 'http://localhost:4173',
2928
reuseExistingServer: !process.env.CI,

frontend/src/components/VaultDashboard.test.tsx

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -131,16 +131,14 @@ describe("VaultDashboard", () => {
131131
fireEvent.click(button);
132132

133133
await waitFor(() => {
134-
expect(
135-
screen.getByText(/Waiting for confirmation\.\.\./i),
136-
).toBeInTheDocument();
134+
expect(screen.getByText(/Processing Transaction/i)).toBeInTheDocument();
137135
});
138136

139137
// Resolve the mocked API call
140138
resolveSubmit();
141139

142140
// Loading state should be visible while mutation is pending.
143-
expect(screen.getByText(/Waiting for confirmation\.\.\./i)).toBeInTheDocument();
141+
expect(screen.getByText(/Processing Transaction/i)).toBeInTheDocument();
144142
});
145143

146144
it("fills the input with max allowable amount via MAX button", async () => {
@@ -205,8 +203,6 @@ describe("VaultDashboard", () => {
205203
await waitFor(() => {
206204
expect(screen.getByRole("alert")).toHaveTextContent("Data unavailable");
207205
}, { timeout: 3000 });
208-
expect(screen.getByRole("alert")).toHaveTextContent(
209-
"We could not reach the server. Check your connection and try again.",
210-
);
206+
expect(screen.getByRole("alert")).toHaveTextContent("Failed to load vault data");
211207
});
212208
});

frontend/src/components/VaultDashboard.tsx

Lines changed: 5 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -141,10 +141,6 @@ const VaultDashboard: React.FC<VaultDashboardProps> = ({
141141
const toast = useToast();
142142
const [activeTab, setActiveTab] = useState<TransactionTab>("deposit");
143143
const [amount, setAmount] = useState("");
144-
const [isProcessing, setIsProcessing] = useState<"deposit" | "withdraw" | null>(null);
145-
const [pendingBalanceChange, setPendingBalanceChange] = useState(0);
146-
const [showWithdrawalConfirm, setShowWithdrawalConfirm] = useState(false);
147-
const [pendingWithdrawalAmount, setPendingWithdrawalAmount] = useState(0);
148144
const [touched, setTouched] =
149145
useState<Record<TransactionTab, boolean>>(INITIAL_TOUCHED_STATE);
150146

@@ -253,7 +249,6 @@ const VaultDashboard: React.FC<VaultDashboardProps> = ({
253249
} catch (err: unknown) {
254250
toast.error({
255251
title: "Transaction Failed",
256-
description: err instanceof Error ? err.message : "An error occurred during the transaction.",
257252
description:
258253
err instanceof Error
259254
? err.message
@@ -262,22 +257,8 @@ const VaultDashboard: React.FC<VaultDashboardProps> = ({
262257
}
263258
};
264259

265-
const handleWithdrawalCancel = () => {
266-
setShowWithdrawalConfirm(false);
267-
setPendingWithdrawalAmount(0);
268-
};
269-
270260
return (
271261
<div className="vault-dashboard gap-lg">
272-
<WithdrawalConfirmationModal
273-
isOpen={showWithdrawalConfirm}
274-
amount={pendingWithdrawalAmount}
275-
estimatedFee={estimatedUsdcFee}
276-
onConfirm={handleWithdrawalConfirm}
277-
onCancel={handleWithdrawalCancel}
278-
isProcessing={isProcessing === "withdraw"}
279-
/>
280-
281262
<div className="vault-dashboard-stats">
282263
<div className="glass-panel" style={{ padding: "32px" }}>
283264
{error && (
@@ -514,11 +495,14 @@ const VaultDashboard: React.FC<VaultDashboardProps> = ({
514495
</div>
515496
)}
516497

517-
<Tabs value={activeTab} defaultValue="deposit" onValueChange={(v) => setActiveTab(v as "deposit" | "withdraw")}>
518498
<Tabs
519499
value={activeTab}
520500
defaultValue="deposit"
521-
onValueChange={(value) => setActiveTab(value as TransactionTab)}
501+
onValueChange={(value) => {
502+
setActiveTab(value as TransactionTab);
503+
setAmount("");
504+
setTouched(INITIAL_TOUCHED_STATE);
505+
}}
522506
>
523507
<TabsList style={{ marginBottom: "24px" }}>
524508
<TabsTrigger value="deposit">Deposit</TabsTrigger>

0 commit comments

Comments
 (0)