provider-restore.test.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. import { describe, expect, test, vi } from "vitest";
  2. type SelectRow = Record<string, unknown>;
  3. function createRestoreDbHarness(options: {
  4. selectQueue: SelectRow[][];
  5. updateReturningQueue?: SelectRow[][];
  6. }) {
  7. const selectQueue = [...options.selectQueue];
  8. const updateReturningQueue = [...(options.updateReturningQueue ?? [])];
  9. const selectLimitMock = vi.fn(async () => selectQueue.shift() ?? []);
  10. const selectOrderByMock = vi.fn(() => ({ limit: selectLimitMock }));
  11. const selectWhereMock = vi.fn(() => ({ limit: selectLimitMock, orderBy: selectOrderByMock }));
  12. const selectFromMock = vi.fn(() => ({ where: selectWhereMock }));
  13. const selectMock = vi.fn(() => ({ from: selectFromMock }));
  14. const updateReturningMock = vi.fn(async () => updateReturningQueue.shift() ?? []);
  15. const updateWhereMock = vi.fn(() => ({ returning: updateReturningMock }));
  16. const updateSetMock = vi.fn(() => ({ where: updateWhereMock }));
  17. const updateMock = vi.fn(() => ({ set: updateSetMock }));
  18. const tx = {
  19. select: selectMock,
  20. update: updateMock,
  21. };
  22. const transactionMock = vi.fn(async (runInTx: (trx: typeof tx) => Promise<unknown>) => {
  23. return runInTx(tx);
  24. });
  25. return {
  26. db: {
  27. transaction: transactionMock,
  28. select: selectMock,
  29. update: updateMock,
  30. },
  31. mocks: {
  32. transactionMock,
  33. selectLimitMock,
  34. updateMock,
  35. updateSetMock,
  36. },
  37. };
  38. }
  39. async function setupProviderRepository(options: {
  40. selectQueue: SelectRow[][];
  41. updateReturningQueue?: SelectRow[][];
  42. }) {
  43. vi.resetModules();
  44. const harness = createRestoreDbHarness(options);
  45. vi.doMock("@/drizzle/db", () => ({
  46. db: harness.db,
  47. }));
  48. vi.doMock("@/repository/provider-endpoints", () => ({
  49. ensureProviderEndpointExistsForUrl: vi.fn(),
  50. getOrCreateProviderVendorIdFromUrls: vi.fn(),
  51. syncProviderEndpointOnProviderEdit: vi.fn(),
  52. tryDeleteProviderVendorIfEmpty: vi.fn(),
  53. }));
  54. const repository = await import("../../../src/repository/provider");
  55. return {
  56. ...repository,
  57. harness,
  58. };
  59. }
  60. describe("provider repository restore", () => {
  61. test("restoreProvider restores recent soft-deleted provider and clears deletedAt", async () => {
  62. const deletedAt = new Date(Date.now() - 15_000);
  63. const { restoreProvider, harness } = await setupProviderRepository({
  64. selectQueue: [
  65. [
  66. {
  67. id: 1,
  68. providerVendorId: null,
  69. providerType: "claude",
  70. url: "https://api.example.com/v1",
  71. deletedAt,
  72. },
  73. ],
  74. ],
  75. updateReturningQueue: [[{ id: 1 }]],
  76. });
  77. const restored = await restoreProvider(1);
  78. expect(restored).toBe(true);
  79. expect(harness.mocks.transactionMock).toHaveBeenCalledTimes(1);
  80. expect(harness.mocks.updateMock).toHaveBeenCalledTimes(1);
  81. expect(harness.mocks.updateSetMock).toHaveBeenCalledWith(
  82. expect.objectContaining({
  83. deletedAt: null,
  84. updatedAt: expect.any(Date),
  85. })
  86. );
  87. });
  88. test("restoreProvider returns false when provider row is already restored concurrently", async () => {
  89. const deletedAt = new Date(Date.now() - 5_000);
  90. const { restoreProvider, harness } = await setupProviderRepository({
  91. selectQueue: [
  92. [
  93. {
  94. id: 31,
  95. providerVendorId: null,
  96. providerType: "claude",
  97. url: "https://api.example.com/v1",
  98. deletedAt,
  99. },
  100. ],
  101. ],
  102. updateReturningQueue: [[]],
  103. });
  104. const restored = await restoreProvider(31);
  105. expect(restored).toBe(false);
  106. expect(harness.mocks.updateMock).toHaveBeenCalledTimes(1);
  107. expect(harness.mocks.selectLimitMock).toHaveBeenCalledTimes(1);
  108. });
  109. test("restoreProvider rejects provider deleted more than 60 seconds ago", async () => {
  110. const deletedAt = new Date(Date.now() - 61_000);
  111. const { restoreProvider, harness } = await setupProviderRepository({
  112. selectQueue: [
  113. [
  114. {
  115. id: 2,
  116. providerVendorId: null,
  117. providerType: "claude",
  118. url: "https://api.example.com/v1",
  119. deletedAt,
  120. },
  121. ],
  122. ],
  123. updateReturningQueue: [[{ id: 2 }]],
  124. });
  125. const restored = await restoreProvider(2);
  126. expect(restored).toBe(false);
  127. expect(harness.mocks.updateMock).not.toHaveBeenCalled();
  128. });
  129. test("restoreProvidersBatch restores multiple providers in a single transaction", async () => {
  130. const recent = new Date(Date.now() - 10_000);
  131. const { restoreProvidersBatch, harness } = await setupProviderRepository({
  132. selectQueue: [
  133. [
  134. {
  135. id: 11,
  136. providerVendorId: null,
  137. providerType: "claude",
  138. url: "https://api.example.com/v1",
  139. deletedAt: recent,
  140. },
  141. ],
  142. [
  143. {
  144. id: 12,
  145. providerVendorId: null,
  146. providerType: "claude",
  147. url: "https://api.example.com/v1",
  148. deletedAt: recent,
  149. },
  150. ],
  151. [],
  152. ],
  153. updateReturningQueue: [[{ id: 11 }], [{ id: 12 }]],
  154. });
  155. const restoredCount = await restoreProvidersBatch([11, 12, 11, 13]);
  156. expect(restoredCount).toBe(2);
  157. expect(harness.mocks.transactionMock).toHaveBeenCalledTimes(1);
  158. expect(harness.mocks.selectLimitMock).toHaveBeenCalledTimes(3);
  159. expect(harness.mocks.updateMock).toHaveBeenCalledTimes(2);
  160. });
  161. test("restoreProvidersBatch should short-circuit for empty id list", async () => {
  162. const { restoreProvidersBatch, harness } = await setupProviderRepository({
  163. selectQueue: [],
  164. updateReturningQueue: [],
  165. });
  166. const restoredCount = await restoreProvidersBatch([]);
  167. expect(restoredCount).toBe(0);
  168. expect(harness.mocks.transactionMock).not.toHaveBeenCalled();
  169. });
  170. test("restoreProvider skips endpoint restoration when provider url is blank", async () => {
  171. const deletedAt = new Date(Date.now() - 8_000);
  172. const { restoreProvider, harness } = await setupProviderRepository({
  173. selectQueue: [
  174. [
  175. {
  176. id: 55,
  177. providerVendorId: 5,
  178. providerType: "claude",
  179. url: " ",
  180. deletedAt,
  181. },
  182. ],
  183. ],
  184. updateReturningQueue: [[{ id: 55 }]],
  185. });
  186. const restored = await restoreProvider(55);
  187. expect(restored).toBe(true);
  188. expect(harness.mocks.selectLimitMock).toHaveBeenCalledTimes(1);
  189. expect(harness.mocks.updateMock).toHaveBeenCalledTimes(1);
  190. });
  191. test("restoreProvider skips endpoint restoration when active provider reference exists", async () => {
  192. const deletedAt = new Date(Date.now() - 8_000);
  193. const { restoreProvider, harness } = await setupProviderRepository({
  194. selectQueue: [
  195. [
  196. {
  197. id: 66,
  198. providerVendorId: 8,
  199. providerType: "claude",
  200. url: "https://api.example.com/v1/messages",
  201. deletedAt,
  202. },
  203. ],
  204. [{ id: 999 }],
  205. ],
  206. updateReturningQueue: [[{ id: 66 }]],
  207. });
  208. const restored = await restoreProvider(66);
  209. expect(restored).toBe(true);
  210. expect(harness.mocks.selectLimitMock).toHaveBeenCalledTimes(2);
  211. expect(harness.mocks.updateMock).toHaveBeenCalledTimes(1);
  212. });
  213. test("restoreProvider skips endpoint restoration when no deleted endpoint can be matched", async () => {
  214. const deletedAt = new Date(Date.now() - 8_000);
  215. const { restoreProvider, harness } = await setupProviderRepository({
  216. selectQueue: [
  217. [
  218. {
  219. id: 67,
  220. providerVendorId: 8,
  221. providerType: "claude",
  222. url: "https://api.example.com/v1/messages",
  223. deletedAt,
  224. },
  225. ],
  226. [],
  227. [],
  228. [],
  229. ],
  230. updateReturningQueue: [[{ id: 67 }]],
  231. });
  232. const restored = await restoreProvider(67);
  233. expect(restored).toBe(true);
  234. expect(harness.mocks.selectLimitMock).toHaveBeenCalledTimes(4);
  235. expect(harness.mocks.updateMock).toHaveBeenCalledTimes(1);
  236. });
  237. test("restoreProvider skips endpoint restoration when active endpoint already exists", async () => {
  238. const deletedAt = new Date(Date.now() - 10_000);
  239. const { restoreProvider, harness } = await setupProviderRepository({
  240. selectQueue: [
  241. [
  242. {
  243. id: 77,
  244. providerVendorId: 9,
  245. providerType: "claude",
  246. url: "https://api.example.com/v1/messages",
  247. deletedAt,
  248. },
  249. ],
  250. [],
  251. [{ id: 9001 }],
  252. ],
  253. updateReturningQueue: [[{ id: 77 }]],
  254. });
  255. const restored = await restoreProvider(77);
  256. expect(restored).toBe(true);
  257. expect(harness.mocks.selectLimitMock).toHaveBeenCalledTimes(3);
  258. expect(harness.mocks.updateMock).toHaveBeenCalledTimes(1);
  259. });
  260. });