Skip to content

Commit

Permalink
Add more tests
Browse files Browse the repository at this point in the history
1. Covered more test cases
2. Fixed querystring bugs
3. Upgraded jest and ts-jest
4. Refactor
5. Added `ttlCheckIntervalMs` plugin option
6. Fixed types
7. Updated README
  • Loading branch information
denbon05 committed Apr 19, 2024
1 parent 1cc5558 commit d99e26e
Show file tree
Hide file tree
Showing 15 changed files with 7,163 additions and 3,433 deletions.
22 changes: 2 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,35 +53,17 @@ axios.get(url);

## API

### Options (default)

```ts
{
ttlInMinutes?: 5,
disableCache?: false;
statusesToCache?: [200],
methodsToCache?: ['GET'],
excludeRoutes?: [];
}
```

### On fastify instance

<p><b>app.lcache</b> available inside your app</p>

```ts
interface CachedResponse<T> {
payload: T;
headers?: { [key: string]: string | number | string[] };
statusCode?: number;
}

interface IStorage {
// Get cached data
get<T>(key: string): CachedResponse<T>;
get(key: string): any;

// Set data to cache
set<T>(key: string, value: CachedResponse<T>): void;
set(key: string, value: any): void;

// Check if data exists in cache
has(key: string): boolean;
Expand Down
8 changes: 8 additions & 0 deletions __tests__/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ export const getApp = (options: ICacheOptions = {}) => {
reply.send('pong');
});

app.post('/ping', async (_req, reply) => {
reply.send('pong');
});

app.delete('/ping', async (_req, reply) => {
reply.send('pong');
});

app.get('/json', async (_req, reply) => {
reply.send({ hello: 'world' });
});
Expand Down
180 changes: 175 additions & 5 deletions __tests__/lcache.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { TLL_CHECK_INTERVAL_MS } from '@/constants';
import '@/types/fastify';
import type { RequestMethod } from '@/types/lcache';
import type { FastifyInstance } from 'fastify';
import { getApp } from './helpers';

Expand All @@ -13,11 +15,40 @@ describe('Caching with default options', () => {
await app.close();
});

test('Plugin exists on app instance', () => {
test('Plugin should exist on app instance', () => {
expect(app.hasDecorator('lcache')).toBeTruthy();
});

test('Cache is working', async () => {
test('Should be possible to put/retrieve values from app instance', async () => {
const strKey = 'str';
const expectedStrValue = 'data1';
const objKey = 'obj';
const expectedObjValue = {
name: 'Bob',
age: 23,
};
app.lcache.set(strKey, expectedStrValue);
app.lcache.set(objKey, expectedObjValue);

const actualStrValue = app.lcache.get(strKey);
expect(actualStrValue).toEqual(expectedStrValue);

const actualObjValue = app.lcache.get(objKey);
expect(actualObjValue).toEqual(actualObjValue);
});

test('Should be possible to overwrite data by key', async () => {
const key = 'str';
const value1 = 'data1';
const value2 = NaN;

app.lcache.set(key, value1);
app.lcache.set(key, value2);

expect(app.lcache.get(key)).toStrictEqual(value2);
});

test('Cache should work', async () => {
const spyGet = jest.spyOn(app.lcache, 'get');
const spySet = jest.spyOn(app.lcache, 'set');
const getPing = async () =>
Expand Down Expand Up @@ -51,6 +82,26 @@ describe('Caching with default options', () => {
expect(res2.headers['content-type']).toBe(res1.headers['content-type']);
expect(spyGet).toHaveBeenCalledTimes(1);
});

test('Response should be cached separately for different query', async () => {
const spyGet = jest.spyOn(app.lcache, 'get');
const spySet = jest.spyOn(app.lcache, 'set');
const res1 = await app.inject({
method: 'GET',
path: '/date',
query: 'asd',
});

const res2 = await app.inject({
method: 'GET',
path: '/date',
query: 'sdf',
});

expect(res1.body).not.toBe(res2.body);
expect(spyGet).not.toHaveBeenCalled();
expect(spySet).toHaveBeenCalledTimes(2);
});
});

describe('Caching with custom options', () => {
Expand All @@ -68,21 +119,21 @@ describe('Caching with custom options', () => {
await app.close();
});

test('Response should be cached separately for different payload', async () => {
test('Response should be cached separately for different body', async () => {
const spyGet = jest.spyOn(app.lcache, 'get');
const spySet = jest.spyOn(app.lcache, 'set');
const res1 = await app.inject({
method: 'POST',
path: '/post',
payload: {
body: {
data: 'first-payload',
},
});

const res2 = await app.inject({
method: 'POST',
path: '/post',
payload: {
body: {
data: 'second-payload',
},
});
Expand Down Expand Up @@ -129,4 +180,123 @@ describe('Caching with custom options', () => {
expect(res1.body).not.toBe(res2.body);
expect(spySet).not.toHaveBeenCalled();
});

test('Cache reset should work', async () => {
const spySet = jest.spyOn(app.lcache, 'set');
const getPing = async () =>
app.inject({
method: 'GET',
path: '/ping',
});

const postPing = async () =>
app.inject({
method: 'POST',
path: '/ping',
});

const dataKey1 = 'someKey1';
const dataValue1 = 'someValue1';
const dataKey2 = 'someKey2';
const dataValue2 = [1, 2, 3, 4];
const dataKey3 = 'someKey3';
const dataValue3 = 'someValue3';

// add data to the cache via request
await getPing();
await postPing();
// manually set some values
app.lcache.set(dataKey1, dataValue1);
app.lcache.set(dataKey2, dataValue2);
app.lcache.set(dataKey3, dataValue3);

expect(spySet).toBeCalledTimes(5); // 1 post + 1 get requests + 3 manual set
// remove specific key
app.lcache.reset(dataKey1);
app.lcache.reset([dataKey3]); // also check array

expect(app.lcache.get(dataKey1)).toBeFalsy();
expect(app.lcache.get(dataKey1)).toBeUndefined();
expect(app.lcache.get(dataKey3)).toBeUndefined();
// expect not specified data is still in the cache
expect(app.lcache.get(dataKey2)).toStrictEqual(dataValue2);

// prune all cached data
app.lcache.reset();
expect(app.lcache.has(dataKey2)).toBeFalsy();
expect(app.lcache.get(dataKey2)).toBeUndefined();

// check cached data via request
const spyGet = jest.spyOn(app.lcache, 'get');
await getPing();
await postPing();
// lcache should place data again to the cache and not try to get it
expect(spyGet).not.toHaveBeenCalled();
});
});

describe('Disabled lcache plugin', () => {
let app: FastifyInstance;
const methodsToCache: RequestMethod[] = ['GET', 'POST', 'DELETE'];

beforeEach(async () => {
app = await getApp({
excludeRoutes: [],
statusesToCache: [200, 201],
methodsToCache,
disableCache: true,
});
});

afterEach(async () => {
await app.close();
});

test.each(methodsToCache)(
"Shouldn't cache %s method regardless plugin config",
async (httpMethod) => {
const spySet = jest.spyOn(app.lcache, 'set');

await app.inject({
method: httpMethod,
path: '/ping',
payload: {
data: '456',
},
});

expect(spySet).not.toHaveBeenCalled();
}
);
});

describe('Interval cleanup', () => {
// convert to minutes for lcache usage
const ttlInMinutes = TLL_CHECK_INTERVAL_MS / 60000;
let app: FastifyInstance;

beforeEach(async () => {
app = await getApp({
ttlInMinutes,
});
});

afterEach(async () => {
await app.close();
jest.clearAllTimers();
});

test('Cached data should be removed after ttl', async () => {
const key = 'someKey';
const value = 'someValue';

app.lcache.set(key, value);
// wait doubled ttl time
await new Promise((resolve) => {
setTimeout(resolve, TLL_CHECK_INTERVAL_MS * 2);
});

expect(app.lcache.get(key)).toBeUndefined();
expect(app.lcache.has(key)).toBeFalsy();
});
});
9 changes: 2 additions & 7 deletions jest.config.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
import type { Config } from 'jest';
import type { JestConfigWithTsJest } from 'ts-jest';

const jestConfig: Config = {
const jestConfig: JestConfigWithTsJest = {
rootDir: '.',
testEnvironment: 'node',
transform: { '^.+\\.ts?$': 'ts-jest' },
globals: {
'ts-jest': {
diagnostics: false,
},
},
moduleFileExtensions: ['js', 'ts', 'd.ts'],
testRegex: '.*\\.test\\.ts$',
modulePathIgnorePatterns: ['<rootDir>/__tests__/helpers/'],
Expand Down
8 changes: 8 additions & 0 deletions lib/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/* eslint-disable import/prefer-default-export */

import type { RequestMethod } from './types/lcache';

export const TTL_IN_MINUTES = 5;
export const STATUSES_TO_CACHE = [200];
export const METHODS_TO_CACHE: RequestMethod[] = ['GET'];
export const TLL_CHECK_INTERVAL_MS = 1000;
4 changes: 2 additions & 2 deletions lib/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { FastifyRequest } from 'fastify';
import type {
RequestMethod,
ICacheOptions,
ICachePluginOptions,
RequestMethod,
} from './types/lcache';

const getMilliseconds = (min: number): number => min * 60000;
Expand All @@ -15,7 +15,7 @@ export const formatOptions = (opts: ICacheOptions): ICachePluginOptions => ({
ttl: getMilliseconds(opts.ttlInMinutes),
});

export const shouldBeCached = (
export const checkShouldBeCached = (
opts: ICachePluginOptions,
request: FastifyRequest,
statusCode: number
Expand Down
30 changes: 14 additions & 16 deletions lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,35 @@
import type { FastifyInstance, FastifyPluginCallback } from 'fastify';
import fp from 'fastify-plugin';
import type { ICacheOptions } from '@/types/lcache';
import { formatOptions, shouldBeCached } from '@/helpers';
import { checkShouldBeCached, formatOptions } from '@/helpers';
import MapStorage from '@/storage/Map';
import type { ICacheOptions } from '@/types/lcache';
import { buildCacheKey } from '@/utils';
import type { FastifyInstance, FastifyPluginCallback } from 'fastify';
import fp from 'fastify-plugin';
import * as constants from '@/constants';

const defaultOpts: ICacheOptions = {
ttlInMinutes: 5,
statusesToCache: [200],
methodsToCache: ['GET'],
ttlInMinutes: constants.TTL_IN_MINUTES,
statusesToCache: constants.STATUSES_TO_CACHE,
methodsToCache: constants.METHODS_TO_CACHE,
ttlCheckIntervalMs: constants.TLL_CHECK_INTERVAL_MS,
};

const cache: FastifyPluginCallback<ICacheOptions> = (
instance: FastifyInstance,
opts: ICacheOptions,
_next
) => {
if (opts.disableCache) {
_next();
return;
}

const storageOpts = formatOptions({ ...defaultOpts, ...opts });

const storage = new MapStorage(storageOpts);

instance.addHook('onSend', async (request, reply, payload) => {
const cacheKey = buildCacheKey(request);

if (
const shouldValueBeCached =
!storage.has(cacheKey) &&
shouldBeCached(storageOpts, request, reply.statusCode)
) {
checkShouldBeCached(storageOpts, request, reply.statusCode) &&
!opts.disableCache;

if (shouldValueBeCached) {
storage.set(cacheKey, {
payload,
headers: reply.getHeaders(),
Expand Down
Loading

0 comments on commit d99e26e

Please sign in to comment.