@taicode/common-server 4.0.0-beta.1 → 4.0.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (292) hide show
  1. package/output/app/index.d.ts +18 -15
  2. package/output/app/index.d.ts.map +1 -1
  3. package/output/app/index.js +330 -18
  4. package/output/auth/index.d.ts +73 -0
  5. package/output/auth/index.d.ts.map +1 -0
  6. package/output/auth/index.js +196 -0
  7. package/output/core/assemble.d.ts +64 -0
  8. package/output/core/assemble.d.ts.map +1 -0
  9. package/output/core/assemble.js +88 -0
  10. package/output/core/boot/context.d.ts +109 -0
  11. package/output/core/boot/context.d.ts.map +1 -0
  12. package/output/core/boot/context.js +145 -0
  13. package/output/core/boot/lifecycle.d.ts +206 -0
  14. package/output/core/boot/lifecycle.d.ts.map +1 -0
  15. package/output/core/boot/lifecycle.js +255 -0
  16. package/output/core/boot/shutdown.d.ts +34 -0
  17. package/output/core/boot/shutdown.d.ts.map +1 -0
  18. package/output/core/boot/shutdown.js +50 -0
  19. package/output/core/boot/signal.d.ts +13 -0
  20. package/output/core/boot/signal.d.ts.map +1 -0
  21. package/output/{app → core/boot}/signal.js +3 -2
  22. package/output/core/boot/steps.d.ts +138 -0
  23. package/output/core/boot/steps.d.ts.map +1 -0
  24. package/output/core/boot/steps.js +259 -0
  25. package/output/core/entries.d.ts +151 -0
  26. package/output/core/entries.d.ts.map +1 -0
  27. package/output/core/entries.js +242 -0
  28. package/output/core/entries.test.d.ts +2 -0
  29. package/output/core/entries.test.d.ts.map +1 -0
  30. package/output/core/entries.test.js +458 -0
  31. package/output/core/env.d.ts +83 -0
  32. package/output/core/env.d.ts.map +1 -0
  33. package/output/core/env.js +124 -0
  34. package/output/{app → core}/env.test.d.ts.map +1 -1
  35. package/output/core/env.test.js +105 -0
  36. package/output/core/modules/auth-provider/index.d.ts +10 -0
  37. package/output/core/modules/auth-provider/index.d.ts.map +1 -0
  38. package/output/core/modules/auth-provider/index.js +9 -0
  39. package/output/core/modules/auth-provider/types.d.ts +71 -0
  40. package/output/core/modules/auth-provider/types.d.ts.map +1 -0
  41. package/output/core/modules/context/index.d.ts +18 -0
  42. package/output/core/modules/context/index.d.ts.map +1 -0
  43. package/output/core/modules/context/index.js +23 -0
  44. package/output/core/modules/context/index.test.d.ts.map +1 -0
  45. package/output/core/modules/cookie/index.d.ts +21 -0
  46. package/output/core/modules/cookie/index.d.ts.map +1 -0
  47. package/output/{app/core/cookie.js → core/modules/cookie/index.js} +1 -6
  48. package/output/core/modules/identity/index.d.ts +4 -0
  49. package/output/core/modules/identity/index.d.ts.map +1 -0
  50. package/output/{app/core/identity.js → core/modules/identity/index.js} +1 -1
  51. package/output/core/modules/logging/index.d.ts +5 -0
  52. package/output/core/modules/logging/index.d.ts.map +1 -0
  53. package/output/{app/core/logging.js → core/modules/logging/index.js} +2 -2
  54. package/output/core/modules/openapi/index.d.ts +7 -0
  55. package/output/core/modules/openapi/index.d.ts.map +1 -0
  56. package/output/{app/core/openapi.js → core/modules/openapi/index.js} +11 -12
  57. package/output/core/modules/permission-operations/index.d.ts +26 -0
  58. package/output/core/modules/permission-operations/index.d.ts.map +1 -0
  59. package/output/core/modules/permission-operations/index.js +36 -0
  60. package/output/core/modules/permission-operations/index.test.d.ts +2 -0
  61. package/output/core/modules/permission-operations/index.test.d.ts.map +1 -0
  62. package/output/core/modules/permission-operations/index.test.js +45 -0
  63. package/output/{app/core/request-id.d.ts → core/modules/request-id/index.d.ts} +1 -4
  64. package/output/core/modules/request-id/index.d.ts.map +1 -0
  65. package/output/{app/core/request-id.js → core/modules/request-id/index.js} +2 -2
  66. package/output/core/modules/response/index.d.ts +50 -0
  67. package/output/core/modules/response/index.d.ts.map +1 -0
  68. package/output/{app/core/response.js → core/modules/response/index.js} +31 -18
  69. package/output/{schema/index.d.ts → core/modules/schema/builders.d.ts} +8 -1
  70. package/output/core/modules/schema/builders.d.ts.map +1 -0
  71. package/output/{schema/index.js → core/modules/schema/builders.js} +7 -4
  72. package/output/core/modules/schema/index.d.ts +34 -0
  73. package/output/core/modules/schema/index.d.ts.map +1 -0
  74. package/output/core/modules/schema/index.js +62 -0
  75. package/output/core/modules/schema/index.test.d.ts +2 -0
  76. package/output/core/modules/schema/index.test.d.ts.map +1 -0
  77. package/output/core/modules/schema/index.test.js +55 -0
  78. package/output/core/modules/system-routes/index.d.ts +15 -0
  79. package/output/core/modules/system-routes/index.d.ts.map +1 -0
  80. package/output/core/modules/system-routes/index.js +205 -0
  81. package/output/core/resource.d.ts +56 -0
  82. package/output/core/resource.d.ts.map +1 -0
  83. package/output/core/resource.js +56 -0
  84. package/output/core/test-support/database.d.ts +89 -0
  85. package/output/core/test-support/database.d.ts.map +1 -0
  86. package/output/core/test-support/database.js +131 -0
  87. package/output/core/test-support/database.test.d.ts +2 -0
  88. package/output/core/test-support/database.test.d.ts.map +1 -0
  89. package/output/core/test-support/database.test.js +111 -0
  90. package/output/core/test-support/environment.d.ts +38 -0
  91. package/output/core/test-support/environment.d.ts.map +1 -0
  92. package/output/core/test-support/environment.js +45 -0
  93. package/output/core/test-support/service-context.d.ts +64 -0
  94. package/output/core/test-support/service-context.d.ts.map +1 -0
  95. package/output/core/test-support/service-context.js +63 -0
  96. package/output/core/test-support/service-context.test.d.ts +2 -0
  97. package/output/core/test-support/service-context.test.d.ts.map +1 -0
  98. package/output/core/test-support/service-context.test.js +55 -0
  99. package/output/core/test-support/test-server.d.ts +154 -0
  100. package/output/core/test-support/test-server.d.ts.map +1 -0
  101. package/output/core/test-support/test-server.js +114 -0
  102. package/output/core/test-support/test-server.test.d.ts +2 -0
  103. package/output/core/test-support/test-server.test.d.ts.map +1 -0
  104. package/output/core/test-support/test-server.test.js +296 -0
  105. package/output/core/test-support/testdata.d.ts +255 -0
  106. package/output/core/test-support/testdata.d.ts.map +1 -0
  107. package/output/core/test-support/testdata.js +271 -0
  108. package/output/core/test-support/testdata.test.d.ts +2 -0
  109. package/output/core/test-support/testdata.test.d.ts.map +1 -0
  110. package/output/core/test-support/testdata.test.js +334 -0
  111. package/output/{app → core}/type-augment.d.ts +2 -2
  112. package/output/core/type-augment.d.ts.map +1 -0
  113. package/output/core/types.d.ts +582 -0
  114. package/output/core/types.d.ts.map +1 -0
  115. package/output/cors/index.d.ts +3 -0
  116. package/output/cors/index.d.ts.map +1 -0
  117. package/output/cors/index.js +28 -0
  118. package/output/index.d.ts +10 -4
  119. package/output/index.d.ts.map +1 -1
  120. package/output/index.js +25 -5
  121. package/output/rate-limit/index.d.ts +23 -0
  122. package/output/rate-limit/index.d.ts.map +1 -0
  123. package/output/rate-limit/index.js +43 -0
  124. package/output/{rate-limiter/rate-limiter.d.ts → rate-limit/limiter.d.ts} +1 -1
  125. package/output/rate-limit/limiter.d.ts.map +1 -0
  126. package/output/{rate-limiter → rate-limit}/memory-store.d.ts +6 -0
  127. package/output/rate-limit/memory-store.d.ts.map +1 -0
  128. package/output/{rate-limiter → rate-limit}/memory-store.js +8 -0
  129. package/output/{rate-limiter → rate-limit}/redis-store.d.ts +18 -0
  130. package/output/rate-limit/redis-store.d.ts.map +1 -0
  131. package/output/{rate-limiter → rate-limit}/redis-store.js +35 -0
  132. package/output/rate-limit/redis-store.test.d.ts.map +1 -0
  133. package/output/{rate-limiter → rate-limit}/redis-store.test.js +2 -2
  134. package/output/rate-limit/types.d.ts +45 -0
  135. package/output/rate-limit/types.d.ts.map +1 -0
  136. package/output/rate-limit/types.js +1 -0
  137. package/output/{redis-cache/redis-cache.d.ts → redis/cache.d.ts} +1 -1
  138. package/output/redis/cache.d.ts.map +1 -0
  139. package/output/redis/cache.test.d.ts +2 -0
  140. package/output/redis/cache.test.d.ts.map +1 -0
  141. package/output/{redis-cache/redis-cache.test.js → redis/cache.test.js} +1 -1
  142. package/output/{app/redis.d.ts → redis/index.d.ts} +12 -7
  143. package/output/redis/index.d.ts.map +1 -0
  144. package/output/{app/redis.js → redis/index.js} +15 -5
  145. package/output/redis/index.test.d.ts +2 -0
  146. package/output/{context → redis}/index.test.d.ts.map +1 -1
  147. package/output/redis/index.test.js +55 -0
  148. package/output/redis-queue/index.js +1 -1
  149. package/output/redis-queue/test-helpers.d.ts +1 -1
  150. package/output/redis-queue/test-helpers.js +1 -1
  151. package/output/redis-queue/types.d.ts +3 -12
  152. package/output/redis-queue/types.d.ts.map +1 -1
  153. package/package.json +5 -5
  154. package/output/app/auth.test.d.ts +0 -2
  155. package/output/app/auth.test.d.ts.map +0 -1
  156. package/output/app/auth.test.js +0 -148
  157. package/output/app/boot.d.ts +0 -48
  158. package/output/app/boot.d.ts.map +0 -1
  159. package/output/app/boot.js +0 -123
  160. package/output/app/config.test.d.ts +0 -2
  161. package/output/app/config.test.d.ts.map +0 -1
  162. package/output/app/config.test.js +0 -132
  163. package/output/app/context.d.ts +0 -68
  164. package/output/app/context.d.ts.map +0 -1
  165. package/output/app/context.js +0 -73
  166. package/output/app/core/cookie.d.ts +0 -25
  167. package/output/app/core/cookie.d.ts.map +0 -1
  168. package/output/app/core/identity.d.ts +0 -12
  169. package/output/app/core/identity.d.ts.map +0 -1
  170. package/output/app/core/index.d.ts +0 -44
  171. package/output/app/core/index.d.ts.map +0 -1
  172. package/output/app/core/index.js +0 -57
  173. package/output/app/core/logging.d.ts +0 -13
  174. package/output/app/core/logging.d.ts.map +0 -1
  175. package/output/app/core/openapi.d.ts +0 -17
  176. package/output/app/core/openapi.d.ts.map +0 -1
  177. package/output/app/core/request-id.d.ts.map +0 -1
  178. package/output/app/core/response.d.ts +0 -41
  179. package/output/app/core/response.d.ts.map +0 -1
  180. package/output/app/define-server.negative-cases.d.ts +0 -32
  181. package/output/app/define-server.negative-cases.d.ts.map +0 -1
  182. package/output/app/define-server.negative-cases.js +0 -209
  183. package/output/app/define-server.test.d.ts +0 -2
  184. package/output/app/define-server.test.d.ts.map +0 -1
  185. package/output/app/define-server.test.js +0 -97
  186. package/output/app/entries.d.ts +0 -29
  187. package/output/app/entries.d.ts.map +0 -1
  188. package/output/app/entries.js +0 -64
  189. package/output/app/env.d.ts +0 -59
  190. package/output/app/env.d.ts.map +0 -1
  191. package/output/app/env.js +0 -66
  192. package/output/app/env.test.js +0 -124
  193. package/output/app/error-contract.test.d.ts +0 -2
  194. package/output/app/error-contract.test.d.ts.map +0 -1
  195. package/output/app/error-contract.test.js +0 -125
  196. package/output/app/errors.d.ts +0 -20
  197. package/output/app/errors.d.ts.map +0 -1
  198. package/output/app/errors.js +0 -51
  199. package/output/app/generate-openapi.test.d.ts +0 -2
  200. package/output/app/generate-openapi.test.d.ts.map +0 -1
  201. package/output/app/generate-openapi.test.js +0 -123
  202. package/output/app/lifecycle-logging.test.d.ts +0 -2
  203. package/output/app/lifecycle-logging.test.d.ts.map +0 -1
  204. package/output/app/lifecycle-logging.test.js +0 -155
  205. package/output/app/lifecycle.d.ts +0 -70
  206. package/output/app/lifecycle.d.ts.map +0 -1
  207. package/output/app/lifecycle.js +0 -99
  208. package/output/app/lifecycle.test.d.ts +0 -2
  209. package/output/app/lifecycle.test.d.ts.map +0 -1
  210. package/output/app/lifecycle.test.js +0 -152
  211. package/output/app/middleware/auth.d.ts +0 -71
  212. package/output/app/middleware/auth.d.ts.map +0 -1
  213. package/output/app/middleware/auth.js +0 -190
  214. package/output/app/middleware/context.d.ts +0 -13
  215. package/output/app/middleware/context.d.ts.map +0 -1
  216. package/output/app/middleware/context.js +0 -15
  217. package/output/app/middleware/cors.d.ts +0 -14
  218. package/output/app/middleware/cors.d.ts.map +0 -1
  219. package/output/app/middleware/cors.js +0 -30
  220. package/output/app/middleware/index.d.ts +0 -17
  221. package/output/app/middleware/index.d.ts.map +0 -1
  222. package/output/app/middleware/index.js +0 -16
  223. package/output/app/middleware/rate-limit.d.ts +0 -24
  224. package/output/app/middleware/rate-limit.d.ts.map +0 -1
  225. package/output/app/middleware/rate-limit.js +0 -46
  226. package/output/app/middleware-order.test.d.ts +0 -2
  227. package/output/app/middleware-order.test.d.ts.map +0 -1
  228. package/output/app/middleware-order.test.js +0 -148
  229. package/output/app/permission-operations.d.ts +0 -48
  230. package/output/app/permission-operations.d.ts.map +0 -1
  231. package/output/app/permission-operations.js +0 -77
  232. package/output/app/permission-operations.test.d.ts +0 -2
  233. package/output/app/permission-operations.test.d.ts.map +0 -1
  234. package/output/app/permission-operations.test.js +0 -72
  235. package/output/app/permission-registration.test.d.ts +0 -2
  236. package/output/app/permission-registration.test.d.ts.map +0 -1
  237. package/output/app/permission-registration.test.js +0 -138
  238. package/output/app/redis.d.ts.map +0 -1
  239. package/output/app/registered-operations.test.d.ts +0 -2
  240. package/output/app/registered-operations.test.d.ts.map +0 -1
  241. package/output/app/registered-operations.test.js +0 -57
  242. package/output/app/request-id.test.d.ts +0 -2
  243. package/output/app/request-id.test.d.ts.map +0 -1
  244. package/output/app/request-id.test.js +0 -130
  245. package/output/app/resources.test.d.ts +0 -2
  246. package/output/app/resources.test.d.ts.map +0 -1
  247. package/output/app/resources.test.js +0 -197
  248. package/output/app/shutdown.d.ts +0 -22
  249. package/output/app/shutdown.d.ts.map +0 -1
  250. package/output/app/shutdown.js +0 -30
  251. package/output/app/shutdown.test.d.ts +0 -2
  252. package/output/app/shutdown.test.d.ts.map +0 -1
  253. package/output/app/shutdown.test.js +0 -103
  254. package/output/app/signal.d.ts +0 -12
  255. package/output/app/signal.d.ts.map +0 -1
  256. package/output/app/smoke.test.d.ts +0 -2
  257. package/output/app/smoke.test.d.ts.map +0 -1
  258. package/output/app/smoke.test.js +0 -210
  259. package/output/app/test-support.d.ts +0 -21
  260. package/output/app/test-support.d.ts.map +0 -1
  261. package/output/app/test-support.js +0 -24
  262. package/output/app/type-augment.d.ts.map +0 -1
  263. package/output/app/types.d.ts +0 -211
  264. package/output/app/types.d.ts.map +0 -1
  265. package/output/context/index.d.ts +0 -6
  266. package/output/context/index.d.ts.map +0 -1
  267. package/output/context/index.js +0 -14
  268. package/output/rate-limiter/index.d.ts +0 -5
  269. package/output/rate-limiter/index.d.ts.map +0 -1
  270. package/output/rate-limiter/index.js +0 -4
  271. package/output/rate-limiter/memory-store.d.ts.map +0 -1
  272. package/output/rate-limiter/rate-limiter.d.ts.map +0 -1
  273. package/output/rate-limiter/redis-store.d.ts.map +0 -1
  274. package/output/rate-limiter/redis-store.test.d.ts.map +0 -1
  275. package/output/rate-limiter/types.d.ts +0 -27
  276. package/output/rate-limiter/types.d.ts.map +0 -1
  277. package/output/redis-cache/index.d.ts +0 -4
  278. package/output/redis-cache/index.d.ts.map +0 -1
  279. package/output/redis-cache/index.js +0 -1
  280. package/output/redis-cache/redis-cache.d.ts.map +0 -1
  281. package/output/redis-cache/redis-cache.test.d.ts +0 -2
  282. package/output/redis-cache/redis-cache.test.d.ts.map +0 -1
  283. package/output/schema/index.d.ts.map +0 -1
  284. /package/output/{app → core}/env.test.d.ts +0 -0
  285. /package/output/{rate-limiter → core/modules/auth-provider}/types.js +0 -0
  286. /package/output/{context → core/modules/context}/index.test.d.ts +0 -0
  287. /package/output/{context → core/modules/context}/index.test.js +0 -0
  288. /package/output/{app → core}/type-augment.js +0 -0
  289. /package/output/{app → core}/types.js +0 -0
  290. /package/output/{rate-limiter/rate-limiter.js → rate-limit/limiter.js} +0 -0
  291. /package/output/{rate-limiter → rate-limit}/redis-store.test.d.ts +0 -0
  292. /package/output/{redis-cache/redis-cache.js → redis/cache.js} +0 -0
