action-adapter-openapi.unit.test.ts 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. import { describe, expect, test, vi } from "vitest";
  2. import { z } from "@hono/zod-openapi";
  3. import {
  4. IdParamSchema,
  5. PaginationSchema,
  6. SortSchema,
  7. createActionRoute,
  8. createActionRoutes,
  9. createParamSchema,
  10. } from "@/lib/api/action-adapter-openapi";
  11. /**
  12. * 说明:
  13. * - 这些测试只覆盖 adapter 的“通用执行器”逻辑
  14. * - 不依赖 Next/Hono 的完整运行时
  15. * - 重点验证:参数映射、返回值包装、错误/异常处理、requiresAuth=false 分支
  16. */
  17. function createMockContext(options?: { body?: unknown; jsonThrows?: boolean }) {
  18. const body = options?.body ?? {};
  19. const jsonThrows = options?.jsonThrows ?? false;
  20. return {
  21. req: {
  22. json: async () => {
  23. if (jsonThrows) {
  24. throw new Error("invalid json");
  25. }
  26. return body;
  27. },
  28. },
  29. json: (payload: unknown, status = 200) =>
  30. new Response(JSON.stringify(payload), {
  31. status,
  32. headers: { "content-type": "application/json" },
  33. }),
  34. } as const;
  35. }
  36. describe("Action Adapter:createActionRoute(单元测试)", () => {
  37. test("requiresAuth=false:返回非 ActionResult 时自动包装 {ok:true,data}", async () => {
  38. const { handler } = createActionRoute(
  39. "test",
  40. "returnsRaw",
  41. async () => {
  42. return { hello: "world" };
  43. },
  44. { requiresAuth: false }
  45. );
  46. const response = (await handler(createMockContext({ body: {} }) as any)) as Response;
  47. expect(response.status).toBe(200);
  48. await expect(response.json()).resolves.toEqual({ ok: true, data: { hello: "world" } });
  49. });
  50. test("默认参数推断:schema 单字段时应传入该字段值", async () => {
  51. const action = vi.fn(async (id: number) => ({ id }));
  52. const { handler } = createActionRoute("test", "singleArg", action as any, {
  53. requiresAuth: false,
  54. requestSchema: z.object({ id: z.number() }),
  55. });
  56. const response = (await handler(createMockContext({ body: { id: 123 } }) as any)) as Response;
  57. expect(action).toHaveBeenCalledWith(123);
  58. expect(response.status).toBe(200);
  59. await expect(response.json()).resolves.toEqual({ ok: true, data: { id: 123 } });
  60. });
  61. test("默认参数推断:多字段 schema 传入整个 body(单参)", async () => {
  62. const action = vi.fn(async (body: { a: string; b: string }) => body);
  63. const { handler } = createActionRoute("test", "multiKey", action as any, {
  64. requiresAuth: false,
  65. requestSchema: z.object({ a: z.string(), b: z.string() }),
  66. });
  67. const response = (await handler(
  68. createMockContext({ body: { a: "x", b: "y" } }) as any
  69. )) as Response;
  70. expect(action).toHaveBeenCalledWith({ a: "x", b: "y" });
  71. expect(response.status).toBe(200);
  72. await expect(response.json()).resolves.toEqual({ ok: true, data: { a: "x", b: "y" } });
  73. });
  74. test("argsMapper:应优先使用显式映射以支持多参数 action", async () => {
  75. const action = vi.fn(async (userId: number, data: { name: string }) => ({ userId, data }));
  76. const { handler } = createActionRoute("test", "mappedArgs", action as any, {
  77. requiresAuth: false,
  78. requestSchema: z.object({
  79. userId: z.number(),
  80. data: z.object({ name: z.string() }),
  81. }),
  82. argsMapper: (body: { userId: number; data: { name: string } }) => [body.userId, body.data],
  83. });
  84. const response = (await handler(
  85. createMockContext({ body: { userId: 7, data: { name: "alice" } } }) as any
  86. )) as Response;
  87. expect(action).toHaveBeenCalledWith(7, { name: "alice" });
  88. expect(response.status).toBe(200);
  89. await expect(response.json()).resolves.toEqual({
  90. ok: true,
  91. data: { userId: 7, data: { name: "alice" } },
  92. });
  93. });
  94. test("action 返回 ok=false:应返回 400 且透传 errorCode/errorParams", async () => {
  95. const { handler } = createActionRoute(
  96. "test",
  97. "returnsError",
  98. async () => ({
  99. ok: false,
  100. error: "业务错误",
  101. errorCode: "BIZ_ERROR",
  102. errorParams: { field: "name" },
  103. }),
  104. { requiresAuth: false }
  105. );
  106. const response = (await handler(createMockContext({ body: {} }) as any)) as Response;
  107. expect(response.status).toBe(400);
  108. await expect(response.json()).resolves.toEqual({
  109. ok: false,
  110. error: "业务错误",
  111. errorCode: "BIZ_ERROR",
  112. errorParams: { field: "name" },
  113. });
  114. });
  115. test("action 抛出 Error:应返回 500 且返回 error.message", async () => {
  116. const { handler } = createActionRoute(
  117. "test",
  118. "throwsError",
  119. async () => {
  120. throw new Error("boom");
  121. },
  122. { requiresAuth: false }
  123. );
  124. const response = (await handler(createMockContext({ body: {} }) as any)) as Response;
  125. expect(response.status).toBe(500);
  126. await expect(response.json()).resolves.toEqual({ ok: false, error: "boom" });
  127. });
  128. test("action 抛出非 Error:应返回 500 且返回通用错误消息", async () => {
  129. const { handler } = createActionRoute(
  130. "test",
  131. "throwsUnknown",
  132. async () => {
  133. // eslint-disable-next-line no-throw-literal
  134. throw "boom";
  135. },
  136. { requiresAuth: false }
  137. );
  138. const response = (await handler(createMockContext({ body: {} }) as any)) as Response;
  139. expect(response.status).toBe(500);
  140. await expect(response.json()).resolves.toEqual({ ok: false, error: "服务器内部错误" });
  141. });
  142. test("请求体不是 JSON:应降级为 {} 并继续执行", async () => {
  143. const action = vi.fn(async () => "ok");
  144. const { handler } = createActionRoute("test", "badJson", action as any, {
  145. requiresAuth: false,
  146. });
  147. const response = (await handler(createMockContext({ jsonThrows: true }) as any)) as Response;
  148. expect(action).toHaveBeenCalledTimes(1);
  149. expect(response.status).toBe(200);
  150. await expect(response.json()).resolves.toEqual({ ok: true, data: "ok" });
  151. });
  152. });
  153. describe("Action Adapter:辅助导出函数(单元测试)", () => {
  154. test("createActionRoutes:应批量生成 route/handler", () => {
  155. const routes = createActionRoutes(
  156. "demo",
  157. {
  158. a: async () => ({ ok: true, data: 1 }),
  159. b: async () => 2,
  160. },
  161. {
  162. b: { requiresAuth: false },
  163. }
  164. );
  165. expect(routes).toHaveLength(2);
  166. });
  167. test("通用 schemas:应支持解析与默认值", () => {
  168. const schema = createParamSchema({ name: z.string() });
  169. expect(schema.parse({ name: "x" })).toEqual({ name: "x" });
  170. expect(IdParamSchema.parse({ id: 1 })).toEqual({ id: 1 });
  171. expect(PaginationSchema.parse({})).toEqual({ page: 1, pageSize: 20 });
  172. expect(SortSchema.parse({})).toEqual({ sortBy: undefined, sortOrder: "desc" });
  173. });
  174. });