Skip to content

feat(e2e): add NWC contract tests for get_*, hold, multi, and notifications#538

Open
DSanich wants to merge 1 commit intogetAlby:masterfrom
DSanich:feat/nwc-api-contract-e2e-tests
Open

feat(e2e): add NWC contract tests for get_*, hold, multi, and notifications#538
DSanich wants to merge 1 commit intogetAlby:masterfrom
DSanich:feat/nwc-api-contract-e2e-tests

Conversation

@DSanich
Copy link
Contributor

@DSanich DSanich commented Mar 26, 2026

Summary

  • Added a new NWC API contract e2e test suite to extend coverage beyond payment-only flows.
  • Covered get_info, get_budget, get_balance, hold-invoice methods (make, settle, cancel), multi-payment methods (multi_pay_invoice, multi_pay_keysend), and notifications (payment_received).
  • Improved assertions for wallet behavior variability by validating contract-safe outcomes (e.g. NOT_IMPLEMENTED, and NOT_FOUND for cancel_hold_invoice in current faucet environment).

Summary by CodeRabbit

  • Tests
    • Added comprehensive end-to-end test coverage for Nostr Wallet Connect (NWC) operations, including balance retrieval, budget management, hold invoice creation and settlement, multi-payment capabilities, and notification delivery.
    • Enhanced test suite validates wallet capability detection and error handling across supported and unsupported operations.

@coderabbitai
Copy link

coderabbitai bot commented Mar 26, 2026

📝 Walkthrough

Walkthrough

This 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 getInfo(), invokes the target method, and asserts expected behavior or error handling.

Changes

Cohort / File(s) Summary
Info & Balance Queries
e2e/nwc-get-info.test.ts, e2e/nwc-get-balance.test.ts, e2e/nwc-get-budget.test.ts
Tests for wallet information retrieval, balance queries, and budget data. Each validates expected response fields or NOT_IMPLEMENTED error if unsupported.
Hold Invoice Operations
e2e/nwc-make-hold-invoice.test.ts, e2e/nwc-cancel-hold-invoice.test.ts, e2e/nwc-settle-hold-invoice.test.ts
Tests for creating, canceling, and settling hold invoices. Each checks support via getInfo() and validates invoice creation/settlement with preimage and payment hash handling.
Multi-pay Operations
e2e/nwc-multi-pay-invoice.test.ts, e2e/nwc-multi-pay-keysend.test.ts
Tests for batch payment operations using dual test wallets. Each validates multi-payment responses and handles NOT_IMPLEMENTED gracefully.
Notifications
e2e/nwc-notifications-payment-received.test.ts
Tests subscription to payment_received notifications with dual wallets. Validates notification delivery when sender pays receiver-created invoice.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

Suggested reviewers

  • rolznz

Poem

🐰 E2E tests hopping into the NWC fray,
Hold invoices settled in a whimsical way,
Multi-pay keysends dance round and round,
Notifications chime with a delightful sound,
Testing the wallet in rabbit-approved flight! 🚀

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changeset: adding comprehensive E2E tests for NWC API covering get_*, hold invoice methods, multi-payment methods, and notifications.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@DSanich
Copy link
Contributor Author

DSanich commented Mar 26, 2026

@rolznz Hi! I continued my work on e2e tests and prepared this PR. I hope it will be as useful as the previous ones.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
e2e/nwc-multi-pay-invoice.test.ts (1)

57-61: Relax strict errors expectation 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

📥 Commits

Reviewing files that changed from the base of the PR and between 08a6cd7 and 3f50a0f.

📒 Files selected for processing (9)
  • e2e/nwc-cancel-hold-invoice.test.ts
  • e2e/nwc-get-balance.test.ts
  • e2e/nwc-get-budget.test.ts
  • e2e/nwc-get-info.test.ts
  • e2e/nwc-make-hold-invoice.test.ts
  • e2e/nwc-multi-pay-invoice.test.ts
  • e2e/nwc-multi-pay-keysend.test.ts
  • e2e/nwc-notifications-payment-received.test.ts
  • e2e/nwc-settle-hold-invoice.test.ts


expect(balanceResult.balance).toBeDefined();
expect(typeof balanceResult.balance).toBe("number");
expect(balanceResult.balance).toBe(BALANCE_MSATS);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
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.

Comment on lines +46 to +73
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 });
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "async subscribeNotifications|\\(async \\(\\) =>|return \\(\\) =>" src/nwc/NWCClient.ts -C4

Repository: 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.

Suggested change
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.

Comment on lines +50 to +66
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();
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant