api-actions-integrity.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. /**
  2. * Server Actions 完整性测试
  3. *
  4. * 目的:
  5. * - 验证所有 Server Actions 是否都被正确注册到 OpenAPI
  6. * - 通过 OpenAPI 文档验证端点完整性(避免直接导入 Server Actions)
  7. * - 确保没有遗漏的接口
  8. *
  9. * 用法:
  10. * bun run test:api
  11. */
  12. import { beforeAll, describe, expect, test } from "vitest";
  13. import { callActionsRoute } from "../test-utils";
  14. type OpenAPIDocument = {
  15. paths: Record<
  16. string,
  17. Record<
  18. string,
  19. {
  20. summary?: string;
  21. tags?: string[];
  22. requestBody?: {
  23. content?: {
  24. "application/json"?: {
  25. schema?: {
  26. properties?: Record<string, { type?: string; format?: string }>;
  27. };
  28. };
  29. };
  30. };
  31. responses?: {
  32. [status: string]: {
  33. content?: {
  34. "application/json"?: {
  35. schema?: {
  36. properties?: Record<string, unknown>;
  37. };
  38. };
  39. };
  40. };
  41. };
  42. }
  43. >
  44. >;
  45. };
  46. describe("OpenAPI 端点完整性检查", () => {
  47. let openApiDoc: OpenAPIDocument;
  48. beforeAll(async () => {
  49. const { response, json } = await callActionsRoute({
  50. method: "GET",
  51. pathname: "/api/actions/openapi.json",
  52. });
  53. expect(response.ok).toBe(true);
  54. openApiDoc = json as OpenAPIDocument;
  55. });
  56. test("用户管理模块的所有端点应该被注册", () => {
  57. const expectedPaths = [
  58. "/api/actions/users/getUsers",
  59. "/api/actions/users/getUsersBatch",
  60. "/api/actions/users/searchUsers",
  61. "/api/actions/users/addUser",
  62. "/api/actions/users/editUser",
  63. "/api/actions/users/removeUser",
  64. "/api/actions/users/getUserLimitUsage",
  65. ];
  66. for (const path of expectedPaths) {
  67. expect(openApiDoc.paths[path]).toBeDefined();
  68. expect(openApiDoc.paths[path].post).toBeDefined();
  69. }
  70. });
  71. test("密钥管理模块的所有端点应该被注册", () => {
  72. const expectedPaths = [
  73. "/api/actions/keys/getKeys",
  74. "/api/actions/keys/addKey",
  75. "/api/actions/keys/editKey",
  76. "/api/actions/keys/removeKey",
  77. "/api/actions/keys/getKeyLimitUsage",
  78. ];
  79. for (const path of expectedPaths) {
  80. expect(openApiDoc.paths[path]).toBeDefined();
  81. expect(openApiDoc.paths[path].post).toBeDefined();
  82. }
  83. });
  84. test("供应商管理模块的所有端点应该被注册", () => {
  85. const expectedPaths = [
  86. "/api/actions/providers/getProviders",
  87. "/api/actions/providers/addProvider",
  88. "/api/actions/providers/editProvider",
  89. "/api/actions/providers/removeProvider",
  90. "/api/actions/providers/getProvidersHealthStatus",
  91. "/api/actions/providers/resetProviderCircuit",
  92. "/api/actions/providers/getProviderLimitUsage",
  93. ];
  94. for (const path of expectedPaths) {
  95. expect(openApiDoc.paths[path]).toBeDefined();
  96. expect(openApiDoc.paths[path].post).toBeDefined();
  97. }
  98. });
  99. test("模型价格模块的所有端点应该被注册", () => {
  100. const expectedPaths = [
  101. "/api/actions/model-prices/getModelPrices",
  102. "/api/actions/model-prices/uploadPriceTable",
  103. "/api/actions/model-prices/syncLiteLLMPrices",
  104. "/api/actions/model-prices/getAvailableModelsByProviderType",
  105. "/api/actions/model-prices/hasPriceTable",
  106. ];
  107. for (const path of expectedPaths) {
  108. expect(openApiDoc.paths[path]).toBeDefined();
  109. expect(openApiDoc.paths[path].post).toBeDefined();
  110. }
  111. });
  112. test("统计分析模块的所有端点应该被注册", () => {
  113. const expectedPaths = ["/api/actions/statistics/getUserStatistics"];
  114. for (const path of expectedPaths) {
  115. expect(openApiDoc.paths[path]).toBeDefined();
  116. expect(openApiDoc.paths[path].post).toBeDefined();
  117. }
  118. });
  119. test("使用日志模块的所有端点应该被注册", () => {
  120. const expectedPaths = [
  121. "/api/actions/usage-logs/getUsageLogs",
  122. "/api/actions/usage-logs/getModelList",
  123. "/api/actions/usage-logs/getStatusCodeList",
  124. ];
  125. for (const path of expectedPaths) {
  126. expect(openApiDoc.paths[path]).toBeDefined();
  127. expect(openApiDoc.paths[path].post).toBeDefined();
  128. }
  129. });
  130. test("我的用量模块的所有端点应该被注册", () => {
  131. const expectedPaths = [
  132. "/api/actions/my-usage/getMyUsageMetadata",
  133. "/api/actions/my-usage/getMyQuota",
  134. "/api/actions/my-usage/getMyTodayStats",
  135. "/api/actions/my-usage/getMyUsageLogs",
  136. "/api/actions/my-usage/getMyUsageLogsBatch",
  137. "/api/actions/my-usage/getMyAvailableModels",
  138. "/api/actions/my-usage/getMyAvailableEndpoints",
  139. "/api/actions/my-usage/getMyIpGeoDetails",
  140. ];
  141. for (const path of expectedPaths) {
  142. expect(openApiDoc.paths[path]).toBeDefined();
  143. expect(openApiDoc.paths[path].post).toBeDefined();
  144. }
  145. });
  146. test("我的用量批量日志端点应将 cursor id 声明为整数", () => {
  147. const operation = openApiDoc.paths["/api/actions/my-usage/getMyUsageLogsBatch"]?.post;
  148. const requestCursorId = operation?.requestBody?.content?.["application/json"]?.schema
  149. ?.properties?.cursor as
  150. | { properties?: Record<string, { type?: string; format?: string }> }
  151. | undefined;
  152. const responseData = operation?.responses?.["200"]?.content?.["application/json"]?.schema
  153. ?.properties?.data as
  154. | {
  155. properties?: Record<
  156. string,
  157. { properties?: Record<string, { type?: string; format?: string }> }
  158. >;
  159. }
  160. | undefined;
  161. expect(requestCursorId?.properties?.id?.type).toBe("integer");
  162. expect(responseData?.properties?.nextCursor?.properties?.id?.type).toBe("integer");
  163. });
  164. test("概览模块的所有端点应该被注册", () => {
  165. const expectedPaths = ["/api/actions/overview/getOverviewData"];
  166. for (const path of expectedPaths) {
  167. expect(openApiDoc.paths[path]).toBeDefined();
  168. expect(openApiDoc.paths[path].post).toBeDefined();
  169. }
  170. });
  171. test("issue-947: key/user OpenAPI 契约应注册 5h reset mode 字段", () => {
  172. const addKeyRequest =
  173. openApiDoc.paths["/api/actions/keys/addKey"]?.post?.requestBody?.content?.["application/json"]
  174. ?.schema;
  175. const editKeyRequest =
  176. openApiDoc.paths["/api/actions/keys/editKey"]?.post?.requestBody?.content?.[
  177. "application/json"
  178. ]?.schema;
  179. const addUserResponse =
  180. openApiDoc.paths["/api/actions/users/addUser"]?.post?.responses?.["200"]?.content?.[
  181. "application/json"
  182. ]?.schema;
  183. expect(addKeyRequest?.properties?.limit5hResetMode).toBeDefined();
  184. expect(editKeyRequest?.properties?.limit5hResetMode).toBeDefined();
  185. expect(
  186. addUserResponse?.properties?.data?.properties?.user?.properties?.limit5hResetMode
  187. ).toBeDefined();
  188. });
  189. test("敏感词管理模块的所有端点应该被注册", () => {
  190. const expectedPaths = [
  191. "/api/actions/sensitive-words/listSensitiveWords",
  192. "/api/actions/sensitive-words/createSensitiveWordAction",
  193. "/api/actions/sensitive-words/updateSensitiveWordAction",
  194. "/api/actions/sensitive-words/deleteSensitiveWordAction",
  195. "/api/actions/sensitive-words/refreshCacheAction",
  196. "/api/actions/sensitive-words/getCacheStats",
  197. ];
  198. for (const path of expectedPaths) {
  199. expect(openApiDoc.paths[path]).toBeDefined();
  200. expect(openApiDoc.paths[path].post).toBeDefined();
  201. }
  202. });
  203. test("Session 管理模块的所有端点应该被注册", () => {
  204. const expectedPaths = [
  205. "/api/actions/active-sessions/getActiveSessions",
  206. "/api/actions/active-sessions/getSessionDetails",
  207. "/api/actions/active-sessions/getSessionMessages",
  208. ];
  209. for (const path of expectedPaths) {
  210. expect(openApiDoc.paths[path]).toBeDefined();
  211. expect(openApiDoc.paths[path].post).toBeDefined();
  212. }
  213. });
  214. test("通知管理模块的所有端点应该被注册", () => {
  215. const expectedPaths = [
  216. "/api/actions/notifications/getNotificationSettingsAction",
  217. "/api/actions/notifications/updateNotificationSettingsAction",
  218. "/api/actions/notifications/testWebhookAction",
  219. ];
  220. for (const path of expectedPaths) {
  221. expect(openApiDoc.paths[path]).toBeDefined();
  222. expect(openApiDoc.paths[path].post).toBeDefined();
  223. }
  224. });
  225. test("Webhook 目标管理模块的所有端点应该被注册", () => {
  226. const expectedPaths = [
  227. "/api/actions/webhook-targets/getWebhookTargetsAction",
  228. "/api/actions/webhook-targets/createWebhookTargetAction",
  229. "/api/actions/webhook-targets/updateWebhookTargetAction",
  230. "/api/actions/webhook-targets/deleteWebhookTargetAction",
  231. "/api/actions/webhook-targets/testWebhookTargetAction",
  232. ];
  233. for (const path of expectedPaths) {
  234. expect(openApiDoc.paths[path]).toBeDefined();
  235. expect(openApiDoc.paths[path].post).toBeDefined();
  236. }
  237. });
  238. test("通知绑定模块的所有端点应该被注册", () => {
  239. const expectedPaths = [
  240. "/api/actions/notification-bindings/getBindingsForTypeAction",
  241. "/api/actions/notification-bindings/updateBindingsAction",
  242. ];
  243. for (const path of expectedPaths) {
  244. expect(openApiDoc.paths[path]).toBeDefined();
  245. expect(openApiDoc.paths[path].post).toBeDefined();
  246. }
  247. });
  248. test("所有端点的 summary 应该非空", () => {
  249. const pathsWithoutSummary: string[] = [];
  250. for (const [path, methods] of Object.entries(openApiDoc.paths)) {
  251. for (const [method, operation] of Object.entries(methods)) {
  252. if (!operation.summary || operation.summary.trim() === "") {
  253. pathsWithoutSummary.push(`${method.toUpperCase()} ${path}`);
  254. }
  255. }
  256. }
  257. expect(pathsWithoutSummary).toEqual([]);
  258. });
  259. test("所有端点应该分配到正确的标签", () => {
  260. const pathsWithWrongTags: string[] = [];
  261. const moduleTagMapping: Record<string, string> = {
  262. "/api/actions/users/": "用户管理",
  263. "/api/actions/keys/": "密钥管理",
  264. "/api/actions/providers/": "供应商管理",
  265. "/api/actions/model-prices/": "模型价格",
  266. "/api/actions/statistics/": "统计分析",
  267. "/api/actions/usage-logs/": "使用日志",
  268. "/api/actions/overview/": "概览",
  269. "/api/actions/sensitive-words/": "敏感词管理",
  270. "/api/actions/active-sessions/": "Session 管理",
  271. "/api/actions/notifications/": "通知管理",
  272. "/api/actions/webhook-targets/": "通知管理",
  273. "/api/actions/notification-bindings/": "通知管理",
  274. };
  275. for (const [path, methods] of Object.entries(openApiDoc.paths)) {
  276. const postOperation = methods.post;
  277. if (!postOperation?.tags) continue;
  278. // 查找对应的标签
  279. const expectedTag = Object.entries(moduleTagMapping).find(([prefix]) =>
  280. path.startsWith(prefix)
  281. )?.[1];
  282. if (expectedTag && !postOperation.tags.includes(expectedTag)) {
  283. pathsWithWrongTags.push(
  284. `${path} (期望: ${expectedTag}, 实际: ${postOperation.tags.join(", ")})`
  285. );
  286. }
  287. }
  288. expect(pathsWithWrongTags).toEqual([]);
  289. });
  290. });