providers-undo-store.test.ts 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  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. vi.mock("@/lib/redis/client", () => ({
  7. getRedisClient: () => ({
  8. status: "ready",
  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("server-only", () => ({}));
  24. function buildSnapshot(overrides: Partial<Record<string, unknown>> = {}) {
  25. return {
  26. operationId: "op-1",
  27. operationType: "batch_edit" as const,
  28. preimage: { before: "state" },
  29. providerIds: [1, 2],
  30. createdAt: new Date().toISOString(),
  31. ...overrides,
  32. };
  33. }
  34. describe("providers undo store", () => {
  35. beforeEach(() => {
  36. vi.useFakeTimers();
  37. vi.setSystemTime(new Date("2026-02-18T00:00:00.000Z"));
  38. vi.resetModules();
  39. vi.clearAllMocks();
  40. setexMock.mockResolvedValue("OK");
  41. delMock.mockResolvedValue(1);
  42. });
  43. afterEach(() => {
  44. vi.restoreAllMocks();
  45. vi.useRealTimers();
  46. });
  47. it("stores snapshot and consumes token within TTL", async () => {
  48. const token = "11111111-1111-1111-1111-111111111111";
  49. vi.spyOn(crypto, "randomUUID").mockReturnValue(token);
  50. const snapshot = buildSnapshot();
  51. evalMock.mockResolvedValue(JSON.stringify(snapshot));
  52. const { storeUndoSnapshot, consumeUndoToken } = await import("@/lib/providers/undo-store");
  53. const storeResult = await storeUndoSnapshot(snapshot);
  54. expect(storeResult).toEqual({
  55. undoAvailable: true,
  56. undoToken: token,
  57. expiresAt: "2026-02-18T00:00:30.000Z",
  58. });
  59. expect(setexMock).toHaveBeenCalledWith(`cch:prov:undo:${token}`, 30, JSON.stringify(snapshot));
  60. const consumeResult = await consumeUndoToken(token);
  61. expect(consumeResult).toEqual({
  62. ok: true,
  63. snapshot,
  64. });
  65. expect(evalMock).toHaveBeenCalledWith(expect.any(String), 1, `cch:prov:undo:${token}`);
  66. });
  67. it("returns UNDO_EXPIRED when Redis returns null (TTL passed)", async () => {
  68. const token = "22222222-2222-2222-2222-222222222222";
  69. evalMock.mockResolvedValue(null);
  70. const { consumeUndoToken } = await import("@/lib/providers/undo-store");
  71. const consumeResult = await consumeUndoToken(token);
  72. expect(consumeResult).toEqual({
  73. ok: false,
  74. code: "UNDO_EXPIRED",
  75. });
  76. });
  77. it("consumes a token only once (getAndDelete)", async () => {
  78. const token = "33333333-3333-3333-3333-333333333333";
  79. vi.spyOn(crypto, "randomUUID").mockReturnValue(token);
  80. const snapshot = buildSnapshot({ operationId: "op-3" });
  81. const { storeUndoSnapshot, consumeUndoToken } = await import("@/lib/providers/undo-store");
  82. await storeUndoSnapshot(snapshot);
  83. evalMock.mockResolvedValueOnce(JSON.stringify(snapshot)).mockResolvedValueOnce(null);
  84. const first = await consumeUndoToken(token);
  85. const second = await consumeUndoToken(token);
  86. expect(first).toEqual({ ok: true, snapshot });
  87. expect(second).toEqual({ ok: false, code: "UNDO_EXPIRED" });
  88. });
  89. it("returns UNDO_EXPIRED for unknown token", async () => {
  90. evalMock.mockResolvedValue(null);
  91. const { consumeUndoToken } = await import("@/lib/providers/undo-store");
  92. const result = await consumeUndoToken("undo-token-missing");
  93. expect(result).toEqual({
  94. ok: false,
  95. code: "UNDO_EXPIRED",
  96. });
  97. });
  98. it("stores multiple snapshots with independent tokens", async () => {
  99. const tokenA = "44444444-4444-4444-4444-444444444444";
  100. const tokenB = "55555555-5555-5555-5555-555555555555";
  101. vi.spyOn(crypto, "randomUUID").mockReturnValueOnce(tokenA).mockReturnValueOnce(tokenB);
  102. const { storeUndoSnapshot, consumeUndoToken } = await import("@/lib/providers/undo-store");
  103. const snapshotA = buildSnapshot({ operationId: "op-4", providerIds: [11] });
  104. const snapshotB = buildSnapshot({
  105. operationId: "op-5",
  106. operationType: "single_edit",
  107. providerIds: [22, 23],
  108. });
  109. const storeA = await storeUndoSnapshot(snapshotA);
  110. const storeB = await storeUndoSnapshot(snapshotB);
  111. expect(storeA.undoToken).toBe(tokenA);
  112. expect(storeB.undoToken).toBe(tokenB);
  113. evalMock
  114. .mockResolvedValueOnce(JSON.stringify(snapshotA))
  115. .mockResolvedValueOnce(JSON.stringify(snapshotB));
  116. await expect(consumeUndoToken(tokenA)).resolves.toEqual({
  117. ok: true,
  118. snapshot: snapshotA,
  119. });
  120. await expect(consumeUndoToken(tokenB)).resolves.toEqual({
  121. ok: true,
  122. snapshot: snapshotB,
  123. });
  124. });
  125. it("fails open when storage backend throws", async () => {
  126. vi.spyOn(crypto, "randomUUID").mockImplementation(() => {
  127. throw new Error("uuid failed");
  128. });
  129. const { storeUndoSnapshot } = await import("@/lib/providers/undo-store");
  130. const result = await storeUndoSnapshot(buildSnapshot({ operationId: "op-6" }));
  131. expect(result).toEqual({ undoAvailable: false });
  132. });
  133. it("returns undoAvailable false when Redis set fails", async () => {
  134. const token = "66666666-6666-6666-6666-666666666666";
  135. vi.spyOn(crypto, "randomUUID").mockReturnValue(token);
  136. setexMock.mockRejectedValue(new Error("Redis write error"));
  137. const { storeUndoSnapshot } = await import("@/lib/providers/undo-store");
  138. const result = await storeUndoSnapshot(buildSnapshot({ operationId: "op-7" }));
  139. expect(result).toEqual({ undoAvailable: false });
  140. });
  141. });