api-actions-integrity.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  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("敏感词管理模块的所有端点应该被注册", () => {
  172. const expectedPaths = [
  173. "/api/actions/sensitive-words/listSensitiveWords",
  174. "/api/actions/sensitive-words/createSensitiveWordAction",
  175. "/api/actions/sensitive-words/updateSensitiveWordAction",
  176. "/api/actions/sensitive-words/deleteSensitiveWordAction",
  177. "/api/actions/sensitive-words/refreshCacheAction",
  178. "/api/actions/sensitive-words/getCacheStats",
  179. ];
  180. for (const path of expectedPaths) {
  181. expect(openApiDoc.paths[path]).toBeDefined();
  182. expect(openApiDoc.paths[path].post).toBeDefined();
  183. }
  184. });
  185. test("Session 管理模块的所有端点应该被注册", () => {
  186. const expectedPaths = [
  187. "/api/actions/active-sessions/getActiveSessions",
  188. "/api/actions/active-sessions/getSessionDetails",
  189. "/api/actions/active-sessions/getSessionMessages",
  190. ];
  191. for (const path of expectedPaths) {
  192. expect(openApiDoc.paths[path]).toBeDefined();
  193. expect(openApiDoc.paths[path].post).toBeDefined();
  194. }
  195. });
  196. test("通知管理模块的所有端点应该被注册", () => {
  197. const expectedPaths = [
  198. "/api/actions/notifications/getNotificationSettingsAction",
  199. "/api/actions/notifications/updateNotificationSettingsAction",
  200. "/api/actions/notifications/testWebhookAction",
  201. ];
  202. for (const path of expectedPaths) {
  203. expect(openApiDoc.paths[path]).toBeDefined();
  204. expect(openApiDoc.paths[path].post).toBeDefined();
  205. }
  206. });
  207. test("Webhook 目标管理模块的所有端点应该被注册", () => {
  208. const expectedPaths = [
  209. "/api/actions/webhook-targets/getWebhookTargetsAction",
  210. "/api/actions/webhook-targets/createWebhookTargetAction",
  211. "/api/actions/webhook-targets/updateWebhookTargetAction",
  212. "/api/actions/webhook-targets/deleteWebhookTargetAction",
  213. "/api/actions/webhook-targets/testWebhookTargetAction",
  214. ];
  215. for (const path of expectedPaths) {
  216. expect(openApiDoc.paths[path]).toBeDefined();
  217. expect(openApiDoc.paths[path].post).toBeDefined();
  218. }
  219. });
  220. test("通知绑定模块的所有端点应该被注册", () => {
  221. const expectedPaths = [
  222. "/api/actions/notification-bindings/getBindingsForTypeAction",
  223. "/api/actions/notification-bindings/updateBindingsAction",
  224. ];
  225. for (const path of expectedPaths) {
  226. expect(openApiDoc.paths[path]).toBeDefined();
  227. expect(openApiDoc.paths[path].post).toBeDefined();
  228. }
  229. });
  230. test("所有端点的 summary 应该非空", () => {
  231. const pathsWithoutSummary: string[] = [];
  232. for (const [path, methods] of Object.entries(openApiDoc.paths)) {
  233. for (const [method, operation] of Object.entries(methods)) {
  234. if (!operation.summary || operation.summary.trim() === "") {
  235. pathsWithoutSummary.push(`${method.toUpperCase()} ${path}`);
  236. }
  237. }
  238. }
  239. expect(pathsWithoutSummary).toEqual([]);
  240. });
  241. test("所有端点应该分配到正确的标签", () => {
  242. const pathsWithWrongTags: string[] = [];
  243. const moduleTagMapping: Record<string, string> = {
  244. "/api/actions/users/": "用户管理",
  245. "/api/actions/keys/": "密钥管理",
  246. "/api/actions/providers/": "供应商管理",
  247. "/api/actions/model-prices/": "模型价格",
  248. "/api/actions/statistics/": "统计分析",
  249. "/api/actions/usage-logs/": "使用日志",
  250. "/api/actions/overview/": "概览",
  251. "/api/actions/sensitive-words/": "敏感词管理",
  252. "/api/actions/active-sessions/": "Session 管理",
  253. "/api/actions/notifications/": "通知管理",
  254. "/api/actions/webhook-targets/": "通知管理",
  255. "/api/actions/notification-bindings/": "通知管理",
  256. };
  257. for (const [path, methods] of Object.entries(openApiDoc.paths)) {
  258. const postOperation = methods.post;
  259. if (!postOperation?.tags) continue;
  260. // 查找对应的标签
  261. const expectedTag = Object.entries(moduleTagMapping).find(([prefix]) =>
  262. path.startsWith(prefix)
  263. )?.[1];
  264. if (expectedTag && !postOperation.tags.includes(expectedTag)) {
  265. pathsWithWrongTags.push(
  266. `${path} (期望: ${expectedTag}, 实际: ${postOperation.tags.join(", ")})`
  267. );
  268. }
  269. }
  270. expect(pathsWithWrongTags).toEqual([]);
  271. });
  272. });