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:
commit
7f6822893c
5 changed files with 274 additions and 23 deletions
7
.github/workflows/integration-test.yml
vendored
7
.github/workflows/integration-test.yml
vendored
|
@ -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/
|
||||
|
|
67
packages/core/src/caches/index.test.ts
Normal file
67
packages/core/src/caches/index.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
165
packages/core/src/caches/well-known.test.ts
Normal file
165
packages/core/src/caches/well-known.test.ts
Normal 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);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue