Skip to content

Commit cd0fb34

Browse files
pepakrizclaude
andcommitted
test(e2e): cover the donation flow with Yadio/LNURL mocks
The donation flow calls Yadio and LNURL directly from the route components (no reconciliationClaim involved) and polls the LNURL verify endpoint from the UI, all via real browser fetch() with no injectable dependency. Add donation-mocks.ts's mockDonationApis(), which uses Playwright page.route() to intercept the exchange-rate, pay-request, callback, and verify calls (plus the donation-history endpoint) so the flow is deterministic end to end, including reaching the "paid" screen via setSettled(true). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ZcjwWJ8dUzzhmeeTK7XcG
1 parent d4d9f2f commit cd0fb34

2 files changed

Lines changed: 206 additions & 0 deletions

File tree

e2e/donation-mocks.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import type { Page } from "@playwright/test"
2+
3+
const YADIO_EXRATES_URL = "https://api.yadio.io/exrates/**"
4+
const LNURL_METADATA_URL = "https://payky.me/.well-known/lnurlp/donate"
5+
const LNURL_CALLBACK_URL = "https://donate.e2e.test/callback"
6+
const LNURL_VERIFY_URL = "https://donate.e2e.test/verify/1"
7+
const DEFAULT_INVOICE =
8+
"lnbc1e2edonationinvoice0000000000000000000000000000000000000000"
9+
10+
export interface DonationMockOptions {
11+
/** Fiat-per-BTC rate the mocked Yadio response reports. */
12+
readonly exchangeRate?: number
13+
readonly minSendableSats?: number
14+
readonly maxSendableSats?: number
15+
/** The `pr` (bolt11) string the mocked LNURL callback returns. */
16+
readonly invoice?: string
17+
/** Whether the mocked verify endpoint reports the invoice as settled from the start. */
18+
readonly settled?: boolean
19+
}
20+
21+
export interface DonationMocks {
22+
/** Flips the mocked LNURL verify endpoint's `settled` flag for the next poll. */
23+
setSettled: (settled: boolean) => void
24+
}
25+
26+
/**
27+
* Intercepts every HTTP call the donation flow makes — Yadio's exchange
28+
* rate, the LNURL pay-request/callback/verify trio, and the donation
29+
* history endpoint — so `e2e/donation.spec.ts` doesn't depend on any real
30+
* external service. The LNURL callback/verify URLs point at a non-resolving
31+
* `donate.e2e.test` domain; Playwright's route interception happens before
32+
* DNS resolution, so that's fine as long as every URL it returns is also
33+
* routed here.
34+
*/
35+
export async function mockDonationApis(
36+
page: Page,
37+
options: DonationMockOptions = {}
38+
): Promise<DonationMocks> {
39+
const exchangeRate = options.exchangeRate ?? 1_500_000
40+
const minSendableSats = options.minSendableSats ?? 1
41+
const maxSendableSats = options.maxSendableSats ?? 1_000_000
42+
const invoice = options.invoice ?? DEFAULT_INVOICE
43+
let settled = options.settled ?? false
44+
45+
await page.route(YADIO_EXRATES_URL, async (route) => {
46+
await route.fulfill({
47+
json: { BTC: exchangeRate, timestamp: Date.now() },
48+
})
49+
})
50+
51+
await page.route(LNURL_METADATA_URL, async (route) => {
52+
await route.fulfill({
53+
json: {
54+
tag: "payRequest",
55+
callback: LNURL_CALLBACK_URL,
56+
minSendable: minSendableSats * 1_000,
57+
maxSendable: maxSendableSats * 1_000,
58+
metadata: JSON.stringify([["text/plain", "Donate to Payky"]]),
59+
},
60+
})
61+
})
62+
63+
await page.route(`${LNURL_CALLBACK_URL}**`, async (route) => {
64+
await route.fulfill({
65+
json: { pr: invoice, routes: [], verify: LNURL_VERIFY_URL },
66+
})
67+
})
68+
69+
await page.route(`${LNURL_VERIFY_URL}**`, async (route) => {
70+
await route.fulfill({
71+
json: {
72+
status: "OK",
73+
settled,
74+
preimage: settled ? "e2e-mock-preimage" : null,
75+
pr: invoice,
76+
},
77+
})
78+
})
79+
80+
await page.route("**/api/donations**", async (route) => {
81+
await route.fulfill({ json: { items: [], nextCursor: null } })
82+
})
83+
84+
return {
85+
setSettled: (value) => {
86+
settled = value
87+
},
88+
}
89+
}

