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