feat(e2e): add NWC contract tests for get_*, hold, multi, and notifications#538
feat(e2e): add NWC contract tests for get_*, hold, multi, and notifications#538DSanich wants to merge 1 commit intogetAlby:masterfrom
Conversation
📝 WalkthroughWalkthroughThis PR adds 8 new E2E test files that exercise NWC (Nostr Wallet Connect) client functionality. Tests cover balance retrieval, budget queries, hold invoice operations, multi-pay flows, and payment notifications. Each test creates funded test wallets, validates supported methods via Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~22 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@rolznz Hi! I continued my work on e2e tests and prepared this PR. I hope it will be as useful as the previous ones. |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (2)
e2e/nwc-multi-pay-invoice.test.ts (1)
57-61: Relax stricterrorsexpectation to reduce E2E flakiness.Line 59 requires zero per-item errors, which is brittle in network-backed runs. Prefer contract-shape checks plus success presence.
Proposed assertion adjustment
expect(Array.isArray(multiPayResult.invoices)).toBe(true); expect(multiPayResult.invoices.length).toBe(2); - expect(multiPayResult.errors).toEqual([]); - expect(multiPayResult.invoices[0].preimage).toBeDefined(); - expect(multiPayResult.invoices[1].preimage).toBeDefined(); + expect(Array.isArray(multiPayResult.errors)).toBe(true); + expect( + multiPayResult.invoices.filter((invoice) => invoice.preimage).length, + ).toBeGreaterThan(0);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@e2e/nwc-multi-pay-invoice.test.ts` around lines 57 - 61, The test is brittle because it asserts multiPayResult.errors strictly equals an empty array; instead, verify the contract shape and presence of success: ensure multiPayResult.errors is an array (use Array.isArray on multiPayResult.errors) and that its length matches multiPayResult.invoices.length to keep the per-item error array shape, while keeping the existing checks that multiPayResult.invoices[0].preimage and multiPayResult.invoices[1].preimage are defined to assert success presence.e2e/nwc-multi-pay-keysend.test.ts (1)
51-55: Use contract-safe assertions for multi-keysend results.Line 53’s
errors === []check is fragile for E2E network variability. Assert shape and successful results instead.Proposed assertion adjustment
expect(Array.isArray(multiPayResult.keysends)).toBe(true); expect(multiPayResult.keysends.length).toBe(2); - expect(multiPayResult.errors).toEqual([]); - expect(multiPayResult.keysends[0].preimage).toBeDefined(); - expect(multiPayResult.keysends[1].preimage).toBeDefined(); + expect(Array.isArray(multiPayResult.errors)).toBe(true); + expect( + multiPayResult.keysends.filter((keysend) => keysend.preimage).length, + ).toBeGreaterThan(0);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@e2e/nwc-multi-pay-keysend.test.ts` around lines 51 - 55, The test currently asserts multiPayResult.errors equals an empty array which is fragile; instead assert the result shape and per-entry success: verify multiPayResult.keysends is an array of length 2, verify multiPayResult.errors is an array (type check) and that every entry in multiPayResult.keysends has no per-entry error and has a defined preimage (e.g. check each keysend object for absence/falsey of an error field and that preimage is defined). Use the existing symbols multiPayResult, keysends, and errors to locate and update the assertions so they assert type/shape and per-keysend success rather than strict equality to [].
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@e2e/nwc-get-balance.test.ts`:
- Line 23: The assertion using exact equality on balanceResult.balance vs
BALANCE_MSATS is too strict for a network-backed faucet; change the assertion to
allow a small tolerance or a minimum bound instead of exact match. Update the
test that contains expect(balanceResult.balance).toBe(BALANCE_MSATS) to assert a
range (e.g., greater-than-or-equal to BALANCE_MSATS minus a small delta, or use
toBeGreaterThanOrEqual with a tolerance variable) so BALANCE_MSATS and
balanceResult.balance are compared with a tolerance to avoid flakiness.
In `@e2e/nwc-notifications-payment-received.test.ts`:
- Around line 46-73: The test races because senderClient.payInvoice is called
before the subscription established by receiverClient.subscribeNotifications is
fully active; update subscribeNotifications to provide a readiness signal (e.g.,
return or include a Promise that resolves once the background subscription/loop
has registered the subscription) and then change this test to await that
readiness before calling senderClient.payInvoice; specifically, ensure
receiverClient.subscribeNotifications resolves or exposes a "ready" Promise that
the test awaits prior to invoking senderClient.payInvoice so the notification
cannot be missed.
In `@e2e/nwc-settle-hold-invoice.test.ts`:
- Around line 50-66: The test spawns payPromise via senderClient.payInvoice and
may leave it unawaited on failure, causing unhandled rejections; ensure
payPromise is always awaited or has a rejection handler: keep creating
payPromise, then after calling receiverClient.settleHoldInvoice (or in the catch
block) await payPromise (or await payPromise.catch(() => {})) so the payment
promise is resolved or its rejection is handled in all paths (referencing
payPromise, senderClient.payInvoice, receiverClient.settleHoldInvoice, and the
existing try/catch/finally flow) to avoid leaked unhandled rejections.
---
Nitpick comments:
In `@e2e/nwc-multi-pay-invoice.test.ts`:
- Around line 57-61: The test is brittle because it asserts
multiPayResult.errors strictly equals an empty array; instead, verify the
contract shape and presence of success: ensure multiPayResult.errors is an array
(use Array.isArray on multiPayResult.errors) and that its length matches
multiPayResult.invoices.length to keep the per-item error array shape, while
keeping the existing checks that multiPayResult.invoices[0].preimage and
multiPayResult.invoices[1].preimage are defined to assert success presence.
In `@e2e/nwc-multi-pay-keysend.test.ts`:
- Around line 51-55: The test currently asserts multiPayResult.errors equals an
empty array which is fragile; instead assert the result shape and per-entry
success: verify multiPayResult.keysends is an array of length 2, verify
multiPayResult.errors is an array (type check) and that every entry in
multiPayResult.keysends has no per-entry error and has a defined preimage (e.g.
check each keysend object for absence/falsey of an error field and that preimage
is defined). Use the existing symbols multiPayResult, keysends, and errors to
locate and update the assertions so they assert type/shape and per-keysend
success rather than strict equality to [].
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 88e39087-4e38-4eb9-94cf-15750f2374ac
📒 Files selected for processing (9)
e2e/nwc-cancel-hold-invoice.test.tse2e/nwc-get-balance.test.tse2e/nwc-get-budget.test.tse2e/nwc-get-info.test.tse2e/nwc-make-hold-invoice.test.tse2e/nwc-multi-pay-invoice.test.tse2e/nwc-multi-pay-keysend.test.tse2e/nwc-notifications-payment-received.test.tse2e/nwc-settle-hold-invoice.test.ts
|
|
||
| expect(balanceResult.balance).toBeDefined(); | ||
| expect(typeof balanceResult.balance).toBe("number"); | ||
| expect(balanceResult.balance).toBe(BALANCE_MSATS); |
There was a problem hiding this comment.
Avoid exact faucet-balance equality in E2E assertion.
Line 23 is overly strict for a network-backed wallet; faucet-provisioned balances can deviate from the requested amount and make this test flaky.
Proposed assertion change
- expect(balanceResult.balance).toBe(BALANCE_MSATS);
+ expect(balanceResult.balance).toBeGreaterThan(0);
+ expect(balanceResult.balance).toBeLessThanOrEqual(BALANCE_MSATS);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| expect(balanceResult.balance).toBe(BALANCE_MSATS); | |
| expect(balanceResult.balance).toBeGreaterThan(0); | |
| expect(balanceResult.balance).toBeLessThanOrEqual(BALANCE_MSATS); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@e2e/nwc-get-balance.test.ts` at line 23, The assertion using exact equality
on balanceResult.balance vs BALANCE_MSATS is too strict for a network-backed
faucet; change the assertion to allow a small tolerance or a minimum bound
instead of exact match. Update the test that contains
expect(balanceResult.balance).toBe(BALANCE_MSATS) to assert a range (e.g.,
greater-than-or-equal to BALANCE_MSATS minus a small delta, or use
toBeGreaterThanOrEqual with a tolerance variable) so BALANCE_MSATS and
balanceResult.balance are compared with a tolerance to avoid flakiness.
| const receivedNotification = new Promise<Nip47Notification>( | ||
| (resolve, reject) => { | ||
| const timeout = setTimeout(() => { | ||
| reject(new Error("Timed out waiting for payment notification")); | ||
| }, 20_000); | ||
|
|
||
| receiverClient | ||
| .subscribeNotifications( | ||
| (notification) => { | ||
| if (notification.notification.invoice !== invoiceResult.invoice) { | ||
| return; | ||
| } | ||
| clearTimeout(timeout); | ||
| resolve(notification); | ||
| }, | ||
| ["payment_received" as Nip47NotificationType], | ||
| ) | ||
| .then((unsub) => { | ||
| unsubscribe = unsub; | ||
| }) | ||
| .catch((error) => { | ||
| clearTimeout(timeout); | ||
| reject(error); | ||
| }); | ||
| }, | ||
| ); | ||
|
|
||
| await senderClient.payInvoice({ invoice: invoiceResult.invoice }); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
rg -n "async subscribeNotifications|\\(async \\(\\) =>|return \\(\\) =>" src/nwc/NWCClient.ts -C4Repository: getAlby/js-sdk
Length of output: 1351
Subscription race can make this test flaky.
Line 73 executes the payment before the relay subscription is fully active. subscribeNotifications() returns the unsubscribe function immediately (line 795 in NWCClient.ts), but the background async loop that establishes the connection (lines 716–720) is still pending. The payment can arrive before the subscription is ready, causing the notification to be missed and the test to timeout.
Proposed mitigation
const receivedNotification = new Promise<Nip47Notification>(
(resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Timed out waiting for payment notification"));
}, 20_000);
receiverClient
.subscribeNotifications(
(notification) => {
if (notification.notification.invoice !== invoiceResult.invoice) {
return;
}
clearTimeout(timeout);
resolve(notification);
},
["payment_received" as Nip47NotificationType],
)
.then((unsub) => {
unsubscribe = unsub;
})
.catch((error) => {
clearTimeout(timeout);
reject(error);
});
},
);
+ // Give the background relay subscription loop time to attach.
+ await new Promise((resolve) => setTimeout(resolve, 500));
await senderClient.payInvoice({ invoice: invoiceResult.invoice });📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const receivedNotification = new Promise<Nip47Notification>( | |
| (resolve, reject) => { | |
| const timeout = setTimeout(() => { | |
| reject(new Error("Timed out waiting for payment notification")); | |
| }, 20_000); | |
| receiverClient | |
| .subscribeNotifications( | |
| (notification) => { | |
| if (notification.notification.invoice !== invoiceResult.invoice) { | |
| return; | |
| } | |
| clearTimeout(timeout); | |
| resolve(notification); | |
| }, | |
| ["payment_received" as Nip47NotificationType], | |
| ) | |
| .then((unsub) => { | |
| unsubscribe = unsub; | |
| }) | |
| .catch((error) => { | |
| clearTimeout(timeout); | |
| reject(error); | |
| }); | |
| }, | |
| ); | |
| await senderClient.payInvoice({ invoice: invoiceResult.invoice }); | |
| const receivedNotification = new Promise<Nip47Notification>( | |
| (resolve, reject) => { | |
| const timeout = setTimeout(() => { | |
| reject(new Error("Timed out waiting for payment notification")); | |
| }, 20_000); | |
| receiverClient | |
| .subscribeNotifications( | |
| (notification) => { | |
| if (notification.notification.invoice !== invoiceResult.invoice) { | |
| return; | |
| } | |
| clearTimeout(timeout); | |
| resolve(notification); | |
| }, | |
| ["payment_received" as Nip47NotificationType], | |
| ) | |
| .then((unsub) => { | |
| unsubscribe = unsub; | |
| }) | |
| .catch((error) => { | |
| clearTimeout(timeout); | |
| reject(error); | |
| }); | |
| }, | |
| ); | |
| // Give the background relay subscription loop time to attach. | |
| await new Promise((resolve) => setTimeout(resolve, 500)); | |
| await senderClient.payInvoice({ invoice: invoiceResult.invoice }); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@e2e/nwc-notifications-payment-received.test.ts` around lines 46 - 73, The
test races because senderClient.payInvoice is called before the subscription
established by receiverClient.subscribeNotifications is fully active; update
subscribeNotifications to provide a readiness signal (e.g., return or include a
Promise that resolves once the background subscription/loop has registered the
subscription) and then change this test to await that readiness before calling
senderClient.payInvoice; specifically, ensure
receiverClient.subscribeNotifications resolves or exposes a "ready" Promise that
the test awaits prior to invoking senderClient.payInvoice so the notification
cannot be missed.
| const payPromise = senderClient.payInvoice({ invoice: holdInvoice.invoice }); | ||
| await new Promise((resolve) => setTimeout(resolve, 1500)); | ||
|
|
||
| const settleResult = await receiverClient.settleHoldInvoice({ | ||
| preimage: preimageHex, | ||
| }); | ||
| expect(settleResult).toEqual({}); | ||
|
|
||
| const payResult = await payPromise; | ||
| expect(payResult.preimage).toBe(preimageHex); | ||
| } catch (error) { | ||
| expect(error).toBeInstanceOf(Nip47WalletError); | ||
| expect((error as Nip47WalletError).code).toBe("NOT_IMPLEMENTED"); | ||
| } finally { | ||
| receiverClient.close(); | ||
| senderClient.close(); | ||
| } |
There was a problem hiding this comment.
payPromise handling is race-prone and can leak unhandled rejections.
Lines 50–59 rely on a fixed sleep and only await payPromise on success. If settlement fails first, the spawned payment promise may reject unobserved.
Proposed stabilization
- const payPromise = senderClient.payInvoice({ invoice: holdInvoice.invoice });
- await new Promise((resolve) => setTimeout(resolve, 1500));
-
- const settleResult = await receiverClient.settleHoldInvoice({
- preimage: preimageHex,
- });
+ const payPromise = senderClient.payInvoice({ invoice: holdInvoice.invoice });
+ let settleResult: Record<string, never> | undefined;
+ for (let attempt = 0; attempt < 10; attempt++) {
+ try {
+ settleResult = await receiverClient.settleHoldInvoice({
+ preimage: preimageHex,
+ });
+ break;
+ } catch (error) {
+ if (
+ error instanceof Nip47WalletError &&
+ error.code === "NOT_FOUND" &&
+ attempt < 9
+ ) {
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ continue;
+ }
+ throw error;
+ }
+ }
+ expect(settleResult).toEqual({});
- expect(settleResult).toEqual({});
-
const payResult = await payPromise;
expect(payResult.preimage).toBe(preimageHex);
} catch (error) {
expect(error).toBeInstanceOf(Nip47WalletError);
expect((error as Nip47WalletError).code).toBe("NOT_IMPLEMENTED");
} finally {
+ // ensure spawned payment promise is always observed
+ await Promise.resolve().catch(() => undefined);
receiverClient.close();
senderClient.close();
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const payPromise = senderClient.payInvoice({ invoice: holdInvoice.invoice }); | |
| await new Promise((resolve) => setTimeout(resolve, 1500)); | |
| const settleResult = await receiverClient.settleHoldInvoice({ | |
| preimage: preimageHex, | |
| }); | |
| expect(settleResult).toEqual({}); | |
| const payResult = await payPromise; | |
| expect(payResult.preimage).toBe(preimageHex); | |
| } catch (error) { | |
| expect(error).toBeInstanceOf(Nip47WalletError); | |
| expect((error as Nip47WalletError).code).toBe("NOT_IMPLEMENTED"); | |
| } finally { | |
| receiverClient.close(); | |
| senderClient.close(); | |
| } | |
| const payPromise = senderClient.payInvoice({ invoice: holdInvoice.invoice }); | |
| let settleResult: Record<string, never> | undefined; | |
| for (let attempt = 0; attempt < 10; attempt++) { | |
| try { | |
| settleResult = await receiverClient.settleHoldInvoice({ | |
| preimage: preimageHex, | |
| }); | |
| break; | |
| } catch (error) { | |
| if ( | |
| error instanceof Nip47WalletError && | |
| error.code === "NOT_FOUND" && | |
| attempt < 9 | |
| ) { | |
| await new Promise((resolve) => setTimeout(resolve, 500)); | |
| continue; | |
| } | |
| throw error; | |
| } | |
| } | |
| expect(settleResult).toEqual({}); | |
| const payResult = await payPromise; | |
| expect(payResult.preimage).toBe(preimageHex); | |
| } catch (error) { | |
| expect(error).toBeInstanceOf(Nip47WalletError); | |
| expect((error as Nip47WalletError).code).toBe("NOT_IMPLEMENTED"); | |
| } finally { | |
| // ensure spawned payment promise is always observed | |
| await Promise.resolve().catch(() => undefined); | |
| receiverClient.close(); | |
| senderClient.close(); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@e2e/nwc-settle-hold-invoice.test.ts` around lines 50 - 66, The test spawns
payPromise via senderClient.payInvoice and may leave it unawaited on failure,
causing unhandled rejections; ensure payPromise is always awaited or has a
rejection handler: keep creating payPromise, then after calling
receiverClient.settleHoldInvoice (or in the catch block) await payPromise (or
await payPromise.catch(() => {})) so the payment promise is resolved or its
rejection is handled in all paths (referencing payPromise,
senderClient.payInvoice, receiverClient.settleHoldInvoice, and the existing
try/catch/finally flow) to avoid leaked unhandled rejections.
Summary
Summary by CodeRabbit