provider-endpoint-sync-on-edit.test.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. import { describe, expect, test, vi } from "vitest";
  2. type ProviderRow = Record<string, unknown>;
  3. function createProviderRow(overrides: Partial<ProviderRow> = {}): ProviderRow {
  4. const now = new Date("2025-01-01T00:00:00.000Z");
  5. return {
  6. id: 1,
  7. name: "Provider A",
  8. url: "https://old.example.com/v1/messages",
  9. key: "test-key",
  10. providerVendorId: 11,
  11. isEnabled: true,
  12. weight: 1,
  13. priority: 0,
  14. costMultiplier: "1.0",
  15. groupTag: null,
  16. providerType: "claude",
  17. preserveClientIp: false,
  18. modelRedirects: null,
  19. allowedModels: null,
  20. mcpPassthroughType: "none",
  21. mcpPassthroughUrl: null,
  22. limit5hUsd: null,
  23. limitDailyUsd: null,
  24. dailyResetMode: "fixed",
  25. dailyResetTime: "00:00",
  26. limitWeeklyUsd: null,
  27. limitMonthlyUsd: null,
  28. limitTotalUsd: null,
  29. totalCostResetAt: null,
  30. limitConcurrentSessions: 0,
  31. maxRetryAttempts: null,
  32. circuitBreakerFailureThreshold: 5,
  33. circuitBreakerOpenDuration: 1800000,
  34. circuitBreakerHalfOpenSuccessThreshold: 2,
  35. proxyUrl: null,
  36. proxyFallbackToDirect: false,
  37. firstByteTimeoutStreamingMs: 30000,
  38. streamingIdleTimeoutMs: 10000,
  39. requestTimeoutNonStreamingMs: 600000,
  40. websiteUrl: "https://vendor.example.com",
  41. faviconUrl: null,
  42. cacheTtlPreference: null,
  43. context1mPreference: null,
  44. codexReasoningEffortPreference: null,
  45. codexReasoningSummaryPreference: null,
  46. codexTextVerbosityPreference: null,
  47. codexParallelToolCallsPreference: null,
  48. anthropicMaxTokensPreference: null,
  49. anthropicThinkingBudgetPreference: null,
  50. geminiGoogleSearchPreference: null,
  51. tpm: null,
  52. rpm: null,
  53. rpd: null,
  54. cc: null,
  55. createdAt: now,
  56. updatedAt: now,
  57. deletedAt: null,
  58. ...overrides,
  59. };
  60. }
  61. function createDbMock(currentRow: ProviderRow, updatedRow: ProviderRow) {
  62. const selectLimitMock = vi.fn(async () => [currentRow]);
  63. const selectWhereMock = vi.fn(() => ({ limit: selectLimitMock }));
  64. const selectFromMock = vi.fn(() => ({ where: selectWhereMock }));
  65. const selectMock = vi.fn(() => ({ from: selectFromMock }));
  66. const updateReturningMock = vi.fn(async () => [updatedRow]);
  67. const updateWhereMock = vi.fn(() => ({ returning: updateReturningMock }));
  68. const updateSetMock = vi.fn(() => ({ where: updateWhereMock }));
  69. const updateMock = vi.fn(() => ({ set: updateSetMock }));
  70. const tx = {
  71. select: selectMock,
  72. update: updateMock,
  73. };
  74. const transactionMock = vi.fn(async (runInTx: (trx: typeof tx) => Promise<unknown>) => {
  75. return runInTx(tx);
  76. });
  77. return {
  78. select: selectMock,
  79. update: updateMock,
  80. transaction: transactionMock,
  81. };
  82. }
  83. async function arrangeUrlEditRedScenario(input: {
  84. oldUrl: string;
  85. newUrl: string;
  86. previousVendorId?: number;
  87. nextVendorId?: number;
  88. }) {
  89. vi.resetModules();
  90. const previousVendorId = input.previousVendorId ?? 11;
  91. const nextVendorId = input.nextVendorId ?? previousVendorId;
  92. const currentRow = createProviderRow({
  93. id: 1,
  94. url: input.oldUrl,
  95. providerVendorId: previousVendorId,
  96. providerType: "claude",
  97. });
  98. const updatedRow = createProviderRow({
  99. id: 1,
  100. url: input.newUrl,
  101. providerVendorId: nextVendorId,
  102. providerType: "claude",
  103. });
  104. const db = createDbMock(currentRow, updatedRow);
  105. vi.doMock("@/drizzle/db", () => ({ db }));
  106. const getOrCreateProviderVendorIdFromUrlsMock = vi.fn(async () => nextVendorId);
  107. const ensureProviderEndpointExistsForUrlMock = vi.fn(async () => true);
  108. const tryDeleteProviderVendorIfEmptyMock = vi.fn(async () => false);
  109. const syncProviderEndpointOnProviderEditMock = vi.fn(
  110. async (): Promise<{ action: string; resetCircuitEndpointId?: number }> => ({ action: "noop" })
  111. );
  112. const resetEndpointCircuitMock = vi.fn(async () => {});
  113. vi.doMock("@/repository/provider-endpoints", () => ({
  114. getOrCreateProviderVendorIdFromUrls: getOrCreateProviderVendorIdFromUrlsMock,
  115. ensureProviderEndpointExistsForUrl: ensureProviderEndpointExistsForUrlMock,
  116. tryDeleteProviderVendorIfEmpty: tryDeleteProviderVendorIfEmptyMock,
  117. syncProviderEndpointOnProviderEdit: syncProviderEndpointOnProviderEditMock,
  118. }));
  119. vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
  120. resetEndpointCircuit: resetEndpointCircuitMock,
  121. }));
  122. const { updateProvider } = await import("@/repository/provider");
  123. return {
  124. updateProvider,
  125. mocks: {
  126. ensureProviderEndpointExistsForUrlMock,
  127. syncProviderEndpointOnProviderEditMock,
  128. tryDeleteProviderVendorIfEmptyMock,
  129. resetEndpointCircuitMock,
  130. },
  131. };
  132. }
  133. describe("provider repository - endpoint sync on edit (#722 RED)", () => {
  134. test("old-url exists + new-url absent: should update endpoint row instead of insert-only ensure", async () => {
  135. const oldUrl = "https://old.example.com/v1/messages";
  136. const newUrl = "https://new.example.com/v1/messages";
  137. const { updateProvider, mocks } = await arrangeUrlEditRedScenario({ oldUrl, newUrl });
  138. const provider = await updateProvider(1, { url: newUrl });
  139. expect(provider?.url).toBe(newUrl);
  140. expect(mocks.syncProviderEndpointOnProviderEditMock).toHaveBeenCalledWith(
  141. expect.objectContaining({
  142. providerId: 1,
  143. vendorId: 11,
  144. providerType: "claude",
  145. previousUrl: oldUrl,
  146. nextUrl: newUrl,
  147. }),
  148. expect.objectContaining({ tx: expect.any(Object) })
  149. );
  150. });
  151. test("sync result with reset endpoint id should reset circuit after update commit", async () => {
  152. const oldUrl = "https://old.example.com/v1/messages";
  153. const newUrl = "https://new.example.com/v1/messages";
  154. const { updateProvider, mocks } = await arrangeUrlEditRedScenario({ oldUrl, newUrl });
  155. mocks.syncProviderEndpointOnProviderEditMock.mockResolvedValueOnce({
  156. action: "updated-previous-in-place",
  157. resetCircuitEndpointId: 7,
  158. });
  159. await updateProvider(1, { url: newUrl });
  160. expect(mocks.resetEndpointCircuitMock).toHaveBeenCalledTimes(1);
  161. expect(mocks.resetEndpointCircuitMock).toHaveBeenCalledWith(7);
  162. });
  163. test("old-url exists + new-url exists: should avoid duplicate accumulation and not call insert-only ensure", async () => {
  164. const oldUrl = "https://old.example.com/v1/messages";
  165. const newUrl = "https://new.example.com/v1/messages";
  166. const { updateProvider, mocks } = await arrangeUrlEditRedScenario({ oldUrl, newUrl });
  167. await updateProvider(1, { url: newUrl });
  168. expect(mocks.ensureProviderEndpointExistsForUrlMock).not.toHaveBeenCalled();
  169. });
  170. test("old-url still referenced by another active provider: should keep old-url endpoint (safe cleanup guard)", async () => {
  171. const oldUrl = "https://shared.example.com/v1/messages";
  172. const newUrl = "https://new.example.com/v1/messages";
  173. const { updateProvider, mocks } = await arrangeUrlEditRedScenario({ oldUrl, newUrl });
  174. await updateProvider(1, { url: newUrl });
  175. expect(mocks.syncProviderEndpointOnProviderEditMock).toHaveBeenCalledWith(
  176. expect.objectContaining({
  177. previousUrl: oldUrl,
  178. nextUrl: newUrl,
  179. keepPreviousWhenReferenced: true,
  180. }),
  181. expect.objectContaining({ tx: expect.any(Object) })
  182. );
  183. expect(mocks.tryDeleteProviderVendorIfEmptyMock).not.toHaveBeenCalled();
  184. });
  185. test("endpoint sync throw: should bubble error instead of silent partial success", async () => {
  186. const oldUrl = "https://old.example.com/v1/messages";
  187. const newUrl = "https://new.example.com/v1/messages";
  188. const { updateProvider, mocks } = await arrangeUrlEditRedScenario({ oldUrl, newUrl });
  189. mocks.syncProviderEndpointOnProviderEditMock.mockRejectedValueOnce(new Error("sync failed"));
  190. await expect(updateProvider(1, { url: newUrl })).rejects.toThrow("sync failed");
  191. expect(mocks.tryDeleteProviderVendorIfEmptyMock).not.toHaveBeenCalled();
  192. });
  193. test("vendor cleanup failure should not block provider update", async () => {
  194. const oldUrl = "https://old-vendor.example.com/v1/messages";
  195. const newUrl = "https://new-vendor.example.com/v1/messages";
  196. const { updateProvider, mocks } = await arrangeUrlEditRedScenario({
  197. oldUrl,
  198. newUrl,
  199. previousVendorId: 11,
  200. nextVendorId: 22,
  201. });
  202. mocks.tryDeleteProviderVendorIfEmptyMock.mockRejectedValueOnce(new Error("cleanup failed"));
  203. const provider = await updateProvider(1, { url: newUrl });
  204. expect(provider?.providerVendorId).toBe(22);
  205. expect(mocks.tryDeleteProviderVendorIfEmptyMock).toHaveBeenCalledWith(11);
  206. });
  207. });