balena-cli/tests/nock/nock-mock.ts

216 lines
5.9 KiB
TypeScript
Raw Normal View History

/**
* @license
* Copyright 2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as nock from 'nock';
import * as fs from 'fs';
export interface ScopeOpts {
optional?: boolean;
persist?: boolean;
times?: number;
}
/**
* Base class for tests using nock to intercept HTTP requests.
* Subclasses include BalenaAPIMock, DockerMock and BuilderMock.
*/
export class NockMock {
public readonly scope: nock.Scope;
// Expose `scope` as `expect` to allow for better semantics in tests
public readonly expect;
protected static instanceCount = 0;
constructor(
public basePathPattern: string | RegExp,
public allowUnmocked: boolean = false,
) {
if (NockMock.instanceCount === 0) {
if (!nock.isActive()) {
nock.activate();
}
nock.emitter.on('no match', this.handleUnexpectedRequest);
} else if (process.env.DEBUG) {
console.error(
`[debug] NockMock.constructor() instance count is ${NockMock.instanceCount}`,
);
}
NockMock.instanceCount += 1;
this.scope = nock(this.basePathPattern, { allowUnmocked });
this.expect = this.scope;
}
public optMethod(
method: 'get' | 'delete' | 'patch' | 'post',
uri: string | RegExp | ((uri: string) => boolean),
{ optional = false, persist = false, times = undefined }: ScopeOpts,
) {
let scope = this.scope;
if (persist) {
scope = scope.persist();
}
let reqInterceptor = scope[method](uri);
if (times != null) {
reqInterceptor = reqInterceptor.times(times);
} else if (optional) {
reqInterceptor = reqInterceptor.optionally();
}
return reqInterceptor;
}
public optGet(
uri: string | RegExp | ((uri: string) => boolean),
opts: ScopeOpts,
): nock.Interceptor {
return this.optMethod('get', uri, opts);
}
public optDelete(
uri: string | RegExp | ((uri: string) => boolean),
opts: ScopeOpts,
) {
return this.optMethod('delete', uri, opts);
}
public optPatch(
uri: string | RegExp | ((uri: string) => boolean),
opts: ScopeOpts,
) {
return this.optMethod('patch', uri, opts);
}
public optPost(
uri: string | RegExp | ((uri: string) => boolean),
opts: ScopeOpts,
) {
return this.optMethod('post', uri, opts);
}
protected inspectNoOp(_uri: string, _requestBody: nock.Body): void {
return undefined;
}
protected getInspectedReplyBodyFunction(
inspectRequest: (uri: string, requestBody: nock.Body) => void,
replyBody: nock.ReplyBody,
) {
return function (
this: nock.ReplyFnContext,
uri: string,
requestBody: nock.Body,
cb: (err: NodeJS.ErrnoException | null, result: nock.ReplyBody) => void,
) {
try {
inspectRequest(uri, requestBody);
} catch (err) {
cb(err, '');
}
cb(null, replyBody);
};
}
protected getInspectedReplyFileFunction(
inspectRequest: (uri: string, requestBody: nock.Body) => void,
replyBodyFile: string,
) {
return function (
this: nock.ReplyFnContext,
uri: string,
requestBody: nock.Body,
cb: (err: NodeJS.ErrnoException | null, result: nock.ReplyBody) => void,
) {
try {
inspectRequest(uri, requestBody);
} catch (err) {
cb(err, '');
}
const replyBody = fs.readFileSync(replyBodyFile);
cb(null, replyBody);
};
}
public done() {
try {
// scope.done() will throw an error if there are expected api calls that have not happened.
// So ensure that all expected calls have been made.
this.scope.done();
} finally {
const count = NockMock.instanceCount - 1;
if (count < 0 && process.env.DEBUG) {
console.error(
`[debug] Warning: NockMock.instanceCount is negative (${count})`,
);
}
NockMock.instanceCount = Math.max(0, count);
if (NockMock.instanceCount === 0) {
// Remove 'no match' handler, for tests using nock without this module
nock.emitter.removeAllListeners('no match');
nock.cleanAll();
nock.restore();
} else if (process.env.DEBUG) {
console.error(
`[debug] NockMock.done() instance count is ${NockMock.instanceCount}`,
);
}
}
}
protected handleUnexpectedRequest(req: any) {
const { interceptorServerPort } =
require('./proxy-server') as typeof import('./proxy-server');
const o = req.options || {};
const u = o.uri || {};
const method = req.method;
const proto = req.protocol || req.proto || o.proto || u.protocol;
const host = req.host || req.headers?.host || o.host || u.host;
const path = req.path || o.path || u.path;
// Requests made by the local proxy/interceptor server are OK
if (host === `127.0.0.1:${interceptorServerPort}`) {
return;
}
console.error(
`NockMock: Unexpected HTTP request: ${method} ${proto}//${host}${path}`,
);
// Errors thrown here are not causing the tests to fail for some reason.
// Possibly due to CLI global error handlers? (error.js)
// (Also, nock should automatically throw an error, but also not happening)
// For now, the console.error is sufficient (will fail the test)
}
// For debugging tests
get unfulfilledCallCount(): number {
return this.scope.pendingMocks().length;
}
public debug() {
const scope = this.scope;
let mocks = scope.pendingMocks();
console.error(`pending mocks ${mocks.length}: ${mocks}`);
this.scope.on('request', function (_req, _interceptor, _body) {
console.log(`>> REQUEST:` + _req.path);
mocks = scope.pendingMocks();
console.error(`pending mocks ${mocks.length}: ${mocks}`);
});
this.scope.on('replied', function (_req) {
console.log(`<< REPLIED:` + _req.path);
});
}
}