File size: 9,362 Bytes
8a428e5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { flushPromises, mount, RouterLinkStub } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'
import { setActivePinia, createPinia } from 'pinia'
import BlogPost from './BlogPost.vue'

vi.mock('../api', () => ({ default: { get: vi.fn(), post: vi.fn() } }))
import api from '../api'

const post = {
  id: 1, title: 'Hello World', slug: 'hello-world', content: '<p>Some content here.</p>',
  createdAt: '2026-01-01T00:00:00.000Z', readTime: 5, views: 100, authorName: 'Admin',
  category: { slug: 'tech', name: 'Tech', icon: '💻', color: '#3B82F6' },
  tags: [{ id: 1, name: 'vue' }], featuredImage: null,
  gallery: JSON.stringify(['/a.jpg', '/b.jpg']),
}

const relatedPost = { id: 2, title: 'Another Post', slug: 'another' }
const comment = { id: 1, authorName: 'Jane', content: 'Nice post!', createdAt: '2026-01-01T00:00:00.000Z' }

class FakeBufferSource {
  connect() {}
  start() { setTimeout(() => this.onended?.(), 0) }
  addEventListener(name, cb) { if (name === 'ended') this._endedListener = cb }
}
class FakeAudioContext {
  constructor() { this.currentTime = 0; this.state = 'running' }
  createBufferSource() { return new FakeBufferSource() }
  decodeAudioData() { return Promise.resolve({ duration: 0.01 }) }
  suspend() { this.state = 'suspended' }
  resume() { this.state = 'running' }
  close() { this.state = 'closed' }
}

// onMounted: blog.fetchPost() [api.get /posts/:slug], then (if category)
// blog.fetchPosts() [api.get /posts?...], then api.get /comments/post/:id.
function mockOnMountCalls({ postData = post, related = { posts: [], total: 0, page: 1, pages: 1 }, commentsData = [] } = {}) {
  api.get
    .mockResolvedValueOnce({ data: postData })
    .mockResolvedValueOnce({ data: related })
    .mockResolvedValueOnce({ data: commentsData })
}

async function mountAt(slug = 'hello-world') {
  setActivePinia(createPinia())
  const router = createRouter({
    history: createWebHistory(),
    routes: [{ path: '/blog/:slug', component: BlogPost }],
  })
  router.push(`/blog/${slug}`)
  await router.isReady()
  const wrapper = mount(BlogPost, {
    // buildChunksWithDOM() queries document.querySelector('.post-content'),
    // which only finds anything if the component is actually attached to
    // the document (VTU doesn't do this by default).
    attachTo: document.body,
    global: {
      plugins: [router],
      stubs: { Navbar: true, Footer: true, PostCard: true, RouterLink: RouterLinkStub, Teleport: true },
    },
  })
  await flushPromises()
  return wrapper
}

