mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-30 17:32:26 +00:00
test: add comprehensive payment system tests (lnurl, platformFee, zapReceipts, zapRequests)
Add 4 new test files covering previously untested payment/zap logic: - lnurl.test.mjs: bech32 encode/decode roundtrip, resolveLightningAddress (email, lnurl, URL formats), fetchPayServiceData (success, HTTP errors, invalid JSON, error status), validateInvoiceAmount (boundaries, invalid inputs), requestInvoice (comment truncation, amount formats, error paths) - platform-fee.test.mjs: parsePercentValue (numbers, bigints, strings with %, fractions, edge cases), clampPercent (boundaries, NaN, non-finite), getDefaultPlatformFeePercent, resolvePlatformFeePercent (override vs fallback behavior) - zap-receipt-validator.test.mjs: computeZapRequestHash determinism, extractDescriptionHashFromBolt11, validateZapReceipt covering all failure modes (missing request, invalid JSON, no relays, missing invoice, hash mismatch, amount mismatch, wrong pubkey, invalid signature, no receipt found) plus success path and override injection - zap-requests.test.mjs: normalizeRelayList (dedup, filtering, empty), resolveLnurlTag (bech32 vs email addresses), buildZapRequestPayload (minimal and full params, timestamp handling), signZapRequest (signer validation) Also updates todo-test-coverage-gaps.md with a prioritized, detailed plan covering all remaining untested areas (P0 through P3). https://claude.ai/code/session_01QDsPRh4f31ZpDYqS1kETFV
This commit is contained in:
5 files changed
+2222
-74
No files matched your search
@@ -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
|
||||
@@ -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("[email protected]");
|
||||
assert.equal(result.type, "lud16");
|
||||
assert.equal(result.address, "[email protected]");
|
||||
assert(result.url.includes("example.com"));
|
||||
assert(result.url.includes("/.well-known/lnurlp/alice"));
|
||||
})();
|
||||
|
||||
(function testResolveEmailFormatLowercase() {
|
||||
const result = resolveLightningAddress("[email protected]");
|
||||
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(" [email protected] ");
|
||||
assert.equal(result.address, "[email protected]");
|
||||
})();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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");
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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: "[email protected]", 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");
|
||||
Reference in new issue
Block a user