@oneuptime/common 11.5.13 → 11.6.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.
Files changed (122) hide show
  1. package/Models/DatabaseModels/EnterpriseLicense.ts +21 -0
  2. package/Models/DatabaseModels/EnterpriseLicenseInstance.ts +25 -0
  3. package/Models/DatabaseModels/GlobalConfig.ts +76 -0
  4. package/Server/API/EnterpriseLicenseAPI.ts +57 -0
  5. package/Server/API/GlobalConfigAPI.ts +62 -1
  6. package/Server/EnvironmentConfig.ts +22 -0
  7. package/Server/Infrastructure/Postgres/SchemaMigrations/1784659816363-AddInstanceVersionAndLatestReleaseColumns.ts +38 -0
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/1784705487674-AddEnterpriseLicenseEvaluationColumns.ts +25 -0
  9. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  10. package/Server/Services/AnalyticsDatabaseService.ts +48 -45
  11. package/Server/Services/DatabaseService.ts +66 -1
  12. package/Server/Services/WorkflowService.ts +100 -39
  13. package/Server/Types/Workflow/Components/Schedule.ts +57 -32
  14. package/Server/Utils/AnalyticsDatabase/InsertDedupContext.ts +59 -0
  15. package/Server/Utils/Express.ts +13 -0
  16. package/Server/Utils/Telemetry/TelemetryFanInWriter.ts +799 -0
  17. package/Server/Utils/Telemetry/TelemetryWriterClient.ts +263 -0
  18. package/Server/Utils/Telemetry/TelemetryWriterServer.ts +240 -0
  19. package/Server/Utils/Telemetry/TelemetryWriterShedMetrics.ts +93 -0
  20. package/Tests/Server/Services/DatabaseServiceSanitizeUpdateData.test.ts +232 -0
  21. package/Tests/Server/Services/UpdateOneByIdKeepsRowId.test.ts +107 -0
  22. package/Tests/Server/Services/WorkflowService.test.ts +308 -0
  23. package/Tests/Server/Utils/Attribution.test.ts +203 -0
  24. package/Tests/Server/Utils/Marketing/ConversionUploadProvider.test.ts +183 -0
  25. package/Tests/Server/Utils/Marketing/GoogleAds.test.ts +340 -0
  26. package/Tests/Server/Utils/Monitor/Criteria/ExceptionMonitorCriteria.test.ts +87 -0
  27. package/Tests/Server/Utils/Monitor/Criteria/LogMonitorCriteria.test.ts +165 -0
  28. package/Tests/Server/Utils/Monitor/Criteria/TraceMonitorCriteria.test.ts +86 -0
  29. package/Tests/Server/Utils/Telemetry/TelemetryFanInWriter.test.ts +1565 -0
  30. package/Tests/Server/Utils/Telemetry/TelemetryWriterClient.test.ts +313 -0
  31. package/Tests/Server/Utils/Telemetry/TelemetryWriterServer.test.ts +270 -0
  32. package/Tests/Server/Utils/Telemetry/TelemetryWriterShedMetrics.test.ts +127 -0
  33. package/Tests/Types/AI/CodeFixTaskType.test.ts +3 -0
  34. package/Tests/Types/Monitor/MonitorCriteriaMetricVariables.test.ts +228 -0
  35. package/Tests/Types/Monitor/MonitorStepDefaultTelemetryConfig.test.ts +169 -0
  36. package/Tests/Types/Monitor/MonitorStepExceptionMonitor.test.ts +289 -0
  37. package/Tests/Types/Monitor/MonitorStepLogMonitor.test.ts +231 -0
  38. package/Tests/Types/Monitor/MonitorStepMetricViewConfigUtil.test.ts +245 -0
  39. package/Tests/Types/Monitor/MonitorStepNetworkDeviceMonitor.test.ts +155 -0
  40. package/Tests/Types/Monitor/MonitorStepTelemetrySerialization.test.ts +141 -0
  41. package/Tests/Types/Monitor/MonitorStepTraceMonitor.test.ts +137 -0
  42. package/Tests/Types/Workspace/NotificationRules/NotificationRuleCondition.test.ts +311 -0
  43. package/Tests/UI/Utils/Breadcrumb/BreadcrumbTrailResolver.test.ts +461 -0
  44. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +2852 -0
  45. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +758 -0
  46. package/Tests/UI/Utils/NotificationMethodUtil.test.ts +564 -0
  47. package/Tests/Utils/Array.test.ts +156 -0
  48. package/Tests/Utils/CronTab.test.ts +199 -0
  49. package/Tests/Utils/ModelImportExport.test.ts +101 -0
  50. package/Tests/Utils/Number.test.ts +179 -0
  51. package/Tests/Utils/VersionUtil.test.ts +348 -0
  52. package/Tests/jest.setup.ts +1 -0
  53. package/Types/EnterpriseLicense/EnterpriseLicenseInstanceSummary.ts +6 -0
  54. package/Types/Monitor/MonitorStep.ts +24 -4
  55. package/Types/Monitor/MonitorStepMetricViewConfigUtil.ts +105 -0
  56. package/UI/Components/EditionLabel/EditionLabel.tsx +445 -12
  57. package/UI/Components/Workflow/ArgumentsForm.tsx +68 -30
  58. package/UI/Components/Workflow/CronScheduleField.tsx +411 -0
  59. package/UI/Utils/Breadcrumb/BreadcrumbTrailResolver.ts +176 -0
  60. package/UI/Utils/NotificationMethodUtil.ts +310 -0
  61. package/Utils/CronTab.ts +823 -0
  62. package/Utils/ModelImportExport.ts +21 -6
  63. package/Utils/VersionUtil.ts +260 -0
  64. package/build/dist/Models/DatabaseModels/EnterpriseLicense.js +22 -0
  65. package/build/dist/Models/DatabaseModels/EnterpriseLicense.js.map +1 -1
  66. package/build/dist/Models/DatabaseModels/EnterpriseLicenseInstance.js +26 -0
  67. package/build/dist/Models/DatabaseModels/EnterpriseLicenseInstance.js.map +1 -1
  68. package/build/dist/Models/DatabaseModels/GlobalConfig.js +81 -0
  69. package/build/dist/Models/DatabaseModels/GlobalConfig.js.map +1 -1
  70. package/build/dist/Server/API/EnterpriseLicenseAPI.js +47 -0
  71. package/build/dist/Server/API/EnterpriseLicenseAPI.js.map +1 -1
  72. package/build/dist/Server/API/GlobalConfigAPI.js +52 -1
  73. package/build/dist/Server/API/GlobalConfigAPI.js.map +1 -1
  74. package/build/dist/Server/EnvironmentConfig.js +17 -0
  75. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  76. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784659816363-AddInstanceVersionAndLatestReleaseColumns.js +18 -0
  77. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784659816363-AddInstanceVersionAndLatestReleaseColumns.js.map +1 -0
  78. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784705487674-AddEnterpriseLicenseEvaluationColumns.js +14 -0
  79. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784705487674-AddEnterpriseLicenseEvaluationColumns.js.map +1 -0
  80. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  81. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  82. package/build/dist/Server/Services/AnalyticsDatabaseService.js +44 -16
  83. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  84. package/build/dist/Server/Services/DatabaseService.js +53 -1
  85. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  86. package/build/dist/Server/Services/WorkflowService.js +80 -30
  87. package/build/dist/Server/Services/WorkflowService.js.map +1 -1
  88. package/build/dist/Server/Types/Workflow/Components/Schedule.js +44 -19
  89. package/build/dist/Server/Types/Workflow/Components/Schedule.js.map +1 -1
  90. package/build/dist/Server/Utils/AnalyticsDatabase/InsertDedupContext.js +24 -0
  91. package/build/dist/Server/Utils/AnalyticsDatabase/InsertDedupContext.js.map +1 -0
  92. package/build/dist/Server/Utils/Express.js +12 -0
  93. package/build/dist/Server/Utils/Express.js.map +1 -1
  94. package/build/dist/Server/Utils/Telemetry/TelemetryFanInWriter.js +496 -0
  95. package/build/dist/Server/Utils/Telemetry/TelemetryFanInWriter.js.map +1 -0
  96. package/build/dist/Server/Utils/Telemetry/TelemetryWriterClient.js +165 -0
  97. package/build/dist/Server/Utils/Telemetry/TelemetryWriterClient.js.map +1 -0
  98. package/build/dist/Server/Utils/Telemetry/TelemetryWriterServer.js +136 -0
  99. package/build/dist/Server/Utils/Telemetry/TelemetryWriterServer.js.map +1 -0
  100. package/build/dist/Server/Utils/Telemetry/TelemetryWriterShedMetrics.js +76 -0
  101. package/build/dist/Server/Utils/Telemetry/TelemetryWriterShedMetrics.js.map +1 -0
  102. package/build/dist/Types/Monitor/MonitorStep.js +20 -4
  103. package/build/dist/Types/Monitor/MonitorStep.js.map +1 -1
  104. package/build/dist/Types/Monitor/MonitorStepMetricViewConfigUtil.js +73 -0
  105. package/build/dist/Types/Monitor/MonitorStepMetricViewConfigUtil.js.map +1 -0
  106. package/build/dist/UI/Components/EditionLabel/EditionLabel.js +261 -13
  107. package/build/dist/UI/Components/EditionLabel/EditionLabel.js.map +1 -1
  108. package/build/dist/UI/Components/Workflow/ArgumentsForm.js +30 -6
  109. package/build/dist/UI/Components/Workflow/ArgumentsForm.js.map +1 -1
  110. package/build/dist/UI/Components/Workflow/CronScheduleField.js +209 -0
  111. package/build/dist/UI/Components/Workflow/CronScheduleField.js.map +1 -0
  112. package/build/dist/UI/Utils/Breadcrumb/BreadcrumbTrailResolver.js +129 -0
  113. package/build/dist/UI/Utils/Breadcrumb/BreadcrumbTrailResolver.js.map +1 -0
  114. package/build/dist/UI/Utils/NotificationMethodUtil.js +189 -0
  115. package/build/dist/UI/Utils/NotificationMethodUtil.js.map +1 -0
  116. package/build/dist/Utils/CronTab.js +609 -0
  117. package/build/dist/Utils/CronTab.js.map +1 -0
  118. package/build/dist/Utils/ModelImportExport.js +20 -6
  119. package/build/dist/Utils/ModelImportExport.js.map +1 -1
  120. package/build/dist/Utils/VersionUtil.js +193 -0
  121. package/build/dist/Utils/VersionUtil.js.map +1 -0
  122. package/package.json +1 -1
