Files
renovate/lib/util/cache/package/impl/redis.spec.ts
renovate[bot] 619ce40237 build(deps): update dependency @redis/client to v6 (main) (#45789)
* 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>
2026-09-10 07:15:13 +00:00

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();
});
});
});
});