provider-endpoints.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  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. vi.mock("@/lib/auth", () => ({
  11. getSession: getSessionMock,
  12. }));
  13. vi.mock("@/lib/cache/provider-cache", () => ({
  14. publishProviderCacheInvalidation: publishProviderCacheInvalidationMock,
  15. }));
  16. vi.mock("@/lib/logger", () => ({
  17. logger: {
  18. trace: vi.fn(),
  19. debug: vi.fn(),
  20. info: vi.fn(),
  21. warn: vi.fn(),
  22. error: vi.fn(),
  23. },
  24. }));
  25. vi.mock("@/lib/endpoint-circuit-breaker", () => ({
  26. getEndpointHealthInfo: vi.fn(async () => ({ health: {}, config: {} })),
  27. resetEndpointCircuit: vi.fn(async () => {}),
  28. }));
  29. vi.mock("@/lib/vendor-type-circuit-breaker", () => ({
  30. getVendorTypeCircuitInfo: vi.fn(async () => ({
  31. vendorId: 1,
  32. providerType: "claude",
  33. circuitState: "closed",
  34. circuitOpenUntil: null,
  35. lastFailureTime: null,
  36. manualOpen: false,
  37. })),
  38. resetVendorTypeCircuit: vi.fn(async () => {}),
  39. setVendorTypeCircuitManualOpen: vi.fn(async () => {}),
  40. }));
  41. vi.mock("@/lib/provider-endpoints/probe", () => ({
  42. probeProviderEndpointAndRecord: vi.fn(async () => null),
  43. }));
  44. vi.mock("@/repository", () => ({
  45. createProviderEndpoint: vi.fn(async () => ({})),
  46. deleteProviderVendor: deleteProviderVendorMock,
  47. findProviderEndpointById: findProviderEndpointByIdMock,
  48. findProviderEndpointProbeLogs: vi.fn(async () => []),
  49. findProviderEndpointsByVendorAndType: vi.fn(async () => []),
  50. findProviderVendorById: vi.fn(async () => null),
  51. findProviderVendors: vi.fn(async () => []),
  52. softDeleteProviderEndpoint: softDeleteProviderEndpointMock,
  53. tryDeleteProviderVendorIfEmpty: tryDeleteProviderVendorIfEmptyMock,
  54. updateProviderEndpoint: updateProviderEndpointMock,
  55. updateProviderVendor: updateProviderVendorMock,
  56. }));
  57. describe("provider-endpoints actions", () => {
  58. beforeEach(() => {
  59. vi.clearAllMocks();
  60. });
  61. it("editProviderVendor: requires admin", async () => {
  62. getSessionMock.mockResolvedValue({ user: { role: "user" } });
  63. const { editProviderVendor } = await import("@/actions/provider-endpoints");
  64. const res = await editProviderVendor({ vendorId: 1, displayName: "x" });
  65. expect(res.ok).toBe(false);
  66. expect(res.errorCode).toBe("PERMISSION_DENIED");
  67. });
  68. it("editProviderVendor: computes favicon", async () => {
  69. getSessionMock.mockResolvedValue({ user: { role: "admin" } });
  70. updateProviderVendorMock.mockResolvedValue({
  71. id: 1,
  72. websiteDomain: "example.com",
  73. displayName: "Example",
  74. websiteUrl: "https://example.com/path",
  75. faviconUrl: "https://www.google.com/s2/favicons?domain=example.com&sz=32",
  76. createdAt: new Date(),
  77. updatedAt: new Date(),
  78. });
  79. const { editProviderVendor } = await import("@/actions/provider-endpoints");
  80. const res = await editProviderVendor({
  81. vendorId: 1,
  82. displayName: "Example",
  83. websiteUrl: "https://example.com/path",
  84. });
  85. expect(res.ok).toBe(true);
  86. expect(updateProviderVendorMock).toHaveBeenCalledWith(
  87. 1,
  88. expect.objectContaining({
  89. displayName: "Example",
  90. websiteUrl: "https://example.com/path",
  91. faviconUrl: "https://www.google.com/s2/favicons?domain=example.com&sz=32",
  92. })
  93. );
  94. });
  95. it("editProviderVendor: clearing websiteUrl clears faviconUrl", async () => {
  96. getSessionMock.mockResolvedValue({ user: { role: "admin" } });
  97. updateProviderVendorMock.mockResolvedValue({
  98. id: 1,
  99. websiteDomain: "example.com",
  100. displayName: null,
  101. websiteUrl: null,
  102. faviconUrl: null,
  103. createdAt: new Date(),
  104. updatedAt: new Date(),
  105. });
  106. const { editProviderVendor } = await import("@/actions/provider-endpoints");
  107. const res = await editProviderVendor({
  108. vendorId: 1,
  109. websiteUrl: null,
  110. });
  111. expect(res.ok).toBe(true);
  112. expect(updateProviderVendorMock).toHaveBeenCalledWith(
  113. 1,
  114. expect.objectContaining({
  115. websiteUrl: null,
  116. faviconUrl: null,
  117. })
  118. );
  119. });
  120. it("editProviderEndpoint: conflict maps to CONFLICT errorCode", async () => {
  121. getSessionMock.mockResolvedValue({ user: { role: "admin" } });
  122. updateProviderEndpointMock.mockRejectedValue(
  123. Object.assign(new Error("[ProviderEndpointEdit] endpoint conflict"), {
  124. code: "PROVIDER_ENDPOINT_CONFLICT",
  125. })
  126. );
  127. const { editProviderEndpoint } = await import("@/actions/provider-endpoints");
  128. const res = await editProviderEndpoint({
  129. endpointId: 42,
  130. url: "https://next.example.com/v1/messages",
  131. });
  132. expect(res.ok).toBe(false);
  133. expect(res.errorCode).toBe("CONFLICT");
  134. expect(res.error).not.toContain("duplicate key value");
  135. });
  136. it("editProviderEndpoint: success returns ok with endpoint payload", async () => {
  137. getSessionMock.mockResolvedValue({ user: { role: "admin" } });
  138. const endpoint = {
  139. id: 42,
  140. vendorId: 123,
  141. providerType: "claude" as const,
  142. url: "https://next.example.com/v1/messages",
  143. label: "primary",
  144. sortOrder: 7,
  145. isEnabled: false,
  146. lastProbedAt: null,
  147. lastProbeOk: null,
  148. lastProbeStatusCode: null,
  149. lastProbeLatencyMs: null,
  150. lastProbeErrorType: null,
  151. lastProbeErrorMessage: null,
  152. createdAt: new Date("2026-01-01T00:00:00.000Z"),
  153. updatedAt: new Date("2026-01-01T00:00:00.000Z"),
  154. deletedAt: null,
  155. };
  156. updateProviderEndpointMock.mockResolvedValue(endpoint);
  157. const { editProviderEndpoint } = await import("@/actions/provider-endpoints");
  158. const res = await editProviderEndpoint({
  159. endpointId: 42,
  160. url: endpoint.url,
  161. label: endpoint.label,
  162. sortOrder: endpoint.sortOrder,
  163. isEnabled: endpoint.isEnabled,
  164. });
  165. expect(res.ok).toBe(true);
  166. expect(res.data?.endpoint).toEqual(endpoint);
  167. expect(updateProviderEndpointMock).toHaveBeenCalledWith(42, {
  168. url: endpoint.url,
  169. label: endpoint.label,
  170. sortOrder: endpoint.sortOrder,
  171. isEnabled: endpoint.isEnabled,
  172. });
  173. });
  174. it("removeProviderVendor: deletes vendor and publishes cache invalidation", async () => {
  175. getSessionMock.mockResolvedValue({ user: { role: "admin" } });
  176. deleteProviderVendorMock.mockResolvedValue(true);
  177. publishProviderCacheInvalidationMock.mockResolvedValue(undefined);
  178. const { removeProviderVendor } = await import("@/actions/provider-endpoints");
  179. const res = await removeProviderVendor({ vendorId: 1 });
  180. expect(res.ok).toBe(true);
  181. expect(deleteProviderVendorMock).toHaveBeenCalledWith(1);
  182. expect(publishProviderCacheInvalidationMock).toHaveBeenCalledTimes(1);
  183. });
  184. it("removeProviderVendor: still ok when cache invalidation fails", async () => {
  185. getSessionMock.mockResolvedValue({ user: { role: "admin" } });
  186. deleteProviderVendorMock.mockResolvedValue(true);
  187. publishProviderCacheInvalidationMock.mockRejectedValue(new Error("boom"));
  188. const { removeProviderVendor } = await import("@/actions/provider-endpoints");
  189. const res = await removeProviderVendor({ vendorId: 1 });
  190. expect(res.ok).toBe(true);
  191. expect(deleteProviderVendorMock).toHaveBeenCalledWith(1);
  192. });
  193. it("removeProviderEndpoint: triggers vendor cleanup after soft delete", async () => {
  194. getSessionMock.mockResolvedValue({ user: { role: "admin" } });
  195. findProviderEndpointByIdMock.mockResolvedValue({
  196. id: 99,
  197. vendorId: 123,
  198. providerType: "claude",
  199. url: "https://api.example.com",
  200. label: null,
  201. sortOrder: 0,
  202. isEnabled: true,
  203. lastProbedAt: null,
  204. lastProbeOk: null,
  205. lastProbeStatusCode: null,
  206. lastProbeLatencyMs: null,
  207. lastProbeErrorType: null,
  208. lastProbeErrorMessage: null,
  209. createdAt: new Date(),
  210. updatedAt: new Date(),
  211. deletedAt: null,
  212. });
  213. softDeleteProviderEndpointMock.mockResolvedValue(true);
  214. tryDeleteProviderVendorIfEmptyMock.mockResolvedValue(true);
  215. const { removeProviderEndpoint } = await import("@/actions/provider-endpoints");
  216. const res = await removeProviderEndpoint({ endpointId: 99 });
  217. expect(res.ok).toBe(true);
  218. expect(tryDeleteProviderVendorIfEmptyMock).toHaveBeenCalledWith(123);
  219. });
  220. describe("batchGetEndpointCircuitInfo", () => {
  221. it("returns circuit info for multiple endpoints", async () => {
  222. getSessionMock.mockResolvedValue({ user: { role: "admin" } });
  223. const { getEndpointHealthInfo } = await import("@/lib/endpoint-circuit-breaker");
  224. vi.mocked(getEndpointHealthInfo)
  225. .mockResolvedValueOnce({
  226. health: {
  227. failureCount: 0,
  228. lastFailureTime: null,
  229. circuitState: "closed" as const,
  230. circuitOpenUntil: null,
  231. halfOpenSuccessCount: 0,
  232. },
  233. config: { failureThreshold: 3, openDuration: 300000, halfOpenSuccessThreshold: 1 },
  234. })
  235. .mockResolvedValueOnce({
  236. health: {
  237. failureCount: 5,
  238. lastFailureTime: Date.now(),
  239. circuitState: "open" as const,
  240. circuitOpenUntil: Date.now() + 60000,
  241. halfOpenSuccessCount: 0,
  242. },
  243. config: { failureThreshold: 3, openDuration: 300000, halfOpenSuccessThreshold: 1 },
  244. })
  245. .mockResolvedValueOnce({
  246. health: {
  247. failureCount: 1,
  248. lastFailureTime: Date.now() - 1000,
  249. circuitState: "half-open" as const,
  250. circuitOpenUntil: null,
  251. halfOpenSuccessCount: 0,
  252. },
  253. config: { failureThreshold: 3, openDuration: 300000, halfOpenSuccessThreshold: 1 },
  254. });
  255. const { batchGetEndpointCircuitInfo } = await import("@/actions/provider-endpoints");
  256. const res = await batchGetEndpointCircuitInfo({ endpointIds: [1, 2, 3] });
  257. expect(res.ok).toBe(true);
  258. expect(res.data).toHaveLength(3);
  259. expect(res.data?.[0]).toEqual({
  260. endpointId: 1,
  261. circuitState: "closed",
  262. failureCount: 0,
  263. circuitOpenUntil: null,
  264. });
  265. expect(res.data?.[1]).toEqual({
  266. endpointId: 2,
  267. circuitState: "open",
  268. failureCount: 5,
  269. circuitOpenUntil: expect.any(Number),
  270. });
  271. expect(res.data?.[2]).toEqual({
  272. endpointId: 3,
  273. circuitState: "half-open",
  274. failureCount: 1,
  275. circuitOpenUntil: null,
  276. });
  277. });
  278. it("returns empty array for empty input", async () => {
  279. getSessionMock.mockResolvedValue({ user: { role: "admin" } });
  280. const { batchGetEndpointCircuitInfo } = await import("@/actions/provider-endpoints");
  281. const res = await batchGetEndpointCircuitInfo({ endpointIds: [] });
  282. expect(res.ok).toBe(true);
  283. expect(res.data).toEqual([]);
  284. });
  285. it("requires admin session", async () => {
  286. getSessionMock.mockResolvedValue({ user: { role: "user" } });
  287. const { batchGetEndpointCircuitInfo } = await import("@/actions/provider-endpoints");
  288. const res = await batchGetEndpointCircuitInfo({ endpointIds: [1, 2] });
  289. expect(res.ok).toBe(false);
  290. expect(res.errorCode).toBe("PERMISSION_DENIED");
  291. });
  292. it("validates endpointIds are positive integers", async () => {
  293. getSessionMock.mockResolvedValue({ user: { role: "admin" } });
  294. const { batchGetEndpointCircuitInfo } = await import("@/actions/provider-endpoints");
  295. const res = await batchGetEndpointCircuitInfo({ endpointIds: [0, -1, 1] });
  296. expect(res.ok).toBe(false);
  297. expect(res.errorCode).toBe("MIN_VALUE");
  298. });
  299. });
  300. });