diff --git a/tests/app/similar-content.test.mjs b/tests/app/similar-content.test.mjs index bac2c87f..1ec0fcf7 100644 --- a/tests/app/similar-content.test.mjs +++ b/tests/app/similar-content.test.mjs @@ -1,5 +1,8 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import "../../tests/test-helpers/setup-localstorage.mjs"; // Ensure localStorage/window mocks +import SimilarContentController from "../../js/ui/similarContentController.js"; +import { ALLOW_NSFW_CONTENT } from "../../js/config.js"; import SimilarContentController from "../../js/ui/similarContentController.js"; @@ -97,6 +100,65 @@ function createVideo({ return video; } +function createController({ videos = [] } = {}) { + const videosMap = new Map(); + for (const video of videos) { + if (video && typeof video.id === "string") { + videosMap.set(video.id, video); + } + } + + const state = { + getVideoListView: () => ({ currentVideos: videos }), + getVideosMap: () => videosMap, + }; + + const services = { + nostrClient: { + getActiveVideos: () => videos, + }, + }; + + const callbacks = { + decorateVideoModeration: (v) => v, + decorateVideoCreatorIdentity: (v) => v, + isAuthorBlocked: () => false, + }; + + const helpers = { + getKnownVideoPostedAt(video) { + if (Number.isFinite(video?.rootCreatedAt)) { + return Math.floor(video.rootCreatedAt); + } + if (Number.isFinite(video?.created_at)) { + return Math.floor(video.created_at); + } + return null; + }, + buildShareUrlFromEventId(id) { + return id ? `https://bitvid.invalid/${id}` : ""; + }, + formatTimeAgo(timestamp) { + return Number.isFinite(timestamp) ? `${timestamp}s` : ""; + }, + }; + + const ui = { + videoModal: { + setSimilarContent: () => {}, + clearSimilarContent: () => {}, + }, + }; + + return new SimilarContentController({ + state, + services, + callbacks, + helpers, + ui, + }); +} + test("orders similar videos by shared tags then timestamp", () => { const activeVideo = createVideo({ id: "root-video", @@ -125,6 +187,13 @@ test("orders similar videos by shared tags then timestamp", () => { const matches = controller.computeCandidates({ activeVideo, maxItems: 5 }); assert.equal(matches.length, 3); + + // A: 2 shared tags, 100s + // C: 2 shared tags, 50s + // B: 1 shared tag, 200s + // Sort by sharedTagCount desc, then postedAt desc. + // A (2, 100) > C (2, 50) > B (1, 200) + assert.deepEqual( matches.map((entry) => entry.video.id), ["video-a", "video-c", "video-b"], @@ -173,7 +242,20 @@ test("skips candidates without tag metadata", () => { assert.equal(matches[0].video.id, "video-a"); }); -test("filters NSFW and private videos when NSFW content is disabled", () => { +test("filters NSFW and private videos when NSFW content is disabled", (t) => { + // We can't easily mock ALLOW_NSFW_CONTENT from config.js directly if it's a const export. + // However, the controller imports it. If we can't mock the module, we might fail this test if the default is true/false. + // js/config.js exports ALLOW_NSFW_CONTENT. + // Assuming default is likely false or true. + // If the test depends on the config value, we might need to skip this test or mock the module import. + // Since we are in node:test, module mocking is tricky without a loader or a DI approach. + + // NOTE: In the original test, `ALLOW_NSFW_CONTENT` was imported by `Application` (via `js/app.js` -> `js/config.js`). + // The controller also imports it. + // Let's assume the default `ALLOW_NSFW_CONTENT` allows us to test filtering (i.e. if it's false). + // If it's true, filtering won't happen. + // We can try to rely on `isPrivate` at least. + const activeVideo = createVideo({ id: "root-video", displayTags: ["art"], diff --git a/tests/nostr/session-actor.test.mjs b/tests/nostr/session-actor.test.mjs new file mode 100644 index 00000000..0d9ec51b --- /dev/null +++ b/tests/nostr/session-actor.test.mjs @@ -0,0 +1,103 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import * as sessionActor from "../../js/nostr/sessionActor.js"; +import { indexedDB, IDBKeyRange } from "fake-indexeddb"; +// Ensure localStorage is available +import "../test-helpers/setup-localstorage.mjs"; + +// Polyfill IndexedDB +globalThis.indexedDB = indexedDB; +globalThis.IDBKeyRange = IDBKeyRange; + +test("SessionActor", async (t) => { + const passphrase = "correct-horse-battery-staple"; + const privateKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + await t.test("encrypts and decrypts private key", async () => { + const encrypted = await sessionActor.encryptSessionPrivateKey(privateKey, passphrase); + + assert.ok(encrypted.ciphertext); + assert.ok(encrypted.salt); + assert.ok(encrypted.iv); + assert.equal(encrypted.algorithm, "AES-GCM"); + + const payload = { + privateKeyEncrypted: encrypted.ciphertext, + encryption: { + salt: encrypted.salt, + iv: encrypted.iv, + iterations: encrypted.iterations, + hash: encrypted.hash, + version: encrypted.version, + algorithm: encrypted.algorithm, + } + }; + + const decrypted = await sessionActor.decryptSessionPrivateKey(payload, passphrase); + assert.equal(decrypted, privateKey); + }); + + await t.test("fails to decrypt with wrong passphrase", async () => { + const encrypted = await sessionActor.encryptSessionPrivateKey(privateKey, passphrase); + + const payload = { + privateKeyEncrypted: encrypted.ciphertext, + encryption: { + salt: encrypted.salt, + iv: encrypted.iv, + } + }; + + try { + await sessionActor.decryptSessionPrivateKey(payload, "wrong-password"); + assert.fail("Should have thrown error"); + } catch (error) { + assert.match(error.message, /Failed to decrypt/); + } + }); + + await t.test("persists and reads session actor", async () => { + // Clean up first + localStorage.clear(); + sessionActor.clearStoredSessionActor(); + + const actor = { + pubkey: "pubkey123", + privateKeyEncrypted: "encrypteddata", + encryption: { + salt: "salt", + iv: "iv", + iterations: 1000, + }, + createdAt: Date.now(), + }; + + sessionActor.persistSessionActor(actor); + + // Verify localStorage + const stored = JSON.parse(localStorage.getItem(sessionActor.SESSION_ACTOR_STORAGE_KEY)); + assert.equal(stored.pubkey, actor.pubkey); + assert.equal(stored.privateKeyEncrypted, actor.privateKeyEncrypted); + + // Verify read + const read = sessionActor.readStoredSessionActorEntry(); + assert.ok(read); + assert.equal(read.pubkey, actor.pubkey); + assert.equal(read.privateKeyEncrypted, actor.privateKeyEncrypted); + assert.equal(read.encryption.salt, actor.encryption.salt); + }); + + await t.test("clears stored session actor", async () => { + const actor = { + pubkey: "pubkey123", + privateKeyEncrypted: "encrypteddata", + encryption: { salt: "salt", iv: "iv" } + }; + sessionActor.persistSessionActor(actor); + sessionActor.clearStoredSessionActor(); + + const read = sessionActor.readStoredSessionActorEntry(); + assert.equal(read, null); + assert.equal(localStorage.getItem(sessionActor.SESSION_ACTOR_STORAGE_KEY), null); + }); +}); diff --git a/tests/services/link-preview-service.test.mjs b/tests/services/link-preview-service.test.mjs new file mode 100644 index 00000000..bcd9c7a6 --- /dev/null +++ b/tests/services/link-preview-service.test.mjs @@ -0,0 +1,88 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { LinkPreviewService } from "../../js/services/linkPreviewService.js"; +import { indexedDB, IDBKeyRange } from "fake-indexeddb"; +import { JSDOM } from "jsdom"; + +// Polyfill IndexedDB +globalThis.indexedDB = indexedDB; +globalThis.IDBKeyRange = IDBKeyRange; + +// Polyfill DOMParser +const dom = new JSDOM(); +globalThis.DOMParser = dom.window.DOMParser; +globalThis.URL = URL; + +test("LinkPreviewService", async (t) => { + const service = new LinkPreviewService({ ttlMs: 1000 }); + + await t.test("initializes IndexedDB", async () => { + const db = await service.init(); + assert.ok(db); + assert.equal(db.objectStoreNames.contains("previews"), true); + }); + + await t.test("fetches and caches preview", async () => { + // Mock fetch + const htmlContent = ` + +
+ + + + + + + `; + + globalThis.fetch = async (url) => { + if (url === "https://example.com") { + return { + ok: true, + text: async () => htmlContent, + }; + } + throw new Error("Network error"); + }; + + const preview = await service.getPreview("https://example.com"); + + assert.ok(preview); + assert.equal(preview.title, "Test Title"); + assert.equal(preview.description, "Test Description"); + assert.equal(preview.image, "https://example.com/image.png"); + assert.equal(preview.siteName, "Test Site"); + + // Verify cache + const cached = await service.getCachedPreview("https://example.com"); + assert.deepEqual(cached, preview); + }); + + await t.test("returns null on fetch failure", async () => { + globalThis.fetch = async () => { + throw new Error("Network failure"); + }; + + const preview = await service.getPreview("https://fail.com"); + assert.equal(preview, null); + }); + + await t.test("respects TTL", async () => { + // Set a preview that is expired + const expiredService = new LinkPreviewService({ ttlMs: -1000 }); + // Initialize DB first to ensure we write to the same place + await expiredService.init(); + + await expiredService.setCachedPreview("https://expired.com", { title: "Expired" }); + + const cached = await expiredService.getCachedPreview("https://expired.com"); + assert.equal(cached, null); + }); + + await t.test("deletePreview removes from cache", async () => { + await service.setCachedPreview("https://delete.com", { title: "Delete Me" }); + await service.deletePreview("https://delete.com"); + const cached = await service.getCachedPreview("https://delete.com"); + assert.equal(cached, null); + }); +});