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,28 @@
1
+ {
2
+ "name": "sample-crud",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=20"
8
+ },
9
+ "scripts": {
10
+ "dev": "NODE_ENV=development mega start",
11
+ "start": "NODE_ENV=production mega start",
12
+ "migrate": "mega migrate",
13
+ "migrate:down": "mega migrate:down",
14
+ "migrate:status": "mega migrate:status",
15
+ "scheduler": "mega scheduler",
16
+ "worker": "mega worker",
17
+ "test": "mega test"
18
+ },
19
+ "dependencies": {
20
+ "highlight.js": "^11.11.1",
21
+ "marked": "^18.0.5",
22
+ "mega-framework": "file:../.."
23
+ },
24
+ "devDependencies": {
25
+ "concurrently": "^9.0.0",
26
+ "vitest": "^4.0.0"
27
+ }
28
+ }
@@ -0,0 +1,177 @@
1
+ // @ts-check
2
+ /**
3
+ * 인증 흐름 통합 테스트(ADR-155) — 실 redis(세션·brute-force) + postgres 로 sample/crud 를 부팅하고
4
+ * HTTP 전 경로(회원가입→자동로그인→보호자원→로그아웃→차단, 잘못된 비밀번호→brute-force 잠금)를 검증한다.
5
+ *
6
+ * 인프라(redis·pg) env 가 없으면 통째로 skip 한다(단위 테스트는 `auth-service.test.js` 가 인프라 없이 커버).
7
+ * 실행에 필요한 env(.env): DATABASE_URL(또는 PG_URL)·REDIS_SESSION_URL·REDIS_RATE_URL·SESSION_SECRET.
8
+ *
9
+ * CSRF(쿠키 double-submit, ADR-051)가 켜져 있어 POST 마다 폼 토큰+쿠키를 왕복시킨다 — 실제 브라우저 폼과
10
+ * 같은 경로다. 세션 쿠키(mega.sid)도 누적 쿠키 jar 로 왕복한다.
11
+ */
12
+ import { describe, test, expect, beforeAll, afterAll } from 'vitest'
13
+ import { bootApp, MegaShutdown } from 'mega-framework'
14
+ import { fileURLToPath } from 'node:url'
15
+ import { dirname, resolve } from 'node:path'
16
+ import { User } from '../../../apps/main/models/user.js'
17
+
18
+ // 프로젝트 루트(mega.config.js 가 있는 sample/crud) — 이 파일 기준 상위 4단계.
19
+ const PROJECT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
20
+
21
+ // .env 가 DATABASE_URL 대신 PG_URL 만 줄 수 있으므로(로컬 관례) 매핑한다.
22
+ if (!process.env.DATABASE_URL && process.env.PG_URL) process.env.DATABASE_URL = process.env.PG_URL
23
+
24
+ const hasInfra = Boolean(process.env.DATABASE_URL && process.env.REDIS_SESSION_URL && process.env.REDIS_RATE_URL && process.env.SESSION_SECRET)
25
+ const d = hasInfra ? describe : describe.skip
26
+
27
+ /** set-cookie 를 누적 쿠키 jar 에 반영(maxAge=0 → 삭제). @param {any} res @param {Record<string,string>} jar */
28
+ function applyCookies(res, jar) {
29
+ const raw = res.headers['set-cookie']
30
+ const arr = Array.isArray(raw) ? raw : raw ? [raw] : []
31
+ for (const c of arr) {
32
+ const pair = String(c).split(';')[0]
33
+ const eq = pair.indexOf('=')
34
+ if (eq === -1) continue
35
+ const name = pair.slice(0, eq).trim()
36
+ const val = pair.slice(eq + 1).trim()
37
+ if (val === '') delete jar[name]
38
+ else jar[name] = val
39
+ }
40
+ }
41
+
42
+ /** @param {Record<string,string>} jar @returns {string} Cookie 헤더. */
43
+ function cookieHeader(jar) {
44
+ return Object.entries(jar).map(([k, v]) => `${k}=${v}`).join('; ')
45
+ }
46
+
47
+ /** 렌더된 폼 HTML 에서 hidden `_csrf` 토큰을 뽑는다. @param {string} html @returns {string} */
48
+ function csrfFrom(html) {
49
+ const m = /name="_csrf" value="([^"]+)"/.exec(html)
50
+ if (!m) throw new Error('csrf token not found in form HTML')
51
+ return m[1]
52
+ }
53
+
54
+ /** urlencoded 폼 바디 직렬화. @param {Record<string,string>} fields @returns {string} */
55
+ function form(fields) {
56
+ return Object.entries(fields).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&')
57
+ }
58
+
59
+ d('인증 흐름 E2E — sample/crud 실 redis+pg (ADR-155)', () => {
60
+ /** @type {Awaited<ReturnType<typeof bootApp>>} */
61
+ let boot
62
+ /** @type {any} */
63
+ let fastify
64
+ const EMAIL = `itest-auth-${Date.now()}@example.com`
65
+ const PASSWORD = 'secret-pass-123'
66
+ const NAME = 'Auth Tester'
67
+
68
+ beforeAll(async () => {
69
+ MegaShutdown._reset()
70
+ boot = await bootApp(PROJECT, { listen: false })
71
+ const app = boot.megaApps.find((a) => a.name === 'main')
72
+ fastify = app?.fastify
73
+ await fastify.ready() // onReady → 세션 store connect(실 redis).
74
+ // 스키마 보장(마이그레이션과 동치, 멱등) — 테스트 전제. 실제 운영은 `mega migrate` 가 적용한다.
75
+ 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())')
76
+ await User.query('ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash TEXT')
77
+ await User.query('ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ')
78
+ await User.query('DELETE FROM users WHERE email = $1', [EMAIL])
79
+ })
80
+
81
+ afterAll(async () => {
82
+ if (!boot) return
83
+ await User.query('DELETE FROM users WHERE email = $1', [EMAIL]).catch(() => {})
84
+ await fastify?.close().catch(() => {})
85
+ const app = boot.megaApps.find((a) => a.name === 'main')
86
+ await app?.sessionStore?.disconnect().catch(() => {})
87
+ await boot.ctx.cache('rate').disconnect().catch(() => {})
88
+ await boot.ctx.db('primary').disconnect().catch(() => {})
89
+ MegaShutdown._reset()
90
+ })
91
+
92
+ test('비로그인: 랜딩(/)은 공개 200, /admin/users 는 로그인으로 302, /users API 는 401', async () => {
93
+ const home = await fastify.inject({ method: 'GET', url: '/' })
94
+ expect(home.statusCode).toBe(200)
95
+
96
+ const admin = await fastify.inject({ method: 'GET', url: '/admin/users' })
97
+ expect(admin.statusCode).toBe(302)
98
+ expect(admin.headers.location).toBe('/auth/login')
99
+
100
+ const api = await fastify.inject({ method: 'GET', url: '/users' })
101
+ expect(api.statusCode).toBe(401)
102
+ expect(api.json().error?.code).toBe('auth.required')
103
+ })
104
+
105
+ test('회원가입 → 자동 로그인 → 보호자원 접근 → API 접근 → 로그아웃 → 재차단', async () => {
106
+ /** @type {Record<string,string>} */
107
+ const jar = {}
108
+
109
+ // 1) 회원가입 폼 GET — _csrf 쿠키 + 토큰 확보.
110
+ const regForm = await fastify.inject({ method: 'GET', url: '/register' })
111
+ expect(regForm.statusCode).toBe(200)
112
+ applyCookies(regForm, jar)
113
+ const regToken = csrfFrom(regForm.body)
114
+
115
+ // 2) 회원가입 POST — 성공 시 자동 로그인(세션 발급) + /admin/users 로 302.
116
+ const reg = await fastify.inject({
117
+ method: 'POST',
118
+ url: '/register',
119
+ headers: { cookie: cookieHeader(jar), 'content-type': 'application/x-www-form-urlencoded' },
120
+ payload: form({ _csrf: regToken, name: NAME, email: EMAIL, password: PASSWORD }),
121
+ })
122
+ expect(reg.statusCode).toBe(302)
123
+ expect(reg.headers.location).toBe('/admin/users?notice=registered')
124
+ applyCookies(reg, jar)
125
+ expect(jar['mega.sid']).toBeTruthy() // 세션 쿠키 발급됨.
126
+
127
+ // 3) 보호 자원 GET — 세션 쿠키로 통과(200), 본문에 로그인 사용자 이름 표시.
128
+ const admin = await fastify.inject({ method: 'GET', url: '/admin/users', headers: { cookie: cookieHeader(jar) } })
129
+ expect(admin.statusCode).toBe(200)
130
+ expect(admin.body).toContain(NAME)
131
+ applyCookies(admin, jar)
132
+
133
+ // 4) JSON API GET — 같은 세션으로 통과(envelope ok).
134
+ const api = await fastify.inject({ method: 'GET', url: '/users', headers: { cookie: cookieHeader(jar) } })
135
+ expect(api.statusCode).toBe(200)
136
+ expect(api.json().ok).toBe(true)
137
+
138
+ // 5) 로그아웃 POST — 보호 페이지에서 받은 _csrf 토큰으로.
139
+ const logoutToken = csrfFrom(admin.body)
140
+ const logout = await fastify.inject({
141
+ method: 'POST',
142
+ url: '/auth/logout',
143
+ headers: { cookie: cookieHeader(jar), 'content-type': 'application/x-www-form-urlencoded' },
144
+ payload: form({ _csrf: logoutToken }),
145
+ })
146
+ expect(logout.statusCode).toBe(302)
147
+ expect(logout.headers.location).toBe('/auth/login?notice=logged_out')
148
+ applyCookies(logout, jar)
149
+
150
+ // 6) 로그아웃 후 보호 자원 재차단(302 → 로그인).
151
+ const blocked = await fastify.inject({ method: 'GET', url: '/admin/users', headers: { cookie: cookieHeader(jar) } })
152
+ expect(blocked.statusCode).toBe(302)
153
+ expect(blocked.headers.location).toBe('/auth/login')
154
+ })
155
+
156
+ test('잘못된 비밀번호 반복 → brute-force 잠금(423)', async () => {
157
+ const lockEmail = `itest-lock-${Date.now()}@example.com`
158
+ // 잠금 임계(기본 maxAttempts=5)까지 틀린 로그인을 반복한다. 없는 계정이라도 brute-force 는 subject(IP:email) 기준.
159
+ let lastStatus = 0
160
+ for (let i = 0; i < 6; i++) {
161
+ /** @type {Record<string,string>} */
162
+ const jar = {}
163
+ const formPage = await fastify.inject({ method: 'GET', url: '/auth/login' })
164
+ applyCookies(formPage, jar)
165
+ const token = csrfFrom(formPage.body)
166
+ const res = await fastify.inject({
167
+ method: 'POST',
168
+ url: '/auth/login',
169
+ headers: { cookie: cookieHeader(jar), 'content-type': 'application/x-www-form-urlencoded' },
170
+ payload: form({ _csrf: token, email: lockEmail, password: 'definitely-wrong' }),
171
+ })
172
+ lastStatus = res.statusCode
173
+ }
174
+ // 임계 도달 후에는 잠금(423)으로 응답한다(401 invalid 가 아니라).
175
+ expect(lastStatus).toBe(423)
176
+ })
177
+ })
@@ -0,0 +1,93 @@
1
+ // @ts-check
2
+ /**
3
+ * AuthService 단위 테스트(ADR-155) — 모델(static)을 스파이로 갈음해 DB 없이 비즈니스 로직을 검증한다.
4
+ * 해싱은 실 MegaHash(scrypt)를 그대로 써서 register→authenticate 왕복이 진짜로 맞물리는지 본다(인프라 불필요).
5
+ */
6
+ import { describe, test, expect, vi, afterEach } from 'vitest'
7
+ import { AuthService } from '../../../apps/main/services/auth-service.js'
8
+ import { User } from '../../../apps/main/models/user.js'
9
+
10
+ /** @returns {AuthService} */
11
+ function makeService() {
12
+ return new AuthService(/** @type {any} */ ({ log: { debug() {} } }))
13
+ }
14
+
15
+ afterEach(() => vi.restoreAllMocks())
16
+
17
+ describe('AuthService.register', () => {
18
+ test('유효 입력 → 해시 후 User.register 위임(평문 미저장)', async () => {
19
+ const reg = vi
20
+ .spyOn(User, 'register')
21
+ .mockResolvedValue(/** @type {any} */ ({ id: 1, name: 'Ada', email: 'ada@x.io', created_at: 't' }))
22
+ const out = await makeService().register({ name: ' Ada ', email: ' Ada@X.io ', password: 'secret-12' })
23
+ expect(out).toEqual({ id: 1, name: 'Ada', email: 'ada@x.io', created_at: 't' })
24
+ const arg = reg.mock.calls[0][0]
25
+ expect(arg.name).toBe('Ada') // trim.
26
+ expect(arg.email).toBe('ada@x.io') // trim + lowercase.
27
+ expect(arg.passwordHash).toMatch(/^\$scrypt\$/) // 평문이 아니라 scrypt 해시.
28
+ expect(arg.passwordHash).not.toContain('secret-12')
29
+ })
30
+
31
+ test('이름/이메일 누락 → MegaValidationError(auth.invalid)', async () => {
32
+ await expect(makeService().register({ name: '', email: 'a@b.c', password: 'secret-12' })).rejects.toMatchObject({
33
+ code: 'auth.invalid',
34
+ })
35
+ })
36
+
37
+ test('비밀번호 8자 미만 → MegaValidationError(auth.invalid)', async () => {
38
+ await expect(makeService().register({ name: 'Ada', email: 'a@b.c', password: 'short' })).rejects.toMatchObject({
39
+ code: 'auth.invalid',
40
+ })
41
+ })
42
+
43
+ test('이메일 중복(23505) → MegaConflictError(user.email_taken)', async () => {
44
+ vi.spyOn(User, 'register').mockRejectedValue(/** @type {any} */ (Object.assign(new Error('dup'), { code: '23505' })))
45
+ await expect(makeService().register({ name: 'Ada', email: 'a@b.c', password: 'secret-12' })).rejects.toMatchObject({
46
+ status: 409,
47
+ code: 'user.email_taken',
48
+ })
49
+ })
50
+ })
51
+
52
+ describe('AuthService.authenticate', () => {
53
+ test('올바른 비밀번호 → 신원 반환 + last_login 갱신', async () => {
54
+ const { MegaHash } = await import('mega-framework')
55
+ const hash = await MegaHash.password.hash('secret-12')
56
+ vi.spyOn(User, 'findByEmailWithHash').mockResolvedValue(
57
+ /** @type {any} */ ({ id: 7, name: 'Ada', email: 'ada@x.io', password_hash: hash }),
58
+ )
59
+ const touch = vi.spyOn(User, 'touchLastLogin').mockResolvedValue(undefined)
60
+ const out = await makeService().authenticate({ email: 'Ada@X.io', password: 'secret-12' })
61
+ expect(out).toEqual({ id: 7, name: 'Ada' })
62
+ expect(touch).toHaveBeenCalledWith(7)
63
+ })
64
+
65
+ test('틀린 비밀번호 → null(이유 비노출), last_login 미갱신', async () => {
66
+ const { MegaHash } = await import('mega-framework')
67
+ const hash = await MegaHash.password.hash('secret-12')
68
+ vi.spyOn(User, 'findByEmailWithHash').mockResolvedValue(
69
+ /** @type {any} */ ({ id: 7, name: 'Ada', email: 'ada@x.io', password_hash: hash }),
70
+ )
71
+ const touch = vi.spyOn(User, 'touchLastLogin').mockResolvedValue(undefined)
72
+ expect(await makeService().authenticate({ email: 'ada@x.io', password: 'wrong-pass' })).toBeNull()
73
+ expect(touch).not.toHaveBeenCalled()
74
+ })
75
+
76
+ test('없는 이메일 → null', async () => {
77
+ vi.spyOn(User, 'findByEmailWithHash').mockResolvedValue(null)
78
+ expect(await makeService().authenticate({ email: 'nope@x.io', password: 'secret-12' })).toBeNull()
79
+ })
80
+
81
+ test('비밀번호 미설정 계정(admin CRUD 생성) → null', async () => {
82
+ vi.spyOn(User, 'findByEmailWithHash').mockResolvedValue(
83
+ /** @type {any} */ ({ id: 3, name: 'NoPass', email: 'np@x.io', password_hash: null }),
84
+ )
85
+ expect(await makeService().authenticate({ email: 'np@x.io', password: 'secret-12' })).toBeNull()
86
+ })
87
+
88
+ test('이메일/비밀번호 공란 → null(조회 안 함)', async () => {
89
+ const find = vi.spyOn(User, 'findByEmailWithHash').mockResolvedValue(null)
90
+ expect(await makeService().authenticate({ email: '', password: '' })).toBeNull()
91
+ expect(find).not.toHaveBeenCalled()
92
+ })
93
+ })
@@ -0,0 +1,101 @@
1
+ // @ts-check
2
+ /**
3
+ * chat-bus 단위 테스트(ADR-158) — redis pub/sub cluster broadcast 의 송수신 계약.
4
+ *
5
+ * 인프라 불필요: ioredis 를 fake(duplicate→fake subscriber, publish spy)로 대체해
6
+ * PUBLISH 직렬화·SUBSCRIBE→로컬 전달·exceptSessionIds 제외·손상 페이로드 무시를 검증한다.
7
+ */
8
+ import { describe, test, expect, vi, afterEach } from 'vitest'
9
+ import {
10
+ ensureSubscriber,
11
+ registerConn,
12
+ unregisterConn,
13
+ publish,
14
+ closeChatBus,
15
+ BCAST_CHANNEL,
16
+ } from '../../../apps/main/channels/chat-bus.js'
17
+
18
+ /** fake ioredis — duplicate 는 메시지 핸들러를 캡처하는 fake 구독자를 돌려준다. */
19
+ function fakeRedis() {
20
+ const handlers = {}
21
+ const sub = {
22
+ on: vi.fn((/** @type {string} */ ev, /** @type {Function} */ cb) => {
23
+ handlers[ev] = cb
24
+ }),
25
+ subscribe: vi.fn(async () => 1),
26
+ quit: vi.fn(async () => 'OK'),
27
+ }
28
+ return {
29
+ duplicate: vi.fn(() => sub),
30
+ publish: vi.fn(async () => 1),
31
+ _sub: sub,
32
+ /** 구독 채널로 메시지 1건 주입. */
33
+ emit: (raw) => handlers.message?.(BCAST_CHANNEL, raw),
34
+ }
35
+ }
36
+
37
+ const app = { fastify: { log: { warn: vi.fn(), error: vi.fn() } } }
38
+ const mkSock = () => ({ isOpen: true, send: vi.fn() })
39
+
40
+ afterEach(async () => {
41
+ await closeChatBus()
42
+ vi.clearAllMocks()
43
+ })
44
+
45
+ test('publish 는 채널에 envelope 을 직렬화해 PUBLISH 한다', async () => {
46
+ const redis = fakeRedis()
47
+ const env = { message: { type: 'chat.msg', payload: { text: 'hi' } } }
48
+ await publish(/** @type {any} */ (redis), env)
49
+ expect(redis.publish).toHaveBeenCalledWith(BCAST_CHANNEL, JSON.stringify(env))
50
+ })
51
+
52
+ test('ensureSubscriber 는 워커당 1회만 구독자를 만든다', () => {
53
+ const redis = fakeRedis()
54
+ ensureSubscriber(app, /** @type {any} */ (redis))
55
+ ensureSubscriber(app, /** @type {any} */ (redis))
56
+ expect(redis.duplicate).toHaveBeenCalledTimes(1)
57
+ expect(redis._sub.subscribe).toHaveBeenCalledWith(BCAST_CHANNEL)
58
+ })
59
+
60
+ test('구독 메시지를 로컬 연결에 전달하고 exceptSessionIds 는 건너뛴다', () => {
61
+ const redis = fakeRedis()
62
+ ensureSubscriber(app, /** @type {any} */ (redis))
63
+ const a = mkSock()
64
+ const b = mkSock()
65
+ registerConn(a, { sessionId: 'sA', userName: 'a' })
66
+ registerConn(b, { sessionId: 'sB', userName: 'b' })
67
+
68
+ redis.emit(JSON.stringify({ message: { type: 'chat.msg', payload: { text: 'yo' } }, exceptSessionIds: ['sA'] }))
69
+
70
+ expect(a.send).not.toHaveBeenCalled() // 제외됨.
71
+ expect(b.send).toHaveBeenCalledWith({ type: 'chat.msg', payload: { text: 'yo' } })
72
+ })
73
+
74
+ test('except 없으면 모든 로컬 연결에 전달(본인 echo 포함)', () => {
75
+ const redis = fakeRedis()
76
+ ensureSubscriber(app, /** @type {any} */ (redis))
77
+ const a = mkSock()
78
+ registerConn(a, { sessionId: 'sA', userName: 'a' })
79
+ redis.emit(JSON.stringify({ message: { type: 'chat.msg', payload: { text: 'echo' } } }))
80
+ expect(a.send).toHaveBeenCalledWith({ type: 'chat.msg', payload: { text: 'echo' } })
81
+ })
82
+
83
+ test('unregister 된 연결에는 전달하지 않는다', () => {
84
+ const redis = fakeRedis()
85
+ ensureSubscriber(app, /** @type {any} */ (redis))
86
+ const a = mkSock()
87
+ registerConn(a, { sessionId: 'sA', userName: 'a' })
88
+ unregisterConn(a)
89
+ redis.emit(JSON.stringify({ message: { type: 'chat.msg', payload: {} } }))
90
+ expect(a.send).not.toHaveBeenCalled()
91
+ })
92
+
93
+ test('손상 페이로드는 전달하지 않고 warn 로그', () => {
94
+ const redis = fakeRedis()
95
+ ensureSubscriber(app, /** @type {any} */ (redis))
96
+ const a = mkSock()
97
+ registerConn(a, { sessionId: 'sA', userName: 'a' })
98
+ redis.emit('{not json')
99
+ expect(a.send).not.toHaveBeenCalled()
100
+ expect(app.fastify.log.warn).toHaveBeenCalled()
101
+ })
@@ -0,0 +1,144 @@
1
+ // @ts-check
2
+ /**
3
+ * ChatChannel 단위 테스트(ADR-158) — 라이프사이클 훅이 chat-bus(redis pub/sub) + roster HASH 를
4
+ * 올바르게 호출하는지 mock 으로 검증. 인프라 불필요(redis native·chat-bus 모듈은 spy).
5
+ *
6
+ * 채널은 cluster-wide 전파를 chat-bus.publish 에, 로컬 등록을 register/unregisterConn 에, 접속자 명단을
7
+ * redis HASH(hset/hvals/hdel)에 위임한다 — 그 위임 계약을 단언한다(app.broadcast/joinSession 미사용).
8
+ */
9
+ import { describe, test, expect, vi, beforeEach } from 'vitest'
10
+
11
+ // chat-bus(redis pub/sub) 모듈은 mock — 채널이 올바른 env 로 호출하는지만 본다.
12
+ vi.mock('../../../apps/main/channels/chat-bus.js', () => ({
13
+ ensureSubscriber: vi.fn(),
14
+ registerConn: vi.fn(),
15
+ unregisterConn: vi.fn(),
16
+ publish: vi.fn(async () => {}),
17
+ ROSTER_KEY: 'ws:chat:roster',
18
+ }))
19
+
20
+ import { ChatChannel } from '../../../apps/main/channels/chat-channel.js'
21
+ import * as chatBus from '../../../apps/main/channels/chat-bus.js'
22
+
23
+ /** native redis spy — roster HASH + 기록 리스트. @param {object} [o] */
24
+ function fakeNative({ roster = ['kim'], history = [] } = {}) {
25
+ return {
26
+ hset: vi.fn(async () => 1),
27
+ hvals: vi.fn(async () => roster),
28
+ hdel: vi.fn(async () => 1),
29
+ rpush: vi.fn(async () => 1),
30
+ ltrim: vi.fn(async () => 'OK'),
31
+ expire: vi.fn(async () => 1),
32
+ lrange: vi.fn(async () => history),
33
+ }
34
+ }
35
+
36
+ /** 채널 ctx mock — auth/app/cache/log. @param {object} [o] */
37
+ function makeCtx({ roster = ['kim'], history = [], userName = 'kim' } = {}) {
38
+ const native = fakeNative({ roster, history })
39
+ return {
40
+ ctx: {
41
+ auth: { userId: 'u1', sessionId: 's1', userName },
42
+ app: { fastify: { log: { warn: vi.fn(), debug: vi.fn(), error: vi.fn() } } },
43
+ cache: vi.fn(() => ({ native })),
44
+ log: { warn: vi.fn(), debug: vi.fn(), error: vi.fn() },
45
+ },
46
+ native,
47
+ }
48
+ }
49
+
50
+ /** sock mock — send spy + id + isOpen. */
51
+ function makeSock() {
52
+ return { id: 'conn-1', isOpen: true, send: vi.fn(), close: vi.fn() }
53
+ }
54
+
55
+ beforeEach(() => vi.clearAllMocks())
56
+
57
+ describe('ChatChannel.onConnect', () => {
58
+ test('구독자 보장 + 로컬등록 + roster HSET + 기록재생 + 입장 publish(본인 제외) + 워커PID', async () => {
59
+ const { ctx, native } = makeCtx({
60
+ roster: ['kim', 'old'],
61
+ history: [JSON.stringify({ userId: 'u0', userName: 'old', text: 'hi', ts: 1 })],
62
+ })
63
+ const sock = makeSock()
64
+ await new ChatChannel().onConnect(sock, ctx)
65
+
66
+ expect(chatBus.ensureSubscriber).toHaveBeenCalledWith(ctx.app, native)
67
+ expect(chatBus.registerConn).toHaveBeenCalledWith(sock, { sessionId: 's1', userName: 'kim' })
68
+ expect(native.hset).toHaveBeenCalledWith('ws:chat:roster', 's1', 'kim')
69
+
70
+ // 본인에게: chat.history(me + items + 명단 + 워커PID).
71
+ const hist = sock.send.mock.calls.find(([m]) => m.type === 'chat.history')[0]
72
+ expect(hist.payload.me).toEqual({ userId: 'u1', userName: 'kim' })
73
+ expect(hist.payload.items).toHaveLength(1)
74
+ expect(hist.payload.online).toBe(2)
75
+ expect(hist.payload.members).toEqual(['kim', 'old'])
76
+ expect(hist.payload.workerPid).toBe(process.pid)
77
+
78
+ // 전 클러스터에: 입장 presence(본인 sessionId 제외).
79
+ const pub = chatBus.publish.mock.calls.at(-1)[1]
80
+ expect(pub).toMatchObject({
81
+ message: { type: 'chat.presence', payload: { event: 'join', userName: 'kim', online: 2 } },
82
+ exceptSessionIds: ['s1'],
83
+ })
84
+ })
85
+
86
+ test('redis 없으면 1011 로 닫는다(전파 transport 부재)', async () => {
87
+ const sock = makeSock()
88
+ const ctx = { auth: { userId: 'u1', sessionId: 's1', userName: 'kim' }, cache: vi.fn(() => ({ native: null })), log: { error: vi.fn() } }
89
+ await new ChatChannel().onConnect(sock, ctx)
90
+ expect(sock.close).toHaveBeenCalledWith(1011, expect.any(String))
91
+ expect(chatBus.registerConn).not.toHaveBeenCalled()
92
+ })
93
+
94
+ test('손상된 기록 1건은 건너뛰고 나머지는 재생(debug 로그)', async () => {
95
+ const { ctx } = makeCtx({ history: ['{bad', JSON.stringify({ userName: 'a', text: 'ok', ts: 2 })] })
96
+ const sock = makeSock()
97
+ await new ChatChannel().onConnect(sock, ctx)
98
+ const hist = sock.send.mock.calls.find(([m]) => m.type === 'chat.history')[0]
99
+ expect(hist.payload.items).toHaveLength(1)
100
+ expect(ctx.log.debug).toHaveBeenCalled()
101
+ })
102
+ })
103
+
104
+ describe('ChatChannel.chat.send', () => {
105
+ test('검증된 text 를 기록 적재 + 전 클러스터 publish(본인 포함)', async () => {
106
+ const { ctx, native } = makeCtx()
107
+ await new ChatChannel()['chat.send'](makeSock(), { type: 'chat.send', payload: { text: ' hello ' } }, ctx)
108
+ expect(native.rpush).toHaveBeenCalled()
109
+ expect(native.ltrim).toHaveBeenCalledWith('ws:chat:history', -30, -1)
110
+ const pub = chatBus.publish.mock.calls.at(-1)[1]
111
+ expect(pub.message.type).toBe('chat.msg')
112
+ expect(pub.message.payload).toMatchObject({ userId: 'u1', userName: 'kim', text: 'hello' })
113
+ expect(pub.exceptSessionIds).toBeUndefined() // 본인도 echo.
114
+ })
115
+
116
+ test('공백뿐인 메시지는 무시(전파·적재 없음)', async () => {
117
+ const { ctx, native } = makeCtx()
118
+ await new ChatChannel()['chat.send'](makeSock(), { type: 'chat.send', payload: { text: ' ' } }, ctx)
119
+ expect(chatBus.publish).not.toHaveBeenCalled()
120
+ expect(native.rpush).not.toHaveBeenCalled()
121
+ })
122
+ })
123
+
124
+ describe('ChatChannel.onDisconnect', () => {
125
+ test('로컬 해제 + roster HDEL + 퇴장 publish(본인 제외)', async () => {
126
+ const { ctx, native } = makeCtx({ roster: [] })
127
+ const sock = makeSock()
128
+ await new ChatChannel().onDisconnect(sock, ctx)
129
+ expect(chatBus.unregisterConn).toHaveBeenCalledWith(sock)
130
+ expect(native.hdel).toHaveBeenCalledWith('ws:chat:roster', 's1')
131
+ const pub = chatBus.publish.mock.calls.at(-1)[1]
132
+ expect(pub.message).toMatchObject({ type: 'chat.presence', payload: { event: 'leave', userName: 'kim' } })
133
+ expect(pub.exceptSessionIds).toEqual(['s1'])
134
+ })
135
+
136
+ test('auth 없으면 로컬 해제만(전파 없음)', async () => {
137
+ const { ctx } = makeCtx()
138
+ ctx.auth = null
139
+ const sock = makeSock()
140
+ await new ChatChannel().onDisconnect(sock, ctx)
141
+ expect(chatBus.unregisterConn).toHaveBeenCalledWith(sock)
142
+ expect(chatBus.publish).not.toHaveBeenCalled()
143
+ })
144
+ })
@@ -0,0 +1,93 @@
1
+ // @ts-check
2
+ /**
3
+ * CronDemoService 단위 테스트 — 'demo' 캐시 native(incr/get/lpush/ltrim/lrange)를 가짜로 갈음해 redis 없이
4
+ * 카운터/이력 로직과 다음 실행 시각 계산(MegaCron)을 검증한다. 실 redis 흐름은 통합 검증(E2E)이 커버한다.
5
+ */
6
+ import { describe, test, expect, vi } from 'vitest'
7
+ import { CronDemoService } from '../../../apps/main/services/cron-demo-service.js'
8
+
9
+ /** incr/get/lpush/ltrim/lrange 를 추적하는 가짜 native + ctx 를 만든다. */
10
+ function makeCtx() {
11
+ /** @type {Map<string, number>} */
12
+ const counters = new Map()
13
+ /** @type {Map<string, string[]>} LIST(머리 삽입). */
14
+ const lists = new Map()
15
+ const native = {
16
+ /** @param {string} k */
17
+ async incr(k) {
18
+ const n = (counters.get(k) ?? 0) + 1
19
+ counters.set(k, n)
20
+ return n
21
+ },
22
+ /** @param {string} k */
23
+ async get(k) {
24
+ return counters.has(k) ? String(counters.get(k)) : null
25
+ },
26
+ /** @param {string} k @param {string} v */
27
+ async lpush(k, v) {
28
+ const arr = lists.get(k) ?? []
29
+ arr.unshift(v)
30
+ lists.set(k, arr)
31
+ return arr.length
32
+ },
33
+ ltrim: vi.fn(async (/** @type {string} */ k, /** @type {number} */ _s, /** @type {number} */ stop) => {
34
+ const arr = lists.get(k) ?? []
35
+ lists.set(k, arr.slice(0, stop + 1))
36
+ return 'OK'
37
+ }),
38
+ /** @param {string} k @param {number} start @param {number} stop */
39
+ async lrange(k, start, stop) {
40
+ return (lists.get(k) ?? []).slice(start, stop + 1)
41
+ },
42
+ }
43
+ const cache = { native }
44
+ const ctx = { log: { debug() {} }, cache: (/** @type {string} */ a) => (a === 'demo' ? cache : null) }
45
+ return { ctx, native, counters, lists }
46
+ }
47
+
48
+ describe('CronDemoService — tick', () => {
49
+ test('호출마다 카운터가 1씩 증가하고 이력 LIST 머리에 source 와 함께 쌓인다', async () => {
50
+ const { ctx, native, lists } = makeCtx()
51
+ const svc = new CronDemoService(/** @type {any} */ (ctx))
52
+ const first = await svc.tick('schedule')
53
+ expect(first).toMatchObject({ count: 1, source: 'schedule' })
54
+ expect(typeof first.at).toBe('string')
55
+ const second = await svc.tick('manual')
56
+ expect(second.count).toBe(2)
57
+ // LTRIM 으로 최근 N건만 유지(매 tick 호출).
58
+ expect(native.ltrim).toHaveBeenCalledWith(CronDemoService.HISTORY_KEY, 0, CronDemoService.HISTORY_MAX - 1)
59
+ const stored = (lists.get(CronDemoService.HISTORY_KEY) ?? []).map((s) => JSON.parse(s))
60
+ // 최신이 머리(앞) — manual 이 먼저.
61
+ expect(stored[0]).toMatchObject({ count: 2, source: 'manual' })
62
+ expect(stored[1]).toMatchObject({ count: 1, source: 'schedule' })
63
+ })
64
+ })
65
+
66
+ describe('CronDemoService — snapshot', () => {
67
+ test('누적 카운터 + 최신순 이력 + 다음 실행 시각(미래, 오름차순)을 돌려준다', async () => {
68
+ const { ctx } = makeCtx()
69
+ const svc = new CronDemoService(/** @type {any} */ (ctx))
70
+ await svc.tick('schedule')
71
+ await svc.tick('schedule')
72
+ const snap = await svc.snapshot()
73
+ expect(snap.count).toBe(2)
74
+ expect(snap.cron).toBe(CronDemoService.CRON_EXPR)
75
+ expect(snap.timezone).toBe(CronDemoService.TIMEZONE)
76
+ expect(snap.history).toHaveLength(2)
77
+ expect(snap.nextRuns.length).toBeGreaterThan(0)
78
+ // 모두 미래 + 오름차순.
79
+ const now = Date.now()
80
+ for (const d of snap.nextRuns) expect(d.getTime()).toBeGreaterThan(now)
81
+ for (let i = 1; i < snap.nextRuns.length; i++) {
82
+ expect(snap.nextRuns[i].getTime()).toBeGreaterThan(snap.nextRuns[i - 1].getTime())
83
+ }
84
+ })
85
+
86
+ test('카운터 미존재(get=null) 시 0 으로 본다', async () => {
87
+ const { ctx } = makeCtx()
88
+ const svc = new CronDemoService(/** @type {any} */ (ctx))
89
+ const snap = await svc.snapshot()
90
+ expect(snap.count).toBe(0)
91
+ expect(snap.history).toEqual([])
92
+ })
93
+ })