e2e/donation.spec.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import type { Page } from "@playwright/test"
2+
import { mockDonationApis } from "./donation-mocks.ts"
3+
import { expect, test, translate } from "./fixtures.ts"
4+
5+
// Not gotoPage(): the donations page's heading ("Donations") is a substring
6+
// match of the donation-history empty state's heading ("No donations yet"),
7+
// which makes gotoPage's non-exact getByRole ambiguous.
8+
async function openDonationsPage(page: Page): Promise<void> {
9+
await page.goto("/settings/donations", { waitUntil: "domcontentloaded" })
10+
await page
11+
.getByRole("heading", {
12+
name: translate("en", "settings.donations.title"),
13+
exact: true,
14+
})
15+
.waitFor()
16+
}
17+
18+
test("create a donation invoice then simulate the incoming payment", async ({
19+
seededPage: page,
20+
}) => {
21+
const donationMocks =
22+
await test.step("mock Yadio, LNURL, and donation history APIs", () =>
23+
mockDonationApis(page))
24+
25+
await test.step("open the donations page", () => openDonationsPage(page))
26+
27+
await test.step("enter a sats amount and create the invoice", async () => {
28+
await page
29+
.getByRole("textbox", {
30+
name: translate("en", "settings.donations.sats.label"),
31+
})
32+
.fill("1000")
33+
await page
34+
.getByRole("button", {
35+
name: translate("en", "settings.donations.create"),
36+
})
37+
.click()
38+
await page
39+
.getByRole("heading", {
40+
name: translate("en", "settings.donations.invoice.title"),
41+
})
42+
.waitFor()
43+
})
44+
45+
await test.step("verify the invoice QR renders and payment is awaited", async () => {
46+
await expect(
47+
page
48+
.getByRole("button", {
49+
name: translate("en", "settings.donations.invoice.copy"),
50+
})
51+
.locator("svg")
52+
).toBeVisible()
53+
await expect(
54+
page.getByText(
55+
translate("en", "settings.donations.invoice.verify.waiting")
56+
)
57+
).toBeVisible()
58+
})
59+
60+
await test.step("simulate the incoming Lightning payment", async () => {
61+
donationMocks.setSettled(true)
62+
await expect(
63+
page.getByText(translate("en", "settings.donations.invoice.verify.paid"))
64+
).toBeVisible()
65+
})
66+
67+
await test.step("return to settings", async () => {
68+
// `nativeButton={false}` on this Button makes Base UI force role="button"
69+
// onto the rendered <Link>, so it's not `getByRole("link", ...)`.
70+
await page
71+
.getByRole("button", {
72+
name: translate("en", "settings.donations.invoice.backToSettings"),
73+
})
74+
.click()
75+
await page
76+
.getByRole("heading", { name: translate("en", "settings.title") })
77+
.waitFor()
78+
})
79+
})
80+
81+
test("donation amount is validated against the LNURL sendable range", async ({
82+
seededPage: page,
83+
}) => {
84+
await test.step("mock Yadio, LNURL, and donation history APIs", () =>
85+
mockDonationApis(page, { minSendableSats: 100, maxSendableSats: 500 }))
86+
87+
await test.step("open the donations page", () => openDonationsPage(page))
88+
89+
await test.step("enter an amount above the allowed range", async () => {
90+
await page
91+
.getByRole("textbox", {
92+
name: translate("en", "settings.donations.sats.label"),
93+
})
94+
.fill("999")
95+
await expect(
96+
page.getByText(translate("en", "settings.donations.amount.range"))
97+
).toBeVisible()
98+
await expect(
99+
page.getByRole("button", {
100+
name: translate("en", "settings.donations.create"),
101+
})
102+
).toBeDisabled()
103+
})
104+
105+
await test.step("enter an amount inside the allowed range", async () => {
106+
await page
107+
.getByRole("textbox", {
108+
name: translate("en", "settings.donations.sats.label"),
109+
})
110+
.fill("200")
111+
await expect(
112+
page.getByRole("button", {
113+
name: translate("en", "settings.donations.create"),
114+
})
115+
).toBeEnabled()
116+
})
117+
})

0 commit comments

Comments
 (0)