2
0

client.test.ts 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
  2. const mocks = vi.hoisted(() => {
  3. const state = { status: "ready" };
  4. const mockQuit = vi.fn().mockResolvedValue(undefined);
  5. const mockDisconnect = vi.fn();
  6. const mockOn = vi.fn().mockReturnThis();
  7. const mockInstance = {
  8. get status() {
  9. return state.status;
  10. },
  11. on: mockOn,
  12. quit: mockQuit,
  13. disconnect: mockDisconnect,
  14. };
  15. function MockRedisConstructor() {
  16. return mockInstance;
  17. }
  18. MockRedisConstructor.prototype = {};
  19. const MockRedis = vi.fn(MockRedisConstructor);
  20. return { MockRedis, mockInstance, mockOn, mockQuit, mockDisconnect, state };
  21. });
  22. vi.mock("ioredis", () => ({ default: mocks.MockRedis }));
  23. vi.mock("@/lib/logger", () => ({
  24. logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
  25. }));
  26. vi.mock("server-only", () => ({}));
  27. // eslint-disable-next-line import/order -- must come after vi.mock
  28. import { buildRedisOptionsForUrl, closeRedis, getRedisClient } from "@/lib/redis/client";
  29. describe("buildRedisOptionsForUrl", () => {
  30. it("detects TLS from rediss:// protocol", () => {
  31. const result = buildRedisOptionsForUrl("rediss://localhost:6380");
  32. expect(result.isTLS).toBe(true);
  33. expect(result.options.tls).toBeDefined();
  34. });
  35. it("does not enable TLS for redis:// protocol", () => {
  36. const result = buildRedisOptionsForUrl("redis://localhost:6379");
  37. expect(result.isTLS).toBe(false);
  38. expect(result.options.tls).toBeUndefined();
  39. });
  40. it("falls back to string-prefix detection for malformed URLs", () => {
  41. const result = buildRedisOptionsForUrl("rediss://not a valid url");
  42. expect(result.isTLS).toBe(true);
  43. });
  44. });
  45. describe("getRedisClient", () => {
  46. beforeEach(() => {
  47. vi.clearAllMocks();
  48. mocks.mockOn.mockReturnThis();
  49. mocks.state.status = "ready";
  50. process.env.REDIS_URL = "redis://localhost:6379";
  51. process.env.ENABLE_RATE_LIMIT = "true";
  52. delete process.env.NEXT_PHASE;
  53. });
  54. afterEach(async () => {
  55. await closeRedis();
  56. delete process.env.REDIS_URL;
  57. delete process.env.ENABLE_RATE_LIMIT;
  58. });
  59. it("returns null when REDIS_URL not configured", () => {
  60. delete process.env.REDIS_URL;
  61. expect(getRedisClient({ allowWhenRateLimitDisabled: true })).toBeNull();
  62. });
  63. it("returns null during production build phase", () => {
  64. process.env.NEXT_PHASE = "phase-production-build";
  65. expect(getRedisClient({ allowWhenRateLimitDisabled: true })).toBeNull();
  66. delete process.env.NEXT_PHASE;
  67. });
  68. it("returns null when rate limiting disabled without explicit allow", () => {
  69. process.env.ENABLE_RATE_LIMIT = "false";
  70. expect(getRedisClient()).toBeNull();
  71. });
  72. it("returns singleton on repeated calls", () => {
  73. const first = getRedisClient({ allowWhenRateLimitDisabled: true });
  74. const second = getRedisClient({ allowWhenRateLimitDisabled: true });
  75. expect(first).toBe(second);
  76. expect(mocks.MockRedis).toHaveBeenCalledTimes(1);
  77. });
  78. it("creates new client when existing singleton has status=end", () => {
  79. getRedisClient({ allowWhenRateLimitDisabled: true });
  80. mocks.state.status = "end";
  81. getRedisClient({ allowWhenRateLimitDisabled: true });
  82. expect(mocks.MockRedis).toHaveBeenCalledTimes(2);
  83. });
  84. it("registers 'end' event listener that resets singleton", () => {
  85. getRedisClient({ allowWhenRateLimitDisabled: true });
  86. const endCb = mocks.mockOn.mock.calls.find(([event]) => event === "end")?.[1];
  87. expect(endCb).toBeDefined();
  88. endCb();
  89. mocks.state.status = "ready";
  90. getRedisClient({ allowWhenRateLimitDisabled: true });
  91. expect(mocks.MockRedis).toHaveBeenCalledTimes(2);
  92. });
  93. });
  94. describe("closeRedis", () => {
  95. beforeEach(() => {
  96. vi.clearAllMocks();
  97. mocks.mockOn.mockReturnThis();
  98. mocks.mockQuit.mockResolvedValue(undefined);
  99. mocks.state.status = "ready";
  100. process.env.REDIS_URL = "redis://localhost:6379";
  101. process.env.ENABLE_RATE_LIMIT = "true";
  102. delete process.env.NEXT_PHASE;
  103. });
  104. afterEach(async () => {
  105. await closeRedis();
  106. delete process.env.REDIS_URL;
  107. delete process.env.ENABLE_RATE_LIMIT;
  108. });
  109. it("is a no-op when no client exists", async () => {
  110. await expect(closeRedis()).resolves.toBeUndefined();
  111. expect(mocks.mockQuit).not.toHaveBeenCalled();
  112. });
  113. it("calls quit and resets singleton", async () => {
  114. getRedisClient({ allowWhenRateLimitDisabled: true });
  115. await closeRedis();
  116. expect(mocks.mockQuit).toHaveBeenCalled();
  117. getRedisClient({ allowWhenRateLimitDisabled: true });
  118. expect(mocks.MockRedis).toHaveBeenCalledTimes(2);
  119. });
  120. it("falls back to disconnect when quit throws", async () => {
  121. mocks.mockQuit.mockRejectedValueOnce(new Error("quit failed"));
  122. getRedisClient({ allowWhenRateLimitDisabled: true });
  123. await closeRedis();
  124. expect(mocks.mockDisconnect).toHaveBeenCalled();
  125. });
  126. it("skips quit when client status is already 'end'", async () => {
  127. getRedisClient({ allowWhenRateLimitDisabled: true });
  128. mocks.state.status = "end";
  129. await closeRedis();
  130. expect(mocks.mockQuit).not.toHaveBeenCalled();
  131. });
  132. });