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.
Files changed (59) hide show
  1. package/package.json +1 -1
  2. package/sample/crud/.env +156 -8
  3. package/sample/crud/.env.example +153 -28
  4. package/sample/crud/mega.config.js +61 -2
  5. package/sample/crud/package.json +2 -2
  6. package/sample/crud/yarn.lock +1 -1
  7. package/src/cli/commands/new.js +115 -67
  8. package/src/cli/commands/scaffold.js +6 -12
  9. package/src/cli/index.js +133 -12
  10. package/src/core/boot.js +30 -1
  11. package/src/core/config-validator.js +25 -0
  12. package/src/core/mega-app.js +25 -21
  13. package/src/core/mega-cluster.js +50 -12
  14. package/src/core/scope-registry.js +0 -1
  15. package/src/lib/mega-logger.js +1 -1
  16. package/src/lib/mega-shutdown.js +51 -13
  17. package/sample/crud/test/apps/main/auth-flow.integration.test.js +0 -177
  18. package/sample/crud/test/apps/main/auth-service.test.js +0 -93
  19. package/sample/crud/test/apps/main/chat-channel.test.js +0 -149
  20. package/sample/crud/test/apps/main/cron-demo-service.test.js +0 -93
  21. package/sample/crud/test/apps/main/demo-flow.integration.test.js +0 -386
  22. package/sample/crud/test/apps/main/email-job.test.js +0 -76
  23. package/sample/crud/test/apps/main/guide-service.test.js +0 -68
  24. package/sample/crud/test/apps/main/hash-task.test.js +0 -30
  25. package/sample/crud/test/apps/main/jobs-demo-service.test.js +0 -88
  26. package/sample/crud/test/apps/main/logs-demo-service.test.js +0 -85
  27. package/sample/crud/test/apps/main/metrics-demo-service.test.js +0 -90
  28. package/sample/crud/test/apps/main/note-service.test.js +0 -68
  29. package/sample/crud/test/apps/main/perf-service.test.js +0 -121
  30. package/sample/crud/test/apps/main/perf.integration.test.js +0 -202
  31. package/sample/crud/test/apps/main/redis-demo-service.test.js +0 -98
  32. package/sample/crud/test/apps/main/tracing-demo-service.test.js +0 -90
  33. package/sample/crud/test/apps/main/upload-demo-service.test.js +0 -61
  34. package/sample/crud/test/apps/main/user-service.test.js +0 -65
  35. package/sample/crud/test/apps/main/ws-chat.integration.test.js +0 -233
  36. package/templates/project/app.config.tpl +0 -8
  37. package/templates/project/app.config.views.tpl +0 -37
  38. package/templates/project/ecosystem.config.tpl +0 -10
  39. package/templates/project/env.tpl +0 -12
  40. package/templates/project/gitignore.tpl +0 -8
  41. package/templates/project/locales/client/en.json.tpl +0 -3
  42. package/templates/project/locales/client/ko.json.tpl +0 -3
  43. package/templates/project/locales/server/en.json.tpl +0 -17
  44. package/templates/project/locales/server/ko.json.tpl +0 -17
  45. package/templates/project/mega.config.tpl +0 -11
  46. package/templates/project/package.tpl +0 -25
  47. package/templates/project/public/css/app.css +0 -101
  48. package/templates/project/public/js/app.js +0 -54
  49. package/templates/project/public/js/theme-init.js +0 -12
  50. package/templates/project/public/vendor/bootstrap/bootstrap.bundle.min.js +0 -7
  51. package/templates/project/public/vendor/bootstrap/bootstrap.min.css +0 -6
  52. package/templates/project/readme.tpl +0 -48
  53. package/templates/project/route.test.tpl +0 -13
  54. package/templates/project/route.test.views.tpl +0 -15
  55. package/templates/project/route.tpl +0 -10
  56. package/templates/project/route.views.tpl +0 -10
  57. package/templates/project/views/index.ejs.tpl +0 -58
  58. package/templates/project/views/layout.ejs.tpl +0 -73
  59. package/templates/project/vitest.config.tpl +0 -8
