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