NoteComposer.test.ts raw
1 import { describe, it, expect } from 'vitest'
2 import { NoteComposer } from './NoteComposer'
3 import { ReplyContext } from './ReplyContext'
4 import { QuoteContext } from './QuoteContext'
5 import { Pubkey } from '../shared/value-objects/Pubkey'
6 import { EventId } from '../shared/value-objects/EventId'
7 import { NoteCreated, NoteReplied, UsersMentioned } from './events'
8
9 describe('NoteComposer', () => {
10 const authorPubkey = Pubkey.fromHex('a'.repeat(64))
11 const otherPubkey = Pubkey.fromHex('b'.repeat(64))
12 const eventIdHex = 'c'.repeat(64)
13
14 describe('factory methods', () => {
15 it('creates a new note composer', () => {
16 const composer = NoteComposer.create(authorPubkey)
17
18 expect(composer.author).toEqual(authorPubkey)
19 expect(composer.content).toBe('')
20 expect(composer.isReply).toBe(false)
21 expect(composer.isQuote).toBe(false)
22 expect(composer.isPoll).toBe(false)
23 expect(composer.isNsfw).toBe(false)
24 })
25
26 it('creates a reply composer', () => {
27 const replyContext = ReplyContext.simple(eventIdHex, otherPubkey)
28 const composer = NoteComposer.reply(authorPubkey, replyContext)
29
30 expect(composer.author).toEqual(authorPubkey)
31 expect(composer.isReply).toBe(true)
32 expect(composer.replyContext).not.toBeNull()
33 })
34
35 it('creates a quote composer', () => {
36 const quoteContext = QuoteContext.create(eventIdHex, otherPubkey)
37 const composer = NoteComposer.quote(authorPubkey, quoteContext)
38
39 expect(composer.author).toEqual(authorPubkey)
40 expect(composer.isQuote).toBe(true)
41 expect(composer.quoteContext).not.toBeNull()
42 })
43
44 it('creates a poll composer', () => {
45 const composer = NoteComposer.poll(authorPubkey)
46
47 expect(composer.author).toEqual(authorPubkey)
48 expect(composer.isPoll).toBe(true)
49 expect(composer.pollConfig).not.toBeNull()
50 })
51 })
52
53 describe('content management', () => {
54 it('sets content immutably', () => {
55 const composer1 = NoteComposer.create(authorPubkey)
56 const composer2 = composer1.setContent('Hello, world!')
57
58 expect(composer1.content).toBe('')
59 expect(composer2.content).toBe('Hello, world!')
60 })
61
62 it('extracts hashtags from content', () => {
63 const composer = NoteComposer.create(authorPubkey).setContent(
64 'Hello #nostr and #bitcoin!'
65 )
66
67 expect(composer.hashtags).toEqual(['nostr', 'bitcoin'])
68 })
69
70 it('handles empty hashtags', () => {
71 const composer = NoteComposer.create(authorPubkey).setContent('No hashtags here')
72
73 expect(composer.hashtags).toEqual([])
74 })
75
76 it('lowercases hashtags', () => {
77 const composer = NoteComposer.create(authorPubkey).setContent(
78 '#NOSTR #Bitcoin #Lightning'
79 )
80
81 expect(composer.hashtags).toEqual(['nostr', 'bitcoin', 'lightning'])
82 })
83 })
84
85 describe('mentions', () => {
86 it('adds mentions immutably', () => {
87 const composer1 = NoteComposer.create(authorPubkey)
88 const composer2 = composer1.addMention(otherPubkey)
89
90 expect(composer1.additionalMentions).toHaveLength(0)
91 expect(composer2.additionalMentions).toHaveLength(1)
92 })
93
94 it('prevents duplicate mentions', () => {
95 const composer = NoteComposer.create(authorPubkey)
96 .addMention(otherPubkey)
97 .addMention(otherPubkey)
98
99 expect(composer.additionalMentions).toHaveLength(1)
100 })
101
102 it('removes mentions', () => {
103 const composer = NoteComposer.create(authorPubkey)
104 .addMention(otherPubkey)
105 .removeMention(otherPubkey)
106
107 expect(composer.additionalMentions).toHaveLength(0)
108 })
109
110 it('includes reply context mentions in allMentions', () => {
111 const replyContext = ReplyContext.simple(eventIdHex, otherPubkey)
112 const composer = NoteComposer.reply(authorPubkey, replyContext)
113
114 expect(composer.allMentions).toContainEqual(otherPubkey)
115 })
116
117 it('includes quote author in allMentions', () => {
118 const quoteContext = QuoteContext.create(eventIdHex, otherPubkey)
119 const composer = NoteComposer.quote(authorPubkey, quoteContext)
120
121 expect(composer.allMentions).toContainEqual(otherPubkey)
122 })
123
124 it('deduplicates mentions from different sources', () => {
125 const replyContext = ReplyContext.simple(eventIdHex, otherPubkey)
126 const composer = NoteComposer.reply(authorPubkey, replyContext).addMention(
127 otherPubkey
128 )
129
130 const mentions = composer.allMentions
131 const pubkeyHexes = mentions.map((p) => p.hex)
132 const uniqueHexes = new Set(pubkeyHexes)
133
134 expect(uniqueHexes.size).toBe(pubkeyHexes.length)
135 })
136 })
137
138 describe('options', () => {
139 it('sets content warning', () => {
140 const composer = NoteComposer.create(authorPubkey).setContentWarning(true)
141
142 expect(composer.isNsfw).toBe(true)
143 expect(composer.options.isNsfw).toBe(true)
144 })
145
146 it('sets client tag option', () => {
147 const composer = NoteComposer.create(authorPubkey).setClientTag(true)
148
149 expect(composer.options.addClientTag).toBe(true)
150 })
151
152 it('sets protected option', () => {
153 const composer = NoteComposer.create(authorPubkey).setProtected(true)
154
155 expect(composer.options.isProtected).toBe(true)
156 })
157 })
158
159 describe('poll configuration', () => {
160 it('enables poll with config', () => {
161 const composer = NoteComposer.create(authorPubkey).enablePoll({
162 isMultipleChoice: false,
163 options: [
164 { id: '1', text: 'Option 1' },
165 { id: '2', text: 'Option 2' }
166 ],
167 relays: ['wss://relay.example.com']
168 })
169
170 expect(composer.isPoll).toBe(true)
171 expect(composer.pollConfig?.options).toHaveLength(2)
172 })
173
174 it('disables poll', () => {
175 const composer = NoteComposer.poll(authorPubkey).disablePoll()
176
177 expect(composer.isPoll).toBe(false)
178 expect(composer.pollConfig).toBeNull()
179 })
180
181 it('sets poll options', () => {
182 const composer = NoteComposer.poll(authorPubkey).setPollOptions([
183 { id: '1', text: 'Yes' },
184 { id: '2', text: 'No' },
185 { id: '3', text: 'Maybe' }
186 ])
187
188 expect(composer.pollConfig?.options).toHaveLength(3)
189 })
190
191 it('sets multiple choice mode', () => {
192 const composer = NoteComposer.poll(authorPubkey).setPollMultipleChoice(true)
193
194 expect(composer.pollConfig?.isMultipleChoice).toBe(true)
195 })
196
197 it('clears reply context when enabling poll', () => {
198 const replyContext = ReplyContext.simple(eventIdHex, otherPubkey)
199 const composer = NoteComposer.reply(authorPubkey, replyContext).enablePoll({
200 isMultipleChoice: false,
201 options: [],
202 relays: []
203 })
204
205 expect(composer.isReply).toBe(false)
206 expect(composer.replyContext).toBeNull()
207 })
208 })
209
210 describe('effectiveContent', () => {
211 it('returns plain content for regular notes', () => {
212 const composer = NoteComposer.create(authorPubkey).setContent('Hello')
213
214 expect(composer.effectiveContent).toBe('Hello')
215 })
216
217 it('appends quote URI for quote posts', () => {
218 const quoteContext = QuoteContext.create(eventIdHex, otherPubkey)
219 const composer = NoteComposer.quote(authorPubkey, quoteContext).setContent('Check this out')
220
221 expect(composer.effectiveContent).toContain('Check this out')
222 expect(composer.effectiveContent).toContain('nostr:')
223 })
224 })
225
226 describe('validation', () => {
227 it('fails validation for empty content', () => {
228 const composer = NoteComposer.create(authorPubkey)
229 const result = composer.validate()
230
231 expect(result.isValid).toBe(false)
232 expect(result.errors).toContain('Content cannot be empty')
233 })
234
235 it('passes validation for non-empty content', () => {
236 const composer = NoteComposer.create(authorPubkey).setContent('Hello!')
237 const result = composer.validate()
238
239 expect(result.isValid).toBe(true)
240 expect(result.errors).toHaveLength(0)
241 })
242
243 it('fails validation for poll without enough options', () => {
244 const composer = NoteComposer.poll(authorPubkey)
245 .setContent('Question?')
246 .setPollOptions([{ id: '1', text: 'Only one option' }])
247
248 const result = composer.validate()
249
250 expect(result.isValid).toBe(false)
251 expect(result.errors).toContain('Poll must have at least 2 options')
252 })
253
254 it('fails validation for poll without question', () => {
255 const composer = NoteComposer.poll(authorPubkey).setPollOptions([
256 { id: '1', text: 'Yes' },
257 { id: '2', text: 'No' }
258 ])
259
260 const result = composer.validate()
261
262 expect(result.isValid).toBe(false)
263 expect(result.errors).toContain('Poll question cannot be empty')
264 })
265
266 it('passes validation for valid poll', () => {
267 const composer = NoteComposer.poll(authorPubkey)
268 .setContent('Do you like Nostr?')
269 .setPollOptions([
270 { id: '1', text: 'Yes' },
271 { id: '2', text: 'No' }
272 ])
273
274 const result = composer.validate()
275
276 expect(result.isValid).toBe(true)
277 })
278
279 it('canPublish reflects validation status', () => {
280 const invalid = NoteComposer.create(authorPubkey)
281 const valid = NoteComposer.create(authorPubkey).setContent('Hello!')
282
283 expect(invalid.canPublish()).toBe(false)
284 expect(valid.canPublish()).toBe(true)
285 })
286 })
287
288 describe('domain events', () => {
289 it('creates NoteCreated event', () => {
290 const composer = NoteComposer.create(authorPubkey).setContent('Hello #nostr')
291 const noteId = EventId.fromHex('d'.repeat(64))
292
293 const event = composer.createNoteCreatedEvent(noteId)
294
295 expect(event).toBeInstanceOf(NoteCreated)
296 expect(event.author).toEqual(authorPubkey)
297 expect(event.noteId).toEqual(noteId)
298 expect(event.hashtags).toContain('nostr')
299 })
300
301 it('creates NoteCreated event with reply reference', () => {
302 const replyContext = ReplyContext.simple(eventIdHex, otherPubkey)
303 const composer = NoteComposer.reply(authorPubkey, replyContext).setContent('Reply')
304 const noteId = EventId.fromHex('d'.repeat(64))
305
306 const event = composer.createNoteCreatedEvent(noteId)
307
308 expect(event.replyTo).not.toBeNull()
309 })
310
311 it('creates NoteReplied event for replies', () => {
312 const replyContext = ReplyContext.simple(eventIdHex, otherPubkey)
313 const composer = NoteComposer.reply(authorPubkey, replyContext).setContent('Reply')
314 const replyNoteId = EventId.fromHex('d'.repeat(64))
315
316 const event = composer.createNoteRepliedEvent(replyNoteId)
317
318 expect(event).toBeInstanceOf(NoteReplied)
319 expect(event?.replier).toEqual(authorPubkey)
320 })
321
322 it('returns null NoteReplied event for non-replies', () => {
323 const composer = NoteComposer.create(authorPubkey).setContent('Not a reply')
324 const noteId = EventId.fromHex('d'.repeat(64))
325
326 const event = composer.createNoteRepliedEvent(noteId)
327
328 expect(event).toBeNull()
329 })
330
331 it('creates UsersMentioned event when there are mentions', () => {
332 const replyContext = ReplyContext.simple(eventIdHex, otherPubkey)
333 const composer = NoteComposer.reply(authorPubkey, replyContext).setContent('Hey!')
334 const noteId = EventId.fromHex('d'.repeat(64))
335
336 const event = composer.createUsersMentionedEvent(noteId)
337
338 expect(event).toBeInstanceOf(UsersMentioned)
339 expect(event?.mentionedPubkeys).toContainEqual(otherPubkey)
340 })
341
342 it('returns null UsersMentioned event when no mentions', () => {
343 const composer = NoteComposer.create(authorPubkey).setContent('No mentions')
344 const noteId = EventId.fromHex('d'.repeat(64))
345
346 const event = composer.createUsersMentionedEvent(noteId)
347
348 expect(event).toBeNull()
349 })
350 })
351 })
352