0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

Merge pull request #3681 from logto-io/gao-add-cache-tests

test(core): add cache tests
This commit is contained in:
Gao Sun 2023-04-11 00:09:42 +08:00 committed by GitHub
commit 7f6822893c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 274 additions and 23 deletions

View file

@ -90,6 +90,11 @@ jobs:
- name: Setup Postgres
uses: ikalnytskyi/action-setup-postgres@v4
- name: Setup Redis
uses: supercharge/redis-github-action@1.5.0
with:
redis-version: 6
- uses: actions/download-artifact@v3
with:
name: integration-test-${{ github.sha }}-${{ contains(matrix.target, 'cloud') && 'cloud' || 'oss' }}
@ -112,6 +117,8 @@ jobs:
- name: Run Logto
working-directory: logto/
run: nohup npm start > nohup.out 2> nohup.err < /dev/null &
env:
REDIS_URL: 1
- name: Run Logto Cloud
working-directory: logto/

View file

@ -0,0 +1,67 @@
import { createMockUtils } from '@logto/shared/esm';
import Sinon from 'sinon';
import { EnvSet } from '#src/env-set/index.js';
const { jest } = import.meta;
const { mockEsm } = createMockUtils(jest);
const mockFunction = jest.fn();
mockEsm('redis', () => ({
createClient: () => ({
set: mockFunction,
get: mockFunction,
del: mockFunction,
connect: mockFunction,
disconnect: mockFunction,
on: mockFunction,
}),
}));
const { RedisCache } = await import('./index.js');
const stubRedisUrl = (url?: string) =>
Sinon.stub(EnvSet, 'values').value({
...EnvSet.values,
redisUrl: url,
});
describe('RedisCache', () => {
it('should successfully construct with no REDIS_URL', async () => {
stubRedisUrl();
const cache = new RedisCache();
expect(cache.client).toBeUndefined();
// Not to throw
await Promise.all([
cache.set('foo', 'bar'),
cache.get('foo'),
cache.delete('foo'),
cache.connect(),
cache.disconnect(),
]);
});
it('should successfully construct with a Redis client', async () => {
for (const url of ['1', 'redis://url']) {
jest.clearAllMocks();
stubRedisUrl(url);
const cache = new RedisCache();
expect(cache.client).toBeTruthy();
// eslint-disable-next-line no-await-in-loop
await Promise.all([
cache.set('foo', 'bar'),
cache.get('foo'),
cache.delete('foo'),
cache.connect(),
cache.disconnect(),
]);
// Do sanity check only
expect(mockFunction).toBeCalledTimes(6);
}
});
});

View file

