diff --git a/examples/cache.ts b/examples/cache.ts new file mode 100644 index 0000000..58bbbf9 --- /dev/null +++ b/examples/cache.ts @@ -0,0 +1,18 @@ +import { cache } from '../lib/cache/'; + +class Service { + + @cache(1000, { scope: 'class' }) + public asyncMethod(n: number): Promise { + return new Promise((resolve) => { + setTimeout(() => resolve(n ** 2), 10); + }); + } + +} + +const service = new Service(); + +service.asyncMethod(3).then(res => console.log(res)); // no cache +service.asyncMethod(3).then(res => console.log(res)); // from cache +service.asyncMethod(4).then(res => console.log(res)); // no cache because another argument diff --git a/lib/cache.ts b/lib/cache/CacheOptions.ts similarity index 65% rename from lib/cache.ts rename to lib/cache/CacheOptions.ts index 7c7958a..43a33dc 100644 --- a/lib/cache.ts +++ b/lib/cache/CacheOptions.ts @@ -4,13 +4,12 @@ export type CacheOptions = { * `absolute` (default) strategy expires after the the set amount of time. * `sliding` strategy expires after the method hasn't been called in a set amount of time. */ - expiration: 'absolute' | 'sliding', // TODO: allow custom policy to be injected + expiration?: 'absolute' | 'sliding', // TODO: allow custom policy to be injected /** * The cache scope. * The `class` (default) scope defines a single method scope for all class instances. * The `instance` scope defines a per-instance method scope. - * The hash key is calculated using `object-hash` of current arguments list. */ scope?: 'class' | 'instance', @@ -27,11 +26,9 @@ export type CacheOptions = { size?: number, }; -/** - * Caches the result of a method. - * @param timeout cache timeout in milliseconds. - * @param options (optional) caching options. - */ -export function cache(timeout: number, options?: CacheOptions) { - throw new Error('Not implemented.'); -} +export const DEFAULT_OPTIONS: CacheOptions = { + expiration: 'absolute', + scope: 'class', + storage: 'memory', + size: null, +}; diff --git a/lib/cache/cacheProvider/CacheProvider.ts b/lib/cache/cacheProvider/CacheProvider.ts new file mode 100644 index 0000000..dbd5c6b --- /dev/null +++ b/lib/cache/cacheProvider/CacheProvider.ts @@ -0,0 +1,6 @@ +import { ClassType } from '../../interfaces/class'; +import { Cache } from '../caches/Cache'; + +export interface CacheProvider { + get(instance: ClassType): Cache; +} diff --git a/lib/cache/cacheProvider/ClassCacheProvider.ts b/lib/cache/cacheProvider/ClassCacheProvider.ts new file mode 100644 index 0000000..0d36066 --- /dev/null +++ b/lib/cache/cacheProvider/ClassCacheProvider.ts @@ -0,0 +1,19 @@ +import { Cache } from '../caches/Cache'; +import { CacheFactory } from '../caches/factory'; +import { CacheProvider } from './CacheProvider'; + +export class ClassCacheProvider implements CacheProvider { + + private cache: Cache = null; + + constructor(private readonly cacheFactory: CacheFactory) { } + + public get(): Cache { + if (!this.cache) { + this.cache = this.cacheFactory.create(); + } + + return this.cache; + } + +} diff --git a/lib/cache/cacheProvider/InstanceCacheProvider.ts b/lib/cache/cacheProvider/InstanceCacheProvider.ts new file mode 100644 index 0000000..6333bb0 --- /dev/null +++ b/lib/cache/cacheProvider/InstanceCacheProvider.ts @@ -0,0 +1,22 @@ +import { ClassType } from '../../interfaces/class'; +import { Cache } from '../caches/Cache'; +import { CacheFactory } from '../caches/factory'; +import { CacheProvider } from './CacheProvider'; + +export class InstanceCacheProvider implements CacheProvider { + + private instanceCaches = new WeakMap>(); + + constructor(private readonly cacheFactory: CacheFactory) { } + + public get(instance: ClassType): Cache { + const hasCache = this.instanceCaches.has(instance); + if (!hasCache) { + const cache = this.cacheFactory.create(); + this.instanceCaches.set(instance, cache); + } + + return this.instanceCaches.get(instance); + } + +} diff --git a/lib/cache/cacheProvider/factory.ts b/lib/cache/cacheProvider/factory.ts new file mode 100644 index 0000000..3df1405 --- /dev/null +++ b/lib/cache/cacheProvider/factory.ts @@ -0,0 +1,26 @@ +import { Factory } from '../../interfaces/factory'; +import { CacheFactory } from '../caches/factory'; +import { CacheProvider } from './CacheProvider'; +import { ClassCacheProvider } from './ClassCacheProvider'; +import { InstanceCacheProvider } from './InstanceCacheProvider'; + +export class CacheProviderFactory implements Factory { + + constructor( + private readonly cacheFactory: CacheFactory, + ) { } + + public create(scope: 'class' | 'instance') { + switch (scope) { + case 'class': + return new ClassCacheProvider(this.cacheFactory); + + case 'instance': + return new InstanceCacheProvider(this.cacheFactory); + + default: + throw new Error(`@cache invalid scope option: ${scope}.`); + } + } + +} diff --git a/lib/cache/caches/Cache.ts b/lib/cache/caches/Cache.ts new file mode 100644 index 0000000..ccf3d81 --- /dev/null +++ b/lib/cache/caches/Cache.ts @@ -0,0 +1,36 @@ +import { Expiration } from '../expirations/Expiration'; +import { HashService } from '../../utils/hash'; +import { Storage } from '../storages/Storage'; + +export class Cache { + + constructor( + private readonly storage: Storage, + private readonly expiration: Expiration, + private readonly hash: HashService, + ) { } + + public async set(key: K, value: V): Promise { + const keyHash = this.hash.calculate(key); + + await this.storage.set(keyHash, value); + this.expiration.add(keyHash, key => this.delete(key)); + } + + public async get(key: K): Promise { + const keyHash = this.hash.calculate(key); + + this.expiration.add(keyHash, key => this.delete(key)); + return this.storage.get(keyHash); + } + + public async has(key: K): Promise { + const keyHash = this.hash.calculate(key); + return this.storage.has(keyHash); + } + + private async delete(key: string): Promise { + await this.storage.delete(key); + } + +} diff --git a/lib/cache/caches/factory.ts b/lib/cache/caches/factory.ts new file mode 100644 index 0000000..815f82b --- /dev/null +++ b/lib/cache/caches/factory.ts @@ -0,0 +1,24 @@ +import { Factory } from '../../interfaces/factory'; +import { HashService } from '../../utils/hash'; +import { ExpirationFactory } from '../expirations/factory'; +import { StorageFactory } from '../storages/factory'; +import { Cache } from './Cache'; + +export class CacheFactory implements Factory> { + + constructor( + private readonly hash: HashService, + private readonly expirationFactory: ExpirationFactory, + private readonly storageFactory: StorageFactory, + private readonly expiration: 'absolute' | 'sliding', + private readonly storage: 'memory', + ) { } + + public create(): Cache { + const storage = this.storageFactory.create(this.storage); + const expiration = this.expirationFactory.create(this.expiration); + + return new Cache(storage, expiration, this.hash); + } + +} diff --git a/lib/cache/expirations/AbsoluteExpiration.ts b/lib/cache/expirations/AbsoluteExpiration.ts new file mode 100644 index 0000000..2a4ff26 --- /dev/null +++ b/lib/cache/expirations/AbsoluteExpiration.ts @@ -0,0 +1,25 @@ +import { Expiration } from './Expiration'; + +export class AbsoluteExpiration implements Expiration { + + private readonly expirations = new Set(); + + constructor( + private readonly timeout: number, + ) { } + + public add(key: string, clearCallback: (key: string) => unknown): void { + if (this.expirations.has(key)) { + return; + } + + this.expirations.add(key); + setTimeout(() => this.clear(key, clearCallback), this.timeout); + } + + private clear(key: string, clearCallback: (key: string) => unknown): void { + this.expirations.delete(key); + clearCallback(key); + } + +} diff --git a/lib/cache/expirations/Expiration.ts b/lib/cache/expirations/Expiration.ts new file mode 100644 index 0000000..9342232 --- /dev/null +++ b/lib/cache/expirations/Expiration.ts @@ -0,0 +1,3 @@ +export interface Expiration { + add(key: string, clearCallback: (key: string) => unknown): void; +} diff --git a/lib/cache/expirations/SlidingExpiration.ts b/lib/cache/expirations/SlidingExpiration.ts new file mode 100644 index 0000000..9f944d6 --- /dev/null +++ b/lib/cache/expirations/SlidingExpiration.ts @@ -0,0 +1,39 @@ +import { Expiration } from './Expiration'; + +export class SlidingExpiration implements Expiration { + + private readonly expirations = new Map(); + + constructor( + private readonly timeout: number, + ) { } + + public add(key: string, clearCallback: (key: string) => unknown): void { + if (this.expirations.has(key)) { + this.update(key, clearCallback); + } else { + this.addKey(key, clearCallback); + } + } + + private addKey(key: string, clearCallback: (key: string) => unknown): void { + const timeoutId = setTimeout( + () => { + this.expirations.delete(key); + clearCallback(key); + }, + this.timeout, + ); + + this.expirations.set(key, timeoutId as any); + } + + private update(key: string, clearCallback: (key: string) => unknown): void { + const timeoutId = this.expirations.get(key); + clearTimeout(timeoutId as any); + + this.expirations.delete(key); + this.addKey(key, clearCallback); + } + +} diff --git a/lib/cache/expirations/factory.ts b/lib/cache/expirations/factory.ts new file mode 100644 index 0000000..d0a3ad1 --- /dev/null +++ b/lib/cache/expirations/factory.ts @@ -0,0 +1,25 @@ +import { Factory } from '../../interfaces/factory'; +import { AbsoluteExpiration } from './AbsoluteExpiration'; +import { Expiration } from './Expiration'; +import { SlidingExpiration } from './SlidingExpiration'; + +export class ExpirationFactory implements Factory { + + constructor( + private readonly timeout: number, + ) { } + + public create(expiration: 'absolute' | 'sliding'): Expiration { + switch (expiration) { + case 'absolute': + return new AbsoluteExpiration(this.timeout); + + case 'sliding': + return new SlidingExpiration(this.timeout); + + default: + throw new Error(`@cache Expiration type is not supported: ${expiration}.`); + } + } + +} diff --git a/lib/cache/index.ts b/lib/cache/index.ts new file mode 100644 index 0000000..8bd8e83 --- /dev/null +++ b/lib/cache/index.ts @@ -0,0 +1,80 @@ +import { CacheOptions, DEFAULT_OPTIONS } from './CacheOptions'; +import { ExpirationFactory } from './expirations/factory'; +import { StorageFactory } from './storages/factory'; +import { CacheFactory } from './caches/factory'; +import { HashService } from '../utils/hash'; +import { CacheProviderFactory } from './cacheProvider/factory'; +import { CacheProvider } from './cacheProvider/CacheProvider'; + +export { CacheOptions }; + +type TimeoutCacheOptions = CacheOptions & { timeout: number; }; + +/** + * Caches the result of a method. + * @param timeout cache timeout in milliseconds. + * @param options (optional) caching options. + */ +export function cache(timeout: number): MethodDecorator; +export function cache(options: TimeoutCacheOptions): MethodDecorator; +export function cache(timeout: number, options?: CacheOptions): MethodDecorator; +export function cache( + timeoutOrOptions: number | TimeoutCacheOptions, + optionsOrVoid: CacheOptions = DEFAULT_OPTIONS, +): MethodDecorator { + + const { timeout, options } = parseParameters(timeoutOrOptions, optionsOrVoid); + const cacheProvider = createCacheProvider(timeout, options); + + return function (_: any, __: any, descriptor: PropertyDescriptor) { + const method = descriptor.value; + + descriptor.value = async function (...args: any[]) { + const cacheService = cacheProvider.get(this); + + const isCached = await cacheService.has(args); + if (isCached) { + return cacheService.get(args); + } + + const value = await method(...args); + cacheService.set(args, value); + return value; + }; + + return descriptor; + }; +} + +function parseParameters( + timeoutOrOptions: number | TimeoutCacheOptions, + optionsOrVoid: CacheOptions, +) { + + if (typeof timeoutOrOptions === 'number') { + return { + timeout: timeoutOrOptions, + options: { ...DEFAULT_OPTIONS, ...optionsOrVoid }, + }; + } + + return { + timeout: timeoutOrOptions.timeout, + options: { ...DEFAULT_OPTIONS, ...timeoutOrOptions }, + }; +} + +function createCacheProvider( + timeout: number, + { expiration, scope, size, storage }: CacheOptions, +): CacheProvider { + + const hashService = new HashService(); + const expirationFactory = new ExpirationFactory(timeout); + const storageFactory = new StorageFactory(size); + + const cacheFactory = + new CacheFactory(hashService, expirationFactory, storageFactory, expiration, storage); + + return new CacheProviderFactory(cacheFactory).create(scope); +} diff --git a/lib/cache/storages/MemoryStorage.ts b/lib/cache/storages/MemoryStorage.ts new file mode 100644 index 0000000..c4adf18 --- /dev/null +++ b/lib/cache/storages/MemoryStorage.ts @@ -0,0 +1,37 @@ +import { Storage } from './Storage'; + +export class MemoryStorage implements Storage { + + private readonly storage = new Map(); + + constructor(private readonly limit: number = Infinity) { } + + public async set(key: string, value: V): Promise { + this.checkSize(); + + this.storage.set(key, value); + + return this; + } + + public async get(key: string): Promise { + return this.storage.get(key); + } + + public async has(key: string): Promise { + return this.storage.has(key); + } + + public async delete(key: string): Promise { + this.storage.delete(key); + + return this; + } + + private checkSize(): void { + if (this.storage.size >= this.limit) { + this.delete(this.storage.keys().next().value); + } + } + +} diff --git a/lib/cache/storages/Storage.ts b/lib/cache/storages/Storage.ts new file mode 100644 index 0000000..a06fc8e --- /dev/null +++ b/lib/cache/storages/Storage.ts @@ -0,0 +1,6 @@ +export interface Storage { + set(key: string, value: V): Promise; + get(key: string): Promise; + has(key: string): Promise; + delete(key: string): Promise; +} diff --git a/lib/cache/storages/factory.ts b/lib/cache/storages/factory.ts new file mode 100644 index 0000000..2caa02b --- /dev/null +++ b/lib/cache/storages/factory.ts @@ -0,0 +1,21 @@ +import { Factory } from '../../interfaces/factory'; +import { MemoryStorage } from './MemoryStorage'; +import { Storage } from './Storage'; + +export class StorageFactory implements Factory { + + constructor( + private readonly limit: number, + ) { } + + public create(storage: 'memory'): Storage { + switch (storage) { + case 'memory': + return new MemoryStorage(this.limit); + + default: + throw new Error(`@cache Storage type is not supported: ${storage}.`); + } + } + +} diff --git a/lib/interfaces/class.ts b/lib/interfaces/class.ts new file mode 100644 index 0000000..baa0161 --- /dev/null +++ b/lib/interfaces/class.ts @@ -0,0 +1,3 @@ +export type ClassType = { + new(...args: any[]): T; +}; diff --git a/lib/interfaces/factory.ts b/lib/interfaces/factory.ts new file mode 100644 index 0000000..b23d485 --- /dev/null +++ b/lib/interfaces/factory.ts @@ -0,0 +1,3 @@ +export interface Factory { + create(...args: A): T; +} diff --git a/lib/utils/hash/index.ts b/lib/utils/hash/index.ts new file mode 100644 index 0000000..9605660 --- /dev/null +++ b/lib/utils/hash/index.ts @@ -0,0 +1,9 @@ +import * as hash from 'object-hash'; + +export class HashService { + + public calculate(value: any): string { + return hash(value); + } + +} diff --git a/package.json b/package.json index fb711ae..644eff4 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@types/chai": "4.1.7", "@types/chai-as-promised": "7.1.0", "@types/mocha": "5.2.5", + "@types/object-hash": "1.3.0", "@types/sinon": "7.0.4", "chai": "4.2.0", "chai-as-promised": "7.1.1", diff --git a/test/cache/cache.spec.ts b/test/cache/cache.spec.ts new file mode 100644 index 0000000..8d0b31c --- /dev/null +++ b/test/cache/cache.spec.ts @@ -0,0 +1,349 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { cache, CacheOptions } from '../../lib'; +import { delay } from '../utils'; + +describe('@cache', () => { + + const delayTime = 2; + const timeout = 6; + let methodStub: sinon.SinonStub<[], unknown>; + + beforeEach(() => methodStub = sinon.stub()); + + const factory = (timeout: number, options?: CacheOptions, methodSpy = methodStub) => { + class Test { + + @cache(timeout, options) + public async method(n: number): Promise { + methodSpy(); + await delay(delayTime); + return n + 1; + } + + } + + return Test; + }; + + it('should use cached value if method is called with same arguments', async () => { + const instance = new (factory(timeout)); + + await instance.method(42); + methodStub.reset(); + + await instance.method(42); + + expect(methodStub.called).to.be.false; + }); + + it('should not use cached value if method is called with different arguments', async () => { + const instance = new (factory(timeout)); + + await instance.method(42); + methodStub.reset(); + + await instance.method(24); + + expect(methodStub.calledOnce).to.be.true; + }); + + it('should propagate error if method reject', () => { + const errorMessage = 'any error'; + class Test { + + @cache({ timeout: 100, scope: 'instance' }) + public async method() { + throw new Error(errorMessage); + } + } + + const instance = new Test(); + expect(instance.method()).to.be.rejectedWith(errorMessage); + }); + + describe('options values', () => { + + it('should work without options', () => { + expect(() => new (factory(timeout))().method(42)).to.not.throw(); + }); + + it('should work with correct options', () => { + const options: CacheOptions = { + scope: 'instance', + expiration: 'sliding', + storage: 'memory', + size: 300, + }; + + expect(() => new (factory(timeout, options))().method(42)).to.not.throw(); + }); + + it('should work with one parameter', () => { + const test = () => { + class Test { + + @cache({ timeout: 100, scope: 'instance' }) + public async method() { + return 42; + } + } + + return Test; + }; + + expect(() => new (test())().method()).not.to.throw(); + }); + + describe('should throw for wrong options', () => { + + it('should throw if expiration is not a valid value', () => { + const options: CacheOptions = { expiration: 'abc' as any }; + const instance = new (factory(timeout, options)); + + const expectedError = '@cache Expiration type is not supported: abc.'; + expect(instance.method(42)).to.be.rejectedWith(expectedError); + }); + + it('should throw if scope is not a valid value', () => { + const options: CacheOptions = { scope: 'xyz' as any }; + + const expectedError = '@cache invalid scope option: xyz.'; + expect(() => new (factory(timeout, options))().method(42)).to.throw(expectedError); + }); + + it('should throw if storage is not a valid value', () => { + const options: CacheOptions = { storage: 'qwe' as any }; + const instance = new (factory(timeout, options)); + + const expectedError = '@cache Storage type is not supported: qwe.'; + expect(instance.method(42)).to.be.rejectedWith(expectedError); + }); + + }); + + }); + + describe("it shouldn't change method behaivor", () => { + + it('should return same value as without decorator', async () => { + const instance = new (factory(timeout)); + + expect(await instance.method(42)).to.equals(43); + }); + + describe('result should be same at multiple calls', () => { + + it('for async methods', async () => { + const instance = new (factory(1000)); + const promises = Array.from({ length: 10 }, () => instance.method(42)); + const values = await Promise.all(promises); + + expect(new Set(values).size).to.equals(1); + }); + + }); + + }); + + describe('options behaivor', () => { + + describe('expiration', () => { + + describe('absolute', () => { + + const options: CacheOptions = { expiration: 'absolute' }; + let instance: InstanceType>; + + beforeEach(() => instance = new (factory(timeout, options))); + + it('should return cached value', async () => { + await instance.method(42); + + methodStub.reset(); + await instance.method(42); + + expect(methodStub.called).to.be.false; + }); + + it('should expire after given timeout', async () => { + await instance.method(42); + methodStub.reset(); + + await delay(timeout * 2); + + await instance.method(42); + + expect(methodStub.calledOnce).to.be.true; + }); + + it('should not refresh if was call before expire', async () => { + await instance.method(42); + + await delay(timeout / 2); + await instance.method(42); + methodStub.reset(); + await delay(timeout / 2); + + await instance.method(42); + + expect(methodStub.calledOnce).to.be.true; + }); + + }); + + describe('sliding', () => { + + const options: CacheOptions = { expiration: 'sliding' }; + let instance: InstanceType>; + + beforeEach(() => instance = new (factory(timeout, options))); + + it('should return cached value', async () => { + await instance.method(42); + methodStub.reset(); + + await instance.method(42); + + expect(methodStub.called).to.be.false; + }); + + it('should expire after given timeout', async () => { + await instance.method(42); + methodStub.reset(); + + await delay(timeout * 2); + + await instance.method(42); + expect(methodStub.calledOnce).to.be.true; + }); + + it('should refresh if was call before expire', async () => { + await instance.method(42); + + await delay(timeout / 2); + await instance.method(42); + methodStub.reset(); + await delay(timeout / 2); + + await instance.method(42); + expect(methodStub.called).to.be.false; + }); + + }); + + }); + + describe('scope', () => { + + describe('class', () => { + + const options: CacheOptions = { scope: 'class' }; + let constructor: ReturnType; + let instance: InstanceType; + + beforeEach(() => { + constructor = factory(timeout, options); + instance = new constructor(); + }); + + it('should return cached value for same instance of class', async () => { + await instance.method(42); + methodStub.reset(); + + await instance.method(42); + expect(methodStub.called).to.be.false; + }); + + it('should return cached value for every instances of class', async () => { + await new constructor().method(42); + methodStub.reset(); + + await instance.method(42); + expect(methodStub.called).to.be.false; + }); + + it('should not use cached value if is another class', async () => { + const anotherConstructor = factory(timeout, options); + await new anotherConstructor().method(42); + methodStub.reset(); + + await instance.method(42); + expect(methodStub.calledOnce).to.be.true; + }); + + }); + + describe('instance', () => { + + const options: CacheOptions = { scope: 'instance' }; + let constructor: ReturnType; + let instance: InstanceType; + + beforeEach(() => { + constructor = factory(timeout, options); + instance = new constructor(); + }); + + it('should return cached value for same instance of class', async () => { + await instance.method(42); + methodStub.reset(); + + await instance.method(42); + expect(methodStub.called).to.be.false; + }); + + it('should not return cached value for differenct instances of class', async () => { + await new constructor().method(42); + methodStub.reset(); + + await instance.method(42); + expect(methodStub.calledOnce).to.be.true; + }); + + }); + + }); + + describe('size', () => { + + const options: CacheOptions = { size: 2 }; + let instance: InstanceType>; + + beforeEach(() => instance = new (factory(timeout, options))); + + it('should cache value if storage limit is not reached', async () => { + await instance.method(1); + await instance.method(2); + methodStub.reset(); + + await instance.method(2); + expect(methodStub.called).to.be.false; + }); + + it('should cache value if storage is reached', async () => { + await instance.method(1); + await instance.method(2); + await instance.method(3); + methodStub.reset(); + + await instance.method(3); + expect(methodStub.called).to.be.false; + }); + + it('should remove oldest value if storage limit is reached', async () => { + await instance.method(1); + await instance.method(2); + await instance.method(3); + methodStub.reset(); + + await instance.method(1); + expect(methodStub.calledOnce).to.be.true; + }); + + }); + + }); + +}); diff --git a/test/cache/cacheProvider/ClassCacheProvider.spec.ts b/test/cache/cacheProvider/ClassCacheProvider.spec.ts new file mode 100644 index 0000000..ececbec --- /dev/null +++ b/test/cache/cacheProvider/ClassCacheProvider.spec.ts @@ -0,0 +1,69 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { ClassCacheProvider } from '../../../lib/cache/cacheProvider/ClassCacheProvider'; +import { CacheFactory } from '../../../lib/cache/caches/factory'; + +describe('@cache ClassCacheProvider', () => { + + let cacheFactoryStub: sinon.SinonStubbedInstance; + let service: ClassCacheProvider; + + beforeEach(() => { + cacheFactoryStub = sinon.createStubInstance(CacheFactory); + + service = new ClassCacheProvider(cacheFactoryStub as any); + }); + + describe('constructor', () => { + + it('should create', () => expect(service).to.be.instanceOf(ClassCacheProvider)); + + }); + + describe('get', () => { + + it('should call CacheFactory.create to create an instance at first call', () => { + service.get(); + + expect(cacheFactoryStub.create.calledOnce).to.be.true; + }); + + it('should return instance create from cahceFactory', () => { + const cacheInstance = {} as any; + cacheFactoryStub.create.returns(cacheInstance); + + expect(service.get()).to.equals(cacheInstance); + }); + + it('should return existent instance of cache if is not first call', () => { + const cacheInstance = {} as any; + cacheFactoryStub.create.returns(cacheInstance); + service.get(); + + expect(service.get()).to.equals(cacheInstance); + }); + + it('should not call CacheFactory.create if instance of cache service exists', () => { + cacheFactoryStub.create.returns({} as any); + service.get(); + cacheFactoryStub.create.reset(); + + service.get(); + + expect(cacheFactoryStub.create.called).to.be.false; + }); + + it('should not call CacheFactory.create if instance of cache service exists', () => { + cacheFactoryStub.create.returns({} as any); + service.get(); + cacheFactoryStub.create.reset(); + + service.get(); + + expect(cacheFactoryStub.create.called).to.be.false; + }); + + }); + +}); diff --git a/test/cache/cacheProvider/InstanceCacheProvider.spec.ts b/test/cache/cacheProvider/InstanceCacheProvider.spec.ts new file mode 100644 index 0000000..d055ed3 --- /dev/null +++ b/test/cache/cacheProvider/InstanceCacheProvider.spec.ts @@ -0,0 +1,62 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { InstanceCacheProvider } from '../../../lib/cache/cacheProvider/InstanceCacheProvider'; +import { CacheFactory } from '../../../lib/cache/caches/factory'; + +describe('@cache InstanceCacheProvider', () => { + + let cacheFactoryStub: sinon.SinonStubbedInstance; + let service: InstanceCacheProvider; + + beforeEach(() => { + cacheFactoryStub = sinon.createStubInstance(CacheFactory); + + service = new InstanceCacheProvider(cacheFactoryStub as any); + }); + + describe('constructor', () => { + + it('should create', () => expect(service).to.be.instanceOf(InstanceCacheProvider)); + + }); + + describe('get', () => { + + it('should create new cache instance if was called first time with this instance', () => { + const result = {} as any; + const instance = {} as any; + cacheFactoryStub.create.returns(result); + + expect(service.get(instance)).to.equals(result); + }); + + it('should return already created cache for current isntance', () => { + const result = {} as any; + cacheFactoryStub.create.returns(result); + const instance = {} as any; + service.get(instance); + + expect(service.get(instance)).to.equals(result); + }); + + it('should create new cahce with cacheFactory.create', () => { + service.get({} as any); + + expect(cacheFactoryStub.create.calledOnce).to.be.true; + }); + + it('should not create new cache cache instance if already exists', () => { + cacheFactoryStub.create.returns({} as any); + const instance = {} as any; + service.get(instance); + cacheFactoryStub.create.reset(); + + service.get(instance); + + expect(cacheFactoryStub.create.called).to.be.false; + }); + + }); + +}); diff --git a/test/cache/cacheProvider/factory.spec.ts b/test/cache/cacheProvider/factory.spec.ts new file mode 100644 index 0000000..869e798 --- /dev/null +++ b/test/cache/cacheProvider/factory.spec.ts @@ -0,0 +1,45 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { ClassCacheProvider } from '../../../lib/cache/cacheProvider/ClassCacheProvider'; +import { CacheProviderFactory } from '../../../lib/cache/cacheProvider/factory'; +import { InstanceCacheProvider } from '../../../lib/cache/cacheProvider/InstanceCacheProvider'; +import { CacheFactory } from '../../../lib/cache/caches/factory'; + +describe('@cache CacheProviderFactory', () => { + + let cacheFactoryStub: sinon.SinonStubbedInstance; + let service: CacheProviderFactory; + + beforeEach(() => { + cacheFactoryStub = sinon.createStubInstance(CacheFactory); + + service = new CacheProviderFactory(cacheFactoryStub as any); + }); + + describe('constructor', () => { + + it('should create', () => expect(service).to.be.instanceOf(CacheProviderFactory)); + + }); + + describe('create', () => { + + it('should create instanceof ClassCacheProvider if scope is "class"', () => { + expect(service.create('class')).to.be.instanceOf(ClassCacheProvider); + }); + + it('should create instanceof InstanceCacheProvider if scope is "instance"', () => { + expect(service.create('instance')).to.be.instanceOf(InstanceCacheProvider); + }); + + it('should throw error if scope options is not a valid one', () => { + const scope = '123' as any; + const message = `@cache invalid scope option: ${scope}.`; + + expect(() => service.create(scope)).to.throw(message); + }); + + }); + +}); diff --git a/test/cache/caches/Cache.spec.ts b/test/cache/caches/Cache.spec.ts new file mode 100644 index 0000000..f516177 --- /dev/null +++ b/test/cache/caches/Cache.spec.ts @@ -0,0 +1,176 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { Cache } from '../../../lib/cache/caches/Cache'; +import { AbsoluteExpiration } from '../../../lib/cache/expirations/AbsoluteExpiration'; +import { Expiration } from '../../../lib/cache/expirations/Expiration'; +import { MemoryStorage } from '../../../lib/cache/storages/MemoryStorage'; +import { Storage } from '../../../lib/cache/storages/Storage'; +import { HashService } from '../../../lib/utils/hash'; + +describe('@cache Cache', () => { + + const key = 'key'; + let hashStub: sinon.SinonStubbedInstance; + let storageStub: sinon.SinonStubbedInstance; + let expirationStub: sinon.SinonStubbedInstance; + let service: Cache; + + beforeEach(() => { + hashStub = sinon.createStubInstance(HashService); + storageStub = sinon.createStubInstance(MemoryStorage); + expirationStub = sinon.createStubInstance(AbsoluteExpiration); + + hashStub.calculate.returns(key); + + service = new Cache(storageStub, expirationStub, hashStub); + }); + + describe('constructor', () => { + + it('should create', () => expect(service).to.be.instanceOf(Cache)); + + }); + + describe('set', () => { + + it('should call hash.calculate to obtain arguments hash', async () => { + await service.set(['key'], 'value'); + + expect(hashStub.calculate.calledOnce).to.be.true; + }); + + it('should call storage.set to store given hashed key and data', async () => { + await service.set(['key'], 'value'); + + expect(storageStub.set.calledOnce).to.be.true; + }); + + it('should call storage.set to store given hashed key with correct parameters', async () => { + await service.set(['key'], 'value'); + + expect(storageStub.set.calledWithExactly(key, 'value')).to.be.true; + }); + + it('should call expiration.add to make cache expirable', async () => { + const key = 'key'; + hashStub.calculate.returns(key); + + await service.set(['key'], 'value'); + + expect(expirationStub.add.calledOnce).to.be.true; + }); + + it('should call expiration.add with correct parameters', async () => { + await service.set(['key'], 'value'); + + expect(expirationStub.add.calledWith(key, sinon.match.func)).to.be.true; + }); + + describe('function passed to expiration', () => { + + it('should call storage.delete', async () => { + await service.set(['key'], 'value'); + + const callback = expirationStub.add.firstCall.args[1]; + + await callback(key); + + expect(storageStub.delete.calledOnce).to.be.true; + }); + + it('should call storage.delte with correct parameters', async () => { + await service.set(['key'], 'value'); + + const callback = expirationStub.add.firstCall.args[1]; + + await callback(key); + + expect(storageStub.delete.calledWith(key)).to.be.true; + }); + + }); + + }); + + describe('has', () => { + + it('should call hash.calculate to obtain arguments hash', async () => { + await service.has(['key']); + + expect(hashStub.calculate.calledOnce).to.be.true; + }); + + it('should call storage has to check if key exists', async () => { + await service.has(['key']); + + expect(storageStub.has.calledWith(key)).to.be.true; + }); + + it('should call storage has with correct parameters', async () => { + await service.has(['key']); + + expect(storageStub.has.calledWith(key)).to.be.true; + }); + + }); + + describe('get', () => { + + it('should call hash.calculate to obtain arguments hash', async () => { + await service.get(['key']); + + expect(hashStub.calculate.calledOnce).to.be.true; + }); + + it('should call expiration.add to update cache expiration', async () => { + await service.get(['key']); + + expect(expirationStub.add.calledOnce).to.be.true; + }); + + it('should call expiration.add with correct parameters', async () => { + await service.get(['key']); + + expect(expirationStub.add.calledWith(key, sinon.match.func)).to.be.true; + }); + + it('should call storage.get to obtain cached value', async () => { + await service.get(['key']); + + expect(storageStub.get.calledOnce).to.be.true; + }); + + it('should call storage.get with correct parameters', async () => { + await service.get(['key']); + + expect(storageStub.get.calledWith(key)).to.be.true; + }); + + describe('function passed to expiration', () => { + + let callback: (key: string) => unknown; + + beforeEach(async () => { + await service.get(['key']); + + callback = expirationStub.add.firstCall.args[1]; + }); + + it('should call storage.delete once', async () => { + await callback(key); + + expect(storageStub.delete.calledOnce).to.be.true; + }); + + it('should call storage.delete with correct parameters', async () => { + await callback(key); + + expect(storageStub.delete.calledWith(key)).to.be.true; + }); + + }); + + }); + +}); diff --git a/test/cache/caches/factory.spec.ts b/test/cache/caches/factory.spec.ts new file mode 100644 index 0000000..a89a23f --- /dev/null +++ b/test/cache/caches/factory.spec.ts @@ -0,0 +1,71 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { Cache } from '../../../lib/cache/caches/Cache'; +import { CacheFactory } from '../../../lib/cache/caches/factory'; +import { ExpirationFactory } from '../../../lib/cache/expirations/factory'; +import { StorageFactory } from '../../../lib/cache/storages/factory'; +import { HashService } from '../../../lib/utils/hash'; + +describe('@cache CacheFactory', () => { + + const expiration = 'absolute'; + const storage = 'memory'; + let expirationFactoryStub: sinon.SinonStubbedInstance; + let storageFactoryStub: sinon.SinonStubbedInstance; + let hashStub: sinon.SinonStubbedInstance; + let service: CacheFactory; + + beforeEach(() => { + expirationFactoryStub = sinon.createStubInstance(ExpirationFactory); + storageFactoryStub = sinon.createStubInstance(StorageFactory); + hashStub = sinon.createStubInstance(HashService); + + service = new CacheFactory( + hashStub, + expirationFactoryStub as any, + storageFactoryStub as any, + expiration, + storage, + ); + }); + + describe('constructor', () => { + + it('should create', () => expect(service).to.be.instanceOf(CacheFactory)); + + }); + + describe('create', () => { + + it('should call expirationFactory.create to obtain expiration', () => { + service.create(); + + expect(expirationFactoryStub.create.calledOnce).to.be.true; + }); + + it('should call expirationFactory.create with correct parameters', () => { + service.create(); + + expect(expirationFactoryStub.create.calledWith(expiration)).to.be.true; + }); + + it('should call storageFactory.create to obtain storage', () => { + service.create(); + + expect(storageFactoryStub.create.calledOnce).to.be.true; + }); + + it('should call storageFactory.create with correct parameters', () => { + service.create(); + + expect(storageFactoryStub.create.calledWith(storage)).to.be.true; + }); + + it('should return instance of Cahce', () => { + expect(service.create()).to.be.instanceOf(Cache); + }); + + }); + +}); diff --git a/test/cache/expirations/AbsoluteExpiration.spec.ts b/test/cache/expirations/AbsoluteExpiration.spec.ts new file mode 100644 index 0000000..9cb3e2d --- /dev/null +++ b/test/cache/expirations/AbsoluteExpiration.spec.ts @@ -0,0 +1,93 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { AbsoluteExpiration } from '../../../lib/cache/expirations/AbsoluteExpiration'; +import { delay } from '../../utils'; + +describe('@cache AbsoluteExpiration', () => { + + const timeout = 10; + let service: AbsoluteExpiration; + + beforeEach(() => service = new AbsoluteExpiration(timeout)); + + describe('constructor', () => { + + it('should create', () => expect(service).to.be.instanceOf(AbsoluteExpiration)); + + }); + + describe('add', () => { + + describe('new key', () => { + + it('should call clearCallback after timeout ms', async () => { + const spy = sinon.spy(); + + service.add('key', spy); + + await delay(timeout); + + expect(spy.calledOnce).to.be.true; + }); + + it('should call clearCallback with given key', async () => { + const spy = sinon.spy(); + const key = 'key'; + + service.add('key', spy); + + await delay(timeout); + + expect(spy.calledWith(key)).to.be.true; + }); + + }); + + describe('existing key', () => { + + it('should not update expiration', async () => { + const spy = sinon.spy(); + service.add('key', () => { }); + + service.add('key', spy); + + await delay(timeout); + + expect(spy.called).to.be.false; + }); + + it('should call initial callback', async () => { + const spy = sinon.spy(); + + service.add('key', spy); + + await delay(timeout / 2); + + service.add('key', () => { }); + + await delay(timeout / 2); + + expect(spy.calledOnce).to.be.true; + }); + + it('should call initial callback with given key', async () => { + const spy = sinon.spy(); + const key = 'key'; + + service.add(key, spy); + + await delay(timeout / 2); + + service.add(key, () => { }); + + await delay(timeout / 2); + + expect(spy.calledWith(key)).to.be.true; + }); + + }); + + }); + +}); diff --git a/test/cache/expirations/SlidingExpiration.spec.ts b/test/cache/expirations/SlidingExpiration.spec.ts new file mode 100644 index 0000000..e17e5bc --- /dev/null +++ b/test/cache/expirations/SlidingExpiration.spec.ts @@ -0,0 +1,83 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import { SlidingExpiration } from '../../../lib/cache/expirations/SlidingExpiration'; +import { delay } from '../../utils'; + +describe('@cache SlidingExpiration', () => { + + const timeout = 10; + let service: SlidingExpiration; + + beforeEach(() => service = new SlidingExpiration(timeout)); + + describe('constructor', () => { + + it('should create', () => expect(service).to.be.instanceOf(SlidingExpiration)); + + }); + + describe('add', () => { + + describe('new key', () => { + + it('should call clearCallback after timeout ms', async () => { + const spy = sinon.spy(); + + service.add('key', spy); + await delay(timeout); + + expect(spy.calledOnce).to.be.true; + }); + + it('should call clearCallback with given key', async () => { + const spy = sinon.spy(); + + service.add('key', spy); + await delay(timeout); + + expect(spy.calledWith('key')).to.be.true; + }); + + }); + + describe('existing key', () => { + + it('should remove existing expiration', async () => { + const expirationSpy = sinon.spy(); + service.add('key', expirationSpy); + + service.add('key', () => { }); + + await delay(timeout); + + expect(expirationSpy.called).to.be.false; + }); + + it('should call clearCallback after timeout ms', async () => { + const spy = sinon.spy(); + service.add('key', () => { }); + + service.add('key', spy); + + await delay(timeout); + + expect(spy.calledOnce).to.be.true; + }); + + it('should call clearCallback with given key', async () => { + const spy = sinon.spy(); + service.add('key', () => { }); + + service.add('key', spy); + + await delay(timeout); + + expect(spy.calledWith('key')).to.be.true; + }); + + }); + + }); + +}); diff --git a/test/cache/expirations/factory.spec.ts b/test/cache/expirations/factory.spec.ts new file mode 100644 index 0000000..0b23a5d --- /dev/null +++ b/test/cache/expirations/factory.spec.ts @@ -0,0 +1,39 @@ +import { expect } from 'chai'; + +import { AbsoluteExpiration } from '../../../lib/cache/expirations/AbsoluteExpiration'; +import { ExpirationFactory } from '../../../lib/cache/expirations/factory'; +import { SlidingExpiration } from '../../../lib/cache/expirations/SlidingExpiration'; + +describe('@cache ExpirationFactory', () => { + + const timeout = 42; + let service: ExpirationFactory; + + beforeEach(() => service = new ExpirationFactory(timeout)); + + describe('constructor', () => { + + it('should create', () => expect(service).to.be.instanceOf(ExpirationFactory)); + + }); + + describe('create', () => { + + it('should create instance of AbsoluteExpiration if expiration is "absolute"', () => { + expect(service.create('absolute')).to.be.instanceOf(AbsoluteExpiration); + }); + + it('should create instance of SlidingExpiration if expiration is "sliding"', () => { + expect(service.create('sliding')).to.be.instanceOf(SlidingExpiration); + }); + + it('should throw error if expiration parameter is not valid', () => { + const expiration = '123' as any; + const message = `@cache Expiration type is not supported: ${expiration}.`; + + expect(() => service.create(expiration)).to.throw(message); + }); + + }); + +}); diff --git a/test/cache/storages/MemoryStorage.spec.ts b/test/cache/storages/MemoryStorage.spec.ts new file mode 100644 index 0000000..adb4721 --- /dev/null +++ b/test/cache/storages/MemoryStorage.spec.ts @@ -0,0 +1,82 @@ +import { expect } from 'chai'; + +import { MemoryStorage } from '../../../lib/cache/storages/MemoryStorage'; + +describe('@cache MemoryStorage', () => { + + let service: MemoryStorage; + + beforeEach(() => service = new MemoryStorage()); + + describe('constructor', () => { + + it('should create', () => expect(service).to.be.instanceOf(MemoryStorage)); + + }); + + describe('set', () => { + + it('should set key and value to storage', async () => { + const key = 'key'; + const value = 42; + + await service.set(key, value); + + expect(await service.get(key)).to.equals(value); + }); + + it('should remove oldest key if limit is reached', async () => { + const service = new MemoryStorage(2); + await service.set('a', 1); + await service.set('b', 2); + + await service.set('c', 3); + + expect(await service.has('a')).to.be.false; + }); + + it('should return self instance', async () => { + expect(await service.set('key', 'value')).to.equals(service); + }); + + }); + + describe('get', () => { + + it('should return value from storage', async () => { + await service.set('key', 'value'); + expect(await service.get('key')).to.equals('value'); + }); + + }); + + describe('has', () => { + + it('should return true if key is in storage', async () => { + await service.set('key', 'value'); + expect(await service.has('key')).to.be.true; + }); + + it('should return false if key is not in storage', async () => { + expect(await service.has('key')).to.be.false; + }); + + }); + + describe('delete', () => { + + it('should delete key from storage', async () => { + await service.set('key', '123'); + + await service.delete('key'); + + expect(await service.has('key')).to.be.false; + }); + + it('should return self instance', async () => { + expect(await service.delete('key')).to.equals(service); + }); + + }); + +}); diff --git a/test/cache/storages/factory.spec.ts b/test/cache/storages/factory.spec.ts new file mode 100644 index 0000000..fa70a78 --- /dev/null +++ b/test/cache/storages/factory.spec.ts @@ -0,0 +1,33 @@ +import { expect } from 'chai'; + +import { StorageFactory } from '../../../lib/cache/storages/factory'; +import { MemoryStorage } from '../../../lib/cache/storages/MemoryStorage'; + +describe('@cache StorageFactory', () => { + + const limit = 42; + let service: StorageFactory; + + beforeEach(() => service = new StorageFactory(limit)); + + describe('constructor', () => { + + it('should create', () => expect(service).to.be.instanceOf(StorageFactory)); + + }); + + describe('create', () => { + + it('should return instance of StorageFactory if storage is "memory"', () => { + expect(service.create('memory')).to.be.instanceOf(MemoryStorage); + }); + + it('should throw error if storate is not an valid storage', () => { + const storage = 'memory3' as any; + const message = `@cache Storage type is not supported: ${storage}.`; + expect(() => service.create(storage)).to.throw(message); + }); + + }); + +}); diff --git a/test/indext.ts b/test/indext.ts index 6dfdc7f..08b3dac 100644 --- a/test/indext.ts +++ b/test/indext.ts @@ -1,4 +1,8 @@ import * as chai from 'chai'; import * as chaiAsPromised from 'chai-as-promised'; +import * as sinonChai from 'sinon-chai'; +import * as chaiSpies from 'chai-spies'; chai.use(chaiAsPromised); +chai.use(sinonChai); +chai.use(chaiSpies); diff --git a/test/uitls/hash.spec.ts b/test/uitls/hash.spec.ts new file mode 100644 index 0000000..951144b --- /dev/null +++ b/test/uitls/hash.spec.ts @@ -0,0 +1,71 @@ +import { expect } from 'chai'; +import { HashService } from '../../lib/utils/hash'; + +describe('HashService', () => { + + let service: HashService; + + beforeEach(() => service = new HashService()); + + describe('constructor', () => { + + it('should create', () => expect(service).to.be.instanceOf(HashService)); + + }); + + describe('calculate', () => { + + it('should return string', () => expect(service.calculate({})).to.be.a('string')); + + it('should return same hash for different objects with same values', () => { + const firstObject = { a: 1, b: { x: 3 }, c: [] }; + const secondObject = { a: 1, b: { x: 3 }, c: [] }; + + expect(service.calculate(firstObject)).to.equals(service.calculate(secondObject)); + }); + + it('should not deppend on keys order', () => { + const firstObject = { a: 1, b: 2 }; + const secondObject = { b: 2, a: 1 }; + + expect(service.calculate(firstObject)).to.equals(service.calculate(secondObject)); + }); + + it('should return different hash for objects with different keys', () => { + const firstObject = { a: 1 }; + const secondObject = { b: 1 }; + + expect(service.calculate(firstObject)).not.equals(service.calculate(secondObject)); + }); + + it('should return different hash for objects with different values', () => { + const firstObject = [1]; + const secondObject = [2]; + + expect(service.calculate(firstObject)).not.equals(service.calculate(secondObject)); + }); + + it('should return different hash for object with different key/value pairs', () => { + const firstObject = { a: 1 }; + const secondObject = { b: 2 }; + + expect(service.calculate(firstObject)).not.equals(service.calculate(secondObject)); + }); + + it('should return same keys for objects what are deep equals', () => { + const firstArray = [{ a: 1 }]; + const secondArray = [{ a: 1 }]; + + expect(service.calculate(firstArray)).to.equals(service.calculate(secondArray)); + }); + + it('should return different keys for objects with different keys at more deep level', () => { + const firstArray = [{ a: 1 }]; + const secondArray = [{ b: 1 }]; + + expect(service.calculate(firstArray)).not.equals(service.calculate(secondArray)); + }); + + }); + +});