forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {MetricsClient} from './client'
2
3let appStateCallback: (state: string) => void
4const mockCaptureMessage = jest.fn()
5let mockMetricsApiHost: string | undefined = 'https://test.metrics.api'
6let mockSentryDsn: string | undefined
7
8jest.mock('#/lib/appState', () => ({
9 onAppStateChange: jest.fn(cb => {
10 appStateCallback = cb
11 return {remove: jest.fn()}
12 }),
13}))
14
15jest.mock('#/logger', () => ({
16 Logger: {
17 create: () => ({
18 info: jest.fn(),
19 debug: jest.fn(),
20 error: jest.fn(),
21 }),
22 Context: {Metric: 'metric'},
23 },
24}))
25
26jest.mock('@sentry/react-native', () => ({
27 captureMessage: mockCaptureMessage,
28}))
29
30jest.mock('#/env', () => ({
31 get METRICS_API_HOST() {
32 return mockMetricsApiHost
33 },
34 get SENTRY_DSN() {
35 return mockSentryDsn
36 },
37 IS_WEB: false,
38}))
39
40type TestEvents = {
41 click: {button: string}
42 view: {screen: string}
43}
44
45type FetchRequestBody = {
46 events: Array<{
47 event: string
48 }>
49}
50
51function parseFetchBody(options?: RequestInit): FetchRequestBody {
52 const {body} = options ?? {}
53 const raw =
54 typeof body === 'string' ? body : body == null ? '{}' : JSON.stringify(body)
55 return JSON.parse(raw) as FetchRequestBody
56}
57
58describe('MetricsClient', () => {
59 let fetchMock: jest.Mock
60 let fetchRequests: {body: FetchRequestBody}[]
61
62 beforeEach(() => {
63 jest.useFakeTimers({advanceTimers: true})
64 mockMetricsApiHost = 'https://test.metrics.api'
65 mockSentryDsn = undefined
66 mockCaptureMessage.mockReset()
67 fetchRequests = []
68 fetchMock = jest.fn().mockImplementation((_url, options?: RequestInit) => {
69 const body = parseFetchBody(options)
70 fetchRequests.push({body})
71 return Promise.resolve({ok: true, status: 200})
72 })
73 global.fetch = fetchMock
74 })
75
76 afterEach(() => {
77 jest.useRealTimers()
78 jest.clearAllMocks()
79 })
80
81 it('flushes events on interval', async () => {
82 const client = new MetricsClient<TestEvents>()
83 client.track('click', {button: 'submit'})
84 client.track('view', {screen: 'home'})
85
86 expect(fetchRequests).toHaveLength(0)
87
88 // Advance past the 10 second interval
89 await jest.advanceTimersByTimeAsync(10_000)
90
91 expect(fetchRequests).toHaveLength(1)
92 expect(fetchRequests[0].body.events).toHaveLength(2)
93 expect(fetchRequests[0].body.events[0].event).toBe('click')
94 expect(fetchRequests[0].body.events[1].event).toBe('view')
95 })
96
97 it('flushes when maxBatchSize is exceeded', async () => {
98 const client = new MetricsClient<TestEvents>()
99 client.maxBatchSize = 5
100
101 // Add events up to maxBatchSize (should not flush yet)
102 for (let i = 0; i < 5; i++) {
103 client.track('click', {button: `btn-${i}`})
104 }
105
106 expect(fetchRequests).toHaveLength(0)
107
108 // One more event should trigger flush (> maxBatchSize)
109 client.track('click', {button: 'btn-trigger'})
110
111 // Allow microtasks to run
112 await jest.advanceTimersByTimeAsync(0)
113
114 expect(fetchRequests).toHaveLength(1)
115 expect(fetchRequests[0].body.events).toHaveLength(6)
116 })
117
118 it('retries failed events once on 500 response', async () => {
119 let requestCount = 0
120
121 fetchMock.mockImplementation((_url, options?: RequestInit) => {
122 requestCount++
123 const body = parseFetchBody(options)
124
125 if (requestCount === 1) {
126 // First request fails with 500 - "Failed to fetch" triggers isNetworkError
127 return Promise.resolve({
128 ok: false,
129 status: 500,
130 text: () => Promise.resolve('Internal Server Error'),
131 })
132 }
133
134 // Retry succeeds
135 fetchRequests.push({body})
136 return Promise.resolve({ok: true, status: 200})
137 })
138
139 const client = new MetricsClient<TestEvents>()
140 client.track('click', {button: 'submit'})
141
142 // Trigger flush via interval
143 await jest.advanceTimersByTimeAsync(10_000)
144
145 expect(requestCount).toBe(1)
146 expect(fetchRequests).toHaveLength(0)
147
148 // Simulate app coming to foreground to trigger retry
149 appStateCallback('active')
150 await jest.advanceTimersByTimeAsync(0)
151
152 expect(requestCount).toBe(2)
153 expect(fetchRequests).toHaveLength(1)
154 expect(fetchRequests[0].body.events).toHaveLength(1)
155 expect(fetchRequests[0].body.events[0].event).toBe('click')
156 })
157
158 it('does not retry more than once', async () => {
159 let requestCount = 0
160
161 fetchMock.mockImplementation(() => {
162 requestCount++
163 // Always fail with network-like error
164 return Promise.resolve({
165 ok: false,
166 status: 500,
167 text: () => Promise.resolve('Internal Server Error'),
168 })
169 })
170
171 const client = new MetricsClient<TestEvents>()
172 client.track('click', {button: 'submit'})
173
174 // First flush fails
175 await jest.advanceTimersByTimeAsync(10_000)
176
177 expect(requestCount).toBe(1)
178
179 // Retry also fails
180 appStateCallback('active')
181 await jest.advanceTimersByTimeAsync(0)
182
183 expect(requestCount).toBe(2)
184
185 // Another foreground event should not retry again (events are dropped)
186 appStateCallback('active')
187 await jest.advanceTimersByTimeAsync(0)
188
189 expect(requestCount).toBe(2) // No additional requests
190 })
191
192 it('flushes when app goes to background', async () => {
193 const client = new MetricsClient<TestEvents>()
194 client.track('click', {button: 'submit'})
195
196 expect(fetchRequests).toHaveLength(0)
197
198 // Simulate app going to background
199 appStateCallback('background')
200 await jest.advanceTimersByTimeAsync(0)
201
202 expect(fetchRequests).toHaveLength(1)
203 })
204
205 it('sends metrics through sentry when metrics api host is unset', async () => {
206 mockMetricsApiHost = undefined
207 mockSentryDsn = 'https://public@example.glitchtip.com/1'
208
209 const client = new MetricsClient<TestEvents>()
210 client.track('click', {button: 'submit'})
211 client.track('view', {screen: 'home'})
212
213 await jest.advanceTimersByTimeAsync(10_000)
214
215 expect(fetchRequests).toHaveLength(0)
216 expect(mockCaptureMessage).toHaveBeenCalledTimes(2)
217 expect(mockCaptureMessage).toHaveBeenNthCalledWith(
218 1,
219 'metric:click',
220 expect.objectContaining({
221 level: 'info',
222 fingerprint: ['metric', 'click'],
223 tags: expect.objectContaining({
224 metric_name: 'click',
225 metric_source: 'app',
226 }),
227 extra: expect.objectContaining({
228 logger: 'metric',
229 payload: {button: 'submit'},
230 }),
231 }),
232 )
233 })
234})