github.com/hernad/nomad@v1.6.112/ui/tests/unit/adapters/job-test.js (about) 1 /** 2 * Copyright (c) HashiCorp, Inc. 3 * SPDX-License-Identifier: MPL-2.0 4 */ 5 6 import { next } from '@ember/runloop'; 7 import { assign } from '@ember/polyfills'; 8 import { settled } from '@ember/test-helpers'; 9 import { setupTest } from 'ember-qunit'; 10 import { module, test } from 'qunit'; 11 import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage'; 12 import { AbortController } from 'fetch'; 13 import { TextEncoderLite } from 'text-encoder-lite'; 14 import base64js from 'base64-js'; 15 import addToPath from 'nomad-ui/utils/add-to-path'; 16 import sinon from 'sinon'; 17 import { resolve } from 'rsvp'; 18 19 module('Unit | Adapter | Job', function (hooks) { 20 setupTest(hooks); 21 22 hooks.beforeEach(async function () { 23 this.store = this.owner.lookup('service:store'); 24 this.subject = () => this.store.adapterFor('job'); 25 26 window.sessionStorage.clear(); 27 window.localStorage.clear(); 28 29 this.server = startMirage(); 30 31 this.initializeUI = async ({ region, namespace } = {}) => { 32 if (namespace) window.localStorage.nomadActiveNamespace = namespace; 33 if (region) window.localStorage.nomadActiveRegion = region; 34 35 this.server.create('namespace'); 36 this.server.create('namespace', { id: 'some-namespace' }); 37 this.server.create('node-pool'); 38 this.server.create('node'); 39 this.server.create('job', { id: 'job-1', namespaceId: 'default' }); 40 this.server.create('job', { id: 'job-2', namespaceId: 'some-namespace' }); 41 42 this.server.create('region', { id: 'region-1' }); 43 this.server.create('region', { id: 'region-2' }); 44 45 this.system = this.owner.lookup('service:system'); 46 47 // Namespace, default region, and all regions are requests that all 48 // job requests depend on. Fetching them ahead of time means testing 49 // job adapter behavior in isolation. 50 await this.system.get('namespaces'); 51 this.system.get('shouldIncludeRegion'); 52 await this.system.get('defaultRegion'); 53 54 // Reset the handledRequests array to avoid accounting for this 55 // namespaces request everywhere. 56 this.server.pretender.handledRequests.length = 0; 57 }; 58 59 this.initializeWithJob = async (props = {}) => { 60 await this.initializeUI(props); 61 62 const job = await this.store.findRecord( 63 'job', 64 JSON.stringify(['job-1', props.namespace || 'default']) 65 ); 66 this.server.pretender.handledRequests.length = 0; 67 return job; 68 }; 69 }); 70 71 hooks.afterEach(function () { 72 this.server.shutdown(); 73 }); 74 75 test('The job endpoint is the only required endpoint for fetching a job', async function (assert) { 76 await this.initializeUI(); 77 78 const { pretender } = this.server; 79 const jobName = 'job-1'; 80 const jobNamespace = 'default'; 81 const jobId = JSON.stringify([jobName, jobNamespace]); 82 83 this.subject().findRecord(null, { modelName: 'job' }, jobId); 84 85 await settled(); 86 assert.deepEqual( 87 pretender.handledRequests.mapBy('url'), 88 [`/v1/job/${jobName}`], 89 'The only request made is /job/:id' 90 ); 91 }); 92 93 test('When a namespace is set in localStorage but a job in the default namespace is requested, the namespace query param is not present', async function (assert) { 94 await this.initializeUI({ namespace: 'some-namespace' }); 95 96 const { pretender } = this.server; 97 const jobName = 'job-1'; 98 const jobNamespace = 'default'; 99 const jobId = JSON.stringify([jobName, jobNamespace]); 100 101 this.subject().findRecord(null, { modelName: 'job' }, jobId); 102 await settled(); 103 104 assert.deepEqual( 105 pretender.handledRequests.mapBy('url'), 106 [`/v1/job/${jobName}`], 107 'The only request made is /job/:id with no namespace query param' 108 ); 109 }); 110 111 test('When a namespace is in localStorage and the requested job is in the default namespace, the namespace query param is left out', async function (assert) { 112 await this.initializeUI({ namespace: 'red-herring' }); 113 114 const { pretender } = this.server; 115 const jobName = 'job-1'; 116 const jobNamespace = 'default'; 117 const jobId = JSON.stringify([jobName, jobNamespace]); 118 119 this.subject().findRecord(null, { modelName: 'job' }, jobId); 120 await settled(); 121 122 assert.deepEqual( 123 pretender.handledRequests.mapBy('url'), 124 [`/v1/job/${jobName}`], 125 'The request made is /job/:id with no namespace query param' 126 ); 127 }); 128 129 test('When the job has a namespace other than default, it is in the URL', async function (assert) { 130 await this.initializeUI(); 131 132 const { pretender } = this.server; 133 const jobName = 'job-2'; 134 const jobNamespace = 'some-namespace'; 135 const jobId = JSON.stringify([jobName, jobNamespace]); 136 137 this.subject().findRecord(null, { modelName: 'job' }, jobId); 138 await settled(); 139 140 assert.deepEqual( 141 pretender.handledRequests.mapBy('url'), 142 [`/v1/job/${jobName}?namespace=${jobNamespace}`], 143 'The only request made is /job/:id?namespace=:namespace' 144 ); 145 }); 146 147 test('When there is no token set in the token service, no X-Nomad-Token header is set', async function (assert) { 148 await this.initializeUI(); 149 150 const { pretender } = this.server; 151 const jobId = JSON.stringify(['job-1', 'default']); 152 153 this.subject().findRecord(null, { modelName: 'job' }, jobId); 154 await settled(); 155 156 assert.notOk( 157 pretender.handledRequests 158 .mapBy('requestHeaders') 159 .some((headers) => headers['X-Nomad-Token']), 160 'No token header present on either job request' 161 ); 162 }); 163 164 test('When a token is set in the token service, then X-Nomad-Token header is set', async function (assert) { 165 await this.initializeUI(); 166 167 const { pretender } = this.server; 168 const jobId = JSON.stringify(['job-1', 'default']); 169 const secret = 'here is the secret'; 170 171 this.subject().set('token.secret', secret); 172 this.subject().findRecord(null, { modelName: 'job' }, jobId); 173 await settled(); 174 175 assert.ok( 176 pretender.handledRequests 177 .mapBy('requestHeaders') 178 .every((headers) => headers['X-Nomad-Token'] === secret), 179 'The token header is present on both job requests' 180 ); 181 }); 182 183 test('findAll can be watched', async function (assert) { 184 await this.initializeUI(); 185 186 const { pretender } = this.server; 187 188 const request = () => 189 this.subject().findAll(null, { modelName: 'job' }, null, { 190 reload: true, 191 adapterOptions: { watch: true }, 192 }); 193 194 request(); 195 assert.equal( 196 pretender.handledRequests[0].url, 197 '/v1/jobs?index=1', 198 'Second request is a blocking request for jobs' 199 ); 200 201 await settled(); 202 request(); 203 assert.equal( 204 pretender.handledRequests[1].url, 205 '/v1/jobs?index=2', 206 'Third request is a blocking request with an incremented index param' 207 ); 208 209 await settled(); 210 }); 211 212 test('findRecord can be watched', async function (assert) { 213 await this.initializeUI(); 214 215 const jobId = JSON.stringify(['job-1', 'default']); 216 const { pretender } = this.server; 217 218 const request = () => 219 this.subject().findRecord(null, { modelName: 'job' }, jobId, { 220 reload: true, 221 adapterOptions: { watch: true }, 222 }); 223 224 request(); 225 assert.equal( 226 pretender.handledRequests[0].url, 227 '/v1/job/job-1?index=1', 228 'Second request is a blocking request for job-1' 229 ); 230 231 await settled(); 232 request(); 233 assert.equal( 234 pretender.handledRequests[1].url, 235 '/v1/job/job-1?index=2', 236 'Third request is a blocking request with an incremented index param' 237 ); 238 239 await settled(); 240 }); 241 242 test('relationships can be reloaded', async function (assert) { 243 await this.initializeUI(); 244 245 const { pretender } = this.server; 246 const plainId = 'job-1'; 247 const mockModel = makeMockModel(plainId); 248 249 this.subject().reloadRelationship(mockModel, 'summary'); 250 await settled(); 251 assert.equal( 252 pretender.handledRequests[0].url, 253 `/v1/job/${plainId}/summary`, 254 'Relationship was reloaded' 255 ); 256 }); 257 258 test('relationship reloads can be watched', async function (assert) { 259 await this.initializeUI(); 260 261 const { pretender } = this.server; 262 const plainId = 'job-1'; 263 const mockModel = makeMockModel(plainId); 264 265 this.subject().reloadRelationship(mockModel, 'summary', { watch: true }); 266 assert.equal( 267 pretender.handledRequests[0].url, 268 '/v1/job/job-1/summary?index=1', 269 'First request is a blocking request for job-1 summary relationship' 270 ); 271 272 await settled(); 273 this.subject().reloadRelationship(mockModel, 'summary', { watch: true }); 274 await settled(); 275 276 assert.equal( 277 pretender.handledRequests[1].url, 278 '/v1/job/job-1/summary?index=2', 279 'Second request is a blocking request with an incremented index param' 280 ); 281 }); 282 283 test('findAll can be canceled', async function (assert) { 284 await this.initializeUI(); 285 286 const { pretender } = this.server; 287 const controller = new AbortController(); 288 289 pretender.get('/v1/jobs', () => [200, {}, '[]'], true); 290 291 this.subject() 292 .findAll(null, { modelName: 'job' }, null, { 293 reload: true, 294 adapterOptions: { watch: true, abortController: controller }, 295 }) 296 .catch(() => {}); 297 298 const { request: xhr } = pretender.requestReferences[0]; 299 assert.equal(xhr.status, 0, 'Request is still pending'); 300 301 // Schedule the cancelation before waiting 302 next(() => { 303 controller.abort(); 304 }); 305 306 await settled(); 307 assert.ok(xhr.aborted, 'Request was aborted'); 308 }); 309 310 test('findRecord can be canceled', async function (assert) { 311 await this.initializeUI(); 312 313 const { pretender } = this.server; 314 const jobId = JSON.stringify(['job-1', 'default']); 315 const controller = new AbortController(); 316 317 pretender.get('/v1/job/:id', () => [200, {}, '{}'], true); 318 319 this.subject().findRecord(null, { modelName: 'job' }, jobId, { 320 reload: true, 321 adapterOptions: { watch: true, abortController: controller }, 322 }); 323 324 const { request: xhr } = pretender.requestReferences[0]; 325 assert.equal(xhr.status, 0, 'Request is still pending'); 326 327 // Schedule the cancelation before waiting 328 next(() => { 329 controller.abort(); 330 }); 331 332 await settled(); 333 assert.ok(xhr.aborted, 'Request was aborted'); 334 }); 335 336 test('relationship reloads can be canceled', async function (assert) { 337 await this.initializeUI(); 338 339 const { pretender } = this.server; 340 const plainId = 'job-1'; 341 const controller = new AbortController(); 342 const mockModel = makeMockModel(plainId); 343 pretender.get('/v1/job/:id/summary', () => [200, {}, '{}'], true); 344 345 this.subject().reloadRelationship(mockModel, 'summary', { 346 watch: true, 347 abortController: controller, 348 }); 349 350 const { request: xhr } = pretender.requestReferences[0]; 351 assert.equal(xhr.status, 0, 'Request is still pending'); 352 353 // Schedule the cancelation before waiting 354 next(() => { 355 controller.abort(); 356 }); 357 358 await settled(); 359 assert.ok(xhr.aborted, 'Request was aborted'); 360 }); 361 362 test('requests can be canceled even if multiple requests for the same URL were made', async function (assert) { 363 await this.initializeUI(); 364 365 const { pretender } = this.server; 366 const jobId = JSON.stringify(['job-1', 'default']); 367 const controller1 = new AbortController(); 368 const controller2 = new AbortController(); 369 370 pretender.get('/v1/job/:id', () => [200, {}, '{}'], true); 371 372 this.subject().findRecord(null, { modelName: 'job' }, jobId, { 373 reload: true, 374 adapterOptions: { watch: true, abortController: controller1 }, 375 }); 376 377 this.subject().findRecord(null, { modelName: 'job' }, jobId, { 378 reload: true, 379 adapterOptions: { watch: true, abortController: controller2 }, 380 }); 381 382 const { request: xhr } = pretender.requestReferences[0]; 383 const { request: xhr2 } = pretender.requestReferences[1]; 384 assert.equal(xhr.status, 0, 'Request is still pending'); 385 assert.equal( 386 pretender.requestReferences.length, 387 2, 388 'Two findRecord requests were made' 389 ); 390 assert.equal( 391 pretender.requestReferences.mapBy('url').uniq().length, 392 1, 393 'The two requests have the same URL' 394 ); 395 396 // Schedule the cancelation and resolution before waiting 397 next(() => { 398 controller1.abort(); 399 pretender.resolve(xhr2); 400 }); 401 402 await settled(); 403 assert.ok(xhr.aborted, 'Request one was aborted'); 404 assert.notOk(xhr2.aborted, 'Request two was not aborted'); 405 }); 406 407 test('dispatch job encodes payload as base64', async function (assert) { 408 const job = await this.initializeWithJob(); 409 job.set('parameterized', true); 410 411 const payload = "I'm a payload 🙂"; 412 413 // Base64 encode payload. 414 const Encoder = new TextEncoderLite('utf-8'); 415 const encodedPayload = base64js.fromByteArray(Encoder.encode(payload)); 416 417 await this.subject().dispatch(job, {}, payload); 418 419 const request = this.server.pretender.handledRequests[0]; 420 assert.equal(request.url, `/v1/job/${job.plainId}/dispatch`); 421 assert.equal(request.method, 'POST'); 422 assert.deepEqual(JSON.parse(request.requestBody), { 423 Payload: encodedPayload, 424 Meta: {}, 425 }); 426 }); 427 428 test('when there is no region set, requests are made without the region query param', async function (assert) { 429 await this.initializeUI(); 430 431 const { pretender } = this.server; 432 const jobName = 'job-1'; 433 const jobNamespace = 'default'; 434 const jobId = JSON.stringify([jobName, jobNamespace]); 435 436 await settled(); 437 this.subject().findRecord(null, { modelName: 'job' }, jobId); 438 this.subject().findAll(null, { modelName: 'job' }, null); 439 await settled(); 440 441 assert.deepEqual( 442 pretender.handledRequests.mapBy('url'), 443 [`/v1/job/${jobName}`, '/v1/jobs'], 444 'No requests include the region query param' 445 ); 446 }); 447 448 test('when there is a region set, requests are made with the region query param', async function (assert) { 449 const region = 'region-2'; 450 451 await this.initializeUI({ region }); 452 453 const { pretender } = this.server; 454 const jobName = 'job-1'; 455 const jobNamespace = 'default'; 456 const jobId = JSON.stringify([jobName, jobNamespace]); 457 458 await settled(); 459 this.subject().findRecord(null, { modelName: 'job' }, jobId); 460 this.subject().findAll(null, { modelName: 'job' }, null); 461 await settled(); 462 463 assert.deepEqual( 464 pretender.handledRequests.mapBy('url'), 465 [`/v1/job/${jobName}?region=${region}`, `/v1/jobs?region=${region}`], 466 'Requests include the region query param' 467 ); 468 }); 469 470 test('when the region is set to the default region, requests are made without the region query param', async function (assert) { 471 await this.initializeUI({ region: 'region-1' }); 472 473 const { pretender } = this.server; 474 const jobName = 'job-1'; 475 const jobNamespace = 'default'; 476 const jobId = JSON.stringify([jobName, jobNamespace]); 477 478 await settled(); 479 this.subject().findRecord(null, { modelName: 'job' }, jobId); 480 this.subject().findAll(null, { modelName: 'job' }, null); 481 await settled(); 482 483 assert.deepEqual( 484 pretender.handledRequests.mapBy('url'), 485 [`/v1/job/${jobName}`, '/v1/jobs'], 486 'No requests include the region query param' 487 ); 488 }); 489 490 test('fetchRawDefinition requests include the activeRegion', async function (assert) { 491 const region = 'region-2'; 492 const job = await this.initializeWithJob({ region }); 493 494 await this.subject().fetchRawDefinition(job); 495 496 const request = this.server.pretender.handledRequests[0]; 497 assert.equal(request.url, `/v1/job/${job.plainId}?region=${region}`); 498 assert.equal(request.method, 'GET'); 499 }); 500 501 test('forcePeriodic requests include the activeRegion', async function (assert) { 502 const region = 'region-2'; 503 const job = await this.initializeWithJob({ region }); 504 job.set('periodic', true); 505 506 await this.subject().forcePeriodic(job); 507 508 const request = this.server.pretender.handledRequests[0]; 509 assert.equal( 510 request.url, 511 `/v1/job/${job.plainId}/periodic/force?region=${region}` 512 ); 513 assert.equal(request.method, 'POST'); 514 }); 515 516 test('stop requests include the activeRegion', async function (assert) { 517 const region = 'region-2'; 518 const job = await this.initializeWithJob({ region }); 519 520 await this.subject().stop(job); 521 522 const request = this.server.pretender.handledRequests[0]; 523 assert.equal(request.url, `/v1/job/${job.plainId}?region=${region}`); 524 assert.equal(request.method, 'DELETE'); 525 }); 526 527 test('purge requests include the activeRegion', async function (assert) { 528 const region = 'region-2'; 529 const job = await this.initializeWithJob({ region }); 530 531 await this.subject().purge(job); 532 533 const request = this.server.pretender.handledRequests[0]; 534 assert.equal( 535 request.url, 536 `/v1/job/${job.plainId}?purge=true®ion=${region}` 537 ); 538 assert.equal(request.method, 'DELETE'); 539 }); 540 541 test('parse requests include the activeRegion', async function (assert) { 542 const region = 'region-2'; 543 await this.initializeUI({ region }); 544 545 await this.subject().parse('job "name-goes-here" {'); 546 547 const request = this.server.pretender.handledRequests[0]; 548 assert.equal(request.url, `/v1/jobs/parse?namespace=*®ion=${region}`); 549 assert.equal(request.method, 'POST'); 550 assert.deepEqual(JSON.parse(request.requestBody), { 551 JobHCL: 'job "name-goes-here" {', 552 Canonicalize: true, 553 }); 554 }); 555 556 test('plan requests include the activeRegion', async function (assert) { 557 const region = 'region-2'; 558 const job = await this.initializeWithJob({ region }); 559 job.set('_newDefinitionJSON', {}); 560 561 await this.subject().plan(job); 562 563 const request = this.server.pretender.handledRequests[0]; 564 assert.equal(request.url, `/v1/job/${job.plainId}/plan?region=${region}`); 565 assert.equal(request.method, 'POST'); 566 }); 567 568 test('run requests include the activeRegion', async function (assert) { 569 const region = 'region-2'; 570 const job = await this.initializeWithJob({ region }); 571 job.set('_newDefinitionJSON', {}); 572 573 await this.subject().run(job); 574 575 const request = this.server.pretender.handledRequests[0]; 576 assert.equal(request.url, `/v1/jobs?region=${region}`); 577 assert.equal(request.method, 'POST'); 578 }); 579 580 test('update requests include the activeRegion', async function (assert) { 581 const region = 'region-2'; 582 const job = await this.initializeWithJob({ region }); 583 job.set('_newDefinitionJSON', {}); 584 585 await this.subject().update(job); 586 587 const request = this.server.pretender.handledRequests[0]; 588 assert.equal(request.url, `/v1/job/${job.plainId}?region=${region}`); 589 assert.equal(request.method, 'POST'); 590 }); 591 592 test('scale requests include the activeRegion', async function (assert) { 593 const region = 'region-2'; 594 const job = await this.initializeWithJob({ region }); 595 596 await this.subject().scale(job, 'group-1', 5, 'Reason: a test'); 597 598 const request = this.server.pretender.handledRequests[0]; 599 assert.equal(request.url, `/v1/job/${job.plainId}/scale?region=${region}`); 600 assert.equal(request.method, 'POST'); 601 }); 602 603 test('dispatch requests include the activeRegion', async function (assert) { 604 const region = 'region-2'; 605 const job = await this.initializeWithJob({ region }); 606 job.set('parameterized', true); 607 608 await this.subject().dispatch(job, {}, ''); 609 610 const request = this.server.pretender.handledRequests[0]; 611 assert.equal( 612 request.url, 613 `/v1/job/${job.plainId}/dispatch?region=${region}` 614 ); 615 assert.equal(request.method, 'POST'); 616 }); 617 618 module('#fetchRawSpecification', function () { 619 test('it makes a GET request to the correct URL', async function (assert) { 620 const adapter = this.owner.lookup('adapter:job'); 621 const job = { 622 get: sinon.stub(), 623 }; 624 625 job.get.withArgs('id').returns('["job-id"]'); 626 job.get.withArgs('version').returns('job-version'); 627 628 const expectedURL = addToPath( 629 adapter.urlForFindRecord('["job-id"]', 'job', null, 'submission'), 630 '', 631 'version=' + job.get('version') 632 ); 633 634 // Stub the ajax method to avoid making real API calls 635 sinon.stub(adapter, 'ajax').callsFake(() => resolve({})); 636 637 await adapter.fetchRawSpecification(job); 638 639 assert.ok(adapter.ajax.calledOnce, 'The ajax method is called once'); 640 641 assert.equal( 642 expectedURL, 643 '/v1/job/job-id/submission?version=job-version', 644 'it formats the URL correctly' 645 ); 646 }); 647 648 test('it formats namespaces correctly', async function (assert) { 649 const adapter = this.owner.lookup('adapter:job'); 650 const job = { 651 get: sinon.stub(), 652 }; 653 654 job.get.withArgs('id').returns('["job-id"]'); 655 job.get.withArgs('version').returns('job-version'); 656 job.get.withArgs('namespace').returns('zoey'); 657 658 const expectedURL = addToPath( 659 adapter.urlForFindRecord( 660 '["job-id", "zoey"]', 661 'job', 662 null, 663 'submission' 664 ), 665 '', 666 'version=' + job.get('version') 667 ); 668 669 // Stub the ajax method to avoid making real API calls 670 sinon.stub(adapter, 'ajax').callsFake(() => resolve({})); 671 672 await adapter.fetchRawSpecification(job); 673 674 assert.ok(adapter.ajax.calledOnce, 'The ajax method is called once'); 675 676 assert.equal( 677 expectedURL, 678 '/v1/job/job-id/submission?namespace=zoey&version=job-version' 679 ); 680 }); 681 }); 682 }); 683 684 function makeMockModel(id, options) { 685 return assign( 686 { 687 relationshipFor(name) { 688 return { 689 kind: 'belongsTo', 690 type: 'job-summary', 691 key: name, 692 }; 693 }, 694 belongsTo(name) { 695 return { 696 link() { 697 return `/v1/job/${id}/${name}`; 698 }, 699 }; 700 }, 701 }, 702 options 703 ); 704 }