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,90 @@
1
+ // @ts-check
2
+ /**
3
+ * TracingDemoService 단위 테스트 — zipkinBase 파생, 현재 trace 기록(있을 때만), snapshot 을 검증한다.
4
+ * MegaTracing(currentTraceIds/isEnabled)을 가짜로 갈음하고 redis native(lpush/ltrim/lrange)를 가짜로 둔다.
5
+ */
6
+ import { describe, test, expect, vi, beforeEach } from 'vitest'
7
+
8
+ vi.mock('mega-framework', async (importOriginal) => {
9
+ const actual = /** @type {any} */ (await importOriginal())
10
+ return {
11
+ ...actual,
12
+ MegaTracing: { ...actual.MegaTracing, currentTraceIds: vi.fn(), isEnabled: vi.fn() },
13
+ }
14
+ })
15
+
16
+ import { MegaTracing } from 'mega-framework'
17
+ import { TracingDemoService } from '../../../apps/main/services/tracing-demo-service.js'
18
+
19
+ function makeCtx() {
20
+ /** @type {Map<string, string[]>} */
21
+ const lists = new Map()
22
+ const native = {
23
+ async lpush(/** @type {string} */ k, /** @type {string} */ v) {
24
+ const arr = lists.get(k) ?? []
25
+ arr.unshift(v)
26
+ lists.set(k, arr)
27
+ return arr.length
28
+ },
29
+ ltrim: vi.fn(async (/** @type {string} */ k, /** @type {number} */ _s, /** @type {number} */ stop) => {
30
+ lists.set(k, (lists.get(k) ?? []).slice(0, stop + 1))
31
+ return 'OK'
32
+ }),
33
+ async lrange(/** @type {string} */ k, /** @type {number} */ s, /** @type {number} */ e) {
34
+ return (lists.get(k) ?? []).slice(s, e + 1)
35
+ },
36
+ }
37
+ const ctx = { log: { debug() {} }, cache: (/** @type {string} */ a) => (a === 'demo' ? { native } : null) }
38
+ return { ctx, lists }
39
+ }
40
+
41
+ beforeEach(() => {
42
+ vi.clearAllMocks()
43
+ })
44
+
45
+ describe('TracingDemoService — zipkinBase', () => {
46
+ test('MEGA_OTEL_ZIPKIN_API 에서 /api/v2 를 떼 UI 루트를 만든다', () => {
47
+ const prev = process.env.MEGA_OTEL_ZIPKIN_API
48
+ process.env.MEGA_OTEL_ZIPKIN_API = 'http://zip.example:9411/api/v2'
49
+ expect(TracingDemoService.zipkinBase()).toBe('http://zip.example:9411')
50
+ delete process.env.MEGA_OTEL_ZIPKIN_API
51
+ expect(TracingDemoService.zipkinBase()).toBe('http://localhost:9411')
52
+ if (prev !== undefined) process.env.MEGA_OTEL_ZIPKIN_API = prev
53
+ })
54
+ })
55
+
56
+ describe('TracingDemoService — record', () => {
57
+ test('trace_id 가 있으면 이력 머리에 기록하고 traceId 를 돌려준다', async () => {
58
+ vi.mocked(MegaTracing.currentTraceIds).mockReturnValue({ traceId: 'abc', spanId: 'def' })
59
+ const { ctx, lists } = makeCtx()
60
+ const svc = new TracingDemoService(/** @type {any} */ (ctx))
61
+ const id = await svc.record('GET /demo/tracing')
62
+ expect(id).toBe('abc')
63
+ const stored = (lists.get(TracingDemoService.RECENT_KEY) ?? []).map((s) => JSON.parse(s))
64
+ expect(stored[0]).toMatchObject({ traceId: 'abc', spanId: 'def', route: 'GET /demo/tracing' })
65
+ })
66
+
67
+ test('trace_id 가 없으면(트레이싱 OFF) no-op + null', async () => {
68
+ vi.mocked(MegaTracing.currentTraceIds).mockReturnValue(null)
69
+ const { ctx, lists } = makeCtx()
70
+ const svc = new TracingDemoService(/** @type {any} */ (ctx))
71
+ const id = await svc.record('x')
72
+ expect(id).toBeNull()
73
+ expect(lists.get(TracingDemoService.RECENT_KEY)).toBeUndefined()
74
+ })
75
+ })
76
+
77
+ describe('TracingDemoService — snapshot', () => {
78
+ test('enabled/current/recent/zipkinBase 를 돌려준다', async () => {
79
+ vi.mocked(MegaTracing.isEnabled).mockReturnValue(true)
80
+ vi.mocked(MegaTracing.currentTraceIds).mockReturnValue({ traceId: 't1', spanId: 's1' })
81
+ const { ctx } = makeCtx()
82
+ const svc = new TracingDemoService(/** @type {any} */ (ctx))
83
+ await svc.record('GET /demo/tracing')
84
+ const snap = await svc.snapshot()
85
+ expect(snap.enabled).toBe(true)
86
+ expect(snap.current).toEqual({ traceId: 't1', spanId: 's1' })
87
+ expect(snap.recent).toHaveLength(1)
88
+ expect(snap.zipkinBase).toMatch(/^https?:\/\//)
89
+ })
90
+ })
@@ -0,0 +1,61 @@
1
+ // @ts-check
2
+ /**
3
+ * UploadDemoService 단위 테스트 — saveUploads 결과 메타(파일명/크기/MIME)를 redis 이력에 기록하고 snapshot
4
+ * 으로 최신순 반환하는지 검증한다. redis native(lpush/ltrim/lrange)를 가짜로 둔다.
5
+ */
6
+ import { describe, test, expect, vi } from 'vitest'
7
+ import { UploadDemoService } from '../../../apps/main/services/upload-demo-service.js'
8
+
9
+ function makeCtx() {
10
+ /** @type {Map<string, string[]>} */
11
+ const lists = new Map()
12
+ const native = {
13
+ async lpush(/** @type {string} */ k, /** @type {string} */ v) {
14
+ const arr = lists.get(k) ?? []
15
+ arr.unshift(v)
16
+ lists.set(k, arr)
17
+ return arr.length
18
+ },
19
+ ltrim: vi.fn(async (/** @type {string} */ k, /** @type {number} */ _s, /** @type {number} */ stop) => {
20
+ lists.set(k, (lists.get(k) ?? []).slice(0, stop + 1))
21
+ return 'OK'
22
+ }),
23
+ async lrange(/** @type {string} */ k, /** @type {number} */ s, /** @type {number} */ e) {
24
+ return (lists.get(k) ?? []).slice(s, e + 1)
25
+ },
26
+ }
27
+ const ctx = { log: { debug() {} }, cache: (/** @type {string} */ a) => (a === 'demo' ? { native } : null) }
28
+ return { ctx, lists }
29
+ }
30
+
31
+ describe('UploadDemoService — record', () => {
32
+ test('파일별로 메타를 이력 머리에 쌓는다(최신이 앞)', async () => {
33
+ const { ctx, lists } = makeCtx()
34
+ const svc = new UploadDemoService(/** @type {any} */ (ctx))
35
+ await svc.record([
36
+ { filename: 'a.png', bytes: 100, mimetype: 'image/png' },
37
+ { filename: 'b.pdf', bytes: 200, mimetype: 'application/pdf' },
38
+ ])
39
+ const stored = (lists.get(UploadDemoService.RECENT_KEY) ?? []).map((s) => JSON.parse(s))
40
+ expect(stored[0]).toMatchObject({ filename: 'b.pdf', bytes: 200, mimetype: 'application/pdf' })
41
+ expect(stored[1]).toMatchObject({ filename: 'a.png' })
42
+ })
43
+
44
+ test('빈 배열은 no-op', async () => {
45
+ const { ctx, lists } = makeCtx()
46
+ const svc = new UploadDemoService(/** @type {any} */ (ctx))
47
+ await svc.record([])
48
+ expect(lists.get(UploadDemoService.RECENT_KEY)).toBeUndefined()
49
+ })
50
+ })
51
+
52
+ describe('UploadDemoService — snapshot', () => {
53
+ test('최근 업로드 목록을 최신순으로 돌려준다', async () => {
54
+ const { ctx } = makeCtx()
55
+ const svc = new UploadDemoService(/** @type {any} */ (ctx))
56
+ await svc.record([{ filename: 'a.txt', bytes: 10, mimetype: 'text/plain' }])
57
+ const snap = await svc.snapshot()
58
+ expect(snap.recent).toHaveLength(1)
59
+ expect(snap.recent[0]).toMatchObject({ filename: 'a.txt', bytes: 10, mimetype: 'text/plain' })
60
+ })
61
+ })
@@ -0,0 +1,65 @@
1
+ // @ts-check
2
+ /**
3
+ * UserService 단위 테스트 — 모델(static)을 스파이로 갈음해 DB 없이 비즈니스 로직(검증·404·409 매핑)을 검증.
4
+ * 실 postgres CRUD 는 레포 E2E(scaffold/sample 검증)에서 확인.
5
+ */
6
+ import { describe, test, expect, vi, afterEach } from 'vitest'
7
+ import { UserService } from '../../../apps/main/services/user-service.js'
8
+ import { User } from '../../../apps/main/models/user.js'
9
+
10
+ /** @returns {UserService} */
11
+ function makeService() {
12
+ return new UserService(/** @type {any} */ ({ log: { debug() {} } }))
13
+ }
14
+
15
+ afterEach(() => vi.restoreAllMocks())
16
+
17
+ describe('UserService', () => {
18
+ test('list → User.list 위임', async () => {
19
+ vi.spyOn(User, 'list').mockResolvedValue([/** @type {any} */ ({ id: 1, name: 'a', email: 'a@b.c' })])
20
+ expect(await makeService().list()).toEqual([{ id: 1, name: 'a', email: 'a@b.c' }])
21
+ })
22
+
23
+ test('get — 존재하면 반환', async () => {
24
+ vi.spyOn(User, 'findById').mockResolvedValue(/** @type {any} */ ({ id: 7 }))
25
+ expect(await makeService().get(7)).toEqual({ id: 7 })
26
+ })
27
+
28
+ test('get — 없으면 MegaNotFoundError(404)', async () => {
29
+ vi.spyOn(User, 'findById').mockResolvedValue(null)
30
+ await expect(makeService().get(99)).rejects.toMatchObject({ status: 404, code: 'user.not_found' })
31
+ })
32
+
33
+ test('create — name/email 누락 시 MegaValidationError(400)', async () => {
34
+ await expect(makeService().create({ name: '', email: '' })).rejects.toMatchObject({ status: 400, code: 'user.invalid' })
35
+ })
36
+
37
+ test('create — 정상 생성', async () => {
38
+ const spy = vi.spyOn(User, 'create').mockResolvedValue(/** @type {any} */ ({ id: 1, name: 'a', email: 'a@b.c' }))
39
+ const r = await makeService().create({ name: ' a ', email: ' a@b.c ' })
40
+ expect(r).toEqual({ id: 1, name: 'a', email: 'a@b.c' })
41
+ expect(spy).toHaveBeenCalledWith({ name: 'a', email: 'a@b.c' }) // trim 적용
42
+ })
43
+
44
+ test('create — postgres 23505 → MegaConflictError(409)', async () => {
45
+ vi.spyOn(User, 'create').mockRejectedValue(Object.assign(new Error('dup'), { code: '23505' }))
46
+ await expect(makeService().create({ name: 'a', email: 'a@b.c' })).rejects.toMatchObject({ status: 409, code: 'user.email_taken' })
47
+ })
48
+
49
+ test('update — 없으면 404', async () => {
50
+ vi.spyOn(User, 'update').mockResolvedValue(null)
51
+ await expect(makeService().update(99, { name: 'x' })).rejects.toMatchObject({ status: 404 })
52
+ })
53
+
54
+ test('update — postgres 23505 → MegaConflictError(409)', async () => {
55
+ vi.spyOn(User, 'update').mockRejectedValue(Object.assign(new Error('dup'), { code: '23505' }))
56
+ await expect(makeService().update(1, { email: 'taken@b.c' })).rejects.toMatchObject({ status: 409, code: 'user.email_taken' })
57
+ })
58
+
59
+ test('remove — 없으면 404, 있으면 deleted', async () => {
60
+ const spy = vi.spyOn(User, 'remove').mockResolvedValue(false)
61
+ await expect(makeService().remove(99)).rejects.toMatchObject({ status: 404 })
62
+ spy.mockResolvedValue(true)
63
+ expect(await makeService().remove(1)).toEqual({ deleted: true })
64
+ })
65
+ })
@@ -0,0 +1,232 @@
1
+ // @ts-check
2
+ /**
3
+ * WS 채팅 + ASP 통합 테스트(ADR-158) — 실 sample/crud 부팅(listen) + ASP `ws` 클라이언트.
4
+ *
5
+ * 검증:
6
+ * - WS upgrade 세션 인증(makeWsRequireAuth/readSession) — 비로그인 401, 로그인 통과.
7
+ * - ASP E: 프레임 round-trip — 클라가 envelope `{v,id,type,ts,payload}` 를 wsEncrypt(E:) 로 보내고
8
+ * 서버가 복호→dispatch→E: 로 broadcast. (이 ws 클라의 wire 는 WASM MegaSocket `protocol:'envelope'`
9
+ * 모드와 byte 동일 — frame_encrypt==wsEncrypt, envelope 동일, ADR-160.)
10
+ * - broadcast — 두 로그인 클라가 같은 채널에서 서로의 메시지를 받는다.
11
+ *
12
+ * 인프라(pg·redis·mongo) + ASP_MASTER_SECRET env 가 없으면 통째로 skip.
13
+ */
14
+ import { describe, test, expect, beforeAll, afterAll } from 'vitest'
15
+ import { WebSocket } from 'ws'
16
+ import { bootApp, MegaShutdown, createWsMessage } from 'mega-framework'
17
+ import { MegaAspCrypto } from 'mega-framework/lib'
18
+ import { fileURLToPath } from 'node:url'
19
+ import { dirname, resolve } from 'node:path'
20
+ import { User } from '../../../apps/main/models/user.js'
21
+ import { closeChatBus, ROSTER_KEY } from '../../../apps/main/channels/chat-bus.js'
22
+
23
+ const { wsEncrypt, wsDecrypt } = MegaAspCrypto
24
+ const PROJECT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
25
+
26
+ const WS_PATH = '/ws/chat'
27
+ const HOSTNAME = 'localhost' // Host → 호스트 라우팅 + ASP domain 유도값(둘 다 'localhost').
28
+ const UA = 'MegaChatTest/1.0' // ASP 키 유도용 — 연결 헤더와 wsEncrypt 가 동일해야 복호 성공.
29
+ const SECRET = process.env.ASP_MASTER_SECRET ?? ''
30
+
31
+ const hasInfra = Boolean(
32
+ process.env.DATABASE_URL &&
33
+ process.env.REDIS_SESSION_URL &&
34
+ process.env.REDIS_RATE_URL &&
35
+ process.env.REDIS_DEMO_URL &&
36
+ process.env.MONGO_URL &&
37
+ process.env.SESSION_SECRET &&
38
+ SECRET,
39
+ )
40
+ const d = hasInfra ? describe : describe.skip
41
+
42
+ /** set-cookie → 쿠키 jar. @param {any} res @param {Record<string,string>} jar */
43
+ function applyCookies(res, jar) {
44
+ const raw = res.headers['set-cookie']
45
+ const arr = Array.isArray(raw) ? raw : raw ? [raw] : []
46
+ for (const c of arr) {
47
+ const pair = String(c).split(';')[0]
48
+ const eq = pair.indexOf('=')
49
+ if (eq === -1) continue
50
+ const name = pair.slice(0, eq).trim()
51
+ const val = pair.slice(eq + 1).trim()
52
+ if (val === '') delete jar[name]
53
+ else jar[name] = val
54
+ }
55
+ }
56
+
57
+ /** @param {Record<string,string>} jar @returns {string} */
58
+ const cookieHeader = (jar) => Object.entries(jar).map(([k, v]) => `${k}=${v}`).join('; ')
59
+
60
+ /** @param {string} html @returns {string} */
61
+ function csrfFrom(html) {
62
+ const m = /name="_csrf" value="([^"]+)"/.exec(html)
63
+ if (!m) throw new Error('csrf token not found')
64
+ return m[1]
65
+ }
66
+
67
+ /** @param {Record<string,string>} fields @returns {string} */
68
+ const form = (fields) =>
69
+ Object.entries(fields).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&')
70
+
71
+ /** 회원가입(→자동 로그인)으로 세션 쿠키 jar 확보. @param {any} fastify @param {string} email @returns {Promise<Record<string,string>>} */
72
+ async function registerUser(fastify, email) {
73
+ const jar = {}
74
+ const reg = await fastify.inject({ method: 'GET', url: '/register' })
75
+ applyCookies(reg, jar)
76
+ const done = await fastify.inject({
77
+ method: 'POST',
78
+ url: '/register',
79
+ headers: { cookie: cookieHeader(jar), 'content-type': 'application/x-www-form-urlencoded' },
80
+ payload: form({ _csrf: csrfFrom(reg.body), name: `User ${email}`, email, password: 'secret-pass-123' }),
81
+ })
82
+ applyCookies(done, jar)
83
+ return jar
84
+ }
85
+
86
+ /**
87
+ * ASP envelope ws 클라이언트(WASM `protocol:'envelope'` 모드 byte-equivalent).
88
+ * @param {number} port @param {Record<string,string>} jar
89
+ * @returns {Promise<any>}
90
+ */
91
+ function openClient(port, jar) {
92
+ const url = `ws://${HOSTNAME}:${port}${WS_PATH}`
93
+ const socket = /** @type {any} */ (
94
+ new WebSocket(url, /** @type {any} */ ({ headers: { 'user-agent': UA, cookie: cookieHeader(jar) } }))
95
+ )
96
+ /** @type {object[]} */ const queue = []
97
+ /** @type {Array<(m: object) => void>} */ const waiters = []
98
+ socket.lastEncrypted = false
99
+ socket.on('message', (/** @type {Buffer} */ data) => {
100
+ const frame = data.toString('utf8')
101
+ socket.lastEncrypted = frame.startsWith('E:')
102
+ const plain = frame.startsWith('E:')
103
+ ? wsDecrypt(SECRET, HOSTNAME, WS_PATH, UA, frame.slice(2))
104
+ : frame.startsWith('P:')
105
+ ? frame.slice(2)
106
+ : frame
107
+ const msg = JSON.parse(plain)
108
+ const w = waiters.shift()
109
+ if (w) w(msg)
110
+ else queue.push(msg)
111
+ })
112
+ /** 다음 메시지 1건(평문 envelope). */
113
+ socket.next = () =>
114
+ new Promise((res) => {
115
+ const m = queue.shift()
116
+ if (m !== undefined) res(m)
117
+ else waiters.push(res)
118
+ })
119
+ /** type 이 t 인 다음 메시지까지 소비. @param {string} t */
120
+ socket.nextOf = async (t) => {
121
+ for (let i = 0; i < 20; i++) {
122
+ const m = await socket.next()
123
+ if (m.type === t) return m
124
+ }
125
+ throw new Error(`message type '${t}' not received`)
126
+ }
127
+ /** E: 암호 envelope 송신. @param {string} type @param {object} payload */
128
+ socket.sendChat = (type, payload) => {
129
+ const env = createWsMessage({ type, payload })
130
+ socket.send(`E:${wsEncrypt(SECRET, HOSTNAME, WS_PATH, UA, JSON.stringify(env))}`)
131
+ }
132
+ return new Promise((res, rej) => {
133
+ socket.once('open', () => res(socket))
134
+ socket.once('error', rej)
135
+ })
136
+ }
137
+
138
+ d('WS 채팅 + ASP E2E — sample/crud 실 인프라 (ADR-158)', () => {
139
+ /** @type {Awaited<ReturnType<typeof bootApp>>} */ let boot
140
+ /** @type {any} */ let fastify
141
+ /** @type {number} */ let port
142
+ /** @type {Record<string,string>} */ let jarA
143
+ /** @type {Record<string,string>} */ let jarB
144
+ const emailA = `ws-a-${Date.now()}@example.com`
145
+ const emailB = `ws-b-${Date.now()}@example.com`
146
+
147
+ beforeAll(async () => {
148
+ MegaShutdown._reset()
149
+ boot = await bootApp(PROJECT, { listen: true, port: 0, host: '127.0.0.1' })
150
+ const app = boot.megaApps.find((a) => a.name === 'main')
151
+ fastify = app?.fastify
152
+ port = /** @type {any} */ (boot.server)._httpServer.address().port
153
+ await User.query(
154
+ '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())',
155
+ )
156
+ await User.query('ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash TEXT')
157
+ await User.query('ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ')
158
+ await boot.ctx.cache('demo').del(ROSTER_KEY) // 결정적 접속자 명단.
159
+ jarA = await registerUser(fastify, emailA)
160
+ jarB = await registerUser(fastify, emailB)
161
+ })
162
+
163
+ afterAll(async () => {
164
+ if (!boot) return
165
+ await closeChatBus() // redis pub/sub 구독 연결 정리(이벤트루프 누수 방지).
166
+ await User.query('DELETE FROM users WHERE email = ANY($1)', [[emailA, emailB]]).catch(() => {})
167
+ await boot.ctx.cache('demo').del(ROSTER_KEY).catch(() => {})
168
+ await boot.server.close().catch(() => {})
169
+ const app = boot.megaApps.find((a) => a.name === 'main')
170
+ await app?.sessionStore?.disconnect().catch(() => {})
171
+ await boot.ctx.cache('rate').disconnect().catch(() => {})
172
+ await boot.ctx.cache('demo').disconnect().catch(() => {})
173
+ await boot.ctx.db('mongo').disconnect().catch(() => {})
174
+ await boot.ctx.db('primary').disconnect().catch(() => {})
175
+ MegaShutdown._reset()
176
+ })
177
+
178
+ test('비로그인 WS upgrade 는 401 로 거부된다', async () => {
179
+ const url = `ws://${HOSTNAME}:${port}${WS_PATH}`
180
+ const status = await new Promise((res, rej) => {
181
+ const s = new WebSocket(url, /** @type {any} */ ({ headers: { 'user-agent': UA } }))
182
+ s.on('unexpected-response', (_req, response) => {
183
+ res(response.statusCode)
184
+ s.terminate()
185
+ })
186
+ s.on('open', () => {
187
+ s.close()
188
+ rej(new Error('로그인 없이 연결되면 안 됨'))
189
+ })
190
+ s.on('error', () => {}) // unexpected-response 뒤 따라오는 소켓 에러는 무시.
191
+ })
192
+ expect(status).toBe(401)
193
+ })
194
+
195
+ test('로그인 클라는 연결 직후 ASP E: 로 chat.history 를 받는다', async () => {
196
+ const a = await openClient(port, jarA)
197
+ try {
198
+ const history = await a.nextOf('chat.history')
199
+ expect(a.lastEncrypted).toBe(true) // 수신 프레임이 E:(암호화) 였음.
200
+ expect(history.payload.me.userId).toBeTruthy()
201
+ expect(Array.isArray(history.payload.items)).toBe(true)
202
+ expect(history.payload.online).toBeGreaterThanOrEqual(1)
203
+ // roster(cluster-wide redis HASH) 에 내 이름이 있고, 워커 PID 가 실린다.
204
+ expect(history.payload.members).toContain(history.payload.me.userName)
205
+ expect(typeof history.payload.workerPid).toBe('number')
206
+ } finally {
207
+ a.close()
208
+ }
209
+ })
210
+
211
+ test('broadcast — A 가 보낸 메시지를 A(echo)와 B 가 모두 ASP E: 로 받는다', async () => {
212
+ const b = await openClient(port, jarB)
213
+ await b.nextOf('chat.history')
214
+ const a = await openClient(port, jarA)
215
+ await a.nextOf('chat.history')
216
+ // A 입장 → B 는 presence(join) 수신.
217
+ const join = await b.nextOf('chat.presence')
218
+ expect(join.payload.event).toBe('join')
219
+
220
+ const text = `hello-${Date.now()}`
221
+ a.sendChat('chat.send', { text })
222
+
223
+ const onA = await a.nextOf('chat.msg')
224
+ const onB = await b.nextOf('chat.msg')
225
+ expect(onA.payload.text).toBe(text)
226
+ expect(onB.payload.text).toBe(text)
227
+ expect(a.lastEncrypted && b.lastEncrypted).toBe(true) // 양쪽 모두 E: 암호 수신.
228
+
229
+ a.close()
230
+ b.close()
231
+ })
232
+ })
@@ -0,0 +1,8 @@
1
+ // @ts-check
2
+ import { defineConfig } from 'vitest/config'
3
+
4
+ export default defineConfig({
5
+ test: {
6
+ include: ['test/**/*.test.js', 'apps/**/*.test.js', 'shared/**/*.test.js'],
7
+ },
8
+ })