@dssp/dkpi 1.0.0-alpha.53 → 1.0.0-alpha.55

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 (28) hide show
  1. package/KPI-STATISTICS-SERVICE.md +233 -0
  2. package/dist-client/components/kpi-single-boxplot-chart.js +109 -111
  3. package/dist-client/components/kpi-single-boxplot-chart.js.map +1 -1
  4. package/dist-client/pages/sv-project-completed-list.d.ts +3 -0
  5. package/dist-client/pages/sv-project-completed-list.js +70 -11
  6. package/dist-client/pages/sv-project-completed-list.js.map +1 -1
  7. package/dist-client/pages/sv-project-list.d.ts +3 -0
  8. package/dist-client/pages/sv-project-list.js +69 -11
  9. package/dist-client/pages/sv-project-list.js.map +1 -1
  10. package/dist-client/tsconfig.tsbuildinfo +1 -1
  11. package/dist-server/service/index.d.ts +1 -3
  12. package/dist-server/service/index.js +3 -4
  13. package/dist-server/service/index.js.map +1 -1
  14. package/dist-server/service/kpi-stat/index.d.ts +4 -0
  15. package/dist-server/service/kpi-stat/index.js +8 -0
  16. package/dist-server/service/kpi-stat/index.js.map +1 -0
  17. package/dist-server/service/kpi-stat/kpi-stat-query.d.ts +8 -0
  18. package/dist-server/service/kpi-stat/kpi-stat-query.js +225 -0
  19. package/dist-server/service/kpi-stat/kpi-stat-query.js.map +1 -0
  20. package/dist-server/service/kpi-stat/kpi-stat-types.d.ts +20 -0
  21. package/dist-server/service/kpi-stat/kpi-stat-types.js +78 -0
  22. package/dist-server/service/kpi-stat/kpi-stat-types.js.map +1 -0
  23. package/dist-server/tsconfig.tsbuildinfo +1 -1
  24. package/kpi-module-service-tests.md +1286 -0
  25. package/kpi-module-test-report.md +676 -0
  26. package/kpi-module-unit-test-detailed-report.md +925 -0
  27. package/kpi-module-unit-tests-detailed.md +1452 -0
  28. package/package.json +3 -3
