From 123de860b8db734cb3c722f2b33312b5673947dc Mon Sep 17 00:00:00 2001 From: Matt Keith Date: Fri, 31 Jul 2020 12:08:44 -0600 Subject: [PATCH 1/8] initial support for mockRule --- src/context.ts | 2 + src/filtering/matcher.ts | 2 + src/rule.ts | 48 +++++++++++++++++++ src/yesno.ts | 100 +++++++++++++++++++++++++-------------- 4 files changed, 117 insertions(+), 35 deletions(-) create mode 100644 src/rule.ts 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..58fa6f6 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 | ISerializedHttpPartialDeepMatch | MatchFn; + export const EMPTY_RESPONSE = { body: {}, headers: {}, statusCode: 0 }; /** diff --git a/src/rule.ts b/src/rule.ts new file mode 100644 index 0000000..60c2c25 --- /dev/null +++ b/src/rule.ts @@ -0,0 +1,48 @@ +import Context from './context'; +import { YesNoError } from './errors'; +import { Matcher } from './filtering/matcher'; +import MockResponse from './mock-response'; + +export enum MockMode { + Live = 'LIVE', + Record = 'RECORD', + Respond = 'RESPOND', +} + +export interface IRule { + matcher: Matcher; + mock?: MockResponse; + mode: MockMode; +} + +export interface IRuleParams { + context: Context; + matcher: Matcher; +} + +export default class Rule implements IRule { + public matcher: Matcher; + public mock?: MockResponse; + public mode: MockMode; + private readonly ctx: Context; + + constructor({ context, matcher = {} }: IRuleParams) { + this.ctx = context; + this.matcher = matcher; + this.mode = MockMode.Record; + } + + /** + * Set the rule mode 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].mode = MockMode.Record; + return this.ctx.rules[index]; + } +} diff --git a/src/yesno.ts b/src/yesno.ts index 104bb13..29cb066 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, { MockMode } 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,54 @@ 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.mode === MockMode.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 +357,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 { From ed2fd125b7fc8418f19acfb1e33a333f4c43d3ed Mon Sep 17 00:00:00 2001 From: Matt Keith Date: Mon, 10 Aug 2020 09:17:50 -0600 Subject: [PATCH 2/8] Add support for mockRule.live --- src/filtering/matcher.ts | 2 +- src/rule.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/filtering/matcher.ts b/src/filtering/matcher.ts index 58fa6f6..8884b23 100644 --- a/src/filtering/matcher.ts +++ b/src/filtering/matcher.ts @@ -26,7 +26,7 @@ export type UnsafeMatchFn = (serialized: ISerializedRequestResponseToMatch) => b export type Matcher = ISerializedHttpPartialDeepMatch | MatchFn; -export type HttpFilter = string | RegExp | 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 index 60c2c25..189b7c0 100644 --- a/src/rule.ts +++ b/src/rule.ts @@ -45,4 +45,18 @@ export default class Rule implements IRule { this.ctx.rules[index].mode = MockMode.Record; return this.ctx.rules[index]; } + + /** + * Set the rule mode 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].mode = MockMode.Live; + return this.ctx.rules[index]; + } } From 6ac036128551a4980e9088c54647485ed3ad16ce Mon Sep 17 00:00:00 2001 From: Matt Keith Date: Mon, 17 Aug 2020 10:35:49 -0600 Subject: [PATCH 3/8] rename MockMode to RuleType --- src/rule.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/rule.ts b/src/rule.ts index 189b7c0..599dc9b 100644 --- a/src/rule.ts +++ b/src/rule.ts @@ -3,7 +3,7 @@ import { YesNoError } from './errors'; import { Matcher } from './filtering/matcher'; import MockResponse from './mock-response'; -export enum MockMode { +export enum RuleType { Live = 'LIVE', Record = 'RECORD', Respond = 'RESPOND', @@ -12,7 +12,7 @@ export enum MockMode { export interface IRule { matcher: Matcher; mock?: MockResponse; - mode: MockMode; + ruleType: RuleType; } export interface IRuleParams { @@ -23,17 +23,17 @@ export interface IRuleParams { export default class Rule implements IRule { public matcher: Matcher; public mock?: MockResponse; - public mode: MockMode; + public ruleType: RuleType; private readonly ctx: Context; constructor({ context, matcher = {} }: IRuleParams) { this.ctx = context; this.matcher = matcher; - this.mode = MockMode.Record; + this.ruleType = RuleType.Record; } /** - * Set the rule mode to 'record' + * Set the rule type to 'record' */ public record(): IRule { const index = this.ctx.rules.length - 1; @@ -42,12 +42,12 @@ export default class Rule implements IRule { throw new YesNoError('No rules have been defined yet'); } - this.ctx.rules[index].mode = MockMode.Record; + this.ctx.rules[index].ruleType = RuleType.Record; return this.ctx.rules[index]; } /** - * Set the rule mode to 'live' + * Set the rule type to 'live' */ public live(): IRule { const index = this.ctx.rules.length - 1; @@ -56,7 +56,7 @@ export default class Rule implements IRule { throw new YesNoError('No rules have been defined yet'); } - this.ctx.rules[index].mode = MockMode.Live; + this.ctx.rules[index].ruleType = RuleType.Live; return this.ctx.rules[index]; } } From 81e8a651ecef219d83f2f12f4f10300589808049 Mon Sep 17 00:00:00 2001 From: Matt Keith Date: Mon, 17 Aug 2020 11:08:54 -0600 Subject: [PATCH 4/8] add tests for mockRule --- src/yesno.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/yesno.ts b/src/yesno.ts index 29cb066..0d057fa 100644 --- a/src/yesno.ts +++ b/src/yesno.ts @@ -22,7 +22,7 @@ import { import Interceptor, { IInterceptEvent, IInterceptOptions, IProxiedEvent } from './interceptor'; import MockResponse from './mock-response'; import Recording, { RecordMode as Mode } from './recording'; -import Rule, { MockMode } from './rule'; +import Rule, { RuleType } from './rule'; const debug: IDebugger = require('debug')('yesno'); @@ -339,7 +339,7 @@ export class YesNo implements IFiltered { // see if the rule matches const matchFound = match(rule.matcher)({ request: event.requestSerializer }); if (matchFound) { - if (rule.mode === MockMode.Live) { + if (rule.ruleType === RuleType.Live) { return event.proxy(); } // check for a matching mock From 19a252a4554fb3699e6a93520e4e92005b51fa1e Mon Sep 17 00:00:00 2001 From: Matt Keith Date: Mon, 17 Aug 2020 15:17:57 -0600 Subject: [PATCH 5/8] add tests for mockRule --- test/unit/yesno.spec.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/test/unit/yesno.spec.ts b/test/unit/yesno.spec.ts index db112f4..e416000 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,35 @@ describe('Yesno', () => { }); }); + describe('#mockRule', () => { + const ctx = 'ctx'; + beforeEach(() => { yesno[ctx].rules = []; }); + + it('should add a rule with defaults', async () => { + + await yesno.mockRule("foo"); + + expect(yesno[ctx].rules).to.have.lengthOf(1); + expect(yesno[ctx].rules[0].ruleType).to.equal(RuleType.Record); + }); + + it('should add a rule with type RECORD', async () => { + + await yesno.mockRule("foo").record(); + + expect(yesno[ctx].rules).to.have.lengthOf(1); + expect(yesno[ctx].rules[0].ruleType).to.equal(RuleType.Record); + }); + + it('should add a rule with type LIVE', async () => { + + await yesno.mockRule("foo").live(); + + expect(yesno[ctx].rules).to.have.lengthOf(1); + expect(yesno[ctx].rules[0].ruleType).to.equal(RuleType.Live); + }); + }); + describe('#test', () => { beforeEach(() => { process.env[YESNO_RECORDING_MODE_ENV_VAR] = RecordMode.Spy; From 4514d4062b639a43a8d2d227bdeaa97e6d7c9981 Mon Sep 17 00:00:00 2001 From: Matt Keith Date: Mon, 17 Aug 2020 15:46:51 -0600 Subject: [PATCH 6/8] fix lint --- test/unit/yesno.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/unit/yesno.spec.ts b/test/unit/yesno.spec.ts index e416000..8c6942c 100644 --- a/test/unit/yesno.spec.ts +++ b/test/unit/yesno.spec.ts @@ -432,7 +432,7 @@ describe('Yesno', () => { it('should add a rule with defaults', async () => { - await yesno.mockRule("foo"); + await yesno.mockRule('foo'); expect(yesno[ctx].rules).to.have.lengthOf(1); expect(yesno[ctx].rules[0].ruleType).to.equal(RuleType.Record); @@ -440,7 +440,7 @@ describe('Yesno', () => { it('should add a rule with type RECORD', async () => { - await yesno.mockRule("foo").record(); + await yesno.mockRule('foo').record(); expect(yesno[ctx].rules).to.have.lengthOf(1); expect(yesno[ctx].rules[0].ruleType).to.equal(RuleType.Record); @@ -448,7 +448,7 @@ describe('Yesno', () => { it('should add a rule with type LIVE', async () => { - await yesno.mockRule("foo").live(); + await yesno.mockRule('foo').live(); expect(yesno[ctx].rules).to.have.lengthOf(1); expect(yesno[ctx].rules[0].ruleType).to.equal(RuleType.Live); From 6903fff7bf11e1ad967324893b83e015369f7a78 Mon Sep 17 00:00:00 2001 From: Matt Keith Date: Tue, 18 Aug 2020 09:41:10 -0600 Subject: [PATCH 7/8] update tests for mockRule --- test/unit/yesno.spec.ts | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/test/unit/yesno.spec.ts b/test/unit/yesno.spec.ts index 8c6942c..a2ee15a 100644 --- a/test/unit/yesno.spec.ts +++ b/test/unit/yesno.spec.ts @@ -428,30 +428,51 @@ describe('Yesno', () => { describe('#mockRule', () => { const ctx = 'ctx'; - beforeEach(() => { yesno[ctx].rules = []; }); + beforeEach(() => { + yesno.mock([ + createMock({ response: { body: 'mocked' } }), + ]); + }); + + afterEach(() => { + yesno.clear(); + yesno[ctx].rules = []; + }); it('should add a rule with defaults', async () => { - await yesno.mockRule('foo'); + await yesno.mockRule('http://localhost/get'); 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 RECORD', async () => { - await yesno.mockRule('foo').record(); + 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('foo').live(); + 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'); }); }); From 44147c9e644c5aeaa2fa056e81c6ffc278a2fd9c Mon Sep 17 00:00:00 2001 From: Matt Keith Date: Thu, 20 Aug 2020 16:32:56 -0600 Subject: [PATCH 8/8] default the mockRule action to empty and throw error if not set --- src/rule.ts | 3 ++- src/yesno.ts | 5 +++++ test/unit/yesno.spec.ts | 13 ++++++++----- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/rule.ts b/src/rule.ts index 599dc9b..9a8d081 100644 --- a/src/rule.ts +++ b/src/rule.ts @@ -4,6 +4,7 @@ import { Matcher } from './filtering/matcher'; import MockResponse from './mock-response'; export enum RuleType { + Init = '', Live = 'LIVE', Record = 'RECORD', Respond = 'RESPOND', @@ -29,7 +30,7 @@ export default class Rule implements IRule { constructor({ context, matcher = {} }: IRuleParams) { this.ctx = context; this.matcher = matcher; - this.ruleType = RuleType.Record; + this.ruleType = RuleType.Init; } /** diff --git a/src/yesno.ts b/src/yesno.ts index 0d057fa..60a0a20 100644 --- a/src/yesno.ts +++ b/src/yesno.ts @@ -339,6 +339,11 @@ export class YesNo implements IFiltered { // 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(); } diff --git a/test/unit/yesno.spec.ts b/test/unit/yesno.spec.ts index a2ee15a..9651ad0 100644 --- a/test/unit/yesno.spec.ts +++ b/test/unit/yesno.spec.ts @@ -439,16 +439,19 @@ describe('Yesno', () => { yesno[ctx].rules = []; }); - it('should add a rule with defaults', async () => { + 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.Record); + expect(yesno[ctx].rules[0].ruleType).to.equal(RuleType.Init); - // verify the mocked response - const response = await requestTestServer(); - expect(response).to.equal('mocked'); + // 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 () => {