Skip to content

Commit

Permalink
fix(core): detecting self-references in mocks #5262
Browse files Browse the repository at this point in the history
  • Loading branch information
satanTime committed Mar 25, 2023
1 parent 12b6147 commit c699d57
Show file tree
Hide file tree
Showing 5 changed files with 155 additions and 41 deletions.
35 changes: 26 additions & 9 deletions libs/ng-mocks/src/lib/mock-service/helper.replace-with-mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,17 @@ const handleSection = (section: any[]) => {
return guards;
};

const handleArray = (value: any[], callback: any): [boolean, any[]] => {
const mock = [];
const handleArray = (cache: Map<any, any>, value: any[], callback: any): [boolean, any[]] => {
const mock: Array<any> = [];
let updated = false;
cache.set(value, mock);

for (const valueItem of value) {
if (ngMocksUniverse.isExcludedDef(valueItem)) {
updated = updated || true;
continue;
}
mock.push(callback(valueItem));
mock.push(callback(valueItem, cache));
updated = updated || mock[mock.length - 1] !== valueItem;
}

Expand All @@ -38,16 +39,21 @@ const handleItemKeys = ['canActivate', 'canActivateChild', 'canDeactivate', 'can
const handleItemGetGuards = (mock: any, section: string) =>
Array.isArray(mock[section]) ? handleSection(mock[section]) : mock[section];

const handleItem = (value: Record<keyof any, any>, callback: any): [boolean, Record<keyof any, any>] => {
const handleItem = (
cache: Map<any, any>,
value: Record<keyof any, any>,
callback: any,
): [boolean, Record<keyof any, any>] => {
let mock: Record<keyof any, any> = {};
let updated = false;
cache.set(value, mock);

for (const key of Object.keys(value)) {
if (ngMocksUniverse.isExcludedDef(value[key])) {
updated = updated || true;
continue;
}
mock[key] = callback(value[key]);
mock[key] = callback(value[key], cache);
updated = updated || mock[key] !== value[key];
}

Expand All @@ -63,21 +69,24 @@ const handleItem = (value: Record<keyof any, any>, callback: any): [boolean, Rec
return [updated, mock];
};

const replaceWithMocks = (value: any): any => {
const replaceWithMocks = (value: any, cache: Map<any, any>): any => {
if (ngMocksUniverse.cacheDeclarations.has(value)) {
return ngMocksUniverse.cacheDeclarations.get(value);
}
if (typeof value !== 'object') {
return value;
}
if (cache.has(value)) {
return value;
}

let mock: any;
let updated = false;

if (Array.isArray(value)) {
[updated, mock] = handleArray(value, replaceWithMocks);
[updated, mock] = handleArray(cache, value, replaceWithMocks);
} else if (value) {
[updated, mock] = handleItem(value, replaceWithMocks);
[updated, mock] = handleItem(cache, value, replaceWithMocks);
}

if (updated) {
Expand All @@ -89,4 +98,12 @@ const replaceWithMocks = (value: any): any => {
return value;
};

export default (() => replaceWithMocks)();
const replaceWithMocksWrapper = (value: any) => {
const cache = new Map();
const result = replaceWithMocks(value, cache);
cache.clear();

return result;
};

export default (() => replaceWithMocksWrapper)();
83 changes: 53 additions & 30 deletions libs/ng-mocks/src/lib/mock-service/mock-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,35 @@ import checkIsFunc from './check.is-func';
import checkIsInst from './check.is-inst';
import helperMockService from './helper.mock-service';

const mockVariableMap: Array<
[(def: any) => boolean, (service: any, prefix: string, callback: typeof MockService) => any]
> = [
[checkIsClass, (service: any) => helperMockService.createMockFromPrototype(service.prototype)],
type MockServiceHandler = (cache: Map<any, any>, service: any, prefix?: string, overrides?: any) => any;

const mockVariableMap: Array<[(def: any) => boolean, MockServiceHandler]> = [
[
checkIsClass,
(cache, service) => {
const value = helperMockService.createMockFromPrototype(service.prototype);
cache.set(service, value);

return value;
},
],
[
checkIsFunc,
(service: any, prefix: string) => helperMockService.mockFunction(`func:${prefix || funcGetName(service)}`),
(cache, service, prefix) => {
const value = helperMockService.mockFunction(`func:${prefix || funcGetName(service)}`);
cache.set(service, value());

return value;
},
],
[def => Array.isArray(def), () => []],
[
checkIsInst,
(service, prefix, callback) => {
(cache, service, prefix, callback) => {
const value = helperMockService.createMockFromPrototype(service.constructor.prototype);
cache.set(service, value);
for (const property of Object.keys(service)) {
const mock: any = callback(service[property], `${prefix || 'instance'}.${property}`);
const mock: any = callback(cache, service[property], `${prefix || 'instance'}.${property}`);
if (mock !== undefined) {
value[property] = mock;
}
Expand All @@ -33,14 +47,27 @@ const mockVariableMap: Array<
],
];

const mockVariable = (service: any, prefix: string, callback: typeof MockService) => {
const mockVariable = (cache: Map<any, any>, service: any, prefix: string, callback: MockServiceHandler) => {
for (const [check, createMock] of mockVariableMap) {
if (!check(service)) {
continue;
}

return createMock(service, prefix, callback);
return cache.get(service) ?? createMock(cache, service, prefix, callback);
}
};

/**
* Mocking all methods / properties of a class / object.
*/
const mockService: MockServiceHandler = (cache, service, prefix = '', overrides): any => {
const value: any = mockVariable(cache, service, prefix, mockService);

if (overrides) {
mockHelperStub(value, overrides);
}

return value;
};

/**
Expand All @@ -53,52 +80,48 @@ export function MockService(service: boolean | number | string | null | undefine

/**
* MockService creates a mock instance out of an object or a class.
* The second parameter can be used as overrides.
*
* @see https://ng-mocks.sudo.eu/api/MockService
*
* ```ts
* const service = MockService(AuthService, {
* loggedIn: true,
* });
* const service = MockService(AuthService);
* service.login(); // does nothing, it's dummy.
* ```
*/
export function MockService<T>(service: AnyType<T>, overrides?: Partial<T>, mockNamePrefix?: string): T;
export function MockService<T>(service: AnyType<T>, spyNamePrefix?: string): T;

/**
* MockService creates a mock instance out of an object or a class.
*
* @see https://ng-mocks.sudo.eu/api/MockService
*
* ```ts
* const service = MockService(AuthService);
* service.login(); // does nothing, it's dummy.
* const mockUser = MockService(currentUser);
* mockUser.save(); // does nothing, it's dummy.
*/
export function MockService<T>(service: AnyType<T>, mockNamePrefix?: string): T;
export function MockService<T = any>(service: object, spyNamePrefix?: string): T;

/**
* MockService creates a mock instance out of an object or a class.
* The second parameter can be used as overrides.
*
* @see https://ng-mocks.sudo.eu/api/MockService
*
* ```ts
* const mockUser = MockService(currentUser);
* mockUser.save(); // does nothing, it's dummy.
* const service = MockService(AuthService, {
* loggedIn: true,
* });
* service.login(); // does nothing, it's dummy.
* ```
*/
export function MockService<T = any>(service: object, mockNamePrefix?: string): T;
export function MockService<T>(service: AnyType<T>, overrides?: Partial<T>, spyNamePrefix?: string): T;

export function MockService(service: any, ...args: any[]): any {
// mocking all methods / properties of a class / object.

const mockNamePrefix = args.length > 0 && typeof args[0] === 'string' ? args[0] : args[1];
const prefix = args.length > 0 && typeof args[0] === 'string' ? args[0] : args[1];
const overrides = args.length > 0 && args[0] && typeof args[0] === 'object' ? args[0] : undefined;

const value: any = mockVariable(service, mockNamePrefix, MockService);
const cache = new Map();
const result = mockService(cache, service, prefix, overrides);
cache.clear();

if (overrides) {
mockHelperStub(value, overrides);
}

return value;
return result;
}
18 changes: 16 additions & 2 deletions tests-failures/mock-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,22 @@ class Service {
}
}

MockService(TOKEN);
MockService(Service);
const token = MockService(TOKEN);
const service = MockService(Service);

// token is any, so it can be whatever
const tokenCheck1: undefined = token;
// token is any, so it can be whatever
const tokenCheck2: Service = token;
// token is any, so it can be whatever
const tokenCheck3: number = token;

// @ts-expect-error: Service is not undefined
const serviceCheck1: undefined = service;
// service is Service
const serviceCheck2: Service = service;
// @ts-expect-error: Service is not number
const serviceCheck3: number = service;

// @ts-expect-error: does not accept wrong types.
MockService(Service, {
Expand Down
46 changes: 46 additions & 0 deletions tests/issue-5262/test.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { InjectionToken, NgModule, VERSION } from '@angular/core';

import { MockBuilder, MockRender } from 'ng-mocks';

const TOKEN = new InjectionToken('TOKEN');

@NgModule({
providers: [
{
provide: TOKEN,
useValue: (() => {
const recursive: any = {
index: 0,
};
// It fails without ivy on the compiler level.
if (Number.parseInt(VERSION.major, 10) >= 13) {
recursive.parent = recursive;
}

return recursive;
})(),
},
],
})
class TargetModule {}

// @see https://github.com/help-me-mom/ng-mocks/issues/5262
describe('issue-5262', () => {
describe('mock', () => {
beforeEach(() => MockBuilder(null, TargetModule));

it('does not fail on recursion', () => {
const token = MockRender(TOKEN);
expect(token).toBeDefined();
});
});

describe('keep', () => {
beforeEach(() => MockBuilder(TOKEN, TargetModule));

it('does not fail on recursion', () => {
const token = MockRender(TOKEN);
expect(token).toBeDefined();
});
});
});
14 changes: 14 additions & 0 deletions tests/mock-service/test.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,18 @@ describe('mock-service', () => {
);
expect(instance.echo1()).toBeUndefined();
});

it('adds prefixes', () => {
const instance = MockService(
class {
private readonly value = 'unnamed';

public echo1() {
return this.value;
}
},
'prefix',
);
expect(instance.echo1()).toBeUndefined();
});
});

0 comments on commit c699d57

Please sign in to comment.