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