2020-11-11 14:46:55 +00:00
|
|
|
import { SinonStub, stub, spy, SinonSpy } from 'sinon';
|
|
|
|
import { Promise } from 'bluebird';
|
|
|
|
import * as _ from 'lodash';
|
2020-12-18 20:10:04 +00:00
|
|
|
import rewire = require('rewire');
|
2021-04-26 19:54:04 +00:00
|
|
|
import { expect } from 'chai';
|
2021-05-07 01:47:10 +00:00
|
|
|
|
2020-12-18 20:10:04 +00:00
|
|
|
import { sleep } from './lib/helpers';
|
2020-11-11 14:46:55 +00:00
|
|
|
import * as TargetState from '../src/device-state/target-state';
|
2020-12-18 20:10:04 +00:00
|
|
|
import Log from '../src/lib/supervisor-console';
|
2020-11-11 14:46:55 +00:00
|
|
|
import * as request from '../src/lib/request';
|
2020-12-18 20:10:04 +00:00
|
|
|
import * as deviceConfig from '../src/device-config';
|
|
|
|
import { UpdatesLockedError } from '../src/lib/errors';
|
|
|
|
|
|
|
|
const deviceState = rewire('../src/device-state');
|
2020-11-11 14:46:55 +00:00
|
|
|
|
|
|
|
const stateEndpointBody = {
|
|
|
|
local: {
|
|
|
|
name: 'solitary-bush',
|
|
|
|
config: {},
|
|
|
|
apps: {
|
|
|
|
'123': {
|
|
|
|
name: 'my-app',
|
|
|
|
services: {},
|
|
|
|
volumes: {},
|
|
|
|
networks: {},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
dependent: {
|
|
|
|
apps: {},
|
|
|
|
devices: {},
|
|
|
|
},
|
|
|
|
};
|
|
|
|
|
2020-12-18 20:10:04 +00:00
|
|
|
const req = {
|
|
|
|
getAsync: () =>
|
|
|
|
Promise.resolve([
|
|
|
|
{
|
|
|
|
statusCode: 200,
|
2022-01-25 20:13:29 +00:00
|
|
|
headers: { etag: 'abc' },
|
2020-12-18 20:10:04 +00:00
|
|
|
} as any,
|
|
|
|
JSON.stringify(stateEndpointBody),
|
|
|
|
]),
|
|
|
|
};
|
|
|
|
|
2020-11-11 14:46:55 +00:00
|
|
|
describe('Target state', () => {
|
2020-12-18 20:10:04 +00:00
|
|
|
before(() => {
|
|
|
|
// maxPollTime starts as undefined
|
|
|
|
deviceState.__set__('maxPollTime', 60000);
|
|
|
|
});
|
2020-11-11 14:46:55 +00:00
|
|
|
|
2020-12-18 20:10:04 +00:00
|
|
|
beforeEach(() => {
|
|
|
|
spy(req, 'getAsync');
|
|
|
|
stub(request, 'getRequestInstance').resolves(req as any);
|
|
|
|
});
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
(req.getAsync as SinonSpy).restore();
|
|
|
|
(request.getRequestInstance as SinonStub).restore();
|
|
|
|
});
|
2020-11-11 14:46:55 +00:00
|
|
|
|
2020-12-18 20:10:04 +00:00
|
|
|
describe('update', () => {
|
2022-01-25 20:13:29 +00:00
|
|
|
it('should throw if a 304 is received but no local cache exists', async () => {
|
|
|
|
// new request returns 304
|
|
|
|
const newReq = {
|
|
|
|
getAsync: () =>
|
|
|
|
Promise.resolve([
|
|
|
|
{
|
|
|
|
statusCode: 304,
|
|
|
|
headers: {},
|
|
|
|
} as any,
|
|
|
|
]),
|
|
|
|
};
|
|
|
|
|
|
|
|
(request.getRequestInstance as SinonStub).resolves(newReq as any);
|
|
|
|
|
|
|
|
// Perform target state request
|
|
|
|
await expect(TargetState.update()).to.be.rejected;
|
|
|
|
expect(request.getRequestInstance).to.be.calledOnce;
|
|
|
|
});
|
|
|
|
|
2020-12-18 20:10:04 +00:00
|
|
|
it('should emit target state when a new one is available', async () => {
|
2020-11-11 14:46:55 +00:00
|
|
|
// Setup target state listener
|
|
|
|
const listener = stub();
|
|
|
|
TargetState.emitter.on('target-state-update', listener);
|
|
|
|
|
|
|
|
// Perform target state request
|
|
|
|
await TargetState.update();
|
|
|
|
|
|
|
|
expect(request.getRequestInstance).to.be.calledOnce;
|
|
|
|
|
|
|
|
// The getAsync method gets called once and includes authorization headers
|
|
|
|
expect(req.getAsync).to.be.calledOnce;
|
|
|
|
expect(
|
|
|
|
_.has((req.getAsync as SinonSpy).args[0], ['headers', 'Authorization']),
|
|
|
|
);
|
|
|
|
|
|
|
|
// The listener should receive the endpoint
|
|
|
|
expect(listener).to.be.calledOnceWith(JSON.stringify(stateEndpointBody));
|
|
|
|
|
|
|
|
// Remove getRequestInstance stub
|
|
|
|
(request.getRequestInstance as SinonStub).restore();
|
|
|
|
|
|
|
|
// new request returns 304
|
|
|
|
const newReq = {
|
|
|
|
getAsync: () =>
|
|
|
|
Promise.resolve([
|
|
|
|
{
|
|
|
|
statusCode: 304,
|
|
|
|
headers: {},
|
|
|
|
} as any,
|
|
|
|
]),
|
|
|
|
};
|
|
|
|
|
|
|
|
spy(newReq, 'getAsync');
|
|
|
|
stub(request, 'getRequestInstance').resolves(newReq as any);
|
|
|
|
|
|
|
|
// Perform new target state request
|
|
|
|
await TargetState.update();
|
|
|
|
|
|
|
|
// The new req should have been called
|
|
|
|
expect(newReq.getAsync).to.be.calledOnce;
|
|
|
|
|
|
|
|
// No new calls to the listener
|
|
|
|
expect(listener).to.be.calledOnce;
|
|
|
|
|
|
|
|
// Cleanup
|
|
|
|
TargetState.emitter.off('target-state-update', listener);
|
|
|
|
});
|
|
|
|
|
|
|
|
it('should emit cached target state if there was no listener for the cached state', async () => {
|
|
|
|
// Perform target state request
|
|
|
|
await TargetState.update();
|
|
|
|
|
|
|
|
expect(request.getRequestInstance).to.be.calledOnce;
|
|
|
|
|
|
|
|
// The getAsync method gets called once and includes authorization headers
|
|
|
|
expect(req.getAsync).to.be.calledOnce;
|
|
|
|
expect(
|
|
|
|
_.has((req.getAsync as SinonSpy).args[0], ['headers', 'Authorization']),
|
|
|
|
);
|
|
|
|
|
|
|
|
// Remove getRequestInstance stub
|
|
|
|
(request.getRequestInstance as SinonStub).restore();
|
|
|
|
|
|
|
|
// new request returns 304
|
|
|
|
const newReq = {
|
|
|
|
getAsync: () =>
|
|
|
|
Promise.resolve([
|
|
|
|
{
|
|
|
|
statusCode: 304,
|
|
|
|
headers: {},
|
|
|
|
} as any,
|
|
|
|
]),
|
|
|
|
};
|
|
|
|
|
|
|
|
spy(newReq, 'getAsync');
|
|
|
|
stub(request, 'getRequestInstance').resolves(newReq as any);
|
|
|
|
|
|
|
|
// Setup target state listener after the first request
|
|
|
|
const listener = stub();
|
|
|
|
TargetState.emitter.on('target-state-update', listener);
|
|
|
|
|
|
|
|
// Perform new target state request
|
|
|
|
await TargetState.update();
|
|
|
|
|
|
|
|
// The new req should have been called
|
|
|
|
expect(newReq.getAsync).to.be.calledOnce;
|
|
|
|
|
|
|
|
// The listener should receive the endpoint
|
|
|
|
expect(listener).to.be.calledOnceWith(JSON.stringify(stateEndpointBody));
|
|
|
|
|
|
|
|
// Cleanup
|
|
|
|
TargetState.emitter.off('target-state-update', listener);
|
|
|
|
});
|
|
|
|
|
2020-12-18 20:10:04 +00:00
|
|
|
it('should cancel any target state applying if request is from API', async () => {
|
|
|
|
const logInfoSpy = spy(Log, 'info');
|
|
|
|
|
|
|
|
// This just makes the first step of the applyTarget function throw an error
|
|
|
|
// We could have stubbed any function to throw this error as long as it is inside the try block
|
|
|
|
stub(deviceConfig, 'getRequiredSteps').throws(
|
|
|
|
new UpdatesLockedError('Updates locked'),
|
|
|
|
);
|
|
|
|
|
|
|
|
// Rather then stubbing more values to make the following code execute
|
|
|
|
// I am just going to make the function I want run
|
|
|
|
const updateListener = async (
|
|
|
|
_targetState: any,
|
|
|
|
force: boolean,
|
|
|
|
isFromApi: boolean,
|
|
|
|
) => {
|
|
|
|
deviceState.triggerApplyTarget({ force, isFromApi });
|
2020-11-11 14:46:55 +00:00
|
|
|
};
|
2020-12-18 20:10:04 +00:00
|
|
|
const applyListener = async (force: boolean, isFromApi: boolean) => {
|
|
|
|
deviceState.triggerApplyTarget({ force, isFromApi });
|
|
|
|
};
|
|
|
|
|
|
|
|
// Add the function we want to run to this listener which calls it normally
|
|
|
|
TargetState.emitter.on('target-state-update', updateListener);
|
|
|
|
TargetState.emitter.on('target-state-apply', applyListener);
|
2020-11-11 14:46:55 +00:00
|
|
|
|
2020-12-18 20:10:04 +00:00
|
|
|
// Trigger an update which will start delay due to lock exception
|
|
|
|
await TargetState.update(false, false);
|
2020-11-11 14:46:55 +00:00
|
|
|
|
2020-12-18 20:10:04 +00:00
|
|
|
// Wait for interval to tick a few times
|
|
|
|
await sleep(2000); // 2 seconds
|
|
|
|
|
|
|
|
// Trigger another update but say it's from the API
|
|
|
|
await TargetState.update(false, true);
|
|
|
|
|
|
|
|
// Check for log message stating cancellation
|
|
|
|
expect(logInfoSpy.lastCall?.lastArg).to.equal(
|
|
|
|
'Skipping applyTarget because of a cancellation',
|
|
|
|
);
|
|
|
|
|
|
|
|
// Restore stubs
|
|
|
|
logInfoSpy.restore();
|
|
|
|
(deviceConfig.getRequiredSteps as SinonStub).restore();
|
|
|
|
TargetState.emitter.off('target-state-update', updateListener);
|
|
|
|
TargetState.emitter.off('target-state-apply', applyListener);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
describe('get', () => {
|
|
|
|
it('returns the latest target state endpoint response', async () => {
|
2020-11-11 14:46:55 +00:00
|
|
|
// Perform target state request
|
|
|
|
const response = await TargetState.get();
|
|
|
|
|
|
|
|
// The stubbed methods should only be called once
|
|
|
|
expect(request.getRequestInstance).to.be.calledOnce;
|
|
|
|
expect(req.getAsync).to.be.calledOnce;
|
|
|
|
|
|
|
|
// Cached value should reflect latest response
|
|
|
|
expect(response).to.be.equal(JSON.stringify(stateEndpointBody));
|
|
|
|
});
|
|
|
|
|
|
|
|
it('returns the last cached target state', async () => {
|
|
|
|
// Perform target state request, this should
|
|
|
|
// put the query result in the cache
|
|
|
|
await TargetState.update();
|
|
|
|
|
|
|
|
// Reset the stub
|
|
|
|
(request.getRequestInstance as SinonStub).restore();
|
|
|
|
const newReq = {
|
|
|
|
getAsync: () =>
|
|
|
|
Promise.resolve([
|
|
|
|
{
|
|
|
|
statusCode: 304,
|
|
|
|
headers: {},
|
|
|
|
} as any,
|
|
|
|
]),
|
|
|
|
};
|
|
|
|
spy(newReq, 'getAsync');
|
|
|
|
stub(request, 'getRequestInstance').resolves(newReq as any);
|
|
|
|
|
|
|
|
// Perform target state request
|
|
|
|
const response = await TargetState.get();
|
|
|
|
|
|
|
|
// The stubbed methods should only be called once
|
|
|
|
expect(request.getRequestInstance).to.be.calledOnce;
|
|
|
|
expect(newReq.getAsync).to.be.calledOnce;
|
|
|
|
|
|
|
|
// Cached value should reflect latest response
|
|
|
|
expect(response).to.be.equal(JSON.stringify(stateEndpointBody));
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|