@@ -0,0 +1,156 @@
1
+ import ArrayUtil from "../../Utils/Array";
2
+ import ObjectID from "../../Types/ObjectID";
3
+
4
+ describe("ArrayUtil", () => {
5
+ describe("removeDuplicates", () => {
6
+ it("keeps the first occurrence of each value in order", () => {
7
+ expect(ArrayUtil.removeDuplicates([1, 2, 2, 3, 1])).toEqual([1, 2, 3]);
8
+ });
9
+
10
+ it("returns an empty array unchanged", () => {
11
+ expect(ArrayUtil.removeDuplicates([])).toEqual([]);
12
+ });
13
+
14
+ it("de-duplicates strings", () => {
15
+ expect(ArrayUtil.removeDuplicates(["a", "b", "a"])).toEqual(["a", "b"]);
16
+ });
17
+ });
18
+
19
+ describe("mergeStringArrays", () => {
20
+ it("concatenates and removes duplicates", () => {
21
+ expect(ArrayUtil.mergeStringArrays(["a", "b"], ["b", "c"])).toEqual([
22
+ "a",
23
+ "b",
24
+ "c",
25
+ ]);
26
+ });
27
+
28
+ it("handles empty inputs on either side", () => {
29
+ expect(ArrayUtil.mergeStringArrays([], ["x"])).toEqual(["x"]);
30
+ expect(ArrayUtil.mergeStringArrays(["x"], [])).toEqual(["x"]);
31
+ });
32
+ });
33
+
34
+ describe("isEqual / isStringArrayEqual", () => {
35
+ it("is true for arrays with the same members regardless of order", () => {
36
+ expect(ArrayUtil.isEqual([1, 2, 3], [3, 2, 1])).toBe(true);
37
+ expect(ArrayUtil.isStringArrayEqual(["a", "b"], ["b", "a"])).toBe(true);
38
+ });
39
+
40
+ it("is false for different lengths", () => {
41
+ expect(ArrayUtil.isEqual([1, 2], [1, 2, 3])).toBe(false);
42
+ });
43
+
44
+ it("is false for different members", () => {
45
+ expect(ArrayUtil.isEqual([1, 2, 3], [1, 2, 4])).toBe(false);
46
+ });
47
+
48
+ it("is true for two empty arrays", () => {
49
+ expect(ArrayUtil.isEqual([], [])).toBe(true);
50
+ });
51
+ });
52
+
53
+ describe("shuffle", () => {
54
+ it("returns a new array with the same members", () => {
55
+ const input: Array<number> = [1, 2, 3, 4, 5];
56
+ const shuffled: Array<number> = ArrayUtil.shuffle(input);
57
+ expect(shuffled).not.toBe(input);
58
+ expect([...shuffled].sort()).toEqual([...input].sort());
59
+ });
60
+
61
+ it("does not mutate the input array", () => {
62
+ const input: Array<number> = [1, 2, 3];
63
+ const copy: Array<number> = [...input];
64
+ ArrayUtil.shuffle(input);
65
+ expect(input).toEqual(copy);
66
+ });
67
+
68
+ it("handles empty and single-element arrays", () => {
69
+ expect(ArrayUtil.shuffle([])).toEqual([]);
70
+ expect(ArrayUtil.shuffle([9])).toEqual([9]);
71
+ });
72
+ });
73
+
74
+ describe("removeDuplicatesFromObjectIDArray", () => {
75
+ it("de-duplicates by string value", () => {
76
+ const id1: ObjectID = new ObjectID(
77
+ "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
78
+ );
79
+ const id1Dup: ObjectID = new ObjectID(
80
+ "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
81
+ );
82
+ const id2: ObjectID = new ObjectID(
83
+ "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
84
+ );
85
+
86
+ const result: Array<ObjectID> =
87
+ ArrayUtil.removeDuplicatesFromObjectIDArray([id1, id1Dup, id2]);
88
+
89
+ expect(result).toHaveLength(2);
90
+ expect(
91
+ result.map((id: ObjectID) => {
92
+ return id.toString();
93
+ }),
94
+ ).toEqual([id1.toString(), id2.toString()]);
95
+ });
96
+
97
+ it("returns an empty array unchanged", () => {
98
+ expect(ArrayUtil.removeDuplicatesFromObjectIDArray([])).toEqual([]);
99
+ });
100
+ });
101
+
102
+ describe("sortByFieldName", () => {
103
+ it("sorts objects ascending by the given field", () => {
104
+ const rows: Array<{ name: string; age: number }> = [
105
+ { name: "c", age: 3 },
106
+ { name: "a", age: 1 },
107
+ { name: "b", age: 2 },
108
+ ];
109
+ rows.sort(ArrayUtil.sortByFieldName("age"));
110
+ expect(
111
+ rows.map((r: { name: string }) => {
112
+ return r.name;
113
+ }),
114
+ ).toEqual(["a", "b", "c"]);
115
+ });
116
+
117
+ it("returns 0 for equal field values", () => {
118
+ const comparator: (a: any, b: any) => number =
119
+ ArrayUtil.sortByFieldName("age");
120
+ expect(comparator({ age: 5 }, { age: 5 })).toBe(0);
121
+ });
122
+ });
123
+
124
+ describe("selectItemByRandom", () => {
125
+ it("returns a member of the array", () => {
126
+ const arr: Array<string> = ["x", "y", "z"];
127
+ for (let i: number = 0; i < 100; i++) {
128
+ expect(arr).toContain(ArrayUtil.selectItemByRandom(arr));
129
+ }
130
+ });
131
+
132
+ it("returns the only element of a single-element array", () => {
133
+ expect(ArrayUtil.selectItemByRandom(["only"])).toBe("only");
134
+ });
135
+ });
136
+
137
+ describe("distinctByFieldName", () => {
138
+ it("keeps the first object for each distinct field value", () => {
139
+ const rows: Array<{ group: string; id: number }> = [
140
+ { group: "a", id: 1 },
141
+ { group: "b", id: 2 },
142
+ { group: "a", id: 3 },
143
+ ];
144
+ const result: Array<{ group: string; id: number }> =
145
+ ArrayUtil.distinctByFieldName(rows, "group");
146
+ expect(result).toEqual([
147
+ { group: "a", id: 1 },
148
+ { group: "b", id: 2 },
149
+ ]);
150
+ });
151
+
152
+ it("returns an empty array unchanged", () => {
153
+ expect(ArrayUtil.distinctByFieldName([], "group")).toEqual([]);
154
+ });
155
+ });
156
+ });
@@ -0,0 +1,199 @@
1
+ import CronTab, { CronPreset } from "../../Utils/CronTab";
2
+ import cronParser from "cron-parser";
3
+
4
+ /**
5
+ * These tests cross-check our dependency-free CronTab engine against
6
+ * `cron-parser` (the library BullMQ actually uses to fire repeatable jobs), so
7
+ * the picker's preview and validation stay faithful to real firing behavior.
8
+ */
9
+
10
+ const FIXED_FROM: Date = new Date(Date.UTC(2026, 0, 1, 0, 0, 30));
11
+
12
+ function cronParserNextTimes(
13
+ expression: string,
14
+ count: number,
15
+ from: Date,
16
+ ): Array<number> {
17
+ const interval: {
18
+ next: () => { toDate: () => Date };
19
+ } = cronParser.parseExpression(expression, {
20
+ currentDate: from,
21
+ tz: "UTC",
22
+ }) as unknown as { next: () => { toDate: () => Date } };
23
+
24
+ const times: Array<number> = [];
25
+ for (let i: number = 0; i < count; i++) {
26
+ times.push(interval.next().toDate().getTime());
27
+ }
28
+ return times;
29
+ }
30
+
31
+ describe("CronTab", () => {
32
+ describe("isVariableExpression", () => {
33
+ test("detects workflow variable references", () => {
34
+ expect(CronTab.isVariableExpression("{{local.variables.schedule}}")).toBe(
35
+ true,
36
+ );
37
+ expect(
38
+ CronTab.isVariableExpression("* */{{local.variables.h}} * * *"),
39
+ ).toBe(true);
40
+ });
41
+
42
+ test("returns false for plain cron expressions", () => {
43
+ expect(CronTab.isVariableExpression("0 */18 * * *")).toBe(false);
44
+ expect(CronTab.isVariableExpression("* * * * *")).toBe(false);
45
+ });
46
+ });
47
+
48
+ describe("validation", () => {
49
+ const validExpressions: Array<string> = [
50
+ "* * * * *",
51
+ "*/5 * * * *",
52
+ "0 */18 * * *",
53
+ "0 0 * * *",
54
+ "30 9 * * 1-5",
55
+ "0 0 1 */3 *",
56
+ "0 0 1 1 *",
57
+ "15,45 * * * *",
58
+ "0 0 * * MON",
59
+ "0 0 1 JAN *",
60
+ "0 0 * * 7",
61
+ "*/15 9-17 * * MON-FRI",
62
+ "30 2 * * * ", // trailing space
63
+ "0 0 * * SUN,SAT",
64
+ "5/10 * * * *",
65
+ ];
66
+
67
+ test.each(validExpressions)(
68
+ "accepts valid expression %s",
69
+ (expr: string) => {
70
+ expect(CronTab.getValidationError(expr)).toBeNull();
71
+ expect(CronTab.isValid(expr)).toBe(true);
72
+ },
73
+ );
74
+
75
+ const invalidExpressions: Array<string> = [
76
+ "",
77
+ "* * * *", // 4 fields
78
+ "* * * * * * *", // 7 fields
79
+ "60 * * * *", // minute out of range
80
+ "* 24 * * *", // hour out of range
81
+ "* * 32 * *", // dom out of range
82
+ "* * * 13 *", // month out of range
83
+ "* * * * 8", // dow out of range
84
+ "*/0 * * * *", // step 0
85
+ "abc * * * *", // not a number
86
+ "5-1 * * * *", // reversed range
87
+ "* * * * FUN", // bad day name
88
+ ];
89
+
90
+ test.each(invalidExpressions)(
91
+ "rejects invalid expression %s",
92
+ (expr: string) => {
93
+ expect(CronTab.getValidationError(expr)).not.toBeNull();
94
+ expect(CronTab.isValid(expr)).toBe(false);
95
+ },
96
+ );
97
+
98
+ test("variable expressions are not flagged as validation errors", () => {
99
+ expect(
100
+ CronTab.getValidationError("{{local.variables.schedule}}"),
101
+ ).toBeNull();
102
+ // ...but they are not "valid" concrete crons either.
103
+ expect(CronTab.isValid("{{local.variables.schedule}}")).toBe(false);
104
+ });
105
+ });
106
+
107
+ describe("all presets are valid and describable", () => {
108
+ test.each(
109
+ CronTab.PRESETS.map((p: CronPreset) => {
110
+ return p.value;
111
+ }),
112
+ )("preset %s", (value: string) => {
113
+ expect(CronTab.isValid(value)).toBe(true);
114
+ expect(CronTab.getHumanReadableDescription(value)).toBeTruthy();
115
+ // cron-parser must also accept it (guards against BullMQ rejecting).
116
+ expect(() => {
117
+ return cronParser.parseExpression(value, { tz: "UTC" });
118
+ }).not.toThrow();
119
+ });
120
+ });
121
+
122
+ describe("next execution times match cron-parser", () => {
123
+ const expressions: Array<string> = [
124
+ "* * * * *",
125
+ "*/5 * * * *",
126
+ "*/30 * * * *",
127
+ "0 * * * *",
128
+ "0 */3 * * *",
129
+ "0 */18 * * *",
130
+ "0 0 * * *",
131
+ "30 9 * * *",
132
+ "0 0 * * 0",
133
+ "0 0 * * 1-5",
134
+ "0 0 1 * *",
135
+ "0 0 1 */3 *",
136
+ "0 0 29 2 *", // leap-day only — sparse, exercises month/day jumps
137
+ "15,45 8-10 * * *",
138
+ "0 12 1,15 * *",
139
+ ];
140
+
141
+ test.each(expressions)("next 8 runs for %s", (expr: string) => {
142
+ const ours: Array<number> = CronTab.getNextExecutionTimes(
143
+ expr,
144
+ 8,
145
+ FIXED_FROM,
146
+ ).map((d: Date) => {
147
+ return d.getTime();
148
+ });
149
+
150
+ const theirs: Array<number> = cronParserNextTimes(expr, 8, FIXED_FROM);
151
+
152
+ expect(ours).toEqual(theirs);
153
+ });
154
+ });
155
+
156
+ describe("human-readable descriptions", () => {
157
+ const cases: Array<[string, string]> = [
158
+ ["* * * * *", "Every minute"],
159
+ ["*/5 * * * *", "Every 5 minutes"],
160
+ ["*/30 * * * *", "Every 30 minutes"],
161
+ ["0 * * * *", "Every hour, on the hour"],
162
+ ["0 */2 * * *", "Every 2 hours"],
163
+ ["0 */18 * * *", "Every 18 hours"],
164
+ ["0 0 * * *", "Every day at 00:00 (UTC)"],
165
+ ["30 9 * * *", "Every day at 09:30 (UTC)"],
166
+ ["0 9 * * 1", "Every week on Monday at 09:00 (UTC)"],
167
+ ["0 0 * * 0", "Every week on Sunday at 00:00 (UTC)"],
168
+ ["0 0 1 * *", "Every month on the 1st at 00:00 (UTC)"],
169
+ ["0 0 1 */3 *", "Every 3 months on the 1st at 00:00 (UTC)"],
170
+ ["0 0 1 1 *", "On the 1st of January at 00:00 (UTC)"],
171
+ ];
172
+
173
+ test.each(cases)("describes %s as %s", (expr: string, expected: string) => {
174
+ expect(CronTab.getHumanReadableDescription(expr)).toBe(expected);
175
+ });
176
+
177
+ test("returns null for invalid expressions", () => {
178
+ expect(CronTab.getHumanReadableDescription("nonsense")).toBeNull();
179
+ });
180
+ });
181
+
182
+ describe("next execution times are in the future and ascending", () => {
183
+ test("returns the requested count in strictly increasing order", () => {
184
+ const times: Array<Date> = CronTab.getNextExecutionTimes(
185
+ "*/15 * * * *",
186
+ 5,
187
+ FIXED_FROM,
188
+ );
189
+
190
+ expect(times).toHaveLength(5);
191
+ for (let i: number = 0; i < times.length; i++) {
192
+ expect(times[i]!.getTime()).toBeGreaterThan(FIXED_FROM.getTime());
193
+ if (i > 0) {
194
+ expect(times[i]!.getTime()).toBeGreaterThan(times[i - 1]!.getTime());
195
+ }
196
+ }
197
+ });
198
+ });
199
+ });
@@ -2,6 +2,7 @@ import Dashboard from "../../Models/DatabaseModels/Dashboard";
2
2
  import Monitor from "../../Models/DatabaseModels/Monitor";
