test: add tests for Redis lazy-init and startup retry logic
This commit is contained in:
5 files changed
+403
-1
No files matched your search
@@ -0,0 +1,108 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { handshakeAsync } from "@homarr/redis";
|
||||
|
||||
import { waitForRedisAsync } from "./main";
|
||||
|
||||
const { mockLogger } = vi.hoisted(() => ({
|
||||
mockLogger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./overrides", () => ({}));
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/logs", () => ({
|
||||
createLogger: () => mockLogger,
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/redis", () => ({
|
||||
handshakeAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/cron-jobs", () => ({
|
||||
jobGroup: {
|
||||
initializeAsync: vi.fn().mockResolvedValue(undefined),
|
||||
startAllAsync: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./on-start", () => ({
|
||||
onStartAsync: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
describe("waitForRedisAsync", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("succeeds on first attempt", async () => {
|
||||
vi.mocked(handshakeAsync).mockResolvedValueOnce(undefined);
|
||||
|
||||
await waitForRedisAsync();
|
||||
|
||||
expect(handshakeAsync).toHaveBeenCalledOnce();
|
||||
expect(mockLogger.info).toHaveBeenCalledWith("Redis connection established");
|
||||
});
|
||||
|
||||
test("retries on failure then succeeds", async () => {
|
||||
vi.mocked(handshakeAsync)
|
||||
.mockRejectedValueOnce(new Error("ECONNREFUSED"))
|
||||
.mockRejectedValueOnce(new Error("ECONNREFUSED"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
const promise = waitForRedisAsync();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await promise;
|
||||
|
||||
expect(handshakeAsync).toHaveBeenCalledTimes(3);
|
||||
expect(mockLogger.warn).toHaveBeenCalledTimes(2);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith("Redis connection established");
|
||||
});
|
||||
|
||||
test("logs error and proceeds after max retries exhausted", async () => {
|
||||
vi.mocked(handshakeAsync).mockRejectedValue(new Error("ECONNREFUSED"));
|
||||
|
||||
const promise = waitForRedisAsync();
|
||||
|
||||
for (let index = 0; index < 10; index++) {
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
}
|
||||
await promise;
|
||||
|
||||
expect(handshakeAsync).toHaveBeenCalledTimes(10);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith("Redis did not become ready after retries, proceeding anyway");
|
||||
expect(mockLogger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("warns with attempt count on each retry", async () => {
|
||||
vi.mocked(handshakeAsync).mockRejectedValueOnce(new Error("fail")).mockResolvedValueOnce(undefined);
|
||||
|
||||
const promise = waitForRedisAsync();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await promise;
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith("Redis not ready, retrying (1/10)...");
|
||||
});
|
||||
|
||||
test("does not retry after success", async () => {
|
||||
vi.mocked(handshakeAsync).mockResolvedValueOnce(undefined);
|
||||
|
||||
await waitForRedisAsync();
|
||||
|
||||
expect(handshakeAsync).toHaveBeenCalledOnce();
|
||||
expect(mockLogger.warn).not.toHaveBeenCalled();
|
||||
expect(mockLogger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,7 @@ const logger = createLogger({ module: "tasks" });
|
||||
const MAX_RETRIES = 10;
|
||||
const RETRY_DELAY_MS = 500;
|
||||
|
||||
const waitForRedisAsync = async () => {
|
||||
export const waitForRedisAsync = async () => {
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
await handshakeAsync();
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { ChannelSubscriptionTracker } from "./channel-subscription-tracker";
|
||||
import { createRedisConnection } from "./connection";
|
||||
|
||||
const mockRedisClient = {
|
||||
subscribe: vi.fn().mockResolvedValue("OK"),
|
||||
unsubscribe: vi.fn().mockResolvedValue("OK"),
|
||||
on: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/logs", () => ({
|
||||
createLogger: () => ({ debug: vi.fn(), warn: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("./connection", () => ({
|
||||
createRedisConnection: vi.fn(() => mockRedisClient),
|
||||
}));
|
||||
|
||||
describe("ChannelSubscriptionTracker", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset static state between tests
|
||||
// @ts-expect-error accessing private static for test reset
|
||||
ChannelSubscriptionTracker.subscriptions = new Map();
|
||||
// @ts-expect-error accessing private static for test reset
|
||||
ChannelSubscriptionTracker._redis = undefined;
|
||||
// @ts-expect-error accessing private static for test reset
|
||||
ChannelSubscriptionTracker.listenerActive = false;
|
||||
});
|
||||
|
||||
test("redis client is lazily created on first subscribe", () => {
|
||||
vi.mocked(createRedisConnection).mockClear();
|
||||
|
||||
ChannelSubscriptionTracker.subscribe("channel-1", vi.fn());
|
||||
|
||||
expect(createRedisConnection).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("redis client is reused across multiple subscribes", () => {
|
||||
vi.mocked(createRedisConnection).mockClear();
|
||||
|
||||
ChannelSubscriptionTracker.subscribe("channel-1", vi.fn());
|
||||
ChannelSubscriptionTracker.subscribe("channel-2", vi.fn());
|
||||
|
||||
expect(createRedisConnection).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("subscribes to redis channel on first callback for a channel", () => {
|
||||
ChannelSubscriptionTracker.subscribe("test-channel", vi.fn());
|
||||
|
||||
expect(mockRedisClient.subscribe).toHaveBeenCalledWith("test-channel");
|
||||
});
|
||||
|
||||
test("does not re-subscribe when adding second callback to same channel", () => {
|
||||
ChannelSubscriptionTracker.subscribe("test-channel", vi.fn());
|
||||
mockRedisClient.subscribe.mockClear();
|
||||
|
||||
ChannelSubscriptionTracker.subscribe("test-channel", vi.fn());
|
||||
|
||||
expect(mockRedisClient.subscribe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("unsubscribe function removes the callback", () => {
|
||||
const unsubscribe = ChannelSubscriptionTracker.subscribe("test-channel", vi.fn());
|
||||
|
||||
unsubscribe();
|
||||
|
||||
expect(mockRedisClient.unsubscribe).toHaveBeenCalledWith("test-channel");
|
||||
});
|
||||
|
||||
test("does not unsubscribe from redis if other callbacks remain", () => {
|
||||
ChannelSubscriptionTracker.subscribe("test-channel", vi.fn());
|
||||
const unsubscribe = ChannelSubscriptionTracker.subscribe("test-channel", vi.fn());
|
||||
|
||||
unsubscribe();
|
||||
|
||||
expect(mockRedisClient.unsubscribe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("activates message listener only once", () => {
|
||||
ChannelSubscriptionTracker.subscribe("ch-1", vi.fn());
|
||||
ChannelSubscriptionTracker.subscribe("ch-2", vi.fn());
|
||||
|
||||
expect(mockRedisClient.on).toHaveBeenCalledTimes(1);
|
||||
expect(mockRedisClient.on).toHaveBeenCalledWith("message", expect.any(Function));
|
||||
});
|
||||
|
||||
test("message listener dispatches to correct channel callbacks", () => {
|
||||
const callback1 = vi.fn();
|
||||
const callback2 = vi.fn();
|
||||
ChannelSubscriptionTracker.subscribe("channel-a", callback1);
|
||||
ChannelSubscriptionTracker.subscribe("channel-b", callback2);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const messageHandler = mockRedisClient.on.mock.calls[0]![1] as (channel: string, message: string) => void;
|
||||
messageHandler("channel-a", "test-message");
|
||||
|
||||
expect(callback1).toHaveBeenCalledWith("test-message");
|
||||
expect(callback2).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
createCacheChannel,
|
||||
createGetSetChannel,
|
||||
createListChannel,
|
||||
createSubPubChannel,
|
||||
handshakeAsync,
|
||||
} from "./channel";
|
||||
import { createRedisConnection } from "./connection";
|
||||
|
||||
const mockRedisClient = {
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
set: vi.fn().mockResolvedValue("OK"),
|
||||
del: vi.fn().mockResolvedValue(1),
|
||||
publish: vi.fn().mockResolvedValue(1),
|
||||
lrange: vi.fn().mockResolvedValue([]),
|
||||
lrem: vi.fn().mockResolvedValue(0),
|
||||
lpush: vi.fn().mockResolvedValue(1),
|
||||
ltrim: vi.fn().mockResolvedValue("OK"),
|
||||
llen: vi.fn().mockResolvedValue(0),
|
||||
hello: vi.fn().mockResolvedValue({}),
|
||||
subscribe: vi.fn().mockResolvedValue("OK"),
|
||||
on: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/logs", () => ({
|
||||
createLogger: () => ({ debug: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("./connection", () => ({
|
||||
createRedisConnection: vi.fn(() => mockRedisClient),
|
||||
}));
|
||||
|
||||
vi.mock("./channel-subscription-tracker", () => ({
|
||||
ChannelSubscriptionTracker: {
|
||||
subscribe: vi.fn(() => vi.fn()),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("channel lazy initialization", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(createRedisConnection).mockClear();
|
||||
});
|
||||
|
||||
test("getSetClient is lazily created on first channel operation", async () => {
|
||||
vi.mocked(createRedisConnection).mockClear();
|
||||
|
||||
const channel = createGetSetChannel<string>("test-lazy");
|
||||
expect(createRedisConnection).not.toHaveBeenCalled();
|
||||
|
||||
await channel.getAsync();
|
||||
|
||||
expect(createRedisConnection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("publisher is lazily created on first publish", async () => {
|
||||
const channel = createSubPubChannel<string>("test-pub", { persist: false });
|
||||
|
||||
const callsBefore = vi.mocked(createRedisConnection).mock.calls.length;
|
||||
await channel.publishAsync("hello");
|
||||
|
||||
expect(vi.mocked(createRedisConnection).mock.calls.length).toBeGreaterThan(callsBefore);
|
||||
});
|
||||
|
||||
test("handshakeAsync creates a client and calls hello", async () => {
|
||||
await handshakeAsync();
|
||||
|
||||
expect(mockRedisClient.hello).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createSubPubChannel", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("publishAsync persists data and publishes", async () => {
|
||||
const channel = createSubPubChannel<{ value: number }>("test-channel");
|
||||
|
||||
await channel.publishAsync({ value: 42 });
|
||||
|
||||
expect(mockRedisClient.set).toHaveBeenCalledOnce();
|
||||
expect(mockRedisClient.publish).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("publishAsync with persist:false skips set", async () => {
|
||||
const channel = createSubPubChannel<string>("no-persist", { persist: false });
|
||||
|
||||
await channel.publishAsync("data");
|
||||
|
||||
expect(mockRedisClient.set).not.toHaveBeenCalled();
|
||||
expect(mockRedisClient.publish).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("getLastDataAsync returns null when no data", async () => {
|
||||
const channel = createSubPubChannel<string>("empty");
|
||||
|
||||
const result = await channel.getLastDataAsync();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createListChannel", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("addAsync pushes to list", async () => {
|
||||
const channel = createListChannel<string>("test-list");
|
||||
|
||||
await channel.addAsync("item1");
|
||||
|
||||
expect(mockRedisClient.lpush).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("clearAsync deletes the list", async () => {
|
||||
const channel = createListChannel<string>("test-list");
|
||||
|
||||
await channel.clearAsync();
|
||||
|
||||
expect(mockRedisClient.del).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createCacheChannel", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("invalidateAsync deletes the cache key", async () => {
|
||||
const channel = createCacheChannel<string>("test-cache");
|
||||
|
||||
await channel.invalidateAsync();
|
||||
|
||||
expect(mockRedisClient.del).toHaveBeenCalledWith("cache:test-cache");
|
||||
});
|
||||
|
||||
test("getAsync returns null when no cached data", async () => {
|
||||
const channel = createCacheChannel<string>("test-cache");
|
||||
|
||||
const result = await channel.getAsync();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createRedisClient } from "@homarr/core/infrastructure/redis";
|
||||
|
||||
import { createRedisConnection } from "./connection";
|
||||
|
||||
const mockRedisInstance = { ping: vi.fn() };
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/redis", () => ({
|
||||
createRedisClient: vi.fn(() => mockRedisInstance),
|
||||
}));
|
||||
|
||||
describe("createRedisConnection", () => {
|
||||
test("returns a redis client regardless of CI env", () => {
|
||||
const original = process.env.CI;
|
||||
process.env.CI = "true";
|
||||
|
||||
const client = createRedisConnection();
|
||||
|
||||
expect(client).toBe(mockRedisInstance);
|
||||
expect(client).not.toBeNull();
|
||||
|
||||
process.env.CI = original;
|
||||
});
|
||||
|
||||
test("returns a redis client when DISABLE_REDIS_LOGS is set", () => {
|
||||
const original = process.env.DISABLE_REDIS_LOGS;
|
||||
process.env.DISABLE_REDIS_LOGS = "true";
|
||||
|
||||
const client = createRedisConnection();
|
||||
|
||||
expect(client).toBe(mockRedisInstance);
|
||||
expect(client).not.toBeNull();
|
||||
|
||||
process.env.DISABLE_REDIS_LOGS = original;
|
||||
});
|
||||
|
||||
test("always delegates to createRedisClient", () => {
|
||||
vi.mocked(createRedisClient).mockClear();
|
||||
|
||||
createRedisConnection();
|
||||
|
||||
expect(createRedisClient).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user