forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {MetricsClient} from './client'
2
3let appStateCallback: (state: string) => void
4
5jest.mock('#/lib/appState', () => ({
6 onAppStateChange: jest.fn(cb => {
7 appStateCallback = cb
8 return {remove: jest.fn()}
9 }),
10}))
11
12jest.mock('#/logger', () => ({
13 Logger: {
14 create: () => ({
15 info: jest.fn(),
16 debug: jest.fn(),
17 error: jest.fn(),
18 }),
19 Context: {Metric: 'metric'},
20 },
21}))
22
23jest.mock('#/env', () => ({
24 METRICS_API_HOST: 'https://test.metrics.api',
25 IS_WEB: false,
26}))
27
28type TestEvents = {
29 click: {button: string}
30 view: {screen: string}
31}
32
33describe('MetricsClient', () => {
34 let fetchMock: jest.Mock
35 let fetchRequests: {body: any}[]
36
37 beforeEach(() => {
38 jest.useFakeTimers({advanceTimers: true})
39 fetchRequests = []
40 fetchMock = jest.fn().mockImplementation(async (_url, options) => {
41 const body = JSON.parse(options.body)
42 fetchRequests.push({body})
43 return {ok: true, status: 200}
44 })
45 global.fetch = fetchMock
46 })
47
48 afterEach(() => {
49 jest.useRealTimers()
50 jest.clearAllMocks()
51 })
52
53 it('flushes events on interval', async () => {
54 const client = new MetricsClient<TestEvents>()
55 client.track('click', {button: 'submit'})
56 client.track('view', {screen: 'home'})
57
58 expect(fetchRequests).toHaveLength(0)
59
60 // Advance past the 10 second interval
61 await jest.advanceTimersByTimeAsync(10_000)
62
63 expect(fetchRequests).toHaveLength(1)
64 expect(fetchRequests[0].body.events).toHaveLength(2)
65 expect(fetchRequests[0].body.events[0].event).toBe('click')
66 expect(fetchRequests[0].body.events[1].event).toBe('view')
67 })
68
69 it('flushes when maxBatchSize is exceeded', async () => {
70 const client = new MetricsClient<TestEvents>()
71 client.maxBatchSize = 5
72
73 // Add events up to maxBatchSize (should not flush yet)
74 for (let i = 0; i < 5; i++) {
75 client.track('click', {button: `btn-${i}`})
76 }
77
78 expect(fetchRequests).toHaveLength(0)
79
80 // One more event should trigger flush (> maxBatchSize)
81 client.track('click', {button: 'btn-trigger'})
82
83 // Allow microtasks to run
84 await jest.advanceTimersByTimeAsync(0)
85
86 expect(fetchRequests).toHaveLength(1)
87 expect(fetchRequests[0].body.events).toHaveLength(6)
88 })
89
90 it('retries failed events once on 500 response', async () => {
91 let requestCount = 0
92
93 fetchMock.mockImplementation(async (_url, options) => {
94 requestCount++
95 const body = JSON.parse(options.body)
96
97 if (requestCount === 1) {
98 // First request fails with 500 - "Failed to fetch" triggers isNetworkError
99 return {
100 ok: false,
101 status: 500,
102 text: async () => 'Internal Server Error',
103 }
104 }
105
106 // Retry succeeds
107 fetchRequests.push({body})
108 return {ok: true, status: 200}
109 })
110
111 const client = new MetricsClient<TestEvents>()
112 client.track('click', {button: 'submit'})
113
114 // Trigger flush via interval
115 await jest.advanceTimersByTimeAsync(10_000)
116
117 expect(requestCount).toBe(1)
118 expect(fetchRequests).toHaveLength(0)
119
120 // Simulate app coming to foreground to trigger retry
121 appStateCallback('active')
122 await jest.advanceTimersByTimeAsync(0)
123
124 expect(requestCount).toBe(2)
125 expect(fetchRequests).toHaveLength(1)
126 expect(fetchRequests[0].body.events).toHaveLength(1)
127 expect(fetchRequests[0].body.events[0].event).toBe('click')
128 })
129
130 it('does not retry more than once', async () => {
131 let requestCount = 0
132
133 fetchMock.mockImplementation(async () => {
134 requestCount++
135 // Always fail with network-like error
136 return {
137 ok: false,
138 status: 500,
139 text: async () => 'Internal Server Error',
140 }
141 })
142
143 const client = new MetricsClient<TestEvents>()
144 client.track('click', {button: 'submit'})
145
146 // First flush fails
147 await jest.advanceTimersByTimeAsync(10_000)
148
149 expect(requestCount).toBe(1)
150
151 // Retry also fails
152 appStateCallback('active')
153 await jest.advanceTimersByTimeAsync(0)
154
155 expect(requestCount).toBe(2)
156
157 // Another foreground event should not retry again (events are dropped)
158 appStateCallback('active')
159 await jest.advanceTimersByTimeAsync(0)
160
161 expect(requestCount).toBe(2) // No additional requests
162 })
163
164 it('flushes when app goes to background', async () => {
165 const client = new MetricsClient<TestEvents>()
166 client.track('click', {button: 'submit'})
167
168 expect(fetchRequests).toHaveLength(0)
169
170 // Simulate app going to background
171 appStateCallback('background')
172 await jest.advanceTimersByTimeAsync(0)
173
174 expect(fetchRequests).toHaveLength(1)
175 })
176})