mega-framework 0.1.4 → 0.1.6
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/package.json +1 -1
- package/sample/crud/.env +156 -8
- package/sample/crud/.env.example +153 -28
- package/sample/crud/mega.config.js +61 -2
- package/sample/crud/package.json +2 -2
- package/sample/crud/yarn.lock +1 -1
- package/src/cli/commands/new.js +115 -67
- package/src/cli/commands/scaffold.js +6 -12
- package/src/cli/index.js +133 -12
- package/src/core/boot.js +30 -1
- package/src/core/config-validator.js +25 -0
- package/src/core/mega-app.js +25 -21
- package/src/core/mega-cluster.js +50 -12
- package/src/core/scope-registry.js +0 -1
- package/src/lib/mega-logger.js +1 -1
- package/src/lib/mega-shutdown.js +51 -13
- package/sample/crud/test/apps/main/auth-flow.integration.test.js +0 -177
- package/sample/crud/test/apps/main/auth-service.test.js +0 -93
- package/sample/crud/test/apps/main/chat-channel.test.js +0 -149
- package/sample/crud/test/apps/main/cron-demo-service.test.js +0 -93
- package/sample/crud/test/apps/main/demo-flow.integration.test.js +0 -386
- package/sample/crud/test/apps/main/email-job.test.js +0 -76
- package/sample/crud/test/apps/main/guide-service.test.js +0 -68
- package/sample/crud/test/apps/main/hash-task.test.js +0 -30
- package/sample/crud/test/apps/main/jobs-demo-service.test.js +0 -88
- package/sample/crud/test/apps/main/logs-demo-service.test.js +0 -85
- package/sample/crud/test/apps/main/metrics-demo-service.test.js +0 -90
- package/sample/crud/test/apps/main/note-service.test.js +0 -68
- package/sample/crud/test/apps/main/perf-service.test.js +0 -121
- package/sample/crud/test/apps/main/perf.integration.test.js +0 -202
- package/sample/crud/test/apps/main/redis-demo-service.test.js +0 -98
- package/sample/crud/test/apps/main/tracing-demo-service.test.js +0 -90
- package/sample/crud/test/apps/main/upload-demo-service.test.js +0 -61
- package/sample/crud/test/apps/main/user-service.test.js +0 -65
- package/sample/crud/test/apps/main/ws-chat.integration.test.js +0 -233
- package/templates/project/app.config.tpl +0 -8
- package/templates/project/app.config.views.tpl +0 -37
- package/templates/project/ecosystem.config.tpl +0 -10
- package/templates/project/env.tpl +0 -12
- package/templates/project/gitignore.tpl +0 -8
- package/templates/project/locales/client/en.json.tpl +0 -3
- package/templates/project/locales/client/ko.json.tpl +0 -3
- package/templates/project/locales/server/en.json.tpl +0 -17
- package/templates/project/locales/server/ko.json.tpl +0 -17
- package/templates/project/mega.config.tpl +0 -11
- package/templates/project/package.tpl +0 -25
- package/templates/project/public/css/app.css +0 -101
- package/templates/project/public/js/app.js +0 -54
- package/templates/project/public/js/theme-init.js +0 -12
- package/templates/project/public/vendor/bootstrap/bootstrap.bundle.min.js +0 -7
- package/templates/project/public/vendor/bootstrap/bootstrap.min.css +0 -6
- package/templates/project/readme.tpl +0 -48
- package/templates/project/route.test.tpl +0 -13
- package/templates/project/route.test.views.tpl +0 -15
- package/templates/project/route.tpl +0 -10
- package/templates/project/route.views.tpl +0 -10
- package/templates/project/views/index.ejs.tpl +0 -58
- package/templates/project/views/layout.ejs.tpl +0 -73
- package/templates/project/vitest.config.tpl +0 -8
|
@@ -1,202 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,98 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,90 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,61 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,65 +0,0 @@
|
|
|
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
|
-
})
|