@oneuptime/common 12.0.19 → 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 (124) hide show
  1. package/Models/AnalyticsModels/ChangeEvent.ts +331 -0
  2. package/Models/AnalyticsModels/Index.ts +2 -0
  3. package/Server/API/EnterpriseLicenseAPI.ts +94 -19
  4. package/Server/EnvironmentConfig.ts +15 -5
  5. package/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.ts +42 -0
  6. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +0 -2
  7. package/Server/Services/ChangeEventService.ts +11 -0
  8. package/Server/Services/EnterpriseLicenseService.ts +3 -3
  9. package/Server/Services/Index.ts +2 -0
  10. package/Server/Services/ProjectService.ts +104 -0
  11. package/Server/Utils/Attribution.ts +11 -11
  12. package/Server/Utils/Marketing/MarketingEventWebhook.ts +4 -5
  13. package/Server/Utils/Response.ts +2 -2
  14. package/Server/Utils/StartServer.ts +2 -2
  15. package/Server/Utils/Telemetry/ChangeEventRow.ts +228 -0
  16. package/Server/Views/Partials/AnalyticsConsent.ejs +441 -0
  17. package/Tests/App/Dashboard/AlertEpisodeViewFields.test.tsx +465 -0
  18. package/Tests/App/Dashboard/DashboardChartWidgetZoom.test.tsx +366 -0
  19. package/Tests/App/Dashboard/DiscoveryScanWizardValidation.test.tsx +408 -0
  20. package/Tests/App/Dashboard/EventOverlayHook.test.tsx +249 -0
  21. package/Tests/App/Dashboard/IncidentEpisodeViewFields.test.tsx +474 -0
  22. package/Tests/App/Dashboard/InvestigationArtifacts.test.tsx +315 -0
  23. package/Tests/App/Dashboard/InvestigationDrawer.test.tsx +258 -0
  24. package/Tests/App/Dashboard/MetricChartsCompare.test.tsx +171 -0
  25. package/Tests/App/Dashboard/MetricSeriesInvestigateMenu.test.tsx +368 -0
  26. package/Tests/App/Dashboard/MetricViewCompare.test.tsx +233 -0
  27. package/Tests/Server/API/EnterpriseLicenseReportUserCount.test.ts +763 -0
  28. package/Tests/Server/Infrastructure/Postgres/AddUserSlackAndMicrosoftTeamsMigration.test.ts +236 -0
  29. package/Tests/Server/Services/ProjectServiceChangePlan.test.ts +138 -0
  30. package/Tests/Server/Services/ProjectServiceCreateSubscriptionStarted.test.ts +516 -0
  31. package/Tests/Server/Utils/AI/SRE/Insights/Detectors/ExceptionIdentity.test.ts +224 -0
  32. package/Tests/Server/Utils/Attribution.test.ts +4 -5
  33. package/Tests/Server/Utils/Marketing/MarketingEventUtil.test.ts +2 -2
  34. package/Tests/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.test.ts +300 -0
  35. package/Tests/Server/Utils/ResponseRenderAnalytics.test.ts +53 -0
  36. package/Tests/Server/Views/AnalyticsConsentPartial.test.ts +241 -0
  37. package/Tests/UI/Components/Charts/ChartGhostSeries.test.tsx +103 -0
  38. package/Tests/UI/Components/Charts/ChartTooltipSorted.test.tsx +129 -0
  39. package/Tests/UI/Components/Charts/TooltipEntries.test.ts +191 -0
  40. package/Tests/UI/Components/Detail/DetailFieldErrors.test.tsx +347 -0
  41. package/Tests/UI/Components/Forms/BasicFormStepValidation.test.tsx +392 -0
  42. package/Tests/UI/Components/Forms/ValidationFormSteps.test.ts +549 -0
  43. package/Tests/Utils/EnterpriseLicenseSync.test.ts +562 -0
  44. package/Tests/Utils/Telemetry/CrossSignalScope.test.ts +13 -2
  45. package/Types/Analytics/RevenueEvent.ts +1 -0
  46. package/Types/AnalyticsDatabase/AnalyticsTableName.ts +7 -0
  47. package/Types/Marketing/Attribution.ts +9 -15
  48. package/Types/Marketing/MarketingEvent.ts +19 -12
  49. package/UI/Components/Charts/Area/AreaChart.tsx +23 -1
  50. package/UI/Components/Charts/Bar/BarChart.tsx +8 -1
  51. package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +16 -1
  52. package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +208 -97
  53. package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +26 -3
  54. package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +232 -118
  55. package/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.ts +120 -0
  56. package/UI/Components/Charts/Line/LineChart.tsx +23 -1
  57. package/UI/Components/Detail/Detail.tsx +18 -1
  58. package/Utils/EnterpriseLicense/EnterpriseLicenseSync.ts +244 -0
  59. package/Utils/Metrics/MetricExplorerUrl.ts +119 -5
  60. package/Utils/Telemetry/CrossSignalScope.ts +19 -10
  61. package/build/dist/Models/AnalyticsModels/ChangeEvent.js +298 -0
  62. package/build/dist/Models/AnalyticsModels/ChangeEvent.js.map +1 -0
  63. package/build/dist/Models/AnalyticsModels/Index.js +2 -0
  64. package/build/dist/Models/AnalyticsModels/Index.js.map +1 -1
  65. package/build/dist/Server/API/EnterpriseLicenseAPI.js +66 -12
  66. package/build/dist/Server/API/EnterpriseLicenseAPI.js.map +1 -1
  67. package/build/dist/Server/EnvironmentConfig.js +14 -5
  68. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  69. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js +39 -0
  70. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js.map +1 -1
  71. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +0 -2
  72. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  73. package/build/dist/Server/Services/ChangeEventService.js +9 -0
  74. package/build/dist/Server/Services/ChangeEventService.js.map +1 -0
  75. package/build/dist/Server/Services/EnterpriseLicenseService.js +3 -3
  76. package/build/dist/Server/Services/Index.js +2 -0
  77. package/build/dist/Server/Services/Index.js.map +1 -1
  78. package/build/dist/Server/Services/ProjectService.js +88 -0
  79. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  80. package/build/dist/Server/Utils/Attribution.js +6 -6
  81. package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js +4 -5
  82. package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js.map +1 -1
  83. package/build/dist/Server/Utils/Response.js +2 -2
  84. package/build/dist/Server/Utils/Response.js.map +1 -1
  85. package/build/dist/Server/Utils/StartServer.js +2 -2
  86. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  87. package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js +150 -0
  88. package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js.map +1 -0
  89. package/build/dist/Types/Analytics/RevenueEvent.js +1 -0
  90. package/build/dist/Types/Analytics/RevenueEvent.js.map +1 -1
  91. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js +7 -0
  92. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js.map +1 -1
  93. package/build/dist/Types/Marketing/Attribution.js +9 -15
  94. package/build/dist/Types/Marketing/Attribution.js.map +1 -1
  95. package/build/dist/Types/Marketing/MarketingEvent.js +12 -4
  96. package/build/dist/Types/Marketing/MarketingEvent.js.map +1 -1
  97. package/build/dist/UI/Components/Charts/Area/AreaChart.js +10 -2
  98. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  99. package/build/dist/UI/Components/Charts/Bar/BarChart.js +9 -1
  100. package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
  101. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +9 -1
  102. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
  103. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +114 -51
  104. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
  105. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +35 -23
  106. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
  107. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +122 -55
  108. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
  109. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js +86 -0
  110. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js.map +1 -0
  111. package/build/dist/UI/Components/Charts/Line/LineChart.js +10 -2
  112. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  113. package/build/dist/UI/Components/Detail/Detail.js +17 -1
  114. package/build/dist/UI/Components/Detail/Detail.js.map +1 -1
  115. package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSync.js +174 -0
  116. package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSync.js.map +1 -0
  117. package/build/dist/Utils/Metrics/MetricExplorerUrl.js +81 -0
  118. package/build/dist/Utils/Metrics/MetricExplorerUrl.js.map +1 -1
  119. package/build/dist/Utils/Telemetry/CrossSignalScope.js +14 -8
  120. package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -1
  121. package/package.json +1 -1
  122. package/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.ts +0 -117
  123. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js +0 -106
  124. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js.map +0 -1
