Skip to content
This repository was archived by the owner on Feb 20, 2024. It is now read-only.

initial support for mockRule #95

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/filtering/matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

/**
Expand Down
63 changes: 63 additions & 0 deletions src/rule.ts
Original file line number Diff line number Diff line change
@@ -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];
}
}
105 changes: 70 additions & 35 deletions src/yesno.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<any> | void;
export type GenericTestFunction = (title: string, fn: GenericTest) => any;

export type HttpFilter = string | RegExp | ISerializedHttpPartialDeepMatch | MatchFn;

export interface IRecordableTest {
test?: GenericTestFunction;
it?: GenericTestFunction;
Expand Down Expand Up @@ -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?
Expand Down Expand Up @@ -155,6 +167,7 @@ export class YesNo implements IFiltered {

return records;
}

/**
* Save intercepted requests
*
Expand Down Expand Up @@ -286,6 +299,59 @@ export class YesNo implements IFiltered {
private async onIntercept(event: IInterceptEvent): Promise<void> {
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();
Expand All @@ -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 {
Expand Down
54 changes: 54 additions & 0 deletions test/unit/yesno.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> = { [P in keyof T]?: PartialDeep<T[P]> };
Expand Down Expand Up @@ -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;
Expand Down