@oneuptime/common 12.0.10 → 12.0.11

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.
Files changed (81) hide show
  1. package/Models/DatabaseModels/NetworkDevice.ts +44 -0
  2. package/Models/DatabaseModels/NetworkDeviceLink.ts +116 -0
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.ts +52 -0
  4. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  5. package/Server/Services/NetworkDeviceLinkService.ts +338 -2
  6. package/Server/Types/Billing/MeteredPlan/AllMeteredPlans.ts +19 -5
  7. package/Server/Types/Database/JSONColumnQuery.ts +42 -4
  8. package/Server/Utils/Telemetry/InventoryEntityRegistry.ts +186 -38
  9. package/Tests/App/Dashboard/AdminNotificationRulesPage.test.tsx +420 -112
  10. package/Tests/App/Dashboard/PayAsYouGoConsentGate.test.tsx +543 -0
  11. package/Tests/App/Dashboard/PayAsYouGoFreePlan.test.ts +125 -0
  12. package/Tests/App/Dashboard/PayAsYouGoNotices.test.tsx +612 -0
  13. package/Tests/App/Dashboard/SloWidgetChartRendering.test.tsx +536 -0
  14. package/Tests/Models/AnalyticsModels/CommonModel.test.ts +312 -0
  15. package/Tests/Server/Infrastructure/Postgres/DeviceRoleAndDeclaredLinkParentMigration.test.ts +250 -0
  16. package/Tests/Server/Infrastructure/Postgres/EpisodeMemberNotifyIndexesMigration.test.ts +46 -18
  17. package/Tests/Server/Services/NetworkDeviceLinkParentValidation.test.ts +1043 -0
  18. package/Tests/Server/Types/Billing/PayAsYouGoMeteredPricing.test.ts +125 -0
  19. package/Tests/Server/Types/Database/JSONColumnQuery.test.ts +508 -0
  20. package/Tests/Server/Utils/Telemetry/InventoryEntityRegistry.test.ts +668 -0
  21. package/Tests/Types/Billing/PayAsYouGoPricing.test.ts +64 -0
  22. package/Tests/Types/CustomField/CustomFieldTypeCorrespondence.test.ts +107 -0
  23. package/Tests/Types/Monitor/MonitorType.test.ts +62 -0
  24. package/Tests/UI/Components/Charts/ChartXAxisDataKey.test.tsx +256 -0
  25. package/Tests/UI/Components/Charts/DataPointUtil.test.ts +323 -0
  26. package/Tests/UI/Components/ModelTable/CustomFieldColumns.test.tsx +349 -1
  27. package/Tests/Utils/Monitor/NetworkDeviceRoleUtil.test.ts +502 -0
  28. package/Tests/Utils/Monitor/NetworkTopologyDeclaredHierarchy.test.ts +759 -0
  29. package/Tests/Utils/Schema/BaseSchema.test.ts +651 -0
  30. package/Types/Billing/PayAsYouGoPricing.ts +47 -0
  31. package/Types/CustomField/CustomFieldType.ts +28 -0
  32. package/Types/Monitor/MonitorType.ts +11 -0
  33. package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +13 -0
  34. package/UI/Components/Charts/Area/AreaChart.tsx +4 -2
  35. package/UI/Components/Charts/Bar/BarChart.tsx +4 -2
  36. package/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.ts +13 -0
  37. package/UI/Components/Charts/Line/LineChart.tsx +4 -2
  38. package/UI/Components/Charts/Types/XAxis/XAxis.ts +6 -0
  39. package/UI/Components/Charts/Utils/DataPoint.ts +15 -12
  40. package/UI/Components/ModelTable/CustomFieldColumns.tsx +55 -0
  41. package/Utils/Monitor/NetworkDeviceRoleUtil.ts +45 -0
  42. package/Utils/Monitor/NetworkTopologyUtil.ts +42 -2
  43. package/build/dist/Models/DatabaseModels/NetworkDevice.js +45 -0
  44. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  45. package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js +115 -0
  46. package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js.map +1 -1
  47. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.js +33 -0
  48. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.js.map +1 -0
  49. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  50. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  51. package/build/dist/Server/Services/NetworkDeviceLinkService.js +253 -1
  52. package/build/dist/Server/Services/NetworkDeviceLinkService.js.map +1 -1
  53. package/build/dist/Server/Types/Billing/MeteredPlan/AllMeteredPlans.js +12 -5
  54. package/build/dist/Server/Types/Billing/MeteredPlan/AllMeteredPlans.js.map +1 -1
  55. package/build/dist/Server/Types/Database/JSONColumnQuery.js +40 -4
  56. package/build/dist/Server/Types/Database/JSONColumnQuery.js.map +1 -1
  57. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js +128 -35
  58. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js.map +1 -1
  59. package/build/dist/Types/Billing/PayAsYouGoPricing.js +41 -0
  60. package/build/dist/Types/Billing/PayAsYouGoPricing.js.map +1 -0
  61. package/build/dist/Types/CustomField/CustomFieldType.js +26 -0
  62. package/build/dist/Types/CustomField/CustomFieldType.js.map +1 -1
  63. package/build/dist/Types/Monitor/MonitorType.js +10 -0
  64. package/build/dist/Types/Monitor/MonitorType.js.map +1 -1
  65. package/build/dist/UI/Components/Charts/Area/AreaChart.js +2 -1
  66. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  67. package/build/dist/UI/Components/Charts/Bar/BarChart.js +2 -1
  68. package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
  69. package/build/dist/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.js +12 -0
  70. package/build/dist/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.js.map +1 -1
  71. package/build/dist/UI/Components/Charts/Line/LineChart.js +2 -1
  72. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  73. package/build/dist/UI/Components/Charts/Utils/DataPoint.js +17 -10
  74. package/build/dist/UI/Components/Charts/Utils/DataPoint.js.map +1 -1
  75. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js +42 -0
  76. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -1
  77. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js +37 -0
  78. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js.map +1 -1
  79. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +25 -3
  80. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  81. package/package.json +1 -1