@@ -0,0 +1,474 @@
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, waitFor } from "@testing-library/react";
11
+ import React from "react";
12
+ import { Location } from "react-router-dom";
13
+ import getJestMockFunction, { MockFunction } from "../../MockType";
14
+
15
+ /*
16
+ * Issue #3374: an Incident Episode created without a severity - which the REST
17
+ * API allows, since only `title` is required and incidentSeverityId is a
18
+ * nullable column - listed fine but could not be opened. The severity field's
19
+ * getElement threw BadDataException("Episode Severity not found") from inside
20
+ * Detail's own render, so the whole route was replaced by the app level
21
+ * "Something went wrong" boundary. The only recovery was deleting the episode,
22
+ * which detached every member incident.
23
+ *
24
+ * These tests drive the real page against a fake server and assert what a
25
+ * person actually sees: the Episode Severity row, holding a placeholder.
26
+ *
27
+ * The current state field had the identical shape. Its FK is NOT NULL, but the
28
+ * relation is nullable with orphanedRowAction "nullify" and nothing stops an
29
+ * in-use IncidentState from being soft deleted - so it is covered here too.
30
+ *
31
+ * Detail now also swallows a throwing field renderer as a last resort. That
32
+ * guard must NOT be what makes these pass: every case asserts the page never
33
+ * logged "threw and was ignored", which is how a regression at the page level
34
+ * stays visible instead of being quietly absorbed by the framework.
35
+ */
36
+
37
+ const EPISODE_ID: string = "11111111-1111-4111-8111-111111111111";
38
+ const PROJECT_ID: string = "22222222-2222-4222-8222-222222222222";
39
+
40
+ const getItemMock: MockFunction = getJestMockFunction();
41
+ const getListMock: MockFunction = getJestMockFunction();
42
+ const getCommonHeadersMock: MockFunction = getJestMockFunction();
43
+ const loggerErrorMock: MockFunction = getJestMockFunction();
44
+
45
+ /*
46
+ * The arrow wrappers are load bearing: jest.mock is hoisted above the compiled
47
+ * requires, so the mock consts above are still unassigned when the factory
48
+ * runs. Dereferencing them lazily, at call time, is what makes this work.
49
+ */
50
+ jest.mock("../../../UI/Utils/ModelAPI/ModelAPI", () => {
51
+ return {
52
+ __esModule: true,
53
+ default: {
54
+ getItem: (...args: Array<any>) => {
55
+ return getItemMock(...args);
56
+ },
57
+ getList: (...args: Array<any>) => {
58
+ return getListMock(...args);
59
+ },
60
+ getCommonHeaders: (...args: Array<any>) => {
61
+ return getCommonHeadersMock(...args);
62
+ },
63
+ },
64
+ };
65
+ });
66
+
67
+ jest.mock("../../../UI/Utils/Logger", () => {
68
+ return {
69
+ __esModule: true,
70
+ Logger: {
71
+ error: (...args: Array<any>) => {
72
+ return loggerErrorMock(...args);
73
+ },
74
+ warn: () => {
75
+ return undefined;
76
+ },
77
+ log: () => {
78
+ return undefined;
79
+ },
80
+ info: () => {
81
+ return undefined;
82
+ },
83
+ },
84
+ };
85
+ });
86
+
87
+ /*
88
+ * The page's three heavy children each drive their own API traffic and none of
89
+ * them renders a detail field. Stubbing them keeps this suite pointed at the
90
+ * Episode Details card, which is where #3374 lives.
91
+ */
92
+ jest.mock(
93
+ "../../../../App/FeatureSet/Dashboard/src/Components/IncidentEpisode/ChangeState",
94
+ () => {
95
+ return {
96
+ __esModule: true,
97
+ default: () => {
98
+ return null;
99
+ },
100
+ };
101
+ },
102
+ );
103
+
104
+ jest.mock(
105
+ "../../../../App/FeatureSet/Dashboard/src/Components/IncidentEpisode/IncidentEpisodeMemberRoleAssignment",
106
+ () => {
107
+ return {
108
+ __esModule: true,
109
+ default: () => {
110
+ return null;
111
+ },
112
+ };
113
+ },
114
+ );
115
+
116
+ jest.mock(
117
+ "../../../../App/FeatureSet/Dashboard/src/Components/IncidentEpisode/IncidentEpisodeFeed",
118
+ () => {
119
+ return {
120
+ __esModule: true,
121
+ default: () => {
122
+ return null;
123
+ },
124
+ };
125
+ },
126
+ );
127
+
128
+ import IncidentEpisodeView from "../../../../App/FeatureSet/Dashboard/src/Pages/Incidents/EpisodeView/Index";
129
+ import PageComponentProps from "../../../../App/FeatureSet/Dashboard/src/Pages/PageComponentProps";
130
+ import IncidentEpisode from "../../../Models/DatabaseModels/IncidentEpisode";
131
+ import IncidentSeverity from "../../../Models/DatabaseModels/IncidentSeverity";
132
+ import IncidentState from "../../../Models/DatabaseModels/IncidentState";
133
+ import Route from "../../../Types/API/Route";
134
+ import Color from "../../../Types/Color";
135
+ import ObjectID from "../../../Types/ObjectID";
136
+ import Permission from "../../../Types/Permission";
137
+ import Navigation from "../../../UI/Utils/Navigation";
138
+ import PermissionUtil from "../../../UI/Utils/Permission";
139
+ import UserUtil from "../../../UI/Utils/User";
140
+
141
+ const pageProps: PageComponentProps = {
142
+ pageRoute: new Route("/incidents"),
143
+ currentProject: null,
144
+ hasPaymentMethod: false,
145
+ };
146
+
147
+ interface EpisodeSpec {
148
+ severity?: IncidentSeverity | null | undefined;
149
+ state?: IncidentState | null | undefined;
150
+ }
151
+
152
+ type BuildSeverityFunction = (
153
+ name: string | null,
154
+ color: Color | null,
155
+ ) => IncidentSeverity;
156
+
157
+ const buildSeverity: BuildSeverityFunction = (
158
+ name: string | null,
159
+ color: Color | null,
160
+ ): IncidentSeverity => {
161
+ const severity: IncidentSeverity = new IncidentSeverity();
162
+ severity.id = new ObjectID("33333333-3333-4333-8333-333333333333");
163
+
164
+ if (name) {
165
+ severity.name = name;
166
+ }
167
+
168
+ if (color) {
169
+ severity.color = color;
170
+ }
171
+
172
+ return severity;
173
+ };
174
+
175
+ type BuildStateFunction = (
176
+ name: string | null,
177
+ color: Color | null,
178
+ ) => IncidentState;
179
+
180
+ const buildState: BuildStateFunction = (
181
+ name: string | null,
182
+ color: Color | null,
183
+ ): IncidentState => {
184
+ const state: IncidentState = new IncidentState();
185
+ state.id = new ObjectID("44444444-4444-4444-8444-444444444444");
186
+
187
+ if (name) {
188
+ state.name = name;
189
+ }
190
+
191
+ if (color) {
192
+ state.color = color;
193
+ }
194
+
195
+ return state;
196
+ };
197
+
198
+ /*
199
+ * A row shaped like what the API actually returns for an episode created with
200
+ * `{"title": "..."}` and nothing else: a title, a number, a state assigned by
201
+ * IncidentEpisodeService.onBeforeCreate, and no severity at all.
202
+ */
203
+ type BuildEpisodeFunction = (spec: EpisodeSpec) => IncidentEpisode;
204
+
205
+ const buildEpisode: BuildEpisodeFunction = (
206
+ spec: EpisodeSpec,
207
+ ): IncidentEpisode => {
208
+ const episode: IncidentEpisode = new IncidentEpisode();
209
+ episode.id = new ObjectID(EPISODE_ID);
210
+ episode.projectId = new ObjectID(PROJECT_ID);
211
+ episode.title = "episode without severity";
212
+ episode.episodeNumber = 7;
213
+ episode.episodeNumberWithPrefix = "#7";
214
+ episode.incidentCount = 0;
215
+ episode.createdAt = new Date("2026-01-01T00:00:00.000Z");
216
+
217
+ if (spec.severity) {
218
+ episode.incidentSeverity = spec.severity;
219
+ }
220
+
221
+ if (spec.state) {
222
+ episode.currentIncidentState = spec.state;
223
+ }
224
+
225
+ return episode;
226
+ };
227
+
228
+ type RenderPageFunction = (spec: EpisodeSpec) => Promise<void>;
229
+
230
+ const renderPage: RenderPageFunction = async (
231
+ spec: EpisodeSpec,
232
+ ): Promise<void> => {
233
+ getItemMock.mockResolvedValue(buildEpisode(spec) as never);
234
+
235
+ render(<IncidentEpisodeView {...pageProps} />);
236
+
237
+ await waitFor((): void => {
238
+ expect(screen.getByText("Episode Severity")).toBeInTheDocument();
239
+ });
240
+ };
241
+
242
+ /*
243
+ * Detail renders each field as <container><FieldLabel/><value/></container>,
244
+ * and FieldLabel's root is the only div.space-y-1 in that subtree. Walking up
245
+ * from the label and taking the sibling is what a person reading the row does.
246
+ */
247
+ type FieldValueFunction = (title: string) => string;
248
+
249
+ const fieldValue: FieldValueFunction = (title: string): string => {
250
+ const label: HTMLElement = screen.getByText(title);
251
+ const labelRoot: Element | null = label.closest("div.space-y-1");
252
+ const container: HTMLElement | null | undefined = labelRoot?.parentElement;
253
+
254
+ if (!container) {
255
+ throw new Error(`Could not find the field container for "${title}"`);
256
+ }
257
+
258
+ const value: Element | undefined = container.children[1];
259
+
260
+ if (!value) {
261
+ throw new Error(`The field "${title}" rendered no value element`);
262
+ }
263
+
264
+ return (value.textContent || "").trim();
265
+ };
266
+
267
+ type PillForFunction = (title: string) => HTMLElement;
268
+
269
+ const pillFor: PillForFunction = (title: string): HTMLElement => {
270
+ const label: HTMLElement = screen.getByText(title);
271
+ const labelRoot: Element | null = label.closest("div.space-y-1");
272
+ const pill: HTMLElement | null | undefined =
273
+ labelRoot?.parentElement?.querySelector<HTMLElement>(
274
+ '[data-testid="pill"]',
275
+ );
276
+
277
+ if (!pill) {
278
+ throw new Error(`The field "${title}" rendered no pill`);
279
+ }
280
+
281
+ return pill;
282
+ };
283
+
284
+ type ExpectNoCrashFunction = () => void;
285
+
286
+ /*
287
+ * Two separate claims, and both matter:
288
+ * - the app level ErrorBoundary copy is nowhere on the page, and
289
+ * - Detail never had to swallow a throw, so the page itself is null safe
290
+ * rather than being rescued by the framework guard.
291
+ */
292
+ const expectNoCrash: ExpectNoCrashFunction = (): void => {
293
+ expect(screen.queryByText("Something went wrong")).toBeNull();
294
+ expect(screen.queryByText(/An unexpected error has occurred/)).toBeNull();
295
+
296
+ const swallowed: Array<unknown> = loggerErrorMock.mock.calls.filter(
297
+ (call: Array<unknown>): boolean => {
298
+ return String(call[0]).includes("threw and was ignored");
299
+ },
300
+ );
301
+
302
+ expect(swallowed).toEqual([]);
303
+ };
304
+
305
+ beforeEach((): void => {
306
+ getItemMock.mockReset();
307
+ getListMock.mockReset();
308
+ getCommonHeadersMock.mockReset();
309
+ loggerErrorMock.mockReset();
310
+
311
+ getListMock.mockResolvedValue({
312
+ data: [],
313
+ count: 0,
314
+ skip: 0,
315
+ limit: 0,
316
+ } as never);
317
+ getCommonHeadersMock.mockReturnValue({} as never);
318
+
319
+ const path: string = `/dashboard/${PROJECT_ID}/incidents/episodes/${EPISODE_ID}`;
320
+
321
+ window.history.pushState({}, "", path);
322
+ Navigation.setLocation({
323
+ pathname: path,
324
+ search: "",
325
+ hash: "",
326
+ state: null,
327
+ key: "test",
328
+ } as Location);
329
+
330
+ jest.spyOn(UserUtil, "isMasterAdmin").mockReturnValue(false);
331
+ jest
332
+ .spyOn(PermissionUtil, "getAllPermissions")
333
+ .mockReturnValue([Permission.ProjectAdmin]);
334
+ });
335
+
336
+ afterEach((): void => {
337
+ cleanup();
338
+ jest.restoreAllMocks();
339
+ });
340
+
341
+ describe("Incident Episode detail page: severity (issue #3374)", () => {
342
+ test("renders the page when the episode has no severity at all", async () => {
343
+ await renderPage({ state: buildState("Created", new Color("#4b5563")) });
344
+
345
+ expect(screen.getByText("Episode Severity")).toBeInTheDocument();
346
+ expect(fieldValue("Episode Severity")).toEqual("-");
347
+ expectNoCrash();
348
+ });
349
+
350
+ test("renders the page when the severity relation is explicitly null", async () => {
351
+ getItemMock.mockResolvedValue(
352
+ Object.assign(
353
+ buildEpisode({ state: buildState("Created", new Color("#4b5563")) }),
354
+ { incidentSeverity: null },
355
+ ) as never,
356
+ );
357
+
358
+ render(<IncidentEpisodeView {...pageProps} />);
359
+
360
+ await waitFor((): void => {
361
+ expect(screen.getByText("Episode Severity")).toBeInTheDocument();
362
+ });
363
+
364
+ expect(fieldValue("Episode Severity")).toEqual("-");
365
+ expectNoCrash();
366
+ });
367
+
368
+ test("still renders the rest of the episode when severity is missing", async () => {
369
+ await renderPage({ state: buildState("Created", new Color("#4b5563")) });
370
+
371
+ expect(screen.getByText("Episode Title")).toBeInTheDocument();
372
+ expect(fieldValue("Episode Title")).toEqual("episode without severity");
373
+ expect(fieldValue("Episode Number")).toContain("#7");
374
+ expect(fieldValue("Incident Count")).toEqual("0");
375
+ // No createdByUser on an API-created episode.
376
+ expect(fieldValue("Created By")).toEqual("System");
377
+ expectNoCrash();
378
+ });
379
+
380
+ test("renders the severity pill when the episode has one", async () => {
381
+ await renderPage({
382
+ severity: buildSeverity("Critical", new Color("#ef4444")),
383
+ state: buildState("Created", new Color("#4b5563")),
384
+ });
385
+
386
+ const pill: HTMLElement = pillFor("Episode Severity");
387
+
388
+ expect(pill.textContent).toContain("Critical");
389
+ expect(pill.style.backgroundColor).toEqual("rgb(239, 68, 68)");
390
+ expectNoCrash();
391
+ });
392
+
393
+ test("falls back to Unknown when the severity has no name", async () => {
394
+ await renderPage({
395
+ severity: buildSeverity(null, new Color("#ef4444")),
396
+ state: buildState("Created", new Color("#4b5563")),
397
+ });
398
+
399
+ expect(pillFor("Episode Severity").textContent).toContain("Unknown");
400
+ expectNoCrash();
401
+ });
402
+
403
+ test("renders a severity with no color instead of crashing", async () => {
404
+ await renderPage({
405
+ severity: buildSeverity("Critical", null),
406
+ state: buildState("Created", new Color("#4b5563")),
407
+ });
408
+
409
+ const pill: HTMLElement = pillFor("Episode Severity");
410
+
411
+ expect(pill.textContent).toContain("Critical");
412
+ // Black is the declared fallback in the page.
413
+ expect(pill.style.backgroundColor).toEqual("rgb(0, 0, 0)");
414
+ expectNoCrash();
415
+ });
416
+ });
417
+
418
+ describe("Incident Episode detail page: current state", () => {
419
+ test("renders a placeholder when the state relation is absent", async () => {
420
+ await renderPage({
421
+ severity: buildSeverity("Critical", new Color("#ef4444")),
422
+ });
423
+
424
+ expect(screen.getByText("Current State")).toBeInTheDocument();
425
+ expect(fieldValue("Current State")).toEqual("-");
426
+ expectNoCrash();
427
+ });
428
+
429
+ test("renders the state pill when the episode has one", async () => {
430
+ await renderPage({
431
+ severity: buildSeverity("Critical", new Color("#ef4444")),
432
+ state: buildState("Acknowledged", new Color("#f59e0b")),
433
+ });
434
+
435
+ const pill: HTMLElement = pillFor("Current State");
436
+
437
+ expect(pill.textContent).toContain("Acknowledged");
438
+ expect(pill.style.backgroundColor).toEqual("rgb(245, 158, 11)");
439
+ expectNoCrash();
440
+ });
441
+
442
+ test("falls back to Unknown when the state has no name", async () => {
443
+ await renderPage({
444
+ severity: buildSeverity("Critical", new Color("#ef4444")),
445
+ state: buildState(null, new Color("#f59e0b")),
446
+ });
447
+
448
+ expect(pillFor("Current State").textContent).toContain("Unknown");
449
+ expectNoCrash();
450
+ });
451
+
452
+ test("renders a state with no color instead of crashing", async () => {
453
+ await renderPage({
454
+ severity: buildSeverity("Critical", new Color("#ef4444")),
455
+ state: buildState("Created", null),
456
+ });
457
+
458
+ expect(pillFor("Current State").style.backgroundColor).toEqual(
459
+ "rgb(0, 0, 0)",
460
+ );
461
+ expectNoCrash();
462
+ });
463
+ });
464
+
465
+ describe("Incident Episode detail page: both relations missing", () => {
466
+ test("renders both placeholders when state and severity are absent", async () => {
467
+ await renderPage({});
468
+
469
+ expect(fieldValue("Episode Severity")).toEqual("-");
470
+ expect(fieldValue("Current State")).toEqual("-");
471
+ expect(fieldValue("Episode Title")).toEqual("episode without severity");
472
+ expectNoCrash();
473
+ });
474
+ });