@@ -0,0 +1,1286 @@
1
+ # KPI 모듈 서비스 레이어 테스트
2
+
3
+ ## 4. KPI 서비스 레이어 테스트 (kpi-service.spec.ts)
4
+
5
+ ```typescript
6
+ import { describe, it, expect, beforeEach, jest } from '@jest/globals'
7
+ import { Repository } from 'typeorm'
8
+ import { KpiService } from '../server/service/kpi/kpi-service'
9
+ import { Kpi, KpiStatus, KpiVizType } from '../server/service/kpi/kpi'
10
+ import { KpiHistory } from '../server/service/kpi/kpi-history'
11
+ import { Domain } from '@things-factory/shell'
12
+ import { User } from '@things-factory/auth-base'
13
+
14
+ // Mock 인터페이스
15
+ interface MockRepository<T> extends Partial<Repository<T>> {
16
+ find: jest.Mock
17
+ findOne: jest.Mock
18
+ save: jest.Mock
19
+ delete: jest.Mock
20
+ softDelete: jest.Mock
21
+ count: jest.Mock
22
+ createQueryBuilder: jest.Mock
23
+ }
24
+
25
+ describe('KPI Service Layer Tests', () => {
26
+ let kpiService: KpiService
27
+ let mockKpiRepository: MockRepository<Kpi>
28
+ let mockKpiHistoryRepository: MockRepository<KpiHistory>
29
+ let mockDomainRepository: MockRepository<Domain>
30
+
31
+ let testDomain: Domain
32
+ let testUser: User
33
+
34
+ beforeEach(() => {
35
+ // Mock 리포지토리 생성
36
+ mockKpiRepository = {
37
+ find: jest.fn(),
38
+ findOne: jest.fn(),
39
+ save: jest.fn(),
40
+ delete: jest.fn(),
41
+ softDelete: jest.fn(),
42
+ count: jest.fn(),
43
+ createQueryBuilder: jest.fn()
44
+ }
45
+
46
+ mockKpiHistoryRepository = {
47
+ find: jest.fn(),
48
+ findOne: jest.fn(),
49
+ save: jest.fn(),
50
+ delete: jest.fn(),
51
+ softDelete: jest.fn(),
52
+ count: jest.fn(),
53
+ createQueryBuilder: jest.fn()
54
+ }
55
+
56
+ mockDomainRepository = {
57
+ find: jest.fn(),
58
+ findOne: jest.fn(),
59
+ save: jest.fn(),
60
+ delete: jest.fn(),
61
+ softDelete: jest.fn(),
62
+ count: jest.fn(),
63
+ createQueryBuilder: jest.fn()
64
+ }
65
+
66
+ // 서비스 생성 (의존성 주입)
67
+ kpiService = new KpiService(
68
+ mockKpiRepository as Repository<Kpi>,
69
+ mockKpiHistoryRepository as Repository<KpiHistory>,
70
+ mockDomainRepository as Repository<Domain>
71
+ )
72
+
73
+ // 테스트용 데이터
74
+ testDomain = {
75
+ id: 'domain-123',
76
+ name: 'TEST_DOMAIN',
77
+ description: 'Test Domain'
78
+ } as Domain
79
+
80
+ testUser = {
81
+ id: 'user-123',
82
+ name: 'Test User',
83
+ email: 'test@example.com',
84
+ domain: testDomain
85
+ } as User
86
+ })
87
+
88
+ describe('KPI 생성 서비스 테스트', () => {
89
+ it('새로운 KPI 생성이 성공해야 한다', async () => {
90
+ // Given
91
+ const newKpiData = {
92
+ name: '안전사고율',
93
+ description: '월별 안전사고 발생률',
94
+ formula: 'accident_count / total_hours * 1000',
95
+ isLeaf: true,
96
+ active: true,
97
+ state: KpiStatus.DRAFT,
98
+ vizType: KpiVizType.GAUGE,
99
+ vizMeta: { unit: '건/천시간', min: 0, max: 10 }
100
+ }
101
+
102
+ const expectedKpi = {
103
+ id: 'kpi-123',
104
+ ...newKpiData,
105
+ version: 1,
106
+ domain: testDomain,
107
+ creator: testUser,
108
+ createdAt: new Date(),
109
+ updatedAt: new Date()
110
+ } as Kpi
111
+
112
+ mockKpiRepository.save.mockResolvedValue(expectedKpi)
113
+
114
+ // When
115
+ const result = await kpiService.createKpi(newKpiData, testUser, testDomain)
116
+
117
+ // Then
118
+ expect(result).toEqual(expectedKpi)
119
+ expect(mockKpiRepository.save).toHaveBeenCalledWith(
120
+ expect.objectContaining({
121
+ name: '안전사고율',
122
+ formula: 'accident_count / total_hours * 1000',
123
+ version: 1,
124
+ domain: testDomain,
125
+ creator: testUser
126
+ })
127
+ )
128
+ })
129
+
130
+ it('부모 KPI가 있는 하위 KPI 생성이 성공해야 한다', async () => {
131
+ // Given
132
+ const parentKpi = {
133
+ id: 'parent-kpi-123',
134
+ name: '전체 안전 지수',
135
+ isLeaf: false
136
+ } as Kpi
137
+
138
+ const childKpiData = {
139
+ name: '안전사고율',
140
+ parent: { id: parentKpi.id },
141
+ isLeaf: true,
142
+ formula: 'accident_count / total_hours'
143
+ }
144
+
145
+ mockKpiRepository.findOne.mockResolvedValue(parentKpi)
146
+ mockKpiRepository.save.mockResolvedValue({
147
+ id: 'child-kpi-123',
148
+ ...childKpiData,
149
+ parent: parentKpi,
150
+ domain: testDomain,
151
+ creator: testUser
152
+ } as Kpi)
153
+
154
+ // When
155
+ const result = await kpiService.createKpi(childKpiData, testUser, testDomain)
156
+
157
+ // Then
158
+ expect(result.parent).toEqual(parentKpi)
159
+ expect(mockKpiRepository.findOne).toHaveBeenCalledWith({
160
+ where: { id: parentKpi.id, domain: { id: testDomain.id } }
161
+ })
162
+ })
163
+
164
+ it('존재하지 않는 부모 KPI 참조 시 에러가 발생해야 한다', async () => {
165
+ // Given
166
+ const invalidChildKpiData = {
167
+ name: '잘못된 하위 KPI',
168
+ parent: { id: 'non-existent-parent' },
169
+ isLeaf: true
170
+ }
171
+
172
+ mockKpiRepository.findOne.mockResolvedValue(null)
173
+
174
+ // When & Then
175
+ await expect(kpiService.createKpi(invalidChildKpiData, testUser, testDomain))
176
+ .rejects.toThrow('Parent KPI not found: non-existent-parent')
177
+ })
178
+
179
+ it('중복된 KPI 이름으로 생성 시 에러가 발생해야 한다', async () => {
180
+ // Given
181
+ const duplicateKpiData = {
182
+ name: '중복 KPI',
183
+ isLeaf: true
184
+ }
185
+
186
+ mockKpiRepository.findOne.mockResolvedValue({
187
+ id: 'existing-kpi-123',
188
+ name: '중복 KPI'
189
+ } as Kpi)
190
+
191
+ // When & Then
192
+ await expect(kpiService.createKpi(duplicateKpiData, testUser, testDomain))
193
+ .rejects.toThrow('KPI with name "중복 KPI" already exists in this domain')
194
+ })
195
+ })
196
+
197
+ describe('KPI 수정 서비스 테스트', () => {
198
+ it('DRAFT 상태 KPI 수정이 성공해야 한다', async () => {
199
+ // Given
200
+ const existingKpi = {
201
+ id: 'kpi-123',
202
+ name: '기존 KPI',
203
+ formula: 'old_formula',
204
+ state: KpiStatus.DRAFT,
205
+ version: 1,
206
+ domain: testDomain
207
+ } as Kpi
208
+
209
+ const updateData = {
210
+ name: '수정된 KPI',
211
+ formula: 'new_formula',
212
+ description: '수정된 설명'
213
+ }
214
+
215
+ const updatedKpi = {
216
+ ...existingKpi,
217
+ ...updateData,
218
+ version: 2,
219
+ updatedAt: new Date()
220
+ } as Kpi
221
+
222
+ mockKpiRepository.findOne.mockResolvedValue(existingKpi)
223
+ mockKpiRepository.save.mockResolvedValue(updatedKpi)
224
+
225
+ // When
226
+ const result = await kpiService.updateKpi('kpi-123', updateData, testUser, testDomain)
227
+
228
+ // Then
229
+ expect(result.name).toBe('수정된 KPI')
230
+ expect(result.formula).toBe('new_formula')
231
+ expect(result.version).toBe(2)
232
+ expect(mockKpiRepository.save).toHaveBeenCalledWith(
233
+ expect.objectContaining({
234
+ name: '수정된 KPI',
235
+ formula: 'new_formula',
236
+ version: 2
237
+ })
238
+ )
239
+ })
240
+
241
+ it('RELEASE 상태 KPI 수정 시 버전이 증가하고 DRAFT로 변경되어야 한다', async () => {
242
+ // Given
243
+ const releasedKpi = {
244
+ id: 'kpi-123',
245
+ name: '릴리즈된 KPI',
246
+ formula: 'released_formula',
247
+ state: KpiStatus.RELEASE,
248
+ version: 3,
249
+ domain: testDomain
250
+ } as Kpi
251
+
252
+ const updateData = {
253
+ formula: 'modified_formula'
254
+ }
255
+
256
+ mockKpiRepository.findOne.mockResolvedValue(releasedKpi)
257
+ mockKpiHistoryRepository.save.mockResolvedValue({} as KpiHistory) // 히스토리 저장
258
+ mockKpiRepository.save.mockResolvedValue({
259
+ ...releasedKpi,
260
+ formula: 'modified_formula',
261
+ state: KpiStatus.DRAFT,
262
+ version: 4
263
+ } as Kpi)
264
+
265
+ // When
266
+ const result = await kpiService.updateKpi('kpi-123', updateData, testUser, testDomain)
267
+
268
+ // Then
269
+ expect(result.formula).toBe('modified_formula')
270
+ expect(result.state).toBe(KpiStatus.DRAFT)
271
+ expect(result.version).toBe(4)
272
+
273
+ // 히스토리가 저장되었는지 확인
274
+ expect(mockKpiHistoryRepository.save).toHaveBeenCalledWith(
275
+ expect.objectContaining({
276
+ kpi: expect.objectContaining({ id: 'kpi-123' }),
277
+ version: 3,
278
+ snapshot: expect.any(Object)
279
+ })
280
+ )
281
+ })
282
+
283
+ it('ARCHIVED 상태 KPI는 수정할 수 없어야 한다', async () => {
284
+ // Given
285
+ const archivedKpi = {
286
+ id: 'kpi-123',
287
+ name: '폐기된 KPI',
288
+ state: KpiStatus.ARCHIVED,
289
+ domain: testDomain
290
+ } as Kpi
291
+
292
+ mockKpiRepository.findOne.mockResolvedValue(archivedKpi)
293
+
294
+ // When & Then
295
+ await expect(kpiService.updateKpi('kpi-123', { name: '수정 시도' }, testUser, testDomain))
296
+ .rejects.toThrow('Cannot modify archived KPI')
297
+ })
298
+ })
299
+
300
+ describe('KPI 삭제 서비스 테스트', () => {
301
+ it('하위 KPI가 없는 KPI 삭제가 성공해야 한다', async () => {
302
+ // Given
303
+ const kpiToDelete = {
304
+ id: 'kpi-123',
305
+ name: '삭제할 KPI',
306
+ isLeaf: true,
307
+ domain: testDomain
308
+ } as Kpi
309
+
310
+ mockKpiRepository.findOne.mockResolvedValue(kpiToDelete)
311
+ mockKpiRepository.find.mockResolvedValue([]) // 하위 KPI 없음
312
+ mockKpiRepository.softDelete.mockResolvedValue({ affected: 1 })
313
+
314
+ // When
315
+ const result = await kpiService.deleteKpi('kpi-123', testUser, testDomain)
316
+
317
+ // Then
318
+ expect(result).toBe(true)
319
+ expect(mockKpiRepository.softDelete).toHaveBeenCalledWith('kpi-123')
320
+ })
321
+
322
+ it('하위 KPI가 있는 부모 KPI 삭제 시 에러가 발생해야 한다', async () => {
323
+ // Given
324
+ const parentKpi = {
325
+ id: 'parent-kpi-123',
326
+ name: '부모 KPI',
327
+ isLeaf: false,
328
+ domain: testDomain
329
+ } as Kpi
330
+
331
+ const childKpis = [
332
+ { id: 'child-1', name: '하위 KPI 1' },
333
+ { id: 'child-2', name: '하위 KPI 2' }
334
+ ] as Kpi[]
335
+
336
+ mockKpiRepository.findOne.mockResolvedValue(parentKpi)
337
+ mockKpiRepository.find.mockResolvedValue(childKpis)
338
+
339
+ // When & Then
340
+ await expect(kpiService.deleteKpi('parent-kpi-123', testUser, testDomain))
341
+ .rejects.toThrow('Cannot delete KPI with child KPIs. Found 2 child KPIs.')
342
+ })
343
+
344
+ it('존재하지 않는 KPI 삭제 시 에러가 발생해야 한다', async () => {
345
+ // Given
346
+ mockKpiRepository.findOne.mockResolvedValue(null)
347
+
348
+ // When & Then
349
+ await expect(kpiService.deleteKpi('non-existent-kpi', testUser, testDomain))
350
+ .rejects.toThrow('KPI not found: non-existent-kpi')
351
+ })
352
+ })
353
+
354
+ describe('KPI 릴리즈 서비스 테스트', () => {
355
+ it('DRAFT 상태 KPI 릴리즈가 성공해야 한다', async () => {
356
+ // Given
357
+ const draftKpi = {
358
+ id: 'kpi-123',
359
+ name: '드래프트 KPI',
360
+ formula: 'valid_formula',
361
+ state: KpiStatus.DRAFT,
362
+ version: 2,
363
+ domain: testDomain
364
+ } as Kpi
365
+
366
+ mockKpiRepository.findOne.mockResolvedValue(draftKpi)
367
+ mockKpiHistoryRepository.save.mockResolvedValue({} as KpiHistory)
368
+ mockKpiRepository.save.mockResolvedValue({
369
+ ...draftKpi,
370
+ state: KpiStatus.RELEASE,
371
+ version: 3
372
+ } as Kpi)
373
+
374
+ // When
375
+ const result = await kpiService.releaseKpi('kpi-123', testUser, testDomain)
376
+
377
+ // Then
378
+ expect(result.state).toBe(KpiStatus.RELEASE)
379
+ expect(result.version).toBe(3)
380
+
381
+ // 릴리즈 전 상태가 히스토리에 저장되었는지 확인
382
+ expect(mockKpiHistoryRepository.save).toHaveBeenCalledWith(
383
+ expect.objectContaining({
384
+ kpi: expect.objectContaining({ id: 'kpi-123' }),
385
+ version: 2,
386
+ action: 'RELEASE',
387
+ snapshot: expect.any(Object)
388
+ })
389
+ )
390
+ })
391
+
392
+ it('수식이 없는 leaf KPI 릴리즈 시 에러가 발생해야 한다', async () => {
393
+ // Given
394
+ const invalidKpi = {
395
+ id: 'kpi-123',
396
+ name: '잘못된 KPI',
397
+ formula: null,
398
+ isLeaf: true,
399
+ state: KpiStatus.DRAFT,
400
+ domain: testDomain
401
+ } as Kpi
402
+
403
+ mockKpiRepository.findOne.mockResolvedValue(invalidKpi)
404
+
405
+ // When & Then
406
+ await expect(kpiService.releaseKpi('kpi-123', testUser, testDomain))
407
+ .rejects.toThrow('Cannot release leaf KPI without formula')
408
+ })
409
+
410
+ it('이미 RELEASE 상태인 KPI 릴리즈 시 에러가 발생해야 한다', async () => {
411
+ // Given
412
+ const releasedKpi = {
413
+ id: 'kpi-123',
414
+ name: '이미 릴리즈된 KPI',
415
+ state: KpiStatus.RELEASE,
416
+ domain: testDomain
417
+ } as Kpi
418
+
419
+ mockKpiRepository.findOne.mockResolvedValue(releasedKpi)
420
+
421
+ // When & Then
422
+ await expect(kpiService.releaseKpi('kpi-123', testUser, testDomain))
423
+ .rejects.toThrow('KPI is already in RELEASE state')
424
+ })
425
+ })
426
+
427
+ describe('KPI 조회 서비스 테스트', () => {
428
+ it('페이지네이션을 포함한 KPI 목록 조회가 성공해야 한다', async () => {
429
+ // Given
430
+ const mockKpis = [
431
+ { id: 'kpi-1', name: 'KPI 1' },
432
+ { id: 'kpi-2', name: 'KPI 2' },
433
+ { id: 'kpi-3', name: 'KPI 3' }
434
+ ] as Kpi[]
435
+
436
+ const mockQueryBuilder = {
437
+ where: jest.fn().mockReturnThis(),
438
+ andWhere: jest.fn().mockReturnThis(),
439
+ orderBy: jest.fn().mockReturnThis(),
440
+ skip: jest.fn().mockReturnThis(),
441
+ take: jest.fn().mockReturnThis(),
442
+ getManyAndCount: jest.fn().mockResolvedValue([mockKpis, 15])
443
+ }
444
+
445
+ mockKpiRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder)
446
+
447
+ const queryOptions = {
448
+ page: 1,
449
+ limit: 10,
450
+ sortBy: 'name',
451
+ sortDirection: 'ASC' as const,
452
+ filters: {
453
+ active: true,
454
+ state: KpiStatus.RELEASE
455
+ }
456
+ }
457
+
458
+ // When
459
+ const result = await kpiService.getKpiList(testDomain, queryOptions)
460
+
461
+ // Then
462
+ expect(result.items).toEqual(mockKpis)
463
+ expect(result.total).toBe(15)
464
+ expect(result.page).toBe(1)
465
+ expect(result.pageSize).toBe(10)
466
+ expect(result.totalPages).toBe(2)
467
+
468
+ expect(mockQueryBuilder.where).toHaveBeenCalledWith('kpi.domainId = :domainId', { domainId: testDomain.id })
469
+ expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('kpi.active = :active', { active: true })
470
+ expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('kpi.state = :state', { state: KpiStatus.RELEASE })
471
+ expect(mockQueryBuilder.orderBy).toHaveBeenCalledWith('kpi.name', 'ASC')
472
+ expect(mockQueryBuilder.skip).toHaveBeenCalledWith(0)
473
+ expect(mockQueryBuilder.take).toHaveBeenCalledWith(10)
474
+ })
475
+
476
+ it('계층 구조를 포함한 KPI 상세 조회가 성공해야 한다', async () => {
477
+ // Given
478
+ const mockKpi = {
479
+ id: 'kpi-123',
480
+ name: '테스트 KPI',
481
+ parent: { id: 'parent-123', name: '부모 KPI' },
482
+ children: [
483
+ { id: 'child-1', name: '하위 KPI 1' },
484
+ { id: 'child-2', name: '하위 KPI 2' }
485
+ ]
486
+ } as Kpi
487
+
488
+ mockKpiRepository.findOne.mockResolvedValue(mockKpi)
489
+
490
+ // When
491
+ const result = await kpiService.getKpiById('kpi-123', testDomain, {
492
+ includeParent: true,
493
+ includeChildren: true,
494
+ includeHistory: false
495
+ })
496
+
497
+ // Then
498
+ expect(result).toEqual(mockKpi)
499
+ expect(mockKpiRepository.findOne).toHaveBeenCalledWith({
500
+ where: { id: 'kpi-123', domain: { id: testDomain.id } },
501
+ relations: ['parent', 'children']
502
+ })
503
+ })
504
+
505
+ it('검색 조건을 포함한 KPI 조회가 성공해야 한다', async () => {
506
+ // Given
507
+ const mockQueryBuilder = {
508
+ where: jest.fn().mockReturnThis(),
509
+ andWhere: jest.fn().mockReturnThis(),
510
+ orderBy: jest.fn().mockReturnThis(),
511
+ getMany: jest.fn().mockResolvedValue([
512
+ { id: 'kpi-1', name: '안전 관련 KPI' },
513
+ { id: 'kpi-2', name: '안전사고율' }
514
+ ])
515
+ }
516
+
517
+ mockKpiRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder)
518
+
519
+ // When
520
+ const result = await kpiService.searchKpis(testDomain, {
521
+ keyword: '안전',
522
+ category: 'SAFETY',
523
+ isLeaf: true,
524
+ active: true
525
+ })
526
+
527
+ // Then
528
+ expect(result).toHaveLength(2)
529
+ expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
530
+ 'kpi.name ILIKE :keyword OR kpi.description ILIKE :keyword',
531
+ { keyword: '%안전%' }
532
+ )
533
+ })
534
+ })
535
+
536
+ describe('KPI 계층 구조 서비스 테스트', () => {
537
+ it('KPI 트리 구조 조회가 성공해야 한다', async () => {
538
+ // Given
539
+ const mockKpiTree = [
540
+ {
541
+ id: 'root-1',
542
+ name: '루트 KPI 1',
543
+ children: [
544
+ {
545
+ id: 'child-1-1',
546
+ name: '하위 KPI 1-1',
547
+ children: [
548
+ { id: 'leaf-1-1-1', name: '말단 KPI 1-1-1', children: [] }
549
+ ]
550
+ }
551
+ ]
552
+ },
553
+ {
554
+ id: 'root-2',
555
+ name: '루트 KPI 2',
556
+ children: []
557
+ }
558
+ ]
559
+
560
+ mockKpiRepository.find.mockResolvedValue([
561
+ { id: 'root-1', name: '루트 KPI 1', parent: null },
562
+ { id: 'root-2', name: '루트 KPI 2', parent: null },
563
+ { id: 'child-1-1', name: '하위 KPI 1-1', parent: { id: 'root-1' } },
564
+ { id: 'leaf-1-1-1', name: '말단 KPI 1-1-1', parent: { id: 'child-1-1' } }
565
+ ])
566
+
567
+ // When
568
+ const result = await kpiService.getKpiTree(testDomain)
569
+
570
+ // Then
571
+ expect(result).toHaveLength(2)
572
+ expect(result[0].name).toBe('루트 KPI 1')
573
+ expect(result[0].children).toHaveLength(1)
574
+ expect(result[0].children[0].children).toHaveLength(1)
575
+ })
576
+
577
+ it('특정 KPI의 경로(breadcrumb) 조회가 성공해야 한다', async () => {
578
+ // Given
579
+ const leafKpi = {
580
+ id: 'leaf-123',
581
+ name: '말단 KPI',
582
+ parent: {
583
+ id: 'middle-123',
584
+ name: '중간 KPI',
585
+ parent: {
586
+ id: 'root-123',
587
+ name: '루트 KPI',
588
+ parent: null
589
+ }
590
+ }
591
+ } as Kpi
592
+
593
+ mockKpiRepository.findOne.mockResolvedValue(leafKpi)
594
+
595
+ // When
596
+ const result = await kpiService.getKpiPath('leaf-123', testDomain)
597
+
598
+ // Then
599
+ expect(result).toEqual([
600
+ { id: 'root-123', name: '루트 KPI' },
601
+ { id: 'middle-123', name: '중간 KPI' },
602
+ { id: 'leaf-123', name: '말단 KPI' }
603
+ ])
604
+ })
605
+
606
+ it('순환 참조 검증이 성공해야 한다', async () => {
607
+ // Given - A -> B -> C -> A 순환 참조 시도
608
+ const kpiA = { id: 'kpi-a', name: 'KPI A' } as Kpi
609
+ const kpiB = { id: 'kpi-b', name: 'KPI B', parent: kpiA } as Kpi
610
+ const kpiC = { id: 'kpi-c', name: 'KPI C', parent: kpiB } as Kpi
611
+
612
+ mockKpiRepository.findOne
613
+ .mockResolvedValueOnce(kpiC) // kpi-c 조회
614
+ .mockResolvedValueOnce(kpiB) // kpi-b 조회
615
+ .mockResolvedValueOnce(kpiA) // kpi-a 조회
616
+
617
+ // When & Then - kpi-a의 부모를 kpi-c로 설정하려고 할 때
618
+ await expect(kpiService.updateKpi('kpi-a', { parent: { id: 'kpi-c' } }, testUser, testDomain))
619
+ .rejects.toThrow('Circular reference detected in KPI hierarchy')
620
+ })
621
+ })
622
+
623
+ describe('KPI 복사 서비스 테스트', () => {
624
+ it('KPI 복사가 성공해야 한다', async () => {
625
+ // Given
626
+ const sourceKpi = {
627
+ id: 'source-kpi-123',
628
+ name: '원본 KPI',
629
+ description: '원본 설명',
630
+ formula: 'source_formula',
631
+ vizType: KpiVizType.GAUGE,
632
+ vizMeta: { unit: '%', min: 0, max: 100 },
633
+ state: KpiStatus.RELEASE,
634
+ version: 5,
635
+ domain: testDomain
636
+ } as Kpi
637
+
638
+ const copiedKpi = {
639
+ id: 'copied-kpi-123',
640
+ name: '복사된 KPI',
641
+ description: '원본 설명',
642
+ formula: 'source_formula',
643
+ vizType: KpiVizType.GAUGE,
644
+ vizMeta: { unit: '%', min: 0, max: 100 },
645
+ state: KpiStatus.DRAFT, // 복사본은 DRAFT로 시작
646
+ version: 1,
647
+ domain: testDomain
648
+ } as Kpi
649
+
650
+ mockKpiRepository.findOne.mockResolvedValue(sourceKpi)
651
+ mockKpiRepository.save.mockResolvedValue(copiedKpi)
652
+
653
+ // When
654
+ const result = await kpiService.copyKpi('source-kpi-123', {
655
+ name: '복사된 KPI',
656
+ copyChildren: false
657
+ }, testUser, testDomain)
658
+
659
+ // Then
660
+ expect(result.name).toBe('복사된 KPI')
661
+ expect(result.state).toBe(KpiStatus.DRAFT)
662
+ expect(result.version).toBe(1)
663
+ expect(result.formula).toBe('source_formula')
664
+ expect(result.vizMeta).toEqual({ unit: '%', min: 0, max: 100 })
665
+ })
666
+
667
+ it('하위 KPI를 포함한 복사가 성공해야 한다', async () => {
668
+ // Given
669
+ const sourceParent = {
670
+ id: 'parent-123',
671
+ name: '부모 KPI',
672
+ children: [
673
+ { id: 'child-1', name: '하위 KPI 1' },
674
+ { id: 'child-2', name: '하위 KPI 2' }
675
+ ]
676
+ } as Kpi
677
+
678
+ mockKpiRepository.findOne.mockResolvedValue(sourceParent)
679
+ mockKpiRepository.save
680
+ .mockResolvedValueOnce({ id: 'copied-parent-123', name: '복사된 부모 KPI' } as Kpi)
681
+ .mockResolvedValueOnce({ id: 'copied-child-1', name: '복사된 하위 KPI 1' } as Kpi)
682
+ .mockResolvedValueOnce({ id: 'copied-child-2', name: '복사된 하위 KPI 2' } as Kpi)
683
+
684
+ // When
685
+ const result = await kpiService.copyKpi('parent-123', {
686
+ name: '복사된 부모 KPI',
687
+ copyChildren: true
688
+ }, testUser, testDomain)
689
+
690
+ // Then
691
+ expect(mockKpiRepository.save).toHaveBeenCalledTimes(3) // 부모 + 자식 2개
692
+ })
693
+ })
694
+
695
+ describe('KPI 벌크 작업 서비스 테스트', () => {
696
+ it('여러 KPI 일괄 활성화가 성공해야 한다', async () => {
697
+ // Given
698
+ const kpiIds = ['kpi-1', 'kpi-2', 'kpi-3']
699
+ const mockQueryBuilder = {
700
+ update: jest.fn().mockReturnThis(),
701
+ set: jest.fn().mockReturnThis(),
702
+ where: jest.fn().mockReturnThis(),
703
+ andWhere: jest.fn().mockReturnThis(),
704
+ execute: jest.fn().mockResolvedValue({ affected: 3 })
705
+ }
706
+
707
+ mockKpiRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder)
708
+
709
+ // When
710
+ const result = await kpiService.bulkUpdateKpis(kpiIds, {
711
+ active: true
712
+ }, testUser, testDomain)
713
+
714
+ // Then
715
+ expect(result.affected).toBe(3)
716
+ expect(mockQueryBuilder.set).toHaveBeenCalledWith({ active: true })
717
+ expect(mockQueryBuilder.where).toHaveBeenCalledWith('id IN (:...ids)', { ids: kpiIds })
718
+ })
719
+
720
+ it('여러 KPI 일괄 삭제가 성공해야 한다', async () => {
721
+ // Given
722
+ const kpiIds = ['kpi-1', 'kpi-2']
723
+
724
+ // 삭제할 KPI들이 하위 KPI를 가지지 않음을 확인
725
+ mockKpiRepository.find.mockResolvedValue([])
726
+
727
+ const mockQueryBuilder = {
728
+ softDelete: jest.fn().mockReturnThis(),
729
+ where: jest.fn().mockReturnThis(),
730
+ andWhere: jest.fn().mockReturnThis(),
731
+ execute: jest.fn().mockResolvedValue({ affected: 2 })
732
+ }
733
+
734
+ mockKpiRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder)
735
+
736
+ // When
737
+ const result = await kpiService.bulkDeleteKpis(kpiIds, testUser, testDomain)
738
+
739
+ // Then
740
+ expect(result.deleted).toBe(2)
741
+ expect(mockQueryBuilder.where).toHaveBeenCalledWith('id IN (:...ids)', { ids: kpiIds })
742
+ })
743
+
744
+ it('하위 KPI가 있는 경우 일괄 삭제가 실패해야 한다', async () => {
745
+ // Given
746
+ const kpiIds = ['parent-kpi-1', 'parent-kpi-2']
747
+
748
+ // 하위 KPI들이 존재
749
+ mockKpiRepository.find.mockResolvedValue([
750
+ { id: 'child-1', name: '하위 KPI 1', parent: { id: 'parent-kpi-1' } }
751
+ ])
752
+
753
+ // When & Then
754
+ await expect(kpiService.bulkDeleteKpis(kpiIds, testUser, testDomain))
755
+ .rejects.toThrow('Cannot delete KPIs with child KPIs')
756
+ })
757
+ })
758
+
759
+ describe('KPI 수식 검증 서비스 테스트', () => {
760
+ it('유효한 수식 검증이 성공해야 한다', async () => {
761
+ // Given
762
+ const validFormulas = [
763
+ 'metric1 + metric2',
764
+ 'sum(metric1, metric2) / count',
765
+ 'if(metric1 > 0, metric2 / metric1 * 100, 0)',
766
+ 'round(avg(metric1, metric2, metric3), 2)'
767
+ ]
768
+
769
+ // When & Then
770
+ for (const formula of validFormulas) {
771
+ const result = await kpiService.validateFormula(formula, testDomain)
772
+ expect(result.valid).toBe(true)
773
+ expect(result.errors).toHaveLength(0)
774
+ }
775
+ })
776
+
777
+ it('잘못된 수식 검증이 실패해야 한다', async () => {
778
+ // Given
779
+ const invalidFormulas = [
780
+ { formula: 'metric1 +', expectedError: 'Incomplete expression' },
781
+ { formula: 'unknown_func(metric1)', expectedError: 'Unknown function: unknown_func' },
782
+ { formula: 'if(metric1)', expectedError: 'Invalid number of arguments for function: if' },
783
+ { formula: 'metric1 ** metric2', expectedError: 'Unsupported operator: **' }
784
+ ]
785
+
786
+ // When & Then
787
+ for (const testCase of invalidFormulas) {
788
+ const result = await kpiService.validateFormula(testCase.formula, testDomain)
789
+ expect(result.valid).toBe(false)
790
+ expect(result.errors).toContain(testCase.expectedError)
791
+ }
792
+ })
793
+
794
+ it('수식에 사용된 메트릭 추출이 성공해야 한다', async () => {
795
+ // Given
796
+ const formula = 'defect_count / total_count * 100 + adjustment_factor'
797
+
798
+ // When
799
+ const result = await kpiService.extractMetricsFromFormula(formula)
800
+
801
+ // Then
802
+ expect(result).toEqual(['defect_count', 'total_count', 'adjustment_factor'])
803
+ })
804
+ })
805
+ })
806
+ ```
807
+
808
+ ## 5. KPI 값 계산 서비스 테스트 (kpi-value-calculation.spec.ts)
809
+
810
+ ```typescript
811
+ import { describe, it, expect, beforeEach, jest } from '@jest/globals'
812
+ import { KpiValueCalculationService } from '../server/service/kpi-value/kpi-value-calculation.service'
813
+ import { KpiValue } from '../server/service/kpi-value/kpi-value'
814
+ import { Kpi } from '../server/service/kpi/kpi'
815
+ import { KpiMetric } from '../server/service/kpi-metric/kpi-metric'
816
+ import { DataSample } from '@things-factory/dataset'
817
+
818
+ describe('KPI Value Calculation Service Tests', () => {
819
+ let calculationService: KpiValueCalculationService
820
+ let mockValueProvider: jest.Mock
821
+ let mockDataSampleRepository: jest.Mock
822
+
823
+ beforeEach(() => {
824
+ mockValueProvider = jest.fn()
825
+ mockDataSampleRepository = jest.fn()
826
+
827
+ calculationService = new KpiValueCalculationService(
828
+ mockDataSampleRepository,
829
+ mockValueProvider
830
+ )
831
+ })
832
+
833
+ describe('메트릭 기반 KPI 값 계산 테스트', () => {
834
+ it('간단한 비율 계산이 정확해야 한다', async () => {
835
+ // Given
836
+ const kpi = {
837
+ id: 'kpi-123',
838
+ name: '불량률',
839
+ formula: 'defect_count / total_count * 100',
840
+ metrics: [
841
+ { code: 'defect_count', datasetId: 'quality-ds' },
842
+ { code: 'total_count', datasetId: 'quality-ds' }
843
+ ]
844
+ } as any
845
+
846
+ mockValueProvider
847
+ .mockResolvedValueOnce(25) // defect_count
848
+ .mockResolvedValueOnce(1000) // total_count
849
+
850
+ // When
851
+ const result = await calculationService.calculateKpiValue(kpi, {
852
+ startDate: new Date('2024-01-01'),
853
+ endDate: new Date('2024-01-31'),
854
+ orgScopeId: 'org-123'
855
+ })
856
+
857
+ // Then
858
+ expect(result.value).toBe(2.5) // 25 / 1000 * 100
859
+ expect(result.formula).toBe('defect_count / total_count * 100')
860
+ expect(result.calculatedAt).toBeDefined()
861
+ })
862
+
863
+ it('복합 수식 계산이 정확해야 한다', async () => {
864
+ // Given
865
+ const kpi = {
866
+ id: 'kpi-456',
867
+ name: '종합 성과 지수',
868
+ formula: 'round((quality_score * 0.4 + efficiency_score * 0.3 + safety_score * 0.3), 2)',
869
+ metrics: [
870
+ { code: 'quality_score', datasetId: 'kpi-ds' },
871
+ { code: 'efficiency_score', datasetId: 'kpi-ds' },
872
+ { code: 'safety_score', datasetId: 'kpi-ds' }
873
+ ]
874
+ } as any
875
+
876
+ mockValueProvider
877
+ .mockResolvedValueOnce(85) // quality_score
878
+ .mockResolvedValueOnce(90) // efficiency_score
879
+ .mockResolvedValueOnce(88) // safety_score
880
+
881
+ // When
882
+ const result = await calculationService.calculateKpiValue(kpi, {
883
+ startDate: new Date('2024-01-01'),
884
+ endDate: new Date('2024-01-31'),
885
+ orgScopeId: 'org-123'
886
+ })
887
+
888
+ // Then
889
+ // 85*0.4 + 90*0.3 + 88*0.3 = 34 + 27 + 26.4 = 87.4
890
+ expect(result.value).toBe(87.4)
891
+ })
892
+
893
+ it('Zero Division 방지 로직이 동작해야 한다', async () => {
894
+ // Given
895
+ const kpi = {
896
+ id: 'kpi-789',
897
+ name: '생산성',
898
+ formula: 'if(input_hours > 0, output_qty / input_hours, 0)',
899
+ metrics: [
900
+ { code: 'output_qty', datasetId: 'prod-ds' },
901
+ { code: 'input_hours', datasetId: 'prod-ds' }
902
+ ]
903
+ } as any
904
+
905
+ mockValueProvider
906
+ .mockResolvedValueOnce(1200) // output_qty
907
+ .mockResolvedValueOnce(0) // input_hours (0으로 나누기 상황)
908
+
909
+ // When
910
+ const result = await calculationService.calculateKpiValue(kpi, {
911
+ startDate: new Date('2024-01-01'),
912
+ endDate: new Date('2024-01-31'),
913
+ orgScopeId: 'org-123'
914
+ })
915
+
916
+ // Then
917
+ expect(result.value).toBe(0) // Zero division 방지로 0 반환
918
+ })
919
+ })
920
+
921
+ describe('시계열 집계 계산 테스트', () => {
922
+ it('일별 데이터를 월별로 집계가 정확해야 한다', async () => {
923
+ // Given
924
+ const kpi = {
925
+ id: 'kpi-daily',
926
+ name: '일일 처리량 월평균',
927
+ formula: 'avg(daily_volume)',
928
+ periodType: 'MONTH',
929
+ metrics: [{ code: 'daily_volume', datasetId: 'volume-ds' }]
930
+ } as any
931
+
932
+ const mockDailySamples = [
933
+ { date: '2024-01-01', value: 100 },
934
+ { date: '2024-01-02', value: 120 },
935
+ { date: '2024-01-03', value: 110 },
936
+ // ... 31일간 데이터
937
+ ]
938
+
939
+ mockDataSampleRepository.mockResolvedValue(mockDailySamples)
940
+
941
+ // When
942
+ const result = await calculationService.calculateKpiValueWithAggregation(kpi, {
943
+ startDate: new Date('2024-01-01'),
944
+ endDate: new Date('2024-01-31'),
945
+ aggregationType: 'MONTHLY',
946
+ orgScopeId: 'org-123'
947
+ })
948
+
949
+ // Then
950
+ expect(result.aggregationType).toBe('MONTHLY')
951
+ expect(result.sourceDataCount).toBe(mockDailySamples.length)
952
+ expect(result.value).toBeGreaterThan(0)
953
+ })
954
+
955
+ it('주간 단위 집계가 정확해야 한다', async () => {
956
+ // Given
957
+ const kpi = {
958
+ id: 'kpi-weekly',
959
+ name: '주간 안전 점수',
960
+ formula: 'sum(weekly_safety_incidents)',
961
+ periodType: 'WEEK',
962
+ metrics: [{ code: 'weekly_safety_incidents', datasetId: 'safety-ds' }]
963
+ } as any
964
+
965
+ const mockWeeklyData = [
966
+ { week: 1, value: 2 },
967
+ { week: 2, value: 1 },
968
+ { week: 3, value: 3 },
969
+ { week: 4, value: 0 }
970
+ ]
971
+
972
+ mockValueProvider.mockImplementation((metric) => {
973
+ return Promise.resolve(mockWeeklyData.reduce((sum, d) => sum + d.value, 0))
974
+ })
975
+
976
+ // When
977
+ const result = await calculationService.calculateKpiValue(kpi, {
978
+ startDate: new Date('2024-01-01'),
979
+ endDate: new Date('2024-01-28'),
980
+ aggregationType: 'WEEKLY',
981
+ orgScopeId: 'org-123'
982
+ })
983
+
984
+ // Then
985
+ expect(result.value).toBe(6) // 2 + 1 + 3 + 0 = 6
986
+ })
987
+ })
988
+
989
+ describe('성과 점수 계산 테스트', () => {
990
+ it('등급 기반 점수 계산이 정확해야 한다', async () => {
991
+ // Given
992
+ const kpi = {
993
+ id: 'kpi-grade',
994
+ name: '품질 등급',
995
+ grades: {
996
+ 'A': { minValue: 90, maxValue: 100, score: 100, color: '#43a047' },
997
+ 'B': { minValue: 80, maxValue: 90, score: 80, color: '#66bb6a' },
998
+ 'C': { minValue: 70, maxValue: 80, score: 60, color: '#ffeb3b' },
999
+ 'D': { minValue: 60, maxValue: 70, score: 40, color: '#ffa726' },
1000
+ 'F': { minValue: 0, maxValue: 60, score: 20, color: '#ff4757' }
1001
+ }
1002
+ } as any
1003
+
1004
+ const testCases = [
1005
+ { value: 95, expectedScore: 100, expectedGrade: 'A' },
1006
+ { value: 85, expectedScore: 80, expectedGrade: 'B' },
1007
+ { value: 75, expectedScore: 60, expectedGrade: 'C' },
1008
+ { value: 65, expectedScore: 40, expectedGrade: 'D' },
1009
+ { value: 55, expectedScore: 20, expectedGrade: 'F' }
1010
+ ]
1011
+
1012
+ for (const testCase of testCases) {
1013
+ // When
1014
+ const result = await calculationService.calculatePerformanceScore(
1015
+ testCase.value,
1016
+ kpi
1017
+ )
1018
+
1019
+ // Then
1020
+ expect(result.score).toBe(testCase.expectedScore)
1021
+ expect(result.grade).toBe(testCase.expectedGrade)
1022
+ expect(result.color).toBeDefined()
1023
+ }
1024
+ })
1025
+
1026
+ it('수식 기반 점수 계산이 정확해야 한다', async () => {
1027
+ // Given
1028
+ const kpi = {
1029
+ id: 'kpi-formula-score',
1030
+ name: '효율성 지수',
1031
+ scoreFormula: 'if(value >= 95, 100, if(value >= 85, value * 0.9 + 15, value * 0.7))'
1032
+ } as any
1033
+
1034
+ const testCases = [
1035
+ { value: 98, expectedScore: 100 }, // >= 95: 100점
1036
+ { value: 90, expectedScore: 96 }, // 85~95: 90*0.9+15 = 96
1037
+ { value: 80, expectedScore: 56 } // < 85: 80*0.7 = 56
1038
+ ]
1039
+
1040
+ for (const testCase of testCases) {
1041
+ // When
1042
+ const result = await calculationService.calculatePerformanceScore(
1043
+ testCase.value,
1044
+ kpi
1045
+ )
1046
+
1047
+ // Then
1048
+ expect(result.score).toBe(testCase.expectedScore)
1049
+ }
1050
+ })
1051
+
1052
+ it('성과 지수 함수 기반 점수 계산이 정확해야 한다', async () => {
1053
+ // Given
1054
+ const kpi = {
1055
+ id: 'kpi-performance-index',
1056
+ name: '성과 지수',
1057
+ scoreFormula: 'performance_index(value, 2.5, 3.2, 2.0, 3.5) * 100'
1058
+ } as any
1059
+
1060
+ // When
1061
+ const result = await calculationService.calculatePerformanceScore(0.85, kpi)
1062
+
1063
+ // Then
1064
+ expect(result.score).toBeGreaterThanOrEqual(0)
1065
+ expect(result.score).toBeLessThanOrEqual(100)
1066
+ expect(Number.isFinite(result.score)).toBe(true)
1067
+ })
1068
+ })
1069
+
1070
+ describe('계산 에러 처리 테스트', () => {
1071
+ it('메트릭 데이터가 없을 때 기본값 반환해야 한다', async () => {
1072
+ // Given
1073
+ const kpi = {
1074
+ id: 'kpi-no-data',
1075
+ name: '데이터 없는 KPI',
1076
+ formula: 'missing_metric * 100',
1077
+ metrics: [{ code: 'missing_metric', datasetId: 'empty-ds' }]
1078
+ } as any
1079
+
1080
+ mockValueProvider.mockRejectedValue(new Error('No data found'))
1081
+
1082
+ // When
1083
+ const result = await calculationService.calculateKpiValue(kpi, {
1084
+ startDate: new Date('2024-01-01'),
1085
+ endDate: new Date('2024-01-31'),
1086
+ orgScopeId: 'org-123',
1087
+ defaultValue: 0
1088
+ })
1089
+
1090
+ // Then
1091
+ expect(result.value).toBe(0)
1092
+ expect(result.error).toBeDefined()
1093
+ expect(result.error.message).toContain('No data found')
1094
+ })
1095
+
1096
+ it('수식 계산 오류 시 적절한 에러 메시지를 반환해야 한다', async () => {
1097
+ // Given
1098
+ const kpi = {
1099
+ id: 'kpi-invalid-formula',
1100
+ name: '잘못된 수식 KPI',
1101
+ formula: 'log(-1)', // 음수의 로그 (수학적 오류)
1102
+ metrics: []
1103
+ } as any
1104
+
1105
+ // When & Then
1106
+ await expect(calculationService.calculateKpiValue(kpi, {
1107
+ startDate: new Date('2024-01-01'),
1108
+ endDate: new Date('2024-01-31'),
1109
+ orgScopeId: 'org-123'
1110
+ })).rejects.toThrow('Math domain error')
1111
+ })
1112
+
1113
+ it('타임아웃 처리가 정상 동작해야 한다', async () => {
1114
+ // Given
1115
+ const kpi = {
1116
+ id: 'kpi-timeout',
1117
+ name: '타임아웃 KPI',
1118
+ formula: 'slow_function(metric1)',
1119
+ metrics: [{ code: 'metric1', datasetId: 'slow-ds' }]
1120
+ } as any
1121
+
1122
+ // 10초 후에 응답하는 느린 함수 모킹
1123
+ mockValueProvider.mockImplementation(() =>
1124
+ new Promise(resolve => setTimeout(() => resolve(100), 10000))
1125
+ )
1126
+
1127
+ // When & Then
1128
+ await expect(calculationService.calculateKpiValue(kpi, {
1129
+ startDate: new Date('2024-01-01'),
1130
+ endDate: new Date('2024-01-31'),
1131
+ orgScopeId: 'org-123',
1132
+ timeout: 1000 // 1초 타임아웃
1133
+ })).rejects.toThrow('Calculation timeout')
1134
+ })
1135
+ })
1136
+
1137
+ describe('배치 계산 테스트', () => {
1138
+ it('여러 KPI 일괄 계산이 성공해야 한다', async () => {
1139
+ // Given
1140
+ const kpis = [
1141
+ {
1142
+ id: 'kpi-1',
1143
+ name: 'KPI 1',
1144
+ formula: 'metric1 * 100',
1145
+ metrics: [{ code: 'metric1', datasetId: 'ds1' }]
1146
+ },
1147
+ {
1148
+ id: 'kpi-2',
1149
+ name: 'KPI 2',
1150
+ formula: 'metric2 / 10',
1151
+ metrics: [{ code: 'metric2', datasetId: 'ds2' }]
1152
+ }
1153
+ ] as any[]
1154
+
1155
+ mockValueProvider
1156
+ .mockResolvedValueOnce(1.5) // metric1
1157
+ .mockResolvedValueOnce(200) // metric2
1158
+
1159
+ // When
1160
+ const results = await calculationService.calculateMultipleKpis(kpis, {
1161
+ startDate: new Date('2024-01-01'),
1162
+ endDate: new Date('2024-01-31'),
1163
+ orgScopeId: 'org-123'
1164
+ })
1165
+
1166
+ // Then
1167
+ expect(results).toHaveLength(2)
1168
+ expect(results[0].kpiId).toBe('kpi-1')
1169
+ expect(results[0].value).toBe(150) // 1.5 * 100
1170
+ expect(results[1].kpiId).toBe('kpi-2')
1171
+ expect(results[1].value).toBe(20) // 200 / 10
1172
+ })
1173
+
1174
+ it('배치 계산 중 일부 실패해도 나머지는 계속 처리되어야 한다', async () => {
1175
+ // Given
1176
+ const kpis = [
1177
+ { id: 'kpi-success', formula: 'metric1', metrics: [{ code: 'metric1' }] },
1178
+ { id: 'kpi-fail', formula: 'missing_metric', metrics: [{ code: 'missing_metric' }] },
1179
+ { id: 'kpi-success2', formula: 'metric2', metrics: [{ code: 'metric2' }] }
1180
+ ] as any[]
1181
+
1182
+ mockValueProvider
1183
+ .mockResolvedValueOnce(100) // metric1 (성공)
1184
+ .mockRejectedValueOnce(new Error('Data not found')) // missing_metric (실패)
1185
+ .mockResolvedValueOnce(200) // metric2 (성공)
1186
+
1187
+ // When
1188
+ const results = await calculationService.calculateMultipleKpis(kpis, {
1189
+ startDate: new Date('2024-01-01'),
1190
+ endDate: new Date('2024-01-31'),
1191
+ orgScopeId: 'org-123',
1192
+ continueOnError: true
1193
+ })
1194
+
1195
+ // Then
1196
+ expect(results).toHaveLength(3)
1197
+ expect(results[0].success).toBe(true)
1198
+ expect(results[0].value).toBe(100)
1199
+ expect(results[1].success).toBe(false)
1200
+ expect(results[1].error).toBeDefined()
1201
+ expect(results[2].success).toBe(true)
1202
+ expect(results[2].value).toBe(200)
1203
+ })
1204
+ })
1205
+
1206
+ describe('캐싱 메커니즘 테스트', () => {
1207
+ it('동일한 조건의 계산 결과가 캐시되어야 한다', async () => {
1208
+ // Given
1209
+ const kpi = {
1210
+ id: 'kpi-cache-test',
1211
+ name: '캐시 테스트 KPI',
1212
+ formula: 'expensive_calculation(metric1)',
1213
+ metrics: [{ code: 'metric1', datasetId: 'cache-ds' }]
1214
+ } as any
1215
+
1216
+ const calculationParams = {
1217
+ startDate: new Date('2024-01-01'),
1218
+ endDate: new Date('2024-01-31'),
1219
+ orgScopeId: 'org-123'
1220
+ }
1221
+
1222
+ mockValueProvider.mockResolvedValue(500)
1223
+
1224
+ // When - 첫 번째 호출
1225
+ const result1 = await calculationService.calculateKpiValue(kpi, calculationParams)
1226
+
1227
+ // When - 동일한 조건으로 두 번째 호출
1228
+ const result2 = await calculationService.calculateKpiValue(kpi, calculationParams)
1229
+
1230
+ // Then
1231
+ expect(result1.value).toBe(result2.value)
1232
+ expect(result2.fromCache).toBe(true)
1233
+ expect(mockValueProvider).toHaveBeenCalledTimes(1) // 첫 번째만 실제 계산
1234
+ })
1235
+
1236
+ it('캐시 만료 후 재계산이 수행되어야 한다', async () => {
1237
+ // Given
1238
+ const kpi = {
1239
+ id: 'kpi-cache-expire',
1240
+ name: '캐시 만료 테스트',
1241
+ formula: 'time_sensitive_metric',
1242
+ cacheExpiry: 100 // 100ms 후 만료
1243
+ } as any
1244
+
1245
+ mockValueProvider
1246
+ .mockResolvedValueOnce(100) // 첫 번째 호출
1247
+ .mockResolvedValueOnce(200) // 캐시 만료 후 두 번째 호출
1248
+
1249
+ const params = {
1250
+ startDate: new Date('2024-01-01'),
1251
+ endDate: new Date('2024-01-31'),
1252
+ orgScopeId: 'org-123'
1253
+ }
1254
+
1255
+ // When
1256
+ const result1 = await calculationService.calculateKpiValue(kpi, params)
1257
+
1258
+ // 캐시 만료 대기
1259
+ await new Promise(resolve => setTimeout(resolve, 150))
1260
+
1261
+ const result2 = await calculationService.calculateKpiValue(kpi, params)
1262
+
1263
+ // Then
1264
+ expect(result1.value).toBe(100)
1265
+ expect(result2.value).toBe(200)
1266
+ expect(result2.fromCache).toBe(false)
1267
+ expect(mockValueProvider).toHaveBeenCalledTimes(2)
1268
+ })
1269
+ })
1270
+ })
1271
+ ```
1272
+
1273
+ 단위 테스트를 매우 상세하게 작성했습니다. 주요 특징:
1274
+
1275
+ 1. **Mock 활용**: 외부 의존성을 모킹하여 격리된 테스트 환경 구성
1276
+ 2. **Given-When-Then 패턴**: 명확한 테스트 구조
1277
+ 3. **Edge Case 포함**: Zero Division, 타임아웃, 에러 처리 등
1278
+ 4. **실제 사용 시나리오**: 복합 수식, 계층 구조, 배치 처리 등
1279
+ 5. **성능 테스트**: 대용량 데이터, 캐싱 메커니즘
1280
+ 6. **검증 로직**: 입력 검증, 순환 참조 검사 등
1281
+
1282
+ 각 테스트는 실제 production 환경에서 발생할 수 있는 상황들을 다루고 있어 실용적입니다.
1283
+
1284
+ <function_calls>
1285
+ <invoke name="TodoWrite">
1286
+ <parameter name="todos">[{"content": "Create detailed unit test code examples for KPI module", "status": "completed", "activeForm": "Creating detailed unit test code examples for KPI module"}, {"content": "Write comprehensive entity validation tests", "status": "completed", "activeForm": "Writing comprehensive entity validation tests"}, {"content": "Create formula calculator test cases with edge cases", "status": "completed", "activeForm": "Creating formula calculator test cases with edge cases"}, {"content": "Write KPI value management test scenarios", "status": "completed", "activeForm": "Writing KPI value management test scenarios"}, {"content": "Add service layer unit tests with mocking", "status": "completed", "activeForm": "Adding service layer unit tests with mocking"}]