From 5e09619bcaec2712d16abb08d6998eb3c94ab93c Mon Sep 17 00:00:00 2001 From: limina1 Date: Sat, 19 Jul 2025 21:44:40 -0400 Subject: [PATCH] add tag and event tests --- .gitignore | 18 +- src/lib/navigator/EventNetwork/index.svelte | 2 +- src/routes/visualize/+page.svelte | 2 +- tests/unit/relayDeduplication.test.ts | 457 ++++++++++++++++++++ tests/unit/tagExpansion.test.ts | 420 ++++++++++++++++++ 5 files changed, 882 insertions(+), 17 deletions(-) create mode 100644 tests/unit/relayDeduplication.test.ts create mode 100644 tests/unit/tagExpansion.test.ts diff --git a/.gitignore b/.gitignore index 4338def..ef18a0a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,21 +9,9 @@ node_modules vite.config.js.timestamp-* vite.config.ts.timestamp-* -# tests - ignore all test directories and files -/tests/ -/test/ -/__tests__/ -*.test.js -*.test.ts -*.spec.js -*.spec.ts -*.test.svelte -*.spec.svelte -/coverage/ -/.nyc_output/ - -# documentation -/docs/ +# tests +/tests/e2e/html-report/*.html +/tests/e2e/test-results/*.last-run.json # Deno /.deno/ diff --git a/src/lib/navigator/EventNetwork/index.svelte b/src/lib/navigator/EventNetwork/index.svelte index e2ad6b0..063c015 100644 --- a/src/lib/navigator/EventNetwork/index.svelte +++ b/src/lib/navigator/EventNetwork/index.svelte @@ -994,7 +994,7 @@ }); /** - * Watch for tag expansion depth changes + * Watch for tag expansion changes */ $effect(() => { // Skip if not initialized or no callback diff --git a/src/routes/visualize/+page.svelte b/src/routes/visualize/+page.svelte index 5d02800..b90698b 100644 --- a/src/routes/visualize/+page.svelte +++ b/src/routes/visualize/+page.svelte @@ -687,7 +687,7 @@ await fetchProfilesForNewEvents( newPublications, newContentEvents, - (progress) => { profileLoadingProgress = progress; }, + (progress: { current: number; total: number } | null) => { profileLoadingProgress = progress; }, debug ); diff --git a/tests/unit/relayDeduplication.test.ts b/tests/unit/relayDeduplication.test.ts new file mode 100644 index 0000000..9344cc2 --- /dev/null +++ b/tests/unit/relayDeduplication.test.ts @@ -0,0 +1,457 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { NDKEvent } from '@nostr-dev-kit/ndk'; +import { + deduplicateContentEvents, + deduplicateAndCombineEvents, + isReplaceableEvent, + getEventCoordinate +} from '../../src/lib/utils/eventDeduplication'; + +// Mock NDKEvent for testing +class MockNDKEvent { + id: string; + kind: number; + pubkey: string; + created_at: number; + content: string; + tags: string[][]; + + constructor(id: string, kind: number, pubkey: string, created_at: number, dTag: string, content: string = '') { + this.id = id; + this.kind = kind; + this.pubkey = pubkey; + this.created_at = created_at; + this.content = content; + this.tags = [['d', dTag]]; + } + + tagValue(tagName: string): string | undefined { + const tag = this.tags.find(t => t[0] === tagName); + return tag ? tag[1] : undefined; + } +} + +describe('Relay Deduplication Behavior Tests', () => { + let mockEvents: MockNDKEvent[]; + + beforeEach(() => { + // Create test events with different timestamps + mockEvents = [ + // Older version of a publication content event + new MockNDKEvent('event1', 30041, 'pubkey1', 1000, 'chapter-1', 'Old content'), + // Newer version of the same publication content event + new MockNDKEvent('event2', 30041, 'pubkey1', 2000, 'chapter-1', 'Updated content'), + // Different publication content event + new MockNDKEvent('event3', 30041, 'pubkey1', 1500, 'chapter-2', 'Different content'), + // Publication index event (should not be deduplicated) + new MockNDKEvent('event4', 30040, 'pubkey1', 1200, 'book-1', 'Index content'), + // Regular text note (should not be deduplicated) + new MockNDKEvent('event5', 1, 'pubkey1', 1300, '', 'Regular note'), + ]; + }); + + describe('Addressable Event Deduplication', () => { + it('should keep only the most recent version of addressable events by coordinate', () => { + // Test the deduplication logic for content events + const eventSets = [new Set(mockEvents.filter(e => e.kind === 30041) as NDKEvent[])]; + const result = deduplicateContentEvents(eventSets); + + // Should have 2 unique coordinates: chapter-1 and chapter-2 + expect(result.size).toBe(2); + + // Should keep the newer version of chapter-1 + const chapter1Event = result.get('30041:pubkey1:chapter-1'); + expect(chapter1Event?.id).toBe('event2'); + expect(chapter1Event?.content).toBe('Updated content'); + + // Should keep chapter-2 + const chapter2Event = result.get('30041:pubkey1:chapter-2'); + expect(chapter2Event?.id).toBe('event3'); + }); + + it('should handle events with missing d-tags gracefully', () => { + const eventWithoutDTag = new MockNDKEvent('event6', 30041, 'pubkey1', 1400, '', 'No d-tag'); + eventWithoutDTag.tags = []; // Remove d-tag + + const eventSets = [new Set([eventWithoutDTag] as NDKEvent[])]; + const result = deduplicateContentEvents(eventSets); + + // Should not include events without d-tags + expect(result.size).toBe(0); + }); + + it('should handle events with missing timestamps', () => { + const eventWithoutTimestamp = new MockNDKEvent('event7', 30041, 'pubkey1', 0, 'chapter-3', 'No timestamp'); + const eventWithTimestamp = new MockNDKEvent('event8', 30041, 'pubkey1', 1500, 'chapter-3', 'With timestamp'); + + const eventSets = [new Set([eventWithoutTimestamp, eventWithTimestamp] as NDKEvent[])]; + const result = deduplicateContentEvents(eventSets); + + // Should prefer the event with timestamp + const chapter3Event = result.get('30041:pubkey1:chapter-3'); + expect(chapter3Event?.id).toBe('event8'); + }); + }); + + describe('Mixed Event Type Deduplication', () => { + it('should only deduplicate addressable events (kinds 30000-39999)', () => { + const result = deduplicateAndCombineEvents( + [mockEvents[4]] as NDKEvent[], // Regular text note + new Set([mockEvents[3]] as NDKEvent[]), // Publication index + new Set([mockEvents[0], mockEvents[1], mockEvents[2]] as NDKEvent[]) // Content events + ); + + // Should have 4 events total: + // - 1 regular text note (not deduplicated) + // - 1 publication index (not deduplicated) + // - 2 unique content events (deduplicated from 3) + expect(result.length).toBe(4); + + // Verify the content events were deduplicated + const contentEvents = result.filter(e => e.kind === 30041); + expect(contentEvents.length).toBe(2); + + // Verify the newer version was kept + const newerEvent = contentEvents.find(e => e.id === 'event2'); + expect(newerEvent).toBeDefined(); + }); + + it('should handle non-addressable events correctly', () => { + const regularEvents = [ + new MockNDKEvent('note1', 1, 'pubkey1', 1000, '', 'Note 1'), + new MockNDKEvent('note2', 1, 'pubkey1', 2000, '', 'Note 2'), + new MockNDKEvent('profile1', 0, 'pubkey1', 1500, '', 'Profile 1'), + ]; + + const result = deduplicateAndCombineEvents( + regularEvents as NDKEvent[], + new Set(), + new Set() + ); + + // All regular events should be included (no deduplication) + expect(result.length).toBe(3); + }); + }); + + describe('Coordinate System Validation', () => { + it('should correctly identify event coordinates', () => { + const event = new MockNDKEvent('test', 30041, 'pubkey123', 1000, 'test-chapter'); + const coordinate = getEventCoordinate(event as NDKEvent); + + expect(coordinate).toBe('30041:pubkey123:test-chapter'); + }); + + it('should handle d-tags with colons correctly', () => { + const event = new MockNDKEvent('test', 30041, 'pubkey123', 1000, 'chapter:with:colons'); + const coordinate = getEventCoordinate(event as NDKEvent); + + expect(coordinate).toBe('30041:pubkey123:chapter:with:colons'); + }); + + it('should return null for non-replaceable events', () => { + const event = new MockNDKEvent('test', 1, 'pubkey123', 1000, ''); + const coordinate = getEventCoordinate(event as NDKEvent); + + expect(coordinate).toBeNull(); + }); + }); + + describe('Replaceable Event Detection', () => { + it('should correctly identify replaceable events', () => { + const addressableEvent = new MockNDKEvent('test', 30041, 'pubkey123', 1000, 'test'); + const regularEvent = new MockNDKEvent('test', 1, 'pubkey123', 1000, ''); + + expect(isReplaceableEvent(addressableEvent as NDKEvent)).toBe(true); + expect(isReplaceableEvent(regularEvent as NDKEvent)).toBe(false); + }); + + it('should handle edge cases of replaceable event ranges', () => { + const event29999 = new MockNDKEvent('test', 29999, 'pubkey123', 1000, 'test'); + const event30000 = new MockNDKEvent('test', 30000, 'pubkey123', 1000, 'test'); + const event39999 = new MockNDKEvent('test', 39999, 'pubkey123', 1000, 'test'); + const event40000 = new MockNDKEvent('test', 40000, 'pubkey123', 1000, 'test'); + + expect(isReplaceableEvent(event29999 as NDKEvent)).toBe(false); + expect(isReplaceableEvent(event30000 as NDKEvent)).toBe(true); + expect(isReplaceableEvent(event39999 as NDKEvent)).toBe(true); + expect(isReplaceableEvent(event40000 as NDKEvent)).toBe(false); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty event sets', () => { + const result = deduplicateContentEvents([]); + expect(result.size).toBe(0); + }); + + it('should handle events with null/undefined values', () => { + const invalidEvent = { + id: undefined, + kind: 30041, + pubkey: 'pubkey1', + created_at: 1000, + tagValue: () => undefined, // Return undefined for d-tag + } as unknown as NDKEvent; + + const eventSets = [new Set([invalidEvent])]; + const result = deduplicateContentEvents(eventSets); + + // Should handle gracefully without crashing + expect(result.size).toBe(0); + }); + + it('should handle events from different authors with same d-tag', () => { + const event1 = new MockNDKEvent('event1', 30041, 'pubkey1', 1000, 'same-chapter', 'Author 1'); + const event2 = new MockNDKEvent('event2', 30041, 'pubkey2', 1000, 'same-chapter', 'Author 2'); + + const eventSets = [new Set([event1, event2] as NDKEvent[])]; + const result = deduplicateContentEvents(eventSets); + + // Should have 2 events (different coordinates due to different authors) + expect(result.size).toBe(2); + expect(result.has('30041:pubkey1:same-chapter')).toBe(true); + expect(result.has('30041:pubkey2:same-chapter')).toBe(true); + }); + }); +}); + +describe('Relay Behavior Simulation', () => { + it('should simulate what happens when relays return duplicate events', () => { + // Simulate a relay that returns multiple versions of the same event + const relayEvents = [ + new MockNDKEvent('event1', 30041, 'pubkey1', 1000, 'chapter-1', 'Old version'), + new MockNDKEvent('event2', 30041, 'pubkey1', 2000, 'chapter-1', 'New version'), + new MockNDKEvent('event3', 30041, 'pubkey1', 1500, 'chapter-1', 'Middle version'), + ]; + + // This simulates what a "bad" relay might return + const eventSets = [new Set(relayEvents as NDKEvent[])]; + const result = deduplicateContentEvents(eventSets); + + // Should only keep the newest version + expect(result.size).toBe(1); + const keptEvent = result.get('30041:pubkey1:chapter-1'); + expect(keptEvent?.id).toBe('event2'); + expect(keptEvent?.content).toBe('New version'); + }); + + it('should simulate multiple relays returning different versions', () => { + // Simulate multiple relays returning different versions + const relay1Events = [ + new MockNDKEvent('event1', 30041, 'pubkey1', 1000, 'chapter-1', 'Relay 1 version'), + ]; + + const relay2Events = [ + new MockNDKEvent('event2', 30041, 'pubkey1', 2000, 'chapter-1', 'Relay 2 version'), + ]; + + const eventSets = [new Set(relay1Events as NDKEvent[]), new Set(relay2Events as NDKEvent[])]; + const result = deduplicateContentEvents(eventSets); + + // Should keep the newest version from any relay + expect(result.size).toBe(1); + const keptEvent = result.get('30041:pubkey1:chapter-1'); + expect(keptEvent?.id).toBe('event2'); + expect(keptEvent?.content).toBe('Relay 2 version'); + }); +}); + +describe('Real Relay Deduplication Tests', () => { + // These tests actually query real relays to see if they deduplicate + // Note: These are integration tests and may be flaky due to network conditions + + it('should detect if relays are returning duplicate replaceable events', async () => { + // This test queries real relays to see if they return duplicates + // We'll use a known author who has published multiple versions of content + + // Known author with multiple publication content events + const testAuthor = 'npub1z4m7gkva6yxgvdyclc7zp0qt69x9zgn8lu8sllg06wx6432h77qs0k97ks'; + + // Query for publication content events (kind 30041) from this author + // We expect relays to return only the most recent version of each d-tag + + // This is a placeholder - in a real test, we would: + // 1. Query multiple relays for the same author's 30041 events + // 2. Check if any relay returns multiple events with the same d-tag + // 3. Verify that if duplicates exist, our deduplication logic handles them + + console.log('Note: This test would require actual relay queries to verify deduplication behavior'); + console.log('To run this test properly, we would need to:'); + console.log('1. Query real relays for replaceable events'); + console.log('2. Check if relays return duplicates'); + console.log('3. Verify our deduplication logic works on real data'); + + // For now, we'll just assert that our logic is ready to handle real data + expect(true).toBe(true); + }, 30000); // 30 second timeout for network requests + + it('should verify that our deduplication logic works on real relay data', async () => { + // This test would: + // 1. Fetch real events from relays + // 2. Apply our deduplication logic + // 3. Verify that the results are correct + + console.log('Note: This test would require actual relay queries'); + console.log('To implement this test, we would need to:'); + console.log('1. Set up NDK with real relays'); + console.log('2. Fetch events for a known author with multiple versions'); + console.log('3. Apply deduplication and verify results'); + + expect(true).toBe(true); + }, 30000); +}); + +describe('Practical Relay Behavior Analysis', () => { + it('should document what we know about relay deduplication behavior', () => { + // This test documents our current understanding of relay behavior + // based on the code analysis and the comment from onedev + + console.log('\n=== RELAY DEDUPLICATION BEHAVIOR ANALYSIS ==='); + console.log('\nBased on the code analysis and the comment from onedev:'); + console.log('\n1. THEORETICAL BEHAVIOR:'); + console.log(' - Relays SHOULD handle deduplication for replaceable events'); + console.log(' - Only the most recent version of each coordinate should be stored'); + console.log(' - Client-side deduplication should only be needed for cached/local events'); + + console.log('\n2. REALITY CHECK:'); + console.log(' - Not all relays implement deduplication correctly'); + console.log(' - Some relays may return multiple versions of the same event'); + console.log(' - Network conditions and relay availability can cause inconsistencies'); + + console.log('\n3. ALEXANDRIA\'S APPROACH:'); + console.log(' - Implements client-side deduplication as a safety net'); + console.log(' - Uses coordinate system (kind:pubkey:d-tag) for addressable events'); + console.log(' - Keeps the most recent version based on created_at timestamp'); + console.log(' - Only applies to replaceable events (kinds 30000-39999)'); + + console.log('\n4. WHY KEEP THE DEDUPLICATION:'); + console.log(' - Defensive programming against imperfect relay implementations'); + console.log(' - Handles multiple relay sources with different data'); + console.log(' - Works with cached events that might be outdated'); + console.log(' - Ensures consistent user experience regardless of relay behavior'); + + console.log('\n5. TESTING STRATEGY:'); + console.log(' - Unit tests verify our deduplication logic works correctly'); + console.log(' - Integration tests would verify relay behavior (when network allows)'); + console.log(' - Monitoring can help determine if relays improve over time'); + + // This test documents our understanding rather than asserting specific behavior + expect(true).toBe(true); + }); + + it('should provide recommendations for when to remove deduplication', () => { + console.log('\n=== RECOMMENDATIONS FOR REMOVING DEDUPLICATION ==='); + console.log('\nThe deduplication logic should be kept until:'); + console.log('\n1. RELAY STANDARDS:'); + console.log(' - NIP-33 (replaceable events) is widely implemented by relays'); + console.log(' - Relays consistently return only the most recent version'); + console.log(' - No major relay implementations return duplicates'); + + console.log('\n2. TESTING EVIDENCE:'); + console.log(' - Real-world testing shows relays don\'t return duplicates'); + console.log(' - Multiple relay operators confirm deduplication behavior'); + console.log(' - No user reports of duplicate content issues'); + + console.log('\n3. MONITORING:'); + console.log(' - Add logging to track when deduplication is actually used'); + console.log(' - Monitor relay behavior over time'); + console.log(' - Collect metrics on duplicate events found'); + + console.log('\n4. GRADUAL REMOVAL:'); + console.log(' - Make deduplication configurable (on/off)'); + console.log(' - Test with deduplication disabled in controlled environments'); + console.log(' - Monitor for issues before removing completely'); + + console.log('\n5. FALLBACK STRATEGY:'); + console.log(' - Keep deduplication as a fallback option'); + console.log(' - Allow users to enable it if they experience issues'); + console.log(' - Maintain the code for potential future use'); + + expect(true).toBe(true); + }); +}); + +describe('Logging and Monitoring Tests', () => { + it('should verify that logging works when duplicates are found', () => { + // Mock console.log to capture output + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Create events with duplicates + const duplicateEvents = [ + new MockNDKEvent('event1', 30041, 'pubkey1', 1000, 'chapter-1', 'Old version'), + new MockNDKEvent('event2', 30041, 'pubkey1', 2000, 'chapter-1', 'New version'), + new MockNDKEvent('event3', 30041, 'pubkey1', 1500, 'chapter-1', 'Middle version'), + ]; + + const eventSets = [new Set(duplicateEvents as NDKEvent[])]; + const result = deduplicateContentEvents(eventSets); + + // Verify the deduplication worked + expect(result.size).toBe(1); + + // Verify that logging was called + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('[eventDeduplication] Found 2 duplicate events out of 3 total events') + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('[eventDeduplication] Reduced to 1 unique coordinates') + ); + + // Restore console.log + consoleSpy.mockRestore(); + }); + + it('should verify that logging works when no duplicates are found', () => { + // Mock console.log to capture output + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Create events without duplicates + const uniqueEvents = [ + new MockNDKEvent('event1', 30041, 'pubkey1', 1000, 'chapter-1', 'Content 1'), + new MockNDKEvent('event2', 30041, 'pubkey1', 2000, 'chapter-2', 'Content 2'), + ]; + + const eventSets = [new Set(uniqueEvents as NDKEvent[])]; + const result = deduplicateContentEvents(eventSets); + + // Verify no deduplication was needed + expect(result.size).toBe(2); + + // Verify that logging was called with "no duplicates" message + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('[eventDeduplication] No duplicates found in 2 events') + ); + + // Restore console.log + consoleSpy.mockRestore(); + }); + + it('should verify that deduplicateAndCombineEvents logging works', () => { + // Mock console.log to capture output + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Create events with duplicates + const duplicateEvents = [ + new MockNDKEvent('event1', 30041, 'pubkey1', 1000, 'chapter-1', 'Old version'), + new MockNDKEvent('event2', 30041, 'pubkey1', 2000, 'chapter-1', 'New version'), + ]; + + const result = deduplicateAndCombineEvents( + [] as NDKEvent[], + new Set(), + new Set(duplicateEvents as NDKEvent[]) + ); + + // Verify the deduplication worked + expect(result.length).toBe(1); + + // Verify that logging was called + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('[eventDeduplication] deduplicateAndCombineEvents: Found 1 duplicate coordinates') + ); + + // Restore console.log + consoleSpy.mockRestore(); + }); +}); \ No newline at end of file diff --git a/tests/unit/tagExpansion.test.ts b/tests/unit/tagExpansion.test.ts new file mode 100644 index 0000000..65e71fa --- /dev/null +++ b/tests/unit/tagExpansion.test.ts @@ -0,0 +1,420 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { NDKEvent } from '@nostr-dev-kit/ndk'; +import { + fetchTaggedEventsFromRelays, + findTaggedEventsInFetched, + fetchProfilesForNewEvents, + type TagExpansionResult +} from '../../src/lib/utils/tag_event_fetch'; + +// Mock NDKEvent for testing +class MockNDKEvent { + id: string; + kind: number; + pubkey: string; + created_at: number; + content: string; + tags: string[][]; + + constructor(id: string, kind: number, pubkey: string, created_at: number, content: string = '', tags: string[][] = []) { + this.id = id; + this.kind = kind; + this.pubkey = pubkey; + this.created_at = created_at; + this.content = content; + this.tags = tags; + } + + tagValue(tagName: string): string | undefined { + const tag = this.tags.find(t => t[0] === tagName); + return tag ? tag[1] : undefined; + } + + getMatchingTags(tagName: string): string[][] { + return this.tags.filter(tag => tag[0] === tagName); + } +} + +// Mock NDK instance +const mockNDK = { + fetchEvents: vi.fn() +}; + +// Mock the ndkInstance store +vi.mock('../../src/lib/ndk', () => ({ + ndkInstance: { + subscribe: vi.fn((fn) => { + fn(mockNDK); + return { unsubscribe: vi.fn() }; + }) + } +})); + +// Mock the profile cache utilities +vi.mock('../../src/lib/utils/profileCache', () => ({ + extractPubkeysFromEvents: vi.fn((events: NDKEvent[]) => { + const pubkeys = new Set(); + events.forEach(event => { + if (event.pubkey) pubkeys.add(event.pubkey); + }); + return pubkeys; + }), + batchFetchProfiles: vi.fn(async (pubkeys: string[], onProgress: (fetched: number, total: number) => void) => { + // Simulate progress updates + onProgress(0, pubkeys.length); + onProgress(pubkeys.length, pubkeys.length); + return []; + }) +})); + +describe('Tag Expansion Tests', () => { + let mockPublications: MockNDKEvent[]; + let mockContentEvents: MockNDKEvent[]; + let mockAllEvents: MockNDKEvent[]; + + beforeEach(() => { + vi.clearAllMocks(); + + // Create test publication index events (kind 30040) + mockPublications = [ + new MockNDKEvent('pub1', 30040, 'author1', 1000, 'Book 1', [ + ['t', 'bitcoin'], + ['t', 'cryptocurrency'], + ['a', '30041:author1:chapter-1'], + ['a', '30041:author1:chapter-2'] + ]), + new MockNDKEvent('pub2', 30040, 'author2', 1100, 'Book 2', [ + ['t', 'bitcoin'], + ['t', 'blockchain'], + ['a', '30041:author2:chapter-1'] + ]), + new MockNDKEvent('pub3', 30040, 'author3', 1200, 'Book 3', [ + ['t', 'ethereum'], + ['a', '30041:author3:chapter-1'] + ]) + ]; + + // Create test content events (kind 30041) + mockContentEvents = [ + new MockNDKEvent('content1', 30041, 'author1', 1000, 'Chapter 1 content', [['d', 'chapter-1']]), + new MockNDKEvent('content2', 30041, 'author1', 1100, 'Chapter 2 content', [['d', 'chapter-2']]), + new MockNDKEvent('content3', 30041, 'author2', 1200, 'Author 2 Chapter 1', [['d', 'chapter-1']]), + new MockNDKEvent('content4', 30041, 'author3', 1300, 'Author 3 Chapter 1', [['d', 'chapter-1']]) + ]; + + // Combine all events for testing + mockAllEvents = [...mockPublications, ...mockContentEvents]; + }); + + describe('fetchTaggedEventsFromRelays', () => { + it('should fetch publications with matching tags from relays', async () => { + // Mock the NDK fetch to return publications with 'bitcoin' tag + const bitcoinPublications = mockPublications.filter(pub => + pub.tags.some(tag => tag[0] === 't' && tag[1] === 'bitcoin') + ); + mockNDK.fetchEvents.mockResolvedValueOnce(new Set(bitcoinPublications as NDKEvent[])); + mockNDK.fetchEvents.mockResolvedValueOnce(new Set(mockContentEvents as NDKEvent[])); + + const existingEventIds = new Set(['existing-event']); + const baseEvents: NDKEvent[] = []; + const debug = vi.fn(); + + const result = await fetchTaggedEventsFromRelays( + ['bitcoin'], + existingEventIds, + baseEvents, + debug + ); + + // Should fetch publications with bitcoin tag + expect(mockNDK.fetchEvents).toHaveBeenCalledWith({ + kinds: [30040], + "#t": ['bitcoin'], + limit: 30 + }); + + // Should return the matching publications + expect(result.publications).toHaveLength(2); + expect(result.publications.map(p => p.id)).toContain('pub1'); + expect(result.publications.map(p => p.id)).toContain('pub2'); + + // Should fetch content events for the publications + expect(mockNDK.fetchEvents).toHaveBeenCalledWith({ + kinds: [30041, 30818], + "#d": ['chapter-1', 'chapter-2'] + }); + }); + + it('should filter out existing events to avoid duplicates', async () => { + mockNDK.fetchEvents.mockResolvedValueOnce(new Set(mockPublications as NDKEvent[])); + mockNDK.fetchEvents.mockResolvedValueOnce(new Set(mockContentEvents as NDKEvent[])); + + const existingEventIds = new Set(['pub1']); // pub1 already exists + const baseEvents: NDKEvent[] = []; + const debug = vi.fn(); + + const result = await fetchTaggedEventsFromRelays( + ['bitcoin'], + existingEventIds, + baseEvents, + debug + ); + + // Should exclude pub1 since it already exists + expect(result.publications).toHaveLength(2); + expect(result.publications.map(p => p.id)).not.toContain('pub1'); + expect(result.publications.map(p => p.id)).toContain('pub2'); + expect(result.publications.map(p => p.id)).toContain('pub3'); + }); + + it('should handle empty tag array gracefully', async () => { + // Mock empty result for empty tags + mockNDK.fetchEvents.mockResolvedValueOnce(new Set()); + + const existingEventIds = new Set(); + const baseEvents: NDKEvent[] = []; + const debug = vi.fn(); + + const result = await fetchTaggedEventsFromRelays( + [], + existingEventIds, + baseEvents, + debug + ); + + expect(result.publications).toHaveLength(0); + expect(result.contentEvents).toHaveLength(0); + }); + }); + + describe('findTaggedEventsInFetched', () => { + it('should find publications with matching tags in already fetched events', () => { + const existingEventIds = new Set(['existing-event']); + const baseEvents: NDKEvent[] = []; + const debug = vi.fn(); + + const result = findTaggedEventsInFetched( + mockAllEvents as NDKEvent[], + ['bitcoin'], + existingEventIds, + baseEvents, + debug + ); + + // Should find publications with bitcoin tag + expect(result.publications).toHaveLength(2); + expect(result.publications.map(p => p.id)).toContain('pub1'); + expect(result.publications.map(p => p.id)).toContain('pub2'); + + // Should find content events for those publications + expect(result.contentEvents).toHaveLength(4); + expect(result.contentEvents.map(c => c.id)).toContain('content1'); + expect(result.contentEvents.map(c => c.id)).toContain('content2'); + expect(result.contentEvents.map(c => c.id)).toContain('content3'); + expect(result.contentEvents.map(c => c.id)).toContain('content4'); + }); + + it('should exclude base events from search results', () => { + const existingEventIds = new Set(['pub1']); // pub1 is a base event + const baseEvents: NDKEvent[] = []; + const debug = vi.fn(); + + const result = findTaggedEventsInFetched( + mockAllEvents as NDKEvent[], + ['bitcoin'], + existingEventIds, + baseEvents, + debug + ); + + // Should exclude pub1 since it's a base event + expect(result.publications).toHaveLength(1); + expect(result.publications.map(p => p.id)).not.toContain('pub1'); + expect(result.publications.map(p => p.id)).toContain('pub2'); + }); + + it('should handle multiple tags (OR logic)', () => { + const existingEventIds = new Set(); + const baseEvents: NDKEvent[] = []; + const debug = vi.fn(); + + const result = findTaggedEventsInFetched( + mockAllEvents as NDKEvent[], + ['bitcoin', 'ethereum'], + existingEventIds, + baseEvents, + debug + ); + + // Should find publications with either bitcoin OR ethereum tags + expect(result.publications).toHaveLength(3); + expect(result.publications.map(p => p.id)).toContain('pub1'); // bitcoin + expect(result.publications.map(p => p.id)).toContain('pub2'); // bitcoin + expect(result.publications.map(p => p.id)).toContain('pub3'); // ethereum + }); + + it('should handle events without tags gracefully', () => { + const eventWithoutTags = new MockNDKEvent('no-tags', 30040, 'author4', 1000, 'No tags'); + const allEventsWithNoTags = [...mockAllEvents, eventWithoutTags]; + + const existingEventIds = new Set(); + const baseEvents: NDKEvent[] = []; + const debug = vi.fn(); + + const result = findTaggedEventsInFetched( + allEventsWithNoTags as NDKEvent[], + ['bitcoin'], + existingEventIds, + baseEvents, + debug + ); + + // Should not include events without tags + expect(result.publications.map(p => p.id)).not.toContain('no-tags'); + }); + }); + + describe('fetchProfilesForNewEvents', () => { + it('should extract pubkeys and fetch profiles for new events', async () => { + const onProgressUpdate = vi.fn(); + const debug = vi.fn(); + + await fetchProfilesForNewEvents( + mockPublications as NDKEvent[], + mockContentEvents as NDKEvent[], + onProgressUpdate, + debug + ); + + // Should call progress update with initial state + expect(onProgressUpdate).toHaveBeenCalledWith({ current: 0, total: 3 }); + + // Should call progress update with final state + expect(onProgressUpdate).toHaveBeenCalledWith({ current: 3, total: 3 }); + + // Should clear progress at the end + expect(onProgressUpdate).toHaveBeenCalledWith(null); + }); + + it('should handle empty event arrays gracefully', async () => { + const onProgressUpdate = vi.fn(); + const debug = vi.fn(); + + await fetchProfilesForNewEvents( + [], + [], + onProgressUpdate, + debug + ); + + // Should not call progress update for empty arrays + expect(onProgressUpdate).not.toHaveBeenCalled(); + }); + }); + + describe('Tag Expansion Integration', () => { + it('should demonstrate the complete tag expansion flow', async () => { + // This test simulates the complete flow from the visualize page + + // Step 1: Mock relay fetch for 'bitcoin' tag + const bitcoinPublications = mockPublications.filter(pub => + pub.tags.some(tag => tag[0] === 't' && tag[1] === 'bitcoin') + ); + mockNDK.fetchEvents.mockResolvedValueOnce(new Set(bitcoinPublications as NDKEvent[])); + mockNDK.fetchEvents.mockResolvedValueOnce(new Set(mockContentEvents as NDKEvent[])); + + const existingEventIds = new Set(['base-event']); + const baseEvents: NDKEvent[] = []; + const debug = vi.fn(); + + // Step 2: Fetch from relays + const relayResult = await fetchTaggedEventsFromRelays( + ['bitcoin'], + existingEventIds, + baseEvents, + debug + ); + + expect(relayResult.publications).toHaveLength(2); + expect(relayResult.contentEvents).toHaveLength(4); + + // Step 3: Search in fetched events + const searchResult = findTaggedEventsInFetched( + mockAllEvents as NDKEvent[], + ['bitcoin'], + existingEventIds, + baseEvents, + debug + ); + + expect(searchResult.publications).toHaveLength(2); + expect(searchResult.contentEvents).toHaveLength(4); + + // Step 4: Fetch profiles + const onProgressUpdate = vi.fn(); + await fetchProfilesForNewEvents( + relayResult.publications, + relayResult.contentEvents, + onProgressUpdate, + debug + ); + + expect(onProgressUpdate).toHaveBeenCalledWith(null); + }); + }); + + describe('Edge Cases and Error Handling', () => { + it('should handle malformed a-tags gracefully', () => { + const malformedPublication = new MockNDKEvent('malformed', 30040, 'author1', 1000, 'Malformed', [ + ['t', 'bitcoin'], + ['a', 'invalid-tag-format'], // Missing parts + ['a', '30041:author1:chapter-1'] // Valid format + ]); + + const allEventsWithMalformed = [...mockAllEvents, malformedPublication]; + const existingEventIds = new Set(); + const baseEvents: NDKEvent[] = []; + const debug = vi.fn(); + + const result = findTaggedEventsInFetched( + allEventsWithMalformed as NDKEvent[], + ['bitcoin'], + existingEventIds, + baseEvents, + debug + ); + + // Should still work and include the publication with valid a-tags + expect(result.publications).toHaveLength(3); + expect(result.contentEvents.length).toBeGreaterThan(0); + }); + + it('should handle events with d-tags containing colons', () => { + const publicationWithColonDTag = new MockNDKEvent('colon-pub', 30040, 'author1', 1000, 'Colon d-tag', [ + ['t', 'bitcoin'], + ['a', '30041:author1:chapter:with:colons'] + ]); + + const contentWithColonDTag = new MockNDKEvent('colon-content', 30041, 'author1', 1100, 'Content with colon d-tag', [ + ['d', 'chapter:with:colons'] + ]); + + const allEventsWithColons = [...mockAllEvents, publicationWithColonDTag, contentWithColonDTag]; + const existingEventIds = new Set(); + const baseEvents: NDKEvent[] = []; + const debug = vi.fn(); + + const result = findTaggedEventsInFetched( + allEventsWithColons as NDKEvent[], + ['bitcoin'], + existingEventIds, + baseEvents, + debug + ); + + // Should handle d-tags with colons correctly + expect(result.publications).toHaveLength(3); + expect(result.contentEvents.map(c => c.id)).toContain('colon-content'); + }); + }); +}); \ No newline at end of file