Skip to content

Commit d4bff4b

Browse files
committed
test: 补充 wrapWithEnvelope/compose 单测 + envelope response schema 校验覆盖
在现有测试文件补充(不新建): - test-envelope.ts: wrapWithEnvelope 6 分支单测(零开销退化/success包装/不二次包装/ error包装/re-throw/undefined)+ enveloper 模式下 response schema 校验(通过+失败) - test-router.ts: compose 洋葱模型 6 单测(顺序/终止/reject/同步抛错/重复next/空链) lib/adapters/utils.ts 覆盖率 27.77% → 100%,整体 92.33% 达标。
1 parent a8265b8 commit d4bff4b

2 files changed

Lines changed: 219 additions & 1 deletion

File tree

src/test/test-envelope.ts

Lines changed: 141 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,41 @@
55
import express from "express";
66
import { expressAdapter } from "./adapters";
77
import { httpReq as request } from "./http-req";
8-
import { afterAll, describe, expect, it } from "vitest";
8+
import { afterAll, describe, expect, it, vi } from "vitest";
9+
import { z } from "zod";
10+
import { wrapWithEnvelope } from "../lib/adapters/utils.js";
11+
import type { Context } from "../lib/adapters/types.js";
912
import lib from "./lib";
1013

14+
/** 构造最小 Context mock(state 可读写,reply 记录调用) */
15+
function mockCtx(): Context & {
16+
__returned?: boolean;
17+
__returnValue?: unknown;
18+
reply: {
19+
status: ReturnType<typeof vi.fn>;
20+
json: ReturnType<typeof vi.fn>;
21+
send: ReturnType<typeof vi.fn>;
22+
raw: unknown;
23+
};
24+
} {
25+
const reply = {
26+
status: vi.fn(() => reply),
27+
json: vi.fn(),
28+
send: vi.fn(),
29+
raw: {},
30+
};
31+
return {
32+
method: "GET",
33+
path: "/",
34+
headers: {},
35+
params: {},
36+
query: {},
37+
body: {},
38+
state: {},
39+
reply,
40+
} as Context & { __returned?: boolean; __returnValue?: unknown; reply: typeof reply };
41+
}
42+
1143
describe("全局 response envelope(registerTyped return 模式)", () => {
1244
const envelopers = {
1345
success: (data: unknown) => ({ success: true, data }),
@@ -80,6 +112,114 @@ describe("全局 response envelope(registerTyped return 模式)", () => {
80112
expect(res.status).toBe(200);
81113
expect(res.body).toEqual({ success: true, data: undefined });
82114
});
115+
116+
it("enveloper 模式 + response schema → 校验 return 值后再包装", async () => {
117+
const app = express();
118+
app.use(express.json());
119+
const apiService = lib({ basePath: "" });
120+
apiService.setResponseEnvelopers(envelopers);
121+
122+
apiService.api
123+
.get("/env-schema")
124+
.group("Index")
125+
.title("env-schema")
126+
.registerTyped(
127+
{ response: z.object({ id: z.number() }) },
128+
async () => ({ id: 7 }) // return 值会先经 response schema 校验,再经 enveloper 包装
129+
);
130+
131+
apiService.bind({ adapter: expressAdapter, router: app });
132+
const res = await request(app).get("/env-schema");
133+
expect(res.status).toBe(200);
134+
expect(res.body).toEqual({ success: true, data: { id: 7 } });
135+
});
136+
137+
it("enveloper 模式 + response schema → return 值不合 schema 时抛错(走 error enveloper)", async () => {
138+
const app = express();
139+
app.use(express.json());
140+
const apiService = lib({ basePath: "" });
141+
apiService.setResponseEnvelopers(envelopers);
142+
143+
apiService.api
144+
.get("/env-schema-bad")
145+
.group("Index")
146+
.title("env-schema-bad")
147+
.registerTyped(
148+
{ response: z.object({ id: z.number() }) },
149+
async () => ({ id: "not-a-number" }) as never // 类型撒谎:实际返回 string
150+
);
151+
152+
apiService.bind({ adapter: expressAdapter, router: app });
153+
const res = await request(app).get("/env-schema-bad");
154+
// response schema 校验失败 → 抛 ZodError → error enveloper 包装
155+
expect(res.status).toBe(500);
156+
expect(res.body.success).toBe(false);
157+
});
158+
});
159+
160+
// wrapWithEnvelope 直接单测(不经子包 dist,确保 coverage 统计到 src/lib/adapters/utils)
161+
describe("wrapWithEnvelope 单元测试", () => {
162+
it("未注册任何 enveloper 时零开销退化(返回原 dispatch)", () => {
163+
const dispatch = vi.fn();
164+
const wrapped = wrapWithEnvelope(dispatch as never, {});
165+
expect(wrapped).toBe(dispatch); // 直接返回原 dispatch,不包装
166+
});
167+
168+
it("handler return data → successEnveloper 包装写入 reply.json", async () => {
169+
const ctx = mockCtx();
170+
ctx.__returned = true;
171+
ctx.__returnValue = { id: 1 };
172+
const success = vi.fn((data: unknown) => ({ success: true, data }));
173+
174+
await wrapWithEnvelope(async () => Promise.resolve(), { success })(ctx);
175+
176+
expect(success).toHaveBeenCalledWith({ id: 1 }, ctx);
177+
expect(ctx.reply.json).toHaveBeenCalledWith({ success: true, data: { id: 1 } });
178+
});
179+
180+
it("handler 未置 __returned(自行调 ctx.reply)→ 不二次包装", async () => {
181+
const ctx = mockCtx();
182+
const success = vi.fn();
183+
184+
await wrapWithEnvelope(async () => Promise.resolve(), { success })(ctx);
185+
186+
expect(success).not.toHaveBeenCalled();
187+
expect(ctx.reply.json).not.toHaveBeenCalled();
188+
});
189+
190+
it("handler 抛错 → errorEnveloper 包装 status + body", async () => {
191+
const ctx = mockCtx();
192+
const boom = Object.assign(new Error("not found"), { statusCode: 404 });
193+
const error = vi.fn(() => ({ body: { fail: true }, status: 404 }));
194+
195+
await wrapWithEnvelope(async () => Promise.reject(boom), { error })(ctx);
196+
197+
expect(error).toHaveBeenCalledWith(boom, ctx);
198+
expect(ctx.reply.status).toHaveBeenCalledWith(404);
199+
expect(ctx.reply.json).toHaveBeenCalledWith({ fail: true });
200+
});
201+
202+
it("仅注册 success enveloper 时,抛错 re-throw(不吞错)", async () => {
203+
const ctx = mockCtx();
204+
const success = vi.fn();
205+
206+
await expect(wrapWithEnvelope(async () => Promise.reject(new Error("escape")), { success })(ctx)).rejects.toThrow(
207+
"escape"
208+
);
209+
expect(success).not.toHaveBeenCalled();
210+
});
211+
212+
it("__returned=true 但 returnValue=undefined → successEnveloper 仍被调用", async () => {
213+
const ctx = mockCtx();
214+
ctx.__returned = true;
215+
ctx.__returnValue = undefined;
216+
const success = vi.fn((data: unknown) => ({ ok: true, data }));
217+
218+
await wrapWithEnvelope(async () => Promise.resolve(), { success })(ctx);
219+
220+
expect(success).toHaveBeenCalledWith(undefined, ctx);
221+
expect(ctx.reply.json).toHaveBeenCalled();
222+
});
83223
});
84224

85225
afterAll(() => {});

src/test/test-router.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { expressAdapter, koaAdapter, leizmwebAdapter } from "./adapters";
99
import express from "express";
1010
import { describe, expect, test } from "vitest";
1111
import { z } from "zod";
12+
import { compose } from "../lib/adapters/utils.js";
13+
import type { Context } from "../lib/adapters/types.js";
1214
import { commonSchemas, createAllCrudApis, createGetApi, createPostApi } from "./utils/api-helpers";
1315
import { assertApiRegistered, assertThrowsWithMessage } from "./utils/assertion-helpers";
1416
import { createMockHook, createStandardHooks } from "./utils/mock-factories";
@@ -661,3 +663,79 @@ describe("Router - Unified bind() Method", () => {
661663
});
662664
});
663665
});
666+
667+
// compose 洋葱模型单元测试(bind 路由的中间件链核心,直接 import 源码确保 coverage)
668+
describe("compose 洋葱模型", () => {
669+
/** 构造最小 Context */
670+
function mockCtx(): Context {
671+
return {
672+
method: "GET",
673+
path: "/",
674+
headers: {},
675+
params: {},
676+
query: {},
677+
body: {},
678+
state: {},
679+
reply: { status: () => undefined, json: () => undefined, send: () => undefined, raw: {} },
680+
} as Context;
681+
}
682+
683+
test("按顺序执行中间件链(洋葱:next 前后)", async () => {
684+
const order: string[] = [];
685+
const mws = [
686+
(ctx: Context, next: () => Promise<void>) => {
687+
order.push("a-before");
688+
return next().then(() => order.push("a-after"));
689+
},
690+
(_ctx: Context, next: () => Promise<void>) => {
691+
order.push("b");
692+
return next();
693+
},
694+
];
695+
await compose(mws)(mockCtx());
696+
expect(order).toEqual(["a-before", "b", "a-after"]);
697+
});
698+
699+
test("不调 next 则终止后续链", async () => {
700+
const order: string[] = [];
701+
const mws = [
702+
() => {
703+
order.push("a");
704+
},
705+
() => {
706+
order.push("b");
707+
},
708+
];
709+
await compose(mws)(mockCtx());
710+
expect(order).toEqual(["a"]);
711+
});
712+
713+
test("中间件 reject 向上抛", async () => {
714+
const mws = [() => Promise.reject(new Error("boom"))];
715+
await expect(compose(mws)(mockCtx())).rejects.toThrow("boom");
716+
});
717+
718+
test("中间件同步抛错也 reject", async () => {
719+
const mws = [
720+
() => {
721+
throw new Error("sync-boom");
722+
},
723+
];
724+
await expect(compose(mws)(mockCtx())).rejects.toThrow("sync-boom");
725+
});
726+
727+
test("next 重复调用时报错(防循环)", async () => {
728+
const mws = [
729+
async (_ctx: Context, next: () => Promise<void>) => {
730+
await next();
731+
await next();
732+
},
733+
];
734+
await expect(compose(mws)(mockCtx())).rejects.toThrow("next() called multiple times");
735+
});
736+
737+
test("空中间件链直接完成", async () => {
738+
const ret = await compose([])(mockCtx());
739+
expect(ret).toBeUndefined();
740+
});
741+
});

0 commit comments

Comments
 (0)