api-actions-integrity.test.ts 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  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/addUser",
  60. "/api/actions/users/editUser",
  61. "/api/actions/users/removeUser",
  62. "/api/actions/users/getUserLimitUsage",
  63. ];
  64. for (const path of expectedPaths) {
  65. expect(openApiDoc.paths[path]).toBeDefined();
  66. expect(openApiDoc.paths[path].post).toBeDefined();
  67. }
  68. });
  69. test("密钥管理模块的所有端点应该被注册", () => {
  70. const expectedPaths = [
  71. "/api/actions/keys/getKeys",
  72. "/api/actions/keys/addKey",
  73. "/api/actions/keys/editKey",
  74. "/api/actions/keys/removeKey",
  75. "/api/actions/keys/getKeyLimitUsage",
  76. ];
  77. for (const path of expectedPaths) {
  78. expect(openApiDoc.paths[path]).toBeDefined();
  79. expect(openApiDoc.paths[path].post).toBeDefined();
  80. }
  81. });
  82. test("供应商管理模块的所有端点应该被注册", () => {
  83. const expectedPaths = [
  84. "/api/actions/providers/getProviders",
  85. "/api/actions/providers/addProvider",
  86. "/api/actions/providers/editProvider",
  87. "/api/actions/providers/removeProvider",
  88. "/api/actions/providers/getProvidersHealthStatus",
  89. "/api/actions/providers/resetProviderCircuit",
  90. "/api/actions/providers/getProviderLimitUsage",
  91. ];
  92. for (const path of expectedPaths) {
  93. expect(openApiDoc.paths[path]).toBeDefined();
  94. expect(openApiDoc.paths[path].post).toBeDefined();
  95. }
  96. });
  97. test("模型价格模块的所有端点应该被注册", () => {
  98. const expectedPaths = [
  99. "/api/actions/model-prices/getModelPrices",
  100. "/api/actions/model-prices/uploadPriceTable",
  101. "/api/actions/model-prices/syncLiteLLMPrices",
  102. "/api/actions/model-prices/getAvailableModelsByProviderType",
  103. "/api/actions/model-prices/hasPriceTable",
  104. ];
  105. for (const path of expectedPaths) {
  106. expect(openApiDoc.paths[path]).toBeDefined();
  107. expect(openApiDoc.paths[path].post).toBeDefined();
  108. }
  109. });
  110. test("统计分析模块的所有端点应该被注册", () => {
  111. const expectedPaths = ["/api/actions/statistics/getUserStatistics"];
  112. for (const path of expectedPaths) {
  113. expect(openApiDoc.paths[path]).toBeDefined();
  114. expect(openApiDoc.paths[path].post).toBeDefined();
  115. }
  116. });
  117. test("使用日志模块的所有端点应该被注册", () => {
  118. const expectedPaths = [
  119. "/api/actions/usage-logs/getUsageLogs",
  120. "/api/actions/usage-logs/getModelList",
  121. "/api/actions/usage-logs/getStatusCodeList",
  122. ];
  123. for (const path of expectedPaths) {
  124. expect(openApiDoc.paths[path]).toBeDefined();
  125. expect(openApiDoc.paths[path].post).toBeDefined();
  126. }
  127. });
  128. test("我的用量模块的所有端点应该被注册", () => {
  129. const expectedPaths = [
  130. "/api/actions/my-usage/getMyUsageMetadata",
  131. "/api/actions/my-usage/getMyQuota",
  132. "/api/actions/my-usage/getMyTodayStats",
  133. "/api/actions/my-usage/getMyUsageLogsBatch",
  134. "/api/actions/my-usage/getMyAvailableModels",
  135. "/api/actions/my-usage/getMyAvailableEndpoints",
  136. ];
  137. for (const path of expectedPaths) {
  138. expect(openApiDoc.paths[path]).toBeDefined();
  139. expect(openApiDoc.paths[path].post).toBeDefined();
  140. }
  141. });
  142. test("我的用量批量日志端点应将 cursor id 声明为整数", () => {
  143. const operation = openApiDoc.paths["/api/actions/my-usage/getMyUsageLogsBatch"]?.post;
  144. const requestCursorId = operation?.requestBody?.content?.["application/json"]?.schema
  145. ?.properties?.cursor as
  146. | { properties?: Record<string, { type?: string; format?: string }> }
  147. | undefined;
  148. const responseData = operation?.responses?.["200"]?.content?.["application/json"]?.schema
  149. ?.properties?.data as
  150. | {
  151. properties?: Record<
  152. string,
  153. { properties?: Record<string, { type?: string; format?: string }> }
  154. >;
  155. }
  156. | undefined;
  157. expect(requestCursorId?.properties?.id?.type).toBe("integer");
  158. expect(responseData?.properties?.nextCursor?.properties?.id?.type).toBe("integer");
  159. });
  160. test("概览模块的所有端点应该被注册", () => {
  161. const expectedPaths = ["/api/actions/overview/getOverviewData"];
  162. for (const path of expectedPaths) {
  163. expect(openApiDoc.paths[path]).toBeDefined();
  164. expect(openApiDoc.paths[path].post).toBeDefined();
  165. }
  166. });
  167. test("敏感词管理模块的所有端点应该被注册", () => {
  168. const expectedPaths = [
  169. "/api/actions/sensitive-words/listSensitiveWords",
  170. "/api/actions/sensitive-words/createSensitiveWordAction",
  171. "/api/actions/sensitive-words/updateSensitiveWordAction",
  172. "/api/actions/sensitive-words/deleteSensitiveWordAction",
  173. "/api/actions/sensitive-words/refreshCacheAction",
  174. "/api/actions/sensitive-words/getCacheStats",
  175. ];
  176. for (const path of expectedPaths) {
  177. expect(openApiDoc.paths[path]).toBeDefined();
  178. expect(openApiDoc.paths[path].post).toBeDefined();
  179. }
  180. });
  181. test("Session 管理模块的所有端点应该被注册", () => {
  182. const expectedPaths = [
  183. "/api/actions/active-sessions/getActiveSessions",
  184. "/api/actions/active-sessions/getSessionDetails",
  185. "/api/actions/active-sessions/getSessionMessages",
  186. ];
  187. for (const path of expectedPaths) {
  188. expect(openApiDoc.paths[path]).toBeDefined();
  189. expect(openApiDoc.paths[path].post).toBeDefined();
  190. }
  191. });
  192. test("通知管理模块的所有端点应该被注册", () => {
  193. const expectedPaths = [
  194. "/api/actions/notifications/getNotificationSettingsAction",
  195. "/api/actions/notifications/updateNotificationSettingsAction",
  196. "/api/actions/notifications/testWebhookAction",
  197. ];
  198. for (const path of expectedPaths) {
  199. expect(openApiDoc.paths[path]).toBeDefined();
  200. expect(openApiDoc.paths[path].post).toBeDefined();
  201. }
  202. });
  203. test("Webhook 目标管理模块的所有端点应该被注册", () => {
  204. const expectedPaths = [
  205. "/api/actions/webhook-targets/getWebhookTargetsAction",
  206. "/api/actions/webhook-targets/createWebhookTargetAction",
  207. "/api/actions/webhook-targets/updateWebhookTargetAction",
  208. "/api/actions/webhook-targets/deleteWebhookTargetAction",
  209. "/api/actions/webhook-targets/testWebhookTargetAction",
  210. ];
  211. for (const path of expectedPaths) {
  212. expect(openApiDoc.paths[path]).toBeDefined();
  213. expect(openApiDoc.paths[path].post).toBeDefined();
  214. }
  215. });
  216. test("通知绑定模块的所有端点应该被注册", () => {
  217. const expectedPaths = [
  218. "/api/actions/notification-bindings/getBindingsForTypeAction",
  219. "/api/actions/notification-bindings/updateBindingsAction",
  220. ];
  221. for (const path of expectedPaths) {
  222. expect(openApiDoc.paths[path]).toBeDefined();
  223. expect(openApiDoc.paths[path].post).toBeDefined();
  224. }
  225. });
  226. test("所有端点的 summary 应该非空", () => {
  227. const pathsWithoutSummary: string[] = [];
  228. for (const [path, methods] of Object.entries(openApiDoc.paths)) {
  229. for (const [method, operation] of Object.entries(methods)) {
  230. if (!operation.summary || operation.summary.trim() === "") {
  231. pathsWithoutSummary.push(`${method.toUpperCase()} ${path}`);
  232. }
  233. }
  234. }
  235. expect(pathsWithoutSummary).toEqual([]);
  236. });
  237. test("所有端点应该分配到正确的标签", () => {
  238. const pathsWithWrongTags: string[] = [];
  239. const moduleTagMapping: Record<string, string> = {
  240. "/api/actions/users/": "用户管理",
  241. "/api/actions/keys/": "密钥管理",
  242. "/api/actions/providers/": "供应商管理",
  243. "/api/actions/model-prices/": "模型价格",
  244. "/api/actions/statistics/": "统计分析",
  245. "/api/actions/usage-logs/": "使用日志",
  246. "/api/actions/overview/": "概览",
  247. "/api/actions/sensitive-words/": "敏感词管理",
  248. "/api/actions/active-sessions/": "Session 管理",
  249. "/api/actions/notifications/": "通知管理",
  250. "/api/actions/webhook-targets/": "通知管理",
  251. "/api/actions/notification-bindings/": "通知管理",
  252. };
  253. for (const [path, methods] of Object.entries(openApiDoc.paths)) {
  254. const postOperation = methods.post;
  255. if (!postOperation || !postOperation.tags) continue;
  256. // 查找对应的标签
  257. const expectedTag = Object.entries(moduleTagMapping).find(([prefix]) =>
  258. path.startsWith(prefix)
  259. )?.[1];
  260. if (expectedTag && !postOperation.tags.includes(expectedTag)) {
  261. pathsWithWrongTags.push(
  262. `${path} (期望: ${expectedTag}, 实际: ${postOperation.tags.join(", ")})`
  263. );
  264. }
  265. }
  266. expect(pathsWithWrongTags).toEqual([]);
  267. });
  268. });