import type { SinonStub, SinonSpy } from 'sinon'; import { stub, spy } from 'sinon'; import Bluebird from 'bluebird'; import _ from 'lodash'; import rewire from 'rewire'; import { expect } from 'chai'; import * as TargetState from '~/src/api-binder/poll'; import Log from '~/lib/supervisor-console'; import * as request from '~/lib/request'; import * as deviceConfig from '~/src/device-config'; import { UpdatesLockedError } from '~/lib/errors'; import { setTimeout } from 'timers/promises'; const deviceState = rewire('~/src/device-state'); const stateEndpointBody = { local: { name: 'solitary-bush', config: {}, apps: { '123': { name: 'my-app', services: {}, volumes: {}, networks: {}, }, }, }, }; const req = { getAsync: () => Bluebird.resolve([ { statusCode: 200, headers: { etag: 'abc' }, } as any, JSON.stringify(stateEndpointBody), ]), }; describe('Target state', () => { before(async () => { // maxPollTime starts as undefined deviceState.__set__('maxPollTime', 60000); stub(deviceState, 'applyStep').resolves(); }); beforeEach(() => { spy(req, 'getAsync'); stub(request, 'getRequestInstance').resolves(req as any); }); afterEach(() => { (req.getAsync as SinonSpy).restore(); (request.getRequestInstance as SinonStub).restore(); }); after(async () => { (deviceState.applyStep as SinonStub).restore(); }); describe('update', () => { it('should throw if a 304 is received but no local cache exists', async () => { // new request returns 304 const newReq = { getAsync: () => Bluebird.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; }); it('should emit target state when a new one is available', async () => { // 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: () => Bluebird.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: () => Bluebird.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); }); 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 }); }; 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); // Trigger an update which will start delay due to lock exception await TargetState.update(false, false); // Wait for interval to tick a few times await setTimeout(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 () => { // 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: () => Bluebird.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)); }); }); });