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,386 @@
1
+ // @ts-check
2
+ /**
3
+ * 데모 흐름 통합 테스트(ADR-157) — 실 mongo(notes) + redis(카운터·캐시) + postgres(세션 사용자)로 sample/crud 를
4
+ * 부팅하고 HTTP 전 경로를 검증한다: 비로그인 가드 → 로그인 → notes CRUD(mongo) → redis 방문 카운터·캐시 hit/miss.
5
+ *
6
+ * 인프라(pg·redis·mongo) env 가 없으면 통째로 skip 한다(단위 테스트가 인프라 없이 로직을 커버).
7
+ * 실행에 필요한 env(.env): DATABASE_URL·REDIS_SESSION_URL·REDIS_RATE_URL·REDIS_DEMO_URL·MONGO_URL·SESSION_SECRET.
8
+ */
9
+ import { describe, test, expect, beforeAll, afterAll } from 'vitest'
10
+ import { bootApp, MegaShutdown } from 'mega-framework'
11
+ import { fileURLToPath } from 'node:url'
12
+ import { dirname, resolve } from 'node:path'
13
+ import { existsSync, readFileSync, rmSync } from 'node:fs'
14
+ import { User } from '../../../apps/main/models/user.js'
15
+ import { Note } from '../../../apps/main/models/note.js'
16
+ import { RedisDemoService } from '../../../apps/main/services/redis-demo-service.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(
25
+ process.env.DATABASE_URL &&
26
+ process.env.REDIS_SESSION_URL &&
27
+ process.env.REDIS_RATE_URL &&
28
+ process.env.REDIS_DEMO_URL &&
29
+ process.env.MONGO_URL &&
30
+ process.env.SESSION_SECRET,
31
+ )
32
+ const d = hasInfra ? describe : describe.skip
33
+
34
+ /** set-cookie 를 누적 쿠키 jar 에 반영(maxAge=0 → 삭제). @param {any} res @param {Record<string,string>} jar */
35
+ function applyCookies(res, jar) {
36
+ const raw = res.headers['set-cookie']
37
+ const arr = Array.isArray(raw) ? raw : raw ? [raw] : []
38
+ for (const c of arr) {
39
+ const pair = String(c).split(';')[0]
40
+ const eq = pair.indexOf('=')
41
+ if (eq === -1) continue
42
+ const name = pair.slice(0, eq).trim()
43
+ const val = pair.slice(eq + 1).trim()
44
+ if (val === '') delete jar[name]
45
+ else jar[name] = val
46
+ }
47
+ }
48
+
49
+ /** @param {Record<string,string>} jar @returns {string} Cookie 헤더. */
50
+ function cookieHeader(jar) {
51
+ return Object.entries(jar).map(([k, v]) => `${k}=${v}`).join('; ')
52
+ }
53
+
54
+ /** 렌더된 폼 HTML 에서 hidden `_csrf` 토큰을 뽑는다. @param {string} html @returns {string} */
55
+ function csrfFrom(html) {
56
+ const m = /name="_csrf" value="([^"]+)"/.exec(html)
57
+ if (!m) throw new Error('csrf token not found in form HTML')
58
+ return m[1]
59
+ }
60
+
61
+ /** urlencoded 폼 바디 직렬화. @param {Record<string,string>} fields @returns {string} */
62
+ function form(fields) {
63
+ return Object.entries(fields).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&')
64
+ }
65
+
66
+ /** 업로드 폼의 data-csrf 속성에서 토큰을 뽑는다(multipart 는 헤더로 보냄). @param {string} html @returns {string} */
67
+ function csrfDataFrom(html) {
68
+ const m = /data-csrf="([^"]+)"/.exec(html)
69
+ if (!m) throw new Error('data-csrf token not found in upload form HTML')
70
+ return m[1]
71
+ }
72
+
73
+ /** N개 파일 multipart/form-data 본문 조립(추가 dep 없이). @param {Array<{ name?: string, filename?: string, contentType?: string, value?: string }>} files @param {string} [boundary] */
74
+ function multipartBody(files, boundary = '----megademo') {
75
+ const chunks = []
76
+ for (const f of files) {
77
+ chunks.push(
78
+ Buffer.from(
79
+ `--${boundary}\r\nContent-Disposition: form-data; name="${f.name ?? 'file'}"; filename="${f.filename ?? 'a.bin'}"\r\n` +
80
+ `Content-Type: ${f.contentType ?? 'application/octet-stream'}\r\n\r\n`,
81
+ 'utf8',
82
+ ),
83
+ )
84
+ chunks.push(Buffer.from(String(f.value ?? ''), 'utf8'))
85
+ chunks.push(Buffer.from('\r\n', 'utf8'))
86
+ }
87
+ chunks.push(Buffer.from(`--${boundary}--\r\n`, 'utf8'))
88
+ return { body: Buffer.concat(chunks), contentType: `multipart/form-data; boundary=${boundary}` }
89
+ }
90
+
91
+ d('데모 흐름 E2E — sample/crud 실 mongo+redis+pg (ADR-157)', () => {
92
+ /** @type {Awaited<ReturnType<typeof bootApp>>} */
93
+ let boot
94
+ /** @type {any} */
95
+ let fastify
96
+ /** @type {Record<string,string>} 로그인 세션 쿠키 jar. */
97
+ const jar = {}
98
+ const EMAIL = `itest-demo-${Date.now()}@example.com`
99
+ const PASSWORD = 'secret-pass-123'
100
+ const NAME = 'Demo Tester'
101
+
102
+ beforeAll(async () => {
103
+ MegaShutdown._reset()
104
+ boot = await bootApp(PROJECT, { listen: false })
105
+ const app = boot.megaApps.find((a) => a.name === 'main')
106
+ fastify = app?.fastify
107
+ await fastify.ready()
108
+ // 스키마 보장(멱등) + 테스트 계정 정리.
109
+ 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())')
110
+ await User.query('ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash TEXT')
111
+ await User.query('ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ')
112
+ await User.query('DELETE FROM users WHERE email = $1', [EMAIL])
113
+
114
+ // 회원가입 → 자동 로그인 → 세션 쿠키 확보(가드된 /demo/** 접근에 필요).
115
+ const regForm = await fastify.inject({ method: 'GET', url: '/register' })
116
+ applyCookies(regForm, jar)
117
+ const reg = await fastify.inject({
118
+ method: 'POST',
119
+ url: '/register',
120
+ headers: { cookie: cookieHeader(jar), 'content-type': 'application/x-www-form-urlencoded' },
121
+ payload: form({ _csrf: csrfFrom(regForm.body), name: NAME, email: EMAIL, password: PASSWORD }),
122
+ })
123
+ applyCookies(reg, jar)
124
+ })
125
+
126
+ afterAll(async () => {
127
+ if (!boot) return
128
+ await User.query('DELETE FROM users WHERE email = $1', [EMAIL]).catch(() => {})
129
+ await Note.collection.deleteMany({ title: { $regex: /^itest / } }).catch(() => {})
130
+ await fastify?.close().catch(() => {})
131
+ const app = boot.megaApps.find((a) => a.name === 'main')
132
+ await app?.sessionStore?.disconnect().catch(() => {})
133
+ await boot.ctx.cache('rate').disconnect().catch(() => {})
134
+ await boot.ctx.cache('demo').disconnect().catch(() => {})
135
+ await boot.ctx.db('mongo').disconnect().catch(() => {})
136
+ await boot.ctx.db('primary').disconnect().catch(() => {})
137
+ MegaShutdown._reset()
138
+ })
139
+
140
+ test('비로그인: /demo/notes 와 /demo/redis 는 로그인으로 302', async () => {
141
+ const notes = await fastify.inject({ method: 'GET', url: '/demo/notes' })
142
+ expect(notes.statusCode).toBe(302)
143
+ expect(notes.headers.location).toBe('/auth/login')
144
+ const redis = await fastify.inject({ method: 'GET', url: '/demo/redis' })
145
+ expect(redis.statusCode).toBe(302)
146
+ expect(redis.headers.location).toBe('/auth/login')
147
+ })
148
+
149
+ test('notes CRUD (mongo): 생성 → 목록 표시 → 수정 → 삭제', async () => {
150
+ const TITLE = `itest ${Date.now()}`
151
+ const EDITED = `${TITLE} (edited)`
152
+
153
+ // 신규 폼 GET — CSRF 토큰.
154
+ const newForm = await fastify.inject({ method: 'GET', url: '/demo/notes/new', headers: { cookie: cookieHeader(jar) } })
155
+ expect(newForm.statusCode).toBe(200)
156
+
157
+ // 생성 POST → 목록으로 302.
158
+ const created = await fastify.inject({
159
+ method: 'POST',
160
+ url: '/demo/notes',
161
+ headers: { cookie: cookieHeader(jar), 'content-type': 'application/x-www-form-urlencoded' },
162
+ payload: form({ _csrf: csrfFrom(newForm.body), title: TITLE, body: 'hello mongo' }),
163
+ })
164
+ expect(created.statusCode).toBe(302)
165
+ expect(created.headers.location).toBe('/demo/notes?notice=created')
166
+
167
+ // 목록에 방금 만든 제목이 보인다.
168
+ const list = await fastify.inject({ method: 'GET', url: '/demo/notes', headers: { cookie: cookieHeader(jar) } })
169
+ expect(list.statusCode).toBe(200)
170
+ expect(list.body).toContain(TITLE)
171
+
172
+ // 방금 만든 노트의 id 를 모델로 직접 찾는다(목록 HTML 파싱 대신 — edit URL 구성용).
173
+ const all = await Note.list()
174
+ const mine = /** @type {any} */ (all).find((n) => n.title === TITLE)
175
+ expect(mine).toBeTruthy()
176
+
177
+ // 수정 폼 GET → 수정 POST → 목록으로 302.
178
+ const editForm = await fastify.inject({ method: 'GET', url: `/demo/notes/${mine.id}/edit`, headers: { cookie: cookieHeader(jar) } })
179
+ expect(editForm.statusCode).toBe(200)
180
+ expect(editForm.body).toContain(TITLE)
181
+ const updated = await fastify.inject({
182
+ method: 'POST',
183
+ url: `/demo/notes/${mine.id}`,
184
+ headers: { cookie: cookieHeader(jar), 'content-type': 'application/x-www-form-urlencoded' },
185
+ payload: form({ _csrf: csrfFrom(editForm.body), title: EDITED, body: 'edited body' }),
186
+ })
187
+ expect(updated.statusCode).toBe(302)
188
+ expect(await Note.findById(mine.id)).toMatchObject({ title: EDITED })
189
+
190
+ // 삭제 POST → 목록으로 302, 모델에서도 사라진다.
191
+ const delList = await fastify.inject({ method: 'GET', url: '/demo/notes', headers: { cookie: cookieHeader(jar) } })
192
+ const del = await fastify.inject({
193
+ method: 'POST',
194
+ url: `/demo/notes/${mine.id}/delete`,
195
+ headers: { cookie: cookieHeader(jar), 'content-type': 'application/x-www-form-urlencoded' },
196
+ payload: form({ _csrf: csrfFrom(delList.body) }),
197
+ })
198
+ expect(del.statusCode).toBe(302)
199
+ expect(del.headers.location).toBe('/demo/notes?notice=deleted')
200
+ expect(await Note.findById(mine.id)).toBeNull()
201
+ })
202
+
203
+ test('redis 방문 카운터: 페이지 로드마다 누적 카운터가 1씩 증가한다', async () => {
204
+ const redis = boot.ctx.cache('demo').native
205
+ const before = Number((await redis.get(RedisDemoService.VISITS_TOTAL_KEY)) ?? 0)
206
+ await fastify.inject({ method: 'GET', url: '/demo/redis', headers: { cookie: cookieHeader(jar) } })
207
+ await fastify.inject({ method: 'GET', url: '/demo/redis', headers: { cookie: cookieHeader(jar) } })
208
+ const after = Number(await redis.get(RedisDemoService.VISITS_TOTAL_KEY))
209
+ expect(after).toBe(before + 2)
210
+ })
211
+
212
+ test('redis 캐시: 비운 직후 첫 로드는 miss, 다음 로드는 hit', async () => {
213
+ // 캐시 키를 직접 비워 결정적 상태로 만든다.
214
+ await boot.ctx.cache('demo').del(RedisDemoService.USER_COUNT_KEY)
215
+
216
+ const miss = await fastify.inject({ method: 'GET', url: '/demo/redis', headers: { cookie: cookieHeader(jar) } })
217
+ expect(miss.statusCode).toBe(200)
218
+ expect(miss.body).toContain('캐시 미스') // 기본 로케일 ko.
219
+
220
+ const hit = await fastify.inject({ method: 'GET', url: '/demo/redis', headers: { cookie: cookieHeader(jar) } })
221
+ expect(hit.statusCode).toBe(200)
222
+ expect(hit.body).toContain('캐시 적중')
223
+ })
224
+
225
+ // ── 관측 데모(ADR-163) — 메트릭/Swagger/트레이싱/로그/업로드 ──────────────────────────
226
+
227
+ test('관측 데모 비로그인 가드: 메트릭/트레이싱/로그/업로드/docs 는 로그인으로 302', async () => {
228
+ for (const url of ['/demo/metrics', '/demo/tracing', '/demo/logs', '/demo/upload', '/docs']) {
229
+ const res = await fastify.inject({ method: 'GET', url })
230
+ expect(res.statusCode, url).toBe(302)
231
+ expect(res.headers.location, url).toBe('/auth/login')
232
+ }
233
+ })
234
+
235
+ test('메트릭 데모: 로그인 시 200 + 카드 렌더', async () => {
236
+ const res = await fastify.inject({ method: 'GET', url: '/demo/metrics', headers: { cookie: cookieHeader(jar) } })
237
+ expect(res.statusCode).toBe(200)
238
+ expect(res.body).toContain('메트릭') // h1 title(ko).
239
+ })
240
+
241
+ test('Swagger: /docs/json 이 라우트 schema 를 수집한 OpenAPI 명세를 준다', async () => {
242
+ const res = await fastify.inject({ method: 'GET', url: '/docs/json', headers: { cookie: cookieHeader(jar) } })
243
+ expect(res.statusCode).toBe(200)
244
+ const spec = res.json()
245
+ expect(spec.openapi).toMatch(/^3\./)
246
+ // users 라우트가 자동 수집됐는지(경로 + 태그).
247
+ expect(spec.paths['/users']).toBeTruthy()
248
+ expect(JSON.stringify(spec)).toContain('users')
249
+ })
250
+
251
+ test('트레이싱 데모: 200 + generate POST(사용자 span + DB 핑) 302', async () => {
252
+ const page = await fastify.inject({ method: 'GET', url: '/demo/tracing', headers: { cookie: cookieHeader(jar) } })
253
+ expect(page.statusCode).toBe(200)
254
+ const gen = await fastify.inject({
255
+ method: 'POST',
256
+ url: '/demo/tracing/generate',
257
+ headers: { cookie: cookieHeader(jar), 'content-type': 'application/x-www-form-urlencoded' },
258
+ payload: form({ _csrf: csrfFrom(page.body) }),
259
+ })
260
+ expect(gen.statusCode).toBe(302)
261
+ expect(gen.headers.location).toBe('/demo/tracing?notice=generated')
262
+ })
263
+
264
+ test('로그 데모: emit POST 302 → 최근 목록에 메시지 표시', async () => {
265
+ const page = await fastify.inject({ method: 'GET', url: '/demo/logs', headers: { cookie: cookieHeader(jar) } })
266
+ expect(page.statusCode).toBe(200)
267
+ const MSG = `itest log ${Date.now()}`
268
+ const emit = await fastify.inject({
269
+ method: 'POST',
270
+ url: '/demo/logs/emit',
271
+ headers: { cookie: cookieHeader(jar), 'content-type': 'application/x-www-form-urlencoded' },
272
+ payload: form({ _csrf: csrfFrom(page.body), level: 'warn', message: MSG }),
273
+ })
274
+ expect(emit.statusCode).toBe(302)
275
+ expect(emit.headers.location).toBe('/demo/logs?notice=emitted')
276
+ const after = await fastify.inject({ method: 'GET', url: '/demo/logs', headers: { cookie: cookieHeader(jar) } })
277
+ expect(after.body).toContain(MSG)
278
+ })
279
+
280
+ // ── 가이드 뷰어(/guide) — 라우터 params 스키마 검증(ADR-019) + i18n ──────────────────────
281
+
282
+ test('가이드 목록: 로그인 시 200 + 카드 그리드', async () => {
283
+ const res = await fastify.inject({ method: 'GET', url: '/guide', headers: { cookie: cookieHeader(jar) } })
284
+ expect(res.statusCode).toBe(200)
285
+ expect(res.body).toContain('가이드') // guide_index_title(ko).
286
+ })
287
+
288
+ test('가이드 단일: 유효 slug 는 200 + 목차 렌더', async () => {
289
+ const res = await fastify.inject({ method: 'GET', url: '/guide/01-cli', headers: { cookie: cookieHeader(jar) } })
290
+ expect(res.statusCode).toBe(200)
291
+ expect(res.body).toContain('목차') // guide_toc(ko) — 좌측 목차.
292
+ })
293
+
294
+ test('가이드 단일: 형식 위반 slug 는 라우터 params 스키마가 400(validation.failed)으로 막는다', async () => {
295
+ for (const bad of ['INVALID', 'foo_bar', 'a.b']) {
296
+ const res = await fastify.inject({ method: 'GET', url: `/guide/${encodeURIComponent(bad)}`, headers: { cookie: cookieHeader(jar) } })
297
+ expect(res.statusCode, bad).toBe(400)
298
+ expect(res.json().error.code, bad).toBe('validation.failed')
299
+ }
300
+ })
301
+
302
+ test('가이드 단일: 형식은 맞지만 없는 slug 는 서비스가 404(guide.not_found)', async () => {
303
+ const res = await fastify.inject({ method: 'GET', url: '/guide/99-does-not-exist', headers: { cookie: cookieHeader(jar) } })
304
+ expect(res.statusCode).toBe(404)
305
+ expect(res.json().error.code).toBe('guide.not_found')
306
+ })
307
+
308
+ test('i18n: 검증 에러 메시지가 locale(ko 기본 / en 쿠키)로 번역된다', async () => {
309
+ const ko = await fastify.inject({ method: 'GET', url: '/guide/BAD', headers: { cookie: cookieHeader(jar) } })
310
+ expect(ko.json().error.message).toBe('입력값이 올바르지 않습니다.')
311
+ const en = await fastify.inject({ method: 'GET', url: '/guide/BAD', headers: { cookie: `${cookieHeader(jar)}; mega.lang=en` } })
312
+ expect(en.json().error.message).toBe('Validation failed.')
313
+ })
314
+
315
+ test('로그 데모: message 가 상한(500자)을 넘으면 body 스키마가 400 으로 막는다', async () => {
316
+ const page = await fastify.inject({ method: 'GET', url: '/demo/logs', headers: { cookie: cookieHeader(jar) } })
317
+ const res = await fastify.inject({
318
+ method: 'POST',
319
+ url: '/demo/logs/emit',
320
+ headers: { cookie: cookieHeader(jar), 'content-type': 'application/x-www-form-urlencoded' },
321
+ payload: form({ _csrf: csrfFrom(page.body), level: 'info', message: 'x'.repeat(501) }),
322
+ })
323
+ expect(res.statusCode).toBe(400)
324
+ expect(res.json().error.code).toBe('validation.failed')
325
+ })
326
+
327
+ test('업로드 데모: multipart + csrf-token 헤더로 저장 → JSON envelope + 최근 목록', async () => {
328
+ // 폼 GET — _csrf 쿠키 확보 + data-csrf 토큰 추출(multipart 는 헤더로 전송).
329
+ const page = await fastify.inject({ method: 'GET', url: '/demo/upload', headers: { cookie: cookieHeader(jar) } })
330
+ expect(page.statusCode).toBe(200)
331
+ applyCookies(page, jar)
332
+ const token = csrfDataFrom(page.body)
333
+
334
+ const FNAME = `itest-${Date.now()}.txt`
335
+ const { body, contentType } = multipartBody([{ filename: FNAME, contentType: 'text/plain', value: 'hello upload' }])
336
+ const up = await fastify.inject({
337
+ method: 'POST',
338
+ url: '/demo/upload',
339
+ headers: { cookie: cookieHeader(jar), 'content-type': contentType, 'csrf-token': token },
340
+ payload: body,
341
+ })
342
+ expect(up.statusCode).toBe(200)
343
+ const env = up.json()
344
+ expect(env.ok).toBe(true)
345
+ expect(env.data.files[0]).toMatchObject({ filename: FNAME, mimetype: 'text/plain' })
346
+ // 저장 경로는 프로젝트 루트 기준 상대(var/uploads/...) — 절대경로 비노출.
347
+ expect(env.data.files[0].path).toMatch(/^var[/\\]uploads[/\\]/)
348
+
349
+ // 실제 디스크에 저장됐는지 확인(프로젝트 루트 기준).
350
+ const savedAbs = resolve(PROJECT, env.data.files[0].path)
351
+ expect(existsSync(savedAbs)).toBe(true)
352
+ expect(readFileSync(savedAbs, 'utf8')).toBe('hello upload')
353
+
354
+ // 최근 업로드 목록에 파일명 + 다운로드 링크가 보인다.
355
+ const after = await fastify.inject({ method: 'GET', url: '/demo/upload', headers: { cookie: cookieHeader(jar) } })
356
+ expect(after.body).toContain(FNAME)
357
+ expect(after.body).toContain(`/demo/upload/file/${encodeURIComponent(FNAME)}`)
358
+
359
+ // 다운로드 — 인증 사용자는 소스에서 읽은 파일 내용을 받는다(정적 서빙 아님).
360
+ const dl = await fastify.inject({ method: 'GET', url: `/demo/upload/file/${encodeURIComponent(FNAME)}`, headers: { cookie: cookieHeader(jar) } })
361
+ expect(dl.statusCode).toBe(200)
362
+ expect(dl.body).toBe('hello upload')
363
+ expect(dl.headers['content-disposition']).toContain('attachment')
364
+
365
+ // 비로그인 다운로드는 로그인으로 리다이렉트(가드).
366
+ const dlGuard = await fastify.inject({ method: 'GET', url: `/demo/upload/file/${encodeURIComponent(FNAME)}` })
367
+ expect(dlGuard.statusCode).toBe(302)
368
+ expect(dlGuard.headers.location).toBe('/auth/login')
369
+
370
+ rmSync(savedAbs, { force: true }) // 테스트 산출물 정리.
371
+ })
372
+
373
+ test('업로드 데모: 비허용 MIME 은 415 로 거부', async () => {
374
+ const page = await fastify.inject({ method: 'GET', url: '/demo/upload', headers: { cookie: cookieHeader(jar) } })
375
+ applyCookies(page, jar)
376
+ const token = csrfDataFrom(page.body)
377
+ const { body, contentType } = multipartBody([{ filename: 'evil.bin', contentType: 'application/octet-stream', value: 'x' }])
378
+ const up = await fastify.inject({
379
+ method: 'POST',
380
+ url: '/demo/upload',
381
+ headers: { cookie: cookieHeader(jar), 'content-type': contentType, 'csrf-token': token },
382
+ payload: body,
383
+ })
384
+ expect(up.statusCode).toBe(415)
385
+ })
386
+ })
@@ -0,0 +1,76 @@
1
+ // @ts-check
2
+ /**
3
+ * EmailJob 단위 테스트 — 'demo' 캐시 native(incr/expire/lpush/ltrim)를 가짜로 갈음해 NATS 없이 run() 의 세
4
+ * 모드(ok/flaky/fail) 흐름과 이벤트 기록을 검증한다. 실 큐/재시도/DLQ 는 통합 검증(E2E)이 커버한다.
5
+ */
6
+ import { describe, test, expect, vi } from 'vitest'
7
+ import { EmailJob } from '../../../apps/main/jobs/email-job.js'
8
+
9
+ /** 시도 카운터(incr) + 이벤트 LIST 를 추적하는 가짜 ctx. */
10
+ function makeCtx() {
11
+ /** @type {Map<string, number>} */
12
+ const counters = new Map()
13
+ /** @type {string[]} */
14
+ const events = []
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
+ expire: vi.fn(async () => 1),
23
+ /** @param {string} _k @param {string} v */
24
+ async lpush(_k, v) {
25
+ events.unshift(v)
26
+ return events.length
27
+ },
28
+ ltrim: vi.fn(async () => 'OK'),
29
+ }
30
+ const ctx = { log: { debug() {} }, cache: (/** @type {string} */ a) => (a === 'demo' ? { native } : null) }
31
+ return { ctx, events, native }
32
+ }
33
+
34
+ describe('EmailJob.run — ok 모드', () => {
35
+ test('첫 시도에 sent 를 반환하고 sent 이벤트를 남긴다', async () => {
36
+ const { ctx, events } = makeCtx()
37
+ const job = new EmailJob()
38
+ const r = await job.run({ id: 'e1', to: 'a@b.c', mode: 'ok' }, /** @type {any} */ (ctx))
39
+ expect(r).toEqual({ id: 'e1', status: 'sent', attempt: 1 })
40
+ expect(JSON.parse(events[0])).toMatchObject({ id: 'e1', status: 'sent', attempt: 1 })
41
+ })
42
+ })
43
+
44
+ describe('EmailJob.run — flaky 모드', () => {
45
+ test('1번째 시도는 throw(재시도 유발), 2번째 시도에 성공한다', async () => {
46
+ const { ctx, events } = makeCtx()
47
+ const job = new EmailJob()
48
+ const payload = { id: 'e2', to: 'a@b.c', mode: /** @type {const} */ ('flaky') }
49
+ await expect(job.run(payload, /** @type {any} */ (ctx))).rejects.toThrow(/transient failure on attempt 1/)
50
+ expect(JSON.parse(events[0])).toMatchObject({ status: 'retry', attempt: 1 })
51
+ // 같은 잡 재실행(재시도) → 시도 카운터 2 → 성공.
52
+ const r = await job.run(payload, /** @type {any} */ (ctx))
53
+ expect(r).toEqual({ id: 'e2', status: 'sent', attempt: 2 })
54
+ expect(JSON.parse(events[0])).toMatchObject({ status: 'sent', attempt: 2 })
55
+ })
56
+ })
57
+
58
+ describe('EmailJob.run — fail 모드', () => {
59
+ test('매 시도 throw 하고 failed 이벤트를 남긴다(재시도 소진 시 DLQ 로 라우팅됨)', async () => {
60
+ const { ctx, events } = makeCtx()
61
+ const job = new EmailJob()
62
+ const payload = { id: 'e3', to: 'a@b.c', mode: /** @type {const} */ ('fail') }
63
+ await expect(job.run(payload, /** @type {any} */ (ctx))).rejects.toThrow(/permanent failure on attempt 1/)
64
+ await expect(job.run(payload, /** @type {any} */ (ctx))).rejects.toThrow(/permanent failure on attempt 2/)
65
+ expect(JSON.parse(events[0])).toMatchObject({ status: 'failed', attempt: 2 })
66
+ })
67
+ })
68
+
69
+ describe('EmailJob 설정', () => {
70
+ test('subject/bus/concurrency/retries 가 정본 값과 일치한다', () => {
71
+ expect(EmailJob.subject).toBe('demo.email')
72
+ expect(EmailJob.bus).toBe('jobs')
73
+ expect(EmailJob.retries).toBe(2)
74
+ expect(EmailJob.concurrency).toBe(2)
75
+ })
76
+ })
@@ -0,0 +1,68 @@
1
+ // @ts-check
2
+ /**
3
+ * GuideService 단위 테스트 — 레포 루트 docs/guide 의 실제 마크다운을 읽어 목록·렌더를 검증한다.
4
+ * 가이드 파일은 정적 자산이라 가짜를 두지 않고 실물을 읽는다(서버사이드 렌더 결과의 핵심 계약만 확인).
5
+ */
6
+ import { describe, test, expect } from 'vitest'
7
+ import { GuideService } from '../../../apps/main/services/guide-service.js'
8
+ import { MegaNotFoundError } from 'mega-framework/errors'
9
+
10
+ /** ctx 최소 스텁 — GuideService 는 ctx.log.debug 만 쓴다. */
11
+ function makeCtx() {
12
+ return /** @type {any} */ ({ log: { debug() {} } })
13
+ }
14
+
15
+ describe('GuideService — listGuides', () => {
16
+ test('docs/guide 의 모든 가이드를 slug·title 로 나열한다(번호순)', async () => {
17
+ const svc = new GuideService(makeCtx())
18
+ const guides = await svc.listGuides()
19
+ expect(guides.length).toBeGreaterThanOrEqual(8)
20
+ // 파일명 오름차순 = 가이드 번호순.
21
+ const slugs = guides.map((g) => g.slug)
22
+ expect(slugs).toEqual([...slugs].sort())
23
+ // 각 항목은 비어있지 않은 slug·title 을 가진다.
24
+ for (const g of guides) {
25
+ expect(g.slug).toMatch(/^[a-z0-9-]+$/)
26
+ expect(g.title.length).toBeGreaterThan(0)
27
+ }
28
+ })
29
+
30
+ test('title 은 파일의 첫 H1 텍스트다', async () => {
31
+ const svc = new GuideService(makeCtx())
32
+ const guides = await svc.listGuides()
33
+ const cli = guides.find((g) => g.slug === '01-cli')
34
+ expect(cli?.title).toBe('CLI 사용법')
35
+ })
36
+ })
37
+
38
+ describe('GuideService — renderGuide', () => {
39
+ test('마크다운을 HTML 로 렌더하고 코드블록을 highlight.js 로 하이라이트한다', async () => {
40
+ const svc = new GuideService(makeCtx())
41
+ const guide = await svc.renderGuide('01-cli')
42
+ expect(guide.slug).toBe('01-cli')
43
+ // 코드블록은 hljs 클래스 + 언어 클래스를 받는다(서버사이드 하이라이트). 01-cli 는 bash 예시가 있다.
44
+ expect(guide.html).toContain('<code class="hljs language-bash">')
45
+ // highlight.js 가 토큰을 span 으로 감쌌다.
46
+ expect(guide.html).toContain('hljs-')
47
+ })
48
+
49
+ test('헤딩에 목차 앵커용 id 를 단다(github-slugger 규칙)', async () => {
50
+ const svc = new GuideService(makeCtx())
51
+ const guide = await svc.renderGuide('01-cli')
52
+ // "## mega g / generate" → 슬러그 "mega-g--generate"(목차 링크 #mega-g--generate 와 일치).
53
+ expect(guide.html).toContain('id="mega-g--generate"')
54
+ // "## 설치 + 빠른 시작" → "설치--빠른-시작"(한글 보존).
55
+ expect(guide.html).toContain('id="설치--빠른-시작"')
56
+ })
57
+
58
+ test('존재하지 않는 slug 는 MegaNotFoundError 를 던진다', async () => {
59
+ const svc = new GuideService(makeCtx())
60
+ await expect(svc.renderGuide('does-not-exist')).rejects.toBeInstanceOf(MegaNotFoundError)
61
+ })
62
+
63
+ test('경로 조작 slug 는 형식 검증에서 막힌다(MegaNotFoundError)', async () => {
64
+ const svc = new GuideService(makeCtx())
65
+ await expect(svc.renderGuide('../../etc/passwd')).rejects.toBeInstanceOf(MegaNotFoundError)
66
+ await expect(svc.renderGuide('01-cli/../02-router-controller')).rejects.toBeInstanceOf(MegaNotFoundError)
67
+ })
68
+ })
@@ -0,0 +1,30 @@
1
+ // @ts-check
2
+ /**
3
+ * hash.task(sha256Loop) 단위 테스트 — 워커 task 함수가 결정적(deterministic)으로 SHA-256 을 N회 체인 해시하는지
4
+ * 검증한다. 워커 경계(worker_threads) 없이 함수만 직접 호출해 로직을 본다.
5
+ */
6
+ import { describe, test, expect } from 'vitest'
7
+ import { createHash } from 'node:crypto'
8
+ import { sha256Loop } from '../../../apps/main/workers/hash.task.js'
9
+
10
+ /** @param {string} input @param {number} rounds */
11
+ function expected(input, rounds) {
12
+ let d = input
13
+ for (let i = 0; i < rounds; i++) d = createHash('sha256').update(d).digest('hex')
14
+ return d
15
+ }
16
+
17
+ describe('sha256Loop', () => {
18
+ test('rounds 회 체인 해시 결과가 결정적이다', async () => {
19
+ const r = await sha256Loop({ input: 'mega', rounds: 1 })
20
+ expect(r).toEqual({ digest: expected('mega', 1), rounds: 1 })
21
+ const r3 = await sha256Loop({ input: 'mega', rounds: 3 })
22
+ expect(r3.digest).toBe(expected('mega', 3))
23
+ expect(r3.rounds).toBe(3)
24
+ })
25
+
26
+ test('input 미지정 시 기본 입력(mega)을 쓴다', async () => {
27
+ const r = await sha256Loop({ rounds: 2 })
28
+ expect(r.digest).toBe(expected('mega', 2))
29
+ })
30
+ })
@@ -0,0 +1,88 @@
1
+ // @ts-check
2
+ /**
3
+ * JobsDemoService 단위 테스트 — events()/dlq() 읽기 로직을 가짜 redis/NATS 핸들로 검증한다. enqueue(실 NATS
4
+ * publish)와 워커 소비·재시도·DLQ 라우팅은 통합 검증(E2E)이 커버한다.
5
+ */
6
+ import { describe, test, expect } from 'vitest'
7
+ import { JobsDemoService } from '../../../apps/main/services/jobs-demo-service.js'
8
+ import { EmailJob } from '../../../apps/main/jobs/email-job.js'
9
+
10
+ /**
11
+ * @param {{ events?: string[], jsm?: any }} [opts]
12
+ */
13
+ function makeCtx({ events = [], jsm } = {}) {
14
+ const cache = {
15
+ native: {
16
+ /** @param {string} _k @param {number} start @param {number} stop */
17
+ async lrange(_k, start, stop) {
18
+ return events.slice(start, stop + 1)
19
+ },
20
+ },
21
+ }
22
+ const bus = { native: { jetstreamManager: async () => jsm } }
23
+ const ctx = {
24
+ log: { debug() {} },
25
+ cache: (/** @type {string} */ a) => (a === 'demo' ? cache : null),
26
+ bus: (/** @type {string} */ a) => (a === 'jobs' ? bus : null),
27
+ }
28
+ return { ctx }
29
+ }
30
+
31
+ /** code 프로퍼티를 단 에러(JetStream 404 모사). @param {string} code */
32
+ function apiError(code) {
33
+ const e = new Error(`api error ${code}`)
34
+ Object.assign(e, { code })
35
+ return e
36
+ }
37
+
38
+ describe('JobsDemoService.events', () => {
39
+ test('redis LIST 의 JSON 문자열들을 파싱해 돌려준다', async () => {
40
+ const raw = [JSON.stringify({ id: 'e1', status: 'sent', attempt: 1 })]
41
+ const { ctx } = makeCtx({ events: raw })
42
+ const svc = new JobsDemoService(/** @type {any} */ (ctx))
43
+ const out = await svc.events()
44
+ expect(out).toEqual([{ id: 'e1', status: 'sent', attempt: 1 }])
45
+ })
46
+ })
47
+
48
+ describe('JobsDemoService.dlq', () => {
49
+ test('DLQ 스트림 미존재(404) 시 빈 상태를 돌려준다', async () => {
50
+ const jsm = { streams: { info: async () => { throw apiError('404') } } }
51
+ const { ctx } = makeCtx({ jsm })
52
+ const svc = new JobsDemoService(/** @type {any} */ (ctx))
53
+ expect(await svc.dlq()).toEqual({ count: 0, latest: null })
54
+ })
55
+
56
+ test('메시지가 있으면 카운트 + 가장 최근 1건을 돌려준다', async () => {
57
+ const body = {
58
+ originalSubject: EmailJob.subject,
59
+ failedAt: '2026-06-07T00:00:00.000Z',
60
+ deliveryCount: 3,
61
+ error: { name: 'Error', message: 'boom' },
62
+ payload: { id: 'e3', mode: 'fail' },
63
+ }
64
+ const jsm = {
65
+ streams: {
66
+ info: async () => ({ state: { messages: 2 } }),
67
+ getMessage: async () => ({ json: () => body }),
68
+ },
69
+ }
70
+ const { ctx } = makeCtx({ jsm })
71
+ const svc = new JobsDemoService(/** @type {any} */ (ctx))
72
+ const out = await svc.dlq()
73
+ expect(out.count).toBe(2)
74
+ expect(out.latest).toEqual({
75
+ failedAt: body.failedAt,
76
+ deliveryCount: 3,
77
+ error: 'boom',
78
+ payload: { id: 'e3', mode: 'fail' },
79
+ })
80
+ })
81
+
82
+ test('info 가 404 가 아닌 에러면 전파한다(인프라 장애를 묻지 않음)', async () => {
83
+ const jsm = { streams: { info: async () => { throw apiError('503') } } }
84
+ const { ctx } = makeCtx({ jsm })
85
+ const svc = new JobsDemoService(/** @type {any} */ (ctx))
86
+ await expect(svc.dlq()).rejects.toThrow(/503/)
87
+ })
88
+ })