@oneuptime/common 12.0.20 → 12.0.21

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 (86) hide show
  1. package/Models/AnalyticsModels/ChangeEvent.ts +331 -0
  2. package/Models/AnalyticsModels/Index.ts +2 -0
  3. package/Server/EnvironmentConfig.ts +1 -8
  4. package/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.ts +42 -0
  5. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +0 -2
  6. package/Server/Services/ChangeEventService.ts +11 -0
  7. package/Server/Services/EnterpriseLicenseService.ts +3 -3
  8. package/Server/Services/Index.ts +2 -0
  9. package/Server/Utils/Attribution.ts +11 -11
  10. package/Server/Utils/Marketing/MarketingEventWebhook.ts +4 -5
  11. package/Server/Utils/Telemetry/ChangeEventRow.ts +228 -0
  12. package/Server/Views/Partials/AnalyticsConsent.ejs +3 -95
  13. package/Tests/App/Dashboard/DashboardChartWidgetZoom.test.tsx +64 -0
  14. package/Tests/App/Dashboard/EventOverlayHook.test.tsx +249 -0
  15. package/Tests/App/Dashboard/InvestigationArtifacts.test.tsx +315 -0
  16. package/Tests/App/Dashboard/InvestigationDrawer.test.tsx +258 -0
  17. package/Tests/App/Dashboard/MetricChartsCompare.test.tsx +171 -0
  18. package/Tests/App/Dashboard/MetricViewCompare.test.tsx +233 -0
  19. package/Tests/Server/Infrastructure/Postgres/AddUserSlackAndMicrosoftTeamsMigration.test.ts +236 -0
  20. package/Tests/Server/Utils/AI/SRE/Insights/Detectors/ExceptionIdentity.test.ts +224 -0
  21. package/Tests/Server/Utils/Attribution.test.ts +4 -5
  22. package/Tests/Server/Utils/Marketing/MarketingEventUtil.test.ts +2 -2
  23. package/Tests/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.test.ts +300 -0
  24. package/Tests/UI/Components/Charts/ChartGhostSeries.test.tsx +103 -0
  25. package/Tests/UI/Components/Charts/TooltipEntries.test.ts +44 -8
  26. package/Tests/Utils/Telemetry/CrossSignalScope.test.ts +13 -2
  27. package/Types/AnalyticsDatabase/AnalyticsTableName.ts +7 -0
  28. package/Types/Marketing/Attribution.ts +9 -15
  29. package/Types/Marketing/MarketingEvent.ts +10 -46
  30. package/UI/Components/Charts/Area/AreaChart.tsx +15 -0
  31. package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +16 -1
  32. package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +189 -93
  33. package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +6 -1
  34. package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +213 -114
  35. package/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.ts +42 -27
  36. package/UI/Components/Charts/Line/LineChart.tsx +15 -0
  37. package/Utils/Metrics/MetricExplorerUrl.ts +119 -5
  38. package/Utils/Telemetry/CrossSignalScope.ts +19 -10
  39. package/build/dist/Models/AnalyticsModels/ChangeEvent.js +298 -0
  40. package/build/dist/Models/AnalyticsModels/ChangeEvent.js.map +1 -0
  41. package/build/dist/Models/AnalyticsModels/Index.js +2 -0
  42. package/build/dist/Models/AnalyticsModels/Index.js.map +1 -1
  43. package/build/dist/Server/EnvironmentConfig.js +1 -7
  44. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  45. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js +39 -0
  46. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js.map +1 -1
  47. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +0 -2
  48. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  49. package/build/dist/Server/Services/ChangeEventService.js +9 -0
  50. package/build/dist/Server/Services/ChangeEventService.js.map +1 -0
  51. package/build/dist/Server/Services/EnterpriseLicenseService.js +3 -3
  52. package/build/dist/Server/Services/Index.js +2 -0
  53. package/build/dist/Server/Services/Index.js.map +1 -1
  54. package/build/dist/Server/Utils/Attribution.js +6 -6
  55. package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js +4 -5
  56. package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js.map +1 -1
  57. package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js +150 -0
  58. package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js.map +1 -0
  59. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js +7 -0
  60. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js.map +1 -1
  61. package/build/dist/Types/Marketing/Attribution.js +9 -15
  62. package/build/dist/Types/Marketing/Attribution.js.map +1 -1
  63. package/build/dist/Types/Marketing/MarketingEvent.js +2 -33
  64. package/build/dist/Types/Marketing/MarketingEvent.js.map +1 -1
  65. package/build/dist/UI/Components/Charts/Area/AreaChart.js +1 -1
  66. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  67. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +9 -1
  68. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
  69. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +92 -38
  70. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
  71. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +8 -8
  72. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
  73. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +95 -37
  74. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
  75. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js +39 -24
  76. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js.map +1 -1
  77. package/build/dist/UI/Components/Charts/Line/LineChart.js +1 -1
  78. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  79. package/build/dist/Utils/Metrics/MetricExplorerUrl.js +81 -0
  80. package/build/dist/Utils/Metrics/MetricExplorerUrl.js.map +1 -1
  81. package/build/dist/Utils/Telemetry/CrossSignalScope.js +14 -8
  82. package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -1
  83. package/package.json +1 -1
  84. package/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.ts +0 -117
  85. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js +0 -106
  86. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js.map +0 -1