@ -0,0 +1,165 @@
import { TtlCache } from '@logto/shared';
import { pick } from '@silverhand/essentials';
import { mockConnector0 } from '#src/__mocks__/connector-base-data.js';
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
import { WellKnownCache } from './well-known.js';
const { jest } = import.meta;
const tenantId = 'mock_id';
const cacheStore = new TtlCache<string, string>(60_000);
afterEach(() => {
jest.clearAllMocks();
cacheStore.clear();
});
describe('Well-known cache basics', () => {
it('should be able to get, set, and delete values', async () => {
const cache = new WellKnownCache(tenantId, cacheStore);
await cache.set('sie', WellKnownCache.defaultKey, mockSignInExperience);
expect(await cache.get('sie', WellKnownCache.defaultKey)).toStrictEqual(mockSignInExperience);
await cache.set('connectors-well-known', '123', [mockConnector0]);
expect(await cache.get('connectors-well-known', '123')).toStrictEqual([
pick(mockConnector0, 'connectorId', 'id', 'metadata'),
]);
await cache.delete('sie', WellKnownCache.defaultKey);
expect(await cache.get('sie', WellKnownCache.defaultKey)).toBe(undefined);
});
it('should be able to set the value with wrong structure', async () => {
const cache = new WellKnownCache(tenantId, cacheStore);
// @ts-expect-error
await cache.set('custom-phrases-tags', WellKnownCache.defaultKey, 1);
expect(await cache.get('custom-phrases-tags', WellKnownCache.defaultKey)).toBe(undefined);
});
it('should be able to set and get when cache type is wrong', async () => {
const cache = new WellKnownCache(tenantId, cacheStore);
// @ts-expect-error
await cache.set('custom-phrases-tags-', WellKnownCache.defaultKey, []);
expect(
// @ts-expect-error
await cache.get('custom-phrases-tags-', WellKnownCache.defaultKey)
).toBe(undefined);
});
});
describe('Well-known cache function wrappers', () => {
it('can memoize function and cache the promise', async () => {
jest.useFakeTimers();
const runResult = Object.freeze({ foo: 'bar' });
const run = jest.fn(
async () =>
new Promise<Record<string, unknown>>((resolve) => {
setTimeout(() => {
resolve(runResult);
}, 0);
jest.runOnlyPendingTimers(); // Ensure this runs in fake timers
})
);
const cache = new WellKnownCache(tenantId, cacheStore);
const memoized = cache.memoize(run, ['custom-phrases']);
const [result1, result2] = await Promise.all([memoized(), memoized()]);
expect(result1).toStrictEqual(runResult);
expect(result2).toStrictEqual(runResult);
expect(await cache.get('custom-phrases', WellKnownCache.defaultKey)).toStrictEqual(runResult);
expect(run).toBeCalledTimes(1);
// Second call
expect(await memoized()).toStrictEqual(runResult);
expect(run).toBeCalledTimes(1);
// Cache expired
jest.setSystemTime(Date.now() + 100_000); // Ensure cache is expired
expect(await memoized()).toStrictEqual(runResult);
expect(run).toBeCalledTimes(2);
expect(await cache.get('custom-phrases', WellKnownCache.defaultKey)).toStrictEqual(runResult);
jest.useRealTimers();
});
it('can memoize function with customized cache key builder', async () => {
const run = jest.fn(
async (foo: string, bar: number) =>
new Promise<Record<string, unknown>>((resolve) => {
setTimeout(() => {
resolve({ foo, bar });
}, 0);
})
);
const cache = new WellKnownCache(tenantId, cacheStore);
const memoized = cache.memoize(run, ['custom-phrases', (foo, bar) => `${foo}+${bar}`]);
const [result1, result2] = await Promise.all([memoized('1', 1), memoized('2', 2)]);
expect(result1).toStrictEqual({ foo: '1', bar: 1 });
expect(result2).toStrictEqual({ foo: '2', bar: 2 });
expect(
await Promise.all([cache.get('custom-phrases', '1+1'), cache.get('custom-phrases', '2+2')])
).toStrictEqual([
{ foo: '1', bar: 1 },
{ foo: '2', bar: 2 },
]);
});
it('can create mutate function wrapper with default cache key builder', async () => {
const run = jest.fn(
async () =>
new Promise<Record<string, unknown>>((resolve) => {
setTimeout(() => {
resolve({});
}, 0);
})
);
const update = jest.fn(async () => true);
const cache = new WellKnownCache(tenantId, cacheStore);
const memoized = cache.memoize(run, ['custom-phrases']);
const mutate = cache.mutate(update, ['custom-phrases']);
await memoized();
await mutate();
expect(await cache.get('custom-phrases', WellKnownCache.defaultKey)).toBeUndefined();
await memoized();
expect(run).toBeCalledTimes(2);
});
it('can create mutate function wrapper with customized cache key builder', async () => {
const run = jest.fn(
async (foo: string, bar: number) =>
new Promise<Record<string, unknown>>((resolve) => {
setTimeout(() => {
resolve({ foo, bar });
}, 0);
})
);
const update = jest.fn(async (value: number) => value);
const cache = new WellKnownCache(tenantId, cacheStore);
const memoized = cache.memoize(run, ['custom-phrases', (foo, bar) => `${foo}+${bar}`]);
const mutate = cache.mutate(update, ['custom-phrases', (value) => `1+${value}`]);
await Promise.all([memoized('1', 1), memoized('2', 2)]);
await Promise.all([mutate(1), mutate(2)]);
expect(
await Promise.all([cache.get('custom-phrases', '1+1'), cache.get('custom-phrases', '2+2')])
).toStrictEqual([undefined, { foo: '2', bar: 2 }]);
expect(run).toBeCalledTimes(2);
const mutate2 = cache.mutate(update, ['custom-phrases', () => `2+2`]);
await mutate2(1);
expect(await cache.get('custom-phrases', '2+2')).toBeUndefined();
expect(run).toBeCalledTimes(2);
});
});