@@ -1,76 +0,0 @@
1
- // @ts-check
2
- /**
3
- * EmailJob 단위 테스트 — 'demo' 캐시 native(incr/expire/lpush/ltrim)를 가짜로 갈음해 NATS 없이 run() 의 세
4
- * 모드(ok/flaky/fail) 흐름과 이벤트 기록을 검증한다. 실 큐/재시도/DLQ 는 통합 검증(E2E)이 커버한다.
5
- */
6
- import { describe, test, expect, vi } from 'vitest'
7
- import { EmailJob } from '../../../apps/main/jobs/email-job.js'
8
-
9
- /** 시도 카운터(incr) + 이벤트 LIST 를 추적하는 가짜 ctx. */
10
- function makeCtx() {
11
- /** @type {Map<string, number>} */
12
- const counters = new Map()
13
- /** @type {string[]} */
14
- const events = []
15
- const native = {
16
- /** @param {string} k */
17
- async incr(k) {
18
- const n = (counters.get(k) ?? 0) + 1
19
- counters.set(k, n)
20
- return n
21
- },
22
- expire: vi.fn(async () => 1),
23
- /** @param {string} _k @param {string} v */
24
- async lpush(_k, v) {
25
- events.unshift(v)
26
- return events.length
27
- },
28
- ltrim: vi.fn(async () => 'OK'),
29
- }
30
- const ctx = { log: { debug() {} }, cache: (/** @type {string} */ a) => (a === 'demo' ? { native } : null) }
31
- return { ctx, events, native }
32
- }
33
-
34
- describe('EmailJob.run — ok 모드', () => {
35
- test('첫 시도에 sent 를 반환하고 sent 이벤트를 남긴다', async () => {
36
- const { ctx, events } = makeCtx()
37
- const job = new EmailJob()
38
- const r = await job.run({ id: 'e1', to: 'a@b.c', mode: 'ok' }, /** @type {any} */ (ctx))
39
- expect(r).toEqual({ id: 'e1', status: 'sent', attempt: 1 })
40
- expect(JSON.parse(events[0])).toMatchObject({ id: 'e1', status: 'sent', attempt: 1 })
41
- })
42
- })
43
-
44
- describe('EmailJob.run — flaky 모드', () => {
45
- test('1번째 시도는 throw(재시도 유발), 2번째 시도에 성공한다', async () => {
46
- const { ctx, events } = makeCtx()
47
- const job = new EmailJob()
48
- const payload = { id: 'e2', to: 'a@b.c', mode: /** @type {const} */ ('flaky') }
49
- await expect(job.run(payload, /** @type {any} */ (ctx))).rejects.toThrow(/transient failure on attempt 1/)
50
- expect(JSON.parse(events[0])).toMatchObject({ status: 'retry', attempt: 1 })
51
- // 같은 잡 재실행(재시도) → 시도 카운터 2 → 성공.
52
- const r = await job.run(payload, /** @type {any} */ (ctx))
53
- expect(r).toEqual({ id: 'e2', status: 'sent', attempt: 2 })
54
- expect(JSON.parse(events[0])).toMatchObject({ status: 'sent', attempt: 2 })
55
- })
56
- })
57
-
58
- describe('EmailJob.run — fail 모드', () => {
59
- test('매 시도 throw 하고 failed 이벤트를 남긴다(재시도 소진 시 DLQ 로 라우팅됨)', async () => {
60
- const { ctx, events } = makeCtx()
61
- const job = new EmailJob()
62
- const payload = { id: 'e3', to: 'a@b.c', mode: /** @type {const} */ ('fail') }
63
- await expect(job.run(payload, /** @type {any} */ (ctx))).rejects.toThrow(/permanent failure on attempt 1/)
64
- await expect(job.run(payload, /** @type {any} */ (ctx))).rejects.toThrow(/permanent failure on attempt 2/)
65
- expect(JSON.parse(events[0])).toMatchObject({ status: 'failed', attempt: 2 })
66
- })
67
- })
68
-
69
- describe('EmailJob 설정', () => {
70
- test('subject/bus/concurrency/retries 가 정본 값과 일치한다', () => {
71
- expect(EmailJob.subject).toBe('demo.email')
72
- expect(EmailJob.bus).toBe('jobs')
73
- expect(EmailJob.retries).toBe(2)
74
- expect(EmailJob.concurrency).toBe(2)
75
- })
76
- })
@@ -1,68 +0,0 @@
1
- // @ts-check
2
- /**
3
- * GuideService 단위 테스트 — 레포 루트 docs/guide 의 실제 마크다운을 읽어 목록·렌더를 검증한다.
4
- * 가이드 파일은 정적 자산이라 가짜를 두지 않고 실물을 읽는다(서버사이드 렌더 결과의 핵심 계약만 확인).
5
- */
6
- import { describe, test, expect } from 'vitest'
7
- import { GuideService } from '../../../apps/main/services/guide-service.js'
8
- import { MegaNotFoundError } from 'mega-framework/errors'
9
-
10
- /** ctx 최소 스텁 — GuideService 는 ctx.log.debug 만 쓴다. */
11
- function makeCtx() {
12
- return /** @type {any} */ ({ log: { debug() {} } })
13
- }
14
-
15
- describe('GuideService — listGuides', () => {
16
- test('docs/guide 의 모든 가이드를 slug·title 로 나열한다(번호순)', async () => {
17
- const svc = new GuideService(makeCtx())
18
- const guides = await svc.listGuides()
19
- expect(guides.length).toBeGreaterThanOrEqual(8)
20
- // 파일명 오름차순 = 가이드 번호순.
21
- const slugs = guides.map((g) => g.slug)
22
- expect(slugs).toEqual([...slugs].sort())
23
- // 각 항목은 비어있지 않은 slug·title 을 가진다.
24
- for (const g of guides) {
25
- expect(g.slug).toMatch(/^[a-z0-9-]+$/)
26
- expect(g.title.length).toBeGreaterThan(0)
27
- }
28
- })
29
-
30
- test('title 은 파일의 첫 H1 텍스트다', async () => {
31
- const svc = new GuideService(makeCtx())
32
- const guides = await svc.listGuides()
33
- const cli = guides.find((g) => g.slug === '01-cli')
34
- expect(cli?.title).toBe('CLI 사용법')
35
- })
36
- })
37
-
38
- describe('GuideService — renderGuide', () => {
39
- test('마크다운을 HTML 로 렌더하고 코드블록을 highlight.js 로 하이라이트한다', async () => {
40
- const svc = new GuideService(makeCtx())
41
- const guide = await svc.renderGuide('01-cli')
42
- expect(guide.slug).toBe('01-cli')
43
- // 코드블록은 hljs 클래스 + 언어 클래스를 받는다(서버사이드 하이라이트). 01-cli 는 bash 예시가 있다.
44
- expect(guide.html).toContain('<code class="hljs language-bash">')
45
- // highlight.js 가 토큰을 span 으로 감쌌다.
46
- expect(guide.html).toContain('hljs-')
47
- })
48
-
49
- test('헤딩에 목차 앵커용 id 를 단다(github-slugger 규칙)', async () => {
50
- const svc = new GuideService(makeCtx())
51
- const guide = await svc.renderGuide('01-cli')
52
- // "## mega g / generate" → 슬러그 "mega-g--generate"(목차 링크 #mega-g--generate 와 일치).
53
- expect(guide.html).toContain('id="mega-g--generate"')
54
- // "## 설치 + 빠른 시작" → "설치--빠른-시작"(한글 보존).
55
- expect(guide.html).toContain('id="설치--빠른-시작"')
56
- })
57
-
58
- test('존재하지 않는 slug 는 MegaNotFoundError 를 던진다', async () => {
59
- const svc = new GuideService(makeCtx())
60
- await expect(svc.renderGuide('does-not-exist')).rejects.toBeInstanceOf(MegaNotFoundError)
61
- })
62
-
63
- test('경로 조작 slug 는 형식 검증에서 막힌다(MegaNotFoundError)', async () => {
64
- const svc = new GuideService(makeCtx())
65
- await expect(svc.renderGuide('../../etc/passwd')).rejects.toBeInstanceOf(MegaNotFoundError)
66
- await expect(svc.renderGuide('01-cli/../02-router-controller')).rejects.toBeInstanceOf(MegaNotFoundError)
67
- })
68
- })
@@ -1,30 +0,0 @@
1
- // @ts-check
2
- /**
3
- * hash.task(sha256Loop) 단위 테스트 — 워커 task 함수가 결정적(deterministic)으로 SHA-256 을 N회 체인 해시하는지
4
- * 검증한다. 워커 경계(worker_threads) 없이 함수만 직접 호출해 로직을 본다.
5
- */
6
- import { describe, test, expect } from 'vitest'
7
- import { createHash } from 'node:crypto'
8
- import { sha256Loop } from '../../../apps/main/workers/hash.task.js'
9
-
10
- /** @param {string} input @param {number} rounds */
11
- function expected(input, rounds) {
12
- let d = input
13
- for (let i = 0; i < rounds; i++) d = createHash('sha256').update(d).digest('hex')
14
- return d
15
- }
16
-
17
- describe('sha256Loop', () => {
18
- test('rounds 회 체인 해시 결과가 결정적이다', async () => {
19
- const r = await sha256Loop({ input: 'mega', rounds: 1 })
20
- expect(r).toEqual({ digest: expected('mega', 1), rounds: 1 })
21
- const r3 = await sha256Loop({ input: 'mega', rounds: 3 })
22
- expect(r3.digest).toBe(expected('mega', 3))
23
- expect(r3.rounds).toBe(3)
24
- })
25
-
26
- test('input 미지정 시 기본 입력(mega)을 쓴다', async () => {
27
- const r = await sha256Loop({ rounds: 2 })
28
- expect(r.digest).toBe(expected('mega', 2))
29
- })
30
- })
@@ -1,88 +0,0 @@
1
- // @ts-check
2
- /**
3
- * JobsDemoService 단위 테스트 — events()/dlq() 읽기 로직을 가짜 redis/NATS 핸들로 검증한다. enqueue(실 NATS
4
- * publish)와 워커 소비·재시도·DLQ 라우팅은 통합 검증(E2E)이 커버한다.
5
- */
6
- import { describe, test, expect } from 'vitest'
7
- import { JobsDemoService } from '../../../apps/main/services/jobs-demo-service.js'
8
- import { EmailJob } from '../../../apps/main/jobs/email-job.js'
9
-
10
- /**
11
- * @param {{ events?: string[], jsm?: any }} [opts]
12
- */
13
- function makeCtx({ events = [], jsm } = {}) {
14
- const cache = {
15
- native: {
16
- /** @param {string} _k @param {number} start @param {number} stop */
17
- async lrange(_k, start, stop) {
18
- return events.slice(start, stop + 1)
19
- },
20
- },
21
- }
22
- const bus = { native: { jetstreamManager: async () => jsm } }
23
- const ctx = {
24
- log: { debug() {} },
25
- cache: (/** @type {string} */ a) => (a === 'demo' ? cache : null),
26
- bus: (/** @type {string} */ a) => (a === 'jobs' ? bus : null),
27
- }
28
- return { ctx }
29
- }
30
-
31
- /** code 프로퍼티를 단 에러(JetStream 404 모사). @param {string} code */
32
- function apiError(code) {
33
- const e = new Error(`api error ${code}`)
34
- Object.assign(e, { code })
35
- return e
36
- }
37
-
38
- describe('JobsDemoService.events', () => {
39
- test('redis LIST 의 JSON 문자열들을 파싱해 돌려준다', async () => {
40
- const raw = [JSON.stringify({ id: 'e1', status: 'sent', attempt: 1 })]
41
- const { ctx } = makeCtx({ events: raw })
42
- const svc = new JobsDemoService(/** @type {any} */ (ctx))
43
- const out = await svc.events()
44
- expect(out).toEqual([{ id: 'e1', status: 'sent', attempt: 1 }])
45
- })
46
- })
47
-
48
- describe('JobsDemoService.dlq', () => {
49
- test('DLQ 스트림 미존재(404) 시 빈 상태를 돌려준다', async () => {
50
- const jsm = { streams: { info: async () => { throw apiError('404') } } }
51
- const { ctx } = makeCtx({ jsm })
52
- const svc = new JobsDemoService(/** @type {any} */ (ctx))
53
- expect(await svc.dlq()).toEqual({ count: 0, latest: null })
54
- })
55
-
56
- test('메시지가 있으면 카운트 + 가장 최근 1건을 돌려준다', async () => {
57
- const body = {
58
- originalSubject: EmailJob.subject,
59
- failedAt: '2026-06-07T00:00:00.000Z',
60
- deliveryCount: 3,
61
- error: { name: 'Error', message: 'boom' },
62
- payload: { id: 'e3', mode: 'fail' },
63
- }
64
- const jsm = {
65
- streams: {
66
- info: async () => ({ state: { messages: 2 } }),
67
- getMessage: async () => ({ json: () => body }),
68
- },
69
- }
70
- const { ctx } = makeCtx({ jsm })
71
- const svc = new JobsDemoService(/** @type {any} */ (ctx))
72
- const out = await svc.dlq()
73
- expect(out.count).toBe(2)
74
- expect(out.latest).toEqual({
75
- failedAt: body.failedAt,
76
- deliveryCount: 3,
77
- error: 'boom',
78
- payload: { id: 'e3', mode: 'fail' },
79
- })
80
- })
81
-
82
- test('info 가 404 가 아닌 에러면 전파한다(인프라 장애를 묻지 않음)', async () => {
83
- const jsm = { streams: { info: async () => { throw apiError('503') } } }
84
- const { ctx } = makeCtx({ jsm })
85
- const svc = new JobsDemoService(/** @type {any} */ (ctx))
86
- await expect(svc.dlq()).rejects.toThrow(/503/)
87
- })
88
- })
@@ -1,85 +0,0 @@
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
- })
@@ -1,90 +0,0 @@
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
- })
@@ -1,68 +0,0 @@
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
- })
@@ -1,121 +0,0 @@
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
- })