Skip to content

Commit d214150

Browse files
authored
✨ Added Caching Decorator (with MemoryCache) (#10)
* 💡 Fixed Docs and missing exports * ✨ Implemented InMemory Cache * ✨ ♻️ Implemented Caching to leverage 2 decorators This should make it easier to read. Also splits the responsibilities up a bit. * 🐛 Fixed Bug in communication between decorators * ✅ Created Tests for Decorators * ✅ Created test for memory cache * ♻️ Alligned & Formated Tests * ✅ Extended test and aligned * ✅ Aligned last tests * ✏️ Fixed typos in doc * ♻️ Minor refactor * ✅ Corrected timings * 🔖 Prepare for 0.2.0
1 parent 2d2a0ba commit d214150

22 files changed

+1074
-305
lines changed

__tests__/cache/cache.spec.ts

+277
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
import { CACHE_KEY, Cache, CacheKey } from "../../lib/cache/cache.js";
2+
import { UnitOfTime } from "../../lib/util/types.js";
3+
4+
describe('Cache Decorator', () => {
5+
it('should perform NO-OP if CacheKey has not injected a value', async () => {
6+
class Test {
7+
constructor(private spy: jest.Mock) { }
8+
9+
@Cache()
10+
public missesOutterDecorator(): number {
11+
this.spy();
12+
return 1
13+
}
14+
}
15+
16+
const callRecorder = jest.fn();
17+
const target = new Test(callRecorder);
18+
19+
expect(target.missesOutterDecorator()).toEqual(1);
20+
expect(target.missesOutterDecorator()).toEqual(1);
21+
22+
expect(callRecorder).toHaveBeenCalledTimes(2);
23+
});
24+
25+
describe('Sync Method', () => {
26+
it('should cache successfull calls for a time', async () => {
27+
class Test {
28+
constructor(private spy: jest.Mock) { }
29+
30+
@CacheKey(1)
31+
@Cache({ ttl: 5, unit: UnitOfTime.Millisecond })
32+
public expensiveCall(account: string, key: string): number {
33+
this.spy(key);
34+
return 1
35+
}
36+
}
37+
38+
const callRecorder = jest.fn();
39+
const target = new Test(callRecorder);
40+
41+
expect(target.expensiveCall('123456', 'cache')).toEqual(1);
42+
expect(target.expensiveCall('123456', 'cache')).toEqual(1);
43+
expect(target.expensiveCall('123456', 'cacher')).toEqual(1);
44+
45+
expect(callRecorder).toHaveBeenNthCalledWith(1, 'cache');
46+
expect(callRecorder).toHaveBeenNthCalledWith(2, 'cacher');
47+
expect(callRecorder).toHaveBeenCalledTimes(2);
48+
});
49+
50+
it('should call method if cached value is expired', async () => {
51+
class Test {
52+
constructor(private spy: jest.Mock) { }
53+
54+
@CacheKey(0)
55+
@Cache({ ttl: 5, unit: UnitOfTime.Millisecond })
56+
public expensiveCall(account: string): number {
57+
this.spy(account);
58+
return 1
59+
}
60+
}
61+
62+
const callRecorder = jest.fn();
63+
const target = new Test(callRecorder);
64+
65+
expect(target.expensiveCall('123456')).toEqual(1);
66+
expect(target.expensiveCall('123456')).toEqual(1);
67+
68+
await new Promise(resolve => setTimeout(resolve, 10));
69+
70+
expect(target.expensiveCall('123456')).toEqual(1);
71+
expect(target.expensiveCall('123456')).toEqual(1);
72+
73+
expect(callRecorder).toHaveBeenCalledWith('123456');
74+
expect(callRecorder).toHaveBeenCalledTimes(2);
75+
});
76+
77+
it('should not cache errors', async () => {
78+
class Test {
79+
constructor(private spy: jest.Mock) { }
80+
81+
@CacheKey(0)
82+
@Cache({ ttl: 5, unit: UnitOfTime.Millisecond })
83+
public expensiveCall(account: string): number {
84+
this.spy(account);
85+
throw new Error('Oops');
86+
}
87+
}
88+
89+
const callRecorder = jest.fn();
90+
const target = new Test(callRecorder);
91+
92+
expect(() => target.expensiveCall('123456')).toThrow('Oops');
93+
expect(() => target.expensiveCall('123456')).toThrow('Oops');
94+
95+
expect(callRecorder).toHaveBeenCalledWith('123456');
96+
expect(callRecorder).toHaveBeenCalledTimes(2);
97+
});
98+
});
99+
100+
describe('Async Method', () => {
101+
it('should cache successfull calls for a time', async () => {
102+
class Test {
103+
constructor(private spy: jest.Mock) { }
104+
105+
@CacheKey(1)
106+
@Cache({ ttl: 5, unit: UnitOfTime.Millisecond })
107+
public async expensiveCall(account: string, key: string): Promise<number> {
108+
this.spy(key);
109+
return 1
110+
}
111+
}
112+
113+
const callRecorder = jest.fn();
114+
const target = new Test(callRecorder);
115+
116+
await expect(target.expensiveCall('123456', 'cache')).resolves.toEqual(1);
117+
await expect(target.expensiveCall('123456', 'cache')).resolves.toEqual(1);
118+
await expect(target.expensiveCall('123456', 'cacher')).resolves.toEqual(1);
119+
120+
expect(callRecorder).toHaveBeenNthCalledWith(1, 'cache');
121+
expect(callRecorder).toHaveBeenNthCalledWith(2, 'cacher');
122+
expect(callRecorder).toHaveBeenCalledTimes(2);
123+
});
124+
125+
it('should call method if cached value is expired', async () => {
126+
class Test {
127+
constructor(private spy: jest.Mock) { }
128+
129+
@CacheKey(0)
130+
@Cache({ ttl: 5, unit: UnitOfTime.Millisecond })
131+
public async expensiveCall(account: string): Promise<number> {
132+
this.spy(account);
133+
return 1
134+
}
135+
}
136+
137+
const callRecorder = jest.fn();
138+
const target = new Test(callRecorder);
139+
140+
await expect(target.expensiveCall('123456')).resolves.toEqual(1);
141+
await expect(target.expensiveCall('123456')).resolves.toEqual(1);
142+
143+
await new Promise(resolve => setTimeout(resolve, 10));
144+
145+
await expect(target.expensiveCall('123456')).resolves.toEqual(1);
146+
await expect(target.expensiveCall('123456')).resolves.toEqual(1);
147+
148+
expect(callRecorder).toHaveBeenCalledWith('123456');
149+
expect(callRecorder).toHaveBeenCalledTimes(2);
150+
});
151+
152+
it('should not cache errors', async () => {
153+
class Test {
154+
constructor(private spy: jest.Mock) { }
155+
156+
@CacheKey(0)
157+
@Cache({ ttl: 5, unit: UnitOfTime.Millisecond })
158+
public async expensiveCall(account: string): Promise<number> {
159+
this.spy(account);
160+
throw new Error('Oops');
161+
}
162+
}
163+
164+
const callRecorder = jest.fn();
165+
const target = new Test(callRecorder);
166+
167+
await expect(target.expensiveCall('123456')).rejects.toThrow('Oops');
168+
await expect(target.expensiveCall('123456')).rejects.toThrow('Oops');
169+
170+
expect(callRecorder).toHaveBeenCalledWith('123456');
171+
expect(callRecorder).toHaveBeenCalledTimes(2);
172+
});
173+
});
174+
});
175+
176+
describe('Cache Key Decorator', () => {
177+
test('should inject a valid string parameter into call to @Cache', async () => {
178+
class Test {
179+
constructor(private spy: jest.Mock) { }
180+
181+
@CacheKey(0)
182+
public ಠ_ಠ(...args: unknown[]): string {
183+
this.spy(...args);
184+
return 'Hello'
185+
}
186+
}
187+
188+
const callRecorder = jest.fn();
189+
const target = new Test(callRecorder);
190+
const result = target.ಠ_ಠ('Hello');
191+
192+
expect(result).toEqual('Hello')
193+
194+
expect(callRecorder).toHaveBeenCalledWith({ [CACHE_KEY]: 'Hello' }, 'Hello');
195+
expect(callRecorder).toHaveBeenCalledTimes(1);
196+
});
197+
198+
test('should perform NO-OP if decorated target is not @Cache', async () => {
199+
class Test {
200+
constructor(private spy: jest.Mock) { }
201+
202+
@CacheKey(0)
203+
public notCache(args: string): string {
204+
this.spy(args);
205+
return 'Hello'
206+
}
207+
}
208+
209+
const callRecorder = jest.fn();
210+
const target = new Test(callRecorder);
211+
const result = target.notCache('Hello');
212+
213+
expect(result).toEqual('Hello')
214+
expect(callRecorder).toHaveBeenCalledWith('Hello');
215+
expect(callRecorder).toHaveBeenCalledTimes(1);
216+
});
217+
218+
test('should perform NO-OP if configured position is smaller than 0', async () => {
219+
class Test {
220+
constructor(private spy: jest.Mock) { }
221+
222+
@CacheKey(-1)
223+
public ಠ_ಠ(args: string): string {
224+
this.spy(args);
225+
return 'Hello'
226+
}
227+
}
228+
229+
const callRecorder = jest.fn();
230+
const target = new Test(callRecorder);
231+
const result = target.ಠ_ಠ('Hello');
232+
233+
expect(result).toEqual('Hello')
234+
expect(callRecorder).toHaveBeenCalledWith('Hello');
235+
expect(callRecorder).toHaveBeenCalledTimes(1);
236+
});
237+
238+
test('should perform NO-OP if configured position is larger than arguments', async () => {
239+
class Test {
240+
constructor(private spy: jest.Mock) { }
241+
242+
@CacheKey(2)
243+
public ಠ_ಠ(args: string): string {
244+
this.spy(args);
245+
return 'Hello'
246+
}
247+
}
248+
249+
const callRecorder = jest.fn();
250+
const target = new Test(callRecorder);
251+
const result = target.ಠ_ಠ('Hello');
252+
253+
expect(result).toEqual('Hello')
254+
expect(callRecorder).toHaveBeenCalledWith('Hello');
255+
expect(callRecorder).toHaveBeenCalledTimes(1);
256+
});
257+
258+
test('should perform NO-OP if configured postion belongs to none string argument', async () => {
259+
class Test {
260+
constructor(private spy: jest.Mock) { }
261+
262+
@CacheKey(0)
263+
public ಠ_ಠ(args: number): string {
264+
this.spy(args);
265+
return 'Hello'
266+
}
267+
}
268+
269+
const callRecorder = jest.fn();
270+
const target = new Test(callRecorder);
271+
const result = target.ಠ_ಠ(12);
272+
273+
expect(result).toEqual('Hello')
274+
expect(callRecorder).toHaveBeenCalledWith(12);
275+
expect(callRecorder).toHaveBeenCalledTimes(1);
276+
});
277+
});

__tests__/cache/memory.spec.ts

+117
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { InMemoryCache } from "../../lib/cache/memory.js";
2+
3+
describe('InMemoryCache', () => {
4+
describe('constructor', () => {
5+
it('should have a default max size of 50', async () => {
6+
const target = new InMemoryCache<number>();
7+
expect(target.maxRecord).toEqual(50);
8+
});
9+
10+
it('should use provided max size', async () => {
11+
const target = new InMemoryCache<number>(100);
12+
expect(target.maxRecord).toEqual(100);
13+
})
14+
});
15+
16+
describe('store', () => {
17+
18+
beforeAll(() => {
19+
jest.useFakeTimers({ now: 10000 });
20+
});
21+
22+
afterAll(() => {
23+
jest.useRealTimers();
24+
});
25+
26+
it('should store value with provided ttl', async () => {
27+
const target = new InMemoryCache<number>();
28+
29+
const storeRecorder = jest.spyOn((target as any).cache as Map<string, number>, 'set')
30+
31+
target.store('cache', 1337, 100);
32+
33+
expect(storeRecorder).toHaveBeenCalledWith('cache', { value: 1337, ttl: Date.now() + 100 });
34+
expect(storeRecorder).toHaveBeenCalledTimes(1);
35+
});
36+
37+
it('should store value with no ttl', async () => {
38+
const target = new InMemoryCache<number>();
39+
const storeRecorder = jest.spyOn((target as any).cache as Map<string, number>, 'set')
40+
41+
target.store('cache', 1337);
42+
43+
expect(storeRecorder).toHaveBeenCalledWith('cache', { value: 1337, ttl: -1 });
44+
expect(storeRecorder).toHaveBeenCalledTimes(1);
45+
});
46+
47+
it('should clean older values of limit is reached', async () => {
48+
const target = new InMemoryCache<number>(1);
49+
const storeRecorder = jest.spyOn((target as any).cache as Map<string, number>, 'set')
50+
const deleteRecorder = jest.spyOn((target as any).cache as Map<string, number>, 'delete')
51+
52+
target.store('cache', 1337);
53+
target.store('cacher', 1337);
54+
55+
expect(storeRecorder).toHaveBeenCalledWith('cache', { value: 1337, ttl: -1 });
56+
expect(storeRecorder).toHaveBeenCalledWith('cacher', { value: 1337, ttl: -1 });
57+
expect(storeRecorder).toHaveBeenCalledTimes(2);
58+
59+
expect(deleteRecorder).toHaveBeenCalledWith('cache');
60+
expect(deleteRecorder).toHaveBeenCalledTimes(1);
61+
});
62+
})
63+
64+
describe('has', () => {
65+
const target = new InMemoryCache<number>();
66+
67+
target.store('object_with_ttl', 1337, 13000);
68+
target.store('object_with_no_ttl', 1337, -1);
69+
target.store('object_expired', 1337, -100);
70+
71+
it('should return true if value is cached and has not expired yet', async () => {
72+
expect(target.has('object_with_ttl')).toBeTruthy();
73+
});
74+
75+
it('should return true if value is cached and never expires', async () => {
76+
expect(target.has('object_with_no_ttl')).toBeTruthy();
77+
})
78+
79+
it('should return false if value is not cached', async () => {
80+
expect(target.has('random_object')).toBeFalsy();
81+
});
82+
83+
it('should return false if value is cached but expired (also lazy delete expired value)', async () => {
84+
const deleteRecorder = jest.spyOn((target as any).cache as Map<string, number>, 'delete')
85+
expect(target.has('object_expired')).toBeFalsy();
86+
expect(deleteRecorder).toHaveBeenCalledWith('object_expired');
87+
expect(deleteRecorder).toHaveBeenCalledTimes(1);
88+
});
89+
});
90+
91+
describe('get', () => {
92+
const target = new InMemoryCache<number>();
93+
94+
target.store('object_with_ttl', 1337, 13000);
95+
target.store('object_with_no_ttl', 1337, -1);
96+
target.store('object_expired', 1337, -100);
97+
98+
it('should return the cached value if value is cached and has not expired yet', async () => {
99+
expect(target.get('object_with_ttl')).toEqual(1337);
100+
});
101+
102+
it('should return the cached value if value is cached and never expires', async () => {
103+
expect(target.get('object_with_no_ttl')).toEqual(1337);
104+
})
105+
106+
it('should return undefined if value is not cached', async () => {
107+
expect(target.get('random_object')).toBeUndefined();
108+
});
109+
110+
it('should return undefined if value is cached but expired (also lazy delete expired value)', async () => {
111+
const deleteRecorder = jest.spyOn((target as any).cache as Map<string, number>, 'delete')
112+
expect(target.get('object_expired')).toBeUndefined();
113+
expect(deleteRecorder).toHaveBeenCalledWith('object_expired');
114+
expect(deleteRecorder).toHaveBeenCalledTimes(1);
115+
});
116+
});
117+
});

0 commit comments

Comments
 (0)