@codaco/analytics 7.0.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,276 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { createAnalytics } from "../client";
3
+ import type { AnalyticsConfig } from "../types";
4
+
5
+ // Mock posthog-js
6
+ vi.mock("posthog-js", () => ({
7
+ default: {
8
+ init: vi.fn().mockReturnValue({
9
+ register: vi.fn(),
10
+ debug: vi.fn(),
11
+ }),
12
+ capture: vi.fn(),
13
+ isFeatureEnabled: vi.fn(),
14
+ getFeatureFlag: vi.fn(),
15
+ reloadFeatureFlags: vi.fn(),
16
+ identify: vi.fn(),
17
+ reset: vi.fn(),
18
+ },
19
+ }));
20
+
21
+ import posthog from "posthog-js";
22
+
23
+ describe("createAnalytics", () => {
24
+ beforeEach(() => {
25
+ vi.clearAllMocks();
26
+ });
27
+
28
+ const mockConfig: Required<AnalyticsConfig> = {
29
+ apiHost: "https://ph-relay.networkcanvas.com",
30
+ apiKey: "phc_test",
31
+ installationId: "test-install-123",
32
+ disabled: false,
33
+ debug: false,
34
+ posthogOptions: {},
35
+ };
36
+
37
+ describe("initialization", () => {
38
+ it("should initialize PostHog with correct config", () => {
39
+ createAnalytics(mockConfig);
40
+
41
+ expect(posthog.init).toHaveBeenCalledWith(
42
+ "phc_test",
43
+ expect.objectContaining({
44
+ api_host: "https://ph-relay.networkcanvas.com",
45
+ }),
46
+ );
47
+ });
48
+
49
+ it("should not initialize PostHog when disabled", () => {
50
+ const disabledConfig = { ...mockConfig, disabled: true };
51
+ const analytics = createAnalytics(disabledConfig);
52
+
53
+ expect(posthog.init).not.toHaveBeenCalled();
54
+ expect(analytics.isEnabled()).toBe(false);
55
+ });
56
+
57
+ it("should register installation ID as super property", () => {
58
+ const mockPosthogInstance = {
59
+ register: vi.fn(),
60
+ debug: vi.fn(),
61
+ };
62
+
63
+ vi.mocked(posthog.init).mockImplementation((_token, options) => {
64
+ options?.loaded?.(mockPosthogInstance as never);
65
+ return mockPosthogInstance as never;
66
+ });
67
+
68
+ createAnalytics(mockConfig);
69
+
70
+ expect(mockPosthogInstance.register).toHaveBeenCalledWith({
71
+ installation_id: "test-install-123",
72
+ });
73
+ });
74
+
75
+ it("should enable debug mode when configured", () => {
76
+ const mockPosthogInstance = {
77
+ register: vi.fn(),
78
+ debug: vi.fn(),
79
+ };
80
+
81
+ vi.mocked(posthog.init).mockImplementation((_token, options) => {
82
+ options?.loaded?.(mockPosthogInstance as never);
83
+ return mockPosthogInstance as never;
84
+ });
85
+
86
+ const debugConfig = { ...mockConfig, debug: true };
87
+ createAnalytics(debugConfig);
88
+
89
+ expect(mockPosthogInstance.debug).toHaveBeenCalled();
90
+ });
91
+ });
92
+
93
+ describe("trackEvent", () => {
94
+ it("should capture events with PostHog", () => {
95
+ const analytics = createAnalytics(mockConfig);
96
+
97
+ analytics.trackEvent("app_setup", {
98
+ metadata: { version: "1.0.0" },
99
+ });
100
+
101
+ expect(posthog.capture).toHaveBeenCalledWith("app_setup", {
102
+ metadata: { version: "1.0.0" },
103
+ version: "1.0.0",
104
+ });
105
+ });
106
+
107
+ it("should flatten metadata into properties", () => {
108
+ const analytics = createAnalytics(mockConfig);
109
+
110
+ analytics.trackEvent("protocol_installed", {
111
+ metadata: {
112
+ protocolId: "proto-123",
113
+ version: "2.0.0",
114
+ },
115
+ });
116
+
117
+ expect(posthog.capture).toHaveBeenCalledWith("protocol_installed", {
118
+ metadata: {
119
+ protocolId: "proto-123",
120
+ version: "2.0.0",
121
+ },
122
+ protocolId: "proto-123",
123
+ version: "2.0.0",
124
+ });
125
+ });
126
+
127
+ it("should not capture events when disabled", () => {
128
+ const disabledConfig = { ...mockConfig, disabled: true };
129
+ const analytics = createAnalytics(disabledConfig);
130
+
131
+ analytics.trackEvent("app_setup");
132
+
133
+ expect(posthog.capture).not.toHaveBeenCalled();
134
+ });
135
+
136
+ it("should handle errors gracefully", () => {
137
+ const analytics = createAnalytics(mockConfig);
138
+ vi.mocked(posthog.capture).mockImplementation(() => {
139
+ throw new Error("PostHog error");
140
+ });
141
+
142
+ // Should not throw
143
+ expect(() => analytics.trackEvent("app_setup")).not.toThrow();
144
+ });
145
+ });
146
+
147
+ describe("trackError", () => {
148
+ it("should capture errors with full details", () => {
149
+ const analytics = createAnalytics(mockConfig);
150
+ const error = new Error("Test error");
151
+ error.stack = "Error: Test error\n at test.ts:10";
152
+
153
+ analytics.trackError(error);
154
+
155
+ expect(posthog.capture).toHaveBeenCalledWith(
156
+ "error",
157
+ expect.objectContaining({
158
+ message: "Test error",
159
+ name: "Error",
160
+ stack: expect.stringContaining("Error: Test error"),
161
+ }),
162
+ );
163
+ });
164
+
165
+ it("should include additional properties", () => {
166
+ const analytics = createAnalytics(mockConfig);
167
+ const error = new Error("Test error");
168
+
169
+ analytics.trackError(error, {
170
+ metadata: { context: "test" },
171
+ });
172
+
173
+ expect(posthog.capture).toHaveBeenCalledWith(
174
+ "error",
175
+ expect.objectContaining({
176
+ message: "Test error",
177
+ metadata: { context: "test" },
178
+ context: "test",
179
+ }),
180
+ );
181
+ });
182
+
183
+ it("should not capture errors when disabled", () => {
184
+ const disabledConfig = { ...mockConfig, disabled: true };
185
+ const analytics = createAnalytics(disabledConfig);
186
+ const error = new Error("Test error");
187
+
188
+ analytics.trackError(error);
189
+
190
+ expect(posthog.capture).not.toHaveBeenCalled();
191
+ });
192
+ });
193
+
194
+ describe("feature flags", () => {
195
+ it("should check if feature is enabled", () => {
196
+ const analytics = createAnalytics(mockConfig);
197
+ vi.mocked(posthog.isFeatureEnabled).mockReturnValue(true);
198
+
199
+ const result = analytics.isFeatureEnabled("new-feature");
200
+
201
+ expect(result).toBe(true);
202
+ expect(posthog.isFeatureEnabled).toHaveBeenCalledWith("new-feature");
203
+ });
204
+
205
+ it("should get feature flag value", () => {
206
+ const analytics = createAnalytics(mockConfig);
207
+ vi.mocked(posthog.getFeatureFlag).mockReturnValue("variant-a");
208
+
209
+ const result = analytics.getFeatureFlag("experiment");
210
+
211
+ expect(result).toBe("variant-a");
212
+ expect(posthog.getFeatureFlag).toHaveBeenCalledWith("experiment");
213
+ });
214
+
215
+ it("should reload feature flags", () => {
216
+ const analytics = createAnalytics(mockConfig);
217
+
218
+ analytics.reloadFeatureFlags();
219
+
220
+ expect(posthog.reloadFeatureFlags).toHaveBeenCalled();
221
+ });
222
+
223
+ it("should return false for feature flags when disabled", () => {
224
+ const disabledConfig = { ...mockConfig, disabled: true };
225
+ const analytics = createAnalytics(disabledConfig);
226
+
227
+ expect(analytics.isFeatureEnabled("test")).toBe(false);
228
+ expect(analytics.getFeatureFlag("test")).toBeUndefined();
229
+ });
230
+ });
231
+
232
+ describe("user identification", () => {
233
+ it("should identify users", () => {
234
+ const analytics = createAnalytics(mockConfig);
235
+
236
+ analytics.identify("user-123", { email: "test@example.com" });
237
+
238
+ expect(posthog.identify).toHaveBeenCalledWith("user-123", {
239
+ email: "test@example.com",
240
+ });
241
+ });
242
+
243
+ it("should reset user identity", () => {
244
+ const analytics = createAnalytics(mockConfig);
245
+
246
+ analytics.reset();
247
+
248
+ expect(posthog.reset).toHaveBeenCalled();
249
+ });
250
+
251
+ it("should not identify when disabled", () => {
252
+ const disabledConfig = { ...mockConfig, disabled: true };
253
+ const analytics = createAnalytics(disabledConfig);
254
+
255
+ analytics.identify("user-123");
256
+
257
+ expect(posthog.identify).not.toHaveBeenCalled();
258
+ });
259
+ });
260
+
261
+ describe("utility methods", () => {
262
+ it("should return enabled status", () => {
263
+ const analytics = createAnalytics(mockConfig);
264
+ expect(analytics.isEnabled()).toBe(true);
265
+
266
+ const disabledConfig = { ...mockConfig, disabled: true };
267
+ const disabledAnalytics = createAnalytics(disabledConfig);
268
+ expect(disabledAnalytics.isEnabled()).toBe(false);
269
+ });
270
+
271
+ it("should return installation ID", () => {
272
+ const analytics = createAnalytics(mockConfig);
273
+ expect(analytics.getInstallationId()).toBe("test-install-123");
274
+ });
275
+ });
276
+ });
@@ -0,0 +1,207 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { defaultConfig, isDisabledByEnv, mergeConfig } from "../config";
3
+ import { ErrorPropertiesSchema, EventPropertiesSchema, type EventType, eventTypes, legacyEventTypeMap } from "../types";
4
+
5
+ describe("Event Types", () => {
6
+ it("should export all event types", () => {
7
+ expect(eventTypes).toEqual([
8
+ "app_setup",
9
+ "protocol_installed",
10
+ "interview_started",
11
+ "interview_completed",
12
+ "data_exported",
13
+ "error",
14
+ ]);
15
+ });
16
+
17
+ it("should map legacy event types to new snake_case types", () => {
18
+ expect(legacyEventTypeMap.AppSetup).toBe("app_setup");
19
+ expect(legacyEventTypeMap.ProtocolInstalled).toBe("protocol_installed");
20
+ expect(legacyEventTypeMap.InterviewStarted).toBe("interview_started");
21
+ expect(legacyEventTypeMap.InterviewCompleted).toBe("interview_completed");
22
+ expect(legacyEventTypeMap.DataExported).toBe("data_exported");
23
+ expect(legacyEventTypeMap.Error).toBe("error");
24
+ });
25
+ });
26
+
27
+ describe("Event Schemas", () => {
28
+ describe("EventPropertiesSchema", () => {
29
+ it("should validate event properties with metadata", () => {
30
+ const properties = {
31
+ metadata: {
32
+ version: "1.0.0",
33
+ userId: "123",
34
+ },
35
+ };
36
+
37
+ const result = EventPropertiesSchema.safeParse(properties);
38
+ expect(result.success).toBe(true);
39
+ });
40
+
41
+ it("should validate event properties without metadata", () => {
42
+ const properties = {};
43
+ const result = EventPropertiesSchema.safeParse(properties);
44
+ expect(result.success).toBe(true);
45
+ });
46
+
47
+ it("should allow arbitrary metadata keys and values", () => {
48
+ const properties = {
49
+ metadata: {
50
+ string: "value",
51
+ number: 42,
52
+ boolean: true,
53
+ nested: { key: "value" },
54
+ array: [1, 2, 3],
55
+ },
56
+ };
57
+
58
+ const result = EventPropertiesSchema.safeParse(properties);
59
+ expect(result.success).toBe(true);
60
+ });
61
+ });
62
+
63
+ describe("ErrorPropertiesSchema", () => {
64
+ it("should validate error properties with all fields", () => {
65
+ const errorProps = {
66
+ message: "Something went wrong",
67
+ name: "TestError",
68
+ stack: "Error: Something went wrong\n at test.ts:10",
69
+ cause: "Root cause",
70
+ metadata: {
71
+ context: "test",
72
+ },
73
+ };
74
+
75
+ const result = ErrorPropertiesSchema.safeParse(errorProps);
76
+ expect(result.success).toBe(true);
77
+ });
78
+
79
+ it("should validate error properties with required fields only", () => {
80
+ const errorProps = {
81
+ message: "Error message",
82
+ name: "Error",
83
+ };
84
+
85
+ const result = ErrorPropertiesSchema.safeParse(errorProps);
86
+ expect(result.success).toBe(true);
87
+ });
88
+
89
+ it("should reject error properties missing message", () => {
90
+ const errorProps = {
91
+ name: "Error",
92
+ };
93
+
94
+ const result = ErrorPropertiesSchema.safeParse(errorProps);
95
+ expect(result.success).toBe(false);
96
+ });
97
+
98
+ it("should reject error properties missing name", () => {
99
+ const errorProps = {
100
+ message: "Error message",
101
+ };
102
+
103
+ const result = ErrorPropertiesSchema.safeParse(errorProps);
104
+ expect(result.success).toBe(false);
105
+ });
106
+ });
107
+ });
108
+
109
+ describe("Configuration", () => {
110
+ describe("defaultConfig", () => {
111
+ it("should have correct default values", () => {
112
+ expect(defaultConfig.apiHost).toBe("https://ph-relay.networkcanvas.com");
113
+ expect(defaultConfig.posthogOptions?.disable_session_recording).toBe(true);
114
+ expect(defaultConfig.posthogOptions?.autocapture).toBe(false);
115
+ expect(defaultConfig.posthogOptions?.capture_pageview).toBe(false);
116
+ expect(defaultConfig.posthogOptions?.advanced_disable_feature_flags).toBe(false);
117
+ });
118
+ });
119
+
120
+ describe("mergeConfig", () => {
121
+ it("should merge user config with defaults", () => {
122
+ const userConfig = {
123
+ installationId: "test-123",
124
+ apiKey: "phc_test",
125
+ };
126
+
127
+ const merged = mergeConfig(userConfig);
128
+
129
+ expect(merged.installationId).toBe("test-123");
130
+ expect(merged.apiKey).toBe("phc_test");
131
+ expect(merged.apiHost).toBe("https://ph-relay.networkcanvas.com");
132
+ expect(merged.disabled).toBe(false);
133
+ });
134
+
135
+ it("should allow overriding default values", () => {
136
+ const userConfig = {
137
+ installationId: "test-123",
138
+ apiKey: "phc_test",
139
+ apiHost: "https://custom.posthog.com",
140
+ disabled: true,
141
+ debug: true,
142
+ };
143
+
144
+ const merged = mergeConfig(userConfig);
145
+
146
+ expect(merged.apiHost).toBe("https://custom.posthog.com");
147
+ expect(merged.disabled).toBe(true);
148
+ expect(merged.debug).toBe(true);
149
+ });
150
+
151
+ it("should use placeholder API key when none provided (proxy mode)", () => {
152
+ const userConfig = {
153
+ installationId: "test-123",
154
+ };
155
+
156
+ const merged = mergeConfig(userConfig);
157
+
158
+ // When using proxy mode, a placeholder key is automatically provided
159
+ expect(merged.apiKey).toBe("phc_proxy_mode_placeholder");
160
+ expect(merged.installationId).toBe("test-123");
161
+ });
162
+
163
+ it("should merge PostHog options", () => {
164
+ const userConfig = {
165
+ installationId: "test-123",
166
+ apiKey: "phc_test",
167
+ posthogOptions: {
168
+ autocapture: true,
169
+ capture_pageview: true,
170
+ },
171
+ };
172
+
173
+ const merged = mergeConfig(userConfig);
174
+
175
+ expect(merged.posthogOptions.autocapture).toBe(true);
176
+ expect(merged.posthogOptions.capture_pageview).toBe(true);
177
+ // Should still have default values for other options
178
+ expect(merged.posthogOptions.disable_session_recording).toBe(true);
179
+ });
180
+ });
181
+ });
182
+
183
+ describe("Environment Variables", () => {
184
+ describe("isDisabledByEnv", () => {
185
+ it("should return a boolean value", () => {
186
+ // This test depends on the actual env vars
187
+ // In a real test, you would mock process.env
188
+ const result = isDisabledByEnv();
189
+ expect(typeof result).toBe("boolean");
190
+ });
191
+ });
192
+ });
193
+
194
+ describe("Type Exports", () => {
195
+ it("should export EventType correctly", () => {
196
+ const validTypes: EventType[] = [
197
+ "app_setup",
198
+ "protocol_installed",
199
+ "interview_started",
200
+ "interview_completed",
201
+ "data_exported",
202
+ "error",
203
+ ];
204
+
205
+ expect(validTypes).toEqual(eventTypes);
206
+ });
207
+ });
@@ -0,0 +1,105 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { ensureError } from "../utils";
3
+
4
+ describe("ensureError", () => {
5
+ it("should return the error if value is already an Error instance", () => {
6
+ const error = new Error("Test error");
7
+ const result = ensureError(error);
8
+ expect(result).toBe(error);
9
+ expect(result.message).toBe("Test error");
10
+ });
11
+
12
+ it("should return a default error when no value is thrown", () => {
13
+ const result = ensureError(null);
14
+ expect(result).toBeInstanceOf(Error);
15
+ expect(result.message).toBe("No value was thrown");
16
+ });
17
+
18
+ it("should return a default error when undefined is thrown", () => {
19
+ const result = ensureError(undefined);
20
+ expect(result).toBeInstanceOf(Error);
21
+ expect(result.message).toBe("No value was thrown");
22
+ });
23
+
24
+ it("should wrap a string in an Error", () => {
25
+ const result = ensureError("Something went wrong");
26
+ expect(result).toBeInstanceOf(Error);
27
+ expect(result.message).toContain("This value was thrown as is, not through an Error");
28
+ expect(result.message).toContain('"Something went wrong"');
29
+ });
30
+
31
+ it("should wrap a number in an Error", () => {
32
+ const result = ensureError(42);
33
+ expect(result).toBeInstanceOf(Error);
34
+ expect(result.message).toContain("This value was thrown as is, not through an Error");
35
+ expect(result.message).toContain("42");
36
+ });
37
+
38
+ it("should wrap an object in an Error with stringified value", () => {
39
+ const obj = { code: "ERR_001", details: "Something failed" };
40
+ const result = ensureError(obj);
41
+ expect(result).toBeInstanceOf(Error);
42
+ expect(result.message).toContain("This value was thrown as is, not through an Error");
43
+ expect(result.message).toContain(JSON.stringify(obj));
44
+ });
45
+
46
+ it("should wrap an array in an Error with stringified value", () => {
47
+ const arr = ["error1", "error2"];
48
+ const result = ensureError(arr);
49
+ expect(result).toBeInstanceOf(Error);
50
+ expect(result.message).toContain("This value was thrown as is, not through an Error");
51
+ expect(result.message).toContain(JSON.stringify(arr));
52
+ });
53
+
54
+ it("should handle circular references gracefully", () => {
55
+ const circular: { prop?: unknown } = {};
56
+ circular.prop = circular;
57
+
58
+ // Mock console.error to avoid test output pollution
59
+ const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
60
+
61
+ const result = ensureError(circular);
62
+ expect(result).toBeInstanceOf(Error);
63
+ expect(result.message).toBe(
64
+ "This value was thrown as is, not through an Error: [Unable to stringify the thrown value]",
65
+ );
66
+ expect(consoleErrorSpy).toHaveBeenCalled();
67
+
68
+ consoleErrorSpy.mockRestore();
69
+ });
70
+
71
+ it("should handle custom error classes that inherit from Error", () => {
72
+ class CustomError extends Error {
73
+ code: string;
74
+ constructor(message: string, code: string) {
75
+ super(message);
76
+ this.code = code;
77
+ this.name = "CustomError";
78
+ }
79
+ }
80
+
81
+ const customError = new CustomError("Custom error message", "CUSTOM_001");
82
+ const result = ensureError(customError);
83
+ expect(result).toBe(customError);
84
+ expect(result.message).toBe("Custom error message");
85
+ });
86
+
87
+ it("should handle boolean values", () => {
88
+ const result = ensureError(true);
89
+ expect(result).toBeInstanceOf(Error);
90
+ expect(result.message).toContain("This value was thrown as is, not through an Error");
91
+ expect(result.message).toContain("true");
92
+ });
93
+
94
+ it("should handle empty string", () => {
95
+ const result = ensureError("");
96
+ expect(result).toBeInstanceOf(Error);
97
+ expect(result.message).toBe("No value was thrown");
98
+ });
99
+
100
+ it("should handle zero as a value", () => {
101
+ const result = ensureError(0);
102
+ expect(result).toBeInstanceOf(Error);
103
+ expect(result.message).toBe("No value was thrown");
104
+ });
105
+ });