Feed.test.ts raw

   1  import { describe, it, expect } from 'vitest'
   2  import { Feed } from './Feed'
   3  import { FeedType } from './FeedType'
   4  import { ContentFilter } from './ContentFilter'
   5  import { Pubkey } from '../shared/value-objects/Pubkey'
   6  import { RelayUrl } from '../shared/value-objects/RelayUrl'
   7  import { FeedSwitched, ContentFilterUpdated, FeedRefreshed } from './events'
   8  
   9  describe('Feed', () => {
  10    // Test data
  11    const ownerPubkey = Pubkey.fromHex(
  12      'a'.repeat(64)
  13    )
  14    const relayUrl1 = RelayUrl.tryCreate('wss://relay1.example.com')!
  15    const relayUrl2 = RelayUrl.tryCreate('wss://relay2.example.com')!
  16  
  17    describe('factory methods', () => {
  18      it('creates a following feed', () => {
  19        const feed = Feed.following(ownerPubkey)
  20  
  21        expect(feed.owner).toEqual(ownerPubkey)
  22        expect(feed.type.value).toBe('following')
  23        expect(feed.isSocialFeed).toBe(true)
  24        expect(feed.isRelayFeed).toBe(false)
  25        expect(feed.relayUrls).toEqual([])
  26        expect(feed.lastRefreshedAt).toBeNull()
  27      })
  28  
  29      it('creates a pinned feed', () => {
  30        const feed = Feed.pinned(ownerPubkey)
  31  
  32        expect(feed.owner).toEqual(ownerPubkey)
  33        expect(feed.type.value).toBe('pinned')
  34        expect(feed.isSocialFeed).toBe(true)
  35        expect(feed.isRelayFeed).toBe(false)
  36      })
  37  
  38      it('creates a relay set feed', () => {
  39        const relays = [relayUrl1, relayUrl2]
  40        const feed = Feed.relays(ownerPubkey, 'my-set', relays)
  41  
  42        expect(feed.owner).toEqual(ownerPubkey)
  43        expect(feed.type.value).toBe('relays')
  44        expect(feed.type.relaySetId).toBe('my-set')
  45        expect(feed.isSocialFeed).toBe(false)
  46        expect(feed.isRelayFeed).toBe(true)
  47        expect(feed.relayUrls).toHaveLength(2)
  48        expect(feed.hasRelayUrls).toBe(true)
  49      })
  50  
  51      it('creates a single relay feed', () => {
  52        const feed = Feed.singleRelay(relayUrl1)
  53  
  54        expect(feed.owner).toBeNull()
  55        expect(feed.type.value).toBe('relay')
  56        expect(feed.type.relayUrl).toBe(relayUrl1.value) // Use the actual normalized URL
  57        expect(feed.isSocialFeed).toBe(false)
  58        expect(feed.isRelayFeed).toBe(true)
  59        expect(feed.relayUrls).toHaveLength(1)
  60      })
  61  
  62      it('creates an empty feed', () => {
  63        const feed = Feed.empty()
  64  
  65        expect(feed.owner).toBeNull()
  66        expect(feed.type.value).toBe('following')
  67        expect(feed.relayUrls).toEqual([])
  68      })
  69    })
  70  
  71    describe('switchTo', () => {
  72      it('switches from following to relay feed', () => {
  73        const feed = Feed.following(ownerPubkey)
  74        const newType = FeedType.relay(relayUrl1.value)
  75  
  76        const event = feed.switchTo(newType, [relayUrl1])
  77  
  78        expect(event).toBeInstanceOf(FeedSwitched)
  79        expect(event.fromType?.value).toBe('following')
  80        expect(event.toType.value).toBe('relay')
  81        expect(feed.type.value).toBe('relay')
  82        expect(feed.relayUrls).toHaveLength(1)
  83        expect(feed.lastRefreshedAt).not.toBeNull()
  84      })
  85  
  86      it('switches to relay set feed', () => {
  87        const feed = Feed.following(ownerPubkey)
  88        const newType = FeedType.relays('my-set')
  89        const relays = [relayUrl1, relayUrl2]
  90  
  91        const event = feed.switchTo(newType, relays)
  92  
  93        expect(event.toType.value).toBe('relays')
  94        expect(event.relaySetId).toBe('my-set')
  95        expect(feed.relayUrls).toHaveLength(2)
  96      })
  97  
  98      it('switches to social feed and clears relay URLs', () => {
  99        const feed = Feed.singleRelay(relayUrl1)
 100        const newType = FeedType.following()
 101  
 102        feed.switchTo(newType)
 103  
 104        expect(feed.type.value).toBe('following')
 105        expect(feed.relayUrls).toEqual([])
 106      })
 107    })
 108  
 109    describe('updateContentFilter', () => {
 110      it('updates content filter and returns event', () => {
 111        const feed = Feed.following(ownerPubkey)
 112        const newFilter = ContentFilter.default().withHideReplies(true)
 113  
 114        const event = feed.updateContentFilter(newFilter)
 115  
 116        expect(event).toBeInstanceOf(ContentFilterUpdated)
 117        expect(feed.contentFilter.hideReplies).toBe(true)
 118      })
 119    })
 120  
 121    describe('refresh', () => {
 122      it('marks feed as refreshed and returns event', () => {
 123        const feed = Feed.following(ownerPubkey)
 124        expect(feed.lastRefreshedAt).toBeNull()
 125  
 126        const event = feed.refresh()
 127  
 128        expect(event).toBeInstanceOf(FeedRefreshed)
 129        expect(event.feedType.value).toBe('following')
 130        expect(feed.lastRefreshedAt).not.toBeNull()
 131      })
 132    })
 133  
 134    describe('buildTimelineQuery', () => {
 135      it('returns null for social feed without authors', () => {
 136        const feed = Feed.following(ownerPubkey)
 137        feed.setResolvedRelayUrls([relayUrl1])
 138  
 139        const query = feed.buildTimelineQuery()
 140  
 141        expect(query).toBeNull()
 142      })
 143  
 144      it('builds query for social feed with authors', () => {
 145        const feed = Feed.following(ownerPubkey)
 146        feed.setResolvedRelayUrls([relayUrl1])
 147        const author = Pubkey.fromHex('b'.repeat(64))
 148  
 149        const query = feed.buildTimelineQuery({ authors: [author] })
 150  
 151        expect(query).not.toBeNull()
 152        expect(query!.authors).toHaveLength(1)
 153      })
 154  
 155      it('returns null when no relay URLs are resolved', () => {
 156        const feed = Feed.following(ownerPubkey)
 157  
 158        const query = feed.buildTimelineQuery({ authors: [ownerPubkey] })
 159  
 160        expect(query).toBeNull()
 161      })
 162  
 163      it('builds query for relay feed', () => {
 164        const feed = Feed.singleRelay(relayUrl1)
 165  
 166        const query = feed.buildTimelineQuery()
 167  
 168        expect(query).not.toBeNull()
 169        expect(query!.relays).toHaveLength(1)
 170      })
 171    })
 172  
 173    describe('toState/fromState', () => {
 174      it('serializes and deserializes following feed', () => {
 175        const feed = Feed.following(ownerPubkey)
 176        feed.setResolvedRelayUrls([relayUrl1])
 177        feed.refresh()
 178  
 179        const state = feed.toState()
 180        const restored = Feed.fromState(state, ownerPubkey)
 181  
 182        expect(restored.type.value).toBe('following')
 183        expect(restored.relayUrls).toHaveLength(1)
 184        expect(restored.lastRefreshedAt).not.toBeNull()
 185      })
 186  
 187      it('serializes and deserializes relay set feed', () => {
 188        const feed = Feed.relays(ownerPubkey, 'test-set', [relayUrl1, relayUrl2])
 189  
 190        const state = feed.toState()
 191        const restored = Feed.fromState(state, ownerPubkey)
 192  
 193        expect(restored.type.value).toBe('relays')
 194        expect(restored.type.relaySetId).toBe('test-set')
 195        expect(restored.relayUrls).toHaveLength(2)
 196      })
 197  
 198      it('handles invalid state gracefully', () => {
 199        const invalidState = {
 200          feedType: 'invalid',
 201          relayUrls: [],
 202          contentFilter: {
 203            hideMutedUsers: true,
 204            hideContentMentioningMuted: false,
 205            hideUntrustedUsers: false,
 206            hideReplies: false,
 207            hideReposts: false,
 208            allowedKinds: [],
 209            nsfwPolicy: 'hide'
 210          }
 211        }
 212  
 213        const restored = Feed.fromState(invalidState)
 214  
 215        expect(restored.type.value).toBe('following') // Falls back to empty/following
 216      })
 217    })
 218  
 219    describe('withOwner', () => {
 220      it('creates a copy with new owner', () => {
 221        const feed = Feed.singleRelay(relayUrl1)
 222        const newOwner = Pubkey.fromHex('c'.repeat(64))
 223  
 224        const feedWithOwner = feed.withOwner(newOwner)
 225  
 226        expect(feedWithOwner.owner).toEqual(newOwner)
 227        expect(feedWithOwner.type.value).toBe('relay')
 228        expect(feedWithOwner.relayUrls).toHaveLength(1)
 229      })
 230    })
 231  
 232    describe('equals', () => {
 233      it('returns true for identical feeds', () => {
 234        const feed1 = Feed.following(ownerPubkey)
 235        const feed2 = Feed.following(ownerPubkey)
 236  
 237        expect(feed1.equals(feed2)).toBe(true)
 238      })
 239  
 240      it('returns false for different feed types', () => {
 241        const feed1 = Feed.following(ownerPubkey)
 242        const feed2 = Feed.pinned(ownerPubkey)
 243  
 244        expect(feed1.equals(feed2)).toBe(false)
 245      })
 246  
 247      it('returns false for different relay URLs', () => {
 248        const feed1 = Feed.relays(ownerPubkey, 'set', [relayUrl1])
 249        const feed2 = Feed.relays(ownerPubkey, 'set', [relayUrl1, relayUrl2])
 250  
 251        expect(feed1.equals(feed2)).toBe(false)
 252      })
 253    })
 254  })
 255