forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import assert from 'node:assert'
2import {afterEach, beforeEach, describe, it, mock} from 'node:test'
3
4import {httpLogger} from './logger.js'
5import {MetricsClient} from './metrics.js'
6
7type TestEvents = {
8 click: {button: string}
9 view: {screen: string}
10}
11
12describe('MetricsClient', () => {
13 let fetchMock: ReturnType<typeof mock.fn>
14 let fetchRequests: {body: any}[]
15 let client: MetricsClient<TestEvents>
16 let loggerErrorMock: ReturnType<typeof mock.fn>
17
18 beforeEach(() => {
19 mock.timers.enable({apis: ['setInterval', 'setTimeout']})
20 fetchRequests = []
21 fetchMock = mock.fn(async (_url: any, options: any) => {
22 const body = JSON.parse(options.body)
23 fetchRequests.push({body})
24 return {ok: true, status: 200, text: async () => ''}
25 })
26 ;(globalThis as any).fetch = fetchMock
27 loggerErrorMock = mock.fn()
28 httpLogger.error = loggerErrorMock as any
29 })
30
31 afterEach(() => {
32 client?.stop()
33 mock.timers.reset()
34 mock.restoreAll()
35 })
36
37 it('flushes events on interval', async () => {
38 client = new MetricsClient<TestEvents>({
39 trackingEndpoint: 'https://test.metrics.api',
40 })
41 client.track('click', {button: 'submit'})
42 client.track('view', {screen: 'home'})
43
44 assert.strictEqual(fetchRequests.length, 0)
45
46 mock.timers.tick(10_000)
47 await flush()
48
49 assert.strictEqual(fetchRequests.length, 1)
50 assert.strictEqual(fetchRequests[0].body.events.length, 2)
51 assert.strictEqual(fetchRequests[0].body.events[0].event, 'click')
52 assert.strictEqual(fetchRequests[0].body.events[1].event, 'view')
53 })
54
55 it('flushes when maxBatchSize is exceeded', async () => {
56 client = new MetricsClient<TestEvents>({
57 trackingEndpoint: 'https://test.metrics.api',
58 })
59 client.maxBatchSize = 5
60
61 for (let i = 0; i < 5; i++) {
62 client.track('click', {button: `btn-${i}`})
63 }
64
65 assert.strictEqual(fetchRequests.length, 0)
66
67 client.track('click', {button: 'btn-trigger'})
68 await flush()
69
70 assert.strictEqual(fetchRequests.length, 1)
71 assert.strictEqual(fetchRequests[0].body.events.length, 6)
72 })
73
74 it('logs error on failed request', async () => {
75 fetchMock.mock.mockImplementation(async () => {
76 return {
77 ok: false,
78 status: 500,
79 text: async () => 'Internal Server Error',
80 }
81 })
82
83 client = new MetricsClient<TestEvents>({
84 trackingEndpoint: 'https://test.metrics.api',
85 })
86 client.track('click', {button: 'submit'})
87
88 mock.timers.tick(10_000)
89 await flush()
90
91 assert.strictEqual(fetchMock.mock.callCount(), 1)
92 assert.strictEqual(loggerErrorMock.mock.callCount(), 1)
93 const call = loggerErrorMock.mock.calls[0]
94 const arg = call.arguments[0] as {err: Error}
95 assert.ok(arg.err instanceof Error)
96 assert.strictEqual(call.arguments[1], 'Failed to send metrics')
97 })
98
99 it('handles fetch text() error gracefully', async () => {
100 fetchMock.mock.mockImplementation(async () => {
101 return {
102 ok: false,
103 status: 500,
104 text: async () => {
105 throw new Error('Failed to read response')
106 },
107 }
108 })
109
110 client = new MetricsClient<TestEvents>({
111 trackingEndpoint: 'https://test.metrics.api',
112 })
113 client.track('click', {button: 'submit'})
114
115 mock.timers.tick(10_000)
116 await flush()
117
118 assert.strictEqual(fetchMock.mock.callCount(), 1)
119 assert.strictEqual(loggerErrorMock.mock.callCount(), 1)
120 const call = loggerErrorMock.mock.calls[0]
121 const arg = call.arguments[0] as {err: Error}
122 assert.ok(arg.err instanceof Error)
123 assert.match(arg.err.message, /Unknown error/)
124 assert.strictEqual(call.arguments[1], 'Failed to send metrics')
125 })
126
127 it('flushes when stop() is called', async () => {
128 client = new MetricsClient<TestEvents>({
129 trackingEndpoint: 'https://test.metrics.api',
130 })
131 client.track('click', {button: 'submit'})
132
133 assert.strictEqual(fetchRequests.length, 0)
134
135 client.stop()
136 await flush()
137
138 assert.strictEqual(fetchRequests.length, 1)
139 assert.strictEqual(fetchRequests[0].body.events.length, 1)
140 assert.strictEqual(fetchRequests[0].body.events[0].event, 'click')
141 })
142
143 it('does not send if trackingEndpoint is not configured', async () => {
144 client = new MetricsClient<TestEvents>({})
145 client.track('click', {button: 'submit'})
146
147 mock.timers.tick(10_000)
148 await flush()
149
150 assert.strictEqual(fetchMock.mock.callCount(), 0)
151 })
152
153 it('start() is idempotent', async () => {
154 client = new MetricsClient<TestEvents>({
155 trackingEndpoint: 'https://test.metrics.api',
156 })
157
158 client.track('click', {button: 'submit'})
159 client.start()
160 client.start()
161
162 mock.timers.tick(10_000)
163 await flush()
164
165 assert.strictEqual(fetchRequests.length, 1)
166 })
167
168 it('does not flush if queue is empty', async () => {
169 client = new MetricsClient<TestEvents>({
170 trackingEndpoint: 'https://test.metrics.api',
171 })
172 client.start()
173
174 mock.timers.tick(10_000)
175 await flush()
176
177 assert.strictEqual(fetchMock.mock.callCount(), 0)
178 })
179})
180
181function flush() {
182 return new Promise(r => setImmediate(r))
183}