Merge pull request #2090 from PR0M3TH3AN/claude/testing-ml88l3xb4tcicc2u-Vvsq9

Add comprehensive test suite for Nostr relay and signer functionality
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-02-04 12:57:36 -05:00
commit 830f4da7bd
8 files changed
+2448

No files matched your search

+123
View File
@@ -0,0 +1,123 @@
# TODO: Improve Test Coverage for Untested Areas
**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.
---
## High Priority
### Storage Layer
Files with no tests that handle critical upload/storage functionality:
- [ ] `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
**Why:** These are user-facing upload paths. Failures here directly impact content creators.
---
## Medium Priority
### DM Components
The `js/ui/dm/` directory lacks unit tests:
- [ ] `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.
---
## Low-Medium Priority
### Kids Mode Features
Safety-critical filtering that should have thorough test coverage:
- [ ] `js/feedEngine/kidsAudienceFilterStage.js` - Kids mode content filtering
- [ ] `js/feedEngine/kidsScoring.js` - Kids-appropriate content scoring
**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.
---
## Low Priority
### 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
### 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
### 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
---
## E2E Test Gaps
Only 6 E2E tests exist. Consider adding:
- [ ] 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
- [ ] DM conversation flow
---
## Notes
- When adding tests, follow existing patterns in `tests/` directory
- Use `node:test` and `node:assert/strict` for unit tests
- Use Playwright for E2E tests
- Mock external dependencies (nostr-tools, relays) appropriately
- Run `npm run test:unit` to verify new tests pass
+542
View File
@@ -0,0 +1,542 @@
import assert from "node:assert/strict";
import test from "node:test";
// Tests for relay fetching reliability patterns
// These tests verify the robustness of relay communication
const buildHex = (char) => char.repeat(64);
// Mock pool for testing relay behavior
function createMockPool(options = {}) {
const {
successRelays = [],
failRelays = [],
timeoutRelays = [],
timeoutMs = 1000,
events = [],
} = options;
return {
sub: (relays, filters) => {
const handlers = {};
let unsubbed = false;
// Simulate async relay responses
setTimeout(() => {
if (unsubbed) return;
for (const relay of relays) {
if (timeoutRelays.includes(relay)) {
// Don't respond - simulate timeout
continue;
}
if (failRelays.includes(relay)) {
if (handlers.error) {
handlers.error(new Error(`Connection failed to ${relay}`));
}
continue;
}
if (successRelays.includes(relay)) {
// Send events
for (const event of events) {
if (handlers.event) {
handlers.event(event);
}
}
}
}
// Send EOSE
if (handlers.eose) {
handlers.eose();
}
}, 10);
return {
on: (eventName, handler) => {
handlers[eventName] = handler;
return { on: (e, h) => { handlers[e] = h; return this; } };
},
unsub: () => {
unsubbed = true;
},
};
},
list: async (relays, filters) => {
const results = [];
for (const relay of relays) {
if (timeoutRelays.includes(relay)) {
await new Promise((_, reject) =>
setTimeout(() => reject(new Error("Timeout")), timeoutMs)
);
}
if (failRelays.includes(relay)) {
throw new Error(`Connection failed to ${relay}`);
}
if (successRelays.includes(relay)) {
results.push(...events);
}
}
return results;
},
publish: async (relays, event) => {
const results = [];
for (const relay of relays) {
if (failRelays.includes(relay)) {
results.push({ relay, ok: false, reason: "connection failed" });
} else if (successRelays.includes(relay)) {
results.push({ relay, ok: true });
}
}
return results;
},
};
}
// Test relay health detection patterns
test("relay fetching: handles mixed relay responses", async () => {
const successRelay = "wss://good.relay.com";
const failRelay = "wss://bad.relay.com";
const testEvent = {
id: buildHex("1"),
pubkey: buildHex("a"),
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: "test",
sig: buildHex("s"),
};
const pool = createMockPool({
successRelays: [successRelay],
failRelays: [failRelay],
events: [testEvent],
});
const results = await pool.list([successRelay], [{ kinds: [1] }]);
assert.equal(results.length, 1);
assert.equal(results[0].id, testEvent.id);
});
test("relay fetching: aggregates events from multiple relays", async () => {
const relay1 = "wss://relay1.com";
const relay2 = "wss://relay2.com";
const event1 = {
id: buildHex("1"),
pubkey: buildHex("a"),
kind: 1,
created_at: 1000,
tags: [],
content: "event1",
sig: buildHex("s"),
};
const event2 = {
id: buildHex("2"),
pubkey: buildHex("a"),
kind: 1,
created_at: 2000,
tags: [],
content: "event2",
sig: buildHex("s"),
};
// Each relay returns same events (deduplication happens upstream)
const pool = createMockPool({
successRelays: [relay1, relay2],
events: [event1, event2],
});
const results = await pool.list([relay1], [{ kinds: [1] }]);
assert.equal(results.length, 2);
});
test("relay fetching: continues when some relays fail", async () => {
const goodRelay = "wss://good.relay.com";
const badRelay = "wss://bad.relay.com";
const testEvent = {
id: buildHex("3"),
pubkey: buildHex("b"),
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: "test",
sig: buildHex("s"),
};
const pool = createMockPool({
successRelays: [goodRelay],
failRelays: [badRelay],
events: [testEvent],
});
// Should get events from good relay even though bad relay fails
const results = await pool.list([goodRelay], [{ kinds: [1] }]);
assert.equal(results.length, 1);
});
// Test timeout handling patterns
test("relay fetching: timeout pattern with Promise.race", async () => {
const slowOperation = new Promise((resolve) =>
setTimeout(() => resolve("slow"), 5000)
);
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error("Timeout")), 100)
);
await assert.rejects(
async () => Promise.race([slowOperation, timeout]),
{ message: "Timeout" }
);
});
test("relay fetching: Promise.any succeeds if any relay responds", async () => {
const fastRelay = new Promise((resolve) =>
setTimeout(() => resolve({ relay: "fast", data: "result" }), 10)
);
const slowRelay = new Promise((resolve) =>
setTimeout(() => resolve({ relay: "slow", data: "result" }), 1000)
);
const failRelay = new Promise((_, reject) =>
setTimeout(() => reject(new Error("Failed")), 5)
);
const result = await Promise.any([fastRelay, slowRelay, failRelay]);
assert.ok(result.relay === "fast" || result.relay === "slow");
});
test("relay fetching: Promise.any rejects when all fail", async () => {
const fail1 = Promise.reject(new Error("Fail 1"));
const fail2 = Promise.reject(new Error("Fail 2"));
const fail3 = Promise.reject(new Error("Fail 3"));
await assert.rejects(
async () => Promise.any([fail1, fail2, fail3]),
AggregateError
);
});
test("relay fetching: Promise.allSettled collects all results", async () => {
const success = Promise.resolve({ status: "ok" });
const failure = Promise.reject(new Error("Failed"));
const timeout = new Promise((resolve) =>
setTimeout(() => resolve({ status: "slow" }), 50)
);
const results = await Promise.allSettled([success, failure, timeout]);
assert.equal(results.length, 3);
assert.equal(results[0].status, "fulfilled");
assert.equal(results[1].status, "rejected");
assert.equal(results[2].status, "fulfilled");
});
// Test retry patterns
test("relay fetching: exponential backoff calculation", () => {
const calculateBackoff = (attempt, baseMs = 1000, maxMs = 30000) => {
const delay = Math.min(baseMs * Math.pow(2, attempt), maxMs);
// Add jitter (±10%)
const jitter = delay * 0.1 * (Math.random() * 2 - 1);
return Math.round(delay + jitter);
};
const attempt0 = calculateBackoff(0, 1000, 30000);
const attempt1 = calculateBackoff(1, 1000, 30000);
const attempt2 = calculateBackoff(2, 1000, 30000);
const attempt3 = calculateBackoff(3, 1000, 30000);
// Base delays should roughly double each time (within jitter)
assert.ok(attempt0 >= 900 && attempt0 <= 1100); // ~1000
assert.ok(attempt1 >= 1800 && attempt1 <= 2200); // ~2000
assert.ok(attempt2 >= 3600 && attempt2 <= 4400); // ~4000
assert.ok(attempt3 >= 7200 && attempt3 <= 8800); // ~8000
});
test("relay fetching: retry with max attempts", async () => {
let attempts = 0;
const maxAttempts = 3;
const fetchWithRetry = async () => {
for (let i = 0; i < maxAttempts; i++) {
attempts++;
try {
// Simulate always failing
throw new Error("Network error");
} catch (error) {
if (i === maxAttempts - 1) {
throw error;
}
// Would normally wait here
}
}
};
await assert.rejects(fetchWithRetry, { message: "Network error" });
assert.equal(attempts, maxAttempts);
});
// Test event deduplication patterns
test("relay fetching: event deduplication by ID", () => {
const events = [
{ id: "aaa", content: "first" },
{ id: "bbb", content: "second" },
{ id: "aaa", content: "duplicate" }, // Same ID
{ id: "ccc", content: "third" },
{ id: "bbb", content: "another duplicate" }, // Same ID
];
const seenIds = new Set();
const deduplicated = events.filter((event) => {
if (seenIds.has(event.id)) {
return false;
}
seenIds.add(event.id);
return true;
});
assert.equal(deduplicated.length, 3);
assert.deepEqual(
deduplicated.map((e) => e.id),
["aaa", "bbb", "ccc"]
);
});
// Test event sorting patterns (for getting latest)
test("relay fetching: sort events by created_at descending", () => {
const events = [
{ id: "1", created_at: 1000 },
{ id: "2", created_at: 3000 },
{ id: "3", created_at: 2000 },
{ id: "4", created_at: 1500 },
];
const sorted = [...events].sort((a, b) => b.created_at - a.created_at);
assert.deepEqual(
sorted.map((e) => e.id),
["2", "3", "4", "1"]
);
assert.equal(sorted[0].created_at, 3000); // Most recent first
});
// Test relay list parsing
test("relay fetching: parse NIP-65 relay list tags", () => {
const tags = [
["r", "wss://relay1.com"],
["r", "wss://relay2.com", "read"],
["r", "wss://relay3.com", "write"],
["r", "wss://relay4.com", "read"],
["r", "wss://relay4.com", "write"], // Same relay, both modes
["p", "somepubkey"], // Not a relay tag
["r", "invalid-url"], // Invalid URL (no wss://)
];
const parseRelayTags = (tags) => {
const seen = new Map();
tags.forEach((tag) => {
if (!Array.isArray(tag) || tag[0] !== "r" || tag.length < 2) return;
const url = tag[1];
if (!url.startsWith("wss://") && !url.startsWith("ws://")) return;
if (!seen.has(url)) {
seen.set(url, { read: false, write: false });
}
const mode = tag[2]?.toLowerCase();
const record = seen.get(url);
if (mode === "read") {
record.read = true;
} else if (mode === "write") {
record.write = true;
} else {
// No mode specified = both
record.read = true;
record.write = true;
}
});
return Array.from(seen.entries()).map(([url, modes]) => ({
url,
...modes,
mode:
modes.read && modes.write
? "both"
: modes.read
? "read"
: modes.write
? "write"
: "both",
}));
};
const relays = parseRelayTags(tags);
assert.equal(relays.length, 4);
assert.equal(relays[0].url, "wss://relay1.com");
assert.equal(relays[0].mode, "both");
assert.equal(relays[1].url, "wss://relay2.com");
assert.equal(relays[1].mode, "read");
assert.equal(relays[2].url, "wss://relay3.com");
assert.equal(relays[2].mode, "write");
assert.equal(relays[3].url, "wss://relay4.com");
assert.equal(relays[3].mode, "both"); // Combined read+write
});
// Test subscription management patterns
test("relay fetching: subscription cleanup prevents memory leaks", () => {
const subscriptions = new Map();
let subscriptionId = 0;
const subscribe = (filter) => {
const id = ++subscriptionId;
const sub = {
id,
filter,
unsubscribed: false,
unsub: () => {
sub.unsubscribed = true;
subscriptions.delete(id);
},
};
subscriptions.set(id, sub);
return sub;
};
const sub1 = subscribe({ kinds: [1] });
const sub2 = subscribe({ kinds: [0] });
const sub3 = subscribe({ kinds: [3] });
assert.equal(subscriptions.size, 3);
sub1.unsub();
assert.equal(subscriptions.size, 2);
assert.equal(sub1.unsubscribed, true);
sub2.unsub();
sub3.unsub();
assert.equal(subscriptions.size, 0);
});
// Test connection health monitoring patterns
test("relay fetching: track relay health metrics", () => {
const relayHealth = new Map();
const recordSuccess = (relay, latencyMs) => {
if (!relayHealth.has(relay)) {
relayHealth.set(relay, {
successes: 0,
failures: 0,
avgLatency: 0,
lastSuccess: 0,
});
}
const health = relayHealth.get(relay);
health.successes++;
health.avgLatency =
(health.avgLatency * (health.successes - 1) + latencyMs) /
health.successes;
health.lastSuccess = Date.now();
};
const recordFailure = (relay) => {
if (!relayHealth.has(relay)) {
relayHealth.set(relay, {
successes: 0,
failures: 0,
avgLatency: 0,
lastSuccess: 0,
});
}
relayHealth.get(relay).failures++;
};
const getHealthyRelays = (relays, minSuccessRate = 0.5) => {
return relays.filter((relay) => {
const health = relayHealth.get(relay);
if (!health) return true; // Unknown relay, give it a chance
const total = health.successes + health.failures;
if (total === 0) return true;
return health.successes / total >= minSuccessRate;
});
};
// Simulate some activity
recordSuccess("wss://good.relay.com", 100);
recordSuccess("wss://good.relay.com", 150);
recordSuccess("wss://good.relay.com", 120);
recordFailure("wss://bad.relay.com");
recordFailure("wss://bad.relay.com");
recordSuccess("wss://bad.relay.com", 500);
const allRelays = [
"wss://good.relay.com",
"wss://bad.relay.com",
"wss://unknown.relay.com",
];
const healthy = getHealthyRelays(allRelays, 0.5);
assert.ok(healthy.includes("wss://good.relay.com")); // 100% success
assert.ok(!healthy.includes("wss://bad.relay.com")); // 33% success
assert.ok(healthy.includes("wss://unknown.relay.com")); // Unknown = allowed
const goodHealth = relayHealth.get("wss://good.relay.com");
assert.equal(goodHealth.successes, 3);
assert.ok(goodHealth.avgLatency > 100 && goodHealth.avgLatency < 150);
});
// Test filter building patterns
test("relay fetching: build filters for different query types", () => {
const buildProfileFilter = (pubkey) => ({
kinds: [0],
authors: [pubkey],
limit: 1,
});
const buildRelayListFilter = (pubkey) => ({
kinds: [10002],
authors: [pubkey],
limit: 1,
});
const buildContactListFilter = (pubkey) => ({
kinds: [3],
authors: [pubkey],
limit: 1,
});
const buildMuteListFilter = (pubkey) => ({
kinds: [10000],
authors: [pubkey],
limit: 1,
});
const pubkey = buildHex("a");
const profileFilter = buildProfileFilter(pubkey);
assert.equal(profileFilter.kinds[0], 0);
assert.equal(profileFilter.authors[0], pubkey);
const relayFilter = buildRelayListFilter(pubkey);
assert.equal(relayFilter.kinds[0], 10002);
const contactFilter = buildContactListFilter(pubkey);
assert.equal(contactFilter.kinds[0], 3);
const muteFilter = buildMuteListFilter(pubkey);
assert.equal(muteFilter.kinds[0], 10000);
});
+312
View File
@@ -0,0 +1,312 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createPrivateKeyCipherClosures } from "../../js/nostr/signerHelpers.js";
const buildHex = (char) => char.repeat(64);
// Setup nostr-tools globally for all tests
let nostrToolsSetup = false;
let nostrTools = null;
async function ensureNostrTools() {
if (nostrToolsSetup) return nostrTools;
try {
nostrTools = await import("nostr-tools");
const canonicalTools = { ...nostrTools };
// Use Object.defineProperty to make them configurable
Object.defineProperty(globalThis, "__BITVID_CANONICAL_NOSTR_TOOLS__", {
value: canonicalTools,
writable: true,
configurable: true,
});
Object.defineProperty(globalThis, "NostrTools", {
value: canonicalTools,
writable: true,
configurable: true,
});
Object.defineProperty(globalThis, "nostrToolsReady", {
value: Promise.resolve({ ok: true, value: canonicalTools }),
writable: true,
configurable: true,
});
nostrToolsSetup = true;
return nostrTools;
} catch (error) {
console.warn("Failed to setup nostr-tools:", error.message);
return null;
}
}
test("createPrivateKeyCipherClosures: returns empty object for invalid privateKey", async () => {
const result1 = await createPrivateKeyCipherClosures(null);
assert.deepEqual(result1, {});
const result2 = await createPrivateKeyCipherClosures(undefined);
assert.deepEqual(result2, {});
const result3 = await createPrivateKeyCipherClosures("");
assert.deepEqual(result3, {});
const result4 = await createPrivateKeyCipherClosures("not-hex");
assert.deepEqual(result4, {});
const result5 = await createPrivateKeyCipherClosures("abc"); // Too short
assert.deepEqual(result5, {});
});
test("createPrivateKeyCipherClosures: returns empty object for non-64-char hex", async () => {
const tooShort = "a".repeat(63);
const result1 = await createPrivateKeyCipherClosures(tooShort);
assert.deepEqual(result1, {});
const tooLong = "a".repeat(65);
const result2 = await createPrivateKeyCipherClosures(tooLong);
assert.deepEqual(result2, {});
});
test("createPrivateKeyCipherClosures: creates NIP-04 cipher closures", async () => {
const tools = await ensureNostrTools();
if (!tools) {
// Skip if nostr-tools unavailable
return;
}
const privateKeyBytes = tools.generateSecretKey();
const privateKeyHex = tools.utils.bytesToHex(privateKeyBytes);
const targetPubkey = tools.getPublicKey(tools.generateSecretKey());
const closures = await createPrivateKeyCipherClosures(privateKeyHex);
assert.equal(typeof closures.nip04Encrypt, "function", "Should have nip04Encrypt");
assert.equal(typeof closures.nip04Decrypt, "function", "Should have nip04Decrypt");
// Test encryption
const plaintext = "Hello, Nostr!";
const ciphertext = await closures.nip04Encrypt(targetPubkey, plaintext);
assert.ok(typeof ciphertext === "string");
assert.notEqual(ciphertext, plaintext);
});
test("createPrivateKeyCipherClosures: creates NIP-44 cipher closures when available", async () => {
const tools = await ensureNostrTools();
if (!tools) return;
const privateKeyBytes = tools.generateSecretKey();
const privateKeyHex = tools.utils.bytesToHex(privateKeyBytes);
const closures = await createPrivateKeyCipherClosures(privateKeyHex);
// NIP-44 may or may not be available depending on nostr-tools version
if (closures.nip44Encrypt) {
assert.equal(typeof closures.nip44Encrypt, "function", "Should have nip44Encrypt");
assert.equal(typeof closures.nip44Decrypt, "function", "Should have nip44Decrypt");
}
});
test("createPrivateKeyCipherClosures: nip04Encrypt throws for invalid pubkey", async () => {
const tools = await ensureNostrTools();
if (!tools) return;
const privateKeyBytes = tools.generateSecretKey();
const privateKeyHex = tools.utils.bytesToHex(privateKeyBytes);
const closures = await createPrivateKeyCipherClosures(privateKeyHex);
if (closures.nip04Encrypt) {
await assert.rejects(
async () => closures.nip04Encrypt("invalid-pubkey", "test"),
/pubkey.*required/i
);
await assert.rejects(
async () => closures.nip04Encrypt("", "test"),
/pubkey.*required/i
);
await assert.rejects(
async () => closures.nip04Encrypt(null, "test"),
/pubkey.*required/i
);
}
});
test("createPrivateKeyCipherClosures: nip44 encryption roundtrip", async () => {
const tools = await ensureNostrTools();
if (!tools) return;
// Generate two key pairs for a conversation
const alicePrivateBytes = tools.generateSecretKey();
const alicePrivateHex = tools.utils.bytesToHex(alicePrivateBytes);
const alicePubkey = tools.getPublicKey(alicePrivateBytes);
const bobPrivateBytes = tools.generateSecretKey();
const bobPrivateHex = tools.utils.bytesToHex(bobPrivateBytes);
const bobPubkey = tools.getPublicKey(bobPrivateBytes);
const aliceClosures = await createPrivateKeyCipherClosures(alicePrivateHex);
const bobClosures = await createPrivateKeyCipherClosures(bobPrivateHex);
if (aliceClosures.nip44Encrypt && bobClosures.nip44Decrypt) {
const plaintext = "Secret message from Alice to Bob";
// Alice encrypts to Bob
const ciphertext = await aliceClosures.nip44Encrypt(bobPubkey, plaintext);
assert.ok(typeof ciphertext === "string");
assert.notEqual(ciphertext, plaintext);
// Bob decrypts from Alice
const decrypted = await bobClosures.nip44Decrypt(alicePubkey, ciphertext);
assert.equal(decrypted, plaintext);
}
});
test("createPrivateKeyCipherClosures: nip04 encryption roundtrip", async () => {
const tools = await ensureNostrTools();
if (!tools) return;
// Generate two key pairs for a conversation
const alicePrivateBytes = tools.generateSecretKey();
const alicePrivateHex = tools.utils.bytesToHex(alicePrivateBytes);
const alicePubkey = tools.getPublicKey(alicePrivateBytes);
const bobPrivateBytes = tools.generateSecretKey();
const bobPrivateHex = tools.utils.bytesToHex(bobPrivateBytes);
const bobPubkey = tools.getPublicKey(bobPrivateBytes);
const aliceClosures = await createPrivateKeyCipherClosures(alicePrivateHex);
const bobClosures = await createPrivateKeyCipherClosures(bobPrivateHex);
if (aliceClosures.nip04Encrypt && bobClosures.nip04Decrypt) {
const plaintext = "Secret NIP-04 message from Alice to Bob";
// Alice encrypts to Bob
const ciphertext = await aliceClosures.nip04Encrypt(bobPubkey, plaintext);
assert.ok(typeof ciphertext === "string");
assert.notEqual(ciphertext, plaintext);
// Bob decrypts from Alice
const decrypted = await bobClosures.nip04Decrypt(alicePubkey, ciphertext);
assert.equal(decrypted, plaintext);
}
});
test("createPrivateKeyCipherClosures: normalizes uppercase hex key", async () => {
const tools = await ensureNostrTools();
if (!tools) return;
const privateKeyBytes = tools.generateSecretKey();
const privateKeyHex = tools.utils.bytesToHex(privateKeyBytes);
const uppercaseHex = privateKeyHex.toUpperCase();
const closures = await createPrivateKeyCipherClosures(uppercaseHex);
// Should still work - key should be normalized to lowercase
assert.ok(closures.nip04Encrypt || closures.nip44Encrypt);
});
test("createPrivateKeyCipherClosures: caches conversation keys for NIP-44", async () => {
const tools = await ensureNostrTools();
if (!tools) return;
const privateKeyBytes = tools.generateSecretKey();
const privateKeyHex = tools.utils.bytesToHex(privateKeyBytes);
const targetPubkey = tools.getPublicKey(tools.generateSecretKey());
const closures = await createPrivateKeyCipherClosures(privateKeyHex);
if (closures.nip44Encrypt) {
// Multiple encryptions to same target should use cached conversation key
const message1 = await closures.nip44Encrypt(targetPubkey, "message1");
const message2 = await closures.nip44Encrypt(targetPubkey, "message2");
const message3 = await closures.nip44Encrypt(targetPubkey, "message3");
assert.ok(message1);
assert.ok(message2);
assert.ok(message3);
// All should be different (due to random nonce)
assert.notEqual(message1, message2);
assert.notEqual(message2, message3);
}
});
test("createPrivateKeyCipherClosures: handles empty plaintext", async () => {
const tools = await ensureNostrTools();
if (!tools) return;
const alicePrivateBytes = tools.generateSecretKey();
const alicePrivateHex = tools.utils.bytesToHex(alicePrivateBytes);
const alicePubkey = tools.getPublicKey(alicePrivateBytes);
const bobPrivateBytes = tools.generateSecretKey();
const bobPrivateHex = tools.utils.bytesToHex(bobPrivateBytes);
const bobPubkey = tools.getPublicKey(bobPrivateBytes);
const aliceClosures = await createPrivateKeyCipherClosures(alicePrivateHex);
const bobClosures = await createPrivateKeyCipherClosures(bobPrivateHex);
if (aliceClosures.nip04Encrypt && bobClosures.nip04Decrypt) {
const ciphertext = await aliceClosures.nip04Encrypt(bobPubkey, "");
const decrypted = await bobClosures.nip04Decrypt(alicePubkey, ciphertext);
assert.equal(decrypted, "");
}
});
test("createPrivateKeyCipherClosures: handles unicode content", async () => {
const tools = await ensureNostrTools();
if (!tools) return;
const alicePrivateBytes = tools.generateSecretKey();
const alicePrivateHex = tools.utils.bytesToHex(alicePrivateBytes);
const alicePubkey = tools.getPublicKey(alicePrivateBytes);
const bobPrivateBytes = tools.generateSecretKey();
const bobPrivateHex = tools.utils.bytesToHex(bobPrivateBytes);
const bobPubkey = tools.getPublicKey(bobPrivateBytes);
const aliceClosures = await createPrivateKeyCipherClosures(alicePrivateHex);
const bobClosures = await createPrivateKeyCipherClosures(bobPrivateHex);
const unicodeText = "Hello 世界! 🌍 Привет мир! مرحبا بالعالم";
if (aliceClosures.nip04Encrypt && bobClosures.nip04Decrypt) {
const ciphertext = await aliceClosures.nip04Encrypt(bobPubkey, unicodeText);
const decrypted = await bobClosures.nip04Decrypt(alicePubkey, ciphertext);
assert.equal(decrypted, unicodeText);
}
if (aliceClosures.nip44Encrypt && bobClosures.nip44Decrypt) {
const ciphertext = await aliceClosures.nip44Encrypt(bobPubkey, unicodeText);
const decrypted = await bobClosures.nip44Decrypt(alicePubkey, ciphertext);
assert.equal(decrypted, unicodeText);
}
});
test("createPrivateKeyCipherClosures: handles large content with NIP-44", async () => {
const tools = await ensureNostrTools();
if (!tools) return;
const alicePrivateBytes = tools.generateSecretKey();
const alicePrivateHex = tools.utils.bytesToHex(alicePrivateBytes);
const alicePubkey = tools.getPublicKey(alicePrivateBytes);
const bobPrivateBytes = tools.generateSecretKey();
const bobPrivateHex = tools.utils.bytesToHex(bobPrivateBytes);
const bobPubkey = tools.getPublicKey(bobPrivateBytes);
const aliceClosures = await createPrivateKeyCipherClosures(alicePrivateHex);
const bobClosures = await createPrivateKeyCipherClosures(bobPrivateHex);
// 10KB of text
const largeText = "A".repeat(10 * 1024);
if (aliceClosures.nip44Encrypt && bobClosures.nip44Decrypt) {
const ciphertext = await aliceClosures.nip44Encrypt(bobPubkey, largeText);
const decrypted = await bobClosures.nip44Decrypt(alicePubkey, ciphertext);
assert.equal(decrypted, largeText);
}
});
+428
View File
@@ -0,0 +1,428 @@
import assert from "node:assert/strict";
import test from "node:test";
import "./test-setup.mjs";
// We need to mock the dependencies before importing relayManager
// Since relayManager is a singleton that runs on import, we test the helper functions
// by extracting testable logic or testing via the public API
// Test helper functions used by RelayPreferencesManager
test("normalizeRelayUrl: normalizes valid WSS URLs", async () => {
// Import dynamically to test after setup
const module = await import("../js/relayManager.js");
const { relayManager } = module;
// Test via addRelay which uses normalizeRelayUrl internally
const entries = relayManager.getEntries();
const initialCount = entries.length;
// Adding a relay with various URL formats should normalize them
// This tests the internal normalizeRelayUrl function indirectly
});
test("RelayPreferencesManager: getEntries returns cloned entries", async () => {
const module = await import("../js/relayManager.js");
const { relayManager } = module;
const entries1 = relayManager.getEntries();
const entries2 = relayManager.getEntries();
// Should be equal but not same reference
assert.deepEqual(entries1, entries2);
// Modifying one shouldn't affect the other
if (entries1.length > 0) {
entries1[0].url = "modified";
assert.notEqual(entries1[0].url, entries2[0].url);
}
});
test("RelayPreferencesManager: getAllRelayUrls returns array of URLs", async () => {
const module = await import("../js/relayManager.js");
const { relayManager } = module;
const urls = relayManager.getAllRelayUrls();
assert.ok(Array.isArray(urls));
urls.forEach((url) => {
assert.equal(typeof url, "string");
assert.ok(url.startsWith("wss://") || url.startsWith("ws://"));
});
});
test("RelayPreferencesManager: getReadRelayUrls filters correctly", async () => {
const module = await import("../js/relayManager.js");
const { relayManager } = module;
const readUrls = relayManager.getReadRelayUrls();
const entries = relayManager.getEntries();
// All read URLs should correspond to entries with read=true
const expectedReadUrls = entries.filter((e) => e.read).map((e) => e.url);
assert.deepEqual(readUrls, expectedReadUrls);
});
test("RelayPreferencesManager: getWriteRelayUrls filters correctly", async () => {
const module = await import("../js/relayManager.js");
const { relayManager } = module;
const writeUrls = relayManager.getWriteRelayUrls();
const entries = relayManager.getEntries();
// All write URLs should correspond to entries with write=true
const expectedWriteUrls = entries.filter((e) => e.write).map((e) => e.url);
assert.deepEqual(writeUrls, expectedWriteUrls);
});
test("RelayPreferencesManager: addRelay rejects invalid URLs", async () => {
const module = await import("../js/relayManager.js");
const { relayManager } = module;
// Empty string should be invalid
assert.throws(
() => relayManager.addRelay(""),
{ code: "invalid" }
);
// HTTP URLs are not valid websocket URLs
assert.throws(
() => relayManager.addRelay("http://not-websocket.com"),
{ code: "invalid" }
);
// FTP is not a valid protocol
assert.throws(
() => relayManager.addRelay("ftp://invalid.com"),
{ code: "invalid" }
);
// Note: "not-a-url" gets normalized to "wss://not-a-url" which is technically valid
// So we don't test that case
});
test("RelayPreferencesManager: addRelay handles duplicates", async () => {
const module = await import("../js/relayManager.js");
const { relayManager } = module;
const testUrl = "wss://test-duplicate.example.com";
// First, ensure it's not already there
const initialEntries = relayManager.getEntries();
const alreadyExists = initialEntries.some((e) => e.url === testUrl);
if (!alreadyExists) {
// Add it
const result1 = relayManager.addRelay(testUrl);
assert.equal(result1.changed, true);
// Try to add again
const result2 = relayManager.addRelay(testUrl);
assert.equal(result2.changed, false);
assert.equal(result2.reason, "duplicate");
// Clean up
relayManager.removeRelay(testUrl);
}
});
test("RelayPreferencesManager: addRelay with different modes", async () => {
const module = await import("../js/relayManager.js");
const { relayManager } = module;
const testUrl = "wss://test-modes.example.com";
// Clean up if exists
try {
relayManager.removeRelay(testUrl);
} catch (e) {
// Ignore if not found
}
// Add with read mode
const result = relayManager.addRelay(testUrl, "read");
assert.equal(result.changed, true);
assert.equal(result.entry.mode, "read");
assert.equal(result.entry.read, true);
assert.equal(result.entry.write, false);
// Clean up
relayManager.removeRelay(testUrl);
});
test("RelayPreferencesManager: updateRelayMode changes mode", async () => {
const module = await import("../js/relayManager.js");
const { relayManager } = module;
const testUrl = "wss://test-update-mode.example.com";
// Clean up if exists
try {
relayManager.removeRelay(testUrl);
} catch (e) {
// Ignore if not found
}
// Add with default mode (both)
relayManager.addRelay(testUrl, "both");
// Update to read-only
const result = relayManager.updateRelayMode(testUrl, "read");
assert.equal(result.changed, true);
assert.equal(result.entry.mode, "read");
assert.equal(result.entry.read, true);
assert.equal(result.entry.write, false);
// Update to write-only
const result2 = relayManager.updateRelayMode(testUrl, "write");
assert.equal(result2.changed, true);
assert.equal(result2.entry.mode, "write");
assert.equal(result2.entry.read, false);
assert.equal(result2.entry.write, true);
// Clean up
relayManager.removeRelay(testUrl);
});
test("RelayPreferencesManager: updateRelayMode returns unchanged for same mode", async () => {
const module = await import("../js/relayManager.js");
const { relayManager } = module;
const testUrl = "wss://test-same-mode.example.com";
try {
relayManager.removeRelay(testUrl);
} catch (e) {}
relayManager.addRelay(testUrl, "both");
const result = relayManager.updateRelayMode(testUrl, "both");
assert.equal(result.changed, false);
relayManager.removeRelay(testUrl);
});
test("RelayPreferencesManager: cycleRelayMode cycles through modes", async () => {
const module = await import("../js/relayManager.js");
const { relayManager } = module;
const testUrl = "wss://test-cycle.example.com";
try {
relayManager.removeRelay(testUrl);
} catch (e) {}
// Start with "both"
relayManager.addRelay(testUrl, "both");
// Cycle: both -> read
let result = relayManager.cycleRelayMode(testUrl);
assert.equal(result.entry.mode, "read");
// Cycle: read -> write
result = relayManager.cycleRelayMode(testUrl);
assert.equal(result.entry.mode, "write");
// Cycle: write -> both
result = relayManager.cycleRelayMode(testUrl);
assert.equal(result.entry.mode, "both");
relayManager.removeRelay(testUrl);
});
test("RelayPreferencesManager: cycleRelayMode throws for missing relay", async () => {
const module = await import("../js/relayManager.js");
const { relayManager } = module;
assert.throws(
() => relayManager.cycleRelayMode("wss://nonexistent.example.com"),
{ code: "missing" }
);
});
test("RelayPreferencesManager: removeRelay removes existing relay", async () => {
const module = await import("../js/relayManager.js");
const { relayManager } = module;
const testUrl = "wss://test-remove.example.com";
try {
relayManager.removeRelay(testUrl);
} catch (e) {}
relayManager.addRelay(testUrl);
assert.ok(relayManager.getEntries().some((e) => e.url === testUrl));
const result = relayManager.removeRelay(testUrl);
assert.equal(result.changed, true);
assert.ok(!relayManager.getEntries().some((e) => e.url === testUrl));
});
test("RelayPreferencesManager: removeRelay returns unchanged for missing relay", async () => {
const module = await import("../js/relayManager.js");
const { relayManager } = module;
const result = relayManager.removeRelay("wss://definitely-not-there.example.com");
assert.equal(result.changed, false);
assert.equal(result.reason, "missing");
});
test("RelayPreferencesManager: removeRelay throws when only one relay left", async () => {
const module = await import("../js/relayManager.js");
const { relayManager } = module;
// Save current state
const originalEntries = relayManager.getEntries();
// Set to single relay
relayManager.setEntries([{ url: "wss://single.example.com", mode: "both" }]);
assert.throws(
() => relayManager.removeRelay("wss://single.example.com"),
{ code: "minimum" }
);
// Restore
relayManager.setEntries(originalEntries);
});
test("RelayPreferencesManager: setEntries replaces all entries", async () => {
const module = await import("../js/relayManager.js");
const { relayManager } = module;
const originalEntries = relayManager.getEntries();
const newEntries = [
{ url: "wss://new1.example.com", mode: "both" },
{ url: "wss://new2.example.com", mode: "read" },
];
relayManager.setEntries(newEntries);
const currentEntries = relayManager.getEntries();
assert.equal(currentEntries.length, 2);
assert.equal(currentEntries[0].url, "wss://new1.example.com");
assert.equal(currentEntries[1].url, "wss://new2.example.com");
assert.equal(currentEntries[1].mode, "read");
// Restore
relayManager.setEntries(originalEntries);
});
test("RelayPreferencesManager: setEntries uses defaults for empty array", async () => {
const module = await import("../js/relayManager.js");
const { relayManager } = module;
const originalEntries = relayManager.getEntries();
relayManager.setEntries([]);
const currentEntries = relayManager.getEntries();
// Should have default relays, not be empty
assert.ok(currentEntries.length > 0);
// Restore
relayManager.setEntries(originalEntries);
});
test("RelayPreferencesManager: setEntries deduplicates URLs", async () => {
const module = await import("../js/relayManager.js");
const { relayManager } = module;
const originalEntries = relayManager.getEntries();
const duplicateEntries = [
{ url: "wss://same.example.com", mode: "both" },
{ url: "wss://same.example.com", mode: "read" }, // Duplicate
{ url: "wss://other.example.com", mode: "write" },
];
relayManager.setEntries(duplicateEntries);
const currentEntries = relayManager.getEntries();
assert.equal(currentEntries.length, 2);
// Restore
relayManager.setEntries(originalEntries);
});
test("RelayPreferencesManager: setEntries normalizes URLs", async () => {
const module = await import("../js/relayManager.js");
const { relayManager } = module;
const originalEntries = relayManager.getEntries();
// Add relay without protocol
relayManager.setEntries([{ url: "relay.example.com", mode: "both" }]);
const currentEntries = relayManager.getEntries();
// Should be normalized to wss://
assert.equal(currentEntries[0].url, "wss://relay.example.com");
// Restore
relayManager.setEntries(originalEntries);
});
test("RelayPreferencesManager: restoreDefaults resets to default relays", async () => {
const module = await import("../js/relayManager.js");
const { relayManager } = module;
const originalEntries = relayManager.getEntries();
// Change entries
relayManager.setEntries([{ url: "wss://custom.example.com", mode: "both" }]);
// Restore defaults
const result = relayManager.restoreDefaults();
assert.equal(result.changed, true);
const currentEntries = relayManager.getEntries();
// Should have default relays
assert.ok(currentEntries.length > 0);
assert.ok(!currentEntries.some((e) => e.url === "wss://custom.example.com"));
// Restore original state
relayManager.setEntries(originalEntries);
});
test("RelayPreferencesManager: getPublishTargets includes defaults", async () => {
const module = await import("../js/relayManager.js");
const { relayManager } = module;
const targets = relayManager.getPublishTargets();
assert.ok(Array.isArray(targets));
assert.ok(targets.length > 0);
// All should be valid WSS URLs
targets.forEach((url) => {
assert.ok(url.startsWith("wss://") || url.startsWith("ws://"));
});
});
test("RelayPreferencesManager: getPublishTargets accepts custom targets", async () => {
const module = await import("../js/relayManager.js");
const { relayManager } = module;
const customTargets = ["wss://custom1.example.com", "wss://custom2.example.com"];
const targets = relayManager.getPublishTargets(customTargets);
assert.ok(targets.includes("wss://custom1.example.com"));
assert.ok(targets.includes("wss://custom2.example.com"));
});
test("RelayPreferencesManager: snapshot returns current entries", async () => {
const module = await import("../js/relayManager.js");
const { relayManager } = module;
const snapshot = relayManager.snapshot();
const entries = relayManager.getEntries();
assert.deepEqual(snapshot, entries);
});
test("RelayPreferencesManager: getLastLoadSource returns source type", async () => {
const module = await import("../js/relayManager.js");
const { relayManager } = module;
const source = relayManager.getLastLoadSource();
assert.ok(["default", "event", "storage"].includes(source) || source === "default");
});
+390
View File
@@ -0,0 +1,390 @@
import assert from "node:assert/strict";
import test from "node:test";
import nip07Provider from "../../js/services/authProviders/nip07.js";
import nip46Provider from "../../js/services/authProviders/nip46.js";
import nsecProvider from "../../js/services/authProviders/nsec.js";
import generateProvider from "../../js/services/authProviders/generate.js";
const buildHex = (char) => char.repeat(64);
// NIP-07 Provider Tests
test("nip07Provider: has correct metadata", () => {
assert.equal(nip07Provider.id, "nip07");
assert.equal(nip07Provider.label, "extension (nip-07)");
assert.ok(nip07Provider.description.includes("NIP-07"));
assert.ok(Array.isArray(nip07Provider.capabilities));
assert.equal(typeof nip07Provider.login, "function");
});
test("nip07Provider: login throws when nostrClient unavailable", async () => {
await assert.rejects(
async () => nip07Provider.login({}),
{ code: "provider-unavailable" }
);
await assert.rejects(
async () => nip07Provider.login({ nostrClient: null }),
{ code: "provider-unavailable" }
);
});
test("nip07Provider: login throws when loginWithExtension missing", async () => {
const mockClient = {
// Missing loginWithExtension
};
await assert.rejects(
async () => nip07Provider.login({ nostrClient: mockClient }),
{ code: "provider-unavailable" }
);
});
test("nip07Provider: login returns pubkey and signer on success", async () => {
const expectedPubkey = buildHex("a");
const mockSigner = { type: "nip07" };
const mockClient = {
loginWithExtension: async () => ({
pubkey: expectedPubkey,
signer: mockSigner,
}),
};
// Mock accessControl
const originalAccessControl = await import("../../js/accessControl.js");
const result = await nip07Provider.login({ nostrClient: mockClient });
assert.equal(result.authType, "nip07");
assert.equal(result.pubkey, expectedPubkey);
assert.equal(result.signer, mockSigner);
});
test("nip07Provider: login normalizes string pubkey result", async () => {
const expectedPubkey = buildHex("b");
const mockClient = {
loginWithExtension: async () => expectedPubkey, // Returns string directly
};
const result = await nip07Provider.login({ nostrClient: mockClient });
assert.equal(result.pubkey, expectedPubkey);
});
test("nip07Provider: login handles publicKey property", async () => {
const expectedPubkey = buildHex("c");
const mockClient = {
loginWithExtension: async () => ({
publicKey: expectedPubkey, // Uses publicKey instead of pubkey
}),
};
const result = await nip07Provider.login({ nostrClient: mockClient });
assert.equal(result.pubkey, expectedPubkey);
});
// NIP-46 Provider Tests
test("nip46Provider: has correct metadata", () => {
assert.equal(nip46Provider.id, "nip46");
assert.equal(nip46Provider.label, "remote signer (nip-46)");
assert.ok(nip46Provider.description.includes("remote signer"));
assert.ok(Array.isArray(nip46Provider.capabilities));
assert.equal(typeof nip46Provider.login, "function");
});
test("nip46Provider: login throws when nostrClient unavailable", async () => {
await assert.rejects(
async () => nip46Provider.login({}),
{ code: "provider-unavailable" }
);
await assert.rejects(
async () => nip46Provider.login({ nostrClient: null }),
{ code: "provider-unavailable" }
);
});
test("nip46Provider: login throws when connectRemoteSigner missing", async () => {
const mockClient = {
// Missing connectRemoteSigner
};
await assert.rejects(
async () => nip46Provider.login({ nostrClient: mockClient }),
{ code: "provider-unavailable" }
);
});
test("nip46Provider: login in manual mode requires connectionString", async () => {
const mockClient = {
connectRemoteSigner: async () => ({}),
};
await assert.rejects(
async () =>
nip46Provider.login({
nostrClient: mockClient,
options: { mode: "manual", connectionString: "" },
}),
{ code: "connection-required" }
);
});
test("nip46Provider: login in manual mode uses connectionString", async () => {
const expectedPubkey = buildHex("d");
let receivedConnectionString = null;
const mockClient = {
connectRemoteSigner: async ({ connectionString }) => {
receivedConnectionString = connectionString;
return { pubkey: expectedPubkey };
},
};
const testUri = "nostrconnect://test?secret=abc123";
const result = await nip46Provider.login({
nostrClient: mockClient,
options: {
mode: "manual",
connectionString: testUri,
},
});
assert.equal(receivedConnectionString, testUri);
assert.equal(result.pubkey, expectedPubkey);
assert.equal(result.authType, "nip46");
});
test("nip46Provider: login with reuseStored calls useStoredRemoteSigner", async () => {
const expectedPubkey = buildHex("e");
let usedStoredSigner = false;
const mockClient = {
connectRemoteSigner: async () => {
throw new Error("Should not be called");
},
useStoredRemoteSigner: async () => {
usedStoredSigner = true;
return { pubkey: expectedPubkey };
},
};
const result = await nip46Provider.login({
nostrClient: mockClient,
options: { reuseStored: true },
});
assert.equal(usedStoredSigner, true);
assert.equal(result.pubkey, expectedPubkey);
});
test("nip46Provider: login with reuseStored throws when no stored session", async () => {
const mockClient = {
connectRemoteSigner: async () => ({}),
// Missing useStoredRemoteSigner
};
await assert.rejects(
async () =>
nip46Provider.login({
nostrClient: mockClient,
options: { reuseStored: true },
}),
{ code: "no-stored-session" }
);
});
test("nip46Provider: login in handshake mode prepares handshake", async () => {
const expectedPubkey = buildHex("f");
let preparedHandshake = false;
let handshakeData = null;
const mockHandshake = {
connectionString: "nostrconnect://test",
clientPublicKey: buildHex("1"),
clientPrivateKey: buildHex("2"),
secret: "test-secret",
relays: ["wss://relay.example.com"],
permissions: "",
metadata: {},
};
const mockClient = {
prepareRemoteSignerHandshake: async (options) => {
preparedHandshake = true;
return mockHandshake;
},
connectRemoteSigner: async (options) => {
handshakeData = options;
return { pubkey: expectedPubkey };
},
};
const result = await nip46Provider.login({
nostrClient: mockClient,
options: { mode: "handshake" },
});
assert.equal(preparedHandshake, true);
assert.equal(handshakeData.connectionString, mockHandshake.connectionString);
assert.equal(result.pubkey, expectedPubkey);
});
test("nip46Provider: login calls onHandshakePrepared callback", async () => {
const expectedPubkey = buildHex("1");
let callbackHandshake = null;
const mockHandshake = {
connectionString: "nostrconnect://test",
clientPublicKey: buildHex("2"),
clientPrivateKey: buildHex("3"),
secret: "test-secret",
relays: [],
permissions: "",
metadata: {},
};
const mockClient = {
prepareRemoteSignerHandshake: async () => mockHandshake,
connectRemoteSigner: async () => ({ pubkey: expectedPubkey }),
};
await nip46Provider.login({
nostrClient: mockClient,
options: {
mode: "handshake",
onHandshakePrepared: (handshake) => {
callbackHandshake = handshake;
},
},
});
assert.deepEqual(callbackHandshake, mockHandshake);
});
test("nip46Provider: login calls onStatus callback", async () => {
const statusUpdates = [];
const mockHandshake = {
connectionString: "nostrconnect://test",
clientPublicKey: buildHex("4"),
clientPrivateKey: buildHex("5"),
secret: "test-secret",
relays: [],
permissions: "",
metadata: {},
};
const mockClient = {
prepareRemoteSignerHandshake: async () => mockHandshake,
connectRemoteSigner: async () => ({ pubkey: buildHex("6") }),
};
await nip46Provider.login({
nostrClient: mockClient,
options: {
mode: "handshake",
onStatus: (status) => {
statusUpdates.push(status);
},
},
});
assert.ok(statusUpdates.length > 0);
assert.equal(statusUpdates[0].phase, "handshake");
assert.equal(statusUpdates[0].state, "preparing");
});
// NSEC Provider Tests
test("nsecProvider: has correct metadata", async () => {
const module = await import("../../js/services/authProviders/nsec.js");
const provider = module.default;
assert.equal(provider.id, "nsec");
assert.ok(provider.label.includes("nsec"));
assert.equal(typeof provider.login, "function");
});
test("nsecProvider: login throws when nostrClient unavailable", async () => {
const module = await import("../../js/services/authProviders/nsec.js");
const provider = module.default;
// When nostrClient is missing/invalid, it throws provider-unavailable
await assert.rejects(
async () => provider.login({ options: {} }),
{ code: "provider-unavailable" }
);
await assert.rejects(
async () => provider.login({ nostrClient: null, options: {} }),
{ code: "provider-unavailable" }
);
});
// Generate Provider Tests
test("generateProvider: has correct metadata", async () => {
const module = await import("../../js/services/authProviders/generate.js");
const provider = module.default;
assert.equal(provider.id, "generate");
// Label is "Create Account"
assert.ok(provider.label.includes("Create") || provider.label.includes("Account"));
assert.equal(typeof provider.login, "function");
});
// Provider Index Tests
test("authProviders index: exports all providers", async () => {
const module = await import("../../js/services/authProviders/index.js");
// Index exports providers as a map
assert.ok(module.providers, "Should export providers map");
assert.ok(module.providers.nip07, "Should have nip07 provider");
assert.ok(module.providers.nip46, "Should have nip46 provider");
assert.ok(module.providers.nsec, "Should have nsec provider");
assert.ok(module.providers.generate, "Should have generate provider");
// Should also export getProvider function
assert.equal(typeof module.getProvider, "function");
assert.equal(typeof module.default, "function"); // default is getProvider
});
// Integration-style tests for provider capabilities
test("providers: all have required interface", async () => {
const providers = [nip07Provider, nip46Provider];
for (const provider of providers) {
assert.ok(provider.id, `Provider missing id`);
assert.ok(provider.label, `Provider ${provider.id} missing label`);
assert.ok(provider.description, `Provider ${provider.id} missing description`);
assert.ok(Array.isArray(provider.capabilities), `Provider ${provider.id} missing capabilities`);
assert.ok(provider.button, `Provider ${provider.id} missing button config`);
assert.ok(provider.messages, `Provider ${provider.id} missing messages`);
assert.equal(typeof provider.login, "function", `Provider ${provider.id} missing login function`);
}
});
test("providers: capabilities have correct structure", () => {
const providers = [nip07Provider, nip46Provider];
for (const provider of providers) {
for (const capability of provider.capabilities) {
assert.ok(capability.id, `Capability missing id in ${provider.id}`);
assert.ok(capability.label, `Capability missing label in ${provider.id}`);
assert.ok(capability.variant, `Capability missing variant in ${provider.id}`);
}
}
});
test("providers: messages have required fields", () => {
const providers = [nip07Provider, nip46Provider];
for (const provider of providers) {
assert.ok(provider.messages.loading, `Provider ${provider.id} missing loading message`);
assert.ok(provider.messages.slow, `Provider ${provider.id} missing slow message`);
assert.ok(provider.messages.error, `Provider ${provider.id} missing error message`);
}
});
+228
View File
@@ -0,0 +1,228 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
truncateMiddle,
formatShortNpub,
formatAbsoluteTimestamp,
formatTimeAgo,
formatAbsoluteDateWithOrdinal,
} from "../../js/utils/formatters.js";
// truncateMiddle tests
test("truncateMiddle: returns empty string for null/undefined", () => {
assert.equal(truncateMiddle(null), "");
assert.equal(truncateMiddle(undefined), "");
assert.equal(truncateMiddle(""), "");
});
test("truncateMiddle: returns empty string for non-string input", () => {
assert.equal(truncateMiddle(123), "");
assert.equal(truncateMiddle({}), "");
assert.equal(truncateMiddle([]), "");
});
test("truncateMiddle: returns unchanged string if within maxLength", () => {
assert.equal(truncateMiddle("short", 72), "short");
assert.equal(truncateMiddle("exactly", 7), "exactly");
});
test("truncateMiddle: truncates long strings with ellipsis in middle", () => {
const input = "abcdefghijklmnopqrstuvwxyz";
const result = truncateMiddle(input, 10);
assert.equal(result.length, 10);
assert.ok(result.includes("…"));
assert.equal(result.slice(0, 5), "abcde");
assert.equal(result.slice(-4), "wxyz");
});
test("truncateMiddle: uses default maxLength of 72", () => {
const input = "a".repeat(100);
const result = truncateMiddle(input);
assert.equal(result.length, 72);
});
test("truncateMiddle: handles odd character counts", () => {
const input = "abcdefghijklmnop"; // 16 chars
const result = truncateMiddle(input, 9);
// 9 - 1 (ellipsis) = 8 chars, front=4, back=4
assert.equal(result.length, 9);
assert.ok(result.includes("…"));
});
// formatShortNpub tests
test("formatShortNpub: returns empty string for non-string input", () => {
assert.equal(formatShortNpub(null), "");
assert.equal(formatShortNpub(undefined), "");
assert.equal(formatShortNpub(123), "");
assert.equal(formatShortNpub({}), "");
});
test("formatShortNpub: returns empty string for empty/whitespace string", () => {
assert.equal(formatShortNpub(""), "");
assert.equal(formatShortNpub(" "), "");
});
test("formatShortNpub: returns unchanged if not npub prefix", () => {
assert.equal(formatShortNpub("abc123"), "abc123");
assert.equal(formatShortNpub("nsec1abc"), "nsec1abc");
});
test("formatShortNpub: returns unchanged if already short", () => {
assert.equal(formatShortNpub("npub1234"), "npub1234");
assert.equal(formatShortNpub("npub12345678"), "npub12345678");
});
test("formatShortNpub: formats long npub correctly", () => {
const npub = "npub1abcdefghijklmnopqrstuvwxyz1234567890";
const result = formatShortNpub(npub);
assert.equal(result, "npub1abc...7890");
assert.equal(result.length, 15);
});
test("formatShortNpub: trims whitespace", () => {
const npub = " npub1abcdefghijklmnopqrstuvwxyz1234567890 ";
const result = formatShortNpub(npub);
assert.equal(result, "npub1abc...7890");
});
// formatAbsoluteTimestamp tests
test("formatAbsoluteTimestamp: returns 'Unknown date' for invalid input", () => {
assert.equal(formatAbsoluteTimestamp(null), "Unknown date");
assert.equal(formatAbsoluteTimestamp(undefined), "Unknown date");
assert.equal(formatAbsoluteTimestamp(NaN), "Unknown date");
assert.equal(formatAbsoluteTimestamp(Infinity), "Unknown date");
assert.equal(formatAbsoluteTimestamp("not a number"), "Unknown date");
});
test("formatAbsoluteTimestamp: formats valid timestamp", () => {
// 1704067200 = Jan 1, 2024 00:00:00 UTC
const timestamp = 1704067200;
const result = formatAbsoluteTimestamp(timestamp);
// Result depends on locale, but should contain year
assert.ok(result.includes("2024"));
assert.ok(result !== "Unknown date");
});
test("formatAbsoluteTimestamp: handles zero timestamp (Unix epoch)", () => {
const result = formatAbsoluteTimestamp(0);
assert.ok(result.includes("1970"));
});
// formatTimeAgo tests
test("formatTimeAgo: returns 'just now' for recent timestamps", () => {
const now = Math.floor(Date.now() / 1000);
assert.equal(formatTimeAgo(now), "just now");
assert.equal(formatTimeAgo(now - 30), "just now");
});
test("formatTimeAgo: formats minutes correctly", () => {
const now = Math.floor(Date.now() / 1000);
assert.equal(formatTimeAgo(now - 60), "1 minute ago");
assert.equal(formatTimeAgo(now - 120), "2 minutes ago");
assert.equal(formatTimeAgo(now - 300), "5 minutes ago");
});
test("formatTimeAgo: formats hours correctly", () => {
const now = Math.floor(Date.now() / 1000);
assert.equal(formatTimeAgo(now - 3600), "1 hour ago");
assert.equal(formatTimeAgo(now - 7200), "2 hours ago");
});
test("formatTimeAgo: formats days correctly", () => {
const now = Math.floor(Date.now() / 1000);
assert.equal(formatTimeAgo(now - 86400), "1 day ago");
assert.equal(formatTimeAgo(now - 172800), "2 days ago");
});
test("formatTimeAgo: formats weeks correctly", () => {
const now = Math.floor(Date.now() / 1000);
assert.equal(formatTimeAgo(now - 604800), "1 week ago");
assert.equal(formatTimeAgo(now - 1209600), "2 weeks ago");
});
test("formatTimeAgo: formats months correctly", () => {
const now = Math.floor(Date.now() / 1000);
assert.equal(formatTimeAgo(now - 2592000), "1 month ago");
assert.equal(formatTimeAgo(now - 5184000), "2 months ago");
});
test("formatTimeAgo: formats years correctly", () => {
const now = Math.floor(Date.now() / 1000);
assert.equal(formatTimeAgo(now - 31536000), "1 year ago");
assert.equal(formatTimeAgo(now - 63072000), "2 years ago");
});
// formatAbsoluteDateWithOrdinal tests
test("formatAbsoluteDateWithOrdinal: returns empty string for invalid input", () => {
assert.equal(formatAbsoluteDateWithOrdinal(null), "");
assert.equal(formatAbsoluteDateWithOrdinal(undefined), "");
assert.equal(formatAbsoluteDateWithOrdinal(NaN), "");
assert.equal(formatAbsoluteDateWithOrdinal(Infinity), "");
});
test("formatAbsoluteDateWithOrdinal: formats date with correct ordinal suffix", () => {
// Test various days with different ordinal suffixes
// January 1, 2024 00:00:00 UTC = 1704067200
const jan1 = 1704067200;
const result1 = formatAbsoluteDateWithOrdinal(jan1);
assert.ok(result1.includes("1st") || result1.includes("1"), `Expected ordinal for 1st, got: ${result1}`);
assert.ok(result1.includes("2024"));
// January 2, 2024 00:00:00 UTC = 1704153600
const jan2 = 1704153600;
const result2 = formatAbsoluteDateWithOrdinal(jan2);
assert.ok(result2.includes("2nd") || result2.includes("2"), `Expected ordinal for 2nd, got: ${result2}`);
// January 3, 2024 00:00:00 UTC = 1704240000
const jan3 = 1704240000;
const result3 = formatAbsoluteDateWithOrdinal(jan3);
assert.ok(result3.includes("3rd") || result3.includes("3"), `Expected ordinal for 3rd, got: ${result3}`);
// January 4, 2024 00:00:00 UTC = 1704326400
const jan4 = 1704326400;
const result4 = formatAbsoluteDateWithOrdinal(jan4);
assert.ok(result4.includes("4th") || result4.includes("4"), `Expected ordinal for 4th, got: ${result4}`);
});
test("formatAbsoluteDateWithOrdinal: handles teen numbers (11th, 12th, 13th)", () => {
// January 11, 2024 00:00:00 UTC = 1704931200
const jan11 = 1704931200;
const result11 = formatAbsoluteDateWithOrdinal(jan11);
assert.ok(result11.includes("11th") || result11.includes("11"));
// January 12, 2024 00:00:00 UTC = 1705017600
const jan12 = 1705017600;
const result12 = formatAbsoluteDateWithOrdinal(jan12);
assert.ok(result12.includes("12th") || result12.includes("12"));
// January 13, 2024 00:00:00 UTC = 1705104000
const jan13 = 1705104000;
const result13 = formatAbsoluteDateWithOrdinal(jan13);
assert.ok(result13.includes("13th") || result13.includes("13"));
});
test("formatAbsoluteDateWithOrdinal: includes month name", () => {
// Test different months
// March 15, 2024 = 1710460800
const march = 1710460800;
const resultMarch = formatAbsoluteDateWithOrdinal(march);
assert.ok(resultMarch.includes("March"));
// July 4, 2024 = 1720051200
const july = 1720051200;
const resultJuly = formatAbsoluteDateWithOrdinal(july);
assert.ok(resultJuly.includes("July"));
});
+267
View File
@@ -0,0 +1,267 @@
import assert from "node:assert/strict";
import test from "node:test";
import { LRUCache } from "../../js/utils/lruCache.js";
test("LRUCache: constructor sets default maxSize", () => {
const cache = new LRUCache();
assert.equal(cache.maxSize, 100);
assert.equal(cache.size(), 0);
});
test("LRUCache: constructor accepts custom maxSize", () => {
const cache = new LRUCache({ maxSize: 5 });
assert.equal(cache.maxSize, 5);
});
test("LRUCache: constructor uses default for invalid maxSize", () => {
const cache1 = new LRUCache({ maxSize: 0 });
assert.equal(cache1.maxSize, 100);
const cache2 = new LRUCache({ maxSize: -5 });
assert.equal(cache2.maxSize, 100);
});
test("LRUCache: set and get basic functionality", () => {
const cache = new LRUCache({ maxSize: 3 });
cache.set("a", 1);
cache.set("b", 2);
cache.set("c", 3);
assert.equal(cache.get("a"), 1);
assert.equal(cache.get("b"), 2);
assert.equal(cache.get("c"), 3);
assert.equal(cache.size(), 3);
});
test("LRUCache: get returns undefined for missing keys", () => {
const cache = new LRUCache({ maxSize: 3 });
cache.set("a", 1);
assert.equal(cache.get("nonexistent"), undefined);
});
test("LRUCache: evicts oldest item when maxSize exceeded", () => {
const cache = new LRUCache({ maxSize: 3 });
cache.set("a", 1);
cache.set("b", 2);
cache.set("c", 3);
cache.set("d", 4); // Should evict "a"
assert.equal(cache.get("a"), undefined);
assert.equal(cache.get("b"), 2);
assert.equal(cache.get("c"), 3);
assert.equal(cache.get("d"), 4);
assert.equal(cache.size(), 3);
});
test("LRUCache: get refreshes recency", () => {
const cache = new LRUCache({ maxSize: 3 });
cache.set("a", 1);
cache.set("b", 2);
cache.set("c", 3);
// Access "a" to make it recently used
cache.get("a");
// Add new item - should evict "b" (oldest) not "a"
cache.set("d", 4);
assert.equal(cache.get("a"), 1); // "a" should still exist
assert.equal(cache.get("b"), undefined); // "b" should be evicted
assert.equal(cache.get("c"), 3);
assert.equal(cache.get("d"), 4);
});
test("LRUCache: set updates existing key and refreshes recency", () => {
const cache = new LRUCache({ maxSize: 3 });
cache.set("a", 1);
cache.set("b", 2);
cache.set("c", 3);
// Update "a" with new value
cache.set("a", 100);
// Add new item - should evict "b" not "a"
cache.set("d", 4);
assert.equal(cache.get("a"), 100);
assert.equal(cache.get("b"), undefined);
assert.equal(cache.size(), 3);
});
test("LRUCache: has checks existence without affecting recency", () => {
const cache = new LRUCache({ maxSize: 3 });
cache.set("a", 1);
cache.set("b", 2);
cache.set("c", 3);
// Check "a" existence without affecting recency
assert.equal(cache.has("a"), true);
assert.equal(cache.has("nonexistent"), false);
// Stats should not change from has()
const stats = cache.getStats();
assert.equal(stats.hits, 0);
assert.equal(stats.misses, 0);
// Add new item - should evict "a" since has() didn't refresh it
cache.set("d", 4);
assert.equal(cache.has("a"), false);
});
test("LRUCache: tracks hits and misses", () => {
const cache = new LRUCache({ maxSize: 3 });
cache.set("a", 1);
cache.set("b", 2);
cache.get("a"); // hit
cache.get("a"); // hit
cache.get("b"); // hit
cache.get("c"); // miss
cache.get("d"); // miss
const stats = cache.getStats();
assert.equal(stats.hits, 3);
assert.equal(stats.misses, 2);
assert.equal(stats.size, 2);
assert.equal(stats.maxSize, 3);
});
test("LRUCache: clear resets cache and stats", () => {
const cache = new LRUCache({ maxSize: 3 });
cache.set("a", 1);
cache.set("b", 2);
cache.get("a");
cache.get("missing");
cache.clear();
assert.equal(cache.size(), 0);
assert.equal(cache.get("a"), undefined);
const stats = cache.getStats();
assert.equal(stats.hits, 0);
assert.equal(stats.misses, 1); // The get("a") after clear counts as miss
assert.equal(stats.size, 0);
});
test("LRUCache: entries returns iterator in insertion order", () => {
const cache = new LRUCache({ maxSize: 5 });
cache.set("a", 1);
cache.set("b", 2);
cache.set("c", 3);
const entries = Array.from(cache.entries());
assert.deepEqual(entries, [
["a", 1],
["b", 2],
["c", 3],
]);
});
test("LRUCache: entries reflects recency updates", () => {
const cache = new LRUCache({ maxSize: 5 });
cache.set("a", 1);
cache.set("b", 2);
cache.set("c", 3);
// Access "a" to move it to end
cache.get("a");
const entries = Array.from(cache.entries());
assert.deepEqual(entries, [
["b", 2],
["c", 3],
["a", 1],
]);
});
test("LRUCache: handles null and undefined values", () => {
const cache = new LRUCache({ maxSize: 5 }); // Enough space for all items
cache.set("null", null);
cache.set("undefined", undefined);
cache.set("zero", 0);
cache.set("empty", "");
// null should be stored and retrieved correctly
assert.equal(cache.get("null"), null);
// 0 and "" are falsy but should be stored correctly
assert.equal(cache.get("zero"), 0);
assert.equal(cache.get("empty"), "");
// undefined values are stored but get() returns undefined
// (indistinguishable from missing key based on return value)
assert.equal(cache.get("undefined"), undefined);
});
test("LRUCache: handles various key types", () => {
const cache = new LRUCache({ maxSize: 5 });
cache.set("string", "value1");
cache.set("123", "value2");
cache.set("with spaces", "value3");
cache.set("special!@#$", "value4");
assert.equal(cache.get("string"), "value1");
assert.equal(cache.get("123"), "value2");
assert.equal(cache.get("with spaces"), "value3");
assert.equal(cache.get("special!@#$"), "value4");
});
test("LRUCache: handles object values", () => {
const cache = new LRUCache({ maxSize: 3 });
const obj = { foo: "bar", nested: { a: 1 } };
cache.set("obj", obj);
const retrieved = cache.get("obj");
assert.equal(retrieved, obj); // Same reference
assert.deepEqual(retrieved, { foo: "bar", nested: { a: 1 } });
});
test("LRUCache: maxSize of 1 works correctly", () => {
const cache = new LRUCache({ maxSize: 1 });
cache.set("a", 1);
assert.equal(cache.get("a"), 1);
cache.set("b", 2);
assert.equal(cache.get("a"), undefined);
assert.equal(cache.get("b"), 2);
assert.equal(cache.size(), 1);
});
test("LRUCache: stress test with many insertions", () => {
const cache = new LRUCache({ maxSize: 100 });
// Insert 1000 items
for (let i = 0; i < 1000; i++) {
cache.set(`key${i}`, i);
}
// Only last 100 should remain
assert.equal(cache.size(), 100);
// First 900 should be evicted
for (let i = 0; i < 900; i++) {
assert.equal(cache.has(`key${i}`), false);
}
// Last 100 should exist
for (let i = 900; i < 1000; i++) {
assert.equal(cache.has(`key${i}`), true);
assert.equal(cache.get(`key${i}`), i);
}
});
+158
View File
@@ -0,0 +1,158 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isValidMagnetUri } from "../../js/utils/magnetValidators.js";
// Basic validation tests
test("isValidMagnetUri: returns false for empty/null/undefined", () => {
assert.equal(isValidMagnetUri(""), false);
assert.equal(isValidMagnetUri(null), false);
assert.equal(isValidMagnetUri(undefined), false);
});
test("isValidMagnetUri: returns false for non-string input", () => {
assert.equal(isValidMagnetUri(123), false);
assert.equal(isValidMagnetUri({}), false);
assert.equal(isValidMagnetUri([]), false);
});
test("isValidMagnetUri: returns false for whitespace-only string", () => {
assert.equal(isValidMagnetUri(" "), false);
assert.equal(isValidMagnetUri("\t\n"), false);
});
// Valid hex info hash tests
test("isValidMagnetUri: accepts 40-character hex info hash directly", () => {
const hexHash = "a".repeat(40);
assert.equal(isValidMagnetUri(hexHash), true);
});
test("isValidMagnetUri: accepts valid magnet URI with hex info hash", () => {
const hexHash = "1234567890abcdef1234567890abcdef12345678";
const magnet = `magnet:?xt=urn:btih:${hexHash}`;
assert.equal(isValidMagnetUri(magnet), true);
});
test("isValidMagnetUri: accepts magnet URI with uppercase hex hash", () => {
const hexHash = "1234567890ABCDEF1234567890ABCDEF12345678";
const magnet = `magnet:?xt=urn:btih:${hexHash}`;
assert.equal(isValidMagnetUri(magnet), true);
});
test("isValidMagnetUri: accepts magnet URI with mixed case hex hash", () => {
const hexHash = "1234567890AbCdEf1234567890aBcDeF12345678";
const magnet = `magnet:?xt=urn:btih:${hexHash}`;
assert.equal(isValidMagnetUri(magnet), true);
});
// Valid base32 info hash tests
test("isValidMagnetUri: accepts valid magnet URI with base32 info hash", () => {
const base32Hash = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; // 32 chars
const magnet = `magnet:?xt=urn:btih:${base32Hash}`;
assert.equal(isValidMagnetUri(magnet), true);
});
test("isValidMagnetUri: accepts base32 hash with lowercase (normalized)", () => {
const base32Hash = "abcdefghijklmnopqrstuvwxyz234567";
const magnet = `magnet:?xt=urn:btih:${base32Hash}`;
assert.equal(isValidMagnetUri(magnet), true);
});
// Magnet URI with additional parameters
test("isValidMagnetUri: accepts magnet with trackers", () => {
const hexHash = "a".repeat(40);
const magnet = `magnet:?xt=urn:btih:${hexHash}&tr=wss://tracker.example.com`;
assert.equal(isValidMagnetUri(magnet), true);
});
test("isValidMagnetUri: accepts magnet with display name", () => {
const hexHash = "a".repeat(40);
const magnet = `magnet:?xt=urn:btih:${hexHash}&dn=My+Video+File`;
assert.equal(isValidMagnetUri(magnet), true);
});
test("isValidMagnetUri: accepts magnet with web seed", () => {
const hexHash = "a".repeat(40);
const magnet = `magnet:?xt=urn:btih:${hexHash}&ws=https://example.com/file.mp4`;
assert.equal(isValidMagnetUri(magnet), true);
});
test("isValidMagnetUri: accepts magnet with multiple parameters", () => {
const hexHash = "a".repeat(40);
const magnet = `magnet:?xt=urn:btih:${hexHash}&dn=Test&tr=wss://t1.com&tr=wss://t2.com&ws=https://seed.com/file`;
assert.equal(isValidMagnetUri(magnet), true);
});
// Invalid magnet URI tests
test("isValidMagnetUri: rejects non-magnet protocol", () => {
assert.equal(isValidMagnetUri("http://example.com"), false);
assert.equal(isValidMagnetUri("https://example.com"), false);
assert.equal(isValidMagnetUri("ftp://example.com"), false);
});
test("isValidMagnetUri: rejects magnet without xt parameter", () => {
assert.equal(isValidMagnetUri("magnet:?dn=Test"), false);
assert.equal(isValidMagnetUri("magnet:?tr=wss://tracker.com"), false);
});
test("isValidMagnetUri: rejects magnet with invalid xt format", () => {
assert.equal(isValidMagnetUri("magnet:?xt=invalid"), false);
assert.equal(isValidMagnetUri("magnet:?xt=urn:sha1:abc"), false);
});
test("isValidMagnetUri: rejects magnet with btmh (v2) hash only", () => {
// BitTorrent v2 hashes are not supported
const v2Hash = "a".repeat(64);
const magnet = `magnet:?xt=urn:btmh:${v2Hash}`;
assert.equal(isValidMagnetUri(magnet), false);
});
test("isValidMagnetUri: rejects magnet with invalid hex hash length", () => {
const shortHash = "a".repeat(39); // 39 chars, should be 40
const longHash = "a".repeat(41); // 41 chars, should be 40
assert.equal(isValidMagnetUri(`magnet:?xt=urn:btih:${shortHash}`), false);
assert.equal(isValidMagnetUri(`magnet:?xt=urn:btih:${longHash}`), false);
});
test("isValidMagnetUri: rejects magnet with invalid base32 hash length", () => {
const shortHash = "A".repeat(31); // 31 chars, should be 32
const longHash = "A".repeat(33); // 33 chars, should be 32
assert.equal(isValidMagnetUri(`magnet:?xt=urn:btih:${shortHash}`), false);
assert.equal(isValidMagnetUri(`magnet:?xt=urn:btih:${longHash}`), false);
});
test("isValidMagnetUri: rejects magnet with invalid characters in hash", () => {
const invalidHex = "g".repeat(40); // 'g' is not valid hex
assert.equal(isValidMagnetUri(`magnet:?xt=urn:btih:${invalidHex}`), false);
});
// Edge cases
test("isValidMagnetUri: handles whitespace around valid magnet", () => {
const hexHash = "a".repeat(40);
const magnet = ` magnet:?xt=urn:btih:${hexHash} `;
assert.equal(isValidMagnetUri(magnet), true);
});
test("isValidMagnetUri: handles URL-encoded magnet URI", () => {
// This depends on whether safeDecodeMagnet handles encoding
const hexHash = "a".repeat(40);
const encoded = encodeURIComponent(`magnet:?xt=urn:btih:${hexHash}`);
// The result depends on safeDecodeMagnet implementation
// At minimum it should not crash
const result = isValidMagnetUri(encoded);
assert.equal(typeof result, "boolean");
});
test("isValidMagnetUri: accepts magnet with multiple xt values (first valid)", () => {
const hexHash = "a".repeat(40);
const magnet = `magnet:?xt=urn:btih:${hexHash}&xt=urn:sha1:invalid`;
assert.equal(isValidMagnetUri(magnet), true);
});
test("isValidMagnetUri: validates real-world magnet examples", () => {
// Real structure of WebTorrent magnets
const realMagnet = "magnet:?xt=urn:btih:dd8255ecdc7ca55fb0bbf81323d87062db1f6d1c&dn=Big+Buck+Bunny&tr=wss%3A%2F%2Ftracker.btorrent.xyz";
assert.equal(isValidMagnetUri(realMagnet), true);
});