From a4b0989a9fd8b3a4437a1187464dbdbac318a0b2 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 23 Mar 2026 17:27:15 -0500 Subject: [PATCH] Add metrics test --- bskylink/package.json | 4 +- bskylink/src/metrics.test.ts | 183 +++++++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 bskylink/src/metrics.test.ts diff --git a/bskylink/package.json b/bskylink/package.json index c57cb3cd89..d06247249d 100644 --- a/bskylink/package.json +++ b/bskylink/package.json @@ -4,7 +4,9 @@ "type": "module", "main": "index.ts", "scripts": { - "test": "./tests/infra/with-test-db.sh node --loader ts-node/esm --test ./tests/index.ts", + "test": "npm run test:unit && npm run test:e2e", + "test:e2e": "./tests/infra/with-test-db.sh node --loader ts-node/esm --test ./tests/index.ts", + "test:unit": "node --loader ts-node/esm --test ./src/*.test.ts", "build": "tsc" }, "dependencies": { diff --git a/bskylink/src/metrics.test.ts b/bskylink/src/metrics.test.ts new file mode 100644 index 0000000000..22b6e1505a --- /dev/null +++ b/bskylink/src/metrics.test.ts @@ -0,0 +1,183 @@ +import assert from 'node:assert' +import {afterEach, beforeEach, describe, it, mock} from 'node:test' + +import {httpLogger} from './logger.js' +import {MetricsClient} from './metrics.js' + +type TestEvents = { + click: {button: string} + view: {screen: string} +} + +describe('MetricsClient', () => { + let fetchMock: ReturnType + let fetchRequests: {body: any}[] + let client: MetricsClient + let loggerErrorMock: ReturnType + + beforeEach(() => { + mock.timers.enable({apis: ['setInterval', 'setTimeout']}) + fetchRequests = [] + fetchMock = mock.fn(async (_url: any, options: any) => { + const body = JSON.parse(options.body) + fetchRequests.push({body}) + return {ok: true, status: 200, text: async () => ''} + }) + ;(globalThis as any).fetch = fetchMock + loggerErrorMock = mock.fn() + httpLogger.error = loggerErrorMock as any + }) + + afterEach(() => { + client?.stop() + mock.timers.reset() + mock.restoreAll() + }) + + it('flushes events on interval', async () => { + client = new MetricsClient({ + trackingEndpoint: 'https://test.metrics.api', + }) + client.track('click', {button: 'submit'}) + client.track('view', {screen: 'home'}) + + assert.strictEqual(fetchRequests.length, 0) + + mock.timers.tick(10_000) + await flush() + + assert.strictEqual(fetchRequests.length, 1) + assert.strictEqual(fetchRequests[0].body.events.length, 2) + assert.strictEqual(fetchRequests[0].body.events[0].event, 'click') + assert.strictEqual(fetchRequests[0].body.events[1].event, 'view') + }) + + it('flushes when maxBatchSize is exceeded', async () => { + client = new MetricsClient({ + trackingEndpoint: 'https://test.metrics.api', + }) + client.maxBatchSize = 5 + + for (let i = 0; i < 5; i++) { + client.track('click', {button: `btn-${i}`}) + } + + assert.strictEqual(fetchRequests.length, 0) + + client.track('click', {button: 'btn-trigger'}) + await flush() + + assert.strictEqual(fetchRequests.length, 1) + assert.strictEqual(fetchRequests[0].body.events.length, 6) + }) + + it('logs error on failed request', async () => { + fetchMock.mock.mockImplementation(async () => { + return { + ok: false, + status: 500, + text: async () => 'Internal Server Error', + } + }) + + client = new MetricsClient({ + trackingEndpoint: 'https://test.metrics.api', + }) + client.track('click', {button: 'submit'}) + + mock.timers.tick(10_000) + await flush() + + assert.strictEqual(fetchMock.mock.callCount(), 1) + assert.strictEqual(loggerErrorMock.mock.callCount(), 1) + const call = loggerErrorMock.mock.calls[0] + const arg = call.arguments[0] as {err: Error} + assert.ok(arg.err instanceof Error) + assert.strictEqual(call.arguments[1], 'Failed to send metrics') + }) + + it('handles fetch text() error gracefully', async () => { + fetchMock.mock.mockImplementation(async () => { + return { + ok: false, + status: 500, + text: async () => { + throw new Error('Failed to read response') + }, + } + }) + + client = new MetricsClient({ + trackingEndpoint: 'https://test.metrics.api', + }) + client.track('click', {button: 'submit'}) + + mock.timers.tick(10_000) + await flush() + + assert.strictEqual(fetchMock.mock.callCount(), 1) + assert.strictEqual(loggerErrorMock.mock.callCount(), 1) + const call = loggerErrorMock.mock.calls[0] + const arg = call.arguments[0] as {err: Error} + assert.ok(arg.err instanceof Error) + assert.match(arg.err.message, /Unknown error/) + assert.strictEqual(call.arguments[1], 'Failed to send metrics') + }) + + it('flushes when stop() is called', async () => { + client = new MetricsClient({ + trackingEndpoint: 'https://test.metrics.api', + }) + client.track('click', {button: 'submit'}) + + assert.strictEqual(fetchRequests.length, 0) + + client.stop() + await flush() + + assert.strictEqual(fetchRequests.length, 1) + assert.strictEqual(fetchRequests[0].body.events.length, 1) + assert.strictEqual(fetchRequests[0].body.events[0].event, 'click') + }) + + it('does not send if trackingEndpoint is not configured', async () => { + client = new MetricsClient({}) + client.track('click', {button: 'submit'}) + + mock.timers.tick(10_000) + await flush() + + assert.strictEqual(fetchMock.mock.callCount(), 0) + }) + + it('start() is idempotent', async () => { + client = new MetricsClient({ + trackingEndpoint: 'https://test.metrics.api', + }) + + client.track('click', {button: 'submit'}) + client.start() + client.start() + + mock.timers.tick(10_000) + await flush() + + assert.strictEqual(fetchRequests.length, 1) + }) + + it('does not flush if queue is empty', async () => { + client = new MetricsClient({ + trackingEndpoint: 'https://test.metrics.api', + }) + client.start() + + mock.timers.tick(10_000) + await flush() + + assert.strictEqual(fetchMock.mock.callCount(), 0) + }) +}) + +function flush() { + return new Promise(r => setImmediate(r)) +}