redis-kv-store.test.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
  2. const setexMock = vi.fn();
  3. const getMock = vi.fn();
  4. const delMock = vi.fn();
  5. const evalMock = vi.fn();
  6. function createMockRedis(status = "ready") {
  7. return {
  8. status,
  9. setex: setexMock,
  10. get: getMock,
  11. del: delMock,
  12. eval: evalMock,
  13. };
  14. }
  15. vi.mock("@/lib/logger", () => ({
  16. logger: {
  17. error: vi.fn(),
  18. warn: vi.fn(),
  19. info: vi.fn(),
  20. debug: vi.fn(),
  21. },
  22. }));
  23. vi.mock("@/lib/redis/client", () => ({
  24. getRedisClient: vi.fn(),
  25. }));
  26. vi.mock("server-only", () => ({}));
  27. describe("RedisKVStore", () => {
  28. beforeEach(() => {
  29. vi.clearAllMocks();
  30. });
  31. afterEach(() => {
  32. vi.restoreAllMocks();
  33. });
  34. async function createStore<T>(options?: { status?: string }) {
  35. const { RedisKVStore } = await import("@/lib/redis/redis-kv-store");
  36. const redis = createMockRedis(options?.status);
  37. return {
  38. store: new RedisKVStore<T>({
  39. prefix: "test:",
  40. defaultTtlSeconds: 60,
  41. redisClient: redis,
  42. }),
  43. redis,
  44. };
  45. }
  46. describe("set", () => {
  47. it("stores value with SETEX and default TTL", async () => {
  48. const { store } = await createStore<{ name: string }>();
  49. setexMock.mockResolvedValue("OK");
  50. const result = await store.set("key1", { name: "alice" });
  51. expect(result).toBe(true);
  52. expect(setexMock).toHaveBeenCalledWith("test:key1", 60, JSON.stringify({ name: "alice" }));
  53. });
  54. it("uses custom TTL when provided", async () => {
  55. const { store } = await createStore<string>();
  56. setexMock.mockResolvedValue("OK");
  57. await store.set("key2", "value", 30);
  58. expect(setexMock).toHaveBeenCalledWith("test:key2", 30, JSON.stringify("value"));
  59. });
  60. it("returns false when Redis is not ready", async () => {
  61. const { store } = await createStore<string>({ status: "connecting" });
  62. const result = await store.set("key3", "value");
  63. expect(result).toBe(false);
  64. expect(setexMock).not.toHaveBeenCalled();
  65. });
  66. it("returns false when SETEX throws", async () => {
  67. const { store } = await createStore<string>();
  68. setexMock.mockRejectedValue(new Error("Redis write error"));
  69. const result = await store.set("key4", "value");
  70. expect(result).toBe(false);
  71. });
  72. });
  73. describe("get", () => {
  74. it("retrieves and deserializes stored value", async () => {
  75. const { store } = await createStore<{ count: number }>();
  76. getMock.mockResolvedValue(JSON.stringify({ count: 42 }));
  77. const result = await store.get("key1");
  78. expect(result).toEqual({ count: 42 });
  79. expect(getMock).toHaveBeenCalledWith("test:key1");
  80. });
  81. it("returns null for missing key", async () => {
  82. const { store } = await createStore<string>();
  83. getMock.mockResolvedValue(null);
  84. const result = await store.get("missing");
  85. expect(result).toBeNull();
  86. });
  87. it("returns null when Redis is not ready", async () => {
  88. const { store } = await createStore<string>({ status: "connecting" });
  89. const result = await store.get("key1");
  90. expect(result).toBeNull();
  91. expect(getMock).not.toHaveBeenCalled();
  92. });
  93. it("returns null when GET throws", async () => {
  94. const { store } = await createStore<string>();
  95. getMock.mockRejectedValue(new Error("Redis read error"));
  96. const result = await store.get("key1");
  97. expect(result).toBeNull();
  98. });
  99. it("returns null when stored value is malformed JSON", async () => {
  100. const { store } = await createStore<{ count: number }>();
  101. getMock.mockResolvedValue("not-valid-json");
  102. const result = await store.get("corrupted");
  103. expect(result).toBeNull();
  104. });
  105. });
  106. describe("getAndDelete", () => {
  107. it("atomically retrieves and deletes key via Lua script", async () => {
  108. const { store } = await createStore<{ id: string }>();
  109. evalMock.mockResolvedValue(JSON.stringify({ id: "abc" }));
  110. const result = await store.getAndDelete("key1");
  111. expect(result).toEqual({ id: "abc" });
  112. expect(evalMock).toHaveBeenCalledWith(expect.any(String), 1, "test:key1");
  113. });
  114. it("returns null for missing key", async () => {
  115. const { store } = await createStore<string>();
  116. evalMock.mockResolvedValue(null);
  117. const result = await store.getAndDelete("missing");
  118. expect(result).toBeNull();
  119. });
  120. it("returns null when Redis is not ready", async () => {
  121. const { store } = await createStore<string>({ status: "end" });
  122. const result = await store.getAndDelete("key1");
  123. expect(result).toBeNull();
  124. });
  125. it("returns null when eval throws", async () => {
  126. const { store } = await createStore<string>();
  127. evalMock.mockRejectedValue(new Error("Redis eval error"));
  128. const result = await store.getAndDelete("key1");
  129. expect(result).toBeNull();
  130. });
  131. it("returns null when stored value is malformed JSON", async () => {
  132. const { store } = await createStore<{ count: number }>();
  133. evalMock.mockResolvedValue("{invalid json...");
  134. const result = await store.getAndDelete("corrupted-key");
  135. expect(result).toBeNull();
  136. });
  137. });
  138. describe("delete", () => {
  139. it("deletes key and returns true when key existed", async () => {
  140. const { store } = await createStore<string>();
  141. delMock.mockResolvedValue(1);
  142. const result = await store.delete("key1");
  143. expect(result).toBe(true);
  144. expect(delMock).toHaveBeenCalledWith("test:key1");
  145. });
  146. it("returns false when key did not exist", async () => {
  147. const { store } = await createStore<string>();
  148. delMock.mockResolvedValue(0);
  149. const result = await store.delete("missing");
  150. expect(result).toBe(false);
  151. });
  152. it("returns false when Redis is not ready", async () => {
  153. const { store } = await createStore<string>({ status: "connecting" });
  154. const result = await store.delete("key1");
  155. expect(result).toBe(false);
  156. });
  157. it("returns false when DEL throws", async () => {
  158. const { store } = await createStore<string>();
  159. delMock.mockRejectedValue(new Error("Redis delete error"));
  160. const result = await store.delete("key1");
  161. expect(result).toBe(false);
  162. });
  163. });
  164. describe("key prefixing", () => {
  165. it("prepends prefix to all operations", async () => {
  166. const { store } = await createStore<string>();
  167. setexMock.mockResolvedValue("OK");
  168. getMock.mockResolvedValue(null);
  169. delMock.mockResolvedValue(0);
  170. await store.set("mykey", "val");
  171. await store.get("mykey");
  172. await store.delete("mykey");
  173. expect(setexMock).toHaveBeenCalledWith("test:mykey", expect.any(Number), expect.any(String));
  174. expect(getMock).toHaveBeenCalledWith("test:mykey");
  175. expect(delMock).toHaveBeenCalledWith("test:mykey");
  176. });
  177. });
  178. describe("injected client", () => {
  179. it("returns null for all ops when injected client is null", async () => {
  180. const { RedisKVStore } = await import("@/lib/redis/redis-kv-store");
  181. const store = new RedisKVStore<string>({
  182. prefix: "test:",
  183. defaultTtlSeconds: 60,
  184. redisClient: null,
  185. });
  186. expect(await store.set("k", "v")).toBe(false);
  187. expect(await store.get("k")).toBeNull();
  188. expect(await store.getAndDelete("k")).toBeNull();
  189. expect(await store.delete("k")).toBe(false);
  190. });
  191. });
  192. });