github.com/cockroachdb/cockroach@v20.2.0-alpha.1+incompatible/pkg/ui/src/redux/alerts.spec.ts (about) 1 // Copyright 2018 The Cockroach Authors. 2 // 3 // Use of this software is governed by the Business Source License 4 // included in the file licenses/BSL.txt. 5 // 6 // As of the Change Date specified in that file, in accordance with 7 // the Business Source License, use of this software will be governed 8 // by the Apache License, Version 2.0, included in the file 9 // licenses/APL.txt. 10 11 import { assert } from "chai"; 12 import { Store } from "redux"; 13 import moment from "moment"; 14 import sinon from "sinon"; 15 import { createHashHistory } from "history"; 16 17 import * as protos from "src/js/protos"; 18 import { API_PREFIX } from "src/util/api"; 19 import fetchMock from "src/util/fetch-mock"; 20 21 import { AdminUIState, createAdminUIStore } from "./state"; 22 import { 23 AlertLevel, 24 alertDataSync, 25 staggeredVersionWarningSelector, 26 staggeredVersionDismissedSetting, 27 newVersionNotificationSelector, 28 newVersionDismissedLocalSetting, 29 disconnectedAlertSelector, 30 disconnectedDismissedLocalSetting, 31 emailSubscriptionAlertLocalSetting, 32 emailSubscriptionAlertSelector, 33 } from "./alerts"; 34 import { versionsSelector } from "src/redux/nodes"; 35 import { 36 VERSION_DISMISSED_KEY, INSTRUCTIONS_BOX_COLLAPSED_KEY, 37 setUIDataKey, isInFlight, 38 } from "./uiData"; 39 import { 40 livenessReducerObj, versionReducerObj, nodesReducerObj, clusterReducerObj, healthReducerObj, 41 } from "./apiReducers"; 42 43 const sandbox = sinon.createSandbox(); 44 45 describe("alerts", function() { 46 let store: Store<AdminUIState>; 47 let dispatch: typeof store.dispatch; 48 let state: typeof store.getState; 49 50 beforeEach(function () { 51 store = createAdminUIStore(createHashHistory()); 52 dispatch = store.dispatch; 53 state = store.getState; 54 // localSettings persist values in sessionStorage and 55 // this stub disables caching values between tests. 56 sandbox.stub(sessionStorage, "getItem").returns(null); 57 }); 58 59 afterEach(function() { 60 sandbox.restore(); 61 fetchMock.restore(); 62 }); 63 64 describe("selectors", function() { 65 describe("versions", function() { 66 it("tolerates missing liveness data", function () { 67 dispatch(nodesReducerObj.receiveData([ 68 { 69 build_info: { 70 tag: "0.1", 71 }, 72 }, 73 { 74 build_info: { 75 tag: "0.2", 76 }, 77 }, 78 ])); 79 const versions = versionsSelector(state()); 80 assert.deepEqual(versions, ["0.1", "0.2"]); 81 }); 82 83 it("ignores decommissioned nodes", function () { 84 dispatch(nodesReducerObj.receiveData([ 85 { 86 build_info: { 87 tag: "0.1", 88 }, 89 }, 90 { 91 desc: { 92 node_id: 2, 93 }, 94 build_info: { 95 tag: "0.2", 96 }, 97 }, 98 ])); 99 100 dispatch(livenessReducerObj.receiveData( 101 new protos.cockroach.server.serverpb.LivenessResponse({ 102 livenesses: [{ 103 node_id: 2, 104 decommissioning: true, 105 }], 106 }), 107 )); 108 109 const versions = versionsSelector(state()); 110 assert.deepEqual(versions, ["0.1"]); 111 }); 112 }); 113 114 describe("version mismatch warning", function () { 115 it("requires versions to be loaded before displaying", function () { 116 const alert = staggeredVersionWarningSelector(state()); 117 assert.isUndefined(alert); 118 }); 119 120 it("does not display when versions match", function () { 121 dispatch(nodesReducerObj.receiveData([ 122 { 123 build_info: { 124 tag: "0.1", 125 }, 126 }, 127 { 128 build_info: { 129 tag: "0.1", 130 }, 131 }, 132 ])); 133 const alert = staggeredVersionWarningSelector(state()); 134 assert.isUndefined(alert); 135 }); 136 137 it("displays when mismatch detected and not dismissed", function () { 138 dispatch(nodesReducerObj.receiveData([ 139 { 140 // `desc` intentionally omitted (must not affect outcome). 141 build_info: { 142 tag: "0.1", 143 }, 144 }, 145 { 146 desc: { 147 node_id: 1, 148 }, 149 build_info: { 150 tag: "0.2", 151 }, 152 }, 153 ])); 154 const alert = staggeredVersionWarningSelector(state()); 155 assert.isObject(alert); 156 assert.equal(alert.level, AlertLevel.WARNING); 157 assert.equal(alert.title, "Staggered Version"); 158 }); 159 160 it("does not display if dismissed locally", function () { 161 dispatch(nodesReducerObj.receiveData([ 162 { 163 build_info: { 164 tag: "0.1", 165 }, 166 }, 167 { 168 build_info: { 169 tag: "0.2", 170 }, 171 }, 172 ])); 173 dispatch(staggeredVersionDismissedSetting.set(true)); 174 const alert = staggeredVersionWarningSelector(state()); 175 assert.isUndefined(alert); 176 }); 177 178 it("dismisses by setting local dismissal", function () { 179 dispatch(nodesReducerObj.receiveData([ 180 { 181 build_info: { 182 tag: "0.1", 183 }, 184 }, 185 { 186 build_info: { 187 tag: "0.2", 188 }, 189 }, 190 ])); 191 const alert = staggeredVersionWarningSelector(state()); 192 dispatch(alert.dismiss); 193 assert.isTrue(staggeredVersionDismissedSetting.selector(state())); 194 }); 195 }); 196 197 describe("new version available notification", function () { 198 it("displays nothing when versions have not yet been loaded", function () { 199 dispatch(setUIDataKey(VERSION_DISMISSED_KEY, null)); 200 const alert = newVersionNotificationSelector(state()); 201 assert.isUndefined(alert); 202 }); 203 204 it("displays nothing when persistent dismissal has not been checked", function () { 205 dispatch(versionReducerObj.receiveData({ 206 details: [ 207 { 208 version: "0.1", 209 detail: "alpha", 210 }, 211 ], 212 })); 213 const alert = newVersionNotificationSelector(state()); 214 assert.isUndefined(alert); 215 }); 216 217 it("displays nothing when no new version is available", function () { 218 dispatch(setUIDataKey(VERSION_DISMISSED_KEY, null)); 219 dispatch(versionReducerObj.receiveData({ 220 details: [], 221 })); 222 const alert = newVersionNotificationSelector(state()); 223 assert.isUndefined(alert); 224 }); 225 226 it("displays when new version available and not dismissed", function () { 227 dispatch(setUIDataKey(VERSION_DISMISSED_KEY, null)); 228 dispatch(versionReducerObj.receiveData({ 229 details: [ 230 { 231 version: "0.1", 232 detail: "alpha", 233 }, 234 ], 235 })); 236 const alert = newVersionNotificationSelector(state()); 237 assert.isObject(alert); 238 assert.equal(alert.level, AlertLevel.NOTIFICATION); 239 assert.equal(alert.title, "New Version Available"); 240 }); 241 242 it("respects local dismissal setting", function () { 243 dispatch(setUIDataKey(VERSION_DISMISSED_KEY, null)); 244 dispatch(versionReducerObj.receiveData({ 245 details: [ 246 { 247 version: "0.1", 248 detail: "alpha", 249 }, 250 ], 251 })); 252 dispatch(newVersionDismissedLocalSetting.set(moment())); 253 let alert = newVersionNotificationSelector(state()); 254 assert.isUndefined(alert); 255 256 // Local dismissal only lasts one day. 257 dispatch(newVersionDismissedLocalSetting.set(moment().subtract(2, "days"))); 258 alert = newVersionNotificationSelector(state()); 259 assert.isDefined(alert); 260 }); 261 262 it("respects persistent dismissal setting", function () { 263 dispatch(setUIDataKey(VERSION_DISMISSED_KEY, moment().valueOf())); 264 dispatch(versionReducerObj.receiveData({ 265 details: [ 266 { 267 version: "0.1", 268 detail: "alpha", 269 }, 270 ], 271 })); 272 let alert = newVersionNotificationSelector(state()); 273 assert.isUndefined(alert); 274 275 // Dismissal only lasts one day. 276 dispatch(setUIDataKey(VERSION_DISMISSED_KEY, moment().subtract(2, "days").valueOf())); 277 alert = newVersionNotificationSelector(state()); 278 assert.isDefined(alert); 279 }); 280 281 it("dismisses by setting local and persistent dismissal", function (done) { 282 fetchMock.mock({ 283 matcher: `${API_PREFIX}/uidata`, 284 method: "POST", 285 response: (_url: string) => { 286 const encodedResponse = protos.cockroach.server.serverpb.SetUIDataResponse.encode({}).finish(); 287 return { 288 body: encodedResponse, 289 }; 290 }, 291 }); 292 293 dispatch(setUIDataKey(VERSION_DISMISSED_KEY, null)); 294 dispatch(versionReducerObj.receiveData({ 295 details: [ 296 { 297 version: "0.1", 298 detail: "alpha", 299 }, 300 ], 301 })); 302 const alert = newVersionNotificationSelector(state()); 303 const beforeDismiss = moment(); 304 305 dispatch(alert.dismiss).then(() => { 306 assert.isTrue(newVersionDismissedLocalSetting.selector(state()).isSameOrAfter(beforeDismiss)); 307 assert.isNotNull(state().uiData[VERSION_DISMISSED_KEY]); 308 assert.isNotNull(state().uiData[VERSION_DISMISSED_KEY].data); 309 const dismissedMoment = moment(state().uiData[VERSION_DISMISSED_KEY].data as number); 310 assert.isTrue(dismissedMoment.isSameOrAfter(beforeDismiss)); 311 done(); 312 }); 313 }); 314 }); 315 316 describe("disconnected alert", function () { 317 it("requires health to be available before displaying", function () { 318 const alert = disconnectedAlertSelector(state()); 319 assert.isUndefined(alert); 320 }); 321 322 it("does not display when cluster is healthy", function () { 323 dispatch(healthReducerObj.receiveData( 324 new protos.cockroach.server.serverpb.ClusterResponse({})), 325 ); 326 const alert = disconnectedAlertSelector(state()); 327 assert.isUndefined(alert); 328 }); 329 330 it("displays when cluster health endpoint returns an error", function () { 331 dispatch(healthReducerObj.errorData(new Error("error"))); 332 const alert = disconnectedAlertSelector(state()); 333 assert.isObject(alert); 334 assert.equal(alert.level, AlertLevel.CRITICAL); 335 assert.equal(alert.title, "We're currently having some trouble fetching updated data. If this persists, it might be a good idea to check your network connection to the CockroachDB cluster."); 336 }); 337 338 it("does not display if dismissed locally", function () { 339 dispatch(healthReducerObj.errorData(new Error("error"))); 340 dispatch(disconnectedDismissedLocalSetting.set(moment())); 341 const alert = disconnectedAlertSelector(state()); 342 assert.isUndefined(alert); 343 }); 344 345 it("dismisses by setting local dismissal", function (done) { 346 dispatch(healthReducerObj.errorData(new Error("error"))); 347 const alert = disconnectedAlertSelector(state()); 348 const beforeDismiss = moment(); 349 350 dispatch(alert.dismiss).then(() => { 351 assert.isTrue( 352 disconnectedDismissedLocalSetting.selector(state()).isSameOrAfter(beforeDismiss), 353 ); 354 done(); 355 }); 356 }); 357 }); 358 359 describe("email signup for release notes alert", () => { 360 it("initialized with default 'false' (hidden) state", () => { 361 const settingState = emailSubscriptionAlertLocalSetting.selector(state()); 362 assert.isFalse(settingState); 363 }); 364 365 it("dismissed by alert#dismiss", async () => { 366 // set alert to open state 367 dispatch(emailSubscriptionAlertLocalSetting.set(true)); 368 let openState = emailSubscriptionAlertLocalSetting.selector(state()); 369 assert.isTrue(openState); 370 371 // dismiss alert 372 const alert = emailSubscriptionAlertSelector(state()); 373 await alert.dismiss(dispatch, state); 374 openState = emailSubscriptionAlertLocalSetting.selector(state()); 375 assert.isFalse(openState); 376 }); 377 }); 378 }); 379 380 describe("data sync listener", function() { 381 let sync: () => void; 382 beforeEach(function() { 383 // We don't care about the responses, we only care that the sync listener 384 // is making requests, which can be verified using "inFlight" settings. 385 fetchMock.mock({ 386 matcher: "*", 387 method: "GET", 388 response: () => 500, 389 }); 390 391 sync = alertDataSync(store); 392 }); 393 394 it("dispatches requests for expected data on empty store", function() { 395 sync(); 396 assert.isTrue(isInFlight(state(), VERSION_DISMISSED_KEY)); 397 assert.isTrue(state().cachedData.cluster.inFlight); 398 assert.isTrue(state().cachedData.nodes.inFlight); 399 assert.isFalse(state().cachedData.version.inFlight); 400 assert.isTrue(state().cachedData.health.inFlight); 401 }); 402 403 it("dispatches request for version data when cluster ID and nodes are available", function() { 404 dispatch(nodesReducerObj.receiveData([ 405 { 406 build_info: { 407 tag: "0.1", 408 }, 409 }, 410 ])); 411 dispatch(clusterReducerObj.receiveData(new protos.cockroach.server.serverpb.ClusterResponse({ 412 cluster_id: "my-cluster", 413 }))); 414 415 sync(); 416 assert.isTrue(state().cachedData.version.inFlight); 417 }); 418 419 it("does not request version data when version is staggered", function() { 420 dispatch(nodesReducerObj.receiveData([ 421 { 422 build_info: { 423 tag: "0.1", 424 }, 425 }, 426 { 427 build_info: { 428 tag: "0.2", 429 }, 430 }, 431 ])); 432 dispatch(clusterReducerObj.receiveData(new protos.cockroach.server.serverpb.ClusterResponse({ 433 cluster_id: "my-cluster", 434 }))); 435 436 sync(); 437 assert.isFalse(state().cachedData.version.inFlight); 438 }); 439 440 it("refreshes health function whenever the last health response is no longer valid.", function() { 441 dispatch(healthReducerObj.receiveData( 442 new protos.cockroach.server.serverpb.ClusterResponse({})), 443 ); 444 dispatch(healthReducerObj.invalidateData()); 445 sync(); 446 assert.isTrue(state().cachedData.health.inFlight); 447 }); 448 449 it("does not do anything when all data is available.", function() { 450 dispatch(nodesReducerObj.receiveData([ 451 { 452 build_info: { 453 tag: "0.1", 454 }, 455 }, 456 ])); 457 dispatch(clusterReducerObj.receiveData(new protos.cockroach.server.serverpb.ClusterResponse({ 458 cluster_id: "my-cluster", 459 }))); 460 dispatch(setUIDataKey(VERSION_DISMISSED_KEY, "blank")); 461 dispatch(setUIDataKey(INSTRUCTIONS_BOX_COLLAPSED_KEY, false)); 462 dispatch(versionReducerObj.receiveData({ 463 details: [], 464 })); 465 dispatch(healthReducerObj.receiveData( 466 new protos.cockroach.server.serverpb.ClusterResponse({})), 467 ); 468 469 const expectedState = state(); 470 sync(); 471 assert.deepEqual(state(), expectedState); 472 }); 473 }); 474 });