mega-framework 0.1.0

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 (322) hide show
  1. package/.env +127 -0
  2. package/.env.example +186 -0
  3. package/.prettierrc.json +8 -0
  4. package/CHANGELOG.md +259 -0
  5. package/LICENSE +21 -0
  6. package/README.md +153 -0
  7. package/bin/mega-ws-hub.js +15 -0
  8. package/bin/mega.js +38 -0
  9. package/docker-compose.yml +201 -0
  10. package/eslint.config.js +57 -0
  11. package/infra/otel-collector-config.yaml +43 -0
  12. package/jsconfig.json +18 -0
  13. package/package.json +121 -0
  14. package/sample/crud/.env +18 -0
  15. package/sample/crud/.env.example +50 -0
  16. package/sample/crud/README.md +85 -0
  17. package/sample/crud/apps/main/app.config.js +114 -0
  18. package/sample/crud/apps/main/channels/chat-bus.js +115 -0
  19. package/sample/crud/apps/main/channels/chat-channel.js +145 -0
  20. package/sample/crud/apps/main/controllers/auth-controller.js +144 -0
  21. package/sample/crud/apps/main/controllers/cron-controller.js +34 -0
  22. package/sample/crud/apps/main/controllers/guide-controller.js +37 -0
  23. package/sample/crud/apps/main/controllers/jobs-controller.js +43 -0
  24. package/sample/crud/apps/main/controllers/logs-controller.js +35 -0
  25. package/sample/crud/apps/main/controllers/metrics-controller.js +22 -0
  26. package/sample/crud/apps/main/controllers/note-controller.js +116 -0
  27. package/sample/crud/apps/main/controllers/perf-controller.js +38 -0
  28. package/sample/crud/apps/main/controllers/redis-controller.js +36 -0
  29. package/sample/crud/apps/main/controllers/tracing-controller.js +43 -0
  30. package/sample/crud/apps/main/controllers/upload-controller.js +98 -0
  31. package/sample/crud/apps/main/controllers/user-controller.js +34 -0
  32. package/sample/crud/apps/main/controllers/web-controller.js +137 -0
  33. package/sample/crud/apps/main/controllers/worker-controller.js +57 -0
  34. package/sample/crud/apps/main/controllers/ws-controller.js +29 -0
  35. package/sample/crud/apps/main/jobs/email-job.js +72 -0
  36. package/sample/crud/apps/main/locales/client/en.json +3 -0
  37. package/sample/crud/apps/main/locales/client/ko.json +3 -0
  38. package/sample/crud/apps/main/locales/server/en.json +316 -0
  39. package/sample/crud/apps/main/locales/server/ko.json +316 -0
  40. package/sample/crud/apps/main/middleware/web-auth.js +40 -0
  41. package/sample/crud/apps/main/middleware/ws-auth.js +48 -0
  42. package/sample/crud/apps/main/migrations/20260606000001-create-users.js +27 -0
  43. package/sample/crud/apps/main/migrations/20260606000002-add-auth-to-users.js +30 -0
  44. package/sample/crud/apps/main/models/note.js +71 -0
  45. package/sample/crud/apps/main/models/user.js +86 -0
  46. package/sample/crud/apps/main/public/css/app.css +101 -0
  47. package/sample/crud/apps/main/public/css/guide.css +137 -0
  48. package/sample/crud/apps/main/public/js/app.js +54 -0
  49. package/sample/crud/apps/main/public/js/perf.js +129 -0
  50. package/sample/crud/apps/main/public/js/theme-init.js +12 -0
  51. package/sample/crud/apps/main/public/js/upload-demo.js +63 -0
  52. package/sample/crud/apps/main/public/js/worker-demo.js +92 -0
  53. package/sample/crud/apps/main/public/js/ws-chat.js +161 -0
  54. package/sample/crud/apps/main/public/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
  55. package/sample/crud/apps/main/public/vendor/bootstrap/bootstrap.min.css +6 -0
  56. package/sample/crud/apps/main/public/vendor/highlight/github-dark.css +109 -0
  57. package/sample/crud/apps/main/public/vendor/highlight/github.css +118 -0
  58. package/sample/crud/apps/main/public/vendor/mega-client-wasm/README.md +19 -0
  59. package/sample/crud/apps/main/public/vendor/mega-client-wasm/mega_client_wasm.d.ts +196 -0
  60. package/sample/crud/apps/main/public/vendor/mega-client-wasm/mega_client_wasm.js +1187 -0
  61. package/sample/crud/apps/main/public/vendor/mega-client-wasm/mega_client_wasm_bg.wasm +0 -0
  62. package/sample/crud/apps/main/routes/auth.js +15 -0
  63. package/sample/crud/apps/main/routes/cron.js +14 -0
  64. package/sample/crud/apps/main/routes/guide.js +25 -0
  65. package/sample/crud/apps/main/routes/jobs.js +14 -0
  66. package/sample/crud/apps/main/routes/logs.js +28 -0
  67. package/sample/crud/apps/main/routes/metrics.js +13 -0
  68. package/sample/crud/apps/main/routes/notes.js +19 -0
  69. package/sample/crud/apps/main/routes/perf.js +47 -0
  70. package/sample/crud/apps/main/routes/redis.js +14 -0
  71. package/sample/crud/apps/main/routes/tracing.js +14 -0
  72. package/sample/crud/apps/main/routes/upload.js +16 -0
  73. package/sample/crud/apps/main/routes/users.js +54 -0
  74. package/sample/crud/apps/main/routes/web.js +23 -0
  75. package/sample/crud/apps/main/routes/worker.js +15 -0
  76. package/sample/crud/apps/main/routes/ws.js +30 -0
  77. package/sample/crud/apps/main/schedules/cron-counter-schedule.js +30 -0
  78. package/sample/crud/apps/main/services/auth-service.js +74 -0
  79. package/sample/crud/apps/main/services/cron-demo-service.js +66 -0
  80. package/sample/crud/apps/main/services/guide-service.js +145 -0
  81. package/sample/crud/apps/main/services/jobs-demo-service.js +83 -0
  82. package/sample/crud/apps/main/services/logs-demo-service.js +59 -0
  83. package/sample/crud/apps/main/services/metrics-demo-service.js +144 -0
  84. package/sample/crud/apps/main/services/note-service.js +75 -0
  85. package/sample/crud/apps/main/services/perf-service.js +302 -0
  86. package/sample/crud/apps/main/services/redis-demo-service.js +75 -0
  87. package/sample/crud/apps/main/services/tracing-demo-service.js +69 -0
  88. package/sample/crud/apps/main/services/upload-demo-service.js +48 -0
  89. package/sample/crud/apps/main/services/user-service.js +65 -0
  90. package/sample/crud/apps/main/views/auth/login.ejs +57 -0
  91. package/sample/crud/apps/main/views/auth/register.ejs +71 -0
  92. package/sample/crud/apps/main/views/cron/index.ejs +92 -0
  93. package/sample/crud/apps/main/views/guide/index.ejs +24 -0
  94. package/sample/crud/apps/main/views/guide/page.ejs +64 -0
  95. package/sample/crud/apps/main/views/home.ejs +82 -0
  96. package/sample/crud/apps/main/views/jobs/index.ejs +113 -0
  97. package/sample/crud/apps/main/views/layouts/main.ejs +112 -0
  98. package/sample/crud/apps/main/views/logs/index.ejs +80 -0
  99. package/sample/crud/apps/main/views/metrics/index.ejs +123 -0
  100. package/sample/crud/apps/main/views/notes/edit.ejs +45 -0
  101. package/sample/crud/apps/main/views/notes/list.ejs +74 -0
  102. package/sample/crud/apps/main/views/notes/new.ejs +45 -0
  103. package/sample/crud/apps/main/views/perf/index.ejs +90 -0
  104. package/sample/crud/apps/main/views/redis/index.ejs +65 -0
  105. package/sample/crud/apps/main/views/tracing/index.ejs +106 -0
  106. package/sample/crud/apps/main/views/upload/index.ejs +79 -0
  107. package/sample/crud/apps/main/views/users/edit.ejs +48 -0
  108. package/sample/crud/apps/main/views/users/list.ejs +81 -0
  109. package/sample/crud/apps/main/views/users/new.ejs +48 -0
  110. package/sample/crud/apps/main/views/worker/index.ejs +70 -0
  111. package/sample/crud/apps/main/views/ws/index.ejs +62 -0
  112. package/sample/crud/apps/main/workers/hash-worker.js +17 -0
  113. package/sample/crud/apps/main/workers/hash.task.js +22 -0
  114. package/sample/crud/ecosystem.config.cjs +9 -0
  115. package/sample/crud/mega.config.js +105 -0
  116. package/sample/crud/package-lock.json +5665 -0
  117. package/sample/crud/package.json +28 -0
  118. package/sample/crud/test/apps/main/auth-flow.integration.test.js +177 -0
  119. package/sample/crud/test/apps/main/auth-service.test.js +93 -0
  120. package/sample/crud/test/apps/main/chat-bus.test.js +101 -0
  121. package/sample/crud/test/apps/main/chat-channel.test.js +144 -0
  122. package/sample/crud/test/apps/main/cron-demo-service.test.js +93 -0
  123. package/sample/crud/test/apps/main/demo-flow.integration.test.js +386 -0
  124. package/sample/crud/test/apps/main/email-job.test.js +76 -0
  125. package/sample/crud/test/apps/main/guide-service.test.js +68 -0
  126. package/sample/crud/test/apps/main/hash-task.test.js +30 -0
  127. package/sample/crud/test/apps/main/jobs-demo-service.test.js +88 -0
  128. package/sample/crud/test/apps/main/logs-demo-service.test.js +85 -0
  129. package/sample/crud/test/apps/main/metrics-demo-service.test.js +90 -0
  130. package/sample/crud/test/apps/main/note-service.test.js +68 -0
  131. package/sample/crud/test/apps/main/perf-service.test.js +121 -0
  132. package/sample/crud/test/apps/main/perf.integration.test.js +202 -0
  133. package/sample/crud/test/apps/main/redis-demo-service.test.js +98 -0
  134. package/sample/crud/test/apps/main/tracing-demo-service.test.js +90 -0
  135. package/sample/crud/test/apps/main/upload-demo-service.test.js +61 -0
  136. package/sample/crud/test/apps/main/user-service.test.js +65 -0
  137. package/sample/crud/test/apps/main/ws-chat.integration.test.js +232 -0
  138. package/sample/crud/vitest.config.js +8 -0
  139. package/sample/crud/yarn.lock +2142 -0
  140. package/sample/simple/.env.example +15 -0
  141. package/sample/simple/README.md +52 -0
  142. package/sample/simple/apps/main/app.config.js +35 -0
  143. package/sample/simple/apps/main/controllers/pages-controller.js +22 -0
  144. package/sample/simple/apps/main/locales/client/en.json +3 -0
  145. package/sample/simple/apps/main/locales/client/ko.json +3 -0
  146. package/sample/simple/apps/main/locales/server/en.json +23 -0
  147. package/sample/simple/apps/main/locales/server/ko.json +23 -0
  148. package/sample/simple/apps/main/public/css/app.css +101 -0
  149. package/sample/simple/apps/main/public/hello.txt +1 -0
  150. package/sample/simple/apps/main/public/js/app.js +54 -0
  151. package/sample/simple/apps/main/public/js/theme-init.js +12 -0
  152. package/sample/simple/apps/main/public/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
  153. package/sample/simple/apps/main/public/vendor/bootstrap/bootstrap.min.css +6 -0
  154. package/sample/simple/apps/main/routes/index.js +9 -0
  155. package/sample/simple/apps/main/routes/pages.js +12 -0
  156. package/sample/simple/apps/main/views/index.ejs +56 -0
  157. package/sample/simple/apps/main/views/layouts/main.ejs +74 -0
  158. package/sample/simple/ecosystem.config.cjs +10 -0
  159. package/sample/simple/mega.config.js +27 -0
  160. package/sample/simple/package-lock.json +1851 -0
  161. package/sample/simple/package.json +25 -0
  162. package/sample/simple/test/apps/main/index.test.js +13 -0
  163. package/sample/simple/vitest.config.js +8 -0
  164. package/src/adapters/adapter-manager.js +305 -0
  165. package/src/adapters/adapter-options.js +208 -0
  166. package/src/adapters/file-adapter.js +350 -0
  167. package/src/adapters/file-session-adapter.js +363 -0
  168. package/src/adapters/index.js +38 -0
  169. package/src/adapters/maria-adapter.js +425 -0
  170. package/src/adapters/mega-adapter.js +511 -0
  171. package/src/adapters/mega-bus-adapter.js +81 -0
  172. package/src/adapters/mega-cache-adapter.js +94 -0
  173. package/src/adapters/mega-db-adapter.js +72 -0
  174. package/src/adapters/mega-lock-adapter.js +118 -0
  175. package/src/adapters/mega-log-sink-adapter.js +46 -0
  176. package/src/adapters/mega-session-adapter.js +72 -0
  177. package/src/adapters/mongo-adapter.js +396 -0
  178. package/src/adapters/nats-adapter.js +370 -0
  179. package/src/adapters/postgres-adapter.js +341 -0
  180. package/src/adapters/redis-adapter.js +331 -0
  181. package/src/adapters/redis-session-adapter.js +261 -0
  182. package/src/adapters/redlock-adapter.js +385 -0
  183. package/src/adapters/registry.js +157 -0
  184. package/src/adapters/sqlite-adapter.js +309 -0
  185. package/src/auth/index.js +103 -0
  186. package/src/cli/commands/console-cmd.js +56 -0
  187. package/src/cli/commands/new.js +101 -0
  188. package/src/cli/commands/routes.js +107 -0
  189. package/src/cli/commands/scaffold.js +120 -0
  190. package/src/cli/commands/test-cmd.js +45 -0
  191. package/src/cli/generators/index.js +368 -0
  192. package/src/cli/index.js +472 -0
  193. package/src/cli/template-engine.js +72 -0
  194. package/src/cli/ws-hub.js +582 -0
  195. package/src/core/ajv-mapper.js +80 -0
  196. package/src/core/boot.js +323 -0
  197. package/src/core/cluster-metrics.js +278 -0
  198. package/src/core/config-loader.js +115 -0
  199. package/src/core/config-validator.js +322 -0
  200. package/src/core/ctx-builder.js +253 -0
  201. package/src/core/envelope.js +88 -0
  202. package/src/core/error-mapper.js +116 -0
  203. package/src/core/formbody.js +69 -0
  204. package/src/core/hub-link.js +552 -0
  205. package/src/core/i18n.js +525 -0
  206. package/src/core/index.js +63 -0
  207. package/src/core/mega-app.js +1138 -0
  208. package/src/core/mega-cluster.js +232 -0
  209. package/src/core/mega-server.js +176 -0
  210. package/src/core/mega-service.js +41 -0
  211. package/src/core/migration-runner.js +196 -0
  212. package/src/core/multipart.js +282 -0
  213. package/src/core/openapi.js +114 -0
  214. package/src/core/router.js +388 -0
  215. package/src/core/routes-loader.js +57 -0
  216. package/src/core/scope-registry.js +53 -0
  217. package/src/core/security.js +275 -0
  218. package/src/core/services-loader.js +98 -0
  219. package/src/core/session-cleanup-schedule.js +57 -0
  220. package/src/core/session-store.js +55 -0
  221. package/src/core/session.js +414 -0
  222. package/src/core/static-assets.js +126 -0
  223. package/src/core/template.js +294 -0
  224. package/src/core/workers-manager.js +193 -0
  225. package/src/core/ws-compression.js +112 -0
  226. package/src/core/ws-controller.js +109 -0
  227. package/src/core/ws-message.js +176 -0
  228. package/src/core/ws-upgrade.js +445 -0
  229. package/src/errors/config-error.js +16 -0
  230. package/src/errors/http-errors.js +130 -0
  231. package/src/errors/index.js +19 -0
  232. package/src/errors/mega-error.js +34 -0
  233. package/src/eslint-plugin/index.js +15 -0
  234. package/src/eslint-plugin/no-direct-model-import.js +113 -0
  235. package/src/index.js +131 -0
  236. package/src/lib/asp/config.js +83 -0
  237. package/src/lib/asp/crypto.js +145 -0
  238. package/src/lib/asp/errors.js +49 -0
  239. package/src/lib/asp/nonce-cache.js +94 -0
  240. package/src/lib/asp/plugin.js +263 -0
  241. package/src/lib/asp/ws-terminator.js +101 -0
  242. package/src/lib/env-mapper.js +222 -0
  243. package/src/lib/hub-protocol.js +322 -0
  244. package/src/lib/index.js +42 -0
  245. package/src/lib/logger/telegram-core.js +150 -0
  246. package/src/lib/logger/telegram-transport.js +126 -0
  247. package/src/lib/mega-brute-force.js +225 -0
  248. package/src/lib/mega-circuit-breaker.js +412 -0
  249. package/src/lib/mega-cron.js +169 -0
  250. package/src/lib/mega-hash.js +179 -0
  251. package/src/lib/mega-health.js +91 -0
  252. package/src/lib/mega-job-queue.js +600 -0
  253. package/src/lib/mega-job-worker.js +295 -0
  254. package/src/lib/mega-job.js +140 -0
  255. package/src/lib/mega-logger.js +128 -0
  256. package/src/lib/mega-metrics.js +661 -0
  257. package/src/lib/mega-plugin.js +650 -0
  258. package/src/lib/mega-retry.js +95 -0
  259. package/src/lib/mega-schedule.js +507 -0
  260. package/src/lib/mega-shutdown.js +176 -0
  261. package/src/lib/mega-tracing.js +715 -0
  262. package/src/lib/mega-worker.js +653 -0
  263. package/src/lib/worker-runner/process-entry.js +30 -0
  264. package/src/lib/worker-runner/task-dispatch.js +72 -0
  265. package/src/lib/worker-runner/thread-entry.js +26 -0
  266. package/src/models/index.js +7 -0
  267. package/src/models/mega-model.js +151 -0
  268. package/src/test/index.js +288 -0
  269. package/templates/adapter/code.tpl +40 -0
  270. package/templates/adapter/test.tpl +13 -0
  271. package/templates/app/app.config.tpl +10 -0
  272. package/templates/app/route.tpl +10 -0
  273. package/templates/app/test.tpl +13 -0
  274. package/templates/channel/code.tpl +38 -0
  275. package/templates/channel/test.tpl +19 -0
  276. package/templates/controller/code.tpl +16 -0
  277. package/templates/controller/route.tpl +9 -0
  278. package/templates/controller/test.tpl +14 -0
  279. package/templates/job/code.tpl +23 -0
  280. package/templates/job/test.tpl +17 -0
  281. package/templates/locale/code.tpl +3 -0
  282. package/templates/locale/test.tpl +13 -0
  283. package/templates/middleware/code.tpl +13 -0
  284. package/templates/middleware/test.tpl +11 -0
  285. package/templates/migration/code.tpl +20 -0
  286. package/templates/migration/test.tpl +14 -0
  287. package/templates/model/code.tpl +21 -0
  288. package/templates/model/test.tpl +29 -0
  289. package/templates/project/app.config.tpl +8 -0
  290. package/templates/project/app.config.views.tpl +37 -0
  291. package/templates/project/ecosystem.config.tpl +10 -0
  292. package/templates/project/env.tpl +12 -0
  293. package/templates/project/gitignore.tpl +8 -0
  294. package/templates/project/locales/client/en.json.tpl +3 -0
  295. package/templates/project/locales/client/ko.json.tpl +3 -0
  296. package/templates/project/locales/server/en.json.tpl +17 -0
  297. package/templates/project/locales/server/ko.json.tpl +17 -0
  298. package/templates/project/mega.config.tpl +11 -0
  299. package/templates/project/package.tpl +25 -0
  300. package/templates/project/public/css/app.css +101 -0
  301. package/templates/project/public/js/app.js +54 -0
  302. package/templates/project/public/js/theme-init.js +12 -0
  303. package/templates/project/public/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
  304. package/templates/project/public/vendor/bootstrap/bootstrap.min.css +6 -0
  305. package/templates/project/readme.tpl +48 -0
  306. package/templates/project/route.test.tpl +13 -0
  307. package/templates/project/route.test.views.tpl +15 -0
  308. package/templates/project/route.tpl +10 -0
  309. package/templates/project/route.views.tpl +10 -0
  310. package/templates/project/views/index.ejs.tpl +58 -0
  311. package/templates/project/views/layout.ejs.tpl +73 -0
  312. package/templates/project/vitest.config.tpl +8 -0
  313. package/templates/route/code.tpl +11 -0
  314. package/templates/route/test.tpl +26 -0
  315. package/templates/schedule/code.tpl +19 -0
  316. package/templates/schedule/test.tpl +17 -0
  317. package/templates/service/code.tpl +18 -0
  318. package/templates/service/test.tpl +17 -0
  319. package/templates/worker/code.tpl +14 -0
  320. package/templates/worker/task.tpl +13 -0
  321. package/templates/worker/test.tpl +18 -0
  322. package/vitest.config.js +33 -0
