@oneuptime/common 12.0.31 → 12.0.32

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 (188) hide show
  1. package/Models/DatabaseModels/AlertState.ts +0 -2
  2. package/Models/DatabaseModels/Domain.ts +1 -1
  3. package/Models/DatabaseModels/Incident.ts +1 -1
  4. package/Models/DatabaseModels/IncidentTemplate.ts +1 -1
  5. package/Models/DatabaseModels/Index.ts +2 -0
  6. package/Models/DatabaseModels/MetricType.ts +0 -2
  7. package/Models/DatabaseModels/MonitorStatusTimeline.ts +0 -2
  8. package/Models/DatabaseModels/MonitorTest.ts +0 -2
  9. package/Models/DatabaseModels/NetworkDevice.ts +121 -2
  10. package/Models/DatabaseModels/NetworkDeviceOidTemplate.ts +3 -1
  11. package/Models/DatabaseModels/NetworkDeviceRole.ts +648 -0
  12. package/Models/DatabaseModels/ScheduledMaintenance.ts +1 -1
  13. package/Models/DatabaseModels/ScheduledMaintenanceTemplate.ts +1 -1
  14. package/Models/DatabaseModels/StatusPageAnnouncement.ts +0 -2
  15. package/Models/DatabaseModels/StatusPageAnnouncementTemplate.ts +0 -2
  16. package/Models/DatabaseModels/StatusPagePrivateUser.ts +0 -2
  17. package/Models/DatabaseModels/StatusPageResource.ts +0 -2
  18. package/Models/DatabaseModels/StatusPageSubscriber.ts +0 -2
  19. package/Server/Infrastructure/Postgres/SchemaMigrations/1790800000000-AddNetworkDeviceRoleTable.ts +72 -0
  20. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  21. package/Server/Services/DatabaseService.ts +39 -9
  22. package/Server/Services/Index.ts +2 -0
  23. package/Server/Services/NetworkDeviceRoleService.ts +90 -0
  24. package/Server/Services/ProjectService.ts +85 -0
  25. package/Server/Services/UserNotificationSettingService.ts +0 -6
  26. package/Tests/App/Dashboard/AddNeighborToMonitoringModal.test.tsx +31 -1
  27. package/Tests/App/Dashboard/DashboardChartWidgetZoom.test.tsx +35 -8
  28. package/Tests/App/Dashboard/DashboardCommandPalette.test.tsx +107 -3
  29. package/Tests/App/Dashboard/DashboardWideChartZoom.test.tsx +401 -0
  30. package/Tests/App/Dashboard/MonitorTemplateColumnAndFacet.test.tsx +318 -0
  31. package/Tests/App/Dashboard/NetworkDeviceBulkDelete.test.tsx +357 -0
  32. package/Tests/App/Dashboard/NetworkSideMenu.test.tsx +590 -0
  33. package/Tests/App/Dashboard/SideMenuHarness.tsx +11 -3
  34. package/Tests/Models/DatabaseModels/ModelRegistryInvariants.test.ts +115 -41
  35. package/Tests/Server/Infrastructure/Postgres/NetworkDeviceRoleTableMigration.test.ts +476 -0
  36. package/Tests/Server/Services/DatabaseServiceGenerateSlug.test.ts +215 -0
  37. package/Tests/Server/Services/NetworkDeviceRoleModel.test.ts +672 -0
  38. package/Tests/Server/Services/NetworkDeviceRuleEngines.test.ts +1 -1
  39. package/Tests/Server/Services/ProjectServiceCreateSubscriptionStarted.test.ts +67 -4
  40. package/Tests/Server/Services/ScheduledMaintenanceSubscriberEndTime.test.ts +369 -0
  41. package/Tests/Server/Services/UserNotificationSettingMonitorDefaults.test.ts +248 -0
  42. package/Tests/Server/Services/UserNotificationSettingWorkspaceChannels.test.ts +43 -2
  43. package/Tests/Server/Utils/BasicCron.test.ts +283 -0
  44. package/Tests/Server/Utils/IncomingCallPhoneNumber.test.ts +174 -0
  45. package/Tests/Types/NetworkDevice/DefaultNetworkDeviceRole.test.ts +417 -0
  46. package/Tests/UI/Components/Charts/ChartClickDoubleClickDisambiguation.test.tsx +221 -0
  47. package/Tests/UI/Components/Charts/ChartDoubleClickReset.test.tsx +220 -0
  48. package/Tests/UI/Components/ModelTable/BaseModelTableBulkDelete.test.tsx +751 -0
  49. package/Tests/UI/Components/ModelTable/BaseModelTableColumnCustomization.test.tsx +475 -43
  50. package/Tests/UI/Components/ModelTable/ColumnPreference.test.ts +89 -0
  51. package/Tests/UI/Components/NavBarCommandKGating.test.tsx +103 -8
  52. package/Tests/UI/Components/SideMenuItem.test.tsx +30 -0
  53. package/Tests/UI/Utils/TableColumnsToCsv.test.ts +30 -0
  54. package/Tests/UI/Utils/UseDashboardTimeRangeZoom.test.tsx +187 -0
  55. package/Tests/Utils/Dashboard/DashboardResourceListSharedArgs.test.ts +302 -0
  56. package/Tests/Utils/Dashboard/DashboardTimeRangeZoom.test.ts +242 -0
  57. package/Tests/Utils/Monitor/NetworkDeviceRoleCatalog.test.ts +981 -0
  58. package/Tests/Utils/Monitor/NetworkTopologyRoleCatalog.test.ts +865 -0
  59. package/Tests/Utils/NetworkDevice/DeviceRoleKeyUtil.test.ts +538 -0
  60. package/Tests/Utils/Slo/SloEvaluation.test.ts +171 -0
  61. package/Tests/Utils/Slug.test.ts +61 -1
  62. package/Tests/jest.setup.ts +19 -0
  63. package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +61 -0
  64. package/Types/NetworkDevice/DefaultNetworkDeviceRole.ts +145 -0
  65. package/Types/Permission.ts +45 -0
  66. package/UI/Components/Charts/Area/AreaChart.tsx +7 -0
  67. package/UI/Components/Charts/Bar/BarChart.tsx +6 -0
  68. package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +9 -1
  69. package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +97 -35
  70. package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +14 -0
  71. package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +97 -35
  72. package/UI/Components/Charts/ChartLibrary/Utils/DoubleClick.ts +13 -0
  73. package/UI/Components/Charts/Line/LineChart.tsx +7 -0
  74. package/UI/Components/ModelTable/BaseModelTable.tsx +50 -15
  75. package/UI/Components/Navbar/NavBar.tsx +62 -13
  76. package/UI/Components/Navbar/NavBarMenuModal.tsx +7 -2
  77. package/UI/Components/SideMenu/CountModelSideMenuItem.tsx +2 -0
  78. package/UI/Components/SideMenu/SideMenu.tsx +12 -2
  79. package/UI/Components/SideMenu/SideMenuItem.tsx +4 -1
  80. package/UI/Components/SideMenu/SideMenuSection.tsx +14 -2
  81. package/UI/Utils/UseDashboardTimeRangeZoom.ts +77 -0
  82. package/Utils/Dashboard/DashboardTimeRangeZoom.ts +118 -0
  83. package/Utils/Monitor/NetworkDeviceRoleCatalog.ts +232 -0
  84. package/Utils/Monitor/NetworkDeviceRoleUtil.ts +2 -1
  85. package/Utils/Monitor/NetworkTopologyUtil.ts +116 -33
  86. package/Utils/NetworkDevice/DeviceRoleKeyUtil.ts +137 -0
  87. package/Utils/Rules/RulePatternMatchUtil.ts +1 -1
  88. package/Utils/Slug.ts +34 -1
  89. package/build/dist/Models/DatabaseModels/AlertState.js +0 -2
  90. package/build/dist/Models/DatabaseModels/AlertState.js.map +1 -1
  91. package/build/dist/Models/DatabaseModels/Domain.js +1 -1
  92. package/build/dist/Models/DatabaseModels/Domain.js.map +1 -1
  93. package/build/dist/Models/DatabaseModels/Incident.js +1 -1
  94. package/build/dist/Models/DatabaseModels/Incident.js.map +1 -1
  95. package/build/dist/Models/DatabaseModels/IncidentTemplate.js +1 -1
  96. package/build/dist/Models/DatabaseModels/IncidentTemplate.js.map +1 -1
  97. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  98. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  99. package/build/dist/Models/DatabaseModels/MetricType.js +0 -2
  100. package/build/dist/Models/DatabaseModels/MetricType.js.map +1 -1
  101. package/build/dist/Models/DatabaseModels/MonitorStatusTimeline.js +0 -2
  102. package/build/dist/Models/DatabaseModels/MonitorStatusTimeline.js.map +1 -1
  103. package/build/dist/Models/DatabaseModels/MonitorTest.js +0 -2
  104. package/build/dist/Models/DatabaseModels/MonitorTest.js.map +1 -1
  105. package/build/dist/Models/DatabaseModels/NetworkDevice.js +124 -3
  106. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  107. package/build/dist/Models/DatabaseModels/NetworkDeviceOidTemplate.js +3 -1
  108. package/build/dist/Models/DatabaseModels/NetworkDeviceOidTemplate.js.map +1 -1
  109. package/build/dist/Models/DatabaseModels/NetworkDeviceRole.js +677 -0
  110. package/build/dist/Models/DatabaseModels/NetworkDeviceRole.js.map +1 -0
  111. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js +1 -1
  112. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js.map +1 -1
  113. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceTemplate.js +1 -1
  114. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceTemplate.js.map +1 -1
  115. package/build/dist/Models/DatabaseModels/StatusPageAnnouncement.js +0 -2
  116. package/build/dist/Models/DatabaseModels/StatusPageAnnouncement.js.map +1 -1
  117. package/build/dist/Models/DatabaseModels/StatusPageAnnouncementTemplate.js +0 -2
  118. package/build/dist/Models/DatabaseModels/StatusPageAnnouncementTemplate.js.map +1 -1
  119. package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js +0 -2
  120. package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js.map +1 -1
  121. package/build/dist/Models/DatabaseModels/StatusPageResource.js +0 -2
  122. package/build/dist/Models/DatabaseModels/StatusPageResource.js.map +1 -1
  123. package/build/dist/Models/DatabaseModels/StatusPageSubscriber.js +0 -2
  124. package/build/dist/Models/DatabaseModels/StatusPageSubscriber.js.map +1 -1
  125. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790800000000-AddNetworkDeviceRoleTable.js +39 -0
  126. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790800000000-AddNetworkDeviceRoleTable.js.map +1 -0
  127. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  128. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  129. package/build/dist/Server/Services/DatabaseService.js +31 -5
  130. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  131. package/build/dist/Server/Services/Index.js +2 -0
  132. package/build/dist/Server/Services/Index.js.map +1 -1
  133. package/build/dist/Server/Services/NetworkDeviceRoleService.js +70 -0
  134. package/build/dist/Server/Services/NetworkDeviceRoleService.js.map +1 -0
  135. package/build/dist/Server/Services/ProjectService.js +65 -0
  136. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  137. package/build/dist/Server/Services/UserNotificationSettingService.js +0 -1
  138. package/build/dist/Server/Services/UserNotificationSettingService.js.map +1 -1
  139. package/build/dist/Types/NetworkDevice/DefaultNetworkDeviceRole.js +105 -0
  140. package/build/dist/Types/NetworkDevice/DefaultNetworkDeviceRole.js.map +1 -0
  141. package/build/dist/Types/Permission.js +40 -0
  142. package/build/dist/Types/Permission.js.map +1 -1
  143. package/build/dist/UI/Components/Charts/Area/AreaChart.js +1 -1
  144. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  145. package/build/dist/UI/Components/Charts/Bar/BarChart.js +1 -1
  146. package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
  147. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +6 -1
  148. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
  149. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +79 -31
  150. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
  151. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +7 -1
  152. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
  153. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +79 -31
  154. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
  155. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/DoubleClick.js +14 -0
  156. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/DoubleClick.js.map +1 -0
  157. package/build/dist/UI/Components/Charts/Line/LineChart.js +1 -1
  158. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  159. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +39 -13
  160. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  161. package/build/dist/UI/Components/Navbar/NavBar.js +44 -9
  162. package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
  163. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +2 -2
  164. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
  165. package/build/dist/UI/Components/SideMenu/CountModelSideMenuItem.js +1 -1
  166. package/build/dist/UI/Components/SideMenu/CountModelSideMenuItem.js.map +1 -1
  167. package/build/dist/UI/Components/SideMenu/SideMenu.js +9 -6
  168. package/build/dist/UI/Components/SideMenu/SideMenu.js.map +1 -1
  169. package/build/dist/UI/Components/SideMenu/SideMenuItem.js +1 -1
  170. package/build/dist/UI/Components/SideMenu/SideMenuItem.js.map +1 -1
  171. package/build/dist/UI/Components/SideMenu/SideMenuSection.js +7 -2
  172. package/build/dist/UI/Components/SideMenu/SideMenuSection.js.map +1 -1
  173. package/build/dist/UI/Utils/UseDashboardTimeRangeZoom.js +42 -0
  174. package/build/dist/UI/Utils/UseDashboardTimeRangeZoom.js.map +1 -0
  175. package/build/dist/Utils/Dashboard/DashboardTimeRangeZoom.js +74 -0
  176. package/build/dist/Utils/Dashboard/DashboardTimeRangeZoom.js.map +1 -0
  177. package/build/dist/Utils/Monitor/NetworkDeviceRoleCatalog.js +126 -0
  178. package/build/dist/Utils/Monitor/NetworkDeviceRoleCatalog.js.map +1 -0
  179. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js +2 -1
  180. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js.map +1 -1
  181. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +67 -32
  182. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  183. package/build/dist/Utils/NetworkDevice/DeviceRoleKeyUtil.js +114 -0
  184. package/build/dist/Utils/NetworkDevice/DeviceRoleKeyUtil.js.map +1 -0
  185. package/build/dist/Utils/Rules/RulePatternMatchUtil.js +1 -1
  186. package/build/dist/Utils/Slug.js +31 -1
  187. package/build/dist/Utils/Slug.js.map +1 -1
  188. package/package.json +1 -1
