mirror of
https://github.com/renovatebot/renovate.git
synced 2026-11-01 02:00:10 +00:00
619ce40237
* build(deps): update dependency @redis/client to v6 | datasource | package | from | to | | ---------- | ------------- | ------ | ----- | | npm | @redis/client | 5.12.1 | 6.2.1 | * fix(cache): keep RESP 2 protocol with @redis/client v6 @redis/client v6 defaults to RESP 3, which requires Redis server 6.0 or newer and changed the default type parameters, breaking the RedisClient type alias. Pin RESP 2 explicitly and type the client accordingly. The switch to RESP 3 is deferred to the next major release. --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: secustor <sebastian@poxhofer.at>
355 lines
12 KiB
TypeScript
355 lines
12 KiB
TypeScript
import { RESP_TYPES, createClient, createCluster } from '@redis/client';
|
|
import { DateTime } from 'luxon';
|
|
import { logger as _logger } from '~test/util.ts';
|
|
import { compressToBase64 } from '../../../compress.ts';
|
|
import { decodeEntry, encodeEntry } from '../codec.ts';
|
|
import { PackageCacheRedis, normalizeRedisUrl } from './redis.ts';
|
|
|
|
const { logger } = _logger;
|
|
|
|
vi.mock('@redis/client');
|
|
|
|
describe('util/cache/package/impl/redis', () => {
|
|
describe('normalizeRedisUrl', () => {
|
|
it.each`
|
|
url | expected
|
|
${'redis://user:password@localhost:6379'} | ${'redis://user:password@localhost:6379'}
|
|
${'rediss://user:password@localhost:6379'} | ${'rediss://user:password@localhost:6379'}
|
|
${'redis+cluster://user:password@localhost:6379'} | ${'redis://user:password@localhost:6379'}
|
|
${'rediss+cluster://user:password@localhost:6379'} | ${'rediss://user:password@localhost:6379'}
|
|
`('rewrites $url to $expected', ({ url, expected }) => {
|
|
expect(normalizeRedisUrl(url)).toBe(expected);
|
|
});
|
|
});
|
|
|
|
describe('PackageCacheRedis', () => {
|
|
const clientMock = {
|
|
connect: vi.fn(),
|
|
set: vi.fn(),
|
|
del: vi.fn(),
|
|
destroy: vi.fn(),
|
|
withTypeMapping: vi.fn(),
|
|
};
|
|
const binaryClientMock = {
|
|
get: vi.fn(),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
clientMock.withTypeMapping.mockReturnValue(binaryClientMock);
|
|
vi.mocked(createClient).mockReturnValue(
|
|
clientMock as unknown as ReturnType<typeof createClient>,
|
|
);
|
|
vi.mocked(createCluster).mockReturnValue(
|
|
clientMock as unknown as ReturnType<typeof createCluster>,
|
|
);
|
|
});
|
|
|
|
describe('create', () => {
|
|
it('initializes single-node client and connects', async () => {
|
|
await PackageCacheRedis.create('redis://host', undefined);
|
|
|
|
expect(createClient).toHaveBeenCalledWith({
|
|
RESP: 2,
|
|
pingInterval: 30000,
|
|
socket: { reconnectStrategy: expect.any(Function) },
|
|
url: 'redis://host',
|
|
});
|
|
expect(clientMock.connect).toHaveBeenCalled();
|
|
expect(clientMock.withTypeMapping).toHaveBeenCalledWith({
|
|
[RESP_TYPES.BLOB_STRING]: Buffer,
|
|
});
|
|
|
|
const reconnectStrategy = vi.mocked(createClient).mock.calls[0][0]!
|
|
.socket!.reconnectStrategy as (retries: number) => number;
|
|
expect(reconnectStrategy(1)).toBe(100);
|
|
expect(reconnectStrategy(100)).toBe(3000);
|
|
});
|
|
|
|
it('initializes single-node client with secure url', async () => {
|
|
await PackageCacheRedis.create('rediss://host', '');
|
|
|
|
expect(createClient).toHaveBeenCalledWith(
|
|
expect.objectContaining({ url: 'rediss://host' }),
|
|
);
|
|
});
|
|
|
|
it('initializes cluster client', async () => {
|
|
await PackageCacheRedis.create('redis+cluster://host', '');
|
|
|
|
expect(createCluster).toHaveBeenCalledWith({
|
|
RESP: 2,
|
|
rootNodes: [
|
|
{
|
|
pingInterval: 30000,
|
|
socket: { reconnectStrategy: expect.any(Function) },
|
|
url: 'redis://host',
|
|
},
|
|
],
|
|
});
|
|
expect(clientMock.withTypeMapping).toHaveBeenCalledWith({
|
|
[RESP_TYPES.BLOB_STRING]: Buffer,
|
|
});
|
|
});
|
|
|
|
it('initializes cluster client with username and password', async () => {
|
|
await PackageCacheRedis.create('redis+cluster://user:pass@host', '');
|
|
|
|
expect(createCluster).toHaveBeenCalledWith({
|
|
RESP: 2,
|
|
defaults: { username: 'user', password: 'pass' },
|
|
rootNodes: [
|
|
expect.objectContaining({
|
|
url: 'redis://user:pass@host',
|
|
}),
|
|
],
|
|
});
|
|
});
|
|
|
|
it('initializes cluster client with username only', async () => {
|
|
await PackageCacheRedis.create('redis+cluster://user@host', '');
|
|
|
|
expect(createCluster).toHaveBeenCalledWith({
|
|
RESP: 2,
|
|
defaults: { username: 'user' },
|
|
rootNodes: [
|
|
expect.objectContaining({
|
|
url: 'redis://user@host',
|
|
}),
|
|
],
|
|
});
|
|
});
|
|
|
|
it('initializes cluster client with password only', async () => {
|
|
await PackageCacheRedis.create('redis+cluster://:pass@host', '');
|
|
|
|
expect(createCluster).toHaveBeenCalledWith({
|
|
RESP: 2,
|
|
defaults: { password: 'pass' },
|
|
rootNodes: [
|
|
expect.objectContaining({
|
|
url: 'redis://:pass@host',
|
|
}),
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('get', () => {
|
|
// TODO: Delete this legacy JSON-wrapper read case once legacy.ts is
|
|
// removed.
|
|
it('returns value from legacy JSON-wrapper payload', async () => {
|
|
const cache = await PackageCacheRedis.create('redis://host', 'p:');
|
|
const value = { foo: 'bar' };
|
|
const payloadValue = await compressToBase64(JSON.stringify(value));
|
|
|
|
const payload = JSON.stringify({
|
|
value: payloadValue,
|
|
expiry: DateTime.local().plus({ minutes: 5 }),
|
|
});
|
|
binaryClientMock.get.mockResolvedValueOnce(Buffer.from(payload));
|
|
|
|
await expect(cache.get('_test-namespace', 'key')).resolves.toEqual(
|
|
value,
|
|
);
|
|
// Redis cleanup uses native TTL, so it must not rewrite on read.
|
|
expect(clientMock.set).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns value from envelope payload', async () => {
|
|
const cache = await PackageCacheRedis.create('redis://host', 'p:');
|
|
const value = { foo: 'bar' };
|
|
|
|
binaryClientMock.get.mockResolvedValueOnce(
|
|
await encodeEntry(value, DateTime.local()),
|
|
);
|
|
|
|
await expect(cache.get('_test-namespace', 'key')).resolves.toEqual(
|
|
value,
|
|
);
|
|
});
|
|
|
|
// TODO: Delete this legacy JSON-wrapper expiry case once legacy.ts is
|
|
// removed.
|
|
it('removes expired legacy JSON-wrapper entry', async () => {
|
|
const cache = await PackageCacheRedis.create('redis://host', 'p:');
|
|
const value = { foo: 'bar' };
|
|
const payloadValue = await compressToBase64(JSON.stringify(value));
|
|
|
|
const payload = JSON.stringify({
|
|
value: payloadValue,
|
|
expiry: DateTime.local().minus({ minutes: 1 }),
|
|
});
|
|
binaryClientMock.get.mockResolvedValueOnce(Buffer.from(payload));
|
|
|
|
await expect(
|
|
cache.get('_test-namespace', 'key'),
|
|
).resolves.toBeUndefined();
|
|
expect(clientMock.del).toHaveBeenCalledWith('p:_test-namespace-key');
|
|
});
|
|
|
|
// TODO: Delete this legacy JSON-wrapper malformed-entry case once
|
|
// legacy.ts is removed.
|
|
it('returns undefined for legacy JSON-wrapper payload with missing expiry', async () => {
|
|
const cache = await PackageCacheRedis.create('redis://host', 'p:');
|
|
const value = { foo: 'bar' };
|
|
const payloadValue = await compressToBase64(JSON.stringify(value));
|
|
|
|
const payload = JSON.stringify({ value: payloadValue });
|
|
binaryClientMock.get.mockResolvedValueOnce(Buffer.from(payload));
|
|
|
|
await expect(
|
|
cache.get('_test-namespace', 'key'),
|
|
).resolves.toBeUndefined();
|
|
expect(clientMock.del).toHaveBeenCalledWith('p:_test-namespace-key');
|
|
});
|
|
|
|
// TODO: Delete this legacy JSON-wrapper malformed-entry case once
|
|
// legacy.ts is removed.
|
|
it('returns undefined for legacy JSON-wrapper payload with invalid expiry', async () => {
|
|
const cache = await PackageCacheRedis.create('redis://host', 'p:');
|
|
const value = { foo: 'bar' };
|
|
const payloadValue = await compressToBase64(JSON.stringify(value));
|
|
|
|
const payload = JSON.stringify({
|
|
value: payloadValue,
|
|
expiry: 'not-a-date',
|
|
});
|
|
binaryClientMock.get.mockResolvedValueOnce(Buffer.from(payload));
|
|
|
|
await expect(
|
|
cache.get('_test-namespace', 'key'),
|
|
).resolves.toBeUndefined();
|
|
expect(clientMock.del).toHaveBeenCalledWith('p:_test-namespace-key');
|
|
});
|
|
|
|
it('removes invalid entries', async () => {
|
|
const cache = await PackageCacheRedis.create('redis://host', 'p:');
|
|
|
|
binaryClientMock.get.mockResolvedValueOnce(Buffer.from('garbage'));
|
|
|
|
await expect(
|
|
cache.get('_test-namespace', 'key'),
|
|
).resolves.toBeUndefined();
|
|
expect(logger.once.debug).toHaveBeenCalledWith(
|
|
{ err: expect.any(Error) },
|
|
'Error while reading package cache value',
|
|
);
|
|
expect(clientMock.del).toHaveBeenCalledWith('p:_test-namespace-key');
|
|
});
|
|
|
|
it('returns undefined when invalid entry removal fails', async () => {
|
|
const cache = await PackageCacheRedis.create('redis://host', 'p:');
|
|
|
|
binaryClientMock.get.mockResolvedValueOnce(Buffer.from('garbage'));
|
|
clientMock.del.mockRejectedValueOnce(new Error('delete failed'));
|
|
|
|
await expect(
|
|
cache.get('_test-namespace', 'key'),
|
|
).resolves.toBeUndefined();
|
|
expect(logger.once.debug).toHaveBeenCalledWith(
|
|
{ err: expect.any(Error) },
|
|
'Error while removing package cache value',
|
|
);
|
|
});
|
|
|
|
it('returns undefined on cache miss', async () => {
|
|
const cache = await PackageCacheRedis.create('redis://host', '');
|
|
|
|
binaryClientMock.get.mockResolvedValueOnce(null);
|
|
|
|
await expect(
|
|
cache.get('_test-namespace', 'key'),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('returns undefined on error', async () => {
|
|
const cache = await PackageCacheRedis.create('redis://host', '');
|
|
|
|
binaryClientMock.get.mockRejectedValueOnce(
|
|
new Error('connection lost'),
|
|
);
|
|
|
|
await expect(
|
|
cache.get('_test-namespace', 'key'),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('set', () => {
|
|
it('stores envelope payload with native TTL', async () => {
|
|
const cache = await PackageCacheRedis.create('redis://host', 'p:');
|
|
const value = { foo: 'bar' };
|
|
|
|
await cache.set('_test-namespace', 'key', value, 10);
|
|
|
|
const [, rawPayload] = vi.mocked(clientMock.set).mock.calls[0];
|
|
const decoded = await decodeEntry(rawPayload as Buffer);
|
|
|
|
expect(clientMock.set).toHaveBeenCalledWith(
|
|
'p:_test-namespace-key',
|
|
expect.any(Buffer),
|
|
{ EX: 600 },
|
|
);
|
|
expect(decoded.value).toEqual(value);
|
|
});
|
|
|
|
it('round-trips the written envelope payload', async () => {
|
|
const cache = await PackageCacheRedis.create('redis://host', 'p:');
|
|
const value = { foo: 'bar' };
|
|
|
|
await cache.set('_test-namespace', 'key', value, 10);
|
|
|
|
const [, rawPayload] = vi.mocked(clientMock.set).mock.calls[0];
|
|
binaryClientMock.get.mockResolvedValueOnce(rawPayload);
|
|
|
|
await expect(cache.get('_test-namespace', 'key')).resolves.toEqual(
|
|
value,
|
|
);
|
|
});
|
|
|
|
it('deletes entry with non-positive TTL', async () => {
|
|
const cache = await PackageCacheRedis.create('redis://host', 'p:');
|
|
|
|
await cache.set('_test-namespace', 'key', { foo: 'bar' }, -1);
|
|
|
|
expect(clientMock.del).toHaveBeenCalledWith('p:_test-namespace-key');
|
|
expect(clientMock.set).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('handles set error gracefully', async () => {
|
|
const cache = await PackageCacheRedis.create('redis://host', '');
|
|
|
|
clientMock.set.mockRejectedValueOnce(new Error('write error'));
|
|
|
|
await expect(
|
|
cache.set('_test-namespace', 'key', 'val', 5),
|
|
).resolves.not.toThrow();
|
|
expect(logger.once.warn).toHaveBeenCalledWith(
|
|
{ err: expect.any(Error) },
|
|
'Error while setting package cache value',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('destroy', () => {
|
|
it('destroys the client', async () => {
|
|
const cache = await PackageCacheRedis.create('redis://host', '');
|
|
|
|
await cache.destroy();
|
|
|
|
expect(clientMock.destroy).toHaveBeenCalled();
|
|
});
|
|
|
|
it('handles destroy error gracefully', async () => {
|
|
const cache = await PackageCacheRedis.create('redis://host', '');
|
|
|
|
clientMock.destroy.mockImplementation(() => {
|
|
throw new Error('destroy failed');
|
|
});
|
|
|
|
await expect(cache.destroy()).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
});
|
|
});
|