@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.
- package/Models/AnalyticsModels/ChangeEvent.ts +331 -0
- package/Models/AnalyticsModels/Index.ts +2 -0
- package/Server/EnvironmentConfig.ts +1 -8
- package/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.ts +42 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +0 -2
- package/Server/Services/ChangeEventService.ts +11 -0
- package/Server/Services/EnterpriseLicenseService.ts +3 -3
- package/Server/Services/Index.ts +2 -0
- package/Server/Utils/Attribution.ts +11 -11
- package/Server/Utils/Marketing/MarketingEventWebhook.ts +4 -5
- package/Server/Utils/Telemetry/ChangeEventRow.ts +228 -0
- package/Server/Views/Partials/AnalyticsConsent.ejs +3 -95
- package/Tests/App/Dashboard/DashboardChartWidgetZoom.test.tsx +64 -0
- package/Tests/App/Dashboard/EventOverlayHook.test.tsx +249 -0
- package/Tests/App/Dashboard/InvestigationArtifacts.test.tsx +315 -0
- package/Tests/App/Dashboard/InvestigationDrawer.test.tsx +258 -0
- package/Tests/App/Dashboard/MetricChartsCompare.test.tsx +171 -0
- package/Tests/App/Dashboard/MetricViewCompare.test.tsx +233 -0
- package/Tests/Server/Infrastructure/Postgres/AddUserSlackAndMicrosoftTeamsMigration.test.ts +236 -0
- package/Tests/Server/Utils/AI/SRE/Insights/Detectors/ExceptionIdentity.test.ts +224 -0
- package/Tests/Server/Utils/Attribution.test.ts +4 -5
- package/Tests/Server/Utils/Marketing/MarketingEventUtil.test.ts +2 -2
- package/Tests/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.test.ts +300 -0
- package/Tests/UI/Components/Charts/ChartGhostSeries.test.tsx +103 -0
- package/Tests/UI/Components/Charts/TooltipEntries.test.ts +44 -8
- package/Tests/Utils/Telemetry/CrossSignalScope.test.ts +13 -2
- package/Types/AnalyticsDatabase/AnalyticsTableName.ts +7 -0
- package/Types/Marketing/Attribution.ts +9 -15
- package/Types/Marketing/MarketingEvent.ts +10 -46
- package/UI/Components/Charts/Area/AreaChart.tsx +15 -0
- package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +16 -1
- package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +189 -93
- package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +6 -1
- package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +213 -114
- package/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.ts +42 -27
- package/UI/Components/Charts/Line/LineChart.tsx +15 -0
- package/Utils/Metrics/MetricExplorerUrl.ts +119 -5
- package/Utils/Telemetry/CrossSignalScope.ts +19 -10
- package/build/dist/Models/AnalyticsModels/ChangeEvent.js +298 -0
- package/build/dist/Models/AnalyticsModels/ChangeEvent.js.map +1 -0
- package/build/dist/Models/AnalyticsModels/Index.js +2 -0
- package/build/dist/Models/AnalyticsModels/Index.js.map +1 -1
- package/build/dist/Server/EnvironmentConfig.js +1 -7
- package/build/dist/Server/EnvironmentConfig.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js +39 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +0 -2
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/ChangeEventService.js +9 -0
- package/build/dist/Server/Services/ChangeEventService.js.map +1 -0
- package/build/dist/Server/Services/EnterpriseLicenseService.js +3 -3
- package/build/dist/Server/Services/Index.js +2 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Utils/Attribution.js +6 -6
- package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js +4 -5
- package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js +150 -0
- package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js.map +1 -0
- package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js +7 -0
- package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js.map +1 -1
- package/build/dist/Types/Marketing/Attribution.js +9 -15
- package/build/dist/Types/Marketing/Attribution.js.map +1 -1
- package/build/dist/Types/Marketing/MarketingEvent.js +2 -33
- package/build/dist/Types/Marketing/MarketingEvent.js.map +1 -1
- package/build/dist/UI/Components/Charts/Area/AreaChart.js +1 -1
- package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +9 -1
- package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +92 -38
- package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +8 -8
- package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +95 -37
- package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js +39 -24
- package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js.map +1 -1
- package/build/dist/UI/Components/Charts/Line/LineChart.js +1 -1
- package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
- package/build/dist/Utils/Metrics/MetricExplorerUrl.js +81 -0
- package/build/dist/Utils/Metrics/MetricExplorerUrl.js.map +1 -1
- package/build/dist/Utils/Telemetry/CrossSignalScope.js +14 -8
- package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -1
- package/package.json +1 -1
- package/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.ts +0 -117
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js +0 -106
- 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
|
+
});
|