View file

@ -13,8 +13,6 @@ type WellKnownMap = {
'custom-phrases-tags': string[];
};
const defaultCacheKey = '#';
export type WellKnownCacheType = keyof WellKnownMap;
/**
@ -61,8 +59,9 @@ function getValueGuard(type: WellKnownCacheType): ZodType<WellKnownMap[typeof ty
/**
* A reusable cache for well-known data. The name "well-known" has no direct relation to the `.well-known` routes,
* but indicates the data to store should be publicly viewable. You should never store any data that is protected
* by any authentication method.
* but indicates the data to store should be publicly viewable.
*
* **CAUTION** You should never store any data that is protected by any authentication method.
*
* For better code maintainability, we recommend to use the cache for database queries only unless you have a strong
* reason.
@ -70,6 +69,8 @@ function getValueGuard(type: WellKnownCacheType): ZodType<WellKnownMap[typeof ty
* @see {@link getValueGuard} For how data will be guarded while getting from the cache.
*/
export class WellKnownCache {
static defaultKey = '#';
/**
* @param tenantId The tenant ID this cache is intended for.
* @param cacheStore The storage to use as the cache.
@ -128,7 +129,7 @@ export class WellKnownCache {
// only happens when the original function executed successfully
void Promise.all(
types.map(async ([type, cacheKey]) =>
trySafe(kvCache.delete(type, cacheKey?.(...args) ?? defaultCacheKey))
trySafe(kvCache.delete(type, cacheKey?.(...args) ?? WellKnownCache.defaultKey))
)
);
@ -158,7 +159,7 @@ export class WellKnownCache {
this: unknown,
...args: Args
): Promise<Readonly<WellKnownMap[Type]>> {
const promiseKey = cacheKey?.(...args) ?? defaultCacheKey;
const promiseKey = cacheKey?.(...args) ?? WellKnownCache.defaultKey;
const cachedPromise = promiseCache.get(promiseKey);
if (cachedPromise) {
@ -166,18 +167,21 @@ export class WellKnownCache {
}
const promise = (async () => {
// Wrap with `trySafe()` here to ignore Redis errors
const cachedValue = await trySafe(kvCache.get(type, promiseKey));
try {
// Wrap with `trySafe()` here to ignore Redis errors
const cachedValue = await trySafe(kvCache.get(type, promiseKey));
if (cachedValue) {
return cachedValue;
if (cachedValue) {
return cachedValue;
}
const value = await run.apply(this, args);
await trySafe(kvCache.set(type, promiseKey, value));
return value;
} finally {
promiseCache.delete(promiseKey);
}
const value = await run.apply(this, args);
await trySafe(kvCache.set(type, promiseKey, value));
promiseCache.delete(promiseKey);
return value;
})();
promiseCache.set(promiseKey, promise);

View file

@ -1,7 +1,6 @@
import type { SignInExperience } from '@logto/schemas';
import type { SignInExperience, Translation } from '@logto/schemas';
import { adminTenantApi } from '#src/api/api.js';
import { api } from '#src/api/index.js';
import api, { adminTenantApi, authedAdminApi } from '#src/api/api.js';
describe('.well-known api', () => {
it('get /.well-known/sign-in-exp for console', async () => {
@ -27,10 +26,19 @@ describe('.well-known api', () => {
});
});
it('get /.well-known/sign-in-exp for general app', async () => {
const response = await api.get('.well-known/sign-in-exp').json<SignInExperience>();
// Also test for Redis cache invalidation
it('should be able to return updated phrases', async () => {
const notification = 'Big brother is watching you.';
const original = await api
.get('.well-known/phrases?lng=en')
.json<{ translation: Translation }>();
// Should support sign-in and register
expect(response).toMatchObject({ signInMode: 'SignInAndRegister' });
expect(original.translation.demo_app).not.toHaveProperty('notification', notification);
await authedAdminApi.put('custom-phrases/en', { json: { demo_app: { notification } } });
const updated = await api
.get('.well-known/phrases?lng=en')
.json<{ translation: Translation }>();
expect(updated.translation.demo_app).toHaveProperty('notification', notification);
});
});