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&region=${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=*&region=${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  }