provider-endpoints.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522
  1. import { describe, expect, test, vi } from "vitest";
  2. function createThenableQuery<T>(result: T) {
  3. type Query = Promise<T> & {
  4. from: (...args: unknown[]) => Query;
  5. where: (...args: unknown[]) => Query;
  6. orderBy: (...args: unknown[]) => Query;
  7. limit: (...args: unknown[]) => Query;
  8. };
  9. const query = Promise.resolve(result) as unknown as Query;
  10. query.from = () => query;
  11. query.where = () => query;
  12. query.orderBy = () => query;
  13. query.limit = () => query;
  14. return query;
  15. }
  16. describe("provider-endpoints repository", () => {
  17. test("ensureProviderEndpointExistsForUrl: url 为空时抛错且不写 DB", async () => {
  18. vi.resetModules();
  19. const insertMock = vi.fn();
  20. vi.doMock("@/drizzle/db", () => ({
  21. db: {
  22. insert: insertMock,
  23. },
  24. }));
  25. const { ensureProviderEndpointExistsForUrl } = await import("@/repository/provider-endpoints");
  26. await expect(
  27. ensureProviderEndpointExistsForUrl({
  28. vendorId: 1,
  29. providerType: "claude",
  30. url: " ",
  31. })
  32. ).rejects.toThrow("[ProviderEndpointEnsure] url is required");
  33. expect(insertMock).not.toHaveBeenCalled();
  34. });
  35. test("ensureProviderEndpointExistsForUrl: url 非法时抛错且不写 DB", async () => {
  36. vi.resetModules();
  37. const insertMock = vi.fn();
  38. vi.doMock("@/drizzle/db", () => ({
  39. db: {
  40. insert: insertMock,
  41. },
  42. }));
  43. const { ensureProviderEndpointExistsForUrl } = await import("@/repository/provider-endpoints");
  44. await expect(
  45. ensureProviderEndpointExistsForUrl({
  46. vendorId: 1,
  47. providerType: "claude",
  48. url: "not a url",
  49. })
  50. ).rejects.toThrow("[ProviderEndpointEnsure] url must be a valid URL");
  51. expect(insertMock).not.toHaveBeenCalled();
  52. });
  53. test("ensureProviderEndpointExistsForUrl: 插入成功时返回 true(trim + label=null)", async () => {
  54. vi.resetModules();
  55. const state = { values: undefined as unknown };
  56. const returning = vi.fn(async () => [{ id: 1 }]);
  57. const onConflictDoNothing = vi.fn(() => ({ returning }));
  58. const values = vi.fn((payload: unknown) => {
  59. state.values = payload;
  60. return { onConflictDoNothing };
  61. });
  62. const insertMock = vi.fn(() => ({ values }));
  63. vi.doMock("@/drizzle/db", () => ({
  64. db: {
  65. insert: insertMock,
  66. },
  67. }));
  68. const { ensureProviderEndpointExistsForUrl } = await import("@/repository/provider-endpoints");
  69. const ok = await ensureProviderEndpointExistsForUrl({
  70. vendorId: 1,
  71. providerType: "claude",
  72. url: " https://api.example.com ",
  73. });
  74. expect(ok).toBe(true);
  75. expect(insertMock).toHaveBeenCalledTimes(1);
  76. expect(values).toHaveBeenCalledTimes(1);
  77. expect(state.values).toEqual(
  78. expect.objectContaining({
  79. vendorId: 1,
  80. providerType: "claude",
  81. url: "https://api.example.com",
  82. label: null,
  83. })
  84. );
  85. expect(onConflictDoNothing).toHaveBeenCalledWith(
  86. expect.objectContaining({
  87. target: expect.any(Array),
  88. where: expect.any(Object),
  89. })
  90. );
  91. });
  92. test("ensureProviderEndpointExistsForUrl: 冲突不插入时返回 false", async () => {
  93. vi.resetModules();
  94. const returning = vi.fn(async () => []);
  95. const onConflictDoNothing = vi.fn(() => ({ returning }));
  96. const values = vi.fn(() => ({ onConflictDoNothing }));
  97. const insertMock = vi.fn(() => ({ values }));
  98. vi.doMock("@/drizzle/db", () => ({
  99. db: {
  100. insert: insertMock,
  101. },
  102. }));
  103. const { ensureProviderEndpointExistsForUrl } = await import("@/repository/provider-endpoints");
  104. const ok = await ensureProviderEndpointExistsForUrl({
  105. vendorId: 1,
  106. providerType: "claude",
  107. url: "https://api.example.com",
  108. });
  109. expect(ok).toBe(false);
  110. });
  111. test("ensureProviderEndpointExistsForUrl: 非编辑路径保持 insert-only 语义(不触发 update/transaction)", async () => {
  112. vi.resetModules();
  113. const returning = vi.fn(async () => []);
  114. const onConflictDoNothing = vi.fn(() => ({ returning }));
  115. const values = vi.fn(() => ({ onConflictDoNothing }));
  116. const insertMock = vi.fn(() => ({ values }));
  117. const updateMock = vi.fn();
  118. const transactionMock = vi.fn();
  119. vi.doMock("@/drizzle/db", () => ({
  120. db: {
  121. insert: insertMock,
  122. update: updateMock,
  123. transaction: transactionMock,
  124. },
  125. }));
  126. const { ensureProviderEndpointExistsForUrl } = await import("@/repository/provider-endpoints");
  127. const ok = await ensureProviderEndpointExistsForUrl({
  128. vendorId: 1,
  129. providerType: "codex",
  130. url: "https://api.example.com/v1/responses",
  131. });
  132. expect(ok).toBe(false);
  133. expect(insertMock).toHaveBeenCalledTimes(1);
  134. expect(updateMock).not.toHaveBeenCalled();
  135. expect(transactionMock).not.toHaveBeenCalled();
  136. });
  137. test("backfillProviderEndpointsFromProviders: 全部无效时不写 DB", async () => {
  138. vi.resetModules();
  139. const selectPages = [
  140. [
  141. { id: 1, vendorId: 0, providerType: "claude", url: "https://ok.example.com" },
  142. { id: 2, vendorId: 1, providerType: "claude", url: " " },
  143. { id: 3, vendorId: 1, providerType: "claude", url: "not a url" },
  144. ],
  145. [],
  146. ];
  147. const selectMock = vi.fn(() => createThenableQuery(selectPages.shift() ?? []));
  148. const insertMock = vi.fn();
  149. vi.doMock("@/drizzle/db", () => ({
  150. db: {
  151. select: selectMock,
  152. insert: insertMock,
  153. },
  154. }));
  155. const { backfillProviderEndpointsFromProviders } = await import(
  156. "@/repository/provider-endpoints"
  157. );
  158. const result = await backfillProviderEndpointsFromProviders();
  159. expect(result).toEqual({ inserted: 0, uniqueCandidates: 0, skippedInvalid: 3 });
  160. expect(insertMock).not.toHaveBeenCalled();
  161. });
  162. test("backfillProviderEndpointsFromProviders: 去重 + trim + 统计 inserted/uniqueCandidates/skippedInvalid", async () => {
  163. vi.resetModules();
  164. const capturedValues: unknown[] = [];
  165. const insertState = { values: undefined as unknown };
  166. const returning = vi.fn(async () => {
  167. const values = insertState.values;
  168. if (!Array.isArray(values)) return [];
  169. return values.map((_, idx) => ({ id: idx + 1 }));
  170. });
  171. const onConflictDoNothing = vi.fn(() => ({ returning }));
  172. const values = vi.fn((payload: unknown) => {
  173. insertState.values = payload;
  174. if (Array.isArray(payload)) capturedValues.push(...payload);
  175. return { onConflictDoNothing };
  176. });
  177. const insertMock = vi.fn(() => ({ values }));
  178. const selectPages = [
  179. [
  180. { id: 1, vendorId: 1, providerType: "claude", url: " https://a.com " },
  181. { id: 2, vendorId: 1, providerType: "claude", url: "https://a.com" },
  182. { id: 3, vendorId: 1, providerType: "openai-compatible", url: "https://a.com" },
  183. ],
  184. [
  185. { id: 4, vendorId: 2, providerType: "claude", url: "https://a.com" },
  186. { id: 5, vendorId: 0, providerType: "claude", url: "https://bad-vendor.com" },
  187. { id: 6, vendorId: 1, providerType: "claude", url: "not a url" },
  188. ],
  189. [],
  190. ];
  191. const selectMock = vi.fn(() => createThenableQuery(selectPages.shift() ?? []));
  192. vi.doMock("@/drizzle/db", () => ({
  193. db: {
  194. select: selectMock,
  195. insert: insertMock,
  196. },
  197. }));
  198. const { backfillProviderEndpointsFromProviders } = await import(
  199. "@/repository/provider-endpoints"
  200. );
  201. const result = await backfillProviderEndpointsFromProviders();
  202. expect(result).toEqual({ inserted: 3, uniqueCandidates: 3, skippedInvalid: 2 });
  203. expect(capturedValues).toEqual(
  204. expect.arrayContaining([
  205. expect.objectContaining({ vendorId: 1, providerType: "claude", url: "https://a.com" }),
  206. expect.objectContaining({
  207. vendorId: 1,
  208. providerType: "openai-compatible",
  209. url: "https://a.com",
  210. }),
  211. expect.objectContaining({ vendorId: 2, providerType: "claude", url: "https://a.com" }),
  212. ])
  213. );
  214. });
  215. test("backfillProviderEndpointsFromProviders: 冲突不插入时 inserted=0 但 uniqueCandidates 仍统计", async () => {
  216. vi.resetModules();
  217. const insertState = { values: undefined as unknown };
  218. const returning = vi.fn(async () => []);
  219. const onConflictDoNothing = vi.fn(() => ({ returning }));
  220. const values = vi.fn((payload: unknown) => {
  221. insertState.values = payload;
  222. return { onConflictDoNothing };
  223. });
  224. const insertMock = vi.fn(() => ({ values }));
  225. const selectPages = [
  226. [
  227. { id: 1, vendorId: 1, providerType: "claude", url: "https://a.com" },
  228. { id: 2, vendorId: 1, providerType: "openai-compatible", url: "https://a.com" },
  229. ],
  230. [],
  231. ];
  232. const selectMock = vi.fn(() => createThenableQuery(selectPages.shift() ?? []));
  233. vi.doMock("@/drizzle/db", () => ({
  234. db: {
  235. select: selectMock,
  236. insert: insertMock,
  237. },
  238. }));
  239. const { backfillProviderEndpointsFromProviders } = await import(
  240. "@/repository/provider-endpoints"
  241. );
  242. const result = await backfillProviderEndpointsFromProviders();
  243. expect(result).toEqual({ inserted: 0, uniqueCandidates: 2, skippedInvalid: 0 });
  244. });
  245. test("tryDeleteProviderVendorIfEmpty: 有 active provider 时不删除", async () => {
  246. vi.resetModules();
  247. const selectPages = [[{ id: 1 }], []];
  248. const selectMock = vi.fn(() => createThenableQuery(selectPages.shift() ?? []));
  249. const deleteMock = vi.fn();
  250. const transactionMock = vi.fn(async (fn: (tx: any) => Promise<any>) => {
  251. return fn({
  252. select: selectMock,
  253. delete: deleteMock,
  254. });
  255. });
  256. vi.doMock("@/drizzle/db", () => ({
  257. db: {
  258. transaction: transactionMock,
  259. },
  260. }));
  261. const { tryDeleteProviderVendorIfEmpty } = await import("@/repository/provider-endpoints");
  262. const ok = await tryDeleteProviderVendorIfEmpty(123);
  263. expect(ok).toBe(false);
  264. expect(selectMock).toHaveBeenCalledTimes(1);
  265. expect(deleteMock).not.toHaveBeenCalled();
  266. });
  267. test("tryDeleteProviderVendorIfEmpty: 有 active endpoint 时不删除", async () => {
  268. vi.resetModules();
  269. const selectPages = [[], [{ id: 1 }]];
  270. const selectMock = vi.fn(() => createThenableQuery(selectPages.shift() ?? []));
  271. const deleteMock = vi.fn();
  272. const transactionMock = vi.fn(async (fn: (tx: any) => Promise<any>) => {
  273. return fn({
  274. select: selectMock,
  275. delete: deleteMock,
  276. });
  277. });
  278. vi.doMock("@/drizzle/db", () => ({
  279. db: {
  280. transaction: transactionMock,
  281. },
  282. }));
  283. const { tryDeleteProviderVendorIfEmpty } = await import("@/repository/provider-endpoints");
  284. const ok = await tryDeleteProviderVendorIfEmpty(123);
  285. expect(ok).toBe(false);
  286. expect(selectMock).toHaveBeenCalledTimes(2);
  287. expect(deleteMock).not.toHaveBeenCalled();
  288. });
  289. test("tryDeleteProviderVendorIfEmpty: 无 active provider/endpoint 时删除 vendor", async () => {
  290. vi.resetModules();
  291. const selectPages = [[], []];
  292. const selectMock = vi.fn(() => createThenableQuery(selectPages.shift() ?? []));
  293. let deleteCallIndex = 0;
  294. const deleteMock = vi.fn(() => {
  295. deleteCallIndex += 1;
  296. if (deleteCallIndex === 1) {
  297. return {
  298. where: vi.fn(async () => []),
  299. };
  300. }
  301. return {
  302. where: vi.fn(() => ({
  303. returning: vi.fn(async () => [{ id: 123 }]),
  304. })),
  305. };
  306. });
  307. const transactionMock = vi.fn(async (fn: (tx: any) => Promise<any>) => {
  308. return fn({
  309. select: selectMock,
  310. delete: deleteMock,
  311. });
  312. });
  313. vi.doMock("@/drizzle/db", () => ({
  314. db: {
  315. transaction: transactionMock,
  316. },
  317. }));
  318. const { tryDeleteProviderVendorIfEmpty } = await import("@/repository/provider-endpoints");
  319. const ok = await tryDeleteProviderVendorIfEmpty(123);
  320. expect(ok).toBe(true);
  321. expect(selectMock).toHaveBeenCalledTimes(2);
  322. expect(deleteMock).toHaveBeenCalledTimes(2);
  323. });
  324. test("tryDeleteProviderVendorIfEmpty: vendor 不存在时返回 false", async () => {
  325. vi.resetModules();
  326. const selectPages = [[], []];
  327. const selectMock = vi.fn(() => createThenableQuery(selectPages.shift() ?? []));
  328. let deleteCallIndex = 0;
  329. const deleteMock = vi.fn(() => {
  330. deleteCallIndex += 1;
  331. if (deleteCallIndex === 1) {
  332. return {
  333. where: vi.fn(async () => []),
  334. };
  335. }
  336. return {
  337. where: vi.fn(() => ({
  338. returning: vi.fn(async () => []),
  339. })),
  340. };
  341. });
  342. const transactionMock = vi.fn(async (fn: (tx: any) => Promise<any>) => {
  343. return fn({
  344. select: selectMock,
  345. delete: deleteMock,
  346. });
  347. });
  348. vi.doMock("@/drizzle/db", () => ({
  349. db: {
  350. transaction: transactionMock,
  351. },
  352. }));
  353. const { tryDeleteProviderVendorIfEmpty } = await import("@/repository/provider-endpoints");
  354. const ok = await tryDeleteProviderVendorIfEmpty(123);
  355. expect(ok).toBe(false);
  356. expect(selectMock).toHaveBeenCalledTimes(2);
  357. expect(deleteMock).toHaveBeenCalledTimes(2);
  358. });
  359. test("tryDeleteProviderVendorIfEmpty: transaction 抛错时抛出异常", async () => {
  360. vi.resetModules();
  361. const transactionMock = vi.fn(async () => {
  362. throw new Error("boom");
  363. });
  364. vi.doMock("@/drizzle/db", () => ({
  365. db: {
  366. transaction: transactionMock,
  367. },
  368. }));
  369. const { tryDeleteProviderVendorIfEmpty } = await import("@/repository/provider-endpoints");
  370. await expect(tryDeleteProviderVendorIfEmpty(123)).rejects.toThrow("boom");
  371. });
  372. test("deleteProviderVendor: vendor 存在时返回 true 且执行级联删除", async () => {
  373. vi.resetModules();
  374. let deleteCallIndex = 0;
  375. const deleteMock = vi.fn(() => {
  376. deleteCallIndex += 1;
  377. // 1) delete endpoints, 2) delete providers
  378. if (deleteCallIndex <= 2) {
  379. return {
  380. where: vi.fn(async () => []),
  381. };
  382. }
  383. // 3) delete vendor
  384. return {
  385. where: vi.fn(() => ({
  386. returning: vi.fn(async () => [{ id: 123 }]),
  387. })),
  388. };
  389. });
  390. const transactionMock = vi.fn(async (fn: (tx: any) => Promise<any>) => {
  391. return fn({
  392. delete: deleteMock,
  393. });
  394. });
  395. vi.doMock("@/drizzle/db", () => ({
  396. db: {
  397. transaction: transactionMock,
  398. },
  399. }));
  400. const { deleteProviderVendor } = await import("@/repository/provider-endpoints");
  401. const ok = await deleteProviderVendor(123);
  402. expect(ok).toBe(true);
  403. expect(deleteMock).toHaveBeenCalledTimes(3);
  404. });
  405. test("deleteProviderVendor: vendor 不存在时返回 false", async () => {
  406. vi.resetModules();
  407. let deleteCallIndex = 0;
  408. const deleteMock = vi.fn(() => {
  409. deleteCallIndex += 1;
  410. if (deleteCallIndex <= 2) {
  411. return {
  412. where: vi.fn(async () => []),
  413. };
  414. }
  415. return {
  416. where: vi.fn(() => ({
  417. returning: vi.fn(async () => []),
  418. })),
  419. };
  420. });
  421. const transactionMock = vi.fn(async (fn: (tx: any) => Promise<any>) => {
  422. return fn({
  423. delete: deleteMock,
  424. });
  425. });
  426. vi.doMock("@/drizzle/db", () => ({
  427. db: {
  428. transaction: transactionMock,
  429. },
  430. }));
  431. const { deleteProviderVendor } = await import("@/repository/provider-endpoints");
  432. const ok = await deleteProviderVendor(123);
  433. expect(ok).toBe(false);
  434. expect(deleteMock).toHaveBeenCalledTimes(3);
  435. });
  436. });