@@ -0,0 +1,981 @@
1
+ import { describe, expect, test } from "@jest/globals";
2
+ import DEFAULT_NETWORK_DEVICE_ROLES, {
3
+ DefaultNetworkDeviceRole,
4
+ } from "../../../Types/NetworkDevice/DefaultNetworkDeviceRole";
5
+ import {
6
+ NetworkTopologyDeviceRole,
7
+ NetworkTopologyNode,
8
+ NetworkTopologyNodeShape,
9
+ } from "../../../Types/Monitor/SnmpMonitor/NetworkTopology";
10
+ import {
11
+ TopologyDeviceRoleInput,
12
+ TopologyNodeRoleStamp,
13
+ applyRoleStamp,
14
+ buildDeviceRoleIndex,
15
+ normalizeRoleKey,
16
+ roleKeyForNode,
17
+ stampForRoleKey,
18
+ } from "../../../Utils/Monitor/NetworkDeviceRoleCatalog";
19
+
20
+ /*
21
+ * This module is the ONLY place that turns a project's configured
22
+ * NetworkDeviceRole rows into the four presentation answers stamped onto a
23
+ * topology node. Everything downstream — the shape module, the three layouts,
24
+ * the legend, the accessible label, the adopt-a-neighbour flow — reads those
25
+ * stamped fields and never sees the table.
26
+ *
27
+ * So the contract these tests pin is mostly about ABSENCE. A field the stamp
28
+ * does not carry must be missing from the node, not present-and-undefined,
29
+ * because "absent means fall back to the built-in behaviour" is what keeps a
30
+ * project with no roles configured (or a payload built before the feature
31
+ * existed) drawing exactly the map it drew before. Several assertions
32
+ * therefore check Object.keys / not.toHaveProperty rather than settling for
33
+ * toBeUndefined, which cannot tell the two apart.
34
+ */
35
+
36
+ // Every field a stamp can carry, so "the rest stayed absent" can be asserted.
37
+ const STAMP_FIELDS: ReadonlyArray<keyof TopologyNodeRoleStamp> = [
38
+ "roleId",
39
+ "roleKey",
40
+ "roleLabel",
41
+ "roleShape",
42
+ "isCoreLayerRole",
43
+ "isSnmpWalkableRole",
44
+ ];
45
+
46
+ // The eight silhouettes the renderer has geometry for.
47
+ const ALL_SHAPES: ReadonlyArray<NetworkTopologyNodeShape> = [
48
+ "circle",
49
+ "rounded-square",
50
+ "diamond",
51
+ "triangle",
52
+ "hexagon",
53
+ "tower",
54
+ "cylinder",
55
+ "rect",
56
+ ];
57
+
58
+ const makeRole: (
59
+ key: string,
60
+ overrides?: Partial<TopologyDeviceRoleInput>,
61
+ ) => TopologyDeviceRoleInput = (
62
+ key: string,
63
+ overrides?: Partial<TopologyDeviceRoleInput>,
64
+ ): TopologyDeviceRoleInput => {
65
+ return {
66
+ key,
67
+ name: key,
68
+ ...overrides,
69
+ };
70
+ };
71
+
72
+ const makeNode: (
73
+ overrides?: Partial<NetworkTopologyNode>,
74
+ ) => NetworkTopologyNode = (
75
+ overrides?: Partial<NetworkTopologyNode>,
76
+ ): NetworkTopologyNode => {
77
+ return {
78
+ id: "device-1",
79
+ name: "core-sw-1",
80
+ isManaged: true,
81
+ status: "up",
82
+ ...overrides,
83
+ };
84
+ };
85
+
86
+ // The eleven seeded defaults, in the shape the topology builder passes them.
87
+ const seededRoles: Array<TopologyDeviceRoleInput> =
88
+ DEFAULT_NETWORK_DEVICE_ROLES.map(
89
+ (role: DefaultNetworkDeviceRole): TopologyDeviceRoleInput => {
90
+ return {
91
+ key: role.key,
92
+ name: role.name,
93
+ topologyShape: role.topologyShape,
94
+ isCoreLayer: role.isCoreLayer,
95
+ isSnmpWalkable: role.isSnmpWalkable,
96
+ };
97
+ },
98
+ );
99
+
100
+ const snapshot: (value: unknown) => string = (value: unknown): string => {
101
+ return JSON.stringify(value);
102
+ };
103
+
104
+ describe("normalizeRoleKey", () => {
105
+ test('trims and lowercases, so a hand-edited or imported "Router " still finds the Router row', () => {
106
+ expect(normalizeRoleKey(" Router ")).toBe("router");
107
+ expect(normalizeRoleKey("ROUTER")).toBe("router");
108
+ expect(normalizeRoleKey("\t\n router \n")).toBe("router");
109
+ expect(normalizeRoleKey("router")).toBe("router");
110
+ });
111
+
112
+ /*
113
+ * undefined rather than "" is the load-bearing part: every caller branches
114
+ * on falsiness to mean "this node has no configured role", and an empty
115
+ * string would be indexed and looked up as a real key.
116
+ */
117
+ test("a blank or missing key is no key at all, not an empty-string key", () => {
118
+ expect(normalizeRoleKey("")).toBeUndefined();
119
+ expect(normalizeRoleKey(" ")).toBeUndefined();
120
+ expect(normalizeRoleKey("\t\n")).toBeUndefined();
121
+ expect(normalizeRoleKey(null)).toBeUndefined();
122
+ expect(normalizeRoleKey(undefined)).toBeUndefined();
123
+ });
124
+
125
+ test("is idempotent, so normalizing an already-normalized key is safe", () => {
126
+ const once: string | undefined = normalizeRoleKey(" WirelessAccessPoint ");
127
+ expect(once).toBe("wirelessaccesspoint");
128
+ expect(normalizeRoleKey(once)).toBe(once);
129
+ });
130
+
131
+ /*
132
+ * Matching a key is a lookup, not a derivation — deriveDeviceRoleKey is what
133
+ * builds keys out of names. Collapsing "edge router" to "edgerouter" here
134
+ * would silently match a row the operator never named.
135
+ */
136
+ test("only trims the ends — it never rewrites the middle of the key", () => {
137
+ expect(normalizeRoleKey(" Edge Router ")).toBe("edge router");
138
+ expect(normalizeRoleKey("pos-terminal")).toBe("pos-terminal");
139
+ });
140
+ });
141
+
142
+ describe("buildDeviceRoleIndex", () => {
143
+ test("indexes every row under its normalized key so lookup ignores case and padding", () => {
144
+ const roles: Array<TopologyDeviceRoleInput> = [
145
+ makeRole("wirelessAccessPoint"),
146
+ makeRole(" Router "),
147
+ ];
148
+
149
+ const index: Map<string, TopologyDeviceRoleInput> =
150
+ buildDeviceRoleIndex(roles);
151
+
152
+ expect(index.size).toBe(2);
153
+ expect(index.has("wirelessaccesspoint")).toBe(true);
154
+ expect(index.has("router")).toBe(true);
155
+ });
156
+
157
+ /*
158
+ * The index holds the row itself, not a projection of it, because
159
+ * stampForRoleKey reads the configured label, shape and flags straight off
160
+ * whatever is stored here.
161
+ */
162
+ test("stores the row object it was given rather than a copy of it", () => {
163
+ const role: TopologyDeviceRoleInput = makeRole("router", {
164
+ name: "Edge Router",
165
+ });
166
+
167
+ const index: Map<string, TopologyDeviceRoleInput> = buildDeviceRoleIndex([
168
+ role,
169
+ ]);
170
+
171
+ expect(index.get("router")).toBe(role);
172
+ });
173
+
174
+ /*
175
+ * A keyless row cannot be matched by anything — the classifier's answer and
176
+ * a device's assignment are both keys — so indexing it under "" would only
177
+ * create an entry that a blank lookup could accidentally hit.
178
+ */
179
+ test('skips a row whose key is empty or whitespace instead of indexing it under ""', () => {
180
+ const index: Map<string, TopologyDeviceRoleInput> = buildDeviceRoleIndex([
181
+ makeRole(""),
182
+ makeRole(" "),
183
+ makeRole("router"),
184
+ ]);
185
+
186
+ expect(index.size).toBe(1);
187
+ expect(index.has("")).toBe(false);
188
+ expect(index.has("router")).toBe(true);
189
+ });
190
+
191
+ /*
192
+ * Deliberately first-wins. The key is unique per project in the database, so
193
+ * a clash means the caller handed us rows from more than one project, and
194
+ * quietly taking the LAST one would draw the other project's shapes on this
195
+ * project's map.
196
+ */
197
+ test("the first row wins on a key clash and the later one is ignored", () => {
198
+ const first: TopologyDeviceRoleInput = makeRole("router", {
199
+ name: "This Project's Router",
200
+ topologyShape: "circle",
201
+ });
202
+ const second: TopologyDeviceRoleInput = makeRole("router", {
203
+ name: "Another Project's Router",
204
+ topologyShape: "diamond",
205
+ });
206
+
207
+ const index: Map<string, TopologyDeviceRoleInput> = buildDeviceRoleIndex([
208
+ first,
209
+ second,
210
+ ]);
211
+
212
+ expect(index.size).toBe(1);
213
+ expect(index.get("router")).toBe(first);
214
+ });
215
+
216
+ test("a clash that differs only in case or padding is still a clash", () => {
217
+ const first: TopologyDeviceRoleInput = makeRole("Router", {
218
+ name: "Kept",
219
+ });
220
+ const second: TopologyDeviceRoleInput = makeRole(" router ", {
221
+ name: "Dropped",
222
+ });
223
+
224
+ const index: Map<string, TopologyDeviceRoleInput> = buildDeviceRoleIndex([
225
+ first,
226
+ second,
227
+ ]);
228
+
229
+ expect(index.size).toBe(1);
230
+ expect(index.get("router")!.name).toBe("Kept");
231
+ });
232
+
233
+ /*
234
+ * The normal case for a project that has not been backfilled yet: no rows,
235
+ * no entries, and every later lookup falls back to built-in behaviour.
236
+ */
237
+ test("an empty role list gives an empty index", () => {
238
+ const index: Map<string, TopologyDeviceRoleInput> = buildDeviceRoleIndex(
239
+ [],
240
+ );
241
+
242
+ expect(index.size).toBe(0);
243
+ expect(stampForRoleKey("router", index)).toEqual({});
244
+ });
245
+
246
+ test("does not mutate the array it was given or the rows in it", () => {
247
+ const roles: Array<TopologyDeviceRoleInput> = [
248
+ makeRole("router", { name: "Router", topologyShape: "circle" }),
249
+ makeRole(""),
250
+ makeRole("router", { name: "Duplicate" }),
251
+ ];
252
+ const before: string = snapshot(roles);
253
+
254
+ buildDeviceRoleIndex(roles);
255
+
256
+ expect(snapshot(roles)).toBe(before);
257
+ expect(roles).toHaveLength(3);
258
+ });
259
+
260
+ test("indexes all eleven seeded defaults under their built-in keys", () => {
261
+ const index: Map<string, TopologyDeviceRoleInput> =
262
+ buildDeviceRoleIndex(seededRoles);
263
+
264
+ expect(index.size).toBe(DEFAULT_NETWORK_DEVICE_ROLES.length);
265
+ for (const role of DEFAULT_NETWORK_DEVICE_ROLES) {
266
+ expect(index.has(role.key.toLowerCase())).toBe(true);
267
+ }
268
+ });
269
+ });
270
+
271
+ describe("stampForRoleKey — an empty stamp is the fallback", () => {
272
+ const index: Map<string, TopologyDeviceRoleInput> = buildDeviceRoleIndex([
273
+ makeRole("router", { name: "Router", topologyShape: "circle" }),
274
+ ]);
275
+
276
+ /*
277
+ * The permanent case for a project that deleted a seeded role the
278
+ * classifier can still produce: nothing is stamped, so the client draws the
279
+ * built-in router exactly as it did before roles were configurable.
280
+ */
281
+ test("a key with no row stamps nothing at all", () => {
282
+ const stamp: TopologyNodeRoleStamp = stampForRoleKey("posTerminal", index);
283
+
284
+ expect(stamp).toEqual({});
285
+ expect(Object.keys(stamp)).toHaveLength(0);
286
+ });
287
+
288
+ test("an absent or blank key stamps nothing", () => {
289
+ expect(stampForRoleKey(undefined, index)).toEqual({});
290
+ expect(stampForRoleKey("", index)).toEqual({});
291
+ expect(stampForRoleKey(" ", index)).toEqual({});
292
+ expect(Object.keys(stampForRoleKey(undefined, index))).toHaveLength(0);
293
+ });
294
+
295
+ /*
296
+ * A project that has not been backfilled has rows for nothing, and every
297
+ * node on its map must still be drawn.
298
+ */
299
+ test("an empty index stamps nothing even for a key that would otherwise match", () => {
300
+ const empty: Map<string, TopologyDeviceRoleInput> = buildDeviceRoleIndex(
301
+ [],
302
+ );
303
+
304
+ expect(stampForRoleKey("router", empty)).toEqual({});
305
+ });
306
+
307
+ test("an empty stamp carries none of the six fields, not undefined ones", () => {
308
+ const stamp: TopologyNodeRoleStamp = stampForRoleKey("firewall", index);
309
+
310
+ for (const field of STAMP_FIELDS) {
311
+ expect(stamp).not.toHaveProperty(field);
312
+ }
313
+ });
314
+ });
315
+
316
+ describe("stampForRoleKey — a field is stamped only when the row carries it", () => {
317
+ test("a fully configured row stamps all six fields", () => {
318
+ const index: Map<string, TopologyDeviceRoleInput> = buildDeviceRoleIndex([
319
+ makeRole("router", {
320
+ id: "role-id-1",
321
+ name: "Edge Router",
322
+ topologyShape: "hexagon",
323
+ isCoreLayer: true,
324
+ isSnmpWalkable: true,
325
+ }),
326
+ ]);
327
+
328
+ expect(stampForRoleKey("router", index)).toEqual({
329
+ roleId: "role-id-1",
330
+ roleKey: "router",
331
+ roleLabel: "Edge Router",
332
+ roleShape: "hexagon",
333
+ isCoreLayerRole: true,
334
+ isSnmpWalkableRole: true,
335
+ });
336
+ });
337
+
338
+ /*
339
+ * The four fields the row says nothing about must stay ABSENT rather than
340
+ * present-and-undefined, so the client falls back per field: the built-in
341
+ * shape, the built-in core set, the built-in SNMP-walkable set.
342
+ */
343
+ test("a row with only a key and a name leaves the other four fields absent", () => {
344
+ const index: Map<string, TopologyDeviceRoleInput> = buildDeviceRoleIndex([
345
+ makeRole("router", { name: "Router" }),
346
+ ]);
347
+
348
+ const stamp: TopologyNodeRoleStamp = stampForRoleKey("router", index);
349
+
350
+ expect(Object.keys(stamp).sort()).toEqual(["roleKey", "roleLabel"]);
351
+ expect(stamp).not.toHaveProperty("roleId");
352
+ expect(stamp).not.toHaveProperty("roleShape");
353
+ expect(stamp).not.toHaveProperty("isCoreLayerRole");
354
+ expect(stamp).not.toHaveProperty("isSnmpWalkableRole");
355
+ });
356
+
357
+ /*
358
+ * The legend groups nodes by role key, and a group with no key cannot be
359
+ * grouped — so the key is the one field a found row always contributes,
360
+ * however empty the rest of it is.
361
+ */
362
+ test("roleKey is always stamped when the row is found, even if nothing else is", () => {
363
+ const index: Map<string, TopologyDeviceRoleInput> = buildDeviceRoleIndex([
364
+ { key: "router", name: "" },
365
+ ]);
366
+
367
+ const stamp: TopologyNodeRoleStamp = stampForRoleKey("router", index);
368
+
369
+ expect(Object.keys(stamp)).toEqual(["roleKey"]);
370
+ expect(stamp.roleKey).toBe("router");
371
+ });
372
+
373
+ /*
374
+ * An empty name is not a label. Stamping "" would draw a blank chip in the
375
+ * legend and a blank accessible label; leaving it absent falls back to the
376
+ * built-in name for the classified role.
377
+ */
378
+ test("a row with a blank name stamps no label, so the built-in one is used", () => {
379
+ const index: Map<string, TopologyDeviceRoleInput> = buildDeviceRoleIndex([
380
+ makeRole("router", { name: "", topologyShape: "circle" }),
381
+ ]);
382
+
383
+ const stamp: TopologyNodeRoleStamp = stampForRoleKey("router", index);
384
+
385
+ expect(stamp).not.toHaveProperty("roleLabel");
386
+ expect(stamp.roleShape).toBe("circle");
387
+ });
388
+
389
+ test("a row with a blank id stamps no roleId", () => {
390
+ const index: Map<string, TopologyDeviceRoleInput> = buildDeviceRoleIndex([
391
+ makeRole("router", { id: "", name: "Router" }),
392
+ ]);
393
+
394
+ expect(stampForRoleKey("router", index)).not.toHaveProperty("roleId");
395
+ });
396
+
397
+ test("the row is found however the caller cases or pads the key", () => {
398
+ const index: Map<string, TopologyDeviceRoleInput> = buildDeviceRoleIndex([
399
+ makeRole("wirelessAccessPoint", { name: "Access Point" }),
400
+ ]);
401
+
402
+ expect(stampForRoleKey("wirelessAccessPoint", index).roleLabel).toBe(
403
+ "Access Point",
404
+ );
405
+ expect(stampForRoleKey(" WIRELESSACCESSPOINT ", index).roleLabel).toBe(
406
+ "Access Point",
407
+ );
408
+ });
409
+
410
+ /*
411
+ * The stamped key is the ROW's key verbatim, not the lowercased key the
412
+ * lookup was done with. Built-in keys are lowerCamelCase
413
+ * ("wirelessAccessPoint"), and a node carrying "wirelessaccesspoint" would
414
+ * no longer equal the key anything else in the product compares against.
415
+ */
416
+ test("stamps the row's own key, not the lowercased key it was looked up by", () => {
417
+ const index: Map<string, TopologyDeviceRoleInput> = buildDeviceRoleIndex([
418
+ makeRole("wirelessAccessPoint", { name: "Access Point" }),
419
+ ]);
420
+
421
+ expect(stampForRoleKey("WIRELESSACCESSPOINT", index).roleKey).toBe(
422
+ "wirelessAccessPoint",
423
+ );
424
+ });
425
+ });
426
+
427
+ describe("stampForRoleKey — topologyShape is validated, not trusted", () => {
428
+ test("accepts each of the eight silhouettes the renderer can draw", () => {
429
+ for (const shape of ALL_SHAPES) {
430
+ const index: Map<string, TopologyDeviceRoleInput> = buildDeviceRoleIndex([
431
+ makeRole("custom", { topologyShape: shape }),
432
+ ]);
433
+
434
+ expect(stampForRoleKey("custom", index).roleShape).toBe(shape);
435
+ }
436
+ });
437
+
438
+ /*
439
+ * topologyShape is a free-text column — the geometry lives in the client and
440
+ * adding a shape must not need a migration — so an unrecognised value is
441
+ * dropped rather than handed to a renderer with no path for it, which would
442
+ * draw nothing at all.
443
+ */
444
+ test("drops a shape the renderer has no path for", () => {
445
+ const unknownShapes: ReadonlyArray<string> = [
446
+ "octagon",
447
+ "",
448
+ " ",
449
+ "star",
450
+ "rounded_square",
451
+ ];
452
+
453
+ for (const shape of unknownShapes) {
454
+ const index: Map<string, TopologyDeviceRoleInput> = buildDeviceRoleIndex([
455
+ makeRole("custom", { name: "Custom", topologyShape: shape }),
456
+ ]);
457
+
458
+ expect(stampForRoleKey("custom", index)).not.toHaveProperty("roleShape");
459
+ }
460
+ });
461
+
462
+ /*
463
+ * Shapes are matched exactly, unlike keys: they are written by the settings
464
+ * dropdown, not typed by hand, and a near-miss is a bug worth falling back
465
+ * on rather than guessing at.
466
+ */
467
+ test("a shape with the wrong case or stray padding is not a shape", () => {
468
+ for (const shape of ["CIRCLE", "Rounded-Square", " circle "]) {
469
+ const index: Map<string, TopologyDeviceRoleInput> = buildDeviceRoleIndex([
470
+ makeRole("custom", { topologyShape: shape }),
471
+ ]);
472
+
473
+ expect(stampForRoleKey("custom", index)).not.toHaveProperty("roleShape");
474
+ }
475
+ });
476
+
477
+ /*
478
+ * One bad column value must cost only the shape. Blanking the whole stamp
479
+ * would also lose the label and the layout flags the operator did configure.
480
+ */
481
+ test("a bad shape does not cost the row its label or its flags", () => {
482
+ const index: Map<string, TopologyDeviceRoleInput> = buildDeviceRoleIndex([
483
+ makeRole("posTerminal", {
484
+ name: "PoS Terminal",
485
+ topologyShape: "octagon",
486
+ isCoreLayer: false,
487
+ isSnmpWalkable: false,
488
+ }),
489
+ ]);
490
+
491
+ expect(stampForRoleKey("posTerminal", index)).toEqual({
492
+ roleKey: "posTerminal",
493
+ roleLabel: "PoS Terminal",
494
+ isCoreLayerRole: false,
495
+ isSnmpWalkableRole: false,
496
+ });
497
+ });
498
+ });
499
+
500
+ describe("stampForRoleKey — an explicit false is an answer", () => {
501
+ /*
502
+ * "This role is not core" and "this role says nothing about being core" are
503
+ * different: the first must beat the built-in CORE set (a project that
504
+ * decided its firewalls are not core devices), the second must fall back to
505
+ * it. Dropping false as falsy would collapse the two.
506
+ */
507
+ test("isCoreLayer false is stamped as false rather than dropped as falsy", () => {
508
+ const index: Map<string, TopologyDeviceRoleInput> = buildDeviceRoleIndex([
509
+ makeRole("firewall", { name: "Firewall", isCoreLayer: false }),
510
+ ]);
511
+
512
+ const stamp: TopologyNodeRoleStamp = stampForRoleKey("firewall", index);
513
+
514
+ expect(stamp).toHaveProperty("isCoreLayerRole");
515
+ expect(stamp.isCoreLayerRole).toBe(false);
516
+ });
517
+
518
+ test("isSnmpWalkable false is stamped as false rather than dropped as falsy", () => {
519
+ const index: Map<string, TopologyDeviceRoleInput> = buildDeviceRoleIndex([
520
+ makeRole("camera", { name: "Camera", isSnmpWalkable: false }),
521
+ ]);
522
+
523
+ const stamp: TopologyNodeRoleStamp = stampForRoleKey("camera", index);
524
+
525
+ expect(stamp).toHaveProperty("isSnmpWalkableRole");
526
+ expect(stamp.isSnmpWalkableRole).toBe(false);
527
+ });
528
+
529
+ test("a row that omits both flags stamps neither of them", () => {
530
+ const index: Map<string, TopologyDeviceRoleInput> = buildDeviceRoleIndex([
531
+ makeRole("router", { name: "Router" }),
532
+ ]);
533
+
534
+ const stamp: TopologyNodeRoleStamp = stampForRoleKey("router", index);
535
+
536
+ expect(stamp).not.toHaveProperty("isCoreLayerRole");
537
+ expect(stamp).not.toHaveProperty("isSnmpWalkableRole");
538
+ });
539
+ });
540
+
541
+ describe("stampForRoleKey — purity", () => {
542
+ test("does not mutate the index or the rows inside it", () => {
543
+ const roles: Array<TopologyDeviceRoleInput> = [
544
+ makeRole("router", {
545
+ id: "role-1",
546
+ name: "Router",
547
+ topologyShape: "circle",
548
+ isCoreLayer: true,
549
+ isSnmpWalkable: true,
550
+ }),
551
+ ];
552
+ const index: Map<string, TopologyDeviceRoleInput> =
553
+ buildDeviceRoleIndex(roles);
554
+ const before: string = snapshot(roles);
555
+
556
+ stampForRoleKey("router", index);
557
+ stampForRoleKey("nothing-here", index);
558
+ stampForRoleKey(undefined, index);
559
+
560
+ expect(snapshot(roles)).toBe(before);
561
+ expect(index.size).toBe(1);
562
+ });
563
+
564
+ /*
565
+ * Each node gets its own stamp object. Sharing one would let a consumer that
566
+ * edits a node's role fields corrupt every other node of the same role.
567
+ */
568
+ test("returns a fresh object per call, so one node's stamp cannot leak into another's", () => {
569
+ const index: Map<string, TopologyDeviceRoleInput> = buildDeviceRoleIndex([
570
+ makeRole("router", { name: "Router" }),
571
+ ]);
572
+
573
+ const first: TopologyNodeRoleStamp = stampForRoleKey("router", index);
574
+ const second: TopologyNodeRoleStamp = stampForRoleKey("router", index);
575
+
576
+ expect(first).not.toBe(second);
577
+ expect(first).toEqual(second);
578
+
579
+ first.roleLabel = "Mutated";
580
+
581
+ expect(second.roleLabel).toBe("Router");
582
+ expect(stampForRoleKey("router", index).roleLabel).toBe("Router");
583
+ });
584
+
585
+ test("the empty stamp is a fresh object too, not a shared constant", () => {
586
+ const index: Map<string, TopologyDeviceRoleInput> = buildDeviceRoleIndex(
587
+ [],
588
+ );
589
+
590
+ const first: TopologyNodeRoleStamp = stampForRoleKey("router", index);
591
+ first.roleKey = "mutated";
592
+
593
+ expect(stampForRoleKey("router", index)).toEqual({});
594
+ });
595
+ });
596
+
597
+ /*
598
+ * The seeded defaults reproduce the renderer's historical SHAPE_BY_ROLE map
599
+ * and CORE_DEVICE_ROLES set, so a freshly seeded project must draw exactly
600
+ * the map it drew before roles were rows. This is the regression that would
601
+ * be noticed last and hurt most.
602
+ */
603
+ describe("the eleven seeded defaults round-trip through the catalog", () => {
604
+ const index: Map<string, TopologyDeviceRoleInput> =
605
+ buildDeviceRoleIndex(seededRoles);
606
+
607
+ test("every default stamps its configured label, shape and layout flags", () => {
608
+ for (const role of DEFAULT_NETWORK_DEVICE_ROLES) {
609
+ expect(stampForRoleKey(role.key, index)).toEqual({
610
+ roleKey: role.key,
611
+ roleLabel: role.name,
612
+ roleShape: role.topologyShape,
613
+ isCoreLayerRole: role.isCoreLayer,
614
+ isSnmpWalkableRole: role.isSnmpWalkable,
615
+ });
616
+ }
617
+ });
618
+
619
+ test("every default's shape is one the renderer can actually draw", () => {
620
+ for (const role of DEFAULT_NETWORK_DEVICE_ROLES) {
621
+ expect(ALL_SHAPES).toContain(role.topologyShape);
622
+ }
623
+ });
624
+
625
+ /*
626
+ * "unknown" is not seeded on purpose — it is the classifier declining to
627
+ * answer, not a role anyone assigns — so it must find no row even in a
628
+ * fully seeded project.
629
+ */
630
+ test('"unknown" matches no seeded row', () => {
631
+ expect(stampForRoleKey("unknown", index)).toEqual({});
632
+ });
633
+ });
634
+
635
+ describe("roleKeyForNode", () => {
636
+ /*
637
+ * The operator's assignment wins for presentation exactly as it does for
638
+ * `role` itself: a device explicitly filed under a role is drawn as that
639
+ * role however the classifier reads its sysDescr.
640
+ */
641
+ test("an assigned key beats the classifier's answer", () => {
642
+ expect(roleKeyForNode("posTerminal", "switch")).toBe("posterminal");
643
+ expect(roleKeyForNode("router", "host")).toBe("router");
644
+ });
645
+
646
+ /*
647
+ * The assignment is normalized the same way the index is keyed, so the two
648
+ * meet in the middle whatever case the stored column is in.
649
+ */
650
+ test("the assigned key is normalized, and still finds its row", () => {
651
+ expect(roleKeyForNode(" ROUTER ", "switch")).toBe("router");
652
+
653
+ const index: Map<string, TopologyDeviceRoleInput> = buildDeviceRoleIndex([
654
+ makeRole("router", { name: "Edge Router" }),
655
+ ]);
656
+
657
+ expect(
658
+ stampForRoleKey(roleKeyForNode(" Router ", "switch"), index).roleLabel,
659
+ ).toBe("Edge Router");
660
+ });
661
+
662
+ /*
663
+ * With no assignment the classifier's answer is used, so a project that
664
+ * renamed "Router" to "Edge Router" sees the new name on every device it
665
+ * never had to touch.
666
+ */
667
+ test("with no assignment the classifier's answer is used, verbatim", () => {
668
+ expect(roleKeyForNode(undefined, "wirelessAccessPoint")).toBe(
669
+ "wirelessAccessPoint",
670
+ );
671
+ expect(roleKeyForNode(undefined, "loadBalancer")).toBe("loadBalancer");
672
+ });
673
+
674
+ test("a classified key keeps its camelCase and still resolves through the index", () => {
675
+ const index: Map<string, TopologyDeviceRoleInput> =
676
+ buildDeviceRoleIndex(seededRoles);
677
+
678
+ const key: string | undefined = roleKeyForNode(
679
+ undefined,
680
+ "wirelessAccessPoint",
681
+ );
682
+
683
+ expect(stampForRoleKey(key, index).roleLabel).toBe("Wireless AP");
684
+ });
685
+
686
+ /*
687
+ * A stored column that is blank is not an assignment — it is the absence of
688
+ * one — so it must not shadow the classifier and leave the node unclassified.
689
+ */
690
+ test("a blank assignment falls through to the classifier", () => {
691
+ expect(roleKeyForNode("", "router")).toBe("router");
692
+ expect(roleKeyForNode(" ", "router")).toBe("router");
693
+ expect(roleKeyForNode(undefined, "router")).toBe("router");
694
+ });
695
+
696
+ /*
697
+ * "unknown" is the classifier saying it has no answer, not a role. It is no
698
+ * row's key, and looking it up would be a wasted miss that reads as if the
699
+ * project were missing a role it should have.
700
+ */
701
+ test('a classified "unknown" is never looked up', () => {
702
+ expect(roleKeyForNode(undefined, "unknown")).toBeUndefined();
703
+ expect(roleKeyForNode("", "unknown")).toBeUndefined();
704
+ expect(roleKeyForNode(" ", "unknown")).toBeUndefined();
705
+ });
706
+
707
+ /*
708
+ * The short-circuit guards the CLASSIFIER's answer only. An assigned
709
+ * "unknown" is passed through, and in a project with no such row it simply
710
+ * finds nothing — which is the same neutral node, reached honestly.
711
+ */
712
+ test('an assigned "unknown" is returned and simply matches no row', () => {
713
+ const index: Map<string, TopologyDeviceRoleInput> =
714
+ buildDeviceRoleIndex(seededRoles);
715
+
716
+ expect(roleKeyForNode("unknown", "router")).toBe("unknown");
717
+ expect(stampForRoleKey("unknown", index)).toEqual({});
718
+ });
719
+
720
+ test('every built-in role except "unknown" passes through unchanged', () => {
721
+ for (const role of DEFAULT_NETWORK_DEVICE_ROLES) {
722
+ const classified: NetworkTopologyDeviceRole = role.key;
723
+ expect(roleKeyForNode(undefined, classified)).toBe(role.key);
724
+ }
725
+ });
726
+ });
727
+
728
+ describe("applyRoleStamp", () => {
729
+ test("copies only the fields the stamp carries and leaves the rest absent", () => {
730
+ const node: NetworkTopologyNode = makeNode({ role: "switch" });
731
+
732
+ applyRoleStamp(node, { roleKey: "switch", roleLabel: "Access Switch" });
733
+
734
+ expect(node.roleKey).toBe("switch");
735
+ expect(node.roleLabel).toBe("Access Switch");
736
+ expect(node).not.toHaveProperty("roleId");
737
+ expect(node).not.toHaveProperty("roleShape");
738
+ expect(node).not.toHaveProperty("isCoreLayerRole");
739
+ expect(node).not.toHaveProperty("isSnmpWalkableRole");
740
+ expect(Object.keys(node).sort()).toEqual([
741
+ "id",
742
+ "isManaged",
743
+ "name",
744
+ "role",
745
+ "roleKey",
746
+ "roleLabel",
747
+ "status",
748
+ ]);
749
+ });
750
+
751
+ test("a full stamp copies all six fields onto the node", () => {
752
+ const node: NetworkTopologyNode = makeNode();
753
+
754
+ applyRoleStamp(node, {
755
+ roleId: "role-1",
756
+ roleKey: "router",
757
+ roleLabel: "Edge Router",
758
+ roleShape: "hexagon",
759
+ isCoreLayerRole: true,
760
+ isSnmpWalkableRole: true,
761
+ });
762
+
763
+ expect(node.roleId).toBe("role-1");
764
+ expect(node.roleKey).toBe("router");
765
+ expect(node.roleLabel).toBe("Edge Router");
766
+ expect(node.roleShape).toBe("hexagon");
767
+ expect(node.isCoreLayerRole).toBe(true);
768
+ expect(node.isSnmpWalkableRole).toBe(true);
769
+ });
770
+
771
+ /*
772
+ * The unconfigured project's whole path: an empty stamp reaches every node
773
+ * and the payload comes out identical to the one built before roles existed.
774
+ */
775
+ test("an empty stamp leaves the node exactly as it was", () => {
776
+ const node: NetworkTopologyNode = makeNode({
777
+ role: "router",
778
+ kind: "device",
779
+ });
780
+ const before: string = snapshot(node);
781
+ const keysBefore: Array<string> = Object.keys(node).sort();
782
+
783
+ applyRoleStamp(node, {});
784
+
785
+ expect(snapshot(node)).toBe(before);
786
+ expect(Object.keys(node).sort()).toEqual(keysBefore);
787
+ for (const field of STAMP_FIELDS) {
788
+ expect(node).not.toHaveProperty(field);
789
+ }
790
+ });
791
+
792
+ test("applying the same stamp twice is idempotent", () => {
793
+ const stamp: TopologyNodeRoleStamp = {
794
+ roleKey: "router",
795
+ roleLabel: "Edge Router",
796
+ roleShape: "circle",
797
+ isCoreLayerRole: true,
798
+ };
799
+
800
+ const once: NetworkTopologyNode = makeNode({ role: "router" });
801
+ applyRoleStamp(once, stamp);
802
+ const afterFirst: string = snapshot(once);
803
+
804
+ applyRoleStamp(once, stamp);
805
+
806
+ expect(snapshot(once)).toBe(afterFirst);
807
+ });
808
+
809
+ /*
810
+ * tierForNode reads isCoreLayerRole BEFORE the built-in core set, so a false
811
+ * that got dropped on the way onto the node would put a role the operator
812
+ * explicitly demoted straight back at core level.
813
+ */
814
+ test("writes an explicit false onto the node rather than skipping it", () => {
815
+ const node: NetworkTopologyNode = makeNode({ role: "firewall" });
816
+
817
+ applyRoleStamp(node, {
818
+ roleKey: "firewall",
819
+ isCoreLayerRole: false,
820
+ isSnmpWalkableRole: false,
821
+ });
822
+
823
+ expect(node).toHaveProperty("isCoreLayerRole");
824
+ expect(node.isCoreLayerRole).toBe(false);
825
+ expect(node).toHaveProperty("isSnmpWalkableRole");
826
+ expect(node.isSnmpWalkableRole).toBe(false);
827
+ });
828
+
829
+ /*
830
+ * A field the stamp does not carry must neither be created as undefined nor
831
+ * clear a value already on the node — "absent" has to stay absent for any
832
+ * consumer that checks with `in` or serialises the node.
833
+ */
834
+ test("a field the stamp omits is neither created nor cleared", () => {
835
+ const node: NetworkTopologyNode = makeNode({
836
+ roleLabel: "Already Set",
837
+ roleShape: "diamond",
838
+ });
839
+
840
+ applyRoleStamp(node, { roleKey: "router", roleLabel: undefined });
841
+
842
+ expect(node.roleKey).toBe("router");
843
+ expect(node.roleLabel).toBe("Already Set");
844
+ expect(node.roleShape).toBe("diamond");
845
+ expect(node).not.toHaveProperty("roleId");
846
+ expect(node).not.toHaveProperty("isCoreLayerRole");
847
+ });
848
+
849
+ test("leaves the node's own identity and status fields alone", () => {
850
+ const node: NetworkTopologyNode = makeNode({
851
+ role: "switch",
852
+ kind: "device",
853
+ status: "down",
854
+ vendor: "Cisco",
855
+ });
856
+
857
+ applyRoleStamp(node, { roleKey: "switch", roleLabel: "Access Switch" });
858
+
859
+ expect(node.id).toBe("device-1");
860
+ expect(node.name).toBe("core-sw-1");
861
+ expect(node.isManaged).toBe(true);
862
+ expect(node.status).toBe("down");
863
+ expect(node.kind).toBe("device");
864
+ expect(node.role).toBe("switch");
865
+ expect(node.vendor).toBe("Cisco");
866
+ });
867
+
868
+ test("does not mutate the stamp it was handed", () => {
869
+ const stamp: TopologyNodeRoleStamp = {
870
+ roleKey: "router",
871
+ roleLabel: "Edge Router",
872
+ isCoreLayerRole: false,
873
+ };
874
+ const before: string = snapshot(stamp);
875
+
876
+ applyRoleStamp(makeNode(), stamp);
877
+
878
+ expect(snapshot(stamp)).toBe(before);
879
+ });
880
+
881
+ /*
882
+ * One stamp is applied to every node of a role, so it must be reusable
883
+ * across nodes without any of them picking up another's fields.
884
+ */
885
+ test("the same stamp can be applied to many nodes independently", () => {
886
+ const stamp: TopologyNodeRoleStamp = stampForRoleKey(
887
+ "router",
888
+ buildDeviceRoleIndex([
889
+ makeRole("router", { name: "Edge Router", topologyShape: "circle" }),
890
+ ]),
891
+ );
892
+
893
+ const first: NetworkTopologyNode = makeNode({ id: "a" });
894
+ const second: NetworkTopologyNode = makeNode({ id: "b" });
895
+
896
+ applyRoleStamp(first, stamp);
897
+ first.roleLabel = "Renamed on this node only";
898
+ applyRoleStamp(second, stamp);
899
+
900
+ expect(second.roleLabel).toBe("Edge Router");
901
+ });
902
+ });
903
+
904
+ describe("the catalog end to end", () => {
905
+ /*
906
+ * Purity all the way through: the same rows and the same node must produce
907
+ * the same payload every time, because the topology is rebuilt on every
908
+ * poll and a map that shifted between identical builds would be unreadable.
909
+ */
910
+ test("the same rows and node produce an identical result on every run", () => {
911
+ const run: () => NetworkTopologyNode = (): NetworkTopologyNode => {
912
+ const index: Map<string, TopologyDeviceRoleInput> =
913
+ buildDeviceRoleIndex(seededRoles);
914
+ const node: NetworkTopologyNode = makeNode({
915
+ role: "wirelessAccessPoint",
916
+ });
917
+ applyRoleStamp(
918
+ node,
919
+ stampForRoleKey(
920
+ roleKeyForNode(undefined, node.role || "unknown"),
921
+ index,
922
+ ),
923
+ );
924
+ return node;
925
+ };
926
+
927
+ expect(snapshot(run())).toBe(snapshot(run()));
928
+ });
929
+
930
+ /*
931
+ * The whole point of the feature, in one pass: a renamed and reshaped role
932
+ * reaches a device the operator never edited, because the classifier's key
933
+ * still matches the row.
934
+ */
935
+ test("a renamed role reaches a device nobody assigned a role to", () => {
936
+ const index: Map<string, TopologyDeviceRoleInput> = buildDeviceRoleIndex([
937
+ makeRole("router", {
938
+ id: "role-router",
939
+ name: "Edge Router",
940
+ topologyShape: "hexagon",
941
+ isCoreLayer: true,
942
+ isSnmpWalkable: true,
943
+ }),
944
+ ]);
945
+ const node: NetworkTopologyNode = makeNode({ role: "router" });
946
+
947
+ applyRoleStamp(
948
+ node,
949
+ stampForRoleKey(roleKeyForNode(undefined, "router"), index),
950
+ );
951
+
952
+ expect(node.roleLabel).toBe("Edge Router");
953
+ expect(node.roleShape).toBe("hexagon");
954
+ expect(node.isCoreLayerRole).toBe(true);
955
+ expect(node.roleId).toBe("role-router");
956
+ // The classified role is untouched — the configured fields sit alongside it.
957
+ expect(node.role).toBe("router");
958
+ });
959
+
960
+ /*
961
+ * And the same pass for a project with nothing configured: not one of the
962
+ * six fields appears, so every consumer downstream behaves exactly as it did
963
+ * before device roles were configurable.
964
+ */
965
+ test("a project with no roles configured gets a node with no role fields at all", () => {
966
+ const index: Map<string, TopologyDeviceRoleInput> = buildDeviceRoleIndex(
967
+ [],
968
+ );
969
+ const node: NetworkTopologyNode = makeNode({ role: "router" });
970
+
971
+ applyRoleStamp(
972
+ node,
973
+ stampForRoleKey(roleKeyForNode(undefined, "router"), index),
974
+ );
975
+
976
+ for (const field of STAMP_FIELDS) {
977
+ expect(node).not.toHaveProperty(field);
978
+ }
979
+ expect(node.role).toBe("router");
980
+ });
981
+ });