@@ -0,0 +1,612 @@
1
+ import "@testing-library/jest-dom";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import * as React from "react";
5
+ import { beforeEach, describe, expect, it } from "@jest/globals";
6
+ import { PlanType } from "../../../Types/Billing/SubscriptionPlan";
7
+ import MonitorType from "../../../Types/Monitor/MonitorType";
8
+ import Monitor from "../../../Models/DatabaseModels/Monitor";
9
+ import TelemetryIngestionKey from "../../../Models/DatabaseModels/TelemetryIngestionKey";
10
+ import FormFieldSchemaType from "../../../UI/Components/Forms/Types/FormFieldSchemaType";
11
+ import FormValues from "../../../UI/Components/Forms/Types/FormValues";
12
+ import { ModelField } from "../../../UI/Components/Forms/ModelForm";
13
+ import ProjectUtil from "../../../UI/Utils/Project";
14
+ import getJestMockFunction, { MockFunction } from "../../MockType";
15
+ import { getJestSpyOn } from "../../Spy";
16
+
17
+ /*
18
+ * The pay-as-you-go notices are the only warning a Free plan user gets before
19
+ * a charge starts. They have to appear for exactly one audience - Free plan on
20
+ * a billed deployment - and they have to quote the real rate.
21
+ */
22
+
23
+ interface MutableConfig {
24
+ billingEnabled: boolean;
25
+ }
26
+
27
+ const config: MutableConfig = { billingEnabled: true };
28
+
29
+ (
30
+ globalThis as unknown as { __payAsYouGoNoticeConfig: MutableConfig }
31
+ ).__payAsYouGoNoticeConfig = config;
32
+
33
+ jest.mock("../../../UI/Config", () => {
34
+ const mocked: Record<string, unknown> = {
35
+ ...jest.requireActual("../../../UI/Config"),
36
+ };
37
+
38
+ Object.defineProperty(mocked, "BILLING_ENABLED", {
39
+ get: (): boolean => {
40
+ return Boolean(
41
+ (
42
+ globalThis as unknown as {
43
+ __payAsYouGoNoticeConfig: MutableConfig | undefined;
44
+ }
45
+ ).__payAsYouGoNoticeConfig?.billingEnabled,
46
+ );
47
+ },
48
+ });
49
+
50
+ return mocked;
51
+ });
52
+
53
+ // Imported after the mock so the components read the switchable flag.
54
+ import {
55
+ ACTIVE_MONITOR_PRICE_TEXT,
56
+ MONITOR_CONSENT_ERROR,
57
+ MONITOR_CONSENT_FIELD_KEY,
58
+ MonitorBatchPayAsYouGoConsent,
59
+ MonitorPayAsYouGoCard,
60
+ SESSION_REPLAY_PRICE_PER_GB_TEXT,
61
+ TELEMETRY_CONSENT_ERROR,
62
+ TELEMETRY_CONSENT_FIELD_KEY,
63
+ TELEMETRY_PRICE_PER_GB_TEXT,
64
+ TelemetryPayAsYouGoCard,
65
+ getMonitorBatchPriceSentence,
66
+ getMonitorPayAsYouGoFormFields,
67
+ getTelemetryPayAsYouGoFormFields,
68
+ isMonitorBatchConsentRequired,
69
+ validateMonitorConsent,
70
+ validateTelemetryConsent,
71
+ } from "../../../../App/FeatureSet/Dashboard/src/Components/Billing/PayAsYouGo";
72
+
73
+ type SetPlanFunction = (plan: PlanType | null) => void;
74
+
75
+ const setPlan: SetPlanFunction = (plan: PlanType | null): void => {
76
+ getJestSpyOn(ProjectUtil, "getCurrentPlan").mockReturnValue(plan);
77
+ };
78
+
79
+ describe("Pay as you go notices", () => {
80
+ beforeEach(() => {
81
+ jest.restoreAllMocks();
82
+ config.billingEnabled = true;
83
+ setPlan(PlanType.Free);
84
+ });
85
+
86
+ describe("TelemetryPayAsYouGoCard", () => {
87
+ it("tells a Free plan project that telemetry is pay as you go, and what it costs", () => {
88
+ render(<TelemetryPayAsYouGoCard />);
89
+
90
+ expect(
91
+ screen.getByText("Telemetry is a pay as you go feature"),
92
+ ).toBeInTheDocument();
93
+
94
+ const card: HTMLElement = screen.getByTestId(
95
+ "telemetry-pay-as-you-go-card",
96
+ );
97
+
98
+ expect(card).toHaveTextContent("$0.10");
99
+ expect(card).toHaveTextContent("per GB ingested (15 day retention)");
100
+ expect(card).toHaveTextContent("Pay as you go");
101
+ expect(card).toHaveTextContent(
102
+ "$0.10 per GB ingested, with 15 day retention",
103
+ );
104
+ });
105
+
106
+ it("also quotes the session replay rate, which rides on the same key at 20x", () => {
107
+ /*
108
+ * Session replay is metered at $2.00/GB through the same ingestion key.
109
+ * A card that quotes only $0.10 invites a user to plan around a bill
110
+ * twenty times smaller than the one they will get.
111
+ */
112
+ render(<TelemetryPayAsYouGoCard />);
113
+
114
+ const card: HTMLElement = screen.getByTestId(
115
+ "telemetry-pay-as-you-go-card",
116
+ );
117
+
118
+ expect(card).toHaveTextContent("Session replay recordings are billed at");
119
+ expect(card).toHaveTextContent("$2 per GB, with 15 day retention");
120
+ expect(card).toHaveTextContent("Starting at");
121
+ });
122
+
123
+ it("says the charge stops when the key is deleted, so the notice is not just a threat", () => {
124
+ render(<TelemetryPayAsYouGoCard />);
125
+
126
+ expect(
127
+ screen.getByTestId("telemetry-pay-as-you-go-card"),
128
+ ).toHaveTextContent("delete the key, and the charge stops");
129
+ });
130
+
131
+ it("links out to the public pricing page", async () => {
132
+ const open: jest.SpyInstance<any, any> = getJestSpyOn(window, "open");
133
+ open.mockImplementation(() => {
134
+ return null;
135
+ });
136
+
137
+ render(<TelemetryPayAsYouGoCard />);
138
+
139
+ await userEvent.click(screen.getByText("View pricing"));
140
+
141
+ expect(open).toHaveBeenCalledWith(
142
+ "https://oneuptime.com/pricing",
143
+ "_blank",
144
+ );
145
+ });
146
+
147
+ it.each([PlanType.Growth, PlanType.Scale, PlanType.Enterprise])(
148
+ "renders nothing on the %s plan",
149
+ (plan: PlanType) => {
150
+ setPlan(plan);
151
+
152
+ const { container }: { container: HTMLElement } = render(
153
+ <TelemetryPayAsYouGoCard />,
154
+ );
155
+
156
+ expect(container).toBeEmptyDOMElement();
157
+ },
158
+ );
159
+
160
+ it("renders nothing on a self-hosted install", () => {
161
+ config.billingEnabled = false;
162
+
163
+ const { container }: { container: HTMLElement } = render(
164
+ <TelemetryPayAsYouGoCard />,
165
+ );
166
+
167
+ expect(container).toBeEmptyDOMElement();
168
+ });
169
+ });
170
+
171
+ describe("MonitorPayAsYouGoCard", () => {
172
+ it("tells a Free plan project every monitor except Manual is billed, and at what rate", () => {
173
+ render(<MonitorPayAsYouGoCard />);
174
+
175
+ expect(
176
+ screen.getByText("Monitors are a pay as you go feature"),
177
+ ).toBeInTheDocument();
178
+
179
+ const card: HTMLElement = screen.getByTestId(
180
+ "monitor-pay-as-you-go-card",
181
+ );
182
+
183
+ expect(card).toHaveTextContent("$1");
184
+ expect(card).toHaveTextContent("per monitor per month");
185
+ expect(card).toHaveTextContent(
186
+ "Every monitor type except Manual is an active monitor",
187
+ );
188
+ expect(card).toHaveTextContent(
189
+ "Manual monitors are always free, and unlimited.",
190
+ );
191
+ });
192
+
193
+ it.each([PlanType.Growth, PlanType.Scale, PlanType.Enterprise])(
194
+ "renders nothing on the %s plan",
195
+ (plan: PlanType) => {
196
+ setPlan(plan);
197
+
198
+ const { container }: { container: HTMLElement } = render(
199
+ <MonitorPayAsYouGoCard />,
200
+ );
201
+
202
+ expect(container).toBeEmptyDOMElement();
203
+ },
204
+ );
205
+
206
+ it("renders nothing on a self-hosted install", () => {
207
+ config.billingEnabled = false;
208
+
209
+ const { container }: { container: HTMLElement } = render(
210
+ <MonitorPayAsYouGoCard />,
211
+ );
212
+
213
+ expect(container).toBeEmptyDOMElement();
214
+ });
215
+
216
+ it("renders nothing when the plan is not known yet", () => {
217
+ setPlan(null);
218
+
219
+ const { container }: { container: HTMLElement } = render(
220
+ <MonitorPayAsYouGoCard />,
221
+ );
222
+
223
+ expect(container).toBeEmptyDOMElement();
224
+ });
225
+ });
226
+
227
+ describe("quoted rates match the pricing constants", () => {
228
+ it("uses the advertised figures verbatim", () => {
229
+ expect(TELEMETRY_PRICE_PER_GB_TEXT).toBe("$0.10");
230
+ expect(SESSION_REPLAY_PRICE_PER_GB_TEXT).toBe("$2");
231
+ expect(ACTIVE_MONITOR_PRICE_TEXT).toBe("$1");
232
+ expect(MONITOR_CONSENT_ERROR).toContain("$1");
233
+ });
234
+
235
+ it("does not put a single per-GB rate in the telemetry consent error", () => {
236
+ /*
237
+ * Two different per-GB rates travel on one ingestion key, so any single
238
+ * figure in a message about "telemetry" is wrong for one of them. The
239
+ * error stays rate-free and the rates live next to the checkbox.
240
+ */
241
+ expect(TELEMETRY_CONSENT_ERROR).not.toContain("$");
242
+ expect(TELEMETRY_CONSENT_ERROR).toContain("billed as you use it");
243
+ });
244
+ });
245
+
246
+ describe("getTelemetryPayAsYouGoFormFields", () => {
247
+ it("adds a notice and a consent checkbox on the Free plan", () => {
248
+ const fields: Array<ModelField<TelemetryIngestionKey>> =
249
+ getTelemetryPayAsYouGoFormFields();
250
+
251
+ expect(fields).toHaveLength(2);
252
+ expect(fields[0]?.fieldType).toBe(FormFieldSchemaType.CustomComponent);
253
+ expect(fields[1]?.fieldType).toBe(FormFieldSchemaType.Checkbox);
254
+ });
255
+
256
+ it("renders the modal notice with the rate and a pricing link", () => {
257
+ const noticeField: ModelField<TelemetryIngestionKey> =
258
+ getTelemetryPayAsYouGoFormFields()[0]!;
259
+
260
+ render(
261
+ <div>
262
+ {noticeField.getCustomElement!(
263
+ {} as FormValues<TelemetryIngestionKey>,
264
+ {},
265
+ )}
266
+ </div>,
267
+ );
268
+
269
+ expect(
270
+ screen.getByText("Telemetry is a pay as you go feature"),
271
+ ).toBeInTheDocument();
272
+ expect(
273
+ screen.getByText("See pay as you go pricing").closest("a"),
274
+ ).toHaveAttribute("href", "https://oneuptime.com/pricing");
275
+ });
276
+
277
+ it("names both per-GB rates in the modal notice and on the consent box", () => {
278
+ const [noticeField, consentField]: Array<
279
+ ModelField<TelemetryIngestionKey>
280
+ > = getTelemetryPayAsYouGoFormFields() as [
281
+ ModelField<TelemetryIngestionKey>,
282
+ ModelField<TelemetryIngestionKey>,
283
+ ];
284
+
285
+ const { container }: { container: HTMLElement } = render(
286
+ <div>
287
+ {noticeField.getCustomElement!(
288
+ {} as FormValues<TelemetryIngestionKey>,
289
+ {},
290
+ )}
291
+ </div>,
292
+ );
293
+
294
+ expect(container.textContent).toContain("$0.10 per GB ingested");
295
+ expect(container.textContent).toContain("$2 per GB");
296
+
297
+ /*
298
+ * The title is the sentence the user affirms by ticking, so it must not
299
+ * carry a rate that is wrong for half the data the key accepts.
300
+ */
301
+ expect(consentField.title).not.toContain("$");
302
+ expect(String(consentField.description)).toContain("$0.10 per GB");
303
+ expect(String(consentField.description)).toContain("$2 per GB");
304
+ });
305
+
306
+ it("keeps the consent value out of the API payload", () => {
307
+ const consentField: ModelField<TelemetryIngestionKey> =
308
+ getTelemetryPayAsYouGoFormFields()[1]!;
309
+
310
+ /*
311
+ * overrideField with no overrideFieldKey is what makes this a purely
312
+ * client-side field: ModelForm builds the payload from `field`, and
313
+ * miscDataProps from `overrideFieldKey`. Neither is set to a real key.
314
+ */
315
+ expect(consentField.field).toBeUndefined();
316
+ expect(consentField.overrideFieldKey).toBeUndefined();
317
+ expect(consentField.overrideField).toEqual({
318
+ [TELEMETRY_CONSENT_FIELD_KEY]: true,
319
+ });
320
+ expect(consentField.showEvenIfPermissionDoesNotExist).toBe(true);
321
+ });
322
+
323
+ it("seeds the consent value as false so validation actually runs on it", () => {
324
+ const consentField: ModelField<TelemetryIngestionKey> =
325
+ getTelemetryPayAsYouGoFormFields()[1]!;
326
+
327
+ /*
328
+ * The form skips customValidation for keys that are absent from its
329
+ * values, and a plain `defaultValue: false` is falsy and seeds nothing.
330
+ * getDefaultValue is the only thing that puts the key there.
331
+ */
332
+ expect(consentField.getDefaultValue).toBeDefined();
333
+ expect(
334
+ consentField.getDefaultValue!({} as FormValues<TelemetryIngestionKey>),
335
+ ).toBe(false);
336
+ expect(consentField.required).toBe(true);
337
+ });
338
+
339
+ it("does not touch the edit form - the key is only created once", () => {
340
+ for (const field of getTelemetryPayAsYouGoFormFields()) {
341
+ expect(field.doNotShowWhenEditing).toBe(true);
342
+ }
343
+ });
344
+
345
+ it.each([PlanType.Growth, PlanType.Scale, PlanType.Enterprise])(
346
+ "adds nothing on the %s plan",
347
+ (plan: PlanType) => {
348
+ setPlan(plan);
349
+
350
+ expect(getTelemetryPayAsYouGoFormFields()).toEqual([]);
351
+ },
352
+ );
353
+
354
+ it("adds nothing on a self-hosted install", () => {
355
+ config.billingEnabled = false;
356
+
357
+ expect(getTelemetryPayAsYouGoFormFields()).toEqual([]);
358
+ });
359
+ });
360
+
361
+ describe("getMonitorPayAsYouGoFormFields", () => {
362
+ it("adds one consent checkbox, on the step it was asked for", () => {
363
+ const fields: Array<ModelField<Monitor>> = getMonitorPayAsYouGoFormFields(
364
+ { stepId: "monitor-info" },
365
+ );
366
+
367
+ expect(fields).toHaveLength(1);
368
+ expect(fields[0]?.fieldType).toBe(FormFieldSchemaType.Checkbox);
369
+ expect(fields[0]?.stepId).toBe("monitor-info");
370
+ expect(fields[0]?.overrideField).toEqual({
371
+ [MONITOR_CONSENT_FIELD_KEY]: true,
372
+ });
373
+ expect(fields[0]?.overrideFieldKey).toBeUndefined();
374
+ });
375
+
376
+ it("is shown for billed monitor types and hidden for Manual", () => {
377
+ const showIf: (values: FormValues<Monitor>) => boolean =
378
+ getMonitorPayAsYouGoFormFields({ stepId: "monitor-info" })[0]!.showIf!;
379
+
380
+ expect(
381
+ showIf({ monitorType: MonitorType.Manual } as FormValues<Monitor>),
382
+ ).toBe(false);
383
+
384
+ for (const monitorType of [
385
+ MonitorType.Website,
386
+ MonitorType.API,
387
+ MonitorType.Logs,
388
+ MonitorType.IncomingRequest,
389
+ MonitorType.NetworkDevice,
390
+ ]) {
391
+ expect(
392
+ showIf({ monitorType: monitorType } as FormValues<Monitor>),
393
+ ).toBe(true);
394
+ }
395
+ });
396
+
397
+ it.each([PlanType.Growth, PlanType.Scale, PlanType.Enterprise])(
398
+ "adds nothing on the %s plan",
399
+ (plan: PlanType) => {
400
+ setPlan(plan);
401
+
402
+ expect(
403
+ getMonitorPayAsYouGoFormFields({ stepId: "monitor-info" }),
404
+ ).toEqual([]);
405
+ },
406
+ );
407
+
408
+ it("adds nothing on a self-hosted install", () => {
409
+ config.billingEnabled = false;
410
+
411
+ expect(
412
+ getMonitorPayAsYouGoFormFields({ stepId: "monitor-info" }),
413
+ ).toEqual([]);
414
+ });
415
+ });
416
+
417
+ describe("bulk monitor creation (recommendations)", () => {
418
+ it("needs consent when any monitor in the batch is billed", () => {
419
+ expect(
420
+ isMonitorBatchConsentRequired([
421
+ MonitorType.Manual,
422
+ MonitorType.Kubernetes,
423
+ ]),
424
+ ).toBe(true);
425
+ expect(isMonitorBatchConsentRequired([MonitorType.Host])).toBe(true);
426
+ });
427
+
428
+ it("needs no consent for an all-Manual or empty batch", () => {
429
+ expect(isMonitorBatchConsentRequired([MonitorType.Manual])).toBe(false);
430
+ expect(isMonitorBatchConsentRequired([])).toBe(false);
431
+ });
432
+
433
+ it.each([PlanType.Growth, PlanType.Scale, PlanType.Enterprise])(
434
+ "needs no consent on the %s plan",
435
+ (plan: PlanType) => {
436
+ setPlan(plan);
437
+
438
+ expect(isMonitorBatchConsentRequired([MonitorType.Kubernetes])).toBe(
439
+ false,
440
+ );
441
+ },
442
+ );
443
+
444
+ it("needs no consent on a self-hosted install", () => {
445
+ config.billingEnabled = false;
446
+
447
+ expect(isMonitorBatchConsentRequired([MonitorType.Kubernetes])).toBe(
448
+ false,
449
+ );
450
+ });
451
+
452
+ it("quotes the batch total, counting only the billed monitors", () => {
453
+ const sentence: string = getMonitorBatchPriceSentence([
454
+ MonitorType.Kubernetes,
455
+ MonitorType.Host,
456
+ MonitorType.Docker,
457
+ MonitorType.Manual,
458
+ ]);
459
+
460
+ /*
461
+ * "up to", because the Free plan also caps active monitors - a batch
462
+ * that hits the cap is rejected part way rather than billed in full.
463
+ */
464
+ expect(sentence).toContain("Creating 3 monitors");
465
+ expect(sentence).toContain("adds up to $3 per month");
466
+ expect(sentence).toContain("$1 per monitor per month");
467
+ });
468
+
469
+ it("says monitor, not monitors, for a batch of one", () => {
470
+ expect(getMonitorBatchPriceSentence([MonitorType.Host])).toContain(
471
+ "Creating 1 monitor adds up to $1 per month",
472
+ );
473
+ });
474
+
475
+ it("renders a checkbox carrying the batch total and a pricing link", () => {
476
+ render(
477
+ <MonitorBatchPayAsYouGoConsent
478
+ monitorTypes={[MonitorType.Kubernetes, MonitorType.Host]}
479
+ value={false}
480
+ onChange={() => {}}
481
+ />,
482
+ );
483
+
484
+ const notice: HTMLElement = screen.getByTestId(
485
+ "monitor-batch-pay-as-you-go-notice",
486
+ );
487
+
488
+ expect(notice).toHaveTextContent("adds up to $2 per month");
489
+ expect(
490
+ screen.getByTestId("monitor-batch-pay-as-you-go-consent"),
491
+ ).toBeInTheDocument();
492
+ expect(
493
+ screen.getByText("See pay as you go pricing").closest("a"),
494
+ ).toHaveAttribute("href", "https://oneuptime.com/pricing");
495
+ });
496
+
497
+ it("reports ticks back to the caller, which is what unlocks its submit", async () => {
498
+ const onChange: MockFunction = getJestMockFunction();
499
+
500
+ render(
501
+ <MonitorBatchPayAsYouGoConsent
502
+ monitorTypes={[MonitorType.Kubernetes]}
503
+ value={false}
504
+ onChange={onChange as (value: boolean) => void}
505
+ />,
506
+ );
507
+
508
+ await userEvent.click(
509
+ screen.getByTestId("monitor-batch-pay-as-you-go-consent"),
510
+ );
511
+
512
+ /*
513
+ * Checkbox also passes an "indeterminate" second argument; only the
514
+ * first one is the answer.
515
+ */
516
+ expect(onChange.mock.calls[0]?.[0]).toBe(true);
517
+ });
518
+
519
+ it("renders nothing for an all-Manual batch", () => {
520
+ const { container }: { container: HTMLElement } = render(
521
+ <MonitorBatchPayAsYouGoConsent
522
+ monitorTypes={[MonitorType.Manual]}
523
+ value={false}
524
+ onChange={() => {}}
525
+ />,
526
+ );
527
+
528
+ expect(container).toBeEmptyDOMElement();
529
+ });
530
+
531
+ it("renders nothing off the Free plan", () => {
532
+ setPlan(PlanType.Growth);
533
+
534
+ const { container }: { container: HTMLElement } = render(
535
+ <MonitorBatchPayAsYouGoConsent
536
+ monitorTypes={[MonitorType.Kubernetes]}
537
+ value={false}
538
+ onChange={() => {}}
539
+ />,
540
+ );
541
+
542
+ expect(container).toBeEmptyDOMElement();
543
+ });
544
+ });
545
+
546
+ describe("consent validators", () => {
547
+ it("only an explicit tick satisfies the telemetry consent", () => {
548
+ expect(validateTelemetryConsent({})).toBe(TELEMETRY_CONSENT_ERROR);
549
+ expect(
550
+ validateTelemetryConsent({ [TELEMETRY_CONSENT_FIELD_KEY]: false }),
551
+ ).toBe(TELEMETRY_CONSENT_ERROR);
552
+ expect(
553
+ validateTelemetryConsent({ [TELEMETRY_CONSENT_FIELD_KEY]: undefined }),
554
+ ).toBe(TELEMETRY_CONSENT_ERROR);
555
+ expect(
556
+ validateTelemetryConsent({ [TELEMETRY_CONSENT_FIELD_KEY]: true }),
557
+ ).toBeNull();
558
+ });
559
+
560
+ it("does not accept a truthy non-boolean as consent", () => {
561
+ /*
562
+ * The form's own `required` check stringifies values, which is exactly
563
+ * why it cannot be used here - "false" is a non-empty string. Nothing
564
+ * but boolean true counts.
565
+ */
566
+ expect(
567
+ validateTelemetryConsent({ [TELEMETRY_CONSENT_FIELD_KEY]: "true" }),
568
+ ).toBe(TELEMETRY_CONSENT_ERROR);
569
+ expect(
570
+ validateTelemetryConsent({ [TELEMETRY_CONSENT_FIELD_KEY]: 1 }),
571
+ ).toBe(TELEMETRY_CONSENT_ERROR);
572
+ });
573
+
574
+ it("blocks a billed monitor until it is acknowledged", () => {
575
+ expect(validateMonitorConsent({ monitorType: MonitorType.Website })).toBe(
576
+ MONITOR_CONSENT_ERROR,
577
+ );
578
+ expect(
579
+ validateMonitorConsent({
580
+ monitorType: MonitorType.Website,
581
+ [MONITOR_CONSENT_FIELD_KEY]: false,
582
+ }),
583
+ ).toBe(MONITOR_CONSENT_ERROR);
584
+ expect(
585
+ validateMonitorConsent({
586
+ monitorType: MonitorType.Website,
587
+ [MONITOR_CONSENT_FIELD_KEY]: true,
588
+ }),
589
+ ).toBeNull();
590
+ });
591
+
592
+ it("never blocks a Manual monitor - there is nothing to acknowledge", () => {
593
+ expect(validateMonitorConsent({ monitorType: MonitorType.Manual })).toBe(
594
+ null,
595
+ );
596
+ expect(
597
+ validateMonitorConsent({
598
+ monitorType: MonitorType.Manual,
599
+ [MONITOR_CONSENT_FIELD_KEY]: false,
600
+ }),
601
+ ).toBeNull();
602
+ });
603
+
604
+ it("blocks when no monitor type has been picked yet", () => {
605
+ /*
606
+ * An unset type is not Manual, so it is treated as billed. Failing this
607
+ * way round means the box can never be skipped by submitting early.
608
+ */
609
+ expect(validateMonitorConsent({})).toBe(MONITOR_CONSENT_ERROR);
610
+ });
611
+ });
612
+ });