@@ -0,0 +1,154 @@
1
+ import type { AppEnv, ClientsView, LoggerLike, ResourcesView, Server } from '../types.js';
2
+ import type { FastifyInstance } from 'fastify';
3
+ /**
4
+ * `createTestServer()` ——**测试对象的唯一构造方式**。
5
+ *
6
+ * ## 它是唯一的一个,不是「又一个」入口
7
+ *
8
+ * 这里有一个硬性约束:**测试必须跑在生产的同一套装配上**。请求链、响应信封、错误码映射、
9
+ * `request.ctx`、关闭流程,只要测试里另有一套实现,它就会在生产之外漂移,而漂移只在线暴露。
10
+ *
11
+ * 因此测试对象的构造只有一个入口,它**不接受任何槽位覆盖**:没有 `resources` 参数、没有
12
+ * `services` 参数、没有 `overrideXxx`。覆盖面一开,测试就能绕开生产接线,这个入口随即失去
13
+ * 全部证明力——那还不如直接写个假 app。
14
+ *
15
+ * 需要「只测一个模块」时,**组合定义**,而不是覆盖槽位:
16
+ *
17
+ * ```ts
18
+ * const moduleServer = defineServer({
19
+ * ...server,
20
+ * routers: ({ app }) => app.register(createUserRouter(stub)),
21
+ * })
22
+ * const testServer = await createTestServer(moduleServer)
23
+ * ```
24
+ *
25
+ * 组合用的是同一个 `defineServer` 产物、同一套装配,只是把另一个模块的接线换掉。
26
+ *
27
+ * ## 与 `startServer` 的唯一差别
28
+ *
29
+ * `startServer` 会 listen 并装信号处理器;测试用 `app.inject()`,且 vitest worker 收不到
30
+ * SIGTERM。除此之外(env 分层 → resources → config → clients → services → `init()` →
31
+ * middlewares → 系统路由 → routers、八步关闭)**逐行相同**。
32
+ *
33
+ * ## 参数就是那个 `Server`,没有第二个参数
34
+ *
35
+ * 连接串结算、闸门、改写 `DATABASE_URL` 全部固定(见 `prepareTestDatabase()`)。
36
+ * 没有「用哪个连接串」这类开关:测试对象的身份就是测试,让它可以被配置成「有点像测试」
37
+ * 是把安全前提变成调用方的责任。
38
+ *
39
+ * ## 返回值:`TestServer` 就是测试要的一切
40
+ *
41
+ * 测试要断言的对象往往不是 HTTP 响应,而是**装配出来的东西本身**:`resources.database` 连的
42
+ * 是不是 `*_test`、某个 service 有没有被 `dispose`、`config` 里那个开关有没有生效。
43
+ * 因此返回装配期**已经构造完成**的各槽位产物,而不是让调用方重新跑一遍工厂——重新构造就等于
44
+ * 测试里又拼了一份,与生产接线脱钩。
45
+ *
46
+ * **不包同义方法**:没有 `close()`、没有 `getDatabase()`。`app` 就是原生 `FastifyInstance`,
47
+ * 关闭就是 `await app.close()`,走同一套八步流程;连接串就是 `env.DATABASE_URL`。多包一层
48
+ * `close()` 只是在已有接口上再加一个同义名,反而让「用哪个」变成需要记的事。
49
+ *
50
+ * **唯一的例外是 `reprovision()`**:它不是现有接口的同义名,生产入口没有对应物——
51
+ * 「用例之间要一份干净基线」只有测试知道。
52
+ */
53
+ export interface TestServer<Resources, Config, Clients, Services, Env> {
54
+ /** 原生 Fastify 实例(已 `ready()`,用 `inject` 发请求;关闭用 `app.close()`) */
55
+ readonly app: FastifyInstance;
56
+ /**
57
+ * 装配后的 env 快照(框架固定约定 + 应用声明的扩展字段 `Env`,`AppEnv<Env>`)。
58
+ *
59
+ * **测试断言「连的是哪个库」就看这里**:`env.DATABASE_URL` 在装配前已结算成 `*_test`
60
+ * 目标,它就是本次实际连的连接串。不额外给一个 `databaseUrl` 字段——同一份事实出现两次,
61
+ * 迟早会有人读到另一个。应用扩展的 env(如 `REDIS_KEY_PREFIX`)也在这里、且有类型。
62
+ */
63
+ readonly env: AppEnv<Env>;
64
+ /** `resources` 槽位产物(`false` 键已按 `Live<Value>` 剔除) */
65
+ readonly resources: ResourcesView<Resources>;
66
+ /** `config` 槽位产物(`Awaited<>` 归一) */
67
+ readonly config: Awaited<Config>;
68
+ /** `clients` 槽位产物 */
69
+ readonly clients: ClientsView<Clients>;
70
+ /** `services` 槽位产物(已 `init()` 过;测试直接拿它调方法,不 mock) */
71
+ readonly services: Awaited<Services>;
72
+ /** 日志器(与 `app.log` 同一个;关闭阶段仍可用) */
73
+ readonly log: LoggerLike;
74
+ /**
75
+ * **重新装配一个新的实例**:清掉旧实例的数据 → 关掉旧实例 → 按同一份 `env` 快照重跑一遍装配。
76
+ *
77
+ * 返回的是一份**新对象**,不是原地重启:
78
+ *
79
+ * ```ts
80
+ * let server = await createTestServer(server)
81
+ * beforeEach(async () => {
82
+ * server = await server.reprovision()
83
+ * })
84
+ * ```
85
+ *
86
+ * 旧对象上的一切(`app` / `resources` / `services`)**已经失效**:连接已断、`app` 已 `close()`。
87
+ * 还在引用旧实例的 `describe` 报错时不要奇怪——它的名字就叫“旧装配”。
88
+ *
89
+ * ## 为什么是「重新装配」而不是「复位」
90
+ *
91
+ * 状态有两类,而只有一类能被「复位」:
92
+ *
93
+ * | 状态 | 在哪 | 能不能拨回去 |
94
+ * | --- | --- | --- |
95
+ * | 库里的行 / 队列 key / 临时文件 | 外部 | 能(`clean()`) |
96
+ * | 限流桶、进程内缓存、连接级状态 | 内存 | **不能可靠地拨回** |
97
+ *
98
+ * 第二类覆盖地方、无法可靠拨回(漏清一处就是偶发红),而重新装配不需要「谁记得清」:
99
+ * `init()` 重跑一次,内存态天然就是初始值。
100
+ *
101
+ * 另一层原因在 Fastify:`ready()` 是一次性的,同一实例上无法重跑注册流程。想「不换实例而重跑
102
+ * 装配」在结构上就不成立。
103
+ *
104
+ * ## 四个步骤(顺序不可换)
105
+ *
106
+ * 1. **闸门**:`ENV` 必须可清理(`development` / `test`),否则直接抛错;
107
+ * 2. **`clean()`**:由 boot 层的 `cleanServerUnits()` 编排——资源声明序逐项清(数据库那个
108
+ * 必须在最前,它持有 `TRUNCATE`),再服务声明序逐项清;**必须在 `app.close()` 之前**——
109
+ * 关完之后连接就没了;
110
+ * 3. **`app.close()`**:走完整八步关闭(逐单元 `dispose()` 会在这里顺带把缓冲落盘);
111
+ * 4. **重新装配**:复用同一份 `env` 快照(**不重跑** `prepareTestDatabase()`),重建
112
+ * `resources` → `config` → `clients` → `services` → `init()` → 路由 → `ready()` → `loadTestData()`。
113
+ *
114
+ * ## 失败语义
115
+ *
116
+ * - `clean()` 失败**冒泡**:没清干净就去装配,后面全是伪绿;
117
+ * - `app.close()` 失败**不阻断**(八步关闭本来就是逐步骤聚合、失败只 `log.error`):
118
+ * 旧实例释放不完美不应该阻止下一条用例。
119
+ *
120
+ * ## 耗时
121
+ *
122
+ * 一次完整装配 = 一次 `setup()` + 一次 `init()`。这是**故意的约束**:`setup()` 必须保持在
123
+ * 几十毫秒量级(只做连接 / 预执 / 极小的结构检查),跑 `prisma migrate` 这类事归 npm script——
124
+ * `reprovision()` 是**逐用例**跑的,装配慢就等于整个测试套件慢。
125
+ */
126
+ reprovision(): Promise<TestServer<Resources, Config, Clients, Services, Env>>;
127
+ }
128
+ /**
129
+ * 构造测试对象。
130
+ *
131
+ * 固定顺序(安全闸门全部在装配之前,`resources` 一行代码都还没跑):
132
+ * ```
133
+ * prepareTestDatabase():
134
+ * 加载 env(强制叠加 .env.test / .env.test.local,ENV 未给时兜底 test)
135
+ * → 环境闸门(ENV 必须可清理)
136
+ * → 结算测试连接串(显式 TEST_DATABASE_URL 优先,否则派生 +_test,幂等)
137
+ * → schema 闸门(必须以 _test 结尾)
138
+ * → 回写 process.env.DATABASE_URL
139
+ * → resources → core → config → clients → services → init() → middlewares
140
+ * → 系统路由 → routers → 关闭钩子 → ready()
141
+ * → 装载 testdata/ 脚本并整体 load(ctx)
142
+ * ```
143
+ *
144
+ * `testdata/` 的 `load()` 排在 `ready()` 之后:脚本拿到的是装配好的 `ctx`(`env` + `resources` +
145
+ * `log`),所以它写数据用的就是被测服务读数据的那份连接——两套连接写/读同一张表而看不到对方
146
+ * 写入(缓存 / 连接级状态)这类问题从结构上不会发生。排在最后也让失败语义最干净:装载失败时
147
+ * 装配已完成、可正常 `app.close()` 释放。
148
+ *
149
+ * 不会再拿应用目标跟测试目标比对:本进程读到的 `DATABASE_URL` 此时已经是测试目标,比对恒等于
150
+ * 「自比自」。真正拦住「测试清空应用库」的是派生 + schema 闸门——派生只会给 schema 追加
151
+ * `_test`,schema 闸门只放行 `*_test`,所以测试目标结构上不可能是应用目标。
152
+ */
153
+ export declare function createTestServer<Resources, Config, Clients, Services, Env>(server: Server<Resources, Config, Clients, Services, Env>): Promise<TestServer<Resources, Config, Clients, Services, Env>>;
154
+ //# sourceMappingURL=test-server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"test-server.d.ts","sourceRoot":"","sources":["../../../source/core/test-support/test-server.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AAEzF,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAA;AAE9C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AACH,MAAM,WAAW,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG;IACnE,kEAAkE;IAClE,QAAQ,CAAC,GAAG,EAAE,eAAe,CAAA;IAC7B;;;;;;OAMG;IACH,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC,GAAG,CAAC,CAAA;IACzB,qDAAqD;IACrD,QAAQ,CAAC,SAAS,EAAE,aAAa,CAAC,SAAS,CAAC,CAAA;IAC5C,oCAAoC;IACpC,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC,MAAM,CAAC,CAAA;IAChC,qBAAqB;IACrB,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC,OAAO,CAAC,CAAA;IACtC,qDAAqD;IACrD,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC,QAAQ,CAAC,CAAA;IACpC,mCAAmC;IACnC,QAAQ,CAAC,GAAG,EAAE,UAAU,CAAA;IACxB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAmDG;IACH,WAAW,IAAI,OAAO,CAAC,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,CAAC,CAAC,CAAA;CAC9E;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAsB,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,CAAC,GAAG,OAAO,CAAC,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,CAAC,CAAC,CAO3M"}
@@ -0,0 +1,114 @@
1
+ import { cleanServerUnits, initServiceUnits } from '../boot/lifecycle.js';
2
+ import { buildClients, buildConfig, buildResources, buildServices, createBootContext, installShutdownHook, registerCoreStep, registerMiddlewares, registerRouters, registerSystemRoutesStep, resolveAppEnv, setupResources, } from '../boot/steps.js';
3
+ import { assertCleanableEnvironment } from '../env.js';
4
+ import { maskDatabaseUrl } from './database.js';
5
+ import { prepareTestDatabase } from './environment.js';
6
+ import { discoverTestDataModules, loadTestData } from './testdata.js';
7
+ /**
8
+ * 构造测试对象。
9
+ *
10
+ * 固定顺序(安全闸门全部在装配之前,`resources` 一行代码都还没跑):
11
+ * ```
12
+ * prepareTestDatabase():
13
+ * 加载 env(强制叠加 .env.test / .env.test.local,ENV 未给时兜底 test)
14
+ * → 环境闸门(ENV 必须可清理)
15
+ * → 结算测试连接串(显式 TEST_DATABASE_URL 优先,否则派生 +_test,幂等)
16
+ * → schema 闸门(必须以 _test 结尾)
17
+ * → 回写 process.env.DATABASE_URL
18
+ * → resources → core → config → clients → services → init() → middlewares
19
+ * → 系统路由 → routers → 关闭钩子 → ready()
20
+ * → 装载 testdata/ 脚本并整体 load(ctx)
21
+ * ```
22
+ *
23
+ * `testdata/` 的 `load()` 排在 `ready()` 之后:脚本拿到的是装配好的 `ctx`(`env` + `resources` +
24
+ * `log`),所以它写数据用的就是被测服务读数据的那份连接——两套连接写/读同一张表而看不到对方
25
+ * 写入(缓存 / 连接级状态)这类问题从结构上不会发生。排在最后也让失败语义最干净:装载失败时
26
+ * 装配已完成、可正常 `app.close()` 释放。
27
+ *
28
+ * 不会再拿应用目标跟测试目标比对:本进程读到的 `DATABASE_URL` 此时已经是测试目标,比对恒等于
29
+ * 「自比自」。真正拦住「测试清空应用库」的是派生 + schema 闸门——派生只会给 schema 追加
30
+ * `_test`,schema 闸门只放行 `*_test`,所以测试目标结构上不可能是应用目标。
31
+ */
32
+ export async function createTestServer(server) {
33
+ // 闸门 + 结算测试连接串全在框架内部,顺序不由调用方决定;**不改写全局 process.env**(见
34
+ // `prepareTestDatabase`)——测试目标只注入到本次装配的 `env` 快照里,`resources` 从
35
+ // `ctx.env.DATABASE_URL` 读取,同进程并行多个实例互不污染。
36
+ const env = resolveAppEnv(prepareTestDatabase());
37
+ return assemble(server, env);
38
+ }
39
+ /**
40
+ * 一次完整装配(`createTestServer` 与 `reprovision()` 共用)。
41
+ *
42
+ * 共用是刻意的:`reprovision()` 走的是**同一条**装配路径,不是「简化版重启」。两条路径一旦
43
+ * 分叉,测试里跑的就和第一次装配不再是同一件事,`reprovision()` 之后的用例就失去了证明力。
44
+ *
45
+ * 入参 `env` 是**已结算好**的快照:`reprovision()` 复用它、不重跑 `prepareTestDatabase()`——
46
+ * 连接串 / ENV 在一次测试生命周期里不该变,重算只会引入「第二次结算结果不同」的怪异失败。
47
+ *
48
+ * 返回的**只有** `TestServer`:装配期单元(`ResourceSetupUnit[]` 之类)是 boot 层的产物,
49
+ * 只在本函数的闭包里给 `reprovision()` 用,不往测试入口的外面漏。
50
+ */
51
+ async function assemble(server, env) {
52
+ // 以下步序与 `startServer` 逐行对应,只去掉 listen 与信号处理器
53
+ const ctx = createBootContext(server, env);
54
+ const { resources, units: resourceUnits, classifyResourceError } = await buildResources(ctx);
55
+ await setupResources(ctx, resourceUnits);
56
+ await registerCoreStep(ctx, classifyResourceError);
57
+ await buildConfig(ctx, resources);
58
+ const clients = await buildClients(ctx, resources);
59
+ const services = await buildServices(ctx, resources, clients);
60
+ await initServiceUnits({
61
+ service: ctx.service,
62
+ log: ctx.log,
63
+ services: services,
64
+ });
65
+ await registerMiddlewares(ctx, services, clients);
66
+ registerSystemRoutesStep(ctx, services);
67
+ await registerRouters(ctx, services, clients);
68
+ installShutdownHook(ctx, services, resourceUnits);
69
+ await ctx.app.ready();
70
+ ctx.log.info({ service: ctx.service, database: maskDatabaseUrl(env.DATABASE_URL), env: env.ENV }, '[test-server] ready(测试实例不会监听端口)');
71
+ // `buildConfig` 一定跑过(本路径不跳过 config),缺了就按「没有 config」的语义
72
+ // 如实报错(`read()` 抛 `CONFIG_UNAVAILABLE`),而不是拿 `undefined` 冒充产物。
73
+ if (!ctx.configAccess.available) {
74
+ throw new Error('[test-server] config 槽位没有产出:装配顺序被改坏了(应当紧跟 resources 之后)');
75
+ }
76
+ // 装载`testdata/` 脚本并整体 `load(ctx)`:闸门(ENV 必须可清理)在 `loadTestData` 内部,
77
+ // 这里不重复判断,也不降级成「非测试就不算」——失败直接冒泡。
78
+ //
79
+ // `ctx` 与 `resources` 槽位同一个口径:脚本写数据 / 清缓存用的就是被测服务的那份实例。
80
+ const testDataCtx = {
81
+ env,
82
+ resources,
83
+ config: ctx.configAccess.read(),
84
+ clients,
85
+ services,
86
+ log: ctx.log,
87
+ };
88
+ // 脚本名单只在本函数的闭包里用:装配时 `load()` 一次就够了。用例之间的复位是
89
+ // `reprovision()` 的职责(重跑装配 = 重新发现 + 重新 `load`),返回值不需要把它挂在外面。
90
+ const testData = await discoverTestDataModules();
91
+ await loadTestData({ ctx: testDataCtx, units: testData, service: ctx.service });
92
+ return {
93
+ app: ctx.app,
94
+ env,
95
+ resources,
96
+ config: ctx.configAccess.read(),
97
+ clients,
98
+ services,
99
+ log: ctx.log,
100
+ reprovision: async () => {
101
+ // 1. 闸门先于一切:非可清理环境下连 `clean()` 都不该跑
102
+ assertCleanableEnvironment(env.ENV);
103
+ // 2. 清数据**必须在 `close()` 之前**:连接一断就清不了了。顺序由 boot 层编排
104
+ // (资源在前、服务在后),本入口只说「给我一份干净基线」。失败冒泡——
105
+ // 没清干净就重装,后面全是伪绿。
106
+ await cleanServerUnits({ service: ctx.service, log: ctx.log, resources: resourceUnits, services: services });
107
+ // 3. 关掉旧实例:八步关闭自带逐单元 `dispose()`(缓冲落盘的归属就是它),
108
+ // 失败只 `log.error`、不阻断——旧实例释放不完美不该拦住下一条用例。
109
+ await ctx.app.close();
110
+ // 4. 用同一份 `env` 快照重新装配(内部照常 `loadTestData`)
111
+ return assemble(server, env);
112
+ },
113
+ };
114
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=test-server.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"test-server.test.d.ts","sourceRoot":"","sources":["../../../source/core/test-support/test-server.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,296 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { defineServer } from '../entries.js';
3
+ import { routerSchema } from '../modules/schema/builders.js';
4
+ import { defineResource } from '../resource.js';
5
+ import { createTestServer } from './test-server.js';
6
+ const IDENTITY = { service: 'probe', title: 'Probe', version: '0.0.0' };
7
+ const ROUTE_SCHEMA = routerSchema({
8
+ operationId: 'probeGet',
9
+ summary: '探测',
10
+ description: '探测路由',
11
+ tags: ['探测'],
12
+ });
13
+ const APP_URL = 'postgresql://postgres:secret@db.internal:5432/taicode-labs?schema=probe';
14
+ const TEST_URL = 'postgresql://postgres:secret@db.internal:5432/taicode-labs?schema=probe_test';
15
+ const ORIGINAL_ENV = { ...process.env };
16
+ afterEach(() => {
17
+ process.env = { ...ORIGINAL_ENV };
18
+ });
19
+ function buildServer(seen = {}) {
20
+ return defineServer({
21
+ app: IDENTITY,
22
+ resources: (ctx) => {
23
+ seen.databaseUrl = ctx.env.DATABASE_URL;
24
+ seen.env = ctx.env.ENV;
25
+ return {};
26
+ },
27
+ config: () => ({ server: { cookieNames: ['token'] } }),
28
+ clients: () => ({}),
29
+ services: () => ({}),
30
+ middlewares: () => [],
31
+ routers: ({ app }) => {
32
+ app.get('/probe', { schema: ROUTE_SCHEMA }, async () => ({ ok: true }));
33
+ },
34
+ });
35
+ }
36
+ /** 把 vitest 自己设的环境变量清干净,让每个用例只看到自己注入的值 */
37
+ function useEnv(overrides) {
38
+ process.env.ENV = 'development';
39
+ // 注意:给 process.env 赋 undefined 会变成字符串 "undefined",所以必须先 delete
40
+ delete process.env.DATABASE_URL;
41
+ delete process.env.TEST_DATABASE_URL;
42
+ process.env.LOG_LEVEL = 'silent';
43
+ for (const [key, value] of Object.entries(overrides)) {
44
+ if (value === undefined) {
45
+ delete process.env[key];
46
+ }
47
+ else {
48
+ process.env[key] = value;
49
+ }
50
+ }
51
+ }
52
+ describe('createTestServer', () => {
53
+ it('与 startServer 装配同一套处理链:系统路由 + 业务路由 + zod 序列化 + 成功信封', async () => {
54
+ useEnv({ DATABASE_URL: APP_URL });
55
+ const testServer = await createTestServer(buildServer());
56
+ const response = await testServer.app.inject({ method: 'GET', url: '/probe' });
57
+ expect(response.statusCode).toBe(200);
58
+ // 信封形状来自框架 core/response,而不是测试自己拼的一份
59
+ expect(response.json()).toEqual({ status: 'SUCCESS', message: 'ok', data: { ok: true } });
60
+ // 框架系统路由同样就位
61
+ expect((await testServer.app.inject({ method: 'GET', url: '/health' })).statusCode).toBe(200);
62
+ await testServer.app.close();
63
+ });
64
+ it('数据层被结算到 *_test(resources 从 ctx.env 拿到的就是测试目标),且不改写全局 process.env', async () => {
65
+ useEnv({ DATABASE_URL: APP_URL });
66
+ const seen = {};
67
+ const testServer = await createTestServer(buildServer(seen));
68
+ // resources 从 `ctx.env.DATABASE_URL` 读到的是结算后的测试目标
69
+ expect(seen.databaseUrl).toContain('schema=probe_test');
70
+ // 交出来的连接串就是「测试实际连的那个」,不是从 process.env 再推一遍
71
+ expect(testServer.env.DATABASE_URL).toBe(seen.databaseUrl);
72
+ // **不改写全局 process.env.DATABASE_URL**:测试目标只属于本次装配,避免同进程并行串扰
73
+ expect(process.env.DATABASE_URL).toBe(APP_URL);
74
+ await testServer.app.close();
75
+ });
76
+ it('显式 TEST_DATABASE_URL 优先于派生', async () => {
77
+ useEnv({
78
+ DATABASE_URL: APP_URL,
79
+ TEST_DATABASE_URL: TEST_URL.replace('probe_test', 'explicit_test'),
80
+ });
81
+ const seen = {};
82
+ const testServer = await createTestServer(buildServer(seen));
83
+ expect(seen.databaseUrl).toContain('schema=explicit_test');
84
+ await testServer.app.close();
85
+ });
86
+ it('不监听端口(测试用 inject,占端口会让重跑互相踩)', async () => {
87
+ useEnv({ DATABASE_URL: APP_URL });
88
+ const testServer = await createTestServer(buildServer());
89
+ expect(testServer.app.server.listening).toBe(false);
90
+ await testServer.app.close();
91
+ });
92
+ it('env 强制加载测试层:ENV 没人给时兜底成 test(不用调用方额外声明)', async () => {
93
+ useEnv({ DATABASE_URL: APP_URL, ENV: undefined });
94
+ const seen = {};
95
+ const testServer = await createTestServer(buildServer(seen));
96
+ expect(seen.env).toBe('test');
97
+ await testServer.app.close();
98
+ });
99
+ it('环境闸门:非可清理环境在装配之前就失败(resources 一次都没跑)', async () => {
100
+ useEnv({ DATABASE_URL: APP_URL, ENV: 'production' });
101
+ const onResources = vi.fn(buildServer().resources);
102
+ const server = defineServer({ ...buildServer(), resources: onResources });
103
+ await expect(createTestServer(server)).rejects.toThrow(/ENV="production"/);
104
+ expect(onResources).not.toHaveBeenCalled();
105
+ });
106
+ it('schema 闸门:目标不以 *_test 结尾时拒绝装配', async () => {
107
+ useEnv({ DATABASE_URL: APP_URL, TEST_DATABASE_URL: APP_URL });
108
+ const onResources = vi.fn(buildServer().resources);
109
+ const server = defineServer({ ...buildServer(), resources: onResources });
110
+ await expect(createTestServer(server)).rejects.toThrow(/必须以 _test 结尾/);
111
+ expect(onResources).not.toHaveBeenCalled();
112
+ });
113
+ it('关闭时走同一套八步流程(services 的 dispose 被调用)', async () => {
114
+ useEnv({ DATABASE_URL: APP_URL });
115
+ const onDispose = vi.fn();
116
+ const server = defineServer({
117
+ ...buildServer(),
118
+ services: () => ({ unit: { dispose: onDispose } }),
119
+ });
120
+ const testServer = await createTestServer(server);
121
+ await testServer.app.close();
122
+ expect(onDispose).toHaveBeenCalledTimes(1);
123
+ });
124
+ it('业务路由缺少 schema 时注册即失败(守卫在测试入口同样生效)', async () => {
125
+ useEnv({ DATABASE_URL: APP_URL });
126
+ const server = defineServer({
127
+ ...buildServer(),
128
+ routers: ({ app }) => {
129
+ app.get('/bad', async () => ({ ok: true }));
130
+ },
131
+ });
132
+ await expect(createTestServer(server)).rejects.toThrow(/GET \/bad 缺少 schema/);
133
+ });
134
+ it('返回装配产物本身:resources / config / clients / services 与槽位一一对应', async () => {
135
+ useEnv({ DATABASE_URL: APP_URL });
136
+ const resources = { cache: { get: () => undefined } };
137
+ const config = { server: { cookieNames: ['token'] } };
138
+ const clients = { account: { verify: async () => true } };
139
+ const services = { user: { name: () => 'user' } };
140
+ const testServer = await createTestServer(defineServer({
141
+ app: IDENTITY,
142
+ resources: () => resources,
143
+ config: () => config,
144
+ clients: () => clients,
145
+ services: () => services,
146
+ middlewares: () => [],
147
+ routers: () => { },
148
+ }));
149
+ // 装配产物直接交出来:测试能断言/操作它们本身,而不是再构造一份副本。
150
+ // `resources` / `clients` 经过 `Live<T>` 视图(剔 `false` 键),所以按值比较;
151
+ // `config` / `services` 就是槽位原始返回值,引用相同。
152
+ expect(testServer.resources).toEqual(resources);
153
+ expect(testServer.clients).toEqual(clients);
154
+ expect(testServer.config).toBe(config);
155
+ expect(testServer.services).toBe(services);
156
+ // env 是**改写后**的快照(resources 看到的就是它)
157
+ expect(testServer.env.DATABASE_URL).toContain('schema=probe_test');
158
+ await testServer.app.close();
159
+ });
160
+ it('只有定义一个参数:没有「半个生产」的配置入口', () => {
161
+ // 签名只有一个必填参数。这条断言盯着「别再加 options」——一旦加了,测试就能绕开
162
+ // 生产接线,本入口随即失去证明力。
163
+ expect(createTestServer.length).toBe(1);
164
+ });
165
+ it('不复位入口只留 reprovision 一个:flushServices / resetTestData 都已移除', async () => {
166
+ useEnv({ DATABASE_URL: APP_URL });
167
+ const testServer = await createTestServer(buildServer());
168
+ expect('flushServices' in testServer).toBe(false);
169
+ expect('resetTestData' in testServer).toBe(false);
170
+ expect('cleanServerData' in testServer).toBe(false);
171
+ expect(typeof testServer.reprovision).toBe('function');
172
+ await testServer.app.close();
173
+ });
174
+ it('不外漏装配层产物:resourceUnits 只活在闭包里', async () => {
175
+ useEnv({ DATABASE_URL: APP_URL });
176
+ const server = defineServer({
177
+ ...buildServer(),
178
+ resources: () => ({
179
+ database: defineResource({ marker: 'db' }, { clean: () => { } }),
180
+ }),
181
+ });
182
+ const testServer = await createTestServer(server);
183
+ // `ResourceSetupUnit[]` 是 boot 层的产物;把它挂在测试对象上等于把下层的实现细节
184
+ // 变成公开契约,测试就能绕过 `reprovision()` 自己编排清理。
185
+ expect('resourceUnits' in testServer).toBe(false);
186
+ // `testData`(已发现的脚本名单)同理:它是装配期的中间产物,唯一用法是「再来一遍基线」,
187
+ // 而那件事就是 `reprovision()`。外面既没有 `loadTestData` 可以拿它做什么,也没有第二个入口
188
+ // 需要它——挂出去只会让人以为「我该自己编排装载顺序」。
189
+ expect('testData' in testServer).toBe(false);
190
+ expect(Object.keys(testServer).sort()).toEqual(['app', 'clients', 'config', 'env', 'log', 'reprovision', 'resources', 'services']);
191
+ await testServer.app.close();
192
+ });
193
+ });
194
+ describe('TestServer.reprovision', () => {
195
+ it('清完数据再关旧实例:clean() 一定跑在 dispose() 之前(连接还在)', async () => {
196
+ useEnv({ DATABASE_URL: APP_URL });
197
+ const order = [];
198
+ const server = defineServer({
199
+ ...buildServer(),
200
+ resources: () => ({
201
+ database: defineResource({ marker: 'db' }, {
202
+ clean: () => { order.push('clean:database'); },
203
+ dispose: () => { order.push('dispose:database'); },
204
+ }),
205
+ }),
206
+ services: () => ({
207
+ cache: {
208
+ clean: () => { order.push('clean:cache'); },
209
+ dispose: () => { order.push('dispose:cache'); },
210
+ },
211
+ }),
212
+ });
213
+ const first = await createTestServer(server);
214
+ const second = await first.reprovision();
215
+ // 资源声明序 → 服务声明序 → 八步关闭(dispose 逆序)
216
+ expect(order).toEqual(['clean:database', 'clean:cache', 'dispose:cache', 'dispose:database']);
217
+ // 旧 app 已关(再 inject 会失败),新 app 可用
218
+ await expect(first.app.inject({ method: 'GET', url: '/health' })).rejects.toThrow();
219
+ expect((await second.app.inject({ method: 'GET', url: '/health' })).statusCode).toBe(200);
220
+ await second.app.close();
221
+ });
222
+ it('返回的是新实例:app / resources / services 全部换新,env 快照复用', async () => {
223
+ useEnv({ DATABASE_URL: APP_URL });
224
+ let built = 0;
225
+ const server = defineServer({
226
+ ...buildServer(),
227
+ services: () => {
228
+ built += 1;
229
+ return { unit: { id: built } };
230
+ },
231
+ });
232
+ const first = await createTestServer(server);
233
+ const second = await first.reprovision();
234
+ expect(second).not.toBe(first);
235
+ expect(second.app).not.toBe(first.app);
236
+ expect(second.services).not.toBe(first.services);
237
+ expect(second.services).toEqual({ unit: { id: 2 } });
238
+ // env 是同一份快照(不重跑 prepareTestDatabase)
239
+ expect(second.env).toBe(first.env);
240
+ await second.app.close();
241
+ });
242
+ it('clean() 失败冒泡:不会去装配、也不会留下半开的世界', async () => {
243
+ useEnv({ DATABASE_URL: APP_URL });
244
+ let assembled = 0;
245
+ const server = defineServer({
246
+ ...buildServer(),
247
+ resources: () => ({
248
+ database: defineResource({ marker: 'db' }, {
249
+ clean: () => { throw new Error('truncate failed'); },
250
+ }),
251
+ }),
252
+ services: () => {
253
+ assembled += 1;
254
+ return {};
255
+ },
256
+ });
257
+ const testServer = await createTestServer(server);
258
+ await expect(testServer.reprovision()).rejects.toThrow(/truncate failed/);
259
+ // 失败发生在装配之前:services 只被构造过一次
260
+ expect(assembled).toBe(1);
261
+ await testServer.app.close();
262
+ });
263
+ it('app.close() 失败不阻断:clean 已跑完,仍然会重新装配', async () => {
264
+ useEnv({ DATABASE_URL: APP_URL });
265
+ const order = [];
266
+ const server = defineServer({
267
+ ...buildServer(),
268
+ resources: () => ({
269
+ database: defineResource({ marker: 'db' }, {
270
+ clean: () => { order.push('clean'); },
271
+ dispose: () => { throw new Error('dispose failed'); },
272
+ }),
273
+ }),
274
+ });
275
+ const first = await createTestServer(server);
276
+ const second = await first.reprovision();
277
+ expect(order).toEqual(['clean']);
278
+ expect((await second.app.inject({ method: 'GET', url: '/health' })).statusCode).toBe(200);
279
+ await second.app.close();
280
+ });
281
+ it('环境闸门:非可清理环境下连 clean() 都不跑', async () => {
282
+ useEnv({ DATABASE_URL: APP_URL });
283
+ const onClean = vi.fn();
284
+ const server = defineServer({
285
+ ...buildServer(),
286
+ resources: () => ({
287
+ database: defineResource({ marker: 'db' }, { clean: onClean }),
288
+ }),
289
+ });
290
+ const testServer = await createTestServer(server);
291
+ testServer.env.ENV = 'production';
292
+ await expect(testServer.reprovision()).rejects.toThrow(/ENV="production"/);
293
+ expect(onClean).not.toHaveBeenCalled();
294
+ await testServer.app.close();
295
+ });
296
+ });