@@ -0,0 +1,85 @@
1
+ // @ts-check
2
+ /**
3
+ * LogsDemoService 단위 테스트 — 실제 logger emit(레벨 화이트리스트) + 안전 메타데이터만 redis 이력에 저장
4
+ * (시크릿 제외)하는지 검증한다. MegaTracing.currentTraceIds 를 가짜로, redis native 를 가짜로 둔다.
5
+ */
6
+ import { describe, test, expect, vi, beforeEach } from 'vitest'
7
+
8
+ vi.mock('mega-framework', async (importOriginal) => {
9
+ const actual = /** @type {any} */ (await importOriginal())
10
+ return { ...actual, MegaTracing: { ...actual.MegaTracing, currentTraceIds: vi.fn() } }
11
+ })
12
+
13
+ import { MegaTracing } from 'mega-framework'
14
+ import { LogsDemoService } from '../../../apps/main/services/logs-demo-service.js'
15
+
16
+ function makeCtx() {
17
+ /** @type {Map<string, string[]>} */
18
+ const lists = new Map()
19
+ const native = {
20
+ async lpush(/** @type {string} */ k, /** @type {string} */ v) {
21
+ const arr = lists.get(k) ?? []
22
+ arr.unshift(v)
23
+ lists.set(k, arr)
24
+ return arr.length
25
+ },
26
+ ltrim: vi.fn(async (/** @type {string} */ k, /** @type {number} */ _s, /** @type {number} */ stop) => {
27
+ lists.set(k, (lists.get(k) ?? []).slice(0, stop + 1))
28
+ return 'OK'
29
+ }),
30
+ async lrange(/** @type {string} */ k, /** @type {number} */ s, /** @type {number} */ e) {
31
+ return (lists.get(k) ?? []).slice(s, e + 1)
32
+ },
33
+ }
34
+ const log = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }
35
+ const ctx = { log, requestId: 'req-1', cache: (/** @type {string} */ a) => (a === 'demo' ? { native } : null) }
36
+ return { ctx, lists, log }
37
+ }
38
+
39
+ beforeEach(() => {
40
+ vi.clearAllMocks()
41
+ vi.mocked(MegaTracing.currentTraceIds).mockReturnValue({ traceId: 'tr', spanId: 'sp' })
42
+ })
43
+
44
+ describe('LogsDemoService — emit', () => {
45
+ test('선택 레벨로 실제 logger 를 호출하고 안전 메타만 이력에 저장한다(시크릿 제외)', async () => {
46
+ const { ctx, lists, log } = makeCtx()
47
+ const svc = new LogsDemoService(/** @type {any} */ (ctx))
48
+ const r = await svc.emit('warn', ' hello ')
49
+ expect(r).toMatchObject({ level: 'warn', message: 'hello', traceId: 'tr' })
50
+ expect(log.warn).toHaveBeenCalledTimes(1)
51
+ // logger payload 엔 시연용 시크릿이 들어가지만(콘솔 redact 시연), redis 이력엔 시크릿이 없어야 한다.
52
+ const stored = (lists.get(LogsDemoService.RECENT_KEY) ?? []).map((s) => JSON.parse(s))
53
+ expect(stored[0]).toMatchObject({ level: 'warn', message: 'hello', traceId: 'tr', reqId: 'req-1' })
54
+ expect(JSON.stringify(stored[0])).not.toContain('password')
55
+ expect(JSON.stringify(stored[0])).not.toContain('demo-token')
56
+ })
57
+
58
+ test('허용되지 않은 레벨은 info 로 폴백', async () => {
59
+ const { ctx, log } = makeCtx()
60
+ const svc = new LogsDemoService(/** @type {any} */ (ctx))
61
+ const r = await svc.emit('fatal', 'x')
62
+ expect(r.level).toBe('info')
63
+ expect(log.info).toHaveBeenCalledTimes(1)
64
+ })
65
+
66
+ test('빈 메시지는 기본 문구로 대체', async () => {
67
+ const { ctx } = makeCtx()
68
+ const svc = new LogsDemoService(/** @type {any} */ (ctx))
69
+ const r = await svc.emit('info', ' ')
70
+ expect(r.message.length).toBeGreaterThan(0)
71
+ })
72
+ })
73
+
74
+ describe('LogsDemoService — snapshot', () => {
75
+ test('최근 emit 메타 목록 + 레벨 화이트리스트를 돌려준다', async () => {
76
+ const { ctx } = makeCtx()
77
+ const svc = new LogsDemoService(/** @type {any} */ (ctx))
78
+ await svc.emit('info', 'a')
79
+ await svc.emit('error', 'b')
80
+ const snap = await svc.snapshot()
81
+ expect(snap.recent).toHaveLength(2)
82
+ expect(snap.recent[0]).toMatchObject({ message: 'b', level: 'error' })
83
+ expect(snap.levels).toEqual(['debug', 'info', 'warn', 'error'])
84
+ })
85
+ })
@@ -0,0 +1,90 @@
1
+ // @ts-check
2
+ /**
3
+ * MetricsDemoService 단위 테스트 — Prometheus exposition 텍스트 파서(parseLine/parse/sum)와 snapshot 요약을
4
+ * 검증한다. snapshot 은 collectCluster() 를 가짜로 갈음해 redis·실 메트릭 없이 집계 로직만 본다.
5
+ */
6
+ import { describe, test, expect, vi } from 'vitest'
7
+
8
+ vi.mock('mega-framework', async (importOriginal) => {
9
+ const actual = /** @type {any} */ (await importOriginal())
10
+ return { ...actual, collectCluster: vi.fn() }
11
+ })
12
+
13
+ import { collectCluster } from 'mega-framework'
14
+ import { MetricsDemoService } from '../../../apps/main/services/metrics-demo-service.js'
15
+
16
+ const SAMPLE = [
17
+ '# HELP mega_http_requests_total total',
18
+ '# TYPE mega_http_requests_total counter',
19
+ 'mega_http_requests_total{method="GET",route="/",status_code="200",app="main"} 3',
20
+ 'mega_http_requests_total{method="GET",route="/users",status_code="200",app="main"} 5',
21
+ 'mega_http_requests_total{method="POST",route="/users",status_code="400",app="main"} 1',
22
+ 'mega_http_requests_total{method="GET",route="/x",status_code="500",app="main"} 2',
23
+ 'mega_jobs_total{queue="email",event="enqueued"} 4',
24
+ 'mega_jobs_total{queue="email",event="processed"} 3',
25
+ 'mega_jobs_total{queue="email",event="dlq"} 1',
26
+ 'mega_ws_messages_total{type="message.send",ns="/ws/chat",app="main"} 7',
27
+ 'mega_process_memory_bytes{kind="heapUsed"} 10485760',
28
+ 'mega_process_memory_bytes{kind="rss"} 52428800',
29
+ 'mega_process_uptime_seconds 123.5',
30
+ 'mega_process_cpu_seconds_total{type="user"} 1.5',
31
+ 'mega_process_cpu_seconds_total{type="system"} 0.5',
32
+ ].join('\n')
33
+
34
+ function makeCtx() {
35
+ return { log: { debug() {} } }
36
+ }
37
+
38
+ describe('MetricsDemoService — parseLine', () => {
39
+ test('주석/빈 줄은 null, 라벨 있는/없는 샘플은 파싱된다', () => {
40
+ expect(MetricsDemoService.parseLine('# HELP x')).toBeNull()
41
+ expect(MetricsDemoService.parseLine(' ')).toBeNull()
42
+ expect(MetricsDemoService.parseLine('mega_x 0')).toEqual({ name: 'mega_x', labels: {}, value: 0 })
43
+ expect(MetricsDemoService.parseLine('mega_x{a="1",b="y"} 4.5')).toEqual({
44
+ name: 'mega_x',
45
+ labels: { a: '1', b: 'y' },
46
+ value: 4.5,
47
+ })
48
+ })
49
+
50
+ test('값이 숫자가 아니면 null', () => {
51
+ expect(MetricsDemoService.parseLine('mega_x abc')).toBeNull()
52
+ })
53
+ })
54
+
55
+ describe('MetricsDemoService — sum', () => {
56
+ test('패밀리 합산 + 라벨 필터', () => {
57
+ const samples = MetricsDemoService.parse(SAMPLE)
58
+ expect(MetricsDemoService.sum(samples, 'mega_http_requests_total')).toBe(11)
59
+ expect(MetricsDemoService.sum(samples, 'mega_jobs_total', (l) => l.event === 'enqueued')).toBe(4)
60
+ expect(MetricsDemoService.sum(samples, 'mega_process_cpu_seconds_total')).toBe(2)
61
+ })
62
+ })
63
+
64
+ describe('MetricsDemoService — snapshot', () => {
65
+ test('collect() 결과를 HTTP/잡/WS/process 요약으로 정리한다', async () => {
66
+ vi.mocked(collectCluster).mockResolvedValue(SAMPLE)
67
+ const svc = new MetricsDemoService(/** @type {any} */ (makeCtx()))
68
+ const snap = await svc.snapshot()
69
+
70
+ expect(snap.enabled).toBe(true)
71
+ expect(snap.http.total).toBe(11)
72
+ expect(snap.http.byClass).toEqual({ '2xx': 8, '3xx': 0, '4xx': 1, '5xx': 2 })
73
+ expect(snap.http.topRoutes[0]).toEqual({ route: '/users', count: 6 })
74
+ expect(snap.jobs).toEqual({ enqueued: 4, processed: 3, retried: 0, dlq: 1 })
75
+ expect(snap.ws.total).toBe(7)
76
+ expect(snap.ws.byType).toEqual([{ type: 'message.send', count: 7 }])
77
+ expect(snap.process.heapUsedMb).toBeCloseTo(10, 5)
78
+ expect(snap.process.rssMb).toBeCloseTo(50, 5)
79
+ expect(snap.process.uptimeSec).toBe(123.5)
80
+ expect(snap.process.cpuSec).toBe(2)
81
+ })
82
+
83
+ test('메트릭 OFF(collect 빈 문자열)면 enabled=false', async () => {
84
+ vi.mocked(collectCluster).mockResolvedValue('')
85
+ const svc = new MetricsDemoService(/** @type {any} */ (makeCtx()))
86
+ const snap = await svc.snapshot()
87
+ expect(snap.enabled).toBe(false)
88
+ expect(snap.http.total).toBe(0)
89
+ })
90
+ })
@@ -0,0 +1,68 @@
1
+ // @ts-check
2
+ /**
3
+ * NoteService 단위 테스트(ADR-157) — 모델(static)을 스파이로 갈음해 mongo 없이 비즈니스 로직(검증·404)을
4
+ * 검증한다. 실 mongo CRUD 는 demo-flow.integration.test.js 가 커버한다.
5
+ */
6
+ import { describe, test, expect, vi, afterEach } from 'vitest'
7
+ import { NoteService } from '../../../apps/main/services/note-service.js'
8
+ import { Note } from '../../../apps/main/models/note.js'
9
+
10
+ /** @returns {NoteService} */
11
+ function makeService() {
12
+ return new NoteService(/** @type {any} */ ({ log: { debug() {} } }))
13
+ }
14
+
15
+ afterEach(() => vi.restoreAllMocks())
16
+
17
+ describe('NoteService', () => {
18
+ test('list → Note.list 위임', async () => {
19
+ vi.spyOn(Note, 'list').mockResolvedValue([/** @type {any} */ ({ id: 'a', title: 't', body: 'b' })])
20
+ expect(await makeService().list()).toEqual([{ id: 'a', title: 't', body: 'b' }])
21
+ })
22
+
23
+ test('get — 존재하면 반환', async () => {
24
+ vi.spyOn(Note, 'findById').mockResolvedValue(/** @type {any} */ ({ id: 'x', title: 't' }))
25
+ expect(await makeService().get('x')).toEqual({ id: 'x', title: 't' })
26
+ })
27
+
28
+ test('get — 없으면 MegaNotFoundError(404)', async () => {
29
+ vi.spyOn(Note, 'findById').mockResolvedValue(null)
30
+ await expect(makeService().get('nope')).rejects.toMatchObject({ status: 404, code: 'note.not_found' })
31
+ })
32
+
33
+ test('create — 제목 누락 시 MegaValidationError(400)', async () => {
34
+ await expect(makeService().create({ title: ' ', body: 'b' })).rejects.toMatchObject({ status: 400, code: 'note.invalid' })
35
+ })
36
+
37
+ test('create — 제목 길이 초과 시 MegaValidationError(400)', async () => {
38
+ const long = 'x'.repeat(NoteService.MAX_TITLE + 1)
39
+ await expect(makeService().create({ title: long })).rejects.toMatchObject({ status: 400, code: 'note.invalid' })
40
+ })
41
+
42
+ test('create — 정상 생성(trim 후 위임)', async () => {
43
+ const spy = vi.spyOn(Note, 'create').mockResolvedValue(/** @type {any} */ ({ id: 'n1', title: 'memo', body: 'hi' }))
44
+ const r = await makeService().create({ title: ' memo ', body: ' hi ' })
45
+ expect(r).toMatchObject({ id: 'n1', title: 'memo' })
46
+ expect(spy).toHaveBeenCalledWith({ title: 'memo', body: 'hi' })
47
+ })
48
+
49
+ test('update — 없으면 MegaNotFoundError(404)', async () => {
50
+ vi.spyOn(Note, 'update').mockResolvedValue(null)
51
+ await expect(makeService().update('gone', { title: 't' })).rejects.toMatchObject({ status: 404, code: 'note.not_found' })
52
+ })
53
+
54
+ test('update — 정상 수정', async () => {
55
+ vi.spyOn(Note, 'update').mockResolvedValue(/** @type {any} */ ({ id: 'n1', title: 'new' }))
56
+ expect(await makeService().update('n1', { title: ' new ', body: '' })).toMatchObject({ id: 'n1', title: 'new' })
57
+ })
58
+
59
+ test('remove — 없으면 MegaNotFoundError(404)', async () => {
60
+ vi.spyOn(Note, 'remove').mockResolvedValue(false)
61
+ await expect(makeService().remove('gone')).rejects.toMatchObject({ status: 404, code: 'note.not_found' })
62
+ })
63
+
64
+ test('remove — 성공 시 { deleted: true }', async () => {
65
+ vi.spyOn(Note, 'remove').mockResolvedValue(true)
66
+ expect(await makeService().remove('n1')).toEqual({ deleted: true })
67
+ })
68
+ })
@@ -0,0 +1,121 @@
1
+ // @ts-check
2
+ /**
3
+ * PerfService 단위 테스트(ADR-174) — 통계 함수(percentile/summarize)의 정확성과, 인프라가 필요 없는
4
+ * in-process 시나리오(http/json/crypto)의 실행·clamp·검증을 다룬다. 어댑터 왕복 시나리오(db/cache/session)는
5
+ * 실 백엔드 의존이라 통합/수동 검증 몫이다(perf-service 는 ctx.db/cache/app 접근만 위임).
6
+ */
7
+ import { describe, test, expect } from 'vitest'
8
+ import { PerfService, percentile, summarize, SCENARIO_LIMITS } from '../../../apps/main/services/perf-service.js'
9
+
10
+ /** 로그만 있는 최소 ctx — in-process 시나리오는 db/cache/app 을 안 건드린다. */
11
+ function makeCtx() {
12
+ return { log: { debug() {}, warn() {} } }
13
+ }
14
+
15
+ describe('percentile', () => {
16
+ test('nearest-rank — 정렬된 1..10 에서 표준 백분위', () => {
17
+ const sorted = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
18
+ expect(percentile(sorted, 50)).toBe(5)
19
+ expect(percentile(sorted, 90)).toBe(9)
20
+ expect(percentile(sorted, 95)).toBe(10)
21
+ expect(percentile(sorted, 99)).toBe(10)
22
+ expect(percentile(sorted, 100)).toBe(10)
23
+ })
24
+
25
+ test('단일 표본은 모든 백분위가 그 값', () => {
26
+ expect(percentile([42], 50)).toBe(42)
27
+ expect(percentile([42], 99)).toBe(42)
28
+ })
29
+
30
+ test('빈 표본은 0', () => {
31
+ expect(percentile([], 50)).toBe(0)
32
+ })
33
+ })
34
+
35
+ describe('summarize', () => {
36
+ test('avg/min/max/ok/fail 과 백분위를 정확히 계산', () => {
37
+ const samples = [10, 20, 30, 40, 50] // 미정렬 입력도 정렬해 처리
38
+ const s = summarize(samples, 100, 5)
39
+ expect(s.ok).toBe(5)
40
+ expect(s.fail).toBe(0)
41
+ expect(s.min).toBe(10)
42
+ expect(s.max).toBe(50)
43
+ expect(s.avg).toBe(30)
44
+ expect(s.p50).toBe(30)
45
+ expect(s.durationMs).toBe(100)
46
+ // rps = total(5) / wallSec(0.1) = 50
47
+ expect(s.rps).toBe(50)
48
+ })
49
+
50
+ test('성공 표본 < total 이면 fail 로 채워진다', () => {
51
+ const s = summarize([5, 5], 50, 4)
52
+ expect(s.ok).toBe(2)
53
+ expect(s.fail).toBe(2)
54
+ })
55
+
56
+ test('표본 0 이면 통계는 0, fail=total', () => {
57
+ const s = summarize([], 10, 3)
58
+ expect(s.ok).toBe(0)
59
+ expect(s.fail).toBe(3)
60
+ expect(s.avg).toBe(0)
61
+ expect(s.p99).toBe(0)
62
+ })
63
+ })
64
+
65
+ describe('PerfService.run — in-process 시나리오', () => {
66
+ test('http.echo — 전부 성공, 통계 필드 존재', async () => {
67
+ const svc = new PerfService(makeCtx())
68
+ const r = await svc.run({ scenario: 'http.echo', iterations: 50 })
69
+ expect(r.scenario).toBe('http.echo')
70
+ expect(r.iterations).toBe(50)
71
+ expect(r.ok).toBe(50)
72
+ expect(r.fail).toBe(0)
73
+ expect(r.rps).toBeGreaterThan(0)
74
+ expect(typeof r.p95).toBe('number')
75
+ expect(r.clamped).toBeUndefined()
76
+ })
77
+
78
+ test('http.jsonLarge — payloadSize 반영, 전부 성공', async () => {
79
+ const svc = new PerfService(makeCtx())
80
+ const r = await svc.run({ scenario: 'http.jsonLarge', iterations: 20, payloadSize: 2048 })
81
+ expect(r.payloadSize).toBe(2048)
82
+ expect(r.ok).toBe(20)
83
+ expect(r.fail).toBe(0)
84
+ })
85
+
86
+ test('crypto.aspRoundtrip — encrypt→decrypt 왕복 전부 성공', async () => {
87
+ const svc = new PerfService(makeCtx())
88
+ const r = await svc.run({ scenario: 'crypto.aspRoundtrip', iterations: 30, payloadSize: 128 })
89
+ expect(r.ok).toBe(30)
90
+ expect(r.fail).toBe(0)
91
+ })
92
+ })
93
+
94
+ describe('PerfService.run — clamp + 검증', () => {
95
+ test('crypto.hash — 동시성이 시나리오 상한(4)으로 clamp 되고 결과에 표시', async () => {
96
+ const svc = new PerfService(makeCtx())
97
+ // iterations 3 은 상한(500) 이내라 그대로, concurrency 8 은 상한(4)으로 줄어든다.
98
+ const r = await svc.run({ scenario: 'crypto.hash', iterations: 3, concurrency: 8 })
99
+ expect(r.iterations).toBe(3)
100
+ expect(r.concurrency).toBe(4)
101
+ expect(r.ok).toBe(3)
102
+ expect(r.clamped).toBeDefined()
103
+ expect(r.clamped.concurrency).toEqual({ requested: 8, applied: 4 })
104
+ expect(r.clamped.iterations).toBeUndefined()
105
+ })
106
+
107
+ test('crypto.hash — concurrency 미지정 시 시나리오 디폴트(1) 적용, clamp 없음', async () => {
108
+ const svc = new PerfService(makeCtx())
109
+ const r = await svc.run({ scenario: 'crypto.hash', iterations: 2 })
110
+ expect(r.concurrency).toBe(SCENARIO_LIMITS['crypto.hash'].defConc)
111
+ expect(r.concurrency).toBe(1)
112
+ expect(r.clamped).toBeUndefined()
113
+ })
114
+
115
+ test('미지원 시나리오는 MegaValidationError', async () => {
116
+ const svc = new PerfService(makeCtx())
117
+ await expect(svc.run({ scenario: 'bogus.scenario', iterations: 1 })).rejects.toMatchObject({
118
+ code: 'perf.unknown_scenario',
119
+ })
120
+ })
121
+ })
@@ -0,0 +1,202 @@
1
+ // @ts-check
2
+ /**
3
+ * /perf 벤치마크 통합 테스트(ADR-174) — 실 postgres+mongo+redis(캐시·세션)로 sample/crud 를 부팅하고
4
+ * 인증된 HTTP 경로로 각 어댑터 시나리오를 실행한다. in-process 시나리오(http/json/crypto)는 단위 테스트가
5
+ * 커버하므로, 여기서는 어댑터 왕복(db.pg/db.mongo/cache.redis/session)과 스키마 검증·가드·teardown 을 본다.
6
+ *
7
+ * 인프라(pg·redis·mongo) env 가 없으면 통째로 skip 한다. 필요한 env(.env): DATABASE_URL·REDIS_SESSION_URL·
8
+ * REDIS_RATE_URL·REDIS_DEMO_URL·MONGO_URL·SESSION_SECRET.
9
+ */
10
+ import { describe, test, expect, beforeAll, afterAll } from 'vitest'
11
+ import { bootApp, MegaShutdown } from 'mega-framework'
12
+ import { fileURLToPath } from 'node:url'
13
+ import { dirname, resolve } from 'node:path'
14
+ import { User } from '../../../apps/main/models/user.js'
15
+
16
+ // 프로젝트 루트(mega.config.js 가 있는 sample/crud) — 이 파일 기준 상위 4단계.
17
+ const PROJECT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
18
+
19
+ // .env 가 DATABASE_URL 대신 PG_URL 만 줄 수 있으므로(로컬 관례) 매핑한다.
20
+ if (!process.env.DATABASE_URL && process.env.PG_URL) process.env.DATABASE_URL = process.env.PG_URL
21
+
22
+ const hasInfra = Boolean(
23
+ process.env.DATABASE_URL &&
24
+ process.env.REDIS_SESSION_URL &&
25
+ process.env.REDIS_RATE_URL &&
26
+ process.env.REDIS_DEMO_URL &&
27
+ process.env.MONGO_URL &&
28
+ process.env.SESSION_SECRET,
29
+ )
30
+ const d = hasInfra ? describe : describe.skip
31
+
32
+ /** set-cookie 를 누적 쿠키 jar 에 반영(maxAge=0 → 삭제). @param {any} res @param {Record<string,string>} jar */
33
+ function applyCookies(res, jar) {
34
+ const raw = res.headers['set-cookie']
35
+ const arr = Array.isArray(raw) ? raw : raw ? [raw] : []
36
+ for (const c of arr) {
37
+ const pair = String(c).split(';')[0]
38
+ const eq = pair.indexOf('=')
39
+ if (eq === -1) continue
40
+ const name = pair.slice(0, eq).trim()
41
+ const val = pair.slice(eq + 1).trim()
42
+ if (val === '') delete jar[name]
43
+ else jar[name] = val
44
+ }
45
+ }
46
+
47
+ /** @param {Record<string,string>} jar @returns {string} Cookie 헤더. */
48
+ function cookieHeader(jar) {
49
+ return Object.entries(jar).map(([k, v]) => `${k}=${v}`).join('; ')
50
+ }
51
+
52
+ /** 렌더된 폼 HTML 에서 hidden `_csrf` 토큰을 뽑는다. @param {string} html @returns {string} */
53
+ function csrfFrom(html) {
54
+ const m = /name="_csrf" value="([^"]+)"/.exec(html)
55
+ if (!m) throw new Error('csrf token not found in form HTML')
56
+ return m[1]
57
+ }
58
+
59
+ /** urlencoded 폼 바디 직렬화. @param {Record<string,string>} fields @returns {string} */
60
+ function form(fields) {
61
+ return Object.entries(fields).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&')
62
+ }
63
+
64
+ d('/perf 벤치마크 E2E — sample/crud 실 pg+mongo+redis (ADR-174)', () => {
65
+ /** @type {Awaited<ReturnType<typeof bootApp>>} */
66
+ let boot
67
+ /** @type {any} */
68
+ let fastify
69
+ /** @type {Record<string,string>} 로그인 세션 쿠키 jar. */
70
+ const jar = {}
71
+ const EMAIL = `itest-perf-${Date.now()}@example.com`
72
+ const PASSWORD = 'secret-pass-123'
73
+ const NAME = 'Perf Tester'
74
+
75
+ /**
76
+ * 인증 쿠키로 /perf/run 을 호출한다(JSON — CSRF 토큰 면제, Origin 헤더 없으면 비브라우저로 통과, ADR-051).
77
+ * @param {object} body @returns {Promise<any>} inject 응답.
78
+ */
79
+ function runPerf(body) {
80
+ return fastify.inject({
81
+ method: 'POST',
82
+ url: '/perf/run',
83
+ headers: { cookie: cookieHeader(jar), 'content-type': 'application/json', accept: 'application/json' },
84
+ payload: JSON.stringify(body),
85
+ })
86
+ }
87
+
88
+ beforeAll(async () => {
89
+ MegaShutdown._reset()
90
+ boot = await bootApp(PROJECT, { listen: false })
91
+ const app = boot.megaApps.find((a) => a.name === 'main')
92
+ fastify = app?.fastify
93
+ await fastify.ready()
94
+ await User.query('CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, created_at TIMESTAMPTZ NOT NULL DEFAULT now())')
95
+ await User.query('ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash TEXT')
96
+ await User.query('ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ')
97
+ await User.query('DELETE FROM users WHERE email = $1', [EMAIL])
98
+
99
+ // 회원가입 → 자동 로그인 → 세션 쿠키 확보(가드된 /perf 접근에 필요).
100
+ const regForm = await fastify.inject({ method: 'GET', url: '/register' })
101
+ applyCookies(regForm, jar)
102
+ const reg = await fastify.inject({
103
+ method: 'POST',
104
+ url: '/register',
105
+ headers: { cookie: cookieHeader(jar), 'content-type': 'application/x-www-form-urlencoded' },
106
+ payload: form({ _csrf: csrfFrom(regForm.body), name: NAME, email: EMAIL, password: PASSWORD }),
107
+ })
108
+ applyCookies(reg, jar)
109
+ })
110
+
111
+ afterAll(async () => {
112
+ if (!boot) return
113
+ await User.query('DELETE FROM users WHERE email = $1', [EMAIL]).catch(() => {})
114
+ await boot.ctx.db('primary').query('DROP TABLE IF EXISTS perf_bench').catch(() => {})
115
+ await fastify?.close().catch(() => {})
116
+ const app = boot.megaApps.find((a) => a.name === 'main')
117
+ await app?.sessionStore?.disconnect().catch(() => {})
118
+ await boot.ctx.cache('rate').disconnect().catch(() => {})
119
+ await boot.ctx.cache('demo').disconnect().catch(() => {})
120
+ await boot.ctx.db('mongo').disconnect().catch(() => {})
121
+ await boot.ctx.db('primary').disconnect().catch(() => {})
122
+ MegaShutdown._reset()
123
+ })
124
+
125
+ test('비로그인: /perf 는 로그인으로 302', async () => {
126
+ const res = await fastify.inject({ method: 'GET', url: '/perf' })
127
+ expect(res.statusCode).toBe(302)
128
+ expect(res.headers.location).toBe('/auth/login')
129
+ })
130
+
131
+ test('로그인 후 /perf UI 200', async () => {
132
+ const res = await fastify.inject({ method: 'GET', url: '/perf', headers: { cookie: cookieHeader(jar) } })
133
+ expect(res.statusCode).toBe(200)
134
+ expect(res.body).toContain('perf.js')
135
+ })
136
+
137
+ test('스키마: 미지원 scenario → 400', async () => {
138
+ const res = await runPerf({ scenario: 'bogus', iterations: 10 })
139
+ expect(res.statusCode).toBe(400)
140
+ })
141
+
142
+ test('스키마: iterations 상한 초과 → 400', async () => {
143
+ const res = await runPerf({ scenario: 'http.echo', iterations: 100001 })
144
+ expect(res.statusCode).toBe(400)
145
+ })
146
+
147
+ test('http.echo — 200, 전부 성공', async () => {
148
+ const res = await runPerf({ scenario: 'http.echo', iterations: 100 })
149
+ expect(res.statusCode).toBe(200)
150
+ const env = JSON.parse(res.body)
151
+ expect(env.ok).toBe(true)
152
+ expect(env.data.ok).toBe(100)
153
+ expect(env.data.fail).toBe(0)
154
+ expect(env.data.rps).toBeGreaterThan(0)
155
+ })
156
+
157
+ test('cache.redis.setGet — 실 redis 왕복 전부 성공', async () => {
158
+ const res = await runPerf({ scenario: 'cache.redis.setGet', iterations: 50, payloadSize: 128 })
159
+ expect(res.statusCode).toBe(200)
160
+ const env = JSON.parse(res.body)
161
+ expect(env.data.ok).toBe(50)
162
+ expect(env.data.fail).toBe(0)
163
+ })
164
+
165
+ test('session.createRead — 실 세션 스토어 왕복 전부 성공', async () => {
166
+ const res = await runPerf({ scenario: 'session.createRead', iterations: 50 })
167
+ expect(res.statusCode).toBe(200)
168
+ const env = JSON.parse(res.body)
169
+ expect(env.data.ok).toBe(50)
170
+ expect(env.data.fail).toBe(0)
171
+ })
172
+
173
+ test('db.pg.insertSelect — 실 postgres 왕복 전부 성공 + teardown 으로 잔여 0', async () => {
174
+ const res = await runPerf({ scenario: 'db.pg.insertSelect', iterations: 50 })
175
+ expect(res.statusCode).toBe(200)
176
+ const env = JSON.parse(res.body)
177
+ expect(env.data.ok).toBe(50)
178
+ expect(env.data.fail).toBe(0)
179
+ // teardown(DELETE WHERE run_id) 후 이 실행이 남긴 행은 없어야 한다(다른 run 부재 시 총 0).
180
+ const left = await boot.ctx.db('primary').query('SELECT count(*)::int AS c FROM perf_bench')
181
+ expect(left.rows[0].c).toBe(0)
182
+ })
183
+
184
+ test('db.mongo.insertFind — 실 mongo 왕복 전부 성공 + teardown 으로 잔여 0', async () => {
185
+ const res = await runPerf({ scenario: 'db.mongo.insertFind', iterations: 50 })
186
+ expect(res.statusCode).toBe(200)
187
+ const env = JSON.parse(res.body)
188
+ expect(env.data.ok).toBe(50)
189
+ expect(env.data.fail).toBe(0)
190
+ const left = await boot.ctx.db('mongo').native.collection('perf_bench').countDocuments({})
191
+ expect(left).toBe(0)
192
+ })
193
+
194
+ test('crypto.hash — 동시성 clamp(>4 → 4) 가 결과에 표시', async () => {
195
+ const res = await runPerf({ scenario: 'crypto.hash', iterations: 3, concurrency: 16 })
196
+ expect(res.statusCode).toBe(200)
197
+ const env = JSON.parse(res.body)
198
+ expect(env.data.ok).toBe(3)
199
+ expect(env.data.concurrency).toBe(4)
200
+ expect(env.data.clamped.concurrency).toEqual({ requested: 16, applied: 4 })
201
+ })
202
+ })
@@ -0,0 +1,98 @@
1
+ // @ts-check
2
+ /**
3
+ * RedisDemoService 단위 테스트(ADR-157) — 'demo' 캐시 어댑터(get/set/del + native incr/expire/ttl)와
4
+ * User.count 를 가짜로 갈음해 redis 없이 카운터·캐시 로직을 검증한다. 실 redis 흐름은 통합 테스트가 커버한다.
5
+ */
6
+ import { describe, test, expect, vi, afterEach } from 'vitest'
7
+ import { RedisDemoService } from '../../../apps/main/services/redis-demo-service.js'
8
+ import { User } from '../../../apps/main/models/user.js'
9
+
10
+ /**
11
+ * incr/expire/ttl/get/set/del 을 추적하는 가짜 'demo' 캐시 어댑터 + 이를 돌려주는 ctx 를 만든다.
12
+ * @param {{ store?: Map<string, any> }} [opts]
13
+ */
14
+ function makeCtx({ store = new Map() } = {}) {
15
+ /** @type {Map<string, number>} INCR 카운터(native). */
16
+ const counters = new Map()
17
+ const native = {
18
+ /** @param {string} k */
19
+ async incr(k) {
20
+ const n = (counters.get(k) ?? 0) + 1
21
+ counters.set(k, n)
22
+ return n
23
+ },
24
+ expire: vi.fn(async () => 1),
25
+ /** @param {string} k */
26
+ async ttl(k) {
27
+ return store.has(k) ? 30 : -2
28
+ },
29
+ }
30
+ const cache = {
31
+ native,
32
+ /** @param {string} k */
33
+ async get(k) {
34
+ return store.has(k) ? store.get(k) : null
35
+ },
36
+ /** @param {string} k @param {any} v */
37
+ async set(k, v) {
38
+ store.set(k, v)
39
+ },
40
+ /** @param {string} k */
41
+ async del(k) {
42
+ store.delete(k)
43
+ },
44
+ }
45
+ const ctx = { log: { debug() {} }, cache: (/** @type {string} */ alias) => (alias === 'demo' ? cache : null) }
46
+ return { ctx, native, store, counters }
47
+ }
48
+
49
+ afterEach(() => vi.restoreAllMocks())
50
+
51
+ describe('RedisDemoService — 방문 카운터', () => {
52
+ test('recordVisit — total/today 가 매 호출마다 1씩 증가하고 당일 키에 EXPIRE 를 건다', async () => {
53
+ const { ctx, native } = makeCtx()
54
+ const svc = new RedisDemoService(/** @type {any} */ (ctx))
55
+ const first = await svc.recordVisit()
56
+ expect(first).toMatchObject({ total: 1, today: 1 })
57
+ expect(typeof first.date).toBe('string')
58
+ const second = await svc.recordVisit()
59
+ expect(second).toMatchObject({ total: 2, today: 2 })
60
+ // 당일 키 TTL 재설정이 매 방문마다 호출된다(rolling).
61
+ expect(native.expire).toHaveBeenCalledWith(RedisDemoService.VISITS_DAY_PREFIX + second.date, RedisDemoService.VISITS_DAY_TTL)
62
+ expect(native.expire).toHaveBeenCalledTimes(2)
63
+ })
64
+ })
65
+
66
+ describe('RedisDemoService — 쿼리 결과 캐시', () => {
67
+ test('miss → SQL 재계산 후 SET, isHit=false', async () => {
68
+ const { ctx, store } = makeCtx()
69
+ vi.spyOn(User, 'count').mockResolvedValue(7)
70
+ const svc = new RedisDemoService(/** @type {any} */ (ctx))
71
+ const r = await svc.getUserCountCached()
72
+ expect(r).toEqual({ value: 7, isHit: false, ttlSeconds: RedisDemoService.USER_COUNT_TTL })
73
+ // SET 으로 캐시에 채워졌다.
74
+ expect(store.get(RedisDemoService.USER_COUNT_KEY)).toBe(7)
75
+ })
76
+
77
+ test('hit → 캐시 값 반환, SQL 미실행, isHit=true + 남은 TTL', async () => {
78
+ const store = new Map([[RedisDemoService.USER_COUNT_KEY, 42]])
79
+ const { ctx } = makeCtx({ store })
80
+ const countSpy = vi.spyOn(User, 'count')
81
+ const svc = new RedisDemoService(/** @type {any} */ (ctx))
82
+ const r = await svc.getUserCountCached()
83
+ expect(r).toEqual({ value: 42, isHit: true, ttlSeconds: 30 })
84
+ expect(countSpy).not.toHaveBeenCalled()
85
+ })
86
+
87
+ test('clearUserCountCache — DEL 후 다음 조회는 miss', async () => {
88
+ const store = new Map([[RedisDemoService.USER_COUNT_KEY, 99]])
89
+ const { ctx } = makeCtx({ store })
90
+ vi.spyOn(User, 'count').mockResolvedValue(3)
91
+ const svc = new RedisDemoService(/** @type {any} */ (ctx))
92
+ await svc.clearUserCountCache()
93
+ expect(store.has(RedisDemoService.USER_COUNT_KEY)).toBe(false)
94
+ const r = await svc.getUserCountCached()
95
+ expect(r.isHit).toBe(false)
96
+ expect(r.value).toBe(3)
97
+ })
98
+ })