mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
8 files changed
+2448
No files matched your search
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user