3
3
  import ScheduledMaintenanceTemplate from "../../Models/DatabaseModels/ScheduledMaintenanceTemplate";
4
4
  import StatusPage from "../../Models/DatabaseModels/StatusPage";
5
+ import Workflow from "../../Models/DatabaseModels/Workflow";
5
6
  import EventInterval from "../../Types/Events/EventInterval";
6
7
  import Recurring from "../../Types/Events/Recurring";
7
8
  import BadDataException from "../../Types/Exception/BadDataException";
@@ -86,6 +87,35 @@ describe("ModelImportExport", () => {
86
87
  expect(columns).not.toContain("embeddedOverallStatusToken");
87
88
  });
88
89
 
90
+ test("should include the workflow graph and its own settings", () => {
91
+ const columns: Array<string> =
92
+ ModelImportExport.getImportExportableColumnNames(Workflow);
93
+
94
+ expect(columns).toContain("name");
95
+ expect(columns).toContain("description");
96
+ expect(columns).toContain("graph");
97
+ expect(columns).toContain("isEnabled");
98
+ });
99
+
100
+ test("should exclude workflow secrets and server-derived trigger state", () => {
101
+ const columns: Array<string> =
102
+ ModelImportExport.getImportExportableColumnNames(Workflow);
103
+
104
+ /*
105
+ * These all carry create: [] access control - they are derived from the
106
+ * graph or generated on create, and re-importing them would either leak
107
+ * the source workflow's webhook secret or point the runner at stale
108
+ * trigger state.
109
+ */
110
+ expect(columns).not.toContain("webhookSecretKey");
111
+ expect(columns).not.toContain("triggerId");
112
+ expect(columns).not.toContain("triggerArguments");
113
+ expect(columns).not.toContain("repeatableJobKey");
114
+ expect(columns).not.toContain("slug");
115
+ expect(columns).not.toContain("projectId");
116
+ expect(columns).not.toContain("labels");
117
+ });
118
+
89
119
  test("should exclude monitor runtime-state columns", () => {
90
120
  const columns: Array<string> =
91
121
  ModelImportExport.getImportExportableColumnNames(Monitor);
@@ -267,6 +297,77 @@ describe("ModelImportExport", () => {
267
297
  expect(imported._id).toBeUndefined();
268
298
  });
269
299
 
300
+ test("should round-trip a workflow graph and drop its webhook secret", () => {
301
+ const graph: JSONObject = {
302
+ nodes: [
303
+ {
304
+ id: "node-1",
305
+ data: {
306
+ metadataId: "Webhook",
307
+ componentType: "Trigger",
308
+ nodeType: "Node",
309
+ arguments: {},
310
+ },
311
+ },
312
+ ],
313
+ edges: [],
314
+ };
315
+
316
+ const original: Workflow = new Workflow();
317
+ original.name = "Deploy Notifier";
318
+ original.description = "Posts to chat on deploy";
319
+ original.isEnabled = true;
320
+ original.graph = graph;
321
+ original.webhookSecretKey = "super-secret-key";
322
+ original.triggerId = "Webhook";
323
+ original.slug = "deploy-notifier";
324
+
325
+ const envelope: JSONObject = ModelImportExport.buildExportEnvelope({
326
+ modelType: Workflow,
327
+ items: [original],
328
+ exportedAt: new Date(),
329
+ });
330
+
331
+ const exported: JSONObject = (envelope["items"] as Array<JSONObject>)[0]!;
332
+
333
+ expect(exported["graph"]).toEqual(graph);
334
+ expect(exported["isEnabled"]).toBe(true);
335
+ expect(exported["webhookSecretKey"]).toBeUndefined();
336
+ expect(exported["triggerId"]).toBeUndefined();
337
+ expect(exported["slug"]).toBeUndefined();
338
+
339
+ const imported: Workflow = ModelImportExport.fromImportJSON({
340
+ json: exported,
341
+ modelType: Workflow,
342
+ });
343
+
344
+ expect(imported.name).toBe("Deploy Notifier");
345
+ expect(imported.description).toBe("Posts to chat on deploy");
346
+ expect(imported.graph).toEqual(graph);
347
+ expect(imported.webhookSecretKey).toBeUndefined();
348
+ expect(imported.triggerId).toBeUndefined();
349
+ expect(imported._id).toBeUndefined();
350
+ });
351
+
352
+ test("should import workflows disabled even when exported enabled", () => {
353
+ /*
354
+ * An imported workflow can reference resources that do not exist here,
355
+ * and enabling it starts it firing - so it must land disabled and be
356
+ * turned on deliberately.
357
+ */
358
+ const imported: Workflow = ModelImportExport.fromImportJSON({
359
+ json: {
360
+ name: "Pager Escalation",
361
+ isEnabled: true,
362
+ graph: { nodes: [], edges: [] },
363
+ },
364
+ modelType: Workflow,
365
+ });
366
+
367
+ expect(imported.name).toBe("Pager Escalation");
368
+ expect(imported.isEnabled).toBe(false);
369
+ });
370
+
270
371
  test("should advance past recurring dates for scheduled maintenance templates", () => {
271
372
  const dayInMs: number = 24 * 60 * 60 * 1000;
272
373
 
@@ -0,0 +1,179 @@
1
+ import NumberUtil from "../../Utils/Number";
2
+
3
+ describe("NumberUtil", () => {
4
+ describe("parseNumberWithDefault", () => {
5
+ it("parses a plain integer string", () => {
6
+ expect(
7
+ NumberUtil.parseNumberWithDefault({ value: "42", defaultValue: 0 }),
8
+ ).toBe(42);
9
+ });
10
+
11
+ it("falls back to the default for undefined, null and empty input", () => {
12
+ expect(
13
+ NumberUtil.parseNumberWithDefault({
14
+ value: undefined,
15
+ defaultValue: 7,
16
+ }),
17
+ ).toBe(7);
18
+ expect(
19
+ NumberUtil.parseNumberWithDefault({
20
+ value: null as unknown as undefined,
21
+ defaultValue: 7,
22
+ }),
23
+ ).toBe(7);
24
+ expect(
25
+ NumberUtil.parseNumberWithDefault({ value: "", defaultValue: 7 }),
26
+ ).toBe(7);
27
+ });
28
+
29
+ it("falls back to the default for non-numeric input", () => {
30
+ expect(
31
+ NumberUtil.parseNumberWithDefault({
32
+ value: "not-a-number",
33
+ defaultValue: 5,
34
+ }),
35
+ ).toBe(5);
36
+ });
37
+
38
+ it("parses the leading integer of a mixed string, like parseInt", () => {
39
+ expect(
40
+ NumberUtil.parseNumberWithDefault({ value: "12px", defaultValue: 0 }),
41
+ ).toBe(12);
42
+ });
43
+
44
+ it("truncates a decimal string to its integer part", () => {
45
+ expect(
46
+ NumberUtil.parseNumberWithDefault({ value: "3.9", defaultValue: 0 }),
47
+ ).toBe(3);
48
+ });
49
+
50
+ it("returns the default when the parsed value is below min", () => {
51
+ expect(
52
+ NumberUtil.parseNumberWithDefault({
53
+ value: "2",
54
+ defaultValue: 10,
55
+ min: 5,
56
+ }),
57
+ ).toBe(10);
58
+ });
59
+
60
+ it("returns the default when the parsed value is above max", () => {
61
+ expect(
62
+ NumberUtil.parseNumberWithDefault({
63
+ value: "200",
64
+ defaultValue: 10,
65
+ max: 100,
66
+ }),
67
+ ).toBe(10);
68
+ });
69
+
70
+ it("accepts a value exactly on the min and max boundaries", () => {
71
+ expect(
72
+ NumberUtil.parseNumberWithDefault({
73
+ value: "5",
74
+ defaultValue: 0,
75
+ min: 5,
76
+ max: 5,
77
+ }),
78
+ ).toBe(5);
79
+ });
80
+
81
+ it("accepts negative values within range", () => {
82
+ expect(
83
+ NumberUtil.parseNumberWithDefault({
84
+ value: "-3",
85
+ defaultValue: 0,
86
+ min: -10,
87
+ }),
88
+ ).toBe(-3);
89
+ });
90
+ });
91
+
92
+ describe("getRandomNumber", () => {
93
+ it("stays within the inclusive range across many draws", () => {
94
+ for (let i: number = 0; i < 1000; i++) {
95
+ const n: number = NumberUtil.getRandomNumber(3, 7);
96
+ expect(n).toBeGreaterThanOrEqual(3);
97
+ expect(n).toBeLessThanOrEqual(7);
98
+ expect(Number.isInteger(n)).toBe(true);
99
+ }
100
+ });
101
+
102
+ it("returns the single value when min equals max", () => {
103
+ expect(NumberUtil.getRandomNumber(4, 4)).toBe(4);
104
+ });
105
+ });
106
+
107
+ describe("convertToTwoDecimalPlaces", () => {
108
+ it("rounds to two decimal places", () => {
109
+ expect(NumberUtil.convertToTwoDecimalPlaces(3.14159)).toBe(3.14);
110
+ expect(NumberUtil.convertToTwoDecimalPlaces(2.005)).toBe(2.01);
111
+ });
112
+
113
+ it("leaves whole numbers unchanged", () => {
114
+ expect(NumberUtil.convertToTwoDecimalPlaces(10)).toBe(10);
115
+ });
116
+
117
+ it("rounds negative numbers", () => {
118
+ expect(NumberUtil.convertToTwoDecimalPlaces(-1.239)).toBe(-1.24);
119
+ });
120
+ });
121
+
122
+ describe("isNumber", () => {
123
+ it("is true for numeric values and numeric strings", () => {
124
+ expect(NumberUtil.isNumber(5)).toBe(true);
125
+ expect(NumberUtil.isNumber("5")).toBe(true);
126
+ expect(NumberUtil.isNumber(0)).toBe(true);
127
+ });
128
+
129
+ it("is false for non-numeric strings", () => {
130
+ expect(NumberUtil.isNumber("abc")).toBe(false);
131
+ });
132
+ });
133
+
134
+ describe("convertToNumber / canBeConvertedToNumber", () => {
135
+ it("converts numeric strings", () => {
136
+ expect(NumberUtil.convertToNumber("42")).toBe(42);
137
+ expect(NumberUtil.convertToNumber("3.5")).toBe(3.5);
138
+ });
139
+
140
+ it("reports whether a value can be converted", () => {
141
+ expect(NumberUtil.canBeConvertedToNumber("42")).toBe(true);
142
+ expect(NumberUtil.canBeConvertedToNumber("nope")).toBe(false);
143
+ });
144
+ });
145
+
146
+ describe("isInteger / isFloat", () => {
147
+ it("distinguishes integers from floats", () => {
148
+ expect(NumberUtil.isInteger(4)).toBe(true);
149
+ expect(NumberUtil.isInteger(4.5)).toBe(false);
150
+ expect(NumberUtil.isFloat(4.5)).toBe(true);
151
+ expect(NumberUtil.isFloat(4)).toBe(false);
152
+ });
153
+ });
154
+
155
+ describe("sign helpers", () => {
156
+ it("classifies positive, negative and zero", () => {
157
+ expect(NumberUtil.isPositive(1)).toBe(true);
158
+ expect(NumberUtil.isPositive(0)).toBe(false);
159
+ expect(NumberUtil.isNegative(-1)).toBe(true);
160
+ expect(NumberUtil.isNegative(0)).toBe(false);
161
+ expect(NumberUtil.isZero(0)).toBe(true);
162
+ expect(NumberUtil.isZero(1)).toBe(false);
163
+ });
164
+ });
165
+
166
+ describe("parity helpers", () => {
167
+ it("classifies even and odd", () => {
168
+ expect(NumberUtil.isEven(2)).toBe(true);
169
+ expect(NumberUtil.isEven(3)).toBe(false);
170
+ expect(NumberUtil.isOdd(3)).toBe(true);
171
+ expect(NumberUtil.isOdd(2)).toBe(false);
172
+ });
173
+
174
+ it("treats zero as even", () => {
175
+ expect(NumberUtil.isEven(0)).toBe(true);
176
+ expect(NumberUtil.isOdd(0)).toBe(false);
177
+ });
178
+ });
179
+ });