@simplybusiness/services 1.5.0 → 1.6.1

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.
@@ -1,9 +1,10 @@
1
+ export declare const getAirbrakeEnvironment: () => string;
1
2
  export type GetAirbrakeOptions = {
2
3
  projectId?: number;
3
4
  projectKey?: string;
4
5
  forceSend?: boolean;
5
6
  };
6
- export declare function getAirbrake(options: GetAirbrakeOptions): {
7
+ export declare function getAirbrake(options?: GetAirbrakeOptions): {
7
8
  notify: jest.Mock<any, any, any> | {
8
9
  (...data: any[]): void;
9
10
  (message?: any, ...optionalParams: any[]): void;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@simplybusiness/services",
3
3
  "license": "UNLICENSED",
4
- "version": "1.5.0",
4
+ "version": "1.6.1",
5
5
  "description": "Internal library for services",
6
6
  "repository": {
7
7
  "type": "git",
@@ -46,7 +46,7 @@
46
46
  "@eslint/compat": "^2.0.0",
47
47
  "@eslint/eslintrc": "^3.3.1",
48
48
  "@eslint/js": "^9.39.1",
49
- "@simplybusiness/eslint-config": "^1.1.0",
49
+ "@simplybusiness/eslint-config": "^1.1.1",
50
50
  "@swc/core": "^1.12.5",
51
51
  "@swc/jest": "^0.2.39",
52
52
  "@testing-library/dom": "^10.4.1",
@@ -79,7 +79,7 @@
79
79
  },
80
80
  "dependencies": {
81
81
  "@airbrake/browser": "^2.1.9",
82
- "@simplybusiness/mobius": "^6.9.5",
82
+ "@simplybusiness/mobius": "^6.9.7",
83
83
  "@snowplow/browser-tracker": "^3.24.6",
84
84
  "classnames": "^2.5.1"
85
85
  },
@@ -0,0 +1,127 @@
1
+ import { Notifier } from "@airbrake/browser";
2
+ import { getAirbrake, getAirbrakeEnvironment } from "./index";
3
+
4
+ jest.mock("@airbrake/browser");
5
+
6
+ const MockNotifier = Notifier as jest.MockedClass<typeof Notifier>;
7
+
8
+ describe("getAirbrake", () => {
9
+ const originalEnv = process.env.AIRBRAKE_ENV;
10
+
11
+ beforeEach(() => {
12
+ jest.clearAllMocks();
13
+ // Clear the notifier instances cache between tests
14
+ // by creating new notifiers with different projectIds
15
+ });
16
+
17
+ afterEach(() => {
18
+ if (originalEnv === undefined) {
19
+ delete process.env.AIRBRAKE_ENV;
20
+ } else {
21
+ process.env.AIRBRAKE_ENV = originalEnv;
22
+ }
23
+ });
24
+
25
+ describe("getAirbrakeEnvironment", () => {
26
+ it("returns AIRBRAKE_ENV when set", () => {
27
+ process.env.AIRBRAKE_ENV = "staging";
28
+ expect(getAirbrakeEnvironment()).toBe("staging");
29
+ });
30
+
31
+ it("returns 'development' when AIRBRAKE_ENV is not set", () => {
32
+ delete process.env.AIRBRAKE_ENV;
33
+ expect(getAirbrakeEnvironment()).toBe("development");
34
+ });
35
+ });
36
+
37
+ describe("environment-based routing", () => {
38
+ it("returns real notifier when AIRBRAKE_ENV is 'production'", () => {
39
+ process.env.AIRBRAKE_ENV = "production";
40
+ const result = getAirbrake({ projectId: 1001 });
41
+
42
+ expect(MockNotifier).toHaveBeenCalledWith(
43
+ expect.objectContaining({
44
+ projectId: 1001,
45
+ environment: "production",
46
+ }),
47
+ );
48
+ expect(result).toBeInstanceOf(Notifier);
49
+ });
50
+
51
+ it("returns real notifier when AIRBRAKE_ENV is 'staging'", () => {
52
+ process.env.AIRBRAKE_ENV = "staging";
53
+ const result = getAirbrake({ projectId: 1002 });
54
+
55
+ expect(MockNotifier).toHaveBeenCalledWith(
56
+ expect.objectContaining({
57
+ projectId: 1002,
58
+ environment: "staging",
59
+ }),
60
+ );
61
+ expect(result).toBeInstanceOf(Notifier);
62
+ });
63
+
64
+ it("returns fake notifier when AIRBRAKE_ENV is 'development'", () => {
65
+ process.env.AIRBRAKE_ENV = "development";
66
+ const result = getAirbrake({ projectId: 1003 });
67
+
68
+ expect(MockNotifier).not.toHaveBeenCalled();
69
+ // In test environment, notify is a jest.fn() to avoid console spam
70
+ expect(typeof result.notify).toBe("function");
71
+ expect(result).not.toBeInstanceOf(Notifier);
72
+ });
73
+
74
+ it("returns fake notifier when AIRBRAKE_ENV is 'integration'", () => {
75
+ process.env.AIRBRAKE_ENV = "integration";
76
+ const result = getAirbrake({ projectId: 1004 });
77
+
78
+ expect(MockNotifier).not.toHaveBeenCalled();
79
+ // In test environment, notify is a jest.fn() to avoid console spam
80
+ expect(typeof result.notify).toBe("function");
81
+ expect(result).not.toBeInstanceOf(Notifier);
82
+ });
83
+ });
84
+
85
+ describe("forceSend option", () => {
86
+ it("returns real notifier when forceSend is true regardless of environment", () => {
87
+ process.env.AIRBRAKE_ENV = "development";
88
+ const result = getAirbrake({ projectId: 1006, forceSend: true });
89
+
90
+ expect(MockNotifier).toHaveBeenCalledWith(
91
+ expect.objectContaining({
92
+ projectId: 1006,
93
+ environment: "development",
94
+ }),
95
+ );
96
+ expect(result).toBeInstanceOf(Notifier);
97
+ });
98
+ });
99
+
100
+ describe("multiple project support", () => {
101
+ it("returns separate notifiers for different projectIds", () => {
102
+ process.env.AIRBRAKE_ENV = "production";
103
+
104
+ const result1 = getAirbrake({ projectId: 2001, projectKey: "key1" });
105
+ const result2 = getAirbrake({ projectId: 2002, projectKey: "key2" });
106
+
107
+ expect(MockNotifier).toHaveBeenCalledTimes(2);
108
+ expect(MockNotifier).toHaveBeenCalledWith(
109
+ expect.objectContaining({ projectId: 2001 }),
110
+ );
111
+ expect(MockNotifier).toHaveBeenCalledWith(
112
+ expect.objectContaining({ projectId: 2002 }),
113
+ );
114
+ expect(result1).not.toBe(result2);
115
+ });
116
+
117
+ it("returns cached notifier for same projectId", () => {
118
+ process.env.AIRBRAKE_ENV = "production";
119
+
120
+ const result1 = getAirbrake({ projectId: 3001 });
121
+ const result2 = getAirbrake({ projectId: 3001 });
122
+
123
+ expect(MockNotifier).toHaveBeenCalledTimes(1);
124
+ expect(result1).toBe(result2);
125
+ });
126
+ });
127
+ });
@@ -5,27 +5,25 @@ import { Notifier } from "@airbrake/browser";
5
5
  const defaultProjectId = 512949;
6
6
  const defaultProjectKey = "4e25197d8faea61c10fbb97702200780";
7
7
 
8
- let notifierInstance: Notifier | null;
9
- const fakeNotify = () => {
10
- // Use jest.fn() for testing
11
- if (process.env.NODE_ENV === "test") {
12
- return jest.fn();
13
- }
14
-
15
- return console.error;
16
- };
8
+ // Map of project IDs to notifier instances to support multiple Airbrake accounts
9
+ const notifierInstances = new Map<number, Notifier>();
17
10
 
18
- // Fake signature which logs to console
11
+ // Fake notifier which logs to console instead of sending to Airbrake
19
12
  const fakeAirbrake = {
20
- notify: fakeNotify(),
13
+ notify:
14
+ globalThis.process?.env?.NODE_ENV === "test"
15
+ ? jest.fn()
16
+ : console.error.bind(console),
21
17
  };
22
18
 
23
- // We don't want to send real alerts on non-quote domains
24
- const shouldUseAirbrake = () => {
25
- const { location } = globalThis;
26
- const isTest = process.env.NODE_ENV === "test";
19
+ // Get Airbrake environment from AIRBRAKE_ENV, defaulting to "development"
20
+ export const getAirbrakeEnvironment = () =>
21
+ globalThis.process?.env?.AIRBRAKE_ENV ?? "development";
27
22
 
28
- return isTest || location?.hostname.indexOf("quote.") > -1;
23
+ // Only send real alerts in staging and production environments
24
+ const shouldUseAirbrake = () => {
25
+ const env = getAirbrakeEnvironment();
26
+ return env === "staging" || env === "production";
29
27
  };
30
28
 
31
29
  export type GetAirbrakeOptions = {
@@ -34,22 +32,30 @@ export type GetAirbrakeOptions = {
34
32
  forceSend?: boolean;
35
33
  };
36
34
 
37
- export function getAirbrake(options: GetAirbrakeOptions) {
38
- const { projectId, projectKey, forceSend } = options;
35
+ export function getAirbrake(options: GetAirbrakeOptions = {}) {
36
+ const {
37
+ projectId = defaultProjectId,
38
+ projectKey = defaultProjectKey,
39
+ forceSend,
40
+ } = options;
39
41
 
40
- if (!forceSend || !shouldUseAirbrake()) {
42
+ if (!forceSend && !shouldUseAirbrake()) {
41
43
  return fakeAirbrake;
42
44
  }
43
45
 
46
+ let notifierInstance = notifierInstances.get(projectId);
47
+
44
48
  if (!notifierInstance) {
45
49
  notifierInstance = new Notifier({
46
- projectId: projectId ?? defaultProjectId,
47
- projectKey: projectKey ?? defaultProjectKey,
48
- environment: process.env.NODE_ENV,
50
+ projectId,
51
+ projectKey,
52
+ environment: getAirbrakeEnvironment(),
49
53
  instrumentation: {
50
54
  onerror: false,
55
+ unhandledrejection: false,
51
56
  },
52
57
  });
58
+ notifierInstances.set(projectId, notifierInstance);
53
59
  }
54
60
 
55
61
  return notifierInstance;
@@ -124,6 +124,7 @@ describe("questionnaireEventDefinitions", () => {
124
124
  vertical: "shop",
125
125
  primary_detail: "test-answer",
126
126
  selected_type: "trade_selector",
127
+ location: "/",
127
128
  },
128
129
  });
129
130
  });
@@ -143,6 +144,134 @@ describe("questionnaireEventDefinitions", () => {
143
144
 
144
145
  expect("data" in result && result.data.vertical).toBe("Landlord");
145
146
  });
147
+
148
+ it("should include trade selection metadata for US page", () => {
149
+ const params = {
150
+ context: {
151
+ site: "simplybusiness_us",
152
+ vertical: "shop",
153
+ },
154
+ answer: "Plumber",
155
+ searchId: "search-123",
156
+ selectedListPosition: 2,
157
+ selectionMethod: "genAI",
158
+ };
159
+
160
+ const result = findEventByName("primaryDetailSelected").makePayload(
161
+ params,
162
+ );
163
+
164
+ expect(result).toEqual({
165
+ schema:
166
+ "iglu:com.simplybusiness/primary_detail_selected/jsonschema/1-4-0",
167
+ data: {
168
+ site: "simplybusiness_us",
169
+ vertical: "shop",
170
+ primary_detail: "Plumber",
171
+ selected_type: "trade_selector",
172
+ location: "/",
173
+ search_id: "search-123",
174
+ selected_list_position: "3",
175
+ selected_location: "trade_selector_vertical",
176
+ selection_method: "genAI",
177
+ business_unit: "simplybusiness_us",
178
+ },
179
+ });
180
+ });
181
+
182
+ it("should include search_id but not US-only fields for non-US page", () => {
183
+ const params = {
184
+ context: {
185
+ site: "simplybusiness_uk",
186
+ vertical: "shop",
187
+ },
188
+ answer: "Plumber",
189
+ searchId: "search-456",
190
+ selectedListPosition: 1,
191
+ selectionMethod: "legacy",
192
+ };
193
+
194
+ const result = findEventByName("primaryDetailSelected").makePayload(
195
+ params,
196
+ );
197
+
198
+ expect(result).toEqual({
199
+ schema:
200
+ "iglu:com.simplybusiness/primary_detail_selected/jsonschema/1-4-0",
201
+ data: {
202
+ site: "simplybusiness_uk",
203
+ vertical: "shop",
204
+ primary_detail: "Plumber",
205
+ selected_type: "trade_selector",
206
+ location: "/",
207
+ search_id: "search-456",
208
+ selection_method: "legacy",
209
+ },
210
+ });
211
+ });
212
+
213
+ it("should handle selectedListPosition of 0 (first item)", () => {
214
+ const params = {
215
+ context: {
216
+ site: "simplybusiness_us",
217
+ vertical: "shop",
218
+ },
219
+ answer: "Plumber",
220
+ searchId: "search-789",
221
+ selectedListPosition: 0,
222
+ selectionMethod: "genAI",
223
+ };
224
+
225
+ const result = findEventByName("primaryDetailSelected").makePayload(
226
+ params,
227
+ );
228
+
229
+ expect(result).toEqual({
230
+ schema:
231
+ "iglu:com.simplybusiness/primary_detail_selected/jsonschema/1-4-0",
232
+ data: {
233
+ site: "simplybusiness_us",
234
+ vertical: "shop",
235
+ primary_detail: "Plumber",
236
+ selected_type: "trade_selector",
237
+ location: "/",
238
+ search_id: "search-789",
239
+ selected_list_position: "1",
240
+ selected_location: "trade_selector_vertical",
241
+ selection_method: "genAI",
242
+ business_unit: "simplybusiness_us",
243
+ },
244
+ });
245
+ });
246
+
247
+ it("should include US-only fields with null position when no metadata", () => {
248
+ const params = {
249
+ context: {
250
+ site: "simplybusiness_us",
251
+ vertical: "shop",
252
+ },
253
+ answer: "Electrician",
254
+ };
255
+
256
+ const result = findEventByName("primaryDetailSelected").makePayload(
257
+ params,
258
+ );
259
+
260
+ expect(result).toEqual({
261
+ schema:
262
+ "iglu:com.simplybusiness/primary_detail_selected/jsonschema/1-4-0",
263
+ data: {
264
+ site: "simplybusiness_us",
265
+ vertical: "shop",
266
+ primary_detail: "Electrician",
267
+ selected_type: "trade_selector",
268
+ location: "/",
269
+ selected_list_position: null,
270
+ selected_location: "trade_selector_vertical",
271
+ business_unit: "simplybusiness_us",
272
+ },
273
+ });
274
+ });
146
275
  });
147
276
 
148
277
  describe("helpTextOpened", () => {
@@ -35,8 +35,16 @@ export const questionnaireEventDefinitions: EventDefinition[] = [
35
35
  name: "primaryDetailSelected",
36
36
  type: "unstructured",
37
37
  makePayload: params => {
38
- const { context, answer, vertical } = params as ParamsType;
38
+ const {
39
+ context,
40
+ answer,
41
+ vertical,
42
+ searchId,
43
+ selectedListPosition,
44
+ selectionMethod,
45
+ } = params as ParamsType;
39
46
  const { site } = context;
47
+ const isUSPage = site === "simplybusiness_us";
40
48
  let verticalName = vertical || context.vertical;
41
49
 
42
50
  if (verticalName.toLowerCase().indexOf("landlord") > -1) {
@@ -44,15 +52,37 @@ export const questionnaireEventDefinitions: EventDefinition[] = [
44
52
  answer === "residential" ? "Landlord" : "Commercial landlord";
45
53
  }
46
54
 
55
+ const data: Record<string, unknown> = {
56
+ site,
57
+ vertical: verticalName,
58
+ primary_detail: answer,
59
+ selected_type: "trade_selector",
60
+ location: window?.location?.pathname ?? "",
61
+ };
62
+
63
+ // Always include search_id if present
64
+ if (searchId) {
65
+ data.search_id = searchId;
66
+ }
67
+ // Always include selection_method if present
68
+ if (selectionMethod) {
69
+ data.selection_method = selectionMethod;
70
+ }
71
+
72
+ // US only fields
73
+ if (isUSPage) {
74
+ data.selected_list_position =
75
+ selectedListPosition !== undefined
76
+ ? (selectedListPosition + 1).toString()
77
+ : null;
78
+ data.selected_location = "trade_selector_vertical";
79
+ data.business_unit = site;
80
+ }
81
+
47
82
  return {
48
83
  schema:
49
84
  "iglu:com.simplybusiness/primary_detail_selected/jsonschema/1-4-0",
50
- data: {
51
- site,
52
- vertical: verticalName,
53
- primary_detail: answer,
54
- selected_type: "trade_selector",
55
- },
85
+ data,
56
86
  };
57
87
  },
58
88
  contexts: ["distributionChannelContext", "serviceChannelContext"],