Merge pull request #1876 from PR0M3TH3AN/ai/tests-core-modules-20241018-16491855254323953443

Add unit tests for core services and nostr modules
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-01-28 08:31:13 -05:00
commit 5dc9c2d016
4 files changed
+513

No files matched your search

+168
View File
@@ -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;
});
});
+115
View File
@@ -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;
}
});
});
+166
View File
@@ -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);
});
});
});
+64
View File
@@ -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
});
});
});