diff --git a/tests/services/profileMetadataService.test.mjs b/tests/services/profileMetadataService.test.mjs new file mode 100644 index 00000000..f0cdd748 --- /dev/null +++ b/tests/services/profileMetadataService.test.mjs @@ -0,0 +1,258 @@ +import { describe, it, mock, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { + fetchProfileMetadataBatch, + fetchProfileMetadata, + ensureProfileMetadataSubscription +} from "../../js/services/profileMetadataService.js"; + +describe("profileMetadataService", () => { + let mockNostr; + let mockPool; + let mockLogger; + + beforeEach(() => { + mockPool = { + list: mock.fn(async () => []), + sub: mock.fn(() => ({ + on: mock.fn(), + unsub: mock.fn(), + })), + }; + + mockNostr = { + pool: mockPool, + relays: ["wss://relay.example.com"], + readRelays: ["wss://read.example.com"], + }; + + mockLogger = { + warn: mock.fn(), + log: mock.fn(), + debug: mock.fn(), + }; + }); + + describe("fetchProfileMetadataBatch", () => { + it("should return empty map if no pubkeys provided", async () => { + const results = await fetchProfileMetadataBatch({ + pubkeys: [], + nostr: mockNostr, + logger: mockLogger, + }); + assert.equal(results.size, 0); + assert.equal(mockPool.list.mock.callCount(), 0); + }); + + it("should fetch profiles for provided pubkeys", async () => { + const pubkey = "0000000000000000000000000000000000000000000000000000000000000001"; + const mockEvent = { + pubkey, + created_at: 1000, + content: JSON.stringify({ name: "Alice", about: "Tester" }), + }; + + mockPool.list.mock.mockImplementation(async () => [mockEvent]); + + const results = await fetchProfileMetadataBatch({ + pubkeys: [pubkey], + nostr: mockNostr, + logger: mockLogger, + }); + + assert.equal(results.size, 1); + const profileData = results.get(pubkey); + assert.ok(profileData); + assert.equal(profileData.profile.name, "Alice"); + assert.equal(profileData.profile.about, "Tester"); + + // Verify pool.list was called + assert.equal(mockPool.list.mock.callCount(), 1); // Once per relay (mockNostr has 2, but logic merges? Wait, let's check logic) + // Logic: resolveRelays prefers readRelays if available. + // resolveRelays(relays, nostr) + // if relays arg is passed, use it. + // else if nostr.readRelays, use it. + // else if nostr.relays, use it. + + // Here we didn't pass relays, so it should use readRelays ("wss://read.example.com") + // So call count should be 1 (since 1 relay in readRelays) + assert.equal(mockPool.list.mock.callCount(), 1); + const callArgs = mockPool.list.mock.calls[0].arguments; + assert.deepEqual(callArgs[0], ["wss://read.example.com"]); + assert.deepEqual(callArgs[1], [{ kinds: [0], authors: [pubkey], limit: 1 }]); + }); + + it("should handle relay failures gracefully", async () => { + const pubkey = "0000000000000000000000000000000000000000000000000000000000000002"; + mockPool.list.mock.mockImplementation(async () => { + throw new Error("Relay error"); + }); + + const results = await fetchProfileMetadataBatch({ + pubkeys: [pubkey], + nostr: mockNostr, + logger: mockLogger, + }); + + assert.equal(results.size, 0); + assert.equal(mockLogger.warn.mock.callCount(), 1); + }); + + it("should parse profile content correctly", async () => { + const pubkey = "0000000000000000000000000000000000000000000000000000000000000003"; + const content = JSON.stringify({ + display_name: "Bob", + picture: "https://example.com/bob.jpg", + website: "https://bob.com", + banner: "https://bob.com/banner.jpg", + lud16: "bob@ln.address", + lud06: "lnurl1...", + }); + + const mockEvent = { + pubkey, + created_at: 2000, + content, + }; + + mockPool.list.mock.mockImplementation(async () => [mockEvent]); + + const results = await fetchProfileMetadataBatch({ + pubkeys: [pubkey], + nostr: mockNostr, + logger: mockLogger, + }); + + const profile = results.get(pubkey).profile; + assert.equal(profile.name, "Bob"); + assert.equal(profile.picture, "https://example.com/bob.jpg"); + assert.equal(profile.website, "https://bob.com"); + assert.equal(profile.banner, "https://bob.com/banner.jpg"); + assert.equal(profile.lud16, "bob@ln.address"); + assert.equal(profile.lud06, "lnurl1..."); + }); + + it("should deduplicate concurrent requests (inflight)", async () => { + const pubkey = "0000000000000000000000000000000000000000000000000000000000000004"; + let resolveList; + const listPromise = new Promise(r => { resolveList = r; }); + + mockPool.list.mock.mockImplementation(() => listPromise); + + // Start two requests + const p1 = fetchProfileMetadataBatch({ pubkeys: [pubkey], nostr: mockNostr, logger: mockLogger }); + const p2 = fetchProfileMetadataBatch({ pubkeys: [pubkey], nostr: mockNostr, logger: mockLogger }); + + // Verify only one list call was made so far + // Wait a tick + await new Promise(r => setTimeout(r, 0)); + assert.equal(mockPool.list.mock.callCount(), 1); + + // Resolve + resolveList([{ pubkey, created_at: 1, content: "{}" }]); + + await Promise.all([p1, p2]); + assert.equal(mockPool.list.mock.callCount(), 1); + }); + }); + + describe("fetchProfileMetadata", () => { + it("should return null for invalid pubkey", async () => { + const result = await fetchProfileMetadata("invalid", { nostr: mockNostr }); + assert.equal(result, null); + }); + + it("should return single profile result", async () => { + const pubkey = "0000000000000000000000000000000000000000000000000000000000000005"; + mockPool.list.mock.mockImplementation(async () => [{ + pubkey, + created_at: 1, + content: JSON.stringify({ name: "Single" }) + }]); + + const result = await fetchProfileMetadata(pubkey, { nostr: mockNostr, logger: mockLogger }); + assert.equal(result.profile.name, "Single"); + }); + }); + + describe("ensureProfileMetadataSubscription", () => { + it("should return null if nostr pool is missing", () => { + const res = ensureProfileMetadataSubscription({ + pubkey: "abc", + nostr: {}, // No pool + }); + assert.equal(res, null); + }); + + it("should create a subscription via relaySubscriptionService", () => { + const pubkey = "0000000000000000000000000000000000000000000000000000000000000006"; + const mockSub = { + on: mock.fn(), + unsub: mock.fn(), + }; + mockPool.sub.mock.mockImplementation(() => mockSub); + + const sub = ensureProfileMetadataSubscription({ + pubkey, + nostr: mockNostr, + logger: mockLogger, + }); + + // relaySubscriptionService returns the subscription object + assert.equal(sub, mockSub); + + assert.equal(mockPool.sub.mock.callCount(), 1); + const args = mockPool.sub.mock.calls[0].arguments; + // check relays + assert.deepEqual(args[0], ["wss://read.example.com"]); + // check filters + assert.deepEqual(args[1], [{ kinds: [0], authors: [pubkey], limit: 1 }]); + }); + + it("should handle onProfile callback", () => { + const pubkey = "0000000000000000000000000000000000000000000000000000000000000007"; + let capturedOnEvent; + + // We need to capture the onEvent passed to relaySubscriptionService.ensureSubscription + // Since we can't easily mock the internal import of relaySubscriptionService, + // we rely on the fact that relaySubscriptionService attaches a listener to the sub. + // BUT, relaySubscriptionService attaches `subscription.on('event', ...)` + // So if we mock `sub.on`, we can capture the handler provided by relaySubscriptionService. + + let relayServiceEventHandler; + const mockSub = { + on: mock.fn((event, handler) => { + if (event === 'event') { + relayServiceEventHandler = handler; + } + }), + unsub: mock.fn(), + }; + mockPool.sub.mock.mockImplementation(() => mockSub); + + const onProfileSpy = mock.fn(); + + ensureProfileMetadataSubscription({ + pubkey, + nostr: mockNostr, + logger: mockLogger, + onProfile: onProfileSpy, + }); + + // Now simulate an event coming from the pool subscription + const event = { + pubkey, + content: JSON.stringify({ name: "Live Update" }), + }; + + if (relayServiceEventHandler) { + relayServiceEventHandler(event); + } + + assert.equal(onProfileSpy.mock.callCount(), 1); + const calledArg = onProfileSpy.mock.calls[0].arguments[0]; + assert.equal(calledArg.profile.name, "Live Update"); + assert.equal(calledArg.event, event); + }); + }); +});