@@ -0,0 +1,171 @@
1
+ import "@testing-library/jest-dom";
2
+ import {
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ jest,
8
+ test,
9
+ } from "@jest/globals";
10
+ import { cleanup, render, screen } from "@testing-library/react";
11
+ import * as React from "react";
12
+ import getJestMockFunction, { MockFunction } from "../../MockType";
13
+
14
+ /*
15
+ * Ghost overlays end-to-end through the real MetricCharts: previous-
16
+ * period results must render as dashed twins of DISPLAYED series only —
17
+ * never as legend chips, never consuming Top-N slots, and always
18
+ * time-shifted onto the current window's bucket grid (unshifted points
19
+ * would be silently dropped by the bucketer).
20
+ */
21
+
22
+ jest.mock("recharts", () => {
23
+ const actual: Record<string, any> = jest.requireActual("recharts") as Record<
24
+ string,
25
+ any
26
+ >;
27
+ return {
28
+ ...actual,
29
+ ResponsiveContainer: ({ children }: { children: React.ReactElement }) => {
30
+ return React.cloneElement(children, {
31
+ width: 600,
32
+ height: 300,
33
+ } as Record<string, unknown>);
34
+ },
35
+ };
36
+ });
37
+
38
+ const fetchExemplarsMock: MockFunction = getJestMockFunction();
39
+
40
+ jest.mock(
41
+ "../../../../App/FeatureSet/Dashboard/src/Components/Metrics/Utils/Metrics",
42
+ () => {
43
+ return {
44
+ __esModule: true,
45
+ default: {
46
+ fetchExemplars: (...args: Array<any>) => {
47
+ fetchExemplarsMock(...args);
48
+ return new Promise(() => {
49
+ // Intentionally never settles.
50
+ });
51
+ },
52
+ setQueryTopNOverride: () => {
53
+ return undefined;
54
+ },
55
+ getQueryConfigTopNKey: (
56
+ _queryConfig: unknown,
57
+ index: number,
58
+ scope?: string,
59
+ ) => {
60
+ return `${scope || ""}:${index}`;
61
+ },
62
+ clearQueryTopNOverridesForScope: () => {
63
+ return undefined;
64
+ },
65
+ serializeAttributeFiltersForKey: (attributes: unknown) => {
66
+ return JSON.stringify(attributes || {});
67
+ },
68
+ },
69
+ DEFAULT_TOP_N_SERIES: 10,
70
+ SHOW_ALL_SERIES_TOP_N: 10_000,
71
+ sanitizeAttributeFilters: (attributes: unknown) => {
72
+ return attributes;
73
+ },
74
+ };
75
+ },
76
+ );
77
+
78
+ import MetricCharts from "../../../../App/FeatureSet/Dashboard/src/Components/Metrics/MetricCharts";
79
+ import AggregatedResult from "../../../Types/BaseDatabase/AggregatedResult";
80
+ import InBetween from "../../../Types/BaseDatabase/InBetween";
81
+ import MetricsAggregationType from "../../../Types/Metrics/MetricsAggregationType";
82
+ import MetricQueryConfigData from "../../../Types/Metrics/MetricQueryConfigData";
83
+ import MetricViewData from "../../../Types/Metrics/MetricViewData";
84
+
85
+ const WINDOW_START: Date = new Date("2026-08-20T10:00:00.000Z");
86
+ const WINDOW_END: Date = new Date("2026-08-20T11:00:00.000Z");
87
+ const WINDOW_MS: number = WINDOW_END.getTime() - WINDOW_START.getTime();
88
+
89
+ function buildViewData(): MetricViewData {
90
+ return {
91
+ queryConfigs: [
92
+ {
93
+ metricAliasData: { metricVariable: "a", legend: "CPU" },
94
+ metricQueryData: {
95
+ filterData: {
96
+ metricName: "cpu.usage",
97
+ attributes: {},
98
+ aggegationType: MetricsAggregationType.Avg,
99
+ },
100
+ },
101
+ } as unknown as MetricQueryConfigData,
102
+ ],
103
+ formulaConfigs: [],
104
+ startAndEndDate: new InBetween<Date>(WINDOW_START, WINDOW_END),
105
+ } as MetricViewData;
106
+ }
107
+
108
+ function buildResult(offsetMs: number): AggregatedResult {
109
+ return {
110
+ data: [
111
+ {
112
+ timestamp: new Date(
113
+ new Date("2026-08-20T10:10:00.000Z").getTime() - offsetMs,
114
+ ),
115
+ value: 40,
116
+ },
117
+ {
118
+ timestamp: new Date(
119
+ new Date("2026-08-20T10:30:00.000Z").getTime() - offsetMs,
120
+ ),
121
+ value: 70,
122
+ },
123
+ ],
124
+ truncated: false,
125
+ } as unknown as AggregatedResult;
126
+ }
127
+
128
+ beforeEach(() => {
129
+ fetchExemplarsMock.mockReset();
130
+ });
131
+
132
+ afterEach(() => {
133
+ cleanup();
134
+ });
135
+
136
+ describe("MetricCharts compare overlays", () => {
137
+ test("renders a dashed ghost twin without adding legend chips", () => {
138
+ const { container } = render(
139
+ <MetricCharts
140
+ metricViewData={buildViewData()}
141
+ metricResults={[buildResult(0)]}
142
+ metricResultsPrevious={[buildResult(WINDOW_MS)]}
143
+ compareOffsetMs={WINDOW_MS}
144
+ metricTypes={[]}
145
+ />,
146
+ );
147
+
148
+ // The ghost line is on the chart, dashed…
149
+ expect(
150
+ container.querySelector('path[stroke-dasharray="6 4"]'),
151
+ ).toBeInTheDocument();
152
+
153
+ // …but the chip legend shows only the live series.
154
+ expect(screen.getAllByText("CPU").length).toBeGreaterThan(0);
155
+ expect(screen.queryByText("CPU (previous)")).toBeNull();
156
+ // And the series count still reads 1.
157
+ expect(screen.getByText("1 series")).toBeInTheDocument();
158
+ });
159
+
160
+ test("no previous results means no ghosts", () => {
161
+ const { container } = render(
162
+ <MetricCharts
163
+ metricViewData={buildViewData()}
164
+ metricResults={[buildResult(0)]}
165
+ metricTypes={[]}
166
+ />,
167
+ );
168
+
169
+ expect(container.querySelector('path[stroke-dasharray="6 4"]')).toBeNull();
170
+ });
171
+ });
@@ -0,0 +1,233 @@
1
+ import "@testing-library/jest-dom";
2
+ import {
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ jest,
8
+ test,
9
+ } from "@jest/globals";
10
+ import { cleanup, render, waitFor } from "@testing-library/react";
11
+ import * as React from "react";
12
+ import getJestMockFunction, { MockFunction } from "../../MockType";
13
+
14
+ /*
15
+ * Compare-to-previous-period: MetricView must fetch the SAME queries a
16
+ * second time over the window shifted back by exactly one window width,
17
+ * hand both result sets to MetricCharts, and degrade to live-only when
18
+ * the shifted fetch fails.
19
+ */
20
+
21
+ const fetchResultsMock: MockFunction = getJestMockFunction();
22
+ const metricChartsMock: MockFunction = getJestMockFunction();
23
+
24
+ jest.mock(
25
+ "../../../../App/FeatureSet/Dashboard/src/Components/Metrics/Utils/Metrics",
26
+ () => {
27
+ return {
28
+ __esModule: true,
29
+ default: {
30
+ fetchResults: (...args: Array<any>) => {
31
+ return fetchResultsMock(...args);
32
+ },
33
+ getMetricTypes: () => {
34
+ return Promise.resolve([]);
35
+ },
36
+ loadAllMetricsTypes: () => {
37
+ return Promise.resolve({
38
+ metricTypes: [],
39
+ telemetryServices: [],
40
+ });
41
+ },
42
+ },
43
+ };
44
+ },
45
+ );
46
+
47
+ jest.mock(
48
+ "../../../../App/FeatureSet/Dashboard/src/Components/Metrics/MetricCharts",
49
+ () => {
50
+ return {
51
+ __esModule: true,
52
+ default: (props: Record<string, unknown>): React.ReactElement => {
53
+ metricChartsMock(props);
54
+ return React.createElement("div", {
55
+ "data-testid": "metric-charts",
56
+ });
57
+ },
58
+ };
59
+ },
60
+ );
61
+
62
+ import MetricView from "../../../../App/FeatureSet/Dashboard/src/Components/Metrics/MetricView";
63
+ import InBetween from "../../../Types/BaseDatabase/InBetween";
64
+ import MetricsAggregationType from "../../../Types/Metrics/MetricsAggregationType";
65
+ import MetricViewData from "../../../Types/Metrics/MetricViewData";
66
+
67
+ const WINDOW_START: Date = new Date("2026-08-20T10:00:00.000Z");
68
+ const WINDOW_END: Date = new Date("2026-08-20T11:00:00.000Z");
69
+ const WINDOW_MS: number = WINDOW_END.getTime() - WINDOW_START.getTime();
70
+
71
+ function buildData(): MetricViewData {
72
+ return {
73
+ queryConfigs: [
74
+ {
75
+ metricAliasData: { metricVariable: "a" },
76
+ metricQueryData: {
77
+ filterData: {
78
+ metricName: "cpu.usage",
79
+ attributes: {},
80
+ aggegationType: MetricsAggregationType.Avg,
81
+ },
82
+ },
83
+ },
84
+ ],
85
+ formulaConfigs: [],
86
+ startAndEndDate: new InBetween<Date>(WINDOW_START, WINDOW_END),
87
+ } as unknown as MetricViewData;
88
+ }
89
+
90
+ interface CapturedFetch {
91
+ metricViewData: MetricViewData;
92
+ }
93
+
94
+ beforeEach(() => {
95
+ fetchResultsMock.mockReset();
96
+ metricChartsMock.mockReset();
97
+ fetchResultsMock.mockReturnValue(
98
+ Promise.resolve([{ data: [], truncated: false }]),
99
+ );
100
+ });
101
+
102
+ afterEach(() => {
103
+ cleanup();
104
+ });
105
+
106
+ describe("MetricView compare with previous period", () => {
107
+ test("fetches the shifted window alongside the live one and hands both down", async () => {
108
+ render(
109
+ <MetricView
110
+ data={buildData()}
111
+ hideQueryElements={true}
112
+ hideStartAndEndDate={true}
113
+ compareWithPreviousPeriod={true}
114
+ onChange={() => {
115
+ // not exercised
116
+ }}
117
+ />,
118
+ );
119
+
120
+ await waitFor(() => {
121
+ expect(fetchResultsMock.mock.calls.length).toBeGreaterThanOrEqual(2);
122
+ });
123
+
124
+ const windows: Array<[number, number]> = fetchResultsMock.mock.calls.map(
125
+ (call: Array<unknown>): [number, number] => {
126
+ const args: CapturedFetch = call[0] as CapturedFetch;
127
+ return [
128
+ args.metricViewData.startAndEndDate!.startValue.getTime(),
129
+ args.metricViewData.startAndEndDate!.endValue.getTime(),
130
+ ];
131
+ },
132
+ );
133
+
134
+ /*
135
+ * One live window and one shifted back by EXACTLY the window width
136
+ * (bucket-congruent modulo the shift). Alignment may floor the live
137
+ * start onto the bucket grid, so assert the relationship between the
138
+ * two fetches rather than raw instants.
139
+ */
140
+ const [liveWindow, previousWindow] = windows as [
141
+ [number, number],
142
+ [number, number],
143
+ ];
144
+ const liveWidth: number = liveWindow[1] - liveWindow[0];
145
+ expect(previousWindow[0]).toBe(liveWindow[0] - liveWidth);
146
+ expect(previousWindow[1]).toBe(liveWindow[1] - liveWidth);
147
+ expect(liveWidth).toBeGreaterThanOrEqual(WINDOW_MS);
148
+
149
+ await waitFor(() => {
150
+ const lastProps: Record<string, unknown> = metricChartsMock.mock.calls[
151
+ metricChartsMock.mock.calls.length - 1
152
+ ]?.[0] as Record<string, unknown>;
153
+ expect(lastProps["metricResultsPrevious"]).toBeTruthy();
154
+ expect(lastProps["compareOffsetMs"]).toBe(liveWidth);
155
+ });
156
+ });
157
+
158
+ test("compare off means exactly one fetch and no ghost props", async () => {
159
+ render(
160
+ <MetricView
161
+ data={buildData()}
162
+ hideQueryElements={true}
163
+ hideStartAndEndDate={true}
164
+ onChange={() => {
165
+ // not exercised
166
+ }}
167
+ />,
168
+ );
169
+
170
+ await waitFor(() => {
171
+ expect(fetchResultsMock).toHaveBeenCalled();
172
+ });
173
+ // Let any (wrong) shifted fetch get scheduled before asserting.
174
+ await new Promise((resolve: (value: unknown) => void) => {
175
+ setTimeout(resolve, 30);
176
+ });
177
+ /*
178
+ * MetricView may legitimately refetch the LIVE window more than once
179
+ * on mount — what compare-off forbids is any fetch of a SHIFTED
180
+ * (earlier) window.
181
+ */
182
+ const anyShiftedFetch: boolean = fetchResultsMock.mock.calls.some(
183
+ (call: Array<unknown>): boolean => {
184
+ const args: CapturedFetch = call[0] as CapturedFetch;
185
+ return (
186
+ args.metricViewData.startAndEndDate!.endValue.getTime() <=
187
+ WINDOW_START.getTime()
188
+ );
189
+ },
190
+ );
191
+ expect(anyShiftedFetch).toBe(false);
192
+ const lastProps: Record<string, unknown> = metricChartsMock.mock.calls[
193
+ metricChartsMock.mock.calls.length - 1
194
+ ]?.[0] as Record<string, unknown>;
195
+ expect(lastProps["metricResultsPrevious"]).toBeUndefined();
196
+ });
197
+
198
+ test("a failed shifted fetch degrades to live-only, never an error", async () => {
199
+ fetchResultsMock.mockImplementation(
200
+ (args: { metricViewData: MetricViewData }) => {
201
+ const start: number =
202
+ args.metricViewData.startAndEndDate!.startValue.getTime();
203
+ // The shifted (earlier) fetch fails; the live one succeeds.
204
+ if (start < WINDOW_START.getTime()) {
205
+ return Promise.reject(new Error("clickhouse hiccup"));
206
+ }
207
+ return Promise.resolve([{ data: [], truncated: false }]);
208
+ },
209
+ );
210
+
211
+ render(
212
+ <MetricView
213
+ data={buildData()}
214
+ hideQueryElements={true}
215
+ hideStartAndEndDate={true}
216
+ compareWithPreviousPeriod={true}
217
+ onChange={() => {
218
+ // not exercised
219
+ }}
220
+ />,
221
+ );
222
+
223
+ await waitFor(() => {
224
+ expect(metricChartsMock).toHaveBeenCalled();
225
+ });
226
+
227
+ const lastProps: Record<string, unknown> = metricChartsMock.mock.calls[
228
+ metricChartsMock.mock.calls.length - 1
229
+ ]?.[0] as Record<string, unknown>;
230
+ expect(lastProps["metricResults"]).toBeTruthy();
231
+ expect(lastProps["metricResultsPrevious"]).toBeUndefined();
232
+ });
233
+ });
@@ -0,0 +1,236 @@
1
+ import { AddUserSlackAndMicrosoftTeams1789000000000 } from "../../../../Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams";
2
+ import SchemaMigrations from "../../../../Server/Infrastructure/Postgres/SchemaMigrations/Index";
3
+ import { MigrationInterface, QueryRunner } from "typeorm";
4
+ import fs from "fs";
5
+ import path from "path";
6
+ import { describe, expect, test } from "@jest/globals";
7
+
8
+ /*
9
+ * The guard that lets this migration run twice.
10
+ *
11
+ * It shipped as AddUserSlackAndMicrosoftTeams1788800000000 and was renumbered
12
+ * to 1789000000000 to clear a timestamp collision. TypeORM records a migration
13
+ * by class NAME, so the rename made it pending again on every database that
14
+ * had already run it, and `CREATE TABLE "UserSlack"` failed on its own table —
15
+ * which stops the runner, leaving this and every later migration pending on
16
+ * every boot until it is fixed.
17
+ *
18
+ * So there are two databases to satisfy, and both are asserted below: one that
19
+ * has never seen this migration must get the whole schema, and one that
20
+ * already carries it must get no DDL at all.
21
+ *
22
+ * The statements are inspected rather than executed — these tests run without
23
+ * a database, and what can go wrong here is which statements are issued, not
24
+ * whether Postgres understands them.
25
+ */
26
+
27
+ const MIGRATION_NAME: string = "AddUserSlackAndMicrosoftTeams1789000000000";
28
+
29
+ const SUPERSEDED_MIGRATION_NAME: string =
30
+ "AddUserSlackAndMicrosoftTeams1788800000000";
31
+
32
+ const MIGRATION_TIMESTAMP: string = "1789000000000";
33
+
34
+ const MIGRATION_DIRECTORY: string = path.join(
35
+ __dirname,
36
+ "../../../../Server/Infrastructure/Postgres/SchemaMigrations",
37
+ );
38
+
39
+ /*
40
+ * Every statement `up` issues after the probe. The count is asserted rather
41
+ * than sampled: the risk the guard introduces is an early return that swallows
42
+ * work a fresh install needs, and only a total catches a statement going
43
+ * missing from the middle.
44
+ */
45
+ const STATEMENTS_IN_UP: number = 30;
46
+
47
+ type CapturedQueryRunner = {
48
+ runner: QueryRunner;
49
+ queries: Array<string>;
50
+ };
51
+
52
+ type IsProbeFunction = (sql: string) => boolean;
53
+
54
+ /*
55
+ * The one read `up` makes: does "UserSlack" already exist? Matched on shape
56
+ * rather than on the exact string so reformatting the SQL does not fail these.
57
+ */
58
+ const isProbe: IsProbeFunction = (sql: string): boolean => {
59
+ return (
60
+ sql.includes("information_schema.tables") && sql.includes("'UserSlack'")
61
+ );
62
+ };
63
+
64
+ type MockQueryRunnerFunction = (
65
+ userSlackTableExists: boolean,
66
+ ) => CapturedQueryRunner;
67
+
68
+ const mockQueryRunner: MockQueryRunnerFunction = (
69
+ userSlackTableExists: boolean,
70
+ ): CapturedQueryRunner => {
71
+ const queries: Array<string> = [];
72
+
73
+ return {
74
+ queries: queries,
75
+ runner: {
76
+ query: async (sql: string): Promise<unknown> => {
77
+ queries.push(sql);
78
+
79
+ if (isProbe(sql)) {
80
+ return [{ exists: userSlackTableExists }];
81
+ }
82
+
83
+ return undefined;
84
+ },
85
+ } as unknown as QueryRunner,
86
+ };
87
+ };
88
+
89
+ const migration: AddUserSlackAndMicrosoftTeams1789000000000 =
90
+ new AddUserSlackAndMicrosoftTeams1789000000000();
91
+
92
+ describe("AddUserSlackAndMicrosoftTeams migration", () => {
93
+ /*
94
+ * TypeORM matches migrations by this name. A mismatch between the class name
95
+ * and the `name` property makes the migration run again on every boot.
96
+ */
97
+ test("names itself consistently so it is recorded as run", () => {
98
+ expect(migration.name).toBe(MIGRATION_NAME);
99
+ expect(AddUserSlackAndMicrosoftTeams1789000000000.name).toBe(
100
+ MIGRATION_NAME,
101
+ );
102
+ });
103
+
104
+ describe("registration", () => {
105
+ test("it is registered exactly once", () => {
106
+ const occurrences: number = SchemaMigrations.filter(
107
+ (registered: new () => MigrationInterface): boolean => {
108
+ return registered.name === MIGRATION_NAME;
109
+ },
110
+ ).length;
111
+
112
+ expect(occurrences).toBe(1);
113
+ });
114
+
115
+ /*
116
+ * The old number belongs to DropMarketingConversionAddEnterpriseLicenseEmail
117
+ * now. Registering both names would run the same DDL twice under two
118
+ * records, which is the failure this migration already had once.
119
+ */
120
+ test("no longer registers the timestamp it was renamed away from", () => {
121
+ const names: Array<string> = SchemaMigrations.map(
122
+ (registered: new () => MigrationInterface): string => {
123
+ return registered.name;
124
+ },
125
+ );
126
+
127
+ expect(names).not.toContain(SUPERSEDED_MIGRATION_NAME);
128
+ });
129
+
130
+ test("exactly one file on disk carries its timestamp, and it is this one", () => {
131
+ const matching: Array<string> = fs
132
+ .readdirSync(MIGRATION_DIRECTORY)
133
+ .filter((file: string): boolean => {
134
+ return file.startsWith(`${MIGRATION_TIMESTAMP}-`);
135
+ });
136
+
137
+ expect(matching).toEqual([
138
+ `${MIGRATION_TIMESTAMP}-AddUserSlackAndMicrosoftTeams.ts`,
139
+ ]);
140
+ });
141
+ });
142
+
143
+ describe("on a database that has never run it", () => {
144
+ test("checks for the table before touching anything", async () => {
145
+ const up: CapturedQueryRunner = mockQueryRunner(false);
146
+
147
+ await migration.up(up.runner);
148
+
149
+ expect(isProbe(up.queries[0]!)).toBe(true);
150
+ });
151
+
152
+ test("creates the whole schema, unchanged by the guard", async () => {
153
+ const up: CapturedQueryRunner = mockQueryRunner(false);
154
+
155
+ await migration.up(up.runner);
156
+
157
+ const statements: Array<string> = up.queries.filter(
158
+ (sql: string): boolean => {
159
+ return !isProbe(sql);
160
+ },
161
+ );
162
+
163
+ expect(statements.length).toBe(STATEMENTS_IN_UP);
164
+ });
165
+
166
+ test("creates both method tables and their notification wiring", async () => {
167
+ const up: CapturedQueryRunner = mockQueryRunner(false);
168
+
169
+ await migration.up(up.runner);
170
+
171
+ const sql: string = up.queries.join("\n");
172
+
173
+ expect(sql).toContain(`CREATE TABLE "UserSlack"`);
174
+ expect(sql).toContain(`CREATE TABLE "UserMicrosoftTeams"`);
175
+ expect(sql).toContain(
176
+ `ALTER TABLE "UserNotificationRule" ADD "userSlackId" uuid`,
177
+ );
178
+ expect(sql).toContain(
179
+ `ALTER TABLE "UserNotificationRule" ADD "userMicrosoftTeamsId" uuid`,
180
+ );
181
+ expect(sql).toContain(
182
+ `ALTER TABLE "UserNotificationSetting" ADD "alertBySlack" boolean NOT NULL DEFAULT false`,
183
+ );
184
+ expect(sql).toContain(
185
+ `ALTER TABLE "UserNotificationSetting" ADD "alertByMicrosoftTeams" boolean NOT NULL DEFAULT false`,
186
+ );
187
+ });
188
+ });
189
+
190
+ describe("on a database that already ran it under its old name", () => {
191
+ /*
192
+ * The whole point. Migrations run one transaction each, so a recorded run
193
+ * committed all 30 statements or none of them — the table being there means
194
+ * the schema is there, and re-issuing any of it can only fail.
195
+ */
196
+ test("issues nothing but the probe", async () => {
197
+ const up: CapturedQueryRunner = mockQueryRunner(true);
198
+
199
+ await migration.up(up.runner);
200
+
201
+ expect(up.queries.length).toBe(1);
202
+ expect(isProbe(up.queries[0]!)).toBe(true);
203
+ });
204
+
205
+ test("never re-runs the CREATE TABLE that was failing", async () => {
206
+ const up: CapturedQueryRunner = mockQueryRunner(true);
207
+
208
+ await migration.up(up.runner);
209
+
210
+ const sql: string = up.queries.join("\n");
211
+
212
+ expect(sql).not.toContain(`CREATE TABLE "UserSlack"`);
213
+ expect(sql).not.toContain(`CREATE TABLE "UserMicrosoftTeams"`);
214
+ expect(sql).not.toContain("ADD CONSTRAINT");
215
+ });
216
+ });
217
+
218
+ /*
219
+ * `down` is deliberately NOT guarded. It reverses the schema whichever name
220
+ * recorded it, and a rollback that finds the tables missing has bigger
221
+ * problems than a failed DROP.
222
+ */
223
+ describe("down", () => {
224
+ test("drops both method tables", async () => {
225
+ const down: CapturedQueryRunner = mockQueryRunner(true);
226
+
227
+ await migration.down(down.runner);
228
+
229
+ const sql: string = down.queries.join("\n");
230
+
231
+ expect(sql).toContain(`DROP TABLE "UserSlack"`);
232
+ expect(sql).toContain(`DROP TABLE "UserMicrosoftTeams"`);
233
+ expect(down.queries.length).toBe(STATEMENTS_IN_UP);
234
+ });
235
+ });
236
+ });