diff --git a/tests/nostr/dmSignalEvents.test.mjs b/tests/nostr/dmSignalEvents.test.mjs new file mode 100644 index 00000000..455003e1 --- /dev/null +++ b/tests/nostr/dmSignalEvents.test.mjs @@ -0,0 +1,168 @@ +import { test, describe, before, after, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert"; +import { generateSecretKey, getPublicKey } from "nostr-tools"; +import "../test-helpers/setup-localstorage.mjs"; + +// Mock globals needed for nostr-tools or other libs +if (!globalThis.crypto) { + globalThis.crypto = { + getRandomValues: (arr) => { + for (let i = 0; i < arr.length; i++) { + arr[i] = Math.floor(Math.random() * 256); + } + return arr; + } + }; +} + +function bytesToHex(bytes) { + return Buffer.from(bytes).toString('hex'); +} + +// Helper to generate 32-byte hex string +function generateHexId() { + return bytesToHex(generateSecretKey()); +} + +describe("dmSignalEvents", () => { + let nostrClient; + let dmSignalEvents; + let originalPool; + let originalRelays; + let originalPubkey; + let originalSessionActor; + let signer; + let getActiveSigner; + + before(async () => { + // Import modules + const facadeModule = await import("../../js/nostrClientFacade.js"); + const registryModule = await import("../../js/nostrClientRegistry.js"); + dmSignalEvents = await import("../../js/nostr/dmSignalEvents.js"); + + nostrClient = facadeModule.nostrClient; + getActiveSigner = registryModule.getActiveSigner; + + // Generate keys + const secret = generateSecretKey(); + const privateKey = bytesToHex(secret); + const pubkey = getPublicKey(secret); + + // Register signer to ensure getActiveSigner returns something + await nostrClient.registerPrivateKeySigner({ privateKey, pubkey }); + signer = getActiveSigner(); + }); + + beforeEach(() => { + originalPool = nostrClient.pool; + originalRelays = nostrClient.relays; + originalPubkey = nostrClient.pubkey; + originalSessionActor = nostrClient.sessionActor; + + // Mock pool + nostrClient.pool = { + publish: mock.fn((urls, event) => { + return { + on: (event, cb) => { + if (event === 'ok') setTimeout(cb, 0); + } + }; + }) + }; + + nostrClient.relays = ["wss://relay.mock"]; + nostrClient.writeRelays = ["wss://relay.mock"]; + + // Ensure we are logged in + nostrClient.pubkey = signer.pubkey; + + // Ensure we are NOT a session actor (which blocks signals) + nostrClient.sessionActor = null; + + // Mock signEvent on the signer + // We keep the original signer object but replace the method + signer.signEvent = mock.fn(async (evt) => ({ ...evt, sig: "mock-sig", id: "mock-id" })); + }); + + afterEach(() => { + nostrClient.pool = originalPool; + nostrClient.relays = originalRelays; + nostrClient.pubkey = originalPubkey; + nostrClient.sessionActor = originalSessionActor; + mock.reset(); + }); + + test("publishDmReadReceipt should succeed", async () => { + const eventId = generateHexId(); + const recipientPubkey = generateHexId(); + const payload = { + eventId, + recipientPubkey, + messageKind: 4 + }; + + const result = await dmSignalEvents.publishDmReadReceipt(nostrClient, payload); + + assert.strictEqual(result.ok, true); + assert.strictEqual(signer.signEvent.mock.callCount(), 1); + assert.strictEqual(nostrClient.pool.publish.mock.callCount(), 1); + + const signedEvent = result.event; + // Check tags + const tags = signedEvent.tags; + assert.ok(tags.find(t => t[0] === "e" && t[1] === eventId)); + assert.ok(tags.find(t => t[0] === "p" && t[1] === recipientPubkey)); + // Check 'k' tag for messageKind + assert.ok(tags.find(t => t[0] === "k" && t[1] === "4")); + + assert.strictEqual(signedEvent.content, ""); + }); + + test("publishDmReadReceipt should fail if session actor", async () => { + // Set sessionActor to look like the current user + nostrClient.sessionActor = { pubkey: nostrClient.pubkey }; + + const payload = { + eventId: generateHexId(), + recipientPubkey: generateHexId(), + messageKind: 4 + }; + + const result = await dmSignalEvents.publishDmReadReceipt(nostrClient, payload); + assert.strictEqual(result.ok, false); + assert.strictEqual(result.error.code, "session-actor-publish-blocked"); + }); + + test("publishDmTypingIndicator should succeed", async () => { + const recipientPubkey = generateHexId(); + const conversationEventId = generateHexId(); + const payload = { + recipientPubkey, + conversationEventId + }; + + const result = await dmSignalEvents.publishDmTypingIndicator(nostrClient, payload); + + assert.strictEqual(result.ok, true); + assert.strictEqual(signer.signEvent.mock.callCount(), 1); + + const signedEvent = result.event; + // Check tags + assert.ok(signedEvent.tags.find(t => t[0] === "p" && t[1] === recipientPubkey)); + assert.ok(signedEvent.tags.find(t => t[0] === "e" && t[1] === conversationEventId)); + }); + + test("should fail if no signer", async () => { + // Temporarily break signer + const originalSignEvent = signer.signEvent; + signer.signEvent = undefined; + + const payload = { eventId: generateHexId(), recipientPubkey: generateHexId() }; + const result = await dmSignalEvents.publishDmReadReceipt(nostrClient, payload); + + assert.strictEqual(result.ok, false); + assert.strictEqual(result.error, "auth-required"); + + signer.signEvent = originalSignEvent; + }); +}); diff --git a/tests/nostr/syncMetadataStore.test.mjs b/tests/nostr/syncMetadataStore.test.mjs new file mode 100644 index 00000000..fe6cbeb8 --- /dev/null +++ b/tests/nostr/syncMetadataStore.test.mjs @@ -0,0 +1,115 @@ +import { test, describe, beforeEach, afterEach } from "node:test"; +import assert from "node:assert"; +import "../test-helpers/setup-localstorage.mjs"; +import { SyncMetadataStore, __testExports } from "../../js/nostr/syncMetadataStore.js"; + +const { SYNC_METADATA_STORAGE_KEY } = __testExports; + +describe("SyncMetadataStore", () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + test("should load from localStorage on initialization", () => { + const data = [["key1", 1234567890]]; + localStorage.setItem(SYNC_METADATA_STORAGE_KEY, JSON.stringify(data)); + + const store = new SyncMetadataStore(); + // computeKey(kind, pubkey, dTag, relayUrl) + // We don't know the components of "key1" but it should be in the map. + // However, the class treats keys opaquely once computed. + // Let's use internal map to verify if possible, or verify via public API if we know the key components. + // Accessing private property? logic uses `this.metadata`. + // We can't easily access `this.metadata` if it's not exposed. + // But we can verify with `getLastSeen`. + + // Wait, "key1" isn't a valid key format generated by computeKey, but load() just loads the map. + // getLastSeen computes the key again. + // So if we pre-populate with a valid key, we can test it. + + const kind = 1; + const pubkey = "pubkey1"; + const dTag = "dtag1"; + const relayUrl = "wss://relay1.com"; + const key = `${kind}:${pubkey}:${dTag}:${relayUrl}`; + const timestamp = 1000; + + localStorage.setItem(SYNC_METADATA_STORAGE_KEY, JSON.stringify([[key, timestamp]])); + + const store2 = new SyncMetadataStore(); + assert.strictEqual(store2.getLastSeen(kind, pubkey, dTag, relayUrl), timestamp); + }); + + test("should update last seen and save to localStorage", () => { + const store = new SyncMetadataStore(); + const kind = 1; + const pubkey = "pubkey1"; + const dTag = "dtag1"; + const relayUrl = "wss://relay1.com"; + const timestamp = 2000; + + store.updateLastSeen(kind, pubkey, dTag, relayUrl, timestamp); + + assert.strictEqual(store.getLastSeen(kind, pubkey, dTag, relayUrl), timestamp); + + const saved = JSON.parse(localStorage.getItem(SYNC_METADATA_STORAGE_KEY)); + const key = `${kind}:${pubkey}:${dTag}:${relayUrl}`; + const entry = saved.find(e => e[0] === key); + assert.ok(entry); + assert.strictEqual(entry[1], timestamp); + }); + + test("should only update if timestamp is newer", () => { + const store = new SyncMetadataStore(); + const kind = 1; + const pubkey = "pubkey1"; + const dTag = "dtag1"; + const relayUrl = "wss://relay1.com"; + + store.updateLastSeen(kind, pubkey, dTag, relayUrl, 2000); + assert.strictEqual(store.getLastSeen(kind, pubkey, dTag, relayUrl), 2000); + + // Try older + store.updateLastSeen(kind, pubkey, dTag, relayUrl, 1000); + assert.strictEqual(store.getLastSeen(kind, pubkey, dTag, relayUrl), 2000); + + // Try newer + store.updateLastSeen(kind, pubkey, dTag, relayUrl, 3000); + assert.strictEqual(store.getLastSeen(kind, pubkey, dTag, relayUrl), 3000); + }); + + test("should get per-relay last seen", () => { + const store = new SyncMetadataStore(); + const kind = 1; + const pubkey = "pubkey1"; + const dTag = "dtag1"; + + store.updateLastSeen(kind, pubkey, dTag, "wss://relay1.com", 1000); + store.updateLastSeen(kind, pubkey, dTag, "wss://relay2.com", 2000); + store.updateLastSeen(kind, pubkey, "other", "wss://relay1.com", 3000); // Should be ignored + + const result = store.getPerRelayLastSeen(kind, pubkey, dTag); + + assert.deepStrictEqual(result, { + "wss://relay1.com": 1000, + "wss://relay2.com": 2000 + }); + }); + + test("should handle missing localStorage gracefully", () => { + const originalLocalStorage = globalThis.localStorage; + delete globalThis.localStorage; // Simulate missing localStorage + + try { + const store = new SyncMetadataStore(); + store.updateLastSeen(1, "p", "d", "r", 100); // Should not throw + // Can't check persistence, but ensure no crash + } finally { + globalThis.localStorage = originalLocalStorage; + } + }); +}); diff --git a/tests/services/attachmentService.test.mjs b/tests/services/attachmentService.test.mjs new file mode 100644 index 00000000..5a983608 --- /dev/null +++ b/tests/services/attachmentService.test.mjs @@ -0,0 +1,166 @@ +import { test, describe, before, after, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert"; +import "../test-helpers/setup-localstorage.mjs"; +import * as attachmentService from "../../js/services/attachmentService.js"; + +// Mock URL.createObjectURL and revokeObjectURL +const originalURL = globalThis.URL; +const originalFetch = globalThis.fetch; + +describe("attachmentService", () => { + before(() => { + if (!globalThis.URL) { + globalThis.URL = {}; + } + }); + + beforeEach(() => { + globalThis.URL.createObjectURL = mock.fn(() => "blob:mock-url"); + globalThis.URL.revokeObjectURL = mock.fn(); + globalThis.fetch = mock.fn(); + + // Clear cache before each test to ensure isolation, + // but we need to be careful not to trigger revokeObjectURL calls that we count? + // Actually, we should just clear it. + attachmentService.clearAttachmentCache(); + // Then reset calls + globalThis.URL.revokeObjectURL.mock.resetCalls(); + }); + + after(() => { + if (originalURL) { + globalThis.URL = originalURL; + } else { + delete globalThis.URL; + } + if (originalFetch) { + globalThis.fetch = originalFetch; + } else { + delete globalThis.fetch; + } + }); + + afterEach(() => { + mock.reset(); + attachmentService.clearAttachmentCache(); + }); + + describe("prepareAttachmentUpload", () => { + test("should prepare upload without encryption", async () => { + const file = new File(["test content"], "test.txt", { type: "text/plain" }); + const result = await attachmentService.prepareAttachmentUpload({ file, encrypt: false }); + + assert.strictEqual(result.name, "test.txt"); + assert.strictEqual(result.type, "text/plain"); + assert.strictEqual(result.size, 12); + assert.strictEqual(result.encrypted, false); + assert.strictEqual(result.key, ""); + // sha256 of "test content" + assert.strictEqual(result.sha256, "6ae8a75555209fd6c44157c0aed8016e763ff435a19cf186f76863140143ff72"); + }); + + test("should prepare upload with encryption", async () => { + const file = new File(["test content"], "test.txt", { type: "text/plain" }); + const result = await attachmentService.prepareAttachmentUpload({ file, encrypt: true }); + + assert.strictEqual(result.name, "test.txt"); + assert.strictEqual(result.type, "text/plain"); // Metadata preserves original type + assert.strictEqual(result.encrypted, true); + assert.ok(result.key.length > 0, "Should return a key"); + assert.notStrictEqual(result.uploadBlob, file, "Should return a new blob"); + assert.strictEqual(result.uploadBlob.type, "application/octet-stream"); // Actual blob is generic binary + }); + }); + + describe("downloadAttachment", () => { + test("should download attachment", async () => { + const mockResponse = { + ok: true, + blob: async () => new Blob(["test content"]), + headers: new Map(), + }; + globalThis.fetch.mock.mockImplementation(async () => mockResponse); + + const result = await attachmentService.downloadAttachment({ url: "https://example.com/file" }); + + assert.strictEqual(globalThis.fetch.mock.callCount(), 1); + assert.strictEqual(result.objectUrl, "blob:mock-url"); + assert.strictEqual(result.size, 12); + }); + + test("should verify hash", async () => { + const mockResponse = { + ok: true, + blob: async () => new Blob(["test content"]), + headers: new Map(), + }; + globalThis.fetch.mock.mockImplementation(async () => mockResponse); + + const expectedHash = "6ae8a75555209fd6c44157c0aed8016e763ff435a19cf186f76863140143ff72"; + await attachmentService.downloadAttachment({ url: "https://example.com/file", expectedHash }); + + // Should not throw + }); + + test("should throw on hash mismatch", async () => { + const mockResponse = { + ok: true, + blob: async () => new Blob(["test content"]), + headers: new Map(), + }; + globalThis.fetch.mock.mockImplementation(async () => mockResponse); + + const expectedHash = "wronghash"; + + await assert.rejects( + async () => { + await attachmentService.downloadAttachment({ url: "https://example.com/file", expectedHash }); + }, + /Attachment hash mismatch/ + ); + }); + }); + + describe("caching", () => { + test("should cache downloaded attachments", async () => { + const mockResponse = { + ok: true, + blob: async () => new Blob(["test content"]), + headers: new Map(), + }; + globalThis.fetch.mock.mockImplementation(async () => mockResponse); + + const expectedHash = "6ae8a75555209fd6c44157c0aed8016e763ff435a19cf186f76863140143ff72"; + + // First download + await attachmentService.downloadAttachment({ url: "https://example.com/file1", expectedHash }); + assert.strictEqual(globalThis.fetch.mock.callCount(), 1); + + // Second download (should be cached) + const result = await attachmentService.downloadAttachment({ url: "https://example.com/file1", expectedHash }); + // Fetch should NOT be called again + assert.strictEqual(globalThis.fetch.mock.callCount(), 1); + assert.strictEqual(result.cached, true); + }); + + test("should clear cache", async () => { + const mockResponse = { + ok: true, + blob: async () => new Blob(["test content"]), + headers: new Map(), + }; + globalThis.fetch.mock.mockImplementation(async () => mockResponse); + const expectedHash = "6ae8a75555209fd6c44157c0aed8016e763ff435a19cf186f76863140143ff72"; + + await attachmentService.downloadAttachment({ url: "https://example.com/file", expectedHash }); + assert.strictEqual(attachmentService.getAttachmentCacheStats().size, 1); + + // Clear call count for revokeObjectURL before clearing cache to isolate the count + globalThis.URL.revokeObjectURL.mock.resetCalls(); + + attachmentService.clearAttachmentCache(); + assert.strictEqual(attachmentService.getAttachmentCacheStats().size, 0); + assert.strictEqual(globalThis.URL.revokeObjectURL.mock.callCount(), 1); + }); + }); +}); diff --git a/tests/services/s3Service.test.mjs b/tests/services/s3Service.test.mjs new file mode 100644 index 00000000..06f065d8 --- /dev/null +++ b/tests/services/s3Service.test.mjs @@ -0,0 +1,64 @@ +import { test, describe, afterEach } from "node:test"; +import assert from "node:assert"; +import { createUiDom } from "../ui/helpers/jsdom-test-helpers.mjs"; + +// Disable network imports to prevent S3 SDK loading +globalThis.__BITVID_DISABLE_NETWORK_IMPORTS__ = true; + +const s3Service = await import("../../js/services/s3Service.js"); + +describe("s3Service", () => { + describe("validateS3Connection", () => { + test("should validate correct configuration", () => { + const config = { + endpoint: "https://s3.example.com", + region: "us-east-1", + accessKeyId: "key", + secretAccessKey: "secret", + bucket: "my-bucket", + forcePathStyle: true, + }; + + const result = s3Service.validateS3Connection(config); + assert.strictEqual(result.endpoint, "https://s3.example.com"); + assert.strictEqual(result.bucket, "my-bucket"); + assert.ok(result.publicBaseUrl.includes("my-bucket")); + }); + + test("should throw on missing required fields", () => { + assert.throws(() => s3Service.validateS3Connection({}), /S3 endpoint is required/); + // Region defaults to auto, so next check is Access Key ID + assert.throws(() => s3Service.validateS3Connection({ endpoint: "x" }), /S3 access key ID is required/); + }); + }); + + describe("getCorsOrigins", () => { + let uiDom; + const originalWindow = global.window; + + afterEach(() => { + if (uiDom) { + uiDom.cleanup(); + uiDom = null; + } + global.window = originalWindow; + }); + + test("should return current origin", () => { + uiDom = createUiDom({ url: "https://app.bitvid.invalid/" }); + global.window = uiDom.window; + + const origins = s3Service.getCorsOrigins(); + assert.ok(origins.includes("https://app.bitvid.invalid")); + }); + + test("should handle localhost", () => { + uiDom = createUiDom({ url: "http://localhost:3000/" }); + global.window = uiDom.window; + + const origins = s3Service.getCorsOrigins(); + assert.ok(origins.includes("http://localhost:3000")); + assert.ok(origins.includes("https://localhost:3000")); // Should add https version + }); + }); +});