system-config-update-missing-columns.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427
  1. import { describe, expect, test, vi } from "vitest";
  2. function createThenableQuery<T>(result: T) {
  3. const query: any = Promise.resolve(result);
  4. query.from = vi.fn(() => query);
  5. query.limit = vi.fn(() => query);
  6. query.set = vi.fn(() => query);
  7. query.where = vi.fn(() => query);
  8. query.returning = vi.fn(() => query);
  9. query.values = vi.fn(() => query);
  10. query.onConflictDoNothing = vi.fn(() => query);
  11. return query;
  12. }
  13. function createRejectedThenableQuery(error: unknown) {
  14. const query: any = {};
  15. query.from = vi.fn(() => query);
  16. query.limit = vi.fn(() => Promise.reject(error));
  17. query.set = vi.fn(() => query);
  18. query.where = vi.fn(() => query);
  19. query.returning = vi.fn(() => Promise.reject(error));
  20. query.values = vi.fn(() => query);
  21. query.onConflictDoNothing = vi.fn(() => Promise.reject(error));
  22. return query;
  23. }
  24. describe("SystemSettings:数据库缺列时的保存兜底", () => {
  25. test("updateSystemSettings 遇到 42703(列缺失)应返回可行动的错误信息", async () => {
  26. vi.resetModules();
  27. const now = new Date("2026-01-04T00:00:00.000Z");
  28. vi.useFakeTimers();
  29. vi.setSystemTime(now);
  30. const selectQuery = createThenableQuery([
  31. {
  32. id: 1,
  33. siteTitle: "Claude Code Hub",
  34. allowGlobalUsageView: false,
  35. currencyDisplay: "USD",
  36. billingModelSource: "original",
  37. enableAutoCleanup: false,
  38. cleanupRetentionDays: 30,
  39. cleanupSchedule: "0 2 * * *",
  40. cleanupBatchSize: 10000,
  41. enableClientVersionCheck: false,
  42. verboseProviderError: false,
  43. enableHttp2: false,
  44. interceptAnthropicWarmupRequests: false,
  45. createdAt: now,
  46. updatedAt: now,
  47. },
  48. ]);
  49. const selectMock = vi.fn(() => selectQuery);
  50. const updateQuery = createThenableQuery([] as unknown[]);
  51. updateQuery.returning = vi.fn(() => Promise.reject({ code: "42703" }));
  52. const updateMock = vi.fn(() => updateQuery);
  53. vi.doMock("@/drizzle/db", () => ({
  54. db: {
  55. select: selectMock,
  56. update: updateMock,
  57. insert: vi.fn(() => createThenableQuery([])),
  58. // 给 tests/setup.ts 的 afterAll 清理逻辑一个可用的 execute
  59. execute: vi.fn(async () => ({ count: 0 })),
  60. },
  61. }));
  62. const { updateSystemSettings } = await import("@/repository/system-config");
  63. await expect(updateSystemSettings({ siteTitle: "AutoBits Claude Code Hub" })).rejects.toThrow(
  64. "system_settings 表列缺失"
  65. );
  66. vi.useRealTimers();
  67. });
  68. test("updateSystemSettings 遇到 42P01(表不存在)应提示先执行迁移", async () => {
  69. vi.resetModules();
  70. const now = new Date("2026-01-04T00:00:00.000Z");
  71. vi.useFakeTimers();
  72. vi.setSystemTime(now);
  73. const selectQuery = createThenableQuery([
  74. {
  75. id: 1,
  76. siteTitle: "Claude Code Hub",
  77. allowGlobalUsageView: false,
  78. currencyDisplay: "USD",
  79. billingModelSource: "original",
  80. createdAt: now,
  81. updatedAt: now,
  82. },
  83. ]);
  84. const selectMock = vi.fn(() => selectQuery);
  85. const updateQuery = createThenableQuery([] as unknown[]);
  86. updateQuery.returning = vi.fn(() => Promise.reject({ code: "42P01" }));
  87. const updateMock = vi.fn(() => updateQuery);
  88. vi.doMock("@/drizzle/db", () => ({
  89. db: {
  90. select: selectMock,
  91. update: updateMock,
  92. insert: vi.fn(() => createThenableQuery([])),
  93. execute: vi.fn(async () => ({ count: 0 })),
  94. },
  95. }));
  96. const { updateSystemSettings } = await import("@/repository/system-config");
  97. await expect(updateSystemSettings({ siteTitle: "AutoBits Claude Code Hub" })).rejects.toThrow(
  98. "系统设置数据表不存在"
  99. );
  100. vi.useRealTimers();
  101. });
  102. test("getSystemSettings 在仅缺 codex_priority_billing_source 列时应保留已有设置", async () => {
  103. vi.resetModules();
  104. const now = new Date("2026-01-04T00:00:00.000Z");
  105. vi.useFakeTimers();
  106. vi.setSystemTime(now);
  107. const selectMock = vi
  108. .fn()
  109. .mockReturnValueOnce(createRejectedThenableQuery({ code: "42703" }))
  110. .mockReturnValueOnce(
  111. createThenableQuery([
  112. {
  113. id: 1,
  114. siteTitle: "Claude Code Hub",
  115. allowGlobalUsageView: false,
  116. currencyDisplay: "USD",
  117. billingModelSource: "original",
  118. timezone: "Asia/Shanghai",
  119. enableAutoCleanup: true,
  120. cleanupRetentionDays: 90,
  121. cleanupSchedule: "0 3 * * *",
  122. cleanupBatchSize: 5000,
  123. enableClientVersionCheck: true,
  124. verboseProviderError: true,
  125. enableHttp2: true,
  126. interceptAnthropicWarmupRequests: true,
  127. enableThinkingSignatureRectifier: false,
  128. enableThinkingBudgetRectifier: false,
  129. enableBillingHeaderRectifier: false,
  130. enableResponseInputRectifier: false,
  131. enableCodexSessionIdCompletion: false,
  132. enableClaudeMetadataUserIdInjection: false,
  133. enableResponseFixer: false,
  134. responseFixerConfig: {
  135. fixTruncatedJson: false,
  136. fixSseFormat: false,
  137. fixEncoding: false,
  138. maxJsonDepth: 50,
  139. maxFixSize: 2048,
  140. },
  141. quotaDbRefreshIntervalSeconds: 30,
  142. quotaLeasePercent5h: "0.10",
  143. quotaLeasePercentDaily: "0.11",
  144. quotaLeasePercentWeekly: "0.12",
  145. quotaLeasePercentMonthly: "0.13",
  146. quotaLeaseCapUsd: "1.50",
  147. createdAt: now,
  148. updatedAt: now,
  149. },
  150. ])
  151. );
  152. vi.doMock("@/drizzle/db", () => ({
  153. db: {
  154. select: selectMock,
  155. update: vi.fn(() => createThenableQuery([])),
  156. insert: vi.fn(() => createThenableQuery([])),
  157. execute: vi.fn(async () => ({ count: 0 })),
  158. },
  159. }));
  160. const { getSystemSettings } = await import("@/repository/system-config");
  161. const result = await getSystemSettings();
  162. expect(result.codexPriorityBillingSource).toBe("requested");
  163. expect(result.enableHttp2).toBe(true);
  164. expect(result.interceptAnthropicWarmupRequests).toBe(true);
  165. expect(result.verboseProviderError).toBe(true);
  166. expect(result.quotaLeasePercentDaily).toBe(0.11);
  167. vi.useRealTimers();
  168. });
  169. test("getSystemSettings 在缺少新列且无记录时应使用降级插入初始化", async () => {
  170. vi.resetModules();
  171. const now = new Date("2026-01-04T00:00:00.000Z");
  172. vi.useFakeTimers();
  173. vi.setSystemTime(now);
  174. const selectMock = vi
  175. .fn()
  176. .mockReturnValueOnce(createRejectedThenableQuery({ code: "42703" }))
  177. .mockReturnValueOnce(createThenableQuery([]))
  178. .mockReturnValueOnce(createRejectedThenableQuery({ code: "42703" }))
  179. .mockReturnValueOnce(
  180. createThenableQuery([
  181. {
  182. id: 1,
  183. siteTitle: "Claude Code Hub",
  184. allowGlobalUsageView: false,
  185. currencyDisplay: "USD",
  186. billingModelSource: "original",
  187. createdAt: now,
  188. updatedAt: now,
  189. },
  190. ])
  191. );
  192. const rejectedInsertQuery = createThenableQuery([] as unknown[]);
  193. rejectedInsertQuery.onConflictDoNothing = vi.fn(() => Promise.reject({ code: "42703" }));
  194. const insertMock = vi
  195. .fn()
  196. .mockReturnValueOnce(rejectedInsertQuery)
  197. .mockReturnValueOnce(createThenableQuery([]));
  198. vi.doMock("@/drizzle/db", () => ({
  199. db: {
  200. select: selectMock,
  201. update: vi.fn(() => createThenableQuery([])),
  202. insert: insertMock,
  203. execute: vi.fn(async () => ({ count: 0 })),
  204. },
  205. }));
  206. const { getSystemSettings } = await import("@/repository/system-config");
  207. const result = await getSystemSettings();
  208. expect(result.siteTitle).toBe("Claude Code Hub");
  209. expect(result.codexPriorityBillingSource).toBe("requested");
  210. expect(insertMock).toHaveBeenCalledTimes(2);
  211. vi.useRealTimers();
  212. });
  213. test("updateSystemSettings 在仅缺新列时应降级保存其他字段", async () => {
  214. vi.resetModules();
  215. const now = new Date("2026-01-04T00:00:00.000Z");
  216. vi.useFakeTimers();
  217. vi.setSystemTime(now);
  218. const selectMock = vi
  219. .fn()
  220. .mockReturnValueOnce(createRejectedThenableQuery({ code: "42703" }))
  221. .mockReturnValueOnce(
  222. createThenableQuery([
  223. {
  224. id: 1,
  225. siteTitle: "Claude Code Hub",
  226. allowGlobalUsageView: false,
  227. currencyDisplay: "USD",
  228. billingModelSource: "original",
  229. enableAutoCleanup: false,
  230. cleanupRetentionDays: 30,
  231. cleanupSchedule: "0 2 * * *",
  232. cleanupBatchSize: 10000,
  233. enableClientVersionCheck: false,
  234. verboseProviderError: false,
  235. enableHttp2: false,
  236. interceptAnthropicWarmupRequests: false,
  237. createdAt: now,
  238. updatedAt: now,
  239. },
  240. ])
  241. );
  242. const rejectedUpdateQuery = createThenableQuery([] as unknown[]);
  243. rejectedUpdateQuery.returning = vi.fn(() => Promise.reject({ code: "42703" }));
  244. const downgradedUpdateQuery = createThenableQuery([
  245. {
  246. id: 1,
  247. siteTitle: "Updated Title",
  248. allowGlobalUsageView: false,
  249. currencyDisplay: "USD",
  250. billingModelSource: "original",
  251. enableAutoCleanup: false,
  252. cleanupRetentionDays: 30,
  253. cleanupSchedule: "0 2 * * *",
  254. cleanupBatchSize: 10000,
  255. enableClientVersionCheck: false,
  256. verboseProviderError: false,
  257. enableHttp2: false,
  258. interceptAnthropicWarmupRequests: false,
  259. createdAt: now,
  260. updatedAt: now,
  261. },
  262. ]);
  263. const updateMock = vi
  264. .fn()
  265. .mockReturnValueOnce(rejectedUpdateQuery)
  266. .mockReturnValueOnce(downgradedUpdateQuery);
  267. vi.doMock("@/drizzle/db", () => ({
  268. db: {
  269. select: selectMock,
  270. update: updateMock,
  271. insert: vi.fn(() => createThenableQuery([])),
  272. execute: vi.fn(async () => ({ count: 0 })),
  273. },
  274. }));
  275. const { updateSystemSettings } = await import("@/repository/system-config");
  276. const result = await updateSystemSettings({
  277. siteTitle: "Updated Title",
  278. codexPriorityBillingSource: "actual",
  279. });
  280. expect(result.siteTitle).toBe("Updated Title");
  281. expect(result.codexPriorityBillingSource).toBe("requested");
  282. expect(updateMock).toHaveBeenCalledTimes(2);
  283. vi.useRealTimers();
  284. });
  285. test("updateSystemSettings 在仅缺 enable_high_concurrency_mode 列时,仍应保留 codexPriorityBillingSource 更新", async () => {
  286. vi.resetModules();
  287. const now = new Date("2026-01-04T00:00:00.000Z");
  288. vi.useFakeTimers();
  289. vi.setSystemTime(now);
  290. const selectMock = vi.fn().mockReturnValue(
  291. createThenableQuery([
  292. {
  293. id: 1,
  294. siteTitle: "Claude Code Hub",
  295. allowGlobalUsageView: false,
  296. currencyDisplay: "USD",
  297. billingModelSource: "original",
  298. codexPriorityBillingSource: "requested",
  299. enableAutoCleanup: false,
  300. cleanupRetentionDays: 30,
  301. cleanupSchedule: "0 2 * * *",
  302. cleanupBatchSize: 10000,
  303. enableClientVersionCheck: false,
  304. verboseProviderError: false,
  305. enableHttp2: false,
  306. interceptAnthropicWarmupRequests: false,
  307. createdAt: now,
  308. updatedAt: now,
  309. },
  310. ])
  311. );
  312. const rejectedUpdateQuery = createThenableQuery([] as unknown[]);
  313. rejectedUpdateQuery.returning = vi.fn(() => Promise.reject({ code: "42703" }));
  314. const downgradedUpdateQuery = createThenableQuery([
  315. {
  316. id: 1,
  317. siteTitle: "Updated Title",
  318. allowGlobalUsageView: false,
  319. currencyDisplay: "USD",
  320. billingModelSource: "original",
  321. codexPriorityBillingSource: "actual",
  322. enableAutoCleanup: false,
  323. cleanupRetentionDays: 30,
  324. cleanupSchedule: "0 2 * * *",
  325. cleanupBatchSize: 10000,
  326. enableClientVersionCheck: false,
  327. verboseProviderError: false,
  328. enableHttp2: false,
  329. interceptAnthropicWarmupRequests: false,
  330. createdAt: now,
  331. updatedAt: now,
  332. },
  333. ]);
  334. const updateMock = vi
  335. .fn()
  336. .mockReturnValueOnce(rejectedUpdateQuery)
  337. .mockReturnValueOnce(downgradedUpdateQuery);
  338. vi.doMock("@/drizzle/db", () => ({
  339. db: {
  340. select: selectMock,
  341. update: updateMock,
  342. insert: vi.fn(() => createThenableQuery([])),
  343. execute: vi.fn(async () => ({ count: 0 })),
  344. },
  345. }));
  346. const { updateSystemSettings } = await import("@/repository/system-config");
  347. const result = await updateSystemSettings({
  348. siteTitle: "Updated Title",
  349. codexPriorityBillingSource: "actual",
  350. enableHighConcurrencyMode: true,
  351. });
  352. expect(result.siteTitle).toBe("Updated Title");
  353. expect(result.codexPriorityBillingSource).toBe("actual");
  354. expect(result.enableHighConcurrencyMode).toBe(false);
  355. expect(updateMock).toHaveBeenCalledTimes(2);
  356. vi.useRealTimers();
  357. });
  358. });