-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
✨ 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
- Loading branch information
Showing
22 changed files
with
1,074 additions
and
305 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,277 @@ | ||
import { CACHE_KEY, Cache, CacheKey } from "../../lib/cache/cache.js"; | ||
import { UnitOfTime } from "../../lib/util/types.js"; | ||
|
||
describe('Cache Decorator', () => { | ||
it('should perform NO-OP if CacheKey has not injected a value', async () => { | ||
class Test { | ||
constructor(private spy: jest.Mock) { } | ||
|
||
@Cache() | ||
public missesOutterDecorator(): number { | ||
this.spy(); | ||
return 1 | ||
} | ||
} | ||
|
||
const callRecorder = jest.fn(); | ||
const target = new Test(callRecorder); | ||
|
||
expect(target.missesOutterDecorator()).toEqual(1); | ||
expect(target.missesOutterDecorator()).toEqual(1); | ||
|
||
expect(callRecorder).toHaveBeenCalledTimes(2); | ||
}); | ||
|
||
describe('Sync Method', () => { | ||
it('should cache successfull calls for a time', async () => { | ||
class Test { | ||
constructor(private spy: jest.Mock) { } | ||
|
||
@CacheKey(1) | ||
@Cache({ ttl: 5, unit: UnitOfTime.Millisecond }) | ||
public expensiveCall(account: string, key: string): number { | ||
this.spy(key); | ||
return 1 | ||
} | ||
} | ||
|
||
const callRecorder = jest.fn(); | ||
const target = new Test(callRecorder); | ||
|
||
expect(target.expensiveCall('123456', 'cache')).toEqual(1); | ||
expect(target.expensiveCall('123456', 'cache')).toEqual(1); | ||
expect(target.expensiveCall('123456', 'cacher')).toEqual(1); | ||
|
||
expect(callRecorder).toHaveBeenNthCalledWith(1, 'cache'); | ||
expect(callRecorder).toHaveBeenNthCalledWith(2, 'cacher'); | ||
expect(callRecorder).toHaveBeenCalledTimes(2); | ||
}); | ||
|
||
it('should call method if cached value is expired', async () => { | ||
class Test { | ||
constructor(private spy: jest.Mock) { } | ||
|
||
@CacheKey(0) | ||
@Cache({ ttl: 5, unit: UnitOfTime.Millisecond }) | ||
public expensiveCall(account: string): number { | ||
this.spy(account); | ||
return 1 | ||
} | ||
} | ||
|
||
const callRecorder = jest.fn(); | ||
const target = new Test(callRecorder); | ||
|
||
expect(target.expensiveCall('123456')).toEqual(1); | ||
expect(target.expensiveCall('123456')).toEqual(1); | ||
|
||
await new Promise(resolve => setTimeout(resolve, 10)); | ||
|
||
expect(target.expensiveCall('123456')).toEqual(1); | ||
expect(target.expensiveCall('123456')).toEqual(1); | ||
|
||
expect(callRecorder).toHaveBeenCalledWith('123456'); | ||
expect(callRecorder).toHaveBeenCalledTimes(2); | ||
}); | ||
|
||
it('should not cache errors', async () => { | ||
class Test { | ||
constructor(private spy: jest.Mock) { } | ||
|
||
@CacheKey(0) | ||
@Cache({ ttl: 5, unit: UnitOfTime.Millisecond }) | ||
public expensiveCall(account: string): number { | ||
this.spy(account); | ||
throw new Error('Oops'); | ||
} | ||
} | ||
|
||
const callRecorder = jest.fn(); | ||
const target = new Test(callRecorder); | ||
|
||
expect(() => target.expensiveCall('123456')).toThrow('Oops'); | ||
expect(() => target.expensiveCall('123456')).toThrow('Oops'); | ||
|
||
expect(callRecorder).toHaveBeenCalledWith('123456'); | ||
expect(callRecorder).toHaveBeenCalledTimes(2); | ||
}); | ||
}); | ||
|
||
describe('Async Method', () => { | ||
it('should cache successfull calls for a time', async () => { | ||
class Test { | ||
constructor(private spy: jest.Mock) { } | ||
|
||
@CacheKey(1) | ||
@Cache({ ttl: 5, unit: UnitOfTime.Millisecond }) | ||
public async expensiveCall(account: string, key: string): Promise<number> { | ||
this.spy(key); | ||
return 1 | ||
} | ||
} | ||
|
||
const callRecorder = jest.fn(); | ||
const target = new Test(callRecorder); | ||
|
||
await expect(target.expensiveCall('123456', 'cache')).resolves.toEqual(1); | ||
await expect(target.expensiveCall('123456', 'cache')).resolves.toEqual(1); | ||
await expect(target.expensiveCall('123456', 'cacher')).resolves.toEqual(1); | ||
|
||
expect(callRecorder).toHaveBeenNthCalledWith(1, 'cache'); | ||
expect(callRecorder).toHaveBeenNthCalledWith(2, 'cacher'); | ||
expect(callRecorder).toHaveBeenCalledTimes(2); | ||
}); | ||
|
||
it('should call method if cached value is expired', async () => { | ||
class Test { | ||
constructor(private spy: jest.Mock) { } | ||
|
||
@CacheKey(0) | ||
@Cache({ ttl: 5, unit: UnitOfTime.Millisecond }) | ||
public async expensiveCall(account: string): Promise<number> { | ||
this.spy(account); | ||
return 1 | ||
} | ||
} | ||
|
||
const callRecorder = jest.fn(); | ||
const target = new Test(callRecorder); | ||
|
||
await expect(target.expensiveCall('123456')).resolves.toEqual(1); | ||
await expect(target.expensiveCall('123456')).resolves.toEqual(1); | ||
|
||
await new Promise(resolve => setTimeout(resolve, 10)); | ||
|
||
await expect(target.expensiveCall('123456')).resolves.toEqual(1); | ||
await expect(target.expensiveCall('123456')).resolves.toEqual(1); | ||
|
||
expect(callRecorder).toHaveBeenCalledWith('123456'); | ||
expect(callRecorder).toHaveBeenCalledTimes(2); | ||
}); | ||
|
||
it('should not cache errors', async () => { | ||
class Test { | ||
constructor(private spy: jest.Mock) { } | ||
|
||
@CacheKey(0) | ||
@Cache({ ttl: 5, unit: UnitOfTime.Millisecond }) | ||
public async expensiveCall(account: string): Promise<number> { | ||
this.spy(account); | ||
throw new Error('Oops'); | ||
} | ||
} | ||
|
||
const callRecorder = jest.fn(); | ||
const target = new Test(callRecorder); | ||
|
||
await expect(target.expensiveCall('123456')).rejects.toThrow('Oops'); | ||
await expect(target.expensiveCall('123456')).rejects.toThrow('Oops'); | ||
|
||
expect(callRecorder).toHaveBeenCalledWith('123456'); | ||
expect(callRecorder).toHaveBeenCalledTimes(2); | ||
}); | ||
}); | ||
}); | ||
|
||
describe('Cache Key Decorator', () => { | ||
test('should inject a valid string parameter into call to @Cache', async () => { | ||
class Test { | ||
constructor(private spy: jest.Mock) { } | ||
|
||
@CacheKey(0) | ||
public ಠ_ಠ(...args: unknown[]): string { | ||
this.spy(...args); | ||
return 'Hello' | ||
} | ||
} | ||
|
||
const callRecorder = jest.fn(); | ||
const target = new Test(callRecorder); | ||
const result = target.ಠ_ಠ('Hello'); | ||
|
||
expect(result).toEqual('Hello') | ||
|
||
expect(callRecorder).toHaveBeenCalledWith({ [CACHE_KEY]: 'Hello' }, 'Hello'); | ||
expect(callRecorder).toHaveBeenCalledTimes(1); | ||
}); | ||
|
||
test('should perform NO-OP if decorated target is not @Cache', async () => { | ||
class Test { | ||
constructor(private spy: jest.Mock) { } | ||
|
||
@CacheKey(0) | ||
public notCache(args: string): string { | ||
this.spy(args); | ||
return 'Hello' | ||
} | ||
} | ||
|
||
const callRecorder = jest.fn(); | ||
const target = new Test(callRecorder); | ||
const result = target.notCache('Hello'); | ||
|
||
expect(result).toEqual('Hello') | ||
expect(callRecorder).toHaveBeenCalledWith('Hello'); | ||
expect(callRecorder).toHaveBeenCalledTimes(1); | ||
}); | ||
|
||
test('should perform NO-OP if configured position is smaller than 0', async () => { | ||
class Test { | ||
constructor(private spy: jest.Mock) { } | ||
|
||
@CacheKey(-1) | ||
public ಠ_ಠ(args: string): string { | ||
this.spy(args); | ||
return 'Hello' | ||
} | ||
} | ||
|
||
const callRecorder = jest.fn(); | ||
const target = new Test(callRecorder); | ||
const result = target.ಠ_ಠ('Hello'); | ||
|
||
expect(result).toEqual('Hello') | ||
expect(callRecorder).toHaveBeenCalledWith('Hello'); | ||
expect(callRecorder).toHaveBeenCalledTimes(1); | ||
}); | ||
|
||
test('should perform NO-OP if configured position is larger than arguments', async () => { | ||
class Test { | ||
constructor(private spy: jest.Mock) { } | ||
|
||
@CacheKey(2) | ||
public ಠ_ಠ(args: string): string { | ||
this.spy(args); | ||
return 'Hello' | ||
} | ||
} | ||
|
||
const callRecorder = jest.fn(); | ||
const target = new Test(callRecorder); | ||
const result = target.ಠ_ಠ('Hello'); | ||
|
||
expect(result).toEqual('Hello') | ||
expect(callRecorder).toHaveBeenCalledWith('Hello'); | ||
expect(callRecorder).toHaveBeenCalledTimes(1); | ||
}); | ||
|
||
test('should perform NO-OP if configured postion belongs to none string argument', async () => { | ||
class Test { | ||
constructor(private spy: jest.Mock) { } | ||
|
||
@CacheKey(0) | ||
public ಠ_ಠ(args: number): string { | ||
this.spy(args); | ||
return 'Hello' | ||
} | ||
} | ||
|
||
const callRecorder = jest.fn(); | ||
const target = new Test(callRecorder); | ||
const result = target.ಠ_ಠ(12); | ||
|
||
expect(result).toEqual('Hello') | ||
expect(callRecorder).toHaveBeenCalledWith(12); | ||
expect(callRecorder).toHaveBeenCalledTimes(1); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,117 @@ | ||
import { InMemoryCache } from "../../lib/cache/memory.js"; | ||
|
||
describe('InMemoryCache', () => { | ||
describe('constructor', () => { | ||
it('should have a default max size of 50', async () => { | ||
const target = new InMemoryCache<number>(); | ||
expect(target.maxRecord).toEqual(50); | ||
}); | ||
|
||
it('should use provided max size', async () => { | ||
const target = new InMemoryCache<number>(100); | ||
expect(target.maxRecord).toEqual(100); | ||
}) | ||
}); | ||
|
||
describe('store', () => { | ||
|
||
beforeAll(() => { | ||
jest.useFakeTimers({ now: 10000 }); | ||
}); | ||
|
||
afterAll(() => { | ||
jest.useRealTimers(); | ||
}); | ||
|
||
it('should store value with provided ttl', async () => { | ||
const target = new InMemoryCache<number>(); | ||
|
||
const storeRecorder = jest.spyOn((target as any).cache as Map<string, number>, 'set') | ||
|
||
target.store('cache', 1337, 100); | ||
|
||
expect(storeRecorder).toHaveBeenCalledWith('cache', { value: 1337, ttl: Date.now() + 100 }); | ||
expect(storeRecorder).toHaveBeenCalledTimes(1); | ||
}); | ||
|
||
it('should store value with no ttl', async () => { | ||
const target = new InMemoryCache<number>(); | ||
const storeRecorder = jest.spyOn((target as any).cache as Map<string, number>, 'set') | ||
|
||
target.store('cache', 1337); | ||
|
||
expect(storeRecorder).toHaveBeenCalledWith('cache', { value: 1337, ttl: -1 }); | ||
expect(storeRecorder).toHaveBeenCalledTimes(1); | ||
}); | ||
|
||
it('should clean older values of limit is reached', async () => { | ||
const target = new InMemoryCache<number>(1); | ||
const storeRecorder = jest.spyOn((target as any).cache as Map<string, number>, 'set') | ||
const deleteRecorder = jest.spyOn((target as any).cache as Map<string, number>, 'delete') | ||
|
||
target.store('cache', 1337); | ||
target.store('cacher', 1337); | ||
|
||
expect(storeRecorder).toHaveBeenCalledWith('cache', { value: 1337, ttl: -1 }); | ||
expect(storeRecorder).toHaveBeenCalledWith('cacher', { value: 1337, ttl: -1 }); | ||
expect(storeRecorder).toHaveBeenCalledTimes(2); | ||
|
||
expect(deleteRecorder).toHaveBeenCalledWith('cache'); | ||
expect(deleteRecorder).toHaveBeenCalledTimes(1); | ||
}); | ||
}) | ||
|
||
describe('has', () => { | ||
const target = new InMemoryCache<number>(); | ||
|
||
target.store('object_with_ttl', 1337, 13000); | ||
target.store('object_with_no_ttl', 1337, -1); | ||
target.store('object_expired', 1337, -100); | ||
|
||
it('should return true if value is cached and has not expired yet', async () => { | ||
expect(target.has('object_with_ttl')).toBeTruthy(); | ||
}); | ||
|
||
it('should return true if value is cached and never expires', async () => { | ||
expect(target.has('object_with_no_ttl')).toBeTruthy(); | ||
}) | ||
|
||
it('should return false if value is not cached', async () => { | ||
expect(target.has('random_object')).toBeFalsy(); | ||
}); | ||
|
||
it('should return false if value is cached but expired (also lazy delete expired value)', async () => { | ||
const deleteRecorder = jest.spyOn((target as any).cache as Map<string, number>, 'delete') | ||
expect(target.has('object_expired')).toBeFalsy(); | ||
expect(deleteRecorder).toHaveBeenCalledWith('object_expired'); | ||
expect(deleteRecorder).toHaveBeenCalledTimes(1); | ||
}); | ||
}); | ||
|
||
describe('get', () => { | ||
const target = new InMemoryCache<number>(); | ||
|
||
target.store('object_with_ttl', 1337, 13000); | ||
target.store('object_with_no_ttl', 1337, -1); | ||
target.store('object_expired', 1337, -100); | ||
|
||
it('should return the cached value if value is cached and has not expired yet', async () => { | ||
expect(target.get('object_with_ttl')).toEqual(1337); | ||
}); | ||
|
||
it('should return the cached value if value is cached and never expires', async () => { | ||
expect(target.get('object_with_no_ttl')).toEqual(1337); | ||
}) | ||
|
||
it('should return undefined if value is not cached', async () => { | ||
expect(target.get('random_object')).toBeUndefined(); | ||
}); | ||
|
||
it('should return undefined if value is cached but expired (also lazy delete expired value)', async () => { | ||
const deleteRecorder = jest.spyOn((target as any).cache as Map<string, number>, 'delete') | ||
expect(target.get('object_expired')).toBeUndefined(); | ||
expect(deleteRecorder).toHaveBeenCalledWith('object_expired'); | ||
expect(deleteRecorder).toHaveBeenCalledTimes(1); | ||
}); | ||
}); | ||
}); |
Oops, something went wrong.