diff --git a/issues/todo-test-coverage-gaps.md b/issues/todo-test-coverage-gaps.md index 8026c544..381304e7 100644 --- a/issues/todo-test-coverage-gaps.md +++ b/issues/todo-test-coverage-gaps.md @@ -3,101 +3,299 @@ **Source:** Test coverage analysis (Feb 2026) **Context:** -Several areas of the codebase lack unit test coverage. This document tracks the remaining gaps after the initial test coverage expansion. +Several areas of the codebase lack unit test coverage. This document tracks the remaining gaps after the initial test coverage expansion. Items are ordered by risk/impact. --- -## High Priority +## P0 — Financial Correctness (js/payments/) -### Storage Layer -Files with no tests that handle critical upload/storage functionality: +These files handle real money (Lightning/zap payments). Bugs here directly affect user funds. **No existing tests except zapSplit, zapSharedState, and nwcClient.** -- [ ] `js/storage/s3-multipart.js` - Multipart upload logic, bucket management -- [ ] `js/storage/s3-client.js` - S3 client wrapper, SDK loading -- [ ] `js/storage/r2-mgmt.js` - R2 bucket management operations -- [ ] `js/storage/r2-s3.js` - R2/S3 integration layer -- [ ] `js/services/s3UploadService.js` - Upload orchestration and progress tracking +### [x] `js/payments/lnurl.js` — LNURL protocol + bech32 encoding +**Exports:** `encodeLnurlBech32`, `resolveLightningAddress`, `fetchPayServiceData`, `validateInvoiceAmount`, `requestInvoice` +**Testing helpers via `__TESTING__`:** `bech32Encode`, `bech32Decode`, `decodeLnurlBech32`, `convertWords`, `createChecksum` +**Dependencies:** TextEncoder/TextDecoder, fetch API +**Test cases needed:** +- bech32 encode/decode roundtrip for various URLs +- `resolveLightningAddress`: lnurl-encoded input, email format (`user@domain`), HTTPS URL, invalid/empty input, mixed-case rejection +- `fetchPayServiceData`: successful fetch, HTTP errors (404/500), invalid JSON, missing callback, error status in response body, invalid metadata JSON gracefully skipped +- `validateInvoiceAmount`: within limits, below min, above max, zero/negative, non-numeric +- `requestInvoice`: valid retrieval, comment truncation based on `commentAllowed`, both `amountSats` and `amountMsats`, missing invoice in response, error response -**Why:** These are user-facing upload paths. Failures here directly impact content creators. +### [x] `js/payments/zapReceiptValidator.js` — Validates zap receipts from relays +**Exports:** `validateZapReceipt(context, overrides)` +**Returns:** `{status: "passed"|"failed"|"skipped", reason, event, checkedRelays}` +**Dependencies:** nostr-tools (SimplePool, event validation), bech32, sha256, bytesToHex +**Test cases needed:** +- Missing/invalid zap request JSON → status:"skipped" +- Valid zap request but no relays specified → status:"skipped" +- Invoice description hash extraction and SHA256 matching +- Amount validation (invoice amount vs expected share, with tolerance) +- Receipt found with matching pubkey and valid signature → status:"passed" +- Receipt found with wrong pubkey → status:"failed" +- No receipt found on relays → status:"failed" +- Override injection for `createPool`, `listEvents`, `decodeDescriptionHash`, `getAmountFromBolt11` +- BOLT11 parsing: valid with description hash tag "h", without it, truncated/invalid + +### [x] `js/payments/platformFee.js` — Fee percentage parsing and clamping +**Exports:** `parsePercentValue`, `clampPercent`, `getDefaultPlatformFeePercent`, `resolvePlatformFeePercent` +**Dependencies:** `../config.js` (PLATFORM_FEE_PERCENT) +**Test cases needed:** +- `parsePercentValue`: integers, floats, bigints, string percentages ("50%"), string fractions ("10/100"), division by zero, empty strings, whitespace +- `clampPercent`: 0, 50, 100, >100, <0, NaN, non-numeric → 0 +- `getDefaultPlatformFeePercent`: valid config, invalid config → 0 +- `resolvePlatformFeePercent`: with override (uses override), without (uses default), null/undefined → default + +### [x] `js/payments/zapRequests.js` — Zap request event creation and publishing +**Exports:** `resolveZapRecipient`, `buildZapRequestPayload`, `signZapRequest`, `publishZapRequest`, `createAndPublishZapRequest` +**Testing helpers via `__TESTING__`:** `normalizeRelayList`, `resolveLnurlTag` +**Dependencies:** lnurl.js, nostrEventSchemas, nostrPublish, signRequestQueue, RELAY_URLS +**Test cases needed:** +- `buildZapRequestPayload`: all params, minimal params, created_at override, additionalTags passthrough → verify kind:9734 event structure +- `signZapRequest`: valid signer, timeout scenario +- `publishZapRequest`: relay acceptance, relay rejection → throws +- `createAndPublishZapRequest`: full flow, recipient doesn't support Nostr zaps, invoice fetch failure (non-fatal) +- `normalizeRelayList`: deduplication, empty list + +### [ ] `js/payments/zapNotifications.js` — UI notifications for zap events +**Exports:** `showLoginRequiredToZapNotification`, `syncNotificationPortalVisibility` +**Dependencies:** DOM APIs, applicationContext, zapMessages +**Test cases needed:** +- With app context (showStatus available) +- Fallback to showError +- Without app context (creates DOM portal elements) +- Auto-hide timing (configurable `autoHideMs`) +- Portal visibility sync based on banner state +- Multiple calls don't duplicate elements +- Accessibility attributes (aria-live, aria-label) + +### [ ] `js/payments/platformAddress.js` — Platform Lightning address from admin profile +**Exports:** `getPlatformLightningAddress`, `__resetPlatformAddressCache` +**Dependencies:** config (PLATFORM_LUD16_OVERRIDE, ADMIN_SUPER_NPUB), nostrClient, nostr-tools (nip19) +**Test cases needed:** +- PLATFORM_LUD16_OVERRIDE set → returns immediately +- No override: fetches admin kind:0 profile, extracts lud16 or lud06 +- Cache TTL (10 min): fresh → returns cached, expired → re-fetches +- forceRefresh bypasses cache +- Missing ADMIN_SUPER_NPUB → null +- Relay failures → null (logged) +- nostr-tools unavailable → fallback handling --- -## Medium Priority +## P0 — Identity/Feed-Critical Utilities -### DM Components -The `js/ui/dm/` directory lacks unit tests: +### [ ] `js/utils/hex.js` — Hex normalization for Nostr pubkeys +**Exports:** `normalizeHexString`, `normalizeHexId`, `normalizeHexPubkey` +**Dependencies:** None (pure utility) +**Why P0:** Incorrect hex normalization breaks identity lookups silently. +**Test cases needed:** +- Valid 64-char hex → lowercase trimmed +- Mixed case → normalized +- Whitespace → trimmed +- Non-string inputs → graceful handling +- Empty string, null, undefined -- [ ] `ConversationList.js` - Conversation list rendering -- [ ] `MessageThread.js` - Message thread display -- [ ] `MessageBubble.js` - Individual message rendering -- [ ] `Composer.js` - Message composition UI -- [ ] `ZapInterface.js` - Zap sending interface -- [ ] `NotificationCenter.js` - DM notification handling -- [ ] `Avatar.js` - Profile avatar display -- [ ] `ContactRow.js` - Contact list item -- [ ] `DMPrivacySettings.js` - Privacy settings UI -- [ ] `AppShell.js` - DM app shell layout - -**Why:** Growing feature area with complex state management. - -### Video Utilities -- [ ] `js/utils/videoTimestamps.js` - Timestamp parsing and formatting -- [ ] `js/utils/videoPointer.js` - Video pointer utilities -- [ ] `js/utils/torrentHash.js` - Torrent hash utilities - -**Why:** Core video functionality used throughout the app. +### [ ] `js/utils/hashtagNormalization.js` — Hashtag canonicalization +**Exports:** `normalizeHashtag`, `formatHashtag` +**Dependencies:** None (pure utility) +**Why P0:** Affects feed filtering and content matching across the app. +**Test cases needed:** +- Strip `#` prefix, handle `##` double prefix +- Trim whitespace before and after +- Lowercase conversion +- Empty-after-strip detection +- `formatHashtag` adds `#` prefix back +- Roundtrip: normalizeHashtag → formatHashtag --- -## Low-Medium Priority +## P1 — Upload Reliability (js/storage/) -### Kids Mode Features -Safety-critical filtering that should have thorough test coverage: +### [ ] `js/storage/s3-multipart.js` — Multipart upload chunking +**Why:** Multipart upload bugs are extremely hard to debug in production. Only `dmDb.js` is tested in this directory. +**Test cases needed:** +- Chunk splitting at correct sizes +- Part numbering sequence +- Upload completion/abort flows +- Progress tracking +- Error recovery for individual part failures -- [ ] `js/feedEngine/kidsAudienceFilterStage.js` - Kids mode content filtering -- [ ] `js/feedEngine/kidsScoring.js` - Kids-appropriate content scoring +### [ ] `js/storage/s3-client.js` — S3-compatible client +**Test cases needed:** +- Request signing +- Bucket operations +- Error response parsing +- SDK loading fallback -**Why:** Safety-critical filtering for younger audiences. - -### Application Bootstrap -- [ ] `js/app.js` - Main application orchestrator -- [ ] `js/bootstrap.js` - Application initialization -- [ ] `js/applicationContext.js` - Application context management - -**Why:** Complex initialization logic that's difficult to test but important for reliability. +### [ ] `js/storage/r2-mgmt.js` — R2 bucket management +### [ ] `js/storage/r2-s3.js` — R2/S3 integration layer +### [ ] `js/services/s3UploadService.js` — Upload orchestration and progress tracking --- -## Low Priority +## P1 — Content Safety (js/feedEngine/) -### Miscellaneous Utilities -- [ ] `js/utils/domUtils.js` - DOM helper functions -- [ ] `js/utils/hex.js` - Hex encoding utilities -- [ ] `js/utils/qrcode.js` - QR code generation -- [ ] `js/utils/linkPreviewSettings.js` - Link preview configuration -- [ ] `js/utils/storage.js` - Local storage utilities -- [ ] `js/utils/storagePointer.js` - Storage pointer helpers -- [ ] `js/utils/profileMedia.js` - Profile media handling +### [ ] `js/feedEngine/kidsAudienceFilterStage.js` — Kids mode content filtering +**Exports:** `createKidsAudienceFilterStage()` → async filter function +**Dependencies:** nostrService, `toSet` utility +**Why P1:** Safety-critical — wrong classification shows inappropriate content to children. +**Test cases needed:** +- Removes non-kids, NSFW, blacklisted, content-warning-flagged videos +- Warning normalization: `trim().toLowerCase().replace(/[\s_]+/g, "-")` +- Array/string/Set warning inputs; comma/semicolon/pipe delimiters +- Context fallback: runtime → config → fallback defaults +- Empty video lists, null video objects -### UI Components -- [ ] `js/ui/ModalManager.js` - Modal lifecycle management -- [ ] `js/ui/initEditModal.js` - Edit modal initialization -- [ ] `js/ui/initDeleteModal.js` - Delete modal initialization -- [ ] `js/ui/watchHistoryController.js` - Watch history UI -- [ ] `js/ui/profileIdentityController.js` - Profile identity display -- [ ] `js/ui/ambientBackground.js` - Ambient background effects -- [ ] `js/ui/components/DeleteModal.js` - Delete confirmation modal -- [ ] `js/ui/components/FeedInfoPopover.js` - Feed information popover -- [ ] `js/ui/components/EventDetailsModal.js` - Event details display -- [ ] `js/ui/components/EmbedVideoModal.js` - Video embed modal +### [ ] `js/feedEngine/kidsScoring.js` — Kids content scoring (0-1 clamped) +**Exports:** `createKidsScorerStage()` → async scoring function +**Dependencies:** `normalizeHashtag`, `toSet`, `isPlainObject` +**Key factors:** age-appropriateness (0.35), educational (0.25), author trust (0.15), popularity (0.1), freshness (0.1), safety multiplier (0.6 weight) +**Test cases needed:** +- Individual factor calculations at boundaries +- Clamping edge cases (NaN, Infinity → 0 or 1) +- Exponential freshness decay with various half-lives +- Safety penalty stacking (trusted reports + muted) +- Weight override resolution order: options → runtime → config +- Metadata injection correctness (kidsScore, components) -### Nostr Internals -- [ ] `js/nostr/dmDecryptWorker.js` - DM decryption web worker -- [ ] `js/nostr/dmDecryptWorkerClient.js` - Worker client interface -- [ ] `js/nostr/nip04Worker.js` - NIP-04 web worker -- [ ] `js/nostr/nip04WorkerClient.js` - NIP-04 worker client -- [ ] `js/nostr/countDiagnostics.js` - Count query diagnostics -- [ ] `js/nostr/maxListenerDiagnostics.js` - Event listener diagnostics +### [ ] `js/feedEngine/sorters.js` — Feed sorting strategies +**Exports:** `createChronologicalSorter()`, `createExploreDiversitySorter()`, `createKidsScoreSorter()` +**Test cases needed:** +- Chronological: timestamp resolution path priority (hook → rootCreatedAt → nip71Source → created_at), trustedMuted items sunk to end +- MMR diversity: cosine similarity on zero vectors, NaN handling, lambda=0 (pure diversity) vs lambda=1 (pure relevance) +- Kids score: desc score → desc timestamp → video ID tiebreak + +--- + +## P1 — DM UI Components (js/ui/dm/) + +**Why:** 14 files, zero tests. Growing feature area with complex state management. + +### [ ] `js/ui/dm/MessageBubble.js` — Individual message rendering +### [ ] `js/ui/dm/Composer.js` — Message composition UI +### [ ] `js/ui/dm/DMPrivacySettings.js` — Privacy configuration +### [ ] `js/ui/dm/MessageThread.js` — Message thread display +### [ ] `js/ui/dm/NotificationCenter.js` — Notification handling +### [ ] `js/ui/dm/ConversationList.js` — Conversation list rendering +### [ ] `js/ui/dm/ZapInterface.js` — In-chat zap sending +### [ ] `js/ui/dm/ZapReceiptList.js` — Zap receipt display +### [ ] `js/ui/dm/ContactRow.js` — Contact list item +### [ ] `js/ui/dm/Avatar.js` — Profile avatar display +### [ ] `js/ui/dm/AppShell.js` — DM app shell layout +### [ ] `js/ui/dm/DayDivider.js` — Date separator between messages +### [ ] `js/ui/dm/index.js` — Module entry point + +--- + +## P2 — Playback Failure Modes + +### [ ] `js/services/playbackService.js` — Additional failure/fallback path tests +**Existing tests:** playbackService.test.mjs (3 variants: base, forcedSource, order) +**What's missing:** The URL→magnet fallback chain is documented as the most fragile path. +**Test cases to add:** +- No video element → error path +- No playable source (no URL, bad magnet) +- Probe HEAD timeout → triggers torrent fallback +- Playback stall (8s default watchdog) → triggers fallback +- Unsupported BTIH magnet format +- Autoplay blocked (NotAllowedError) → wait for user play → restore watchdog +- Race conditions: request signature matching, overlapping session cleanup +- effectiveTimeout=0 when forcedSource set +- Watchdog cleanup before new session + +--- + +## P2 — View Managers + +### [ ] `js/forYouView.js` — "For You" feed with empty state lifecycle +**Exports:** `initForYouView()`, `ForYouView` class +**Test focus:** MutationObserver lifecycle, empty state injection/removal, action button delegation + +### [ ] `js/exploreView.js` — Explore feed +**Test focus:** Same empty state management pattern as forYouView + +### [ ] `js/searchView.js` — Search results with profile/video dual search +**Exports:** `initSearchView()` + internal helpers +**Test focus:** Token cancellation for stale searches, JSON.parse failures, filter state persistence, relay search fallback to local + +### [ ] `js/hashView.js` — Hash-based view routing +**Exports:** `getHashViewName()`, `setHashView(viewName, options)` +**Test focus:** Regex extraction, URL param manipulation, modal/v param preservation + +### [ ] `js/kidsView.js` — Kids-only feed +**Test focus:** Same pattern as exploreView + +--- + +## P2 — UI Modal Components + +### [ ] `js/ui/ModalManager.js` — Modal lifecycle management +### [ ] `js/ui/components/DeleteModal.js` — Delete confirmation (destructive operation) +### [ ] `js/ui/components/UploadModal.js` — Upload flow (destructive operation) +### [ ] `js/ui/components/EditModal.js` — Edit modal +### [ ] `js/ui/components/SearchFilterModal.js` — Search filter UI +### [ ] `js/ui/components/EventDetailsModal.js` — Event details display +### [ ] `js/ui/initEditModal.js` — Edit modal initialization +### [ ] `js/ui/initDeleteModal.js` — Delete modal initialization + +--- + +## P2 — Utilities (js/utils/) + +### [ ] `js/utils/logger.js` — Logger with dev/user channels +**Exports:** `logger`, `devLogger`, `userLogger`, `isCompatibleLogger` +**Test focus:** Console fallback chain, force flag parsing, dev mode gating, noop fallback for missing console + +### [ ] `js/utils/profileBatchFetcher.js` — Batch profile fetching +**Exports:** `batchFetchProfilesFromRelays` +**Test focus:** Pubkey validation (HEX64), cache hit bypass, newest-by-timestamp dedup, malformed JSON handling, relay timeouts + +### [ ] `js/utils/domUtils.js` — DOM helpers +### [ ] `js/utils/storage.js` — localStorage abstraction +### [ ] `js/utils/storagePointer.js` — Storage pointer helpers +### [ ] `js/utils/videoTimestamps.js` — Timestamp parsing +### [ ] `js/utils/videoPointer.js` — Video pointer utilities +### [ ] `js/utils/torrentHash.js` — Torrent hash utilities +### [ ] `js/utils/linkPreviewSettings.js` — Link preview config +### [ ] `js/utils/profileMedia.js` — Profile media handling +### [ ] `js/utils/qrcode.js` — QR code generation + +--- + +## P3 — Infrastructure Improvements + +### [ ] Add coverage reporting +**Details:** Add `--experimental-test-coverage` to Node.js test runner (available since Node 20) or integrate `c8`. This gives concrete numbers and trend tracking for CI. + +### [ ] Worker test harness +**Details:** Web Worker code (`dmDecryptWorker.js`, `nip04Worker.js`) can't be tested in JSDOM. Consider `vitest` with `@vitest/web-worker` or a lightweight worker polyfill. + +### [ ] Fix flaky E2E tests +**Details:** `KNOWN_ISSUES.md` documents broken E2E tests: modal timeouts, missing elements, undefined functions. Fix these to restore CI confidence. + +--- + +## P3 — Low Priority / Hard to Test + +### Nostr Workers (need worker harness) +- [ ] `js/nostr/dmDecryptWorker.js` +- [ ] `js/nostr/dmDecryptWorkerClient.js` +- [ ] `js/nostr/nip04Worker.js` +- [ ] `js/nostr/nip04WorkerClient.js` + +### Diagnostic/Debug Files +- [ ] `js/nostr/countDiagnostics.js` +- [ ] `js/nostr/maxListenerDiagnostics.js` + +### Remaining UI Components +- [ ] `js/ui/watchHistoryController.js` +- [ ] `js/ui/profileIdentityController.js` +- [ ] `js/ui/ambientBackground.js` +- [ ] `js/ui/components/FeedInfoPopover.js` +- [ ] `js/ui/components/EmbedVideoModal.js` +- [ ] `js/ui/components/RevertModal.js` +- [ ] `js/ui/components/ShareNostrModal.js` --- @@ -105,8 +303,8 @@ Safety-critical filtering that should have thorough test coverage: Only 6 E2E tests exist. Consider adding: -- [ ] Full upload flow (video file -> published note) -- [ ] Video playback fallback scenarios (URL -> magnet) +- [ ] Full upload flow (video file → published note) +- [ ] Video playback fallback scenarios (URL → magnet) - [ ] Authentication flows (NIP-07, NIP-46 connection) - [ ] Relay switching behavior under failure conditions - [ ] Watch history persistence and sync @@ -121,3 +319,5 @@ Only 6 E2E tests exist. Consider adding: - Use Playwright for E2E tests - Mock external dependencies (nostr-tools, relays) appropriately - Run `npm run test:unit` to verify new tests pass +- Files exposing `__TESTING__` objects provide internal helpers for white-box testing +- Payment files are pure-ish logic (no DOM) — good candidates for straightforward unit tests diff --git a/tests/lnurl.test.mjs b/tests/lnurl.test.mjs new file mode 100644 index 00000000..27e3eaa2 --- /dev/null +++ b/tests/lnurl.test.mjs @@ -0,0 +1,674 @@ +import assert from "node:assert/strict"; + +if (typeof globalThis.window === "undefined") { + globalThis.window = {}; +} + +if (typeof globalThis.localStorage === "undefined") { + const storage = new Map(); + globalThis.localStorage = { + getItem(key) { + return storage.has(key) ? storage.get(key) : null; + }, + setItem(key, value) { + storage.set(String(key), String(value)); + }, + removeItem(key) { + storage.delete(String(key)); + }, + clear() { + storage.clear(); + }, + }; +} + +if (typeof window.localStorage === "undefined") { + window.localStorage = globalThis.localStorage; +} + +const nostrToolsStub = { + getEventHash() { + return "0".repeat(64); + }, + signEvent() { + return "sig-stub"; + }, + nip19: { + decode(value) { + if (typeof value === "string" && value.startsWith("npub")) { + return { type: "npub", data: "f".repeat(64) }; + } + return null; + }, + }, + SimplePool: class { + async list() { + return []; + } + sub() { + return { on() {}, unsub() {} }; + } + async get() { + return null; + } + async ensureRelay() { + return { close() {}, url: "" }; + } + }, +}; + +window.NostrTools = nostrToolsStub; +globalThis.NostrTools = nostrToolsStub; + +globalThis.fetch = async () => { + throw new Error("Unexpected fetch call during lnurl tests."); +}; + +const { + encodeLnurlBech32, + resolveLightningAddress, + fetchPayServiceData, + validateInvoiceAmount, + requestInvoice, + __TESTING__, +} = await import("../js/payments/lnurl.js"); + +const { bech32Encode, bech32Decode, decodeLnurlBech32, convertWords, createChecksum } = + __TESTING__; + +// --------------------------------------------------------------------------- +// bech32 encode/decode roundtrip +// --------------------------------------------------------------------------- + +(function testBech32RoundtripSimpleUrl() { + const url = "https://example.com/pay"; + const encoded = encodeLnurlBech32(url); + assert(encoded.startsWith("lnurl"), "encoded should start with lnurl prefix"); + const decoded = decodeLnurlBech32(encoded); + assert.equal(decoded, url); +})(); + +(function testBech32RoundtripWithSpecialChars() { + const url = "https://example.com/api?user=alice&amount=1000"; + const encoded = encodeLnurlBech32(url); + const decoded = decodeLnurlBech32(encoded); + assert.equal(decoded, url); +})(); + +(function testBech32RoundtripWithPath() { + const url = "https://wallet.example.org/.well-known/lnurlp/bob"; + const encoded = encodeLnurlBech32(url); + const decoded = decodeLnurlBech32(encoded); + assert.equal(decoded, url); +})(); + +(function testBech32EncodeOutputIsLowercase() { + const encoded = encodeLnurlBech32("https://example.com"); + assert.equal(encoded, encoded.toLowerCase()); +})(); + +// --------------------------------------------------------------------------- +// bech32Decode edge cases +// --------------------------------------------------------------------------- + +(function testBech32DecodeRejectsNonString() { + assert.throws(() => bech32Decode(123), /must be a string/); + assert.throws(() => bech32Decode(null), /must be a string/); + assert.throws(() => bech32Decode(undefined), /must be a string/); +})(); + +(function testBech32DecodeRejectsEmpty() { + assert.throws(() => bech32Decode(""), /cannot be empty/); + assert.throws(() => bech32Decode(" "), /cannot be empty/); +})(); + +(function testBech32DecodeRejectsMixedCase() { + const encoded = encodeLnurlBech32("https://example.com"); + const mixedCase = encoded[0].toUpperCase() + encoded.slice(1).toLowerCase(); + if (mixedCase !== encoded.toLowerCase() && mixedCase !== encoded.toUpperCase()) { + assert.throws(() => bech32Decode(mixedCase), /mix upper and lower/); + } +})(); + +(function testBech32DecodeAcceptsAllUppercase() { + const encoded = encodeLnurlBech32("https://example.com"); + const upper = encoded.toUpperCase(); + const { prefix } = bech32Decode(upper); + assert.equal(prefix, "lnurl"); +})(); + +// --------------------------------------------------------------------------- +// convertWords +// --------------------------------------------------------------------------- + +(function testConvertWordsRoundtrip() { + const original = [72, 101, 108, 108, 111]; // "Hello" + const words5 = convertWords(original, 8, 5, { pad: true }); + const restored = convertWords(words5, 5, 8, { pad: false }); + assert.deepEqual(restored, original); +})(); + +(function testConvertWordsRejectsInvalidValue() { + assert.throws(() => convertWords([-1], 8, 5), /Invalid bech32 word value/); + assert.throws(() => convertWords([256], 8, 5), /Invalid bech32 word value/); +})(); + +// --------------------------------------------------------------------------- +// encodeLnurlBech32 +// --------------------------------------------------------------------------- + +(function testEncodeLnurlBech32RejectsEmpty() { + assert.throws(() => encodeLnurlBech32(""), /missing/i); + assert.throws(() => encodeLnurlBech32(" "), /missing/i); +})(); + +(function testEncodeLnurlBech32RejectsNonString() { + assert.throws(() => encodeLnurlBech32(null), /missing/i); + assert.throws(() => encodeLnurlBech32(undefined), /missing/i); +})(); + +// --------------------------------------------------------------------------- +// decodeLnurlBech32 +// --------------------------------------------------------------------------- + +(function testDecodeLnurlBech32RejectsWrongPrefix() { + const encoded = encodeLnurlBech32("https://example.com"); + // Replace prefix: lnurl -> lnbcx (invalid prefix) + const modified = "bc1" + encoded.slice(5); + assert.throws(() => decodeLnurlBech32(modified)); +})(); + +// --------------------------------------------------------------------------- +// resolveLightningAddress +// --------------------------------------------------------------------------- + +(function testResolveEmailFormat() { + const result = resolveLightningAddress("alice@example.com"); + assert.equal(result.type, "lud16"); + assert.equal(result.address, "alice@example.com"); + assert(result.url.includes("example.com")); + assert(result.url.includes("/.well-known/lnurlp/alice")); +})(); + +(function testResolveEmailFormatLowercase() { + const result = resolveLightningAddress("Alice@Example.COM"); + assert(result.url.includes("alice"), "username should be lowercased in URL"); +})(); + +(function testResolveLnurlFormat() { + const url = "https://pay.example.com/callback"; + const encoded = encodeLnurlBech32(url); + const result = resolveLightningAddress(encoded); + assert.equal(result.type, "lud06"); + assert.equal(result.url, url); +})(); + +(function testResolveHttpsUrl() { + const url = "https://example.com/api/pay"; + const result = resolveLightningAddress(url); + assert.equal(result.type, "url"); + assert.equal(result.url, url); +})(); + +(function testResolveHttpUrl() { + const url = "http://example.com/api/pay"; + const result = resolveLightningAddress(url); + assert.equal(result.type, "url"); + assert.equal(result.url, url); +})(); + +(function testResolveRejectsEmpty() { + assert.throws(() => resolveLightningAddress(""), /required/i); + assert.throws(() => resolveLightningAddress(" "), /required/i); + assert.throws(() => resolveLightningAddress(null), /required/i); + assert.throws(() => resolveLightningAddress(undefined), /required/i); +})(); + +(function testResolveRejectsInvalidEmailMissingParts() { + assert.throws(() => resolveLightningAddress("@example.com"), /Invalid/); + assert.throws(() => resolveLightningAddress("alice@"), /Invalid/); + assert.throws(() => resolveLightningAddress("@"), /Invalid/); +})(); + +(function testResolveRejectsUnsupportedFormat() { + assert.throws(() => resolveLightningAddress("just-a-string"), /Unsupported/); +})(); + +(function testResolveTrimsWhitespace() { + const result = resolveLightningAddress(" alice@example.com "); + assert.equal(result.address, "alice@example.com"); +})(); + +// --------------------------------------------------------------------------- +// fetchPayServiceData +// --------------------------------------------------------------------------- + +async function testFetchPayServiceDataSuccess() { + const mockFetcher = async () => ({ + ok: true, + async json() { + return { + callback: "https://example.com/callback", + minSendable: 1000, + maxSendable: 1000000, + commentAllowed: 100, + allowsNostr: true, + nostrPubkey: "a".repeat(64), + metadata: '[ ["text/plain","test"] ]', + tag: "payRequest", + }; + }, + }); + + const result = await fetchPayServiceData("https://example.com/.well-known/lnurlp/alice", { + fetcher: mockFetcher, + }); + + assert.equal(result.callback, "https://example.com/callback"); + assert.equal(result.minSendable, 1000); + assert.equal(result.maxSendable, 1000000); + assert.equal(result.commentAllowed, 100); + assert.equal(result.allowsNostr, true); + assert.equal(result.nostrPubkey, "a".repeat(64)); + assert.equal(result.tag, "payRequest"); + assert(Array.isArray(result.metadata)); + assert.equal(result.metadata.length, 1); +} + +async function testFetchPayServiceDataHttpError() { + const mockFetcher = async () => ({ + ok: false, + status: 404, + }); + + await assert.rejects( + () => fetchPayServiceData("https://example.com/lnurl", { fetcher: mockFetcher }), + /404/ + ); +} + +async function testFetchPayServiceDataInvalidJson() { + const mockFetcher = async () => ({ + ok: true, + async json() { + throw new SyntaxError("Unexpected token"); + }, + }); + + await assert.rejects( + () => fetchPayServiceData("https://example.com/lnurl", { fetcher: mockFetcher }), + /not return JSON/ + ); +} + +async function testFetchPayServiceDataMissingCallback() { + const mockFetcher = async () => ({ + ok: true, + async json() { + return { minSendable: 1000, maxSendable: 1000000 }; + }, + }); + + await assert.rejects( + () => fetchPayServiceData("https://example.com/lnurl", { fetcher: mockFetcher }), + /missing/i + ); +} + +async function testFetchPayServiceDataErrorStatus() { + const mockFetcher = async () => ({ + ok: true, + async json() { + return { + status: "ERROR", + reason: "Rate limit exceeded", + }; + }, + }); + + await assert.rejects( + () => fetchPayServiceData("https://example.com/lnurl", { fetcher: mockFetcher }), + /Rate limit exceeded/ + ); +} + +async function testFetchPayServiceDataErrorStatusNoReason() { + const mockFetcher = async () => ({ + ok: true, + async json() { + return { status: "error" }; + }, + }); + + await assert.rejects( + () => fetchPayServiceData("https://example.com/lnurl", { fetcher: mockFetcher }), + /returned an error/i + ); +} + +async function testFetchPayServiceDataInvalidMetadataGracefullySkipped() { + const mockFetcher = async () => ({ + ok: true, + async json() { + return { + callback: "https://example.com/callback", + metadata: "not-valid-json{{{", + }; + }, + }); + + const result = await fetchPayServiceData("https://example.com/lnurl", { + fetcher: mockFetcher, + }); + assert.deepEqual(result.metadata, []); +} + +async function testFetchPayServiceDataNormalizesNumericFields() { + const mockFetcher = async () => ({ + ok: true, + async json() { + return { + callback: "https://example.com/callback", + minSendable: "not-a-number", + maxSendable: null, + commentAllowed: -5, + }; + }, + }); + + const result = await fetchPayServiceData("https://example.com/lnurl", { + fetcher: mockFetcher, + }); + assert.equal(result.minSendable, 0); + assert.equal(result.maxSendable, 0); + assert.equal(result.commentAllowed, 0); +} + +async function testFetchPayServiceDataRejectsEmptyUrl() { + await assert.rejects( + () => fetchPayServiceData("", { fetcher: async () => ({ ok: true, json: async () => ({}) }) }), + /missing/i + ); +} + +// --------------------------------------------------------------------------- +// validateInvoiceAmount +// --------------------------------------------------------------------------- + +(function testValidateInvoiceAmountWithinLimits() { + const metadata = { minSendable: 1000, maxSendable: 1000000 }; + const result = validateInvoiceAmount(metadata, 100); + assert.equal(result.amountMsats, 100000); +})(); + +(function testValidateInvoiceAmountBelowMinimum() { + const metadata = { minSendable: 5000, maxSendable: 1000000 }; + assert.throws(() => validateInvoiceAmount(metadata, 1), /below the minimum/); +})(); + +(function testValidateInvoiceAmountAboveMaximum() { + const metadata = { minSendable: 1000, maxSendable: 100000 }; + assert.throws(() => validateInvoiceAmount(metadata, 200), /exceeds the maximum/); +})(); + +(function testValidateInvoiceAmountZero() { + const metadata = { minSendable: 0, maxSendable: 1000000 }; + assert.throws(() => validateInvoiceAmount(metadata, 0), /positive integer/); +})(); + +(function testValidateInvoiceAmountNegative() { + const metadata = { minSendable: 0, maxSendable: 1000000 }; + assert.throws(() => validateInvoiceAmount(metadata, -5), /positive integer/); +})(); + +(function testValidateInvoiceAmountNonNumeric() { + const metadata = { minSendable: 0, maxSendable: 1000000 }; + assert.throws(() => validateInvoiceAmount(metadata, "abc"), /positive integer/); +})(); + +(function testValidateInvoiceAmountMissingMetadata() { + assert.throws(() => validateInvoiceAmount(null, 100), /metadata is required/i); + assert.throws(() => validateInvoiceAmount(undefined, 100), /metadata is required/i); + assert.throws(() => validateInvoiceAmount("not-object", 100), /metadata is required/i); +})(); + +(function testValidateInvoiceAmountExactBoundaries() { + const metadata = { minSendable: 10000, maxSendable: 100000 }; + const resultMin = validateInvoiceAmount(metadata, 10); + assert.equal(resultMin.amountMsats, 10000); + const resultMax = validateInvoiceAmount(metadata, 100); + assert.equal(resultMax.amountMsats, 100000); +})(); + +(function testValidateInvoiceAmountNoMinMax() { + const metadata = {}; + const result = validateInvoiceAmount(metadata, 50); + assert.equal(result.amountMsats, 50000); +})(); + +// --------------------------------------------------------------------------- +// requestInvoice +// --------------------------------------------------------------------------- + +async function testRequestInvoiceSuccess() { + const metadata = { + callback: "https://example.com/callback", + commentAllowed: 50, + }; + + const capturedUrls = []; + const fetcher = async (url) => { + capturedUrls.push(url); + return { + ok: true, + async json() { + return { pr: "lnbc100u1p0..." }; + }, + }; + }; + + const result = await requestInvoice(metadata, { + amountSats: 100, + comment: "Great video!", + fetcher, + }); + + assert.equal(result.invoice, "lnbc100u1p0..."); + assert.equal(capturedUrls.length, 1); + + const url = new URL(capturedUrls[0]); + assert.equal(url.searchParams.get("amount"), "100000"); + assert.equal(url.searchParams.get("comment"), "Great video!"); +} + +async function testRequestInvoiceCommentTruncation() { + const metadata = { + callback: "https://example.com/callback", + commentAllowed: 5, + }; + + const capturedUrls = []; + const fetcher = async (url) => { + capturedUrls.push(url); + return { + ok: true, + async json() { + return { pr: "lnbc1..." }; + }, + }; + }; + + await requestInvoice(metadata, { + amountSats: 10, + comment: "A much longer comment that should be truncated", + fetcher, + }); + + const url = new URL(capturedUrls[0]); + assert.equal(url.searchParams.get("comment"), "A muc"); +} + +async function testRequestInvoiceCommentDisabled() { + const metadata = { + callback: "https://example.com/callback", + commentAllowed: 0, + }; + + const capturedUrls = []; + const fetcher = async (url) => { + capturedUrls.push(url); + return { + ok: true, + async json() { + return { pr: "lnbc1..." }; + }, + }; + }; + + await requestInvoice(metadata, { + amountSats: 10, + comment: "Should be excluded", + fetcher, + }); + + const url = new URL(capturedUrls[0]); + assert.equal(url.searchParams.get("comment"), null, "comment should not be sent when commentAllowed is 0"); +} + +async function testRequestInvoiceUsesAmountMsats() { + const metadata = { + callback: "https://example.com/callback", + commentAllowed: 0, + }; + + const capturedUrls = []; + const fetcher = async (url) => { + capturedUrls.push(url); + return { + ok: true, + async json() { + return { pr: "lnbc1..." }; + }, + }; + }; + + await requestInvoice(metadata, { + amountMsats: 55555, + fetcher, + }); + + const url = new URL(capturedUrls[0]); + assert.equal(url.searchParams.get("amount"), "55555"); +} + +async function testRequestInvoiceMissingInvoice() { + const metadata = { + callback: "https://example.com/callback", + commentAllowed: 0, + }; + + const fetcher = async () => ({ + ok: true, + async json() { + return {}; + }, + }); + + await assert.rejects( + () => requestInvoice(metadata, { amountSats: 10, fetcher }), + /did not return an invoice/i + ); +} + +async function testRequestInvoiceHttpError() { + const metadata = { + callback: "https://example.com/callback", + commentAllowed: 0, + }; + + const fetcher = async () => ({ ok: false, status: 500 }); + + await assert.rejects( + () => requestInvoice(metadata, { amountSats: 10, fetcher }), + /500/ + ); +} + +async function testRequestInvoiceErrorStatus() { + const metadata = { + callback: "https://example.com/callback", + commentAllowed: 0, + }; + + const fetcher = async () => ({ + ok: true, + async json() { + return { status: "ERROR", reason: "Service unavailable" }; + }, + }); + + await assert.rejects( + () => requestInvoice(metadata, { amountSats: 10, fetcher }), + /Service unavailable/ + ); +} + +async function testRequestInvoiceMissingMetadata() { + await assert.rejects( + () => requestInvoice(null, { amountSats: 10 }), + /metadata is required/i + ); +} + +async function testRequestInvoiceIncludesZapRequestAndLnurl() { + const metadata = { + callback: "https://example.com/callback", + commentAllowed: 0, + }; + + const capturedUrls = []; + const fetcher = async (url) => { + capturedUrls.push(url); + return { + ok: true, + async json() { + return { pr: "lnbc1..." }; + }, + }; + }; + + const zapReq = JSON.stringify({ kind: 9734 }); + await requestInvoice(metadata, { + amountSats: 10, + zapRequest: zapReq, + lnurl: "lnurl1test", + fetcher, + }); + + const url = new URL(capturedUrls[0]); + assert.equal(url.searchParams.get("nostr"), zapReq); + assert.equal(url.searchParams.get("lnurl"), "lnurl1test"); +} + +// Run async tests +await testFetchPayServiceDataSuccess(); +await testFetchPayServiceDataHttpError(); +await testFetchPayServiceDataInvalidJson(); +await testFetchPayServiceDataMissingCallback(); +await testFetchPayServiceDataErrorStatus(); +await testFetchPayServiceDataErrorStatusNoReason(); +await testFetchPayServiceDataInvalidMetadataGracefullySkipped(); +await testFetchPayServiceDataNormalizesNumericFields(); +await testFetchPayServiceDataRejectsEmptyUrl(); + +await testRequestInvoiceSuccess(); +await testRequestInvoiceCommentTruncation(); +await testRequestInvoiceCommentDisabled(); +await testRequestInvoiceUsesAmountMsats(); +await testRequestInvoiceMissingInvoice(); +await testRequestInvoiceHttpError(); +await testRequestInvoiceErrorStatus(); +await testRequestInvoiceMissingMetadata(); +await testRequestInvoiceIncludesZapRequestAndLnurl(); + +console.log("lnurl tests passed"); diff --git a/tests/platform-fee.test.mjs b/tests/platform-fee.test.mjs new file mode 100644 index 00000000..acdb81c1 --- /dev/null +++ b/tests/platform-fee.test.mjs @@ -0,0 +1,232 @@ +import assert from "node:assert/strict"; + +if (typeof globalThis.window === "undefined") { + globalThis.window = {}; +} + +if (typeof globalThis.localStorage === "undefined") { + const storage = new Map(); + globalThis.localStorage = { + getItem(key) { + return storage.has(key) ? storage.get(key) : null; + }, + setItem(key, value) { + storage.set(String(key), String(value)); + }, + removeItem(key) { + storage.delete(String(key)); + }, + clear() { + storage.clear(); + }, + }; +} + +if (typeof window.localStorage === "undefined") { + window.localStorage = globalThis.localStorage; +} + +const nostrToolsStub = { + getEventHash() { + return "0".repeat(64); + }, + nip19: { + decode(value) { + if (typeof value === "string" && value.startsWith("npub")) { + return { type: "npub", data: "f".repeat(64) }; + } + return null; + }, + }, + SimplePool: class { + async list() { + return []; + } + }, +}; + +window.NostrTools = nostrToolsStub; +globalThis.NostrTools = nostrToolsStub; + +const { + parsePercentValue, + clampPercent, + getDefaultPlatformFeePercent, + resolvePlatformFeePercent, +} = await import("../js/payments/platformFee.js"); + +// --------------------------------------------------------------------------- +// parsePercentValue +// --------------------------------------------------------------------------- + +(function testParsePercentValueWithIntegers() { + assert.equal(parsePercentValue(0), 0); + assert.equal(parsePercentValue(50), 50); + assert.equal(parsePercentValue(100), 100); + assert.equal(parsePercentValue(-10), -10); +})(); + +(function testParsePercentValueWithFloats() { + assert.equal(parsePercentValue(2.5), 2.5); + assert.equal(parsePercentValue(99.9), 99.9); + assert.equal(parsePercentValue(0.001), 0.001); +})(); + +(function testParsePercentValueWithInfinity() { + assert(Number.isNaN(parsePercentValue(Infinity))); + assert(Number.isNaN(parsePercentValue(-Infinity))); +})(); + +(function testParsePercentValueWithNaN() { + assert(Number.isNaN(parsePercentValue(NaN))); +})(); + +(function testParsePercentValueWithBigint() { + assert.equal(parsePercentValue(50n), 50); + assert.equal(parsePercentValue(0n), 0); + assert.equal(parsePercentValue(100n), 100); +})(); + +(function testParsePercentValueWithPercentStrings() { + assert.equal(parsePercentValue("50%"), 50); + assert.equal(parsePercentValue("2.5%"), 2.5); + assert.equal(parsePercentValue("100%"), 100); + assert.equal(parsePercentValue("0%"), 0); + assert.equal(parsePercentValue(" 30% "), 30); +})(); + +(function testParsePercentValueWithFractionStrings() { + // Note: the implementation treats "a/b" as second / (first + second) * 100 + const result = parsePercentValue("70/30"); + assert.equal(result, 30); + + const halfResult = parsePercentValue("50/50"); + assert.equal(halfResult, 50); + + const fullResult = parsePercentValue("0/100"); + assert.equal(fullResult, 100); + + const noneResult = parsePercentValue("100/0"); + assert.equal(noneResult, 0); +})(); + +(function testParsePercentValueFractionDivisionByZero() { + // When both parts are 0, total is 0 so it can't divide + const result = parsePercentValue("0/0"); + // Falls through to direct numeric parse which gives NaN for "0/0" + // Then fallback regex: matches "0", returns 0 + assert.equal(result, 0); +})(); + +(function testParsePercentValueWithNumericStrings() { + assert.equal(parsePercentValue("42"), 42); + assert.equal(parsePercentValue(" 10 "), 10); + assert.equal(parsePercentValue("0"), 0); +})(); + +(function testParsePercentValueExtractsFromMixed() { + // "50abc" -> strip %, get "50abc" -> Number("50abc") = NaN -> regex extracts "50" + assert.equal(parsePercentValue("50abc"), 50); +})(); + +(function testParsePercentValueEmptyAndWhitespace() { + assert(Number.isNaN(parsePercentValue(""))); + assert(Number.isNaN(parsePercentValue(" "))); +})(); + +(function testParsePercentValueNonNumericString() { + assert(Number.isNaN(parsePercentValue("abc"))); + assert(Number.isNaN(parsePercentValue("no-numbers-here!"))); +})(); + +(function testParsePercentValueNonFiniteTypes() { + assert(Number.isNaN(parsePercentValue(null))); + assert(Number.isNaN(parsePercentValue(undefined))); + assert(Number.isNaN(parsePercentValue({}))); + assert(Number.isNaN(parsePercentValue([]))); + assert(Number.isNaN(parsePercentValue(true))); +})(); + +// --------------------------------------------------------------------------- +// clampPercent +// --------------------------------------------------------------------------- + +(function testClampPercentNormalValues() { + assert.equal(clampPercent(0), 0); + assert.equal(clampPercent(50), 50); + assert.equal(clampPercent(100), 100); +})(); + +(function testClampPercentAbove100() { + assert.equal(clampPercent(150), 100); + assert.equal(clampPercent(999), 100); +})(); + +(function testClampPercentBelowZero() { + assert.equal(clampPercent(-10), 0); + assert.equal(clampPercent(-999), 0); +})(); + +(function testClampPercentNaN() { + assert.equal(clampPercent(NaN), 0); + assert.equal(clampPercent("not-a-number"), 0); +})(); + +(function testClampPercentNonFinite() { + assert.equal(clampPercent(Infinity), 0); + assert.equal(clampPercent(-Infinity), 0); +})(); + +(function testClampPercentNull() { + assert.equal(clampPercent(null), 0); + assert.equal(clampPercent(undefined), 0); +})(); + +(function testClampPercentWithStrings() { + assert.equal(clampPercent("50%"), 50); + assert.equal(clampPercent("200%"), 100); + assert.equal(clampPercent("-5%"), 0); + assert.equal(clampPercent("70/30"), 30); +})(); + +// --------------------------------------------------------------------------- +// getDefaultPlatformFeePercent +// --------------------------------------------------------------------------- + +(function testGetDefaultPlatformFeePercentReturnsConfiguredValue() { + // The config has PLATFORM_FEE_PERCENT = 30 + const result = getDefaultPlatformFeePercent(); + assert.equal(result, 30); +})(); + +// --------------------------------------------------------------------------- +// resolvePlatformFeePercent +// --------------------------------------------------------------------------- + +(function testResolvePlatformFeePercentWithOverride() { + assert.equal(resolvePlatformFeePercent(15), 15); + assert.equal(resolvePlatformFeePercent(0), 0); + assert.equal(resolvePlatformFeePercent(100), 100); + assert.equal(resolvePlatformFeePercent("25%"), 25); + assert.equal(resolvePlatformFeePercent("70/30"), 30); +})(); + +(function testResolvePlatformFeePercentWithOverrideClamped() { + assert.equal(resolvePlatformFeePercent(200), 100); + assert.equal(resolvePlatformFeePercent(-5), 0); +})(); + +(function testResolvePlatformFeePercentWithoutOverride() { + const result = resolvePlatformFeePercent(undefined); + assert.equal(result, 30, "should fall back to config default (30)"); + + const resultNull = resolvePlatformFeePercent(null); + assert.equal(resultNull, 30, "null should also fall back to default"); +})(); + +(function testResolvePlatformFeePercentInvalidOverride() { + const result = resolvePlatformFeePercent("not-a-number"); + assert.equal(result, 30, "invalid override should fall back to default"); +})(); + +console.log("platform-fee tests passed"); diff --git a/tests/zap-receipt-validator.test.mjs b/tests/zap-receipt-validator.test.mjs new file mode 100644 index 00000000..c53315b2 --- /dev/null +++ b/tests/zap-receipt-validator.test.mjs @@ -0,0 +1,762 @@ +import assert from "node:assert/strict"; +import { bech32 } from "@scure/base"; + +if (typeof globalThis.window === "undefined") { + globalThis.window = {}; +} + +if (typeof globalThis.localStorage === "undefined") { + const storage = new Map(); + globalThis.localStorage = { + getItem(key) { + return storage.has(key) ? storage.get(key) : null; + }, + setItem(key, value) { + storage.set(String(key), String(value)); + }, + removeItem(key) { + storage.delete(String(key)); + }, + clear() { + storage.clear(); + }, + }; +} + +if (typeof window.localStorage === "undefined") { + window.localStorage = globalThis.localStorage; +} + +const nostrToolsStub = { + getEventHash() { + return "0".repeat(64); + }, + signEvent() { + return "sig-stub"; + }, + nip19: { + decode(value) { + if (typeof value === "string" && value.startsWith("npub")) { + return { type: "npub", data: "f".repeat(64) }; + } + return null; + }, + }, + SimplePool: class { + async list() { + return []; + } + sub() { + return { on() {}, unsub() {} }; + } + async get() { + return null; + } + async ensureRelay() { + return { close() {}, url: "" }; + } + }, +}; + +window.NostrTools = nostrToolsStub; +globalThis.NostrTools = nostrToolsStub; + +globalThis.fetch = async () => { + throw new Error("Unexpected fetch call during validator tests."); +}; + +const { + validateZapReceipt, + __TESTING__: validatorTesting, +} = await import("../js/payments/zapReceiptValidator.js"); + +const { computeZapRequestHash, extractDescriptionHashFromBolt11 } = validatorTesting; + +const BOLT11_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; + +function buildTestBolt11Invoice({ amountCode = "10u", descriptionHashHex }) { + const hrp = `lnbc${amountCode}`; + const timestamp = 1_700_000_000; + + const timestampWords = []; + let remaining = timestamp; + for (let i = 0; i < 7; i += 1) { + timestampWords.unshift(remaining & 31); + remaining >>= 5; + } + + const words = [...timestampWords]; + + const pushTag = (tag, dataWords) => { + const code = BOLT11_CHARSET.indexOf(tag); + words.push(code); + const length = dataWords.length; + words.push((length >> 5) & 31); + words.push(length & 31); + words.push(...dataWords); + }; + + const paymentHashHex = "11".repeat(32); + pushTag("p", bech32.toWords(Buffer.from(paymentHashHex, "hex"))); + + if (descriptionHashHex) { + pushTag("h", bech32.toWords(Buffer.from(descriptionHashHex, "hex"))); + } + + const signatureWords = new Array(104).fill(0); + const data = [...words, ...signatureWords]; + + return bech32.encode(hrp, data, 2000); +} + +function buildZapRequest({ relays = ["wss://relay.example"], pubkey = "f".repeat(64) } = {}) { + return { + kind: 9734, + pubkey, + content: "", + created_at: 1_700_000_000, + tags: [ + ["p", "a".repeat(64)], + ["amount", String(1_000_000)], + ["relays", ...relays], + ], + }; +} + +// --------------------------------------------------------------------------- +// computeZapRequestHash +// --------------------------------------------------------------------------- + +(function testComputeZapRequestHashDeterministic() { + const input = JSON.stringify({ kind: 9734, content: "test" }); + const hash1 = computeZapRequestHash(input); + const hash2 = computeZapRequestHash(input); + assert.equal(hash1, hash2); + assert.equal(typeof hash1, "string"); + assert(hash1.length > 0, "hash should be non-empty"); +})(); + +(function testComputeZapRequestHashDifferentInputs() { + const hash1 = computeZapRequestHash("input-a"); + const hash2 = computeZapRequestHash("input-b"); + assert.notEqual(hash1, hash2); +})(); + +(function testComputeZapRequestHashEmptyString() { + const hash = computeZapRequestHash(""); + assert.equal(typeof hash, "string"); + assert(hash.length > 0); +})(); + +(function testComputeZapRequestHashNonString() { + // Treats non-string as empty string + const hash = computeZapRequestHash(null); + const hashEmpty = computeZapRequestHash(""); + assert.equal(hash, hashEmpty); +})(); + +// --------------------------------------------------------------------------- +// extractDescriptionHashFromBolt11 +// --------------------------------------------------------------------------- + +(function testExtractDescriptionHashFromValidBolt11() { + const descriptionHashHex = "bb".repeat(32); + const invoice = buildTestBolt11Invoice({ amountCode: "1m", descriptionHashHex }); + const extracted = extractDescriptionHashFromBolt11(invoice); + assert.equal(extracted, descriptionHashHex); +})(); + +(function testExtractDescriptionHashFromBolt11WithoutHash() { + const invoice = buildTestBolt11Invoice({ amountCode: "1m" }); + const extracted = extractDescriptionHashFromBolt11(invoice); + assert.equal(extracted, null); +})(); + +(function testExtractDescriptionHashFromEmptyString() { + assert.equal(extractDescriptionHashFromBolt11(""), null); +})(); + +(function testExtractDescriptionHashFromNull() { + assert.equal(extractDescriptionHashFromBolt11(null), null); +})(); + +(function testExtractDescriptionHashFromUndefined() { + assert.equal(extractDescriptionHashFromBolt11(undefined), null); +})(); + +(function testExtractDescriptionHashFromGarbage() { + assert.equal(extractDescriptionHashFromBolt11("not-a-bolt11-invoice"), null); +})(); + +// --------------------------------------------------------------------------- +// validateZapReceipt — missing/invalid zap request +// --------------------------------------------------------------------------- + +async function testValidateSkippedWhenNoZapRequest() { + const result = await validateZapReceipt({}); + assert.equal(result.status, "skipped"); + assert.match(result.reason, /not provided/i); +} + +async function testValidateSkippedWhenZapRequestIsEmpty() { + const result = await validateZapReceipt({ zapRequest: "" }); + assert.equal(result.status, "skipped"); + assert.match(result.reason, /not provided/i); +} + +async function testValidateFailsWhenZapRequestIsInvalidJson() { + const result = await validateZapReceipt({ zapRequest: "not-json{{{" }); + assert.equal(result.status, "failed"); + assert.match(result.reason, /could not be parsed/i); +} + +// --------------------------------------------------------------------------- +// validateZapReceipt — no relays +// --------------------------------------------------------------------------- + +async function testValidateFailsWhenNoRelaysInZapRequest() { + const zapEvent = { + kind: 9734, + pubkey: "f".repeat(64), + tags: [["p", "a".repeat(64)]], + }; + const result = await validateZapReceipt({ + zapRequest: JSON.stringify(zapEvent), + }); + assert.equal(result.status, "failed"); + assert.match(result.reason, /relays/i); +} + +// --------------------------------------------------------------------------- +// validateZapReceipt — missing invoice +// --------------------------------------------------------------------------- + +async function testValidateFailsWhenNoInvoice() { + const zapEvent = buildZapRequest(); + const result = await validateZapReceipt({ + zapRequest: JSON.stringify(zapEvent), + }); + assert.equal(result.status, "failed"); + assert.match(result.reason, /invoice.*not available/i); +} + +// --------------------------------------------------------------------------- +// validateZapReceipt — description hash mismatch +// --------------------------------------------------------------------------- + +async function testValidateFailsOnDescriptionHashMismatch() { + const zapEvent = buildZapRequest(); + const zapRequestString = JSON.stringify(zapEvent); + const mismatchHash = "cc".repeat(32); + const bolt11 = buildTestBolt11Invoice({ descriptionHashHex: mismatchHash }); + + const result = await validateZapReceipt( + { + zapRequest: zapRequestString, + invoice: { invoice: bolt11 }, + }, + { + nostrTools: { + validateEvent: () => true, + verifyEvent: () => true, + SimplePool: class { + async list() { + return []; + } + close() {} + }, + }, + } + ); + + assert.equal(result.status, "failed"); + assert.match(result.reason, /description hash/i); +} + +// --------------------------------------------------------------------------- +// validateZapReceipt — invoice has no description hash +// --------------------------------------------------------------------------- + +async function testValidateFailsWhenInvoiceLacksDescriptionHash() { + const zapEvent = buildZapRequest(); + const zapRequestString = JSON.stringify(zapEvent); + const bolt11 = buildTestBolt11Invoice({}); + + const result = await validateZapReceipt( + { + zapRequest: zapRequestString, + invoice: { invoice: bolt11 }, + }, + { + nostrTools: { + validateEvent: () => true, + verifyEvent: () => true, + SimplePool: class { + async list() { + return []; + } + close() {} + }, + }, + } + ); + + assert.equal(result.status, "failed"); + assert.match(result.reason, /description hash/i); +} + +// --------------------------------------------------------------------------- +// validateZapReceipt — amount mismatch +// --------------------------------------------------------------------------- + +async function testValidateFailsOnAmountMismatch() { + const zapEvent = buildZapRequest(); + const zapRequestString = JSON.stringify(zapEvent); + const descriptionHash = computeZapRequestHash(zapRequestString); + const bolt11 = buildTestBolt11Invoice({ descriptionHashHex: descriptionHash }); + + const result = await validateZapReceipt( + { + zapRequest: zapRequestString, + amountSats: 500, + metadata: { nostrPubkey: "b".repeat(64) }, + invoice: { invoice: bolt11 }, + }, + { + nostrTools: { + validateEvent: () => true, + verifyEvent: () => true, + SimplePool: class { + async list() { + return []; + } + close() {} + }, + }, + getAmountFromBolt11: () => 999, + } + ); + + assert.equal(result.status, "failed"); + assert.match(result.reason, /amount.*did not match/i); +} + +// --------------------------------------------------------------------------- +// validateZapReceipt — missing nostrPubkey in metadata +// --------------------------------------------------------------------------- + +async function testValidateFailsWhenMetadataMissingPubkey() { + const zapEvent = buildZapRequest(); + const zapRequestString = JSON.stringify(zapEvent); + const descriptionHash = computeZapRequestHash(zapRequestString); + const bolt11 = buildTestBolt11Invoice({ descriptionHashHex: descriptionHash }); + + const result = await validateZapReceipt( + { + zapRequest: zapRequestString, + amountSats: 0, + metadata: {}, + invoice: { invoice: bolt11 }, + }, + { + nostrTools: { + validateEvent: () => true, + verifyEvent: () => true, + SimplePool: class { + async list() { + return []; + } + close() {} + }, + }, + } + ); + + assert.equal(result.status, "failed"); + assert.match(result.reason, /nostrPubkey/i); +} + +// --------------------------------------------------------------------------- +// validateZapReceipt — no receipt found on relays +// --------------------------------------------------------------------------- + +async function testValidateFailsWhenNoReceiptOnRelays() { + const zapEvent = buildZapRequest(); + const zapRequestString = JSON.stringify(zapEvent); + const descriptionHash = computeZapRequestHash(zapRequestString); + const bolt11 = buildTestBolt11Invoice({ descriptionHashHex: descriptionHash }); + + const result = await validateZapReceipt( + { + zapRequest: zapRequestString, + metadata: { nostrPubkey: "b".repeat(64) }, + invoice: { invoice: bolt11 }, + }, + { + nostrTools: { + validateEvent: () => true, + verifyEvent: () => true, + SimplePool: class { + async list() { + return []; + } + close() {} + }, + }, + } + ); + + assert.equal(result.status, "failed"); + assert.match(result.reason, /no zap receipt/i); +} + +// --------------------------------------------------------------------------- +// validateZapReceipt — receipt with wrong pubkey +// --------------------------------------------------------------------------- + +async function testValidateFailsWhenReceiptPubkeyDoesNotMatch() { + const relays = ["wss://relay.test"]; + const zapEvent = buildZapRequest({ relays }); + const zapRequestString = JSON.stringify(zapEvent); + const descriptionHash = computeZapRequestHash(zapRequestString); + const bolt11 = buildTestBolt11Invoice({ descriptionHashHex: descriptionHash }); + + const wrongPubkeyReceipt = { + kind: 9735, + pubkey: "d".repeat(64), + tags: [ + ["bolt11", bolt11], + ["description", zapRequestString], + ], + }; + + const result = await validateZapReceipt( + { + zapRequest: zapRequestString, + metadata: { nostrPubkey: "b".repeat(64) }, + invoice: { invoice: bolt11 }, + }, + { + nostrTools: { + validateEvent: () => true, + verifyEvent: () => true, + SimplePool: class { + async list() { + return [wrongPubkeyReceipt]; + } + close() {} + }, + }, + } + ); + + assert.equal(result.status, "failed"); + assert.match(result.reason, /no compliant zap receipt/i); +} + +// --------------------------------------------------------------------------- +// validateZapReceipt — receipt with invalid signature +// --------------------------------------------------------------------------- + +async function testValidateFailsWhenReceiptSignatureInvalid() { + const relays = ["wss://relay.test"]; + const zapEvent = buildZapRequest({ relays }); + const zapRequestString = JSON.stringify(zapEvent); + const descriptionHash = computeZapRequestHash(zapRequestString); + const bolt11 = buildTestBolt11Invoice({ descriptionHashHex: descriptionHash }); + + const receipt = { + kind: 9735, + pubkey: "b".repeat(64), + tags: [ + ["bolt11", bolt11], + ["description", zapRequestString], + ], + }; + + const result = await validateZapReceipt( + { + zapRequest: zapRequestString, + metadata: { nostrPubkey: "b".repeat(64) }, + invoice: { invoice: bolt11 }, + }, + { + nostrTools: { + validateEvent: () => true, + verifyEvent: () => false, + SimplePool: class { + async list() { + return [receipt]; + } + close() {} + }, + }, + } + ); + + assert.equal(result.status, "failed"); + assert.match(result.reason, /no compliant zap receipt/i); +} + +// --------------------------------------------------------------------------- +// validateZapReceipt — successful validation +// --------------------------------------------------------------------------- + +async function testValidateReceiptSuccess() { + const relays = ["wss://relay.test"]; + const zapEvent = buildZapRequest({ relays }); + const zapRequestString = JSON.stringify(zapEvent); + const descriptionHash = computeZapRequestHash(zapRequestString); + const bolt11 = buildTestBolt11Invoice({ descriptionHashHex: descriptionHash }); + + const receipt = { + kind: 9735, + pubkey: "b".repeat(64), + tags: [ + ["bolt11", bolt11], + ["description", zapRequestString], + ], + }; + + const result = await validateZapReceipt( + { + zapRequest: zapRequestString, + metadata: { nostrPubkey: "b".repeat(64) }, + invoice: { invoice: bolt11 }, + }, + { + nostrTools: { + validateEvent: () => true, + verifyEvent: () => true, + SimplePool: class { + async list() { + return [receipt]; + } + close() {} + }, + }, + } + ); + + assert.equal(result.status, "passed"); + assert.equal(result.event, receipt); + assert.deepEqual(result.checkedRelays, relays); +} + +// --------------------------------------------------------------------------- +// validateZapReceipt — override injection +// --------------------------------------------------------------------------- + +async function testValidateReceiptWithOverrides() { + const relays = ["wss://relay.test"]; + const zapEvent = buildZapRequest({ relays }); + const zapRequestString = JSON.stringify(zapEvent); + const descriptionHash = computeZapRequestHash(zapRequestString); + const bolt11 = buildTestBolt11Invoice({ descriptionHashHex: descriptionHash }); + + const receipt = { + kind: 9735, + pubkey: "b".repeat(64), + tags: [ + ["bolt11", bolt11], + ["description", zapRequestString], + ], + }; + + let listEventsCalled = false; + let createPoolCalled = false; + + const result = await validateZapReceipt( + { + zapRequest: zapRequestString, + amountSats: 42, + metadata: { nostrPubkey: "b".repeat(64) }, + invoice: { invoice: bolt11 }, + }, + { + nostrTools: { + validateEvent: () => true, + verifyEvent: () => true, + SimplePool: class { + async list() { + return []; + } + close() {} + }, + }, + createPool(tools) { + createPoolCalled = true; + return { + list: async () => [], + close() {}, + }; + }, + listEvents(pool, poolRelays, filters) { + listEventsCalled = true; + assert.deepEqual(poolRelays, relays); + return [receipt]; + }, + getAmountFromBolt11: () => 42, + } + ); + + assert.equal(result.status, "passed"); + assert(createPoolCalled, "createPool override should be called"); + assert(listEventsCalled, "listEvents override should be called"); +} + +// --------------------------------------------------------------------------- +// validateZapReceipt — nostrTools unavailable +// --------------------------------------------------------------------------- + +async function testValidateFailsWhenNostrToolsUnavailable() { + const zapEvent = buildZapRequest(); + const zapRequestString = JSON.stringify(zapEvent); + const descriptionHash = computeZapRequestHash(zapRequestString); + const bolt11 = buildTestBolt11Invoice({ descriptionHashHex: descriptionHash }); + + const result = await validateZapReceipt( + { + zapRequest: zapRequestString, + metadata: { nostrPubkey: "b".repeat(64) }, + invoice: { invoice: bolt11 }, + }, + { + nostrTools: 42, // truthy but not an object → triggers "unavailable" + } + ); + + assert.equal(result.status, "failed"); + assert.match(result.reason, /unavailable/i); +} + +// --------------------------------------------------------------------------- +// validateZapReceipt — nostrTools missing validate/verify +// --------------------------------------------------------------------------- + +async function testValidateFailsWhenToolsMissingValidators() { + const zapEvent = buildZapRequest(); + const zapRequestString = JSON.stringify(zapEvent); + const descriptionHash = computeZapRequestHash(zapRequestString); + const bolt11 = buildTestBolt11Invoice({ descriptionHashHex: descriptionHash }); + + const result = await validateZapReceipt( + { + zapRequest: zapRequestString, + metadata: { nostrPubkey: "b".repeat(64) }, + invoice: { invoice: bolt11 }, + }, + { + nostrTools: { someOtherProp: true }, + } + ); + + assert.equal(result.status, "failed"); + assert.match(result.reason, /missing/i); +} + +// --------------------------------------------------------------------------- +// validateZapReceipt — invoice from payment object +// --------------------------------------------------------------------------- + +async function testValidateExtractsInvoiceFromPayment() { + const relays = ["wss://relay.test"]; + const zapEvent = buildZapRequest({ relays }); + const zapRequestString = JSON.stringify(zapEvent); + const descriptionHash = computeZapRequestHash(zapRequestString); + const bolt11 = buildTestBolt11Invoice({ descriptionHashHex: descriptionHash }); + + const receipt = { + kind: 9735, + pubkey: "b".repeat(64), + tags: [ + ["bolt11", bolt11], + ["description", zapRequestString], + ], + }; + + const result = await validateZapReceipt( + { + zapRequest: zapRequestString, + metadata: { nostrPubkey: "b".repeat(64) }, + payment: { bolt11 }, + }, + { + nostrTools: { + validateEvent: () => true, + verifyEvent: () => true, + SimplePool: class { + async list() { + return [receipt]; + } + close() {} + }, + }, + } + ); + + assert.equal(result.status, "passed"); +} + +// --------------------------------------------------------------------------- +// validateZapReceipt — receipt missing description tag +// --------------------------------------------------------------------------- + +async function testValidateFailsWhenReceiptMissingDescription() { + const relays = ["wss://relay.test"]; + const zapEvent = buildZapRequest({ relays }); + const zapRequestString = JSON.stringify(zapEvent); + const descriptionHash = computeZapRequestHash(zapRequestString); + const bolt11 = buildTestBolt11Invoice({ descriptionHashHex: descriptionHash }); + + const receiptNoDesc = { + kind: 9735, + pubkey: "b".repeat(64), + tags: [["bolt11", bolt11]], + }; + + const result = await validateZapReceipt( + { + zapRequest: zapRequestString, + metadata: { nostrPubkey: "b".repeat(64) }, + invoice: { invoice: bolt11 }, + }, + { + nostrTools: { + validateEvent: () => true, + verifyEvent: () => true, + SimplePool: class { + async list() { + return [receiptNoDesc]; + } + close() {} + }, + }, + } + ); + + assert.equal(result.status, "failed"); + assert.match(result.reason, /no compliant zap receipt/i); +} + +// Run all async tests +await testValidateSkippedWhenNoZapRequest(); +await testValidateSkippedWhenZapRequestIsEmpty(); +await testValidateFailsWhenZapRequestIsInvalidJson(); +await testValidateFailsWhenNoRelaysInZapRequest(); +await testValidateFailsWhenNoInvoice(); +await testValidateFailsOnDescriptionHashMismatch(); +await testValidateFailsWhenInvoiceLacksDescriptionHash(); +await testValidateFailsOnAmountMismatch(); +await testValidateFailsWhenMetadataMissingPubkey(); +await testValidateFailsWhenNoReceiptOnRelays(); +await testValidateFailsWhenReceiptPubkeyDoesNotMatch(); +await testValidateFailsWhenReceiptSignatureInvalid(); +await testValidateReceiptSuccess(); +await testValidateReceiptWithOverrides(); +await testValidateFailsWhenNostrToolsUnavailable(); +await testValidateFailsWhenToolsMissingValidators(); +await testValidateExtractsInvoiceFromPayment(); +await testValidateFailsWhenReceiptMissingDescription(); + +console.log("zap-receipt-validator tests passed"); diff --git a/tests/zap-requests.test.mjs b/tests/zap-requests.test.mjs new file mode 100644 index 00000000..52b53d66 --- /dev/null +++ b/tests/zap-requests.test.mjs @@ -0,0 +1,280 @@ +import assert from "node:assert/strict"; + +if (typeof globalThis.window === "undefined") { + globalThis.window = {}; +} + +if (typeof globalThis.localStorage === "undefined") { + const storage = new Map(); + globalThis.localStorage = { + getItem(key) { + return storage.has(key) ? storage.get(key) : null; + }, + setItem(key, value) { + storage.set(String(key), String(value)); + }, + removeItem(key) { + storage.delete(String(key)); + }, + clear() { + storage.clear(); + }, + }; +} + +if (typeof window.localStorage === "undefined") { + window.localStorage = globalThis.localStorage; +} + +const nostrToolsStub = { + getEventHash(event) { + const base = JSON.stringify(event); + let hash = 0; + for (let i = 0; i < base.length; i += 1) { + hash = (hash * 33 + base.charCodeAt(i)) % 0xffffffff; + } + return hash.toString(16).padStart(8, "0"); + }, + signEvent(event, secret) { + return `sig-${(secret || "").slice(0, 8)}-${event.kind}`; + }, + nip19: { + decode(value) { + if (typeof value === "string" && value.startsWith("npub")) { + return { type: "npub", data: "f".repeat(64) }; + } + return null; + }, + }, + SimplePool: class { + async list() { + return []; + } + sub() { + return { on() {}, unsub() {} }; + } + async get() { + return null; + } + async ensureRelay() { + return { close() {}, url: "" }; + } + }, +}; + +window.NostrTools = nostrToolsStub; +globalThis.NostrTools = nostrToolsStub; + +globalThis.fetch = async () => { + throw new Error("Unexpected fetch call during zap-requests tests."); +}; + +const { + buildZapRequestPayload, + signZapRequest, + __TESTING__, +} = await import("../js/payments/zapRequests.js"); + +const { normalizeRelayList, resolveLnurlTag } = __TESTING__; + +const { encodeLnurlBech32 } = await import("../js/payments/lnurl.js"); + +// --------------------------------------------------------------------------- +// normalizeRelayList +// --------------------------------------------------------------------------- + +(function testNormalizeRelayListDeduplicates() { + const result = normalizeRelayList([ + "wss://relay.one", + "wss://relay.two", + "wss://relay.one", + "wss://relay.two", + ]); + assert.deepEqual(result, ["wss://relay.one", "wss://relay.two"]); +})(); + +(function testNormalizeRelayListFiltersNonStrings() { + const result = normalizeRelayList(["wss://relay.one", 123, null, undefined, "", "wss://relay.two"]); + assert.deepEqual(result, ["wss://relay.one", "wss://relay.two"]); +})(); + +(function testNormalizeRelayListTrimsWhitespace() { + const result = normalizeRelayList([" wss://relay.one ", "wss://relay.two "]); + assert.deepEqual(result, ["wss://relay.one", "wss://relay.two"]); +})(); + +(function testNormalizeRelayListEmptyInput() { + assert.deepEqual(normalizeRelayList([]), []); + assert.deepEqual(normalizeRelayList(null), []); + assert.deepEqual(normalizeRelayList(undefined), []); + assert.deepEqual(normalizeRelayList("not-array"), []); +})(); + +// --------------------------------------------------------------------------- +// resolveLnurlTag +// --------------------------------------------------------------------------- + +(function testResolveLnurlTagWithBech32Address() { + const lnurlAddress = encodeLnurlBech32("https://example.com/callback"); + const resolved = { address: lnurlAddress, url: "https://example.com/callback" }; + const tag = resolveLnurlTag(resolved); + assert.equal(tag, lnurlAddress.toLowerCase()); +})(); + +(function testResolveLnurlTagWithUrlAddress() { + const resolved = { address: "alice@example.com", url: "https://example.com/.well-known/lnurlp/alice" }; + const tag = resolveLnurlTag(resolved); + assert(tag.startsWith("lnurl"), "should encode URL to bech32 lnurl"); +})(); + +(function testResolveLnurlTagWithNoAddress() { + const tag = resolveLnurlTag({ url: "" }); + assert.equal(tag, ""); +})(); + +(function testResolveLnurlTagWithNull() { + const tag = resolveLnurlTag(null); + assert.equal(tag, ""); +})(); + +// --------------------------------------------------------------------------- +// buildZapRequestPayload +// --------------------------------------------------------------------------- + +(function testBuildZapRequestPayloadMinimal() { + const event = buildZapRequestPayload({ + senderPubkey: "a".repeat(64), + recipientPubkey: "b".repeat(64), + amountSats: 100, + }); + + assert.equal(event.kind, 9734); + assert.equal(event.pubkey, "a".repeat(64)); + assert(event.created_at > 0, "should have a timestamp"); + + const pTag = event.tags.find((t) => t[0] === "p"); + assert(pTag, "should have a p tag"); + assert.equal(pTag[1], "b".repeat(64)); + + const amountTag = event.tags.find((t) => t[0] === "amount"); + assert(amountTag, "should have an amount tag"); + assert.equal(amountTag[1], "100000"); +})(); + +(function testBuildZapRequestPayloadAllParams() { + const event = buildZapRequestPayload({ + senderPubkey: "a".repeat(64), + recipientPubkey: "b".repeat(64), + relays: ["wss://relay.one", "wss://relay.two"], + amountSats: 500, + comment: "Test zap", + lnurl: "lnurl1test", + eventId: "c".repeat(64), + coordinate: "30078:bbbb:pointer", + createdAt: 1700000000, + additionalTags: [["custom", "value"]], + }); + + assert.equal(event.kind, 9734); + assert.equal(event.pubkey, "a".repeat(64)); + assert.equal(event.created_at, 1700000000); + assert.equal(event.content, "Test zap"); + + const relaysTag = event.tags.find((t) => t[0] === "relays"); + assert(relaysTag, "should have relays tag"); + assert(relaysTag.includes("wss://relay.one")); + assert(relaysTag.includes("wss://relay.two")); + + const eTag = event.tags.find((t) => t[0] === "e"); + assert(eTag, "should have an e tag for eventId"); + assert.equal(eTag[1], "c".repeat(64)); + + const aTag = event.tags.find((t) => t[0] === "a"); + assert(aTag, "should have an a tag for coordinate"); + assert.equal(aTag[1], "30078:bbbb:pointer"); + + const lnurlTag = event.tags.find((t) => t[0] === "lnurl"); + assert(lnurlTag, "should have lnurl tag"); + assert.equal(lnurlTag[1], "lnurl1test"); + + const customTag = event.tags.find((t) => t[0] === "custom"); + assert(customTag, "should include additionalTags"); + assert.equal(customTag[1], "value"); +})(); + +(function testBuildZapRequestPayloadCreatedAtDefault() { + const before = Math.floor(Date.now() / 1000); + const event = buildZapRequestPayload({ + senderPubkey: "a".repeat(64), + recipientPubkey: "b".repeat(64), + amountSats: 10, + }); + const after = Math.floor(Date.now() / 1000); + assert(event.created_at >= before && event.created_at <= after); +})(); + +(function testBuildZapRequestPayloadCustomCreatedAt() { + const event = buildZapRequestPayload({ + senderPubkey: "a".repeat(64), + recipientPubkey: "b".repeat(64), + amountSats: 10, + createdAt: 1234567890, + }); + assert.equal(event.created_at, 1234567890); +})(); + +// --------------------------------------------------------------------------- +// signZapRequest +// --------------------------------------------------------------------------- + +async function testSignZapRequestRequiresSigner() { + const event = buildZapRequestPayload({ + senderPubkey: "a".repeat(64), + recipientPubkey: "b".repeat(64), + amountSats: 10, + }); + + await assert.rejects( + () => signZapRequest(event, null), + /signer is required/i + ); + + await assert.rejects( + () => signZapRequest(event, {}), + /signer is required/i + ); + + await assert.rejects( + () => signZapRequest(event, { signEvent: "not-a-function" }), + /signer is required/i + ); +} + +async function testSignZapRequestCallsSigner() { + const event = buildZapRequestPayload({ + senderPubkey: "a".repeat(64), + recipientPubkey: "b".repeat(64), + amountSats: 10, + }); + + let signedWith = null; + const signer = { + signEvent(eventToSign) { + signedWith = eventToSign; + return { ...eventToSign, sig: "test-signature" }; + }, + }; + + const signed = await signZapRequest(event, signer); + assert(signedWith, "signer should have been called"); + assert.equal(signed.sig, "test-signature"); +} + +// --------------------------------------------------------------------------- +// Run async tests +// --------------------------------------------------------------------------- + +await testSignZapRequestRequiresSigner(); +await testSignZapRequestCallsSigner(); + +console.log("zap-requests tests passed");