diff --git a/apps/tasks/src/main.spec.ts b/apps/tasks/src/main.spec.ts new file mode 100644 index 000000000..c0964f215 --- /dev/null +++ b/apps/tasks/src/main.spec.ts @@ -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(); + }); +}); diff --git a/apps/tasks/src/main.ts b/apps/tasks/src/main.ts index 40496a39b..6ae328186 100644 --- a/apps/tasks/src/main.ts +++ b/apps/tasks/src/main.ts @@ -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(); diff --git a/packages/redis/src/lib/channel-subscription-tracker.spec.ts b/packages/redis/src/lib/channel-subscription-tracker.spec.ts new file mode 100644 index 000000000..219e5ca66 --- /dev/null +++ b/packages/redis/src/lib/channel-subscription-tracker.spec.ts @@ -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(); + }); +}); diff --git a/packages/redis/src/lib/channel.spec.ts b/packages/redis/src/lib/channel.spec.ts new file mode 100644 index 000000000..0f71b2969 --- /dev/null +++ b/packages/redis/src/lib/channel.spec.ts @@ -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("test-lazy"); + expect(createRedisConnection).not.toHaveBeenCalled(); + + await channel.getAsync(); + + expect(createRedisConnection).toHaveBeenCalled(); + }); + + test("publisher is lazily created on first publish", async () => { + const channel = createSubPubChannel("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("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("empty"); + + const result = await channel.getLastDataAsync(); + + expect(result).toBeNull(); + }); +}); + +describe("createListChannel", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("addAsync pushes to list", async () => { + const channel = createListChannel("test-list"); + + await channel.addAsync("item1"); + + expect(mockRedisClient.lpush).toHaveBeenCalledOnce(); + }); + + test("clearAsync deletes the list", async () => { + const channel = createListChannel("test-list"); + + await channel.clearAsync(); + + expect(mockRedisClient.del).toHaveBeenCalledOnce(); + }); +}); + +describe("createCacheChannel", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("invalidateAsync deletes the cache key", async () => { + const channel = createCacheChannel("test-cache"); + + await channel.invalidateAsync(); + + expect(mockRedisClient.del).toHaveBeenCalledWith("cache:test-cache"); + }); + + test("getAsync returns null when no cached data", async () => { + const channel = createCacheChannel("test-cache"); + + const result = await channel.getAsync(); + + expect(result).toBeNull(); + }); +}); diff --git a/packages/redis/src/lib/connection.spec.ts b/packages/redis/src/lib/connection.spec.ts new file mode 100644 index 000000000..5ed307028 --- /dev/null +++ b/packages/redis/src/lib/connection.spec.ts @@ -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(); + }); +});