diff --git a/src/context.ts b/src/context.ts index 13c72bd..1662c23 100644 --- a/src/context.ts +++ b/src/context.ts @@ -9,6 +9,7 @@ import { RequestSerializer, } from './http-serializer'; import { RecordMode as Mode } from './recording'; +import Rule from './rule'; export interface IRedactProp { property: string | string[]; @@ -30,6 +31,7 @@ export interface IResponseForMatchingRequest { */ export default class Context { public mode: Mode = Mode.Spy; + public rules: Rule[] = []; /** * Setting to redact all incoming requests to match redacted mocks diff --git a/src/filtering/matcher.ts b/src/filtering/matcher.ts index 0d0cf58..8884b23 100644 --- a/src/filtering/matcher.ts +++ b/src/filtering/matcher.ts @@ -26,6 +26,8 @@ export type UnsafeMatchFn = (serialized: ISerializedRequestResponseToMatch) => b export type Matcher = ISerializedHttpPartialDeepMatch | MatchFn; +export type HttpFilter = string | RegExp | Matcher; + export const EMPTY_RESPONSE = { body: {}, headers: {}, statusCode: 0 }; /** diff --git a/src/rule.ts b/src/rule.ts new file mode 100644 index 0000000..9a8d081 --- /dev/null +++ b/src/rule.ts @@ -0,0 +1,63 @@ +import Context from './context'; +import { YesNoError } from './errors'; +import { Matcher } from './filtering/matcher'; +import MockResponse from './mock-response'; + +export enum RuleType { + Init = '', + Live = 'LIVE', + Record = 'RECORD', + Respond = 'RESPOND', +} + +export interface IRule { + matcher: Matcher; + mock?: MockResponse; + ruleType: RuleType; +} + +export interface IRuleParams { + context: Context; + matcher: Matcher; +} + +export default class Rule implements IRule { + public matcher: Matcher; + public mock?: MockResponse; + public ruleType: RuleType; + private readonly ctx: Context; + + constructor({ context, matcher = {} }: IRuleParams) { + this.ctx = context; + this.matcher = matcher; + this.ruleType = RuleType.Init; + } + + /** + * Set the rule type to 'record' + */ + public record(): IRule { + const index = this.ctx.rules.length - 1; + + if (index < 0) { + throw new YesNoError('No rules have been defined yet'); + } + + this.ctx.rules[index].ruleType = RuleType.Record; + return this.ctx.rules[index]; + } + + /** + * Set the rule type to 'live' + */ + public live(): IRule { + const index = this.ctx.rules.length - 1; + + if (index < 0) { + throw new YesNoError('No rules have been defined yet'); + } + + this.ctx.rules[index].ruleType = RuleType.Live; + return this.ctx.rules[index]; + } +} diff --git a/src/yesno.ts b/src/yesno.ts index 104bb13..60a0a20 100644 --- a/src/yesno.ts +++ b/src/yesno.ts @@ -8,7 +8,7 @@ import { YesNoError } from './errors'; import * as file from './file'; import FilteredHttpCollection, { IFiltered } from './filtering/collection'; import { ComparatorFn } from './filtering/comparator'; -import { ISerializedHttpPartialDeepMatch, MatchFn } from './filtering/matcher'; +import { HttpFilter, ISerializedHttpPartialDeepMatch, match, MatchFn } from './filtering/matcher'; import { redact as redactRecord, Redactor } from './filtering/redact'; import { createRecord, @@ -22,14 +22,13 @@ import { import Interceptor, { IInterceptEvent, IInterceptOptions, IProxiedEvent } from './interceptor'; import MockResponse from './mock-response'; import Recording, { RecordMode as Mode } from './recording'; +import Rule, { RuleType } from './rule'; const debug: IDebugger = require('debug')('yesno'); export type GenericTest = (...args: any) => Promise | void; export type GenericTestFunction = (title: string, fn: GenericTest) => any; -export type HttpFilter = string | RegExp | ISerializedHttpPartialDeepMatch | MatchFn; - export interface IRecordableTest { test?: GenericTestFunction; it?: GenericTestFunction; @@ -77,6 +76,19 @@ export class YesNo implements IFiltered { this.setMode(Mode.Spy); } + /** + * Set rule for mock/record + * + * @param filter to match requests + * @return new rule index + */ + public mockRule(filter: HttpFilter): Rule { + const matcher = _.isString(filter) || _.isRegExp(filter) ? { url: filter } : filter; + const rule = new Rule({ context: this.ctx, matcher }); + this.ctx.rules.push(rule); + return rule; + } + /** * Mock responses for intercepted requests * @todo Reset the request counter? @@ -155,6 +167,7 @@ export class YesNo implements IFiltered { return records; } + /** * Save intercepted requests * @@ -286,6 +299,59 @@ export class YesNo implements IFiltered { private async onIntercept(event: IInterceptEvent): Promise { this.recordRequest(event.requestSerializer, event.requestNumber); + const sendMockResponse = async () => { + try { + const mockResponse = new MockResponse(event, this.ctx); + const sent = await mockResponse.send(); + + if (sent) { + // redact properties if needed + if (this.ctx.autoRedact !== null) { + const properties = _.isArray(this.ctx.autoRedact.property) + ? this.ctx.autoRedact.property + : [this.ctx.autoRedact.property]; + const record = createRecord({ + duration: 0, + request: sent.request, + response: sent.response, + }); + sent.request = redactRecord(record, properties, this.ctx.autoRedact.redactor).request; + } + + this.recordResponse(sent.request, sent.response, event.requestNumber); + } else if (this.isMode(Mode.Mock)) { + throw new Error('Unexpectedly failed to send mock respond'); + } + } catch (e) { + if (!(e instanceof YesNoError)) { + debug(`[#${event.requestNumber}] Mock response failed unexpectedly`, e); + e.message = `YesNo: Mock response failed: ${e.message}`; + } else { + debug(`[#${event.requestNumber}] Mock response failed`, e.message); + } + + event.clientRequest.emit('error', e); + } + }; + + // process the set of defined rules + for (const rule of this.ctx.rules) { + // see if the rule matches + const matchFound = match(rule.matcher)({ request: event.requestSerializer }); + if (matchFound) { + if (!rule.ruleType) { + const e = new YesNoError('Missing action for mockRule. Please set record, live or respond.'); + event.clientRequest.emit('error', e); + return; + } + if (rule.ruleType === RuleType.Live) { + return event.proxy(); + } + // check for a matching mock + return sendMockResponse(); + } + } + if (!this.ctx.hasResponsesDefinedForMatchers() && !this.isMode(Mode.Mock)) { // No need to mock, send event to its original destination return event.proxy(); @@ -296,38 +362,7 @@ export class YesNo implements IFiltered { return event.proxy(); } - try { - const mockResponse = new MockResponse(event, this.ctx); - const sent = await mockResponse.send(); - - if (sent) { - // redact properties if needed - if (this.ctx.autoRedact !== null) { - const properties = _.isArray(this.ctx.autoRedact.property) - ? this.ctx.autoRedact.property - : [this.ctx.autoRedact.property]; - const record = createRecord({ - duration: 0, - request: sent.request, - response: sent.response, - }); - sent.request = redactRecord(record, properties, this.ctx.autoRedact.redactor).request; - } - - this.recordResponse(sent.request, sent.response, event.requestNumber); - } else if (this.isMode(Mode.Mock)) { - throw new Error('Unexpectedly failed to send mock respond'); - } - } catch (e) { - if (!(e instanceof YesNoError)) { - debug(`[#${event.requestNumber}] Mock response failed unexpectedly`, e); - e.message = `YesNo: Mock response failed: ${e.message}`; - } else { - debug(`[#${event.requestNumber}] Mock response failed`, e.message); - } - - event.clientRequest.emit('error', e); - } + sendMockResponse(); } private onProxied({ requestSerializer, responseSerializer, requestNumber }: IProxiedEvent): void { diff --git a/test/unit/yesno.spec.ts b/test/unit/yesno.spec.ts index db112f4..9651ad0 100644 --- a/test/unit/yesno.spec.ts +++ b/test/unit/yesno.spec.ts @@ -12,6 +12,7 @@ import { IHttpMock } from '../../src/file'; import { ComparatorFn, IComparatorMetadata } from '../../src/filtering/comparator'; import { ISerializedRequest } from '../../src/http-serializer'; import { RecordMode } from '../../src/recording'; +import { RuleType } from '../../src/rule'; import * as testServer from '../test-server'; type PartialDeep = { [P in keyof T]?: PartialDeep }; @@ -425,6 +426,59 @@ describe('Yesno', () => { }); }); + describe('#mockRule', () => { + const ctx = 'ctx'; + beforeEach(() => { + yesno.mock([ + createMock({ response: { body: 'mocked' } }), + ]); + }); + + afterEach(() => { + yesno.clear(); + yesno[ctx].rules = []; + }); + + it('should throw an error if no action is set', async () => { + + await yesno.mockRule('http://localhost/get'); + + expect(yesno[ctx].rules).to.have.lengthOf(1); + expect(yesno[ctx].rules[0].ruleType).to.equal(RuleType.Init); + + // verify the response + try { + expect(async () => await requestTestServer()).to.throw( + 'Error: YesNo: Missing action for mockRule. Set record, live or respond.', + ); + } catch (e) {}; + }); + + it('should add a rule with type RECORD', async () => { + + await yesno.mockRule('http://localhost/get').record(); + + expect(yesno[ctx].rules).to.have.lengthOf(1); + expect(yesno[ctx].rules[0].ruleType).to.equal(RuleType.Record); + + // verify the mocked response + const response = await requestTestServer(); + expect(response).to.equal('mocked'); + }); + + it('should add a rule with type LIVE', async () => { + + await yesno.mockRule('http://localhost/get').live(); + + expect(yesno[ctx].rules).to.have.lengthOf(1); + expect(yesno[ctx].rules[0].ruleType).to.equal(RuleType.Live); + + // verify the proxied response + const response = await requestTestServer({ json: true }); + expect(response.source).to.equal('server'); + }); + }); + describe('#test', () => { beforeEach(() => { process.env[YESNO_RECORDING_MODE_ENV_VAR] = RecordMode.Spy;