Skip to content

Commit b51a94d

Browse files
committed
initial tests
1 parent efd0b4e commit b51a94d

File tree

3 files changed

+293
-20
lines changed

3 files changed

+293
-20
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
"prepack": "oclif manifest && oclif readme",
2727
"pretest": "npm run lint && npm run typecheck",
2828
"readme": "npm run ci:fix && npm run build && npm exec oclif readme",
29-
"test": "globstar -- node --import tsx --test \"test/**/*.test.ts\"",
29+
"test": "globstar -- node --import tsx --test --experimental-test-module-mocks \"test/**/*.test.ts\"",
3030
"test:e2e": "globstar -- node --import tsx --test \"e2e/**/*.test.ts\"",
3131
"typecheck": "tsc --noEmit"
3232
},

test/service/analytics.svc.test.ts

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
import assert from 'node:assert';
2+
import { type TestContext, afterEach, beforeEach, describe, it, mock } from 'node:test';
3+
import sinon from 'sinon';
4+
5+
describe('analytics.svc', () => {
6+
const mockAmplitude = {
7+
init: sinon.spy(),
8+
setOptOut: sinon.spy(),
9+
identify: sinon.spy(),
10+
track: sinon.spy(),
11+
Identify: sinon.stub().returns({}),
12+
Types: { LogLevel: { None: 0 } },
13+
};
14+
const mockNodeMachineId = { machineIdSync: sinon.stub().returns('test-machine-id') };
15+
let originalEnv: typeof process.env;
16+
17+
function setupModule(t: TestContext) {
18+
t.mock.module('@amplitude/analytics-node', { namedExports: mockAmplitude });
19+
t.mock.module('node-machine-id', { defaultExport: mockNodeMachineId });
20+
t.mock.module('../../src/config/constants.ts', {
21+
namedExports: { config: { analyticsUrl: 'https://test-analytics.com' } },
22+
});
23+
24+
return import(import.meta.resolve(`../../src/service/analytics.svc.ts?${Math.random().toFixed(3)}`));
25+
}
26+
27+
beforeEach(() => {
28+
originalEnv = { ...process.env };
29+
});
30+
31+
afterEach(() => {
32+
process.env = originalEnv;
33+
mockAmplitude.init.resetHistory();
34+
mockAmplitude.setOptOut.resetHistory();
35+
mockAmplitude.identify.resetHistory();
36+
mockAmplitude.track.resetHistory();
37+
mockNodeMachineId.machineIdSync.resetHistory();
38+
});
39+
40+
describe('initializeAnalytics', () => {
41+
it('should call amplitude init with correct parameters', async (t) => {
42+
const mod = await setupModule(t);
43+
mod.initializeAnalytics();
44+
45+
assert(mockAmplitude.init.calledOnce);
46+
const initCall = mockAmplitude.init.getCall(0);
47+
assert.strictEqual(initCall.args[0], '0');
48+
assert.deepStrictEqual(initCall.args[1], {
49+
flushQueueSize: 2,
50+
flushIntervalMillis: 250,
51+
logLevel: 0,
52+
serverUrl: 'https://test-analytics.com',
53+
});
54+
});
55+
56+
it('should call setOptOut with true when TRACKING_OPT_OUT is true', async (t) => {
57+
process.env.TRACKING_OPT_OUT = 'true';
58+
59+
const mod = await setupModule(t);
60+
mod.initializeAnalytics();
61+
62+
assert(mockAmplitude.setOptOut.calledOnce);
63+
assert.strictEqual(mockAmplitude.setOptOut.getCall(0).args[0], true);
64+
});
65+
66+
it('should call setOptOut with false when TRACKING_OPT_OUT is not true', async (t) => {
67+
process.env.TRACKING_OPT_OUT = 'false';
68+
69+
const mod = await setupModule(t);
70+
mod.initializeAnalytics();
71+
72+
assert(mockAmplitude.setOptOut.calledOnce);
73+
assert.strictEqual(mockAmplitude.setOptOut.getCall(0).args[0], false);
74+
});
75+
76+
it('should call identify with correct user properties', async (t) => {
77+
const mod = await setupModule(t);
78+
mod.initializeAnalytics();
79+
80+
assert(mockAmplitude.identify.calledOnce);
81+
const identifyCall = mockAmplitude.identify.getCall(0);
82+
83+
const userProperties = identifyCall.args[1];
84+
assert.strictEqual(userProperties.device_id, 'test-machine-id');
85+
assert(typeof userProperties.session_id === 'number');
86+
assert(typeof userProperties.platform === 'string');
87+
assert(typeof userProperties.os_name === 'string');
88+
assert(typeof userProperties.os_version === 'string');
89+
assert(typeof userProperties.app_version === 'string');
90+
});
91+
92+
it('should handle case when npm_package_version is undefined', async (t) => {
93+
process.env.npm_package_version = undefined;
94+
95+
const mod = await setupModule(t);
96+
mod.initializeAnalytics();
97+
98+
const identifyCall = mockAmplitude.identify.getCall(0);
99+
const userProperties = identifyCall.args[1];
100+
assert.strictEqual(userProperties.app_version, 'unknown');
101+
});
102+
});
103+
104+
describe('track', () => {
105+
it('should call amplitude track with event name and no properties when getProperties is undefined', async (t) => {
106+
const mod = await setupModule(t);
107+
mod.track('test-event');
108+
109+
assert(mockAmplitude.track.calledOnce);
110+
const trackCall = mockAmplitude.track.getCall(0);
111+
assert.strictEqual(trackCall.args[0], 'test-event');
112+
assert.strictEqual(trackCall.args[1], undefined);
113+
assert(typeof trackCall.args[2].device_id === 'string');
114+
assert(typeof trackCall.args[2].session_id === 'number');
115+
});
116+
117+
it('should call amplitude track with event name and properties when getProperties returns data', async (t) => {
118+
const mod = await setupModule(t);
119+
const testProperties = { scan_location: '/test/path', eol_true_count: 5 };
120+
const getProperties = sinon.stub().returns(testProperties);
121+
122+
mod.track('test-event', getProperties);
123+
124+
assert(mockAmplitude.track.calledOnce);
125+
const trackCall = mockAmplitude.track.getCall(0);
126+
assert.strictEqual(trackCall.args[0], 'test-event');
127+
assert.deepStrictEqual(trackCall.args[1], testProperties);
128+
assert(typeof trackCall.args[2].device_id === 'string');
129+
assert(typeof trackCall.args[2].session_id === 'number');
130+
});
131+
132+
it('should merge properties into analyticsContext when getProperties returns data', async (t) => {
133+
const mod = await setupModule(t);
134+
const firstProperties = { scan_location: '/test/path1', eol_true_count: 3 };
135+
const secondProperties = { scan_location: '/test/path2', eol_unknown_count: 2 };
136+
137+
mod.track('test-event-1', () => firstProperties);
138+
139+
const getSecondProperties = sinon.stub().callsFake((context) => {
140+
assert.strictEqual(context.scan_location, '/test/path1');
141+
assert.strictEqual(context.eol_true_count, 3);
142+
return secondProperties;
143+
});
144+
145+
mod.track('test-event-2', getSecondProperties);
146+
147+
assert(getSecondProperties.calledOnce);
148+
assert(mockAmplitude.track.calledTwice);
149+
});
150+
151+
it('should preserve existing analyticsContext when getProperties returns undefined', async (t) => {
152+
const mod = await setupModule(t);
153+
const initialProperties = { scan_location: '/test/path', eol_true_count: 5 };
154+
155+
mod.track('test-event-1', () => initialProperties);
156+
157+
const getUndefinedProperties = sinon.stub().callsFake((context) => {
158+
assert.strictEqual(context.scan_location, '/test/path');
159+
assert.strictEqual(context.eol_true_count, 5);
160+
return undefined;
161+
});
162+
163+
mod.track('test-event-2', getUndefinedProperties);
164+
165+
assert(getUndefinedProperties.calledOnce);
166+
assert(mockAmplitude.track.calledTwice);
167+
168+
// Second track call should have undefined properties
169+
const secondTrackCall = mockAmplitude.track.getCall(1);
170+
assert.strictEqual(secondTrackCall.args[1], undefined);
171+
});
172+
173+
it('should pass correct device_id and session_id to amplitude track', async (t) => {
174+
const mod = await setupModule(t);
175+
mod.track('test-event');
176+
177+
assert(mockAmplitude.track.calledOnce);
178+
const trackCall = mockAmplitude.track.getCall(0);
179+
const eventOptions = trackCall.args[2];
180+
181+
assert.strictEqual(eventOptions.device_id, 'test-machine-id');
182+
assert(typeof eventOptions.session_id === 'number');
183+
assert(eventOptions.session_id > 0);
184+
});
185+
});
186+
187+
describe('Module Initialization', () => {
188+
it('should initialize device_id using NodeMachineId.machineIdSync', async (t) => {
189+
await setupModule(t);
190+
191+
assert(mockNodeMachineId.machineIdSync.calledOnce);
192+
assert.strictEqual(mockNodeMachineId.machineIdSync.getCall(0).args[0], true);
193+
});
194+
195+
it('should initialize started_at as a Date object', async (t) => {
196+
const beforeImport = Date.now();
197+
const mod = await setupModule(t);
198+
const afterImport = Date.now();
199+
200+
mod.initializeAnalytics();
201+
202+
const identifyCall = mockAmplitude.identify.getCall(0);
203+
const sessionId = identifyCall.args[1].session_id;
204+
205+
assert(sessionId >= beforeImport);
206+
assert(sessionId <= afterImport);
207+
});
208+
209+
it('should initialize session_id as timestamp from started_at', async (t) => {
210+
const mod = await setupModule(t);
211+
mod.initializeAnalytics();
212+
213+
const identifyCall = mockAmplitude.identify.getCall(0);
214+
const sessionId = identifyCall.args[1].session_id;
215+
216+
// Session ID should be a valid timestamp
217+
assert(typeof sessionId === 'number');
218+
assert(sessionId > 0);
219+
assert(sessionId <= Date.now());
220+
});
221+
222+
it('should initialize defaultAnalyticsContext with correct locale', async (t) => {
223+
const mod = await setupModule(t);
224+
225+
const getProperties = sinon.stub().callsFake((context) => {
226+
assert(typeof context.locale === 'string');
227+
assert(context.locale.length > 0);
228+
return {};
229+
});
230+
231+
mod.track('test-event', getProperties);
232+
assert(getProperties.calledOnce);
233+
});
234+
235+
it('should initialize defaultAnalyticsContext with correct OS platform', async (t) => {
236+
const mod = await setupModule(t);
237+
238+
const getProperties = sinon.stub().callsFake((context) => {
239+
assert(typeof context.os_platform === 'string');
240+
assert(
241+
['darwin', 'linux', 'win32', 'freebsd', 'openbsd', 'android', 'aix', 'sunos'].includes(context.os_platform),
242+
);
243+
return {};
244+
});
245+
246+
mod.track('test-event', getProperties);
247+
assert(getProperties.calledOnce);
248+
});
249+
250+
it('should initialize defaultAnalyticsContext with CLI version from npm_package_version or unknown', async (t) => {
251+
const mod = await setupModule(t);
252+
253+
const getProperties = sinon.stub().callsFake((context) => {
254+
assert(typeof context.cli_version === 'string');
255+
assert(context.cli_version.length > 0);
256+
return {};
257+
});
258+
259+
mod.track('test-event', getProperties);
260+
assert(getProperties.calledOnce);
261+
});
262+
263+
it('should initialize analyticsContext equal to defaultAnalyticsContext', async (t) => {
264+
const mod = await setupModule(t);
265+
266+
const getProperties = sinon.stub().callsFake((context) => {
267+
assert(typeof context.locale === 'string');
268+
assert(typeof context.os_platform === 'string');
269+
assert(typeof context.os_release === 'string');
270+
assert(typeof context.cli_version === 'string');
271+
assert(context.started_at instanceof Date);
272+
return {};
273+
});
274+
275+
mod.track('test-event', getProperties);
276+
assert(getProperties.calledOnce);
277+
});
278+
});
279+
});

