Skip to content

Commit

Permalink
✨ Added Caching Decorator (with MemoryCache) (#10)
Browse files Browse the repository at this point in the history
* 💡 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
Templum authored Mar 22, 2024
1 parent 2d2a0ba commit d214150
Show file tree
Hide file tree
Showing 22 changed files with 1,074 additions and 305 deletions.
277 changes: 277 additions & 0 deletions __tests__/cache/cache.spec.ts
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);
});
});
117 changes: 117 additions & 0 deletions __tests__/cache/memory.spec.ts
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);
});
});
});
Loading

0 comments on commit d214150

Please sign in to comment.