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