Skip to content

Commit d1fb37d

Browse files
authored
[PM-17635] [PM-18601] Simplifying mocking and usage of the sdk (#14287)
* feat: add our own custom deep mocker * feat: use new mock service in totp tests * feat: implement userClient mocking * chore: move mock files * feat: replace existing manual sdkService mocking * chore: rename to 'client' * chore: improve docs * feat: refactor sdkService to never return undefined BitwardenClient
1 parent 4fcc479 commit d1fb37d

File tree

9 files changed

+444
-54
lines changed

9 files changed

+444
-54
lines changed

libs/common/src/platform/abstractions/sdk/sdk.service.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,15 +53,18 @@ export abstract class SdkService {
5353
* This client can be used for operations that require a user context, such as retrieving ciphers
5454
* and operations involving crypto. It can also be used for operations that don't require a user context.
5555
*
56+
* - If the user is not logged when the subscription is created, the observable will complete
57+
* immediately with {@link UserNotLoggedInError}.
58+
* - If the user is logged in, the observable will emit the client and complete whithout an error
59+
* when the user logs out.
60+
*
5661
* **WARNING:** Do not use `firstValueFrom(userClient$)`! Any operations on the client must be done within the observable.
5762
* The client will be destroyed when the observable is no longer subscribed to.
5863
* Please let platform know if you need a client that is not destroyed when the observable is no longer subscribed to.
5964
*
6065
* @param userId The user id for which to retrieve the client
61-
*
62-
* @throws {UserNotLoggedInError} If the user is not logged in
6366
*/
64-
abstract userClient$(userId: UserId): Observable<Rc<BitwardenClient> | undefined>;
67+
abstract userClient$(userId: UserId): Observable<Rc<BitwardenClient>>;
6568

6669
/**
6770
* This method is used during/after an authentication procedure to set a new client for a specific user.

libs/common/src/platform/services/sdk/default-sdk.service.spec.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -132,15 +132,13 @@ describe("DefaultSdkService", () => {
132132
);
133133
keyService.userKey$.calledWith(userId).mockReturnValue(userKey$);
134134

135-
const subject = new BehaviorSubject<Rc<BitwardenClient> | undefined>(undefined);
136-
service.userClient$(userId).subscribe(subject);
137-
await new Promise(process.nextTick);
135+
const userClientTracker = new ObservableTracker(service.userClient$(userId), false);
136+
await userClientTracker.pauseUntilReceived(1);
138137

139138
userKey$.next(undefined);
140-
await new Promise(process.nextTick);
139+
await userClientTracker.expectCompletion();
141140

142141
expect(mockClient.free).toHaveBeenCalledTimes(1);
143-
expect(subject.value).toBe(undefined);
144142
});
145143
});
146144

libs/common/src/platform/services/sdk/default-sdk.service.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export class DefaultSdkService implements SdkService {
7171
private userAgent: string | null = null,
7272
) {}
7373

74-
userClient$(userId: UserId): Observable<Rc<BitwardenClient> | undefined> {
74+
userClient$(userId: UserId): Observable<Rc<BitwardenClient>> {
7575
return this.sdkClientOverrides.pipe(
7676
takeWhile((clients) => clients[userId] !== UnsetClient, false),
7777
map((clients) => {
@@ -88,6 +88,7 @@ export class DefaultSdkService implements SdkService {
8888

8989
return this.internalClient$(userId);
9090
}),
91+
takeWhile((client) => client !== undefined, false),
9192
throwIfEmpty(() => new UserNotLoggedInError(userId)),
9293
);
9394
}
@@ -112,7 +113,7 @@ export class DefaultSdkService implements SdkService {
112113
* @param userId The user id for which to create the client
113114
* @returns An observable that emits the client for the user
114115
*/
115-
private internalClient$(userId: UserId): Observable<Rc<BitwardenClient> | undefined> {
116+
private internalClient$(userId: UserId): Observable<Rc<BitwardenClient>> {
116117
const cached = this.sdkClientCache.get(userId);
117118
if (cached !== undefined) {
118119
return cached;
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { mockDeep } from "./mock-deep";
2+
3+
class ToBeMocked {
4+
property = "value";
5+
6+
method() {
7+
return "method";
8+
}
9+
10+
sub() {
11+
return new SubToBeMocked();
12+
}
13+
}
14+
15+
class SubToBeMocked {
16+
subProperty = "subValue";
17+
18+
sub() {
19+
return new SubSubToBeMocked();
20+
}
21+
}
22+
23+
class SubSubToBeMocked {
24+
subSubProperty = "subSubValue";
25+
}
26+
27+
describe("deepMock", () => {
28+
it("can mock properties", () => {
29+
const mock = mockDeep<ToBeMocked>();
30+
mock.property.replaceProperty("mocked value");
31+
expect(mock.property).toBe("mocked value");
32+
});
33+
34+
it("can mock methods", () => {
35+
const mock = mockDeep<ToBeMocked>();
36+
mock.method.mockReturnValue("mocked method");
37+
expect(mock.method()).toBe("mocked method");
38+
});
39+
40+
it("can mock sub-properties", () => {
41+
const mock = mockDeep<ToBeMocked>();
42+
mock.sub.mockDeep().subProperty.replaceProperty("mocked sub value");
43+
expect(mock.sub().subProperty).toBe("mocked sub value");
44+
});
45+
46+
it("can mock sub-sub-properties", () => {
47+
const mock = mockDeep<ToBeMocked>();
48+
mock.sub.mockDeep().sub.mockDeep().subSubProperty.replaceProperty("mocked sub-sub value");
49+
expect(mock.sub().sub().subSubProperty).toBe("mocked sub-sub value");
50+
});
51+
52+
it("returns the same mock object when calling mockDeep multiple times", () => {
53+
const mock = mockDeep<ToBeMocked>();
54+
const subMock1 = mock.sub.mockDeep();
55+
const subMock2 = mock.sub.mockDeep();
56+
expect(subMock1).toBe(subMock2);
57+
});
58+
});
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
// This is a modification of the code found in https://github.com/marchaos/jest-mock-extended
2+
// to better support deep mocking of objects.
3+
4+
// MIT License
5+
6+
// Copyright (c) 2019 Marc McIntyre
7+
8+
// Permission is hereby granted, free of charge, to any person obtaining a copy
9+
// of this software and associated documentation files (the "Software"), to deal
10+
// in the Software without restriction, including without limitation the rights
11+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12+
// copies of the Software, and to permit persons to whom the Software is
13+
// furnished to do so, subject to the following conditions:
14+
15+
// The above copyright notice and this permission notice shall be included in all
16+
// copies or substantial portions of the Software.
17+
18+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24+
// SOFTWARE.
25+
26+
import { jest } from "@jest/globals";
27+
import { FunctionLike } from "jest-mock";
28+
import { calledWithFn, MatchersOrLiterals } from "jest-mock-extended";
29+
import { PartialDeep } from "type-fest";
30+
31+
type ProxiedProperty = string | number | symbol;
32+
33+
export interface GlobalConfig {
34+
// ignoreProps is required when we don't want to return anything for a mock (for example, when mocking a promise).
35+
ignoreProps?: ProxiedProperty[];
36+
}
37+
38+
const DEFAULT_CONFIG: GlobalConfig = {
39+
ignoreProps: ["then"],
40+
};
41+
42+
let GLOBAL_CONFIG = DEFAULT_CONFIG;
43+
44+
export const JestMockExtended = {
45+
DEFAULT_CONFIG,
46+
configure: (config: GlobalConfig) => {
47+
// Shallow merge so they can override anything they want.
48+
GLOBAL_CONFIG = { ...DEFAULT_CONFIG, ...config };
49+
},
50+
resetConfig: () => {
51+
GLOBAL_CONFIG = DEFAULT_CONFIG;
52+
},
53+
};
54+
55+
export interface CalledWithMock<T extends FunctionLike> extends jest.Mock<T> {
56+
calledWith: (...args: [...MatchersOrLiterals<Parameters<T>>]) => jest.Mock<T>;
57+
}
58+
59+
export interface MockDeepMock<R> {
60+
mockDeep: () => DeepMockProxy<R>;
61+
}
62+
63+
export interface ReplaceProperty<T> {
64+
/**
65+
* mockDeep will by default return a jest.fn() for all properties,
66+
* but this allows you to replace the property with a value.
67+
* @param value The value to replace the property with.
68+
*/
69+
replaceProperty(value: T): void;
70+
}
71+
72+
export type _MockProxy<T> = {
73+
[K in keyof T]: T[K] extends FunctionLike ? T[K] & CalledWithMock<T[K]> : T[K];
74+
};
75+
76+
export type MockProxy<T> = _MockProxy<T> & T;
77+
78+
export type _DeepMockProxy<T> = {
79+
// This supports deep mocks in the else branch
80+
[K in keyof T]: T[K] extends (...args: infer A) => infer R
81+
? T[K] & CalledWithMock<T[K]> & MockDeepMock<R>
82+
: T[K] & ReplaceProperty<T[K]> & _DeepMockProxy<T[K]>;
83+
};
84+
85+
// we intersect with T here instead of on the mapped type above to
86+
// prevent immediate type resolution on a recursive type, this will
87+
// help to improve performance for deeply nested recursive mocking
88+
// at the same time, this intersection preserves private properties
89+
export type DeepMockProxy<T> = _DeepMockProxy<T> & T;
90+
91+
export type _DeepMockProxyWithFuncPropSupport<T> = {
92+
// This supports deep mocks in the else branch
93+
[K in keyof T]: T[K] extends FunctionLike
94+
? CalledWithMock<T[K]> & DeepMockProxy<T[K]>
95+
: DeepMockProxy<T[K]>;
96+
};
97+
98+
export type DeepMockProxyWithFuncPropSupport<T> = _DeepMockProxyWithFuncPropSupport<T> & T;
99+
100+
export interface MockOpts {
101+
deep?: boolean;
102+
fallbackMockImplementation?: (...args: any[]) => any;
103+
}
104+
105+
export const mockClear = (mock: MockProxy<any>) => {
106+
for (const key of Object.keys(mock)) {
107+
if (mock[key] === null || mock[key] === undefined) {
108+
continue;
109+
}
110+
111+
if (mock[key]._isMockObject) {
112+
mockClear(mock[key]);
113+
}
114+
115+
if (mock[key]._isMockFunction) {
116+
mock[key].mockClear();
117+
}
118+
}
119+
120+
// This is a catch for if they pass in a jest.fn()
121+
if (!mock._isMockObject) {
122+
return mock.mockClear();
123+
}
124+
};
125+
126+
export const mockReset = (mock: MockProxy<any>) => {
127+
for (const key of Object.keys(mock)) {
128+
if (mock[key] === null || mock[key] === undefined) {
129+
continue;
130+
}
131+
132+
if (mock[key]._isMockObject) {
133+
mockReset(mock[key]);
134+
}
135+
if (mock[key]._isMockFunction) {
136+
mock[key].mockReset();
137+
}
138+
}
139+
140+
// This is a catch for if they pass in a jest.fn()
141+
// Worst case, we will create a jest.fn() (since this is a proxy)
142+
// below in the get and call mockReset on it
143+
if (!mock._isMockObject) {
144+
return mock.mockReset();
145+
}
146+
};
147+
148+
export function mockDeep<T>(
149+
opts: {
150+
funcPropSupport?: true;
151+
fallbackMockImplementation?: MockOpts["fallbackMockImplementation"];
152+
},
153+
mockImplementation?: PartialDeep<T>,
154+
): DeepMockProxyWithFuncPropSupport<T>;
155+
export function mockDeep<T>(mockImplementation?: PartialDeep<T>): DeepMockProxy<T>;
156+
export function mockDeep(arg1: any, arg2?: any) {
157+
const [opts, mockImplementation] =
158+
typeof arg1 === "object" &&
159+
(typeof arg1.fallbackMockImplementation === "function" || arg1.funcPropSupport === true)
160+
? [arg1, arg2]
161+
: [{}, arg1];
162+
return mock(mockImplementation, {
163+
deep: true,
164+
fallbackMockImplementation: opts.fallbackMockImplementation,
165+
});
166+
}
167+
168+
const overrideMockImp = (obj: PartialDeep<any>, opts?: MockOpts) => {
169+
const proxy = new Proxy<MockProxy<any>>(obj, handler(opts));
170+
for (const name of Object.keys(obj)) {
171+
if (typeof obj[name] === "object" && obj[name] !== null) {
172+
proxy[name] = overrideMockImp(obj[name], opts);
173+
} else {
174+
proxy[name] = obj[name];
175+
}
176+
}
177+
178+
return proxy;
179+
};
180+
181+
const handler = (opts?: MockOpts): ProxyHandler<any> => ({
182+
ownKeys(target: MockProxy<any>) {
183+
return Reflect.ownKeys(target);
184+
},
185+
186+
set: (obj: MockProxy<any>, property: ProxiedProperty, value: any) => {
187+
obj[property] = value;
188+
return true;
189+
},
190+
191+
get: (obj: MockProxy<any>, property: ProxiedProperty) => {
192+
const fn = calledWithFn({ fallbackMockImplementation: opts?.fallbackMockImplementation });
193+
194+
if (!(property in obj)) {
195+
if (GLOBAL_CONFIG.ignoreProps?.includes(property)) {
196+
return undefined;
197+
}
198+
// Jest's internal equality checking does some wierd stuff to check for iterable equality
199+
if (property === Symbol.iterator) {
200+
return obj[property];
201+
}
202+
203+
if (property === "_deepMock") {
204+
return obj[property];
205+
}
206+
// So this calls check here is totally not ideal - jest internally does a
207+
// check to see if this is a spy - which we want to say no to, but blindly returning
208+
// an proxy for calls results in the spy check returning true. This is another reason
209+
// why deep is opt in.
210+
if (opts?.deep && property !== "calls") {
211+
obj[property] = new Proxy<MockProxy<any>>(fn, handler(opts));
212+
obj[property].replaceProperty = <T extends typeof obj, K extends keyof T>(value: T[K]) => {
213+
obj[property] = value;
214+
};
215+
obj[property].mockDeep = () => {
216+
if (obj[property]._deepMock) {
217+
return obj[property]._deepMock;
218+
}
219+
220+
const mock = mockDeep({
221+
fallbackMockImplementation: opts?.fallbackMockImplementation,
222+
});
223+
(obj[property] as CalledWithMock<any>).mockReturnValue(mock);
224+
obj[property]._deepMock = mock;
225+
return mock;
226+
};
227+
obj[property]._isMockObject = true;
228+
} else {
229+
obj[property] = calledWithFn({
230+
fallbackMockImplementation: opts?.fallbackMockImplementation,
231+
});
232+
}
233+
}
234+
235+
// @ts-expect-error Hack by author of jest-mock-extended
236+
if (obj instanceof Date && typeof obj[property] === "function") {
237+
// @ts-expect-error Hack by author of jest-mock-extended
238+
return obj[property].bind(obj);
239+
}
240+
241+
return obj[property];
242+
},
243+
});
244+
245+
const mock = <T, MockedReturn extends MockProxy<T> & T = MockProxy<T> & T>(
246+
mockImplementation: PartialDeep<T> = {} as PartialDeep<T>,
247+
opts?: MockOpts,
248+
): MockedReturn => {
249+
// @ts-expect-error private
250+
mockImplementation!._isMockObject = true;
251+
return overrideMockImp(mockImplementation, opts);
252+
};
253+
254+
export const mockFn = <T extends FunctionLike>(): CalledWithMock<T> & T => {
255+
// @ts-expect-error Hack by author of jest-mock-extended
256+
return calledWithFn();
257+
};
258+
259+
export const stub = <T extends object>(): T => {
260+
return new Proxy<T>({} as T, {
261+
get: (obj, property: ProxiedProperty) => {
262+
if (property in obj) {
263+
// @ts-expect-error Hack by author of jest-mock-extended
264+
return obj[property];
265+
}
266+
return jest.fn();
267+
},
268+
});
269+
};
270+
271+
export default mock;

0 commit comments

Comments
 (0)