system-config-save.test.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. import { beforeEach, describe, expect, it, vi } from "vitest";
  2. import { locales } from "@/i18n/config";
  3. // Mock dependencies
  4. const getSessionMock = vi.fn();
  5. const revalidatePathMock = vi.fn();
  6. const invalidateSystemSettingsCacheMock = vi.fn();
  7. const updateSystemSettingsMock = vi.fn();
  8. const getSystemSettingsMock = vi.fn();
  9. vi.mock("@/lib/auth", () => ({
  10. getSession: () => getSessionMock(),
  11. }));
  12. vi.mock("next/cache", () => ({
  13. revalidatePath: (...args: unknown[]) => revalidatePathMock(...args),
  14. }));
  15. vi.mock("@/lib/config", () => ({
  16. invalidateSystemSettingsCache: () => invalidateSystemSettingsCacheMock(),
  17. }));
  18. vi.mock("@/lib/logger", () => ({
  19. logger: {
  20. trace: vi.fn(),
  21. debug: vi.fn(),
  22. info: vi.fn(),
  23. warn: vi.fn(),
  24. error: vi.fn(),
  25. },
  26. }));
  27. vi.mock("@/lib/utils/timezone", () => ({
  28. resolveSystemTimezone: vi.fn(async () => "UTC"),
  29. isValidIANATimezone: vi.fn(() => true),
  30. }));
  31. vi.mock("@/repository/system-config", () => ({
  32. getSystemSettings: () => getSystemSettingsMock(),
  33. updateSystemSettings: (...args: unknown[]) => updateSystemSettingsMock(...args),
  34. }));
  35. // Import the action after mocks are set up
  36. import { saveSystemSettings } from "@/actions/system-config";
  37. describe("saveSystemSettings", () => {
  38. beforeEach(() => {
  39. vi.clearAllMocks();
  40. // Default: admin session
  41. getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
  42. // Default: successful update
  43. updateSystemSettingsMock.mockResolvedValue({
  44. id: 1,
  45. siteTitle: "Test Site",
  46. allowGlobalUsageView: false,
  47. currencyDisplay: "CNY",
  48. billingModelSource: "original",
  49. codexPriorityBillingSource: "requested",
  50. timezone: null,
  51. enableAutoCleanup: false,
  52. cleanupRetentionDays: 30,
  53. cleanupSchedule: "0 3 * * *",
  54. cleanupBatchSize: 1000,
  55. enableClientVersionCheck: false,
  56. verboseProviderError: false,
  57. enableHttp2: false,
  58. enableHighConcurrencyMode: false,
  59. interceptAnthropicWarmupRequests: false,
  60. enableThinkingSignatureRectifier: false,
  61. enableThinkingBudgetRectifier: false,
  62. enableBillingHeaderRectifier: true,
  63. enableCodexSessionIdCompletion: false,
  64. enableClaudeMetadataUserIdInjection: false,
  65. enableResponseFixer: false,
  66. responseFixerConfig: {
  67. fixEncoding: false,
  68. fixStreamingJson: false,
  69. fixEmptyResponse: false,
  70. fixContentBlockDelta: false,
  71. maxRetries: 3,
  72. timeout: 5000,
  73. },
  74. quotaDbRefreshIntervalSeconds: 60,
  75. quotaLeasePercent5h: 0.05,
  76. quotaLeasePercentDaily: 0.05,
  77. quotaLeasePercentWeekly: 0.05,
  78. quotaLeasePercentMonthly: 0.05,
  79. quotaLeaseCapUsd: null,
  80. createdAt: new Date(),
  81. updatedAt: new Date(),
  82. });
  83. });
  84. it("should return error when user is not admin", async () => {
  85. getSessionMock.mockResolvedValue({ user: { id: 1, role: "user" } });
  86. const result = await saveSystemSettings({ siteTitle: "New Title" });
  87. expect(result.ok).toBe(false);
  88. expect(result.error).toContain("无权限");
  89. expect(updateSystemSettingsMock).not.toHaveBeenCalled();
  90. });
  91. it("should return error when user is not logged in", async () => {
  92. getSessionMock.mockResolvedValue(null);
  93. const result = await saveSystemSettings({ siteTitle: "New Title" });
  94. expect(result.ok).toBe(false);
  95. expect(result.error).toContain("无权限");
  96. expect(updateSystemSettingsMock).not.toHaveBeenCalled();
  97. });
  98. it("should call updateSystemSettings with validated data", async () => {
  99. const result = await saveSystemSettings({
  100. siteTitle: "New Site Title",
  101. verboseProviderError: true,
  102. });
  103. expect(result.ok).toBe(true);
  104. expect(updateSystemSettingsMock).toHaveBeenCalledWith(
  105. expect.objectContaining({
  106. siteTitle: "New Site Title",
  107. verboseProviderError: true,
  108. })
  109. );
  110. });
  111. it("should invalidate system settings cache after successful save", async () => {
  112. await saveSystemSettings({ siteTitle: "New Title" });
  113. expect(invalidateSystemSettingsCacheMock).toHaveBeenCalled();
  114. });
  115. describe("revalidatePath locale coverage", () => {
  116. it("should revalidate paths for ALL supported locales", async () => {
  117. await saveSystemSettings({ siteTitle: "New Title" });
  118. // Collect all revalidatePath calls
  119. const calls = revalidatePathMock.mock.calls.map((call) => call[0]);
  120. // Check that each locale's settings/config path is revalidated
  121. for (const locale of locales) {
  122. const expectedSettingsPath = `/${locale}/settings/config`;
  123. expect(calls).toContain(expectedSettingsPath);
  124. }
  125. });
  126. it("should revalidate dashboard paths for ALL supported locales", async () => {
  127. await saveSystemSettings({ siteTitle: "New Title" });
  128. const calls = revalidatePathMock.mock.calls.map((call) => call[0]);
  129. // Check that each locale's dashboard path is revalidated
  130. for (const locale of locales) {
  131. const expectedDashboardPath = `/${locale}/dashboard`;
  132. expect(calls).toContain(expectedDashboardPath);
  133. }
  134. });
  135. it("should revalidate root layout", async () => {
  136. await saveSystemSettings({ siteTitle: "New Title" });
  137. // Check that root layout is revalidated
  138. expect(revalidatePathMock).toHaveBeenCalledWith("/", "layout");
  139. });
  140. it("should call revalidatePath at least 2 * locales.length + 1 times", async () => {
  141. await saveSystemSettings({ siteTitle: "New Title" });
  142. // 2 paths per locale (settings/config + dashboard) + 1 for root layout
  143. const expectedMinCalls = locales.length * 2 + 1;
  144. expect(revalidatePathMock).toHaveBeenCalledTimes(expectedMinCalls);
  145. });
  146. });
  147. it("should return updated settings on success", async () => {
  148. const mockUpdated = {
  149. id: 1,
  150. siteTitle: "Updated Title",
  151. allowGlobalUsageView: true,
  152. currencyDisplay: "USD",
  153. billingModelSource: "original",
  154. codexPriorityBillingSource: "actual",
  155. timezone: "America/New_York",
  156. enableAutoCleanup: false,
  157. cleanupRetentionDays: 30,
  158. cleanupSchedule: "0 3 * * *",
  159. cleanupBatchSize: 1000,
  160. enableClientVersionCheck: false,
  161. verboseProviderError: true,
  162. enableHttp2: true,
  163. enableHighConcurrencyMode: true,
  164. interceptAnthropicWarmupRequests: false,
  165. enableThinkingSignatureRectifier: false,
  166. enableThinkingBudgetRectifier: false,
  167. enableBillingHeaderRectifier: true,
  168. enableCodexSessionIdCompletion: false,
  169. enableClaudeMetadataUserIdInjection: false,
  170. enableResponseFixer: false,
  171. responseFixerConfig: {
  172. fixEncoding: false,
  173. fixStreamingJson: false,
  174. fixEmptyResponse: false,
  175. fixContentBlockDelta: false,
  176. maxRetries: 3,
  177. timeout: 5000,
  178. },
  179. quotaDbRefreshIntervalSeconds: 60,
  180. quotaLeasePercent5h: 0.05,
  181. quotaLeasePercentDaily: 0.05,
  182. quotaLeasePercentWeekly: 0.05,
  183. quotaLeasePercentMonthly: 0.05,
  184. quotaLeaseCapUsd: null,
  185. createdAt: new Date(),
  186. updatedAt: new Date(),
  187. };
  188. updateSystemSettingsMock.mockResolvedValue(mockUpdated);
  189. const result = await saveSystemSettings({
  190. siteTitle: "Updated Title",
  191. allowGlobalUsageView: true,
  192. currencyDisplay: "USD",
  193. codexPriorityBillingSource: "actual",
  194. timezone: "America/New_York",
  195. verboseProviderError: true,
  196. enableHttp2: true,
  197. enableHighConcurrencyMode: true,
  198. });
  199. expect(result.ok).toBe(true);
  200. expect(result.data).toEqual(mockUpdated);
  201. });
  202. it("should handle repository errors gracefully", async () => {
  203. updateSystemSettingsMock.mockRejectedValue(new Error("Database error"));
  204. const result = await saveSystemSettings({ siteTitle: "New Title" });
  205. expect(result.ok).toBe(false);
  206. expect(result.error).toContain("Database error");
  207. });
  208. it("should pass codexPriorityBillingSource through validation and save", async () => {
  209. const result = await saveSystemSettings({
  210. codexPriorityBillingSource: "actual",
  211. });
  212. expect(result.ok).toBe(true);
  213. expect(updateSystemSettingsMock).toHaveBeenCalledWith(
  214. expect.objectContaining({
  215. codexPriorityBillingSource: "actual",
  216. })
  217. );
  218. });
  219. it("should pass enableHighConcurrencyMode through validation and save", async () => {
  220. const result = await saveSystemSettings({
  221. enableHighConcurrencyMode: true,
  222. });
  223. expect(result.ok).toBe(true);
  224. expect(updateSystemSettingsMock).toHaveBeenCalledWith(
  225. expect.objectContaining({
  226. enableHighConcurrencyMode: true,
  227. })
  228. );
  229. });
  230. });