describe('BlogPost', () => {
  let originalAudioContext, originalRaf, originalCaf

  beforeEach(() => {
    vi.clearAllMocks()
    api.post.mockResolvedValue({ data: { arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)) } })
    originalAudioContext = global.AudioContext
    originalRaf = global.requestAnimationFrame
    originalCaf = global.cancelAnimationFrame
    global.AudioContext = FakeAudioContext
    global.requestAnimationFrame = vi.fn(() => 1)
    global.cancelAnimationFrame = vi.fn()
    Object.defineProperty(navigator, 'clipboard', { value: { writeText: vi.fn() }, configurable: true })
  })

  afterEach(() => {
    global.AudioContext = originalAudioContext
    global.requestAnimationFrame = originalRaf
    global.cancelAnimationFrame = originalCaf
    document.body.innerHTML = ''
  })

  it('renders the post, tags, and gallery once loaded', async () => {
    mockOnMountCalls()
    const wrapper = await mountAt()
    expect(wrapper.find('h1').text()).toBe('Hello World')
    expect(wrapper.text()).toContain('#vue')
    expect(wrapper.text()).toContain('Photo Gallery')
    expect(wrapper.text()).toContain('Tech')
  })

  it('logs and recovers when the post fetch fails', async () => {
    const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
    api.get.mockRejectedValueOnce(new Error('404'))
    const wrapper = await mountAt('missing')
    expect(wrapper.find('article').exists()).toBe(false)
    expect(consoleSpy).toHaveBeenCalled()
    consoleSpy.mockRestore()
  })

  it('loads related posts filtered by category, excluding itself', async () => {
    mockOnMountCalls({ related: { posts: [relatedPost, { ...post }], total: 2, page: 1, pages: 1 } })
    const wrapper = await mountAt()
    expect(wrapper.text()).toContain('Related Posts')
  })

  it('renders existing comments', async () => {
    mockOnMountCalls({ commentsData: [comment] })
    const wrapper = await mountAt()
    expect(wrapper.text()).toContain('Jane')
    expect(wrapper.text()).toContain('Nice post!')
    expect(wrapper.text()).toContain('1 Comments')
  })

  it('submits a new comment', async () => {
    mockOnMountCalls()
    const wrapper = await mountAt()
    api.post.mockResolvedValueOnce({})
    await wrapper.find('input[type="text"]').setValue('New Commenter')
    await wrapper.find('textarea').setValue('Great read!')
    await wrapper.find('form').trigger('submit')
    await flushPromises()
    expect(api.post).toHaveBeenCalledWith('/comments/post/1', expect.objectContaining({ authorName: 'New Commenter', content: 'Great read!' }))
    expect(wrapper.text()).toContain('awaiting approval')
  })

  it('opens, navigates, and closes the gallery lightbox', async () => {
    mockOnMountCalls()
    const wrapper = await mountAt()
    const thumbs = wrapper.findAll('.aspect-square.rounded-xl.overflow-hidden.cursor-pointer.group')
    await thumbs[0].trigger('click')
    expect(wrapper.find('.fixed.inset-0.bg-black\\/90').exists()).toBe(true)

    const nextBtn = wrapper.find('[aria-label="Next image"]')
    await nextBtn.trigger('click')
    expect(wrapper.find('img[alt="Gallery image 2 of 2"]').exists()).toBe(true)

    const closeBtn = wrapper.find('[aria-label="Close gallery"]')
    await closeBtn.trigger('click')
    expect(wrapper.find('.fixed.inset-0.bg-black\\/90').exists()).toBe(false)
  })

  it('copies the page URL when Copy Link is clicked', async () => {
    mockOnMountCalls()
    const wrapper = await mountAt()
    const copyBtn = wrapper.findAll('button').find(b => b.text().includes('Copy Link'))
    await copyBtn.trigger('click')
    expect(navigator.clipboard.writeText).toHaveBeenCalled()
  })

  describe('TTS player', () => {
    it('plays through all chunks and returns to idle', async () => {
      vi.useFakeTimers({ shouldAdvanceTime: true })
      mockOnMountCalls()
      const wrapper = await mountAt()

      const listenTab = wrapper.findAll('button').find(b => b.text().includes('LISTEN'))
      const playPromise = listenTab.trigger('click')
      for (let i = 0; i < 10; i++) {
        await vi.advanceTimersByTimeAsync(50)
        await flushPromises()
      }
      await playPromise
      await flushPromises()
      expect(wrapper.text()).not.toContain('Loading…')
      vi.useRealTimers()
    })

    it('shows an error state when TTS fetching fails', async () => {
      vi.useFakeTimers({ shouldAdvanceTime: true })
      mockOnMountCalls()
      // Must be rejected before mount: onMounted() speculatively pre-fetches
      // chunk 0 (the title), and openPlayer() reuses that same promise —
      // rejecting only after mount would leave the speculative fetch
      // resolved and mask the failure.
      api.post.mockReset()
      api.post.mockRejectedValue({ response: { data: { message: 'TTS down' } } })
      const wrapper = await mountAt()

      const listenTab = wrapper.findAll('button').find(b => b.text().includes('LISTEN'))
      await listenTab.trigger('click')
      await vi.advanceTimersByTimeAsync(100)
      await flushPromises()
      expect(wrapper.text()).toContain('TTS down')
      vi.useRealTimers()
    })

    it('stays in loading state while the first chunk is still in flight', async () => {
      vi.useFakeTimers({ shouldAdvanceTime: true })
      mockOnMountCalls()
      api.post.mockImplementation(() => new Promise(() => {})) // never resolves
      const wrapper = await mountAt()

      const listenTab = wrapper.findAll('button').find(b => b.text().includes('LISTEN'))
      await listenTab.trigger('click')
      await vi.advanceTimersByTimeAsync(10)
      await flushPromises()
      expect(wrapper.text()).toContain('Loading…')
      vi.useRealTimers()
    })

    it('seeks to a later chunk after a completed playback', async () => {
      vi.useFakeTimers({ shouldAdvanceTime: true })
      mockOnMountCalls({
        postData: { ...post, content: '<p>First paragraph.</p><p>Second paragraph.</p><p>Third paragraph.</p>' },
      })
      api.post.mockResolvedValue({ data: { arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)) } })
      const wrapper = await mountAt()

      const listenTab = wrapper.findAll('button').find(b => b.text().includes('LISTEN'))
      const playPromise = listenTab.trigger('click')
      for (let i = 0; i < 15; i++) {
        await vi.advanceTimersByTimeAsync(50)
        await flushPromises()
      }
      await playPromise
      await flushPromises()

      const slider = wrapper.findAll('input[type="range"]')[0]
      await slider.setValue('50')
      await slider.trigger('change')
      await vi.advanceTimersByTimeAsync(50)
      await flushPromises()
      expect(wrapper.text()).toContain('Segment')
      vi.useRealTimers()
    })
  })
})