Skip to content

Commit e7238be

Browse files
committed
feat(unit-test): covers base-client and optimise trakt-client UT
1 parent ca75d2d commit e7238be

File tree

4 files changed

+391
-145
lines changed

4 files changed

+391
-145
lines changed
+380
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,380 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { CancellableFetch } from '../../utils/fetch.utils';
4+
5+
import {
6+
BaseClient,
7+
type BaseOptions,
8+
type BaseQuery,
9+
type BaseTemplate,
10+
ClientEndpoint,
11+
type IApi,
12+
parseBody,
13+
parseUrl,
14+
type ResponseOrTypedResponse,
15+
} from './base-client';
16+
17+
import type { CacheStore } from '../../utils/cache.utils';
18+
import type { CancellablePromise } from '../../utils/fetch.utils';
19+
20+
import type { Updater } from '../../utils/observable.utils';
21+
import type { RecursiveRecord } from '../../utils/typescript.utils';
22+
23+
const mockEndpoint = 'https://api-endpoint.url';
24+
25+
type TestableAuthentication = Record<string, string | number>;
26+
27+
class TestableBaseClient extends BaseClient {
28+
constructor(settings: BaseOptions, authentication: TestableAuthentication, api: IApi) {
29+
super(settings, authentication, api);
30+
}
31+
32+
get callListeners() {
33+
return this._callListeners;
34+
}
35+
36+
publicUpdateAuth(auth: Updater<TestableAuthentication>) {
37+
return this.updateAuth(auth);
38+
}
39+
40+
// eslint-disable-next-line class-methods-use-this -- abstract method
41+
_parseHeaders(): HeadersInit {
42+
return {
43+
'Content-Type': 'application/json',
44+
};
45+
}
46+
47+
// eslint-disable-next-line class-methods-use-this -- abstract method
48+
_parseUrl<T extends RecursiveRecord = RecursiveRecord>(template: BaseTemplate<T>, params: T): URL {
49+
return parseUrl(template, params, mockEndpoint);
50+
}
51+
52+
// eslint-disable-next-line class-methods-use-this -- abstract method
53+
_parseBody<T extends RecursiveRecord = RecursiveRecord>(body: Record<string, string | boolean>, params: T): BodyInit {
54+
return parseBody(body, params);
55+
}
56+
57+
// eslint-disable-next-line class-methods-use-this -- abstract method
58+
_parseResponse(_response: Response): Response {
59+
return _response;
60+
}
61+
62+
publicCall<T extends RecursiveRecord = RecursiveRecord>(template: BaseTemplate<T>, params: T): Promise<Response> {
63+
return this._call(template, params);
64+
}
65+
}
66+
67+
describe('base-client.ts', () => {
68+
const cacheStore: CacheStore<ResponseOrTypedResponse> = {
69+
get: vi.fn(),
70+
set: vi.fn(),
71+
clear: vi.fn(),
72+
delete: vi.fn(),
73+
};
74+
75+
const api: IApi = {
76+
endpoint: new ClientEndpoint({
77+
method: 'GET',
78+
url: '/endpoint',
79+
}),
80+
anotherEndpoint: new ClientEndpoint({
81+
method: 'GET',
82+
url: '/another-endpoint',
83+
}),
84+
endpointWithParams: new ClientEndpoint({
85+
method: 'GET',
86+
url: '/endpoint/:param',
87+
opts: {
88+
parameters: {
89+
path: {
90+
param: true,
91+
},
92+
},
93+
},
94+
}),
95+
endpointWithValidation: new ClientEndpoint({
96+
method: 'GET',
97+
url: '/endpoint/:param',
98+
opts: {
99+
parameters: {
100+
path: {
101+
param: true,
102+
},
103+
},
104+
validate: {
105+
param: (value: string) => value === 'valid',
106+
},
107+
},
108+
}),
109+
endpointWithBody: new ClientEndpoint({
110+
method: 'POST',
111+
url: '/endpoint',
112+
body: {
113+
param: true,
114+
},
115+
}),
116+
endpointWithInit: new ClientEndpoint({
117+
method: 'GET',
118+
url: '/endpoint',
119+
init: {
120+
headers: {
121+
'X-Custom-Header': 'custom-value',
122+
},
123+
},
124+
}),
125+
};
126+
127+
const client = new TestableBaseClient({ cacheStore }, {}, api);
128+
const auth: TestableAuthentication = {
129+
refresh_token: 'refresh_token',
130+
access_token: 'access_token',
131+
expires: 1234567890,
132+
state: 'state',
133+
};
134+
const query: BaseQuery = {
135+
request: {
136+
input: 'https://api.trakt.tv/oauth/device/code',
137+
init: {
138+
headers: {
139+
'Content-Type': 'application/json',
140+
'trakt-api-key': 'client_id',
141+
'trakt-api-version': '2',
142+
},
143+
},
144+
},
145+
query: new Promise(() => {}) as CancellablePromise<unknown>,
146+
};
147+
148+
afterEach(() => {
149+
vi.clearAllMocks();
150+
});
151+
152+
const hasOwnProperty = (template: RecursiveRecord, _client: RecursiveRecord) =>
153+
Object.keys(template).forEach(endpoint => {
154+
expect(_client).toHaveProperty(endpoint);
155+
if (!(template[endpoint] instanceof ClientEndpoint)) {
156+
hasOwnProperty(template[endpoint], _client[endpoint]);
157+
} else {
158+
expect(_client[endpoint]).toBeTypeOf('function');
159+
expect(_client[endpoint].method).toBeDefined();
160+
expect(_client[endpoint].url).toBeDefined();
161+
expect(_client[endpoint].opts).toBeDefined();
162+
if (template[endpoint].validate) expect(_client[endpoint].validate).toBeDefined();
163+
if (template[endpoint].body) expect(_client[endpoint].body).toBeDefined();
164+
if (template[endpoint].init) expect(_client[endpoint].init).toBeDefined();
165+
}
166+
});
167+
168+
it('should have every endpoint', () => {
169+
expect.hasAssertions();
170+
171+
hasOwnProperty(api, client);
172+
});
173+
174+
describe('cache', () => {
175+
it('should delete a cached entry', async () => {
176+
expect.assertions(1);
177+
178+
await client.clearCache('key');
179+
180+
expect(cacheStore.delete).toHaveBeenCalledWith('key');
181+
});
182+
183+
it('should delete a cached entry', async () => {
184+
expect.assertions(1);
185+
186+
await client.clearCache();
187+
188+
expect(cacheStore.clear).toHaveBeenCalledWith();
189+
});
190+
});
191+
192+
describe('observers', () => {
193+
const authObserver = vi.fn();
194+
const callObserver = vi.fn();
195+
196+
afterEach(() => {
197+
client.publicUpdateAuth({});
198+
});
199+
200+
it('should subscribe an observer to authentication state changes', () => {
201+
expect.assertions(5);
202+
203+
client.onAuthChange(authObserver);
204+
205+
client.publicUpdateAuth(auth);
206+
expect(authObserver).toHaveBeenCalledWith(auth);
207+
expect(client.auth.state).toBe(auth.state);
208+
209+
const newState = 'new-state';
210+
client.publicUpdateAuth(_auth => ({ ..._auth, state: newState }));
211+
expect(authObserver).toHaveBeenCalledWith({ ...auth, state: newState });
212+
expect(client.auth.state).toBe(newState);
213+
214+
expect(authObserver).toHaveBeenCalledTimes(2);
215+
});
216+
217+
it('should subscribe an observer to calls', () => {
218+
expect.assertions(2);
219+
220+
client.onCall(callObserver);
221+
222+
client.callListeners.update(query);
223+
expect(callObserver).toHaveBeenCalledWith(query);
224+
expect(callObserver).toHaveBeenCalledTimes(1);
225+
});
226+
227+
it('should unsubscribe an observer', () => {
228+
expect.assertions(6);
229+
230+
client.onAuthChange(authObserver);
231+
client.onCall(callObserver);
232+
233+
client.callListeners.update(query);
234+
client.publicUpdateAuth(auth);
235+
236+
expect(authObserver).toHaveBeenCalledTimes(1);
237+
expect(callObserver).toHaveBeenCalledTimes(1);
238+
239+
client.unsubscribe(authObserver);
240+
241+
client.callListeners.update(query);
242+
client.publicUpdateAuth(auth);
243+
244+
expect(authObserver).toHaveBeenCalledTimes(1);
245+
expect(callObserver).toHaveBeenCalledTimes(2);
246+
247+
client.unsubscribe(callObserver);
248+
249+
client.callListeners.update(query);
250+
client.publicUpdateAuth(auth);
251+
252+
expect(authObserver).toHaveBeenCalledTimes(1);
253+
expect(callObserver).toHaveBeenCalledTimes(2);
254+
});
255+
256+
it('should unsubscribe all observers', () => {
257+
expect.assertions(4);
258+
259+
client.onCall(callObserver);
260+
client.onAuthChange(authObserver);
261+
262+
client.publicUpdateAuth(auth);
263+
client.callListeners.update(query);
264+
265+
expect(authObserver).toHaveBeenCalledTimes(1);
266+
expect(callObserver).toHaveBeenCalledTimes(1);
267+
268+
client.unsubscribe();
269+
270+
client.publicUpdateAuth(auth);
271+
client.callListeners.update(query);
272+
273+
expect(authObserver).toHaveBeenCalledTimes(1);
274+
expect(callObserver).toHaveBeenCalledTimes(1);
275+
});
276+
});
277+
278+
type Params = {
279+
requiredQuery: string;
280+
optionalQuery?: string;
281+
requiredPath: string;
282+
optionalPath?: string;
283+
requiredBody: string;
284+
optionalBody?: string;
285+
};
286+
287+
// Mock data for testing
288+
const mockParams: Params = {
289+
requiredQuery: 'requiredQuery',
290+
requiredPath: 'requiredPath',
291+
requiredBody: 'requiredBody',
292+
};
293+
294+
// Mock TraktApiTemplate for testing
295+
const mockTemplate: BaseTemplate<Params> = {
296+
url: '/movies/:requiredPath/:optionalPath/popular?requiredQuery=&optionalQuery=',
297+
method: 'POST',
298+
opts: {
299+
parameters: {
300+
query: {
301+
requiredQuery: true,
302+
optionalQuery: false,
303+
},
304+
path: {
305+
requiredPath: true,
306+
optionalPath: false,
307+
},
308+
},
309+
},
310+
body: {
311+
requiredBody: true,
312+
optionalBody: false,
313+
},
314+
};
315+
316+
describe('parseBody', () => {
317+
it('should parse body to JSON string', () => {
318+
expect.assertions(1);
319+
320+
const result = parseBody(mockTemplate.body!, mockParams);
321+
expect(result).toBe('{"requiredBody":"requiredBody"}');
322+
});
323+
324+
it('should parse body to JSON string', () => {
325+
expect.assertions(1);
326+
327+
const mockBody: Record<string, unknown> = { ...mockParams, optionalBody: 'optionalBody' };
328+
delete mockBody.requiredBody;
329+
const testFunction = () => parseBody(mockTemplate.body!, mockBody);
330+
expect(testFunction).toThrow("Missing mandatory body parameter: 'requiredBody'");
331+
});
332+
});
333+
334+
describe('parseUrl', () => {
335+
it('should construct a valid URL for Trakt API request', async () => {
336+
expect.assertions(2);
337+
338+
const result = parseUrl(mockTemplate, mockParams, mockEndpoint);
339+
340+
expect(result).toBeInstanceOf(URL);
341+
expect(result?.toString()).toBe(`${mockEndpoint}/movies/requiredPath/popular?requiredQuery=requiredQuery`);
342+
});
343+
344+
it('should throw an error for missing mandatory query parameter', async () => {
345+
expect.assertions(1);
346+
347+
const testFunction = () => parseUrl(mockTemplate, { ...mockParams, requiredQuery: '' }, mockEndpoint);
348+
expect(testFunction).toThrow("Missing mandatory query parameter: 'requiredQuery'");
349+
});
350+
351+
it('should throw an error for missing mandatory path parameter', async () => {
352+
expect.assertions(1);
353+
354+
const testFunction = () => parseUrl(mockTemplate, { ...mockParams, requiredPath: '' }, mockEndpoint);
355+
expect(testFunction).toThrow("Missing mandatory path parameter: 'requiredPath'");
356+
});
357+
});
358+
359+
describe('call', () => {
360+
it('should call an endpoint', async () => {
361+
expect.assertions(2);
362+
363+
const response = new Response();
364+
365+
const spyFetch = vi.spyOn(CancellableFetch, 'fetch').mockResolvedValue(response);
366+
367+
const result = await client.publicCall(mockTemplate, mockParams);
368+
369+
expect(spyFetch).toHaveBeenCalledWith(`${mockEndpoint}/movies/requiredPath/popular?requiredQuery=requiredQuery`, {
370+
body: '{"requiredBody":"requiredBody"}',
371+
headers: {
372+
'Content-Type': 'application/json',
373+
},
374+
method: 'POST',
375+
});
376+
377+
expect(result).toBe(response);
378+
});
379+
});
380+
});

0 commit comments

Comments
 (0)