Skip to content

Commit 3ed9471

Browse files
committed
Add tests
1 parent be6054e commit 3ed9471

9 files changed

Lines changed: 2145 additions & 3 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,8 @@ jobs:
145145
- name: Run tests with phpunit
146146
run: vendor/bin/phpunit tests
147147

148-
# - name: Run tests with vitest
149-
# run: node vendor/bin/phpunit node_modules/vitest/vitest.mjs --run
148+
- name: Run tests with vitest
149+
run: node vendor/bin/phpunit node_modules/vitest/vitest.mjs --run
150150

151151
- name: Upload Panther screenshots
152152
if: failure()
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
import { mount, flushPromises } from '@vue/test-utils'
2+
import { describe, it, expect, vi, beforeEach } from 'vitest'
3+
import BounceOverview from './BounceOverview.vue'
4+
import { bouncesClient } from '../../api'
5+
6+
vi.mock('../../api', () => ({
7+
bouncesClient: {
8+
list: vi.fn(),
9+
},
10+
}))
11+
12+
const makeBounce = (overrides = {}) => ({
13+
id: 1,
14+
date: '2024-03-15T10:30:00Z',
15+
subscriber_email: 'user@example.com',
16+
message_subject: 'Test Campaign',
17+
comment: 'Hard bounce',
18+
status: 'processed',
19+
...overrides,
20+
})
21+
22+
const makeResponse = (items = [], hasMore = false, nextCursor = null) => ({
23+
items,
24+
pagination: { hasMore, nextCursor },
25+
})
26+
27+
beforeEach(() => {
28+
vi.clearAllMocks()
29+
bouncesClient.list.mockResolvedValue(makeResponse())
30+
})
31+
32+
describe('BounceOverview.vue', () => {
33+
34+
describe('on mount', () => {
35+
it('calls bouncesClient.list with cursor=null, pageSize=5, and identified status', async () => {
36+
const wrapper = mount(BounceOverview)
37+
await flushPromises()
38+
expect(bouncesClient.list).toHaveBeenCalledWith(null, 5, 'identified-bounces')
39+
})
40+
41+
it('renders bounce rows after a successful load', async () => {
42+
bouncesClient.list.mockResolvedValue(makeResponse([makeBounce()]))
43+
const wrapper = mount(BounceOverview)
44+
await flushPromises()
45+
expect(wrapper.text()).toContain('user@example.com')
46+
expect(wrapper.text()).toContain('Test Campaign')
47+
})
48+
49+
it('shows empty state when the API returns no items', async () => {
50+
const wrapper = mount(BounceOverview)
51+
await flushPromises()
52+
expect(wrapper.text()).toContain('No bounces found.')
53+
})
54+
55+
it('shows an error message when the API rejects', async () => {
56+
bouncesClient.list.mockRejectedValue(new Error('Network error'))
57+
const wrapper = mount(BounceOverview)
58+
await flushPromises()
59+
expect(wrapper.text()).toContain('Network error')
60+
})
61+
62+
it('shows a generic fallback when the error has no message', async () => {
63+
bouncesClient.list.mockRejectedValue({})
64+
const wrapper = mount(BounceOverview)
65+
await flushPromises()
66+
expect(wrapper.text()).toContain('Failed to load bounces.')
67+
})
68+
})
69+
70+
describe('data normalisation', () => {
71+
it('falls back to "Unknown email" when subscriber_email is missing', async () => {
72+
bouncesClient.list.mockResolvedValue(makeResponse([makeBounce({ subscriber_email: null })]))
73+
const wrapper = mount(BounceOverview)
74+
await flushPromises()
75+
expect(wrapper.text()).toContain('Unknown email')
76+
})
77+
78+
it('falls back to "No subject" when message_subject is missing', async () => {
79+
bouncesClient.list.mockResolvedValue(makeResponse([makeBounce({ message_subject: null })]))
80+
const wrapper = mount(BounceOverview)
81+
await flushPromises()
82+
expect(wrapper.text()).toContain('No subject')
83+
})
84+
85+
it('falls back to "No comment" when comment is missing', async () => {
86+
bouncesClient.list.mockResolvedValue(makeResponse([makeBounce({ comment: null })]))
87+
const wrapper = mount(BounceOverview)
88+
await flushPromises()
89+
expect(wrapper.text()).toContain('No comment')
90+
})
91+
92+
it('falls back to "unknown" when status is missing', async () => {
93+
bouncesClient.list.mockResolvedValue(makeResponse([makeBounce({ status: null })]))
94+
const wrapper = mount(BounceOverview)
95+
await flushPromises()
96+
expect(wrapper.text()).toContain('unknown')
97+
})
98+
99+
it('shows "No date" for a missing date', async () => {
100+
bouncesClient.list.mockResolvedValue(makeResponse([makeBounce({ date: null })]))
101+
const wrapper = mount(BounceOverview)
102+
await flushPromises()
103+
expect(wrapper.text()).toContain('No date')
104+
})
105+
106+
it('shows "No date" for an invalid date string', async () => {
107+
bouncesClient.list.mockResolvedValue(makeResponse([makeBounce({ date: 'not-a-date' })]))
108+
const wrapper = mount(BounceOverview)
109+
await flushPromises()
110+
expect(wrapper.text()).toContain('No date')
111+
})
112+
})
113+
114+
describe('getStatusClass', () => {
115+
const mountWithStatus = async (status) => {
116+
bouncesClient.list.mockResolvedValue(makeResponse([makeBounce({ status })]))
117+
const wrapper = mount(BounceOverview)
118+
await flushPromises()
119+
return wrapper
120+
}
121+
122+
it('applies purple classes for blacklist status', async () => {
123+
const wrapper = await mountWithStatus('blacklisted')
124+
expect(wrapper.find('span.bg-purple-100').exists()).toBe(true)
125+
})
126+
127+
it('applies amber classes for soft/retry status', async () => {
128+
const wrapper = await mountWithStatus('soft bounce')
129+
expect(wrapper.find('span.bg-amber-100').exists()).toBe(true)
130+
})
131+
132+
it('applies emerald classes for processed status', async () => {
133+
const wrapper = await mountWithStatus('processed')
134+
expect(wrapper.find('span.bg-emerald-100').exists()).toBe(true)
135+
})
136+
137+
it('applies slate classes for an unrecognised status', async () => {
138+
const wrapper = await mountWithStatus('something-else')
139+
expect(wrapper.find('span.bg-slate-100').exists()).toBe(true)
140+
})
141+
})
142+
143+
describe('status filter', () => {
144+
it('renders the status select with "identified" as the default', async () => {
145+
const wrapper = mount(BounceOverview)
146+
await flushPromises()
147+
const select = wrapper.find('select#bounce-status-filter')
148+
expect(select.element.value).toBe('identified')
149+
})
150+
151+
it('reloads with unidentified status when filter changes', async () => {
152+
const wrapper = mount(BounceOverview)
153+
await flushPromises()
154+
vi.clearAllMocks()
155+
bouncesClient.list.mockResolvedValue(makeResponse())
156+
157+
await wrapper.find('select#bounce-status-filter').setValue('unidentified')
158+
await flushPromises()
159+
160+
expect(bouncesClient.list).toHaveBeenCalledWith(null, 5, 'unidentified bounce')
161+
})
162+
163+
it('resets to page 1 when the filter changes', async () => {
164+
bouncesClient.list.mockResolvedValue(makeResponse([makeBounce()], true, 10))
165+
const wrapper = mount(BounceOverview)
166+
await flushPromises()
167+
168+
await wrapper.findAll('button')[1].trigger('click') // next page
169+
await flushPromises()
170+
expect(wrapper.text()).toContain('Page 2')
171+
172+
bouncesClient.list.mockResolvedValue(makeResponse())
173+
await wrapper.find('select#bounce-status-filter').setValue('unidentified')
174+
await flushPromises()
175+
expect(wrapper.text()).toContain('Page 1')
176+
})
177+
})
178+
179+
describe('pagination', () => {
180+
it('disables Previous on the first page', async () => {
181+
const wrapper = mount(BounceOverview)
182+
await flushPromises()
183+
const [prev] = wrapper.findAll('button')
184+
expect(prev.attributes('disabled')).toBeDefined()
185+
})
186+
187+
it('enables Next when hasMore is true', async () => {
188+
bouncesClient.list.mockResolvedValue(makeResponse([makeBounce()], true, 10))
189+
const wrapper = mount(BounceOverview)
190+
await flushPromises()
191+
const [, next] = wrapper.findAll('button')
192+
expect(next.attributes('disabled')).toBeUndefined()
193+
})
194+
195+
it('disables Next when hasMore is false', async () => {
196+
bouncesClient.list.mockResolvedValue(makeResponse([makeBounce()], false))
197+
const wrapper = mount(BounceOverview)
198+
await flushPromises()
199+
const [, next] = wrapper.findAll('button')
200+
expect(next.attributes('disabled')).toBeDefined()
201+
})
202+
203+
it('advances to page 2 and fetches with the next cursor', async () => {
204+
bouncesClient.list.mockResolvedValue(makeResponse([makeBounce()], true, 42))
205+
const wrapper = mount(BounceOverview)
206+
await flushPromises()
207+
208+
vi.clearAllMocks()
209+
bouncesClient.list.mockResolvedValue(makeResponse([makeBounce({ id: 2 })], false))
210+
await wrapper.findAll('button')[1].trigger('click') // Next
211+
await flushPromises()
212+
213+
expect(wrapper.text()).toContain('Page 2')
214+
expect(bouncesClient.list).toHaveBeenCalledWith(42, 5, 'identified-bounces')
215+
})
216+
217+
it('goes back to page 1 when Previous is clicked', async () => {
218+
bouncesClient.list.mockResolvedValue(makeResponse([makeBounce()], true, 42))
219+
const wrapper = mount(BounceOverview)
220+
await flushPromises()
221+
222+
await wrapper.findAll('button')[1].trigger('click') // Next
223+
await flushPromises()
224+
225+
vi.clearAllMocks()
226+
bouncesClient.list.mockResolvedValue(makeResponse([makeBounce()]))
227+
await wrapper.findAll('button')[0].trigger('click') // Previous
228+
await flushPromises()
229+
230+
expect(wrapper.text()).toContain('Page 1')
231+
expect(bouncesClient.list).toHaveBeenCalledWith(null, 5, 'identified-bounces')
232+
})
233+
})
234+
235+
describe('stale request cancellation', () => {
236+
it('ignores a slow response that arrives after a newer request', async () => {
237+
let resolveFirst
238+
const slowRequest = new Promise((res) => { resolveFirst = res })
239+
bouncesClient.list
240+
.mockReturnValueOnce(slowRequest)
241+
.mockResolvedValueOnce(makeResponse([makeBounce({ subscriber_email: 'new@example.com' })]))
242+
243+
const wrapper = mount(BounceOverview)
244+
245+
await wrapper.find('select#bounce-status-filter').setValue('unidentified')
246+
await flushPromises()
247+
248+
resolveFirst(makeResponse([makeBounce({ subscriber_email: 'stale@example.com' })]))
249+
await flushPromises()
250+
251+
expect(wrapper.text()).not.toContain('stale@example.com')
252+
expect(wrapper.text()).toContain('new@example.com')
253+
})
254+
})
255+
})

0 commit comments

Comments
 (0)