provider-endpoints.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492
  1. import { beforeEach, describe, expect, it, vi } from "vitest";
  2. const getSessionMock = vi.fn();
  3. const updateProviderVendorMock = vi.fn();
  4. const deleteProviderVendorMock = vi.fn();
  5. const publishProviderCacheInvalidationMock = vi.fn();
  6. const findProviderEndpointByIdMock = vi.fn();
  7. const softDeleteProviderEndpointMock = vi.fn();
  8. const tryDeleteProviderVendorIfEmptyMock = vi.fn();
  9. const updateProviderEndpointMock = vi.fn();
  10. const findProviderEndpointProbeLogsBatchMock = vi.fn();
  11. const findVendorTypeEndpointStatsBatchMock = vi.fn();
  12. const hasEnabledProviderReferenceForVendorTypeUrlMock = vi.fn();
  13. const findDashboardProviderEndpointsByVendorAndTypeMock = vi.fn();
  14. vi.mock("@/lib/auth", () => ({
  15. getSession: getSessionMock,
  16. }));
  17. vi.mock("@/lib/cache/provider-cache", () => ({
  18. publishProviderCacheInvalidation: publishProviderCacheInvalidationMock,
  19. }));
  20. vi.mock("@/lib/logger", () => ({
  21. logger: {
  22. trace: vi.fn(),
  23. debug: vi.fn(),
  24. info: vi.fn(),
  25. warn: vi.fn(),
  26. error: vi.fn(),
  27. },
  28. }));
  29. vi.mock("@/lib/endpoint-circuit-breaker", () => ({
  30. getAllEndpointHealthStatusAsync: vi.fn(async () => ({})),
  31. getEndpointHealthInfo: vi.fn(async () => ({ health: {}, config: {} })),
  32. resetEndpointCircuit: vi.fn(async () => {}),
  33. }));
  34. vi.mock("@/lib/vendor-type-circuit-breaker", () => ({
  35. getVendorTypeCircuitInfo: vi.fn(async () => ({
  36. vendorId: 1,
  37. providerType: "claude",
  38. circuitState: "closed",
  39. circuitOpenUntil: null,
  40. lastFailureTime: null,
  41. manualOpen: false,
  42. })),
  43. resetVendorTypeCircuit: vi.fn(async () => {}),
  44. setVendorTypeCircuitManualOpen: vi.fn(async () => {}),
  45. }));
  46. vi.mock("@/lib/provider-endpoints/probe", () => ({
  47. probeProviderEndpointAndRecordByEndpoint: vi.fn(async () => null),
  48. }));
  49. vi.mock("@/repository/provider-endpoints-batch", () => ({
  50. findProviderEndpointProbeLogsBatch: findProviderEndpointProbeLogsBatchMock,
  51. findVendorTypeEndpointStatsBatch: findVendorTypeEndpointStatsBatchMock,
  52. }));
  53. vi.mock("@/repository/provider-endpoints", () => ({
  54. findDashboardProviderEndpointsByVendorAndType: findDashboardProviderEndpointsByVendorAndTypeMock,
  55. findEnabledProviderVendorTypePairs: vi.fn(async () => []),
  56. hasEnabledProviderReferenceForVendorTypeUrl: hasEnabledProviderReferenceForVendorTypeUrlMock,
  57. }));
  58. vi.mock("@/repository", () => ({
  59. createProviderEndpoint: vi.fn(async () => ({})),
  60. deleteProviderVendor: deleteProviderVendorMock,
  61. findProviderEndpointById: findProviderEndpointByIdMock,
  62. findProviderEndpointProbeLogs: vi.fn(async () => []),
  63. findProviderEndpointsByVendorAndType: vi.fn(async () => []),
  64. findProviderVendorById: vi.fn(async () => null),
  65. findProviderVendors: vi.fn(async () => []),
  66. softDeleteProviderEndpoint: softDeleteProviderEndpointMock,
  67. tryDeleteProviderVendorIfEmpty: tryDeleteProviderVendorIfEmptyMock,
  68. updateProviderEndpoint: updateProviderEndpointMock,
  69. updateProviderVendor: updateProviderVendorMock,
  70. }));
  71. describe("provider-endpoints actions", () => {
  72. beforeEach(() => {
  73. vi.clearAllMocks();
  74. hasEnabledProviderReferenceForVendorTypeUrlMock.mockResolvedValue(false);
  75. findDashboardProviderEndpointsByVendorAndTypeMock.mockResolvedValue([]);
  76. });
  77. it("editProviderVendor: requires admin", async () => {
  78. getSessionMock.mockResolvedValue({ user: { role: "user" } });
  79. const { editProviderVendor } = await import("@/actions/provider-endpoints");
  80. const res = await editProviderVendor({ vendorId: 1, displayName: "x" });
  81. expect(res.ok).toBe(false);
  82. expect(res.errorCode).toBe("PERMISSION_DENIED");
  83. });
  84. it("getDashboardProviderEndpoints: requires admin", async () => {
  85. getSessionMock.mockResolvedValue({ user: { role: "user" } });
  86. const { getDashboardProviderEndpoints } = await import("@/actions/provider-endpoints");
  87. const res = await getDashboardProviderEndpoints({ vendorId: 1, providerType: "claude" });
  88. expect(res).toEqual([]);
  89. expect(findDashboardProviderEndpointsByVendorAndTypeMock).not.toHaveBeenCalled();
  90. });
  91. it("getDashboardProviderEndpoints: invalid input returns empty list", async () => {
  92. getSessionMock.mockResolvedValue({ user: { role: "admin" } });
  93. const { getDashboardProviderEndpoints } = await import("@/actions/provider-endpoints");
  94. const res = await getDashboardProviderEndpoints({ vendorId: 0, providerType: "claude" });
  95. expect(res).toEqual([]);
  96. expect(findDashboardProviderEndpointsByVendorAndTypeMock).not.toHaveBeenCalled();
  97. });
  98. it("getDashboardProviderEndpoints: returns endpoints in use for enabled providers", async () => {
  99. getSessionMock.mockResolvedValue({ user: { role: "admin" } });
  100. const endpoints = [
  101. {
  102. id: 1,
  103. vendorId: 10,
  104. providerType: "claude",
  105. url: "https://api.example.com",
  106. label: null,
  107. sortOrder: 0,
  108. isEnabled: true,
  109. lastProbedAt: null,
  110. lastProbeOk: null,
  111. lastProbeStatusCode: null,
  112. lastProbeLatencyMs: null,
  113. lastProbeErrorType: null,
  114. lastProbeErrorMessage: null,
  115. createdAt: new Date(),
  116. updatedAt: new Date(),
  117. deletedAt: null,
  118. },
  119. ];
  120. findDashboardProviderEndpointsByVendorAndTypeMock.mockResolvedValue(endpoints);
  121. const { getDashboardProviderEndpoints } = await import("@/actions/provider-endpoints");
  122. const res = await getDashboardProviderEndpoints({ vendorId: 10, providerType: "claude" });
  123. expect(res).toEqual(endpoints);
  124. expect(findDashboardProviderEndpointsByVendorAndTypeMock).toHaveBeenCalledWith(10, "claude");
  125. });
  126. it("editProviderVendor: computes favicon", async () => {
  127. getSessionMock.mockResolvedValue({ user: { role: "admin" } });
  128. updateProviderVendorMock.mockResolvedValue({
  129. id: 1,
  130. websiteDomain: "example.com",
  131. displayName: "Example",
  132. websiteUrl: "https://example.com/path",
  133. faviconUrl: "https://www.google.com/s2/favicons?domain=example.com&sz=32",
  134. createdAt: new Date(),
  135. updatedAt: new Date(),
  136. });
  137. const { editProviderVendor } = await import("@/actions/provider-endpoints");
  138. const res = await editProviderVendor({
  139. vendorId: 1,
  140. displayName: "Example",
  141. websiteUrl: "https://example.com/path",
  142. });
  143. expect(res.ok).toBe(true);
  144. expect(updateProviderVendorMock).toHaveBeenCalledWith(
  145. 1,
  146. expect.objectContaining({
  147. displayName: "Example",
  148. websiteUrl: "https://example.com/path",
  149. faviconUrl: "https://www.google.com/s2/favicons?domain=example.com&sz=32",
  150. })
  151. );
  152. });
  153. it("editProviderVendor: clearing websiteUrl clears faviconUrl", async () => {
  154. getSessionMock.mockResolvedValue({ user: { role: "admin" } });
  155. updateProviderVendorMock.mockResolvedValue({
  156. id: 1,
  157. websiteDomain: "example.com",
  158. displayName: null,
  159. websiteUrl: null,
  160. faviconUrl: null,
  161. createdAt: new Date(),
  162. updatedAt: new Date(),
  163. });
  164. const { editProviderVendor } = await import("@/actions/provider-endpoints");
  165. const res = await editProviderVendor({
  166. vendorId: 1,
  167. websiteUrl: null,
  168. });
  169. expect(res.ok).toBe(true);
  170. expect(updateProviderVendorMock).toHaveBeenCalledWith(
  171. 1,
  172. expect.objectContaining({
  173. websiteUrl: null,
  174. faviconUrl: null,
  175. })
  176. );
  177. });
  178. it("editProviderEndpoint: conflict maps to CONFLICT errorCode", async () => {
  179. getSessionMock.mockResolvedValue({ user: { role: "admin" } });
  180. findProviderEndpointByIdMock.mockResolvedValue({
  181. id: 42,
  182. vendorId: 123,
  183. providerType: "claude",
  184. url: "https://api.example.com",
  185. label: null,
  186. sortOrder: 0,
  187. isEnabled: true,
  188. lastProbedAt: null,
  189. lastProbeOk: null,
  190. lastProbeStatusCode: null,
  191. lastProbeLatencyMs: null,
  192. lastProbeErrorType: null,
  193. lastProbeErrorMessage: null,
  194. createdAt: new Date(),
  195. updatedAt: new Date(),
  196. deletedAt: null,
  197. });
  198. updateProviderEndpointMock.mockRejectedValue(
  199. Object.assign(new Error("[ProviderEndpointEdit] endpoint conflict"), {
  200. code: "PROVIDER_ENDPOINT_CONFLICT",
  201. })
  202. );
  203. const { editProviderEndpoint } = await import("@/actions/provider-endpoints");
  204. const res = await editProviderEndpoint({
  205. endpointId: 42,
  206. url: "https://next.example.com/v1/messages",
  207. });
  208. expect(res.ok).toBe(false);
  209. expect(res.errorCode).toBe("CONFLICT");
  210. expect(res.error).not.toContain("duplicate key value");
  211. });
  212. it("editProviderEndpoint: success returns ok with endpoint payload", async () => {
  213. getSessionMock.mockResolvedValue({ user: { role: "admin" } });
  214. const endpoint = {
  215. id: 42,
  216. vendorId: 123,
  217. providerType: "claude" as const,
  218. url: "https://next.example.com/v1/messages",
  219. label: "primary",
  220. sortOrder: 7,
  221. isEnabled: false,
  222. lastProbedAt: null,
  223. lastProbeOk: null,
  224. lastProbeStatusCode: null,
  225. lastProbeLatencyMs: null,
  226. lastProbeErrorType: null,
  227. lastProbeErrorMessage: null,
  228. createdAt: new Date("2026-01-01T00:00:00.000Z"),
  229. updatedAt: new Date("2026-01-01T00:00:00.000Z"),
  230. deletedAt: null,
  231. };
  232. findProviderEndpointByIdMock.mockResolvedValue(endpoint);
  233. updateProviderEndpointMock.mockResolvedValue(endpoint);
  234. const { editProviderEndpoint } = await import("@/actions/provider-endpoints");
  235. const res = await editProviderEndpoint({
  236. endpointId: 42,
  237. url: endpoint.url,
  238. label: endpoint.label,
  239. sortOrder: endpoint.sortOrder,
  240. isEnabled: endpoint.isEnabled,
  241. });
  242. expect(res.ok).toBe(true);
  243. expect(res.data?.endpoint).toEqual(endpoint);
  244. expect(updateProviderEndpointMock).toHaveBeenCalledWith(42, {
  245. url: endpoint.url,
  246. label: endpoint.label,
  247. sortOrder: endpoint.sortOrder,
  248. isEnabled: endpoint.isEnabled,
  249. });
  250. });
  251. it("removeProviderVendor: deletes vendor and publishes cache invalidation", async () => {
  252. getSessionMock.mockResolvedValue({ user: { role: "admin" } });
  253. deleteProviderVendorMock.mockResolvedValue(true);
  254. publishProviderCacheInvalidationMock.mockResolvedValue(undefined);
  255. const { removeProviderVendor } = await import("@/actions/provider-endpoints");
  256. const res = await removeProviderVendor({ vendorId: 1 });
  257. expect(res.ok).toBe(true);
  258. expect(deleteProviderVendorMock).toHaveBeenCalledWith(1);
  259. expect(publishProviderCacheInvalidationMock).toHaveBeenCalledTimes(1);
  260. });
  261. it("removeProviderVendor: still ok when cache invalidation fails", async () => {
  262. getSessionMock.mockResolvedValue({ user: { role: "admin" } });
  263. deleteProviderVendorMock.mockResolvedValue(true);
  264. publishProviderCacheInvalidationMock.mockRejectedValue(new Error("boom"));
  265. const { removeProviderVendor } = await import("@/actions/provider-endpoints");
  266. const res = await removeProviderVendor({ vendorId: 1 });
  267. expect(res.ok).toBe(true);
  268. expect(deleteProviderVendorMock).toHaveBeenCalledWith(1);
  269. });
  270. it("removeProviderEndpoint: triggers vendor cleanup after soft delete", async () => {
  271. getSessionMock.mockResolvedValue({ user: { role: "admin" } });
  272. findProviderEndpointByIdMock.mockResolvedValue({
  273. id: 99,
  274. vendorId: 123,
  275. providerType: "claude",
  276. url: "https://api.example.com",
  277. label: null,
  278. sortOrder: 0,
  279. isEnabled: true,
  280. lastProbedAt: null,
  281. lastProbeOk: null,
  282. lastProbeStatusCode: null,
  283. lastProbeLatencyMs: null,
  284. lastProbeErrorType: null,
  285. lastProbeErrorMessage: null,
  286. createdAt: new Date(),
  287. updatedAt: new Date(),
  288. deletedAt: null,
  289. });
  290. softDeleteProviderEndpointMock.mockResolvedValue(true);
  291. tryDeleteProviderVendorIfEmptyMock.mockResolvedValue(true);
  292. hasEnabledProviderReferenceForVendorTypeUrlMock.mockResolvedValue(false);
  293. const { removeProviderEndpoint } = await import("@/actions/provider-endpoints");
  294. const res = await removeProviderEndpoint({ endpointId: 99 });
  295. expect(res.ok).toBe(true);
  296. const { resetEndpointCircuit } = await import("@/lib/endpoint-circuit-breaker");
  297. expect(resetEndpointCircuit).toHaveBeenCalledWith(99);
  298. expect(tryDeleteProviderVendorIfEmptyMock).toHaveBeenCalledWith(123);
  299. });
  300. it("probeProviderEndpoint: calls probeProviderEndpointAndRecordByEndpoint and returns result", async () => {
  301. getSessionMock.mockResolvedValue({ user: { role: "admin" } });
  302. const endpoint = {
  303. id: 7,
  304. vendorId: 123,
  305. providerType: "claude",
  306. url: "https://api.example.com",
  307. label: null,
  308. sortOrder: 0,
  309. isEnabled: true,
  310. lastProbedAt: null,
  311. lastProbeOk: null,
  312. lastProbeStatusCode: null,
  313. lastProbeLatencyMs: null,
  314. lastProbeErrorType: null,
  315. lastProbeErrorMessage: null,
  316. createdAt: new Date(),
  317. updatedAt: new Date(),
  318. deletedAt: null,
  319. };
  320. findProviderEndpointByIdMock.mockResolvedValue(endpoint);
  321. const { probeProviderEndpointAndRecordByEndpoint } = await import(
  322. "@/lib/provider-endpoints/probe"
  323. );
  324. const result = {
  325. ok: true,
  326. method: "HEAD",
  327. statusCode: 200,
  328. latencyMs: 10,
  329. errorType: null,
  330. errorMessage: null,
  331. } as const;
  332. vi.mocked(probeProviderEndpointAndRecordByEndpoint).mockResolvedValue(result);
  333. const { probeProviderEndpoint } = await import("@/actions/provider-endpoints");
  334. const res = await probeProviderEndpoint({ endpointId: 7, timeoutMs: 5000 });
  335. expect(res.ok).toBe(true);
  336. expect(probeProviderEndpointAndRecordByEndpoint).toHaveBeenCalledWith({
  337. endpoint,
  338. source: "manual",
  339. timeoutMs: 5000,
  340. });
  341. expect(res.data?.result).toEqual(result);
  342. });
  343. describe("batchGetEndpointCircuitInfo", () => {
  344. it("returns circuit info for multiple endpoints", async () => {
  345. getSessionMock.mockResolvedValue({ user: { role: "admin" } });
  346. const { getAllEndpointHealthStatusAsync } = await import("@/lib/endpoint-circuit-breaker");
  347. vi.mocked(getAllEndpointHealthStatusAsync).mockResolvedValue({
  348. 1: {
  349. failureCount: 0,
  350. lastFailureTime: null,
  351. circuitState: "closed",
  352. circuitOpenUntil: null,
  353. halfOpenSuccessCount: 0,
  354. },
  355. 2: {
  356. failureCount: 5,
  357. lastFailureTime: Date.now(),
  358. circuitState: "open",
  359. circuitOpenUntil: Date.now() + 60000,
  360. halfOpenSuccessCount: 0,
  361. },
  362. 3: {
  363. failureCount: 1,
  364. lastFailureTime: Date.now() - 1000,
  365. circuitState: "half-open",
  366. circuitOpenUntil: null,
  367. halfOpenSuccessCount: 0,
  368. },
  369. });
  370. const { batchGetEndpointCircuitInfo } = await import("@/actions/provider-endpoints");
  371. const res = await batchGetEndpointCircuitInfo({ endpointIds: [1, 2, 3] });
  372. expect(res.ok).toBe(true);
  373. expect(getAllEndpointHealthStatusAsync).toHaveBeenCalledWith([1, 2, 3]);
  374. expect(res.data).toHaveLength(3);
  375. expect(res.data?.[0]).toEqual({
  376. endpointId: 1,
  377. circuitState: "closed",
  378. failureCount: 0,
  379. circuitOpenUntil: null,
  380. });
  381. expect(res.data?.[1]).toEqual({
  382. endpointId: 2,
  383. circuitState: "open",
  384. failureCount: 5,
  385. circuitOpenUntil: expect.any(Number),
  386. });
  387. expect(res.data?.[2]).toEqual({
  388. endpointId: 3,
  389. circuitState: "half-open",
  390. failureCount: 1,
  391. circuitOpenUntil: null,
  392. });
  393. });
  394. it("returns empty array for empty input", async () => {
  395. getSessionMock.mockResolvedValue({ user: { role: "admin" } });
  396. const { batchGetEndpointCircuitInfo } = await import("@/actions/provider-endpoints");
  397. const res = await batchGetEndpointCircuitInfo({ endpointIds: [] });
  398. expect(res.ok).toBe(true);
  399. expect(res.data).toEqual([]);
  400. });
  401. it("requires admin session", async () => {
  402. getSessionMock.mockResolvedValue({ user: { role: "user" } });
  403. const { batchGetEndpointCircuitInfo } = await import("@/actions/provider-endpoints");
  404. const res = await batchGetEndpointCircuitInfo({ endpointIds: [1, 2] });
  405. expect(res.ok).toBe(false);
  406. expect(res.errorCode).toBe("PERMISSION_DENIED");
  407. });
  408. it("validates endpointIds are positive integers", async () => {
  409. getSessionMock.mockResolvedValue({ user: { role: "admin" } });
  410. const { batchGetEndpointCircuitInfo } = await import("@/actions/provider-endpoints");
  411. const res = await batchGetEndpointCircuitInfo({ endpointIds: [0, -1, 1] });
  412. expect(res.ok).toBe(false);
  413. expect(res.errorCode).toBe("MIN_VALUE");
  414. });
  415. });
  416. });