test/service/cdx.svc.test.ts

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,27 @@
11
import assert from 'node:assert';
2-
import { describe, it, mock } from 'node:test';
2+
import { type TestContext, describe, it, mock } from 'node:test';
3+
34
// Node <22 may not support mock.module; skip tests if unavailable
45
const hasMockModule = typeof (mock as unknown as { module?: unknown }).module === 'function';
56

67
describe('cdx.svc createSbom', () => {
7-
it('returns bomJson when cdxgen returns an object', { skip: !hasMockModule }, async () => {
8-
const bomJson = { bomFormat: 'CycloneDX', specVersion: '1.6', components: [] };
9-
await mock.module('@cyclonedx/cdxgen', {
10-
namedExports: {
11-
// biome-ignore lint/suspicious/noExplicitAny: test-time ESM mock
12-
createBom: async () => ({ bomJson }) as any,
13-
},
8+
function setupModule({ createBom, t }: { createBom: () => Promise<{ bomJson: unknown } | null>; t: TestContext }) {
9+
t.mock.module('@cyclonedx/cdxgen', {
10+
namedExports: { createBom },
1411
});
1512

16-
const mod = await import('../../src/service/cdx.svc.ts');
13+
return import(import.meta.resolve(`../../src/service/cdx.svc.ts?${Math.random().toFixed(3)}`));
14+
}
15+
16+
it('returns bomJson when cdxgen returns an object', { skip: !hasMockModule }, async (t) => {
17+
const bomJson = { bomFormat: 'CycloneDX', specVersion: '1.6', components: [] };
18+
const mod = await setupModule({ createBom: async () => ({ bomJson }), t });
1719
const res = await mod.createSbom('/tmp/project');
1820
assert.deepStrictEqual(res, bomJson);
19-
mock.restoreAll();
2021
});
2122

22-
it('throws when cdxgen returns a falsy value', { skip: !hasMockModule }, async () => {
23-
await mock.module('@cyclonedx/cdxgen', {
24-
namedExports: {
25-
createBom: async () => null,
26-
},
27-
});
28-
29-
const mod = await import('../../src/service/cdx.svc.ts');
23+
it('throws when cdxgen returns a falsy value', { skip: !hasMockModule }, async (t) => {
24+
const mod = await setupModule({ createBom: async () => null, t });
3025
await assert.rejects(() => mod.createSbom('/tmp/project'), /SBOM not generated/);
31-
mock.restoreAll();
3226
});
3327
});

0 commit comments

Comments
 (0)