diff --git a/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md b/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md index 2c0c13b1..41623360 100644 --- a/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md +++ b/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md @@ -4,10 +4,10 @@ | 항목 | 내용 | |---|---| -| 상태 | 3차 Phase별 리뷰 완료 | -| 확정일 | 2026-07-31 | +| 상태 | Jenkins 회귀 수정 완료 | +| 확정일 | 2026-08-03 | | 요구사항 기준 | `docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md` 전체 | -| 현재 Phase | Phase 1~7 3차 리뷰 완료 | +| 현재 Phase | Phase 1~7 및 `P5-R2` 완료 | | 현재 활성 Goal | 없음 | | 다음 Goal | 없음 | @@ -318,6 +318,20 @@ - [x] **GREEN:** 새 테스트가 구현 결함을 드러낼 때만 `refreshFutures` 또는 lock 내부 존재 확인 경로를 최소 수정한다. - [x] **REFACTOR/GATE:** sleep 없이 `RecommendationSnapshotFallbackServiceTest` focused test와 `git diff --check`를 실행해 결과를 누적한다. +#### Task R5.2 Jenkins single-flight 테스트 scheduling race 제거 + +**Goal 실행 `P5-R2`:** 두 동시 요청이 pending refresh future를 공유하는 계약을 worker 완료 순서에 의존하지 않고 검증한다. + +- **시작 조건:** Jenkins에서 `shouldShareSingleCheerCreatorRefreshFutureForConcurrentRequests`의 두 번째 request `Future.get(...)`이 `TimeoutException`으로 실패하고, `REV-P5-002`가 테스트 scheduling race로 확정됨. +- **완료 증거:** Jenkins 실패 원인 기록, 결정적 테스트 최소 수정, fallback focused test 반복 통과, 직접 영향 회귀·format·문서 명령·diff 검증 결과 누적. +- **범위 밖:** `RecommendationSnapshotFallbackService` production 동작, worker 수, 300ms lock 대기와 1,500ms 홈 대기, 다른 fallback 섹션 변경. + +- [x] **RED:** Jenkins 실패의 `RecommendationSnapshotFallbackServiceTest.kt:322 TimeoutException`과 로컬 focused test 통과를 함께 기록해 worker scheduling에 따라 결과가 달라지는 기존 테스트를 확인한다. +- [x] **RED 확인:** `./gradlew cleanTest test --tests 'kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest.shouldShareSingleCheerCreatorRefreshFutureForConcurrentRequests'`의 로컬 통과와 Jenkins 실패를 대조해 비결정성을 확인한다. +- [x] **GREEN:** 수동 `CapturingExecutor.runNext()` 완료 경쟁 대신 refresh worker를 latch로 pending 상태에 유지한다. 두 요청이 홈 대기 timeout으로 반환할 때까지 refresh를 완료하지 않고 `refreshCheerCreatorSnapshots(...)` 호출이 1회인지 검증한다. +- [x] **GREEN 확인:** 같은 focused test를 반복 실행하고 `RecommendationSnapshotFallbackServiceTest` 전체를 실행해 single-flight, timeout 후 worker 지속, lock 내부 double-check가 모두 통과하는지 확인한다. +- [x] **REFACTOR/GATE:** 사용하지 않는 test helper/import만 제거하고 `ktlintCheck`, `./gradlew tasks --all`, `git diff --check` 결과를 전체 검증 기록에 누적한다. + --- ### Phase 6: API 회귀와 최종 검증 @@ -440,6 +454,8 @@ git diff --check - Non-Goals: Task 4.1, Task 6.1, Task 6.3, Task 7.1에서 다른 스냅샷 섹션 marker 확장 없음, 공개 API URL/응답 필드 변경 없음, 16명 밖 후보 보충 없음, 신규 DDL 없음, 관리자/ML/A-B 제외를 확인한다. ## 전체 검증 기록 +- 2026-08-03: `P5-R2` GREEN/GATE로 수동 `CapturingExecutor` 완료 경쟁을 제거하고, 실제 worker를 latch로 pending 상태에 유지한 채 두 요청의 worker task 제출과 `refreshCheerCreatorSnapshots(...)` 호출이 각각 1회인지 검증하도록 수정했다. focused test는 최초 1회와 연속 10회 모두 통과했고, `RecommendationSnapshotFallbackServiceTest` 전체, `./gradlew ktlintCheck`, `./gradlew tasks --all`, `./gradlew cleanTest test`, `git diff --check`가 모두 `BUILD SUCCESSFUL` 또는 출력 없음으로 통과했다. 전체 테스트는 7분 14초 소요됐다. Oracle 고강도 리뷰도 correctness·결정성·thread cleanup·production 무변경 판단에 blocker 없이 승인했다. +- 2026-08-03: `P5-R2` RED 확인으로 Jenkins의 `RecommendationSnapshotFallbackServiceTest.kt:322 TimeoutException`과 로컬 `./gradlew cleanTest test --tests 'kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest.shouldShareSingleCheerCreatorRefreshFutureForConcurrentRequests'` `BUILD SUCCESSFUL`을 대조했다. 같은 코드가 worker scheduling에 따라 실패·성공하여 기존 테스트의 `taskSubmitted` latch가 두 요청의 동일 future 대기를 보장하지 않는 비결정성을 확인했다. - 2026-07-31: Phase 1~7 3차 리뷰로 PRD·plan-task·현재 코드·테스트를 정적으로 대조했다. 이전 리뷰에서 보완한 문서 정합성, 종료 라이브 데뷔 이력, `CHEER_CREATOR` refresh 실패 로그, fallback single-flight·double-check, 공개 응답 3개 필드, 본인·활성 팔로우 제외가 현재 구현과 회귀 테스트에 유지됨을 확인했다. 각 결과는 기존 `reviews/phase-1-review.md`~`reviews/phase-7-review.md`에 3차 리뷰로 별도 누적했다. 추가 확정 발견 사항이 없어 신규 회귀 Task/Goal은 추가하지 않았다. 사용자 지시에 따라 Gradle compile/test는 실행하지 않았다. `git diff --check`는 출력 없이 통과했다. 문서 명령 유효성 확인용 `./gradlew tasks --all`은 일반 sandbox에서 `~/.gradle` wrapper lock 권한으로 실패한 뒤 승인된 동일 명령에서 `BUILD SUCCESSFUL`로 통과했다. - 2026-07-31: `P3-R2`로 `CHEER_CREATOR` 데뷔 CTE가 채널명이 있는 종료 라이브를 데뷔 이력으로 인정하도록 복구했다. RED 확인으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`를 실행해 `shouldIncludeEndedLiveWithChannelNameAsCheerCreatorDebut`가 `AssertionFailedError`로 실패하는 것을 확인했다. 이후 `findCheerCreatorSnapshots(...)` 라이브 branch에서 `lr.is_active = true`만 제거했고, 같은 repository focused test는 `BUILD SUCCESSFUL`로 통과했다. `./gradlew ktlintCheck`도 `BUILD SUCCESSFUL`로 통과했으며, `git diff --check`는 출력 없이 통과했다. - 2026-07-31: `P1-R2`로 상단 후속 변경 상태를 현재 PRD 전체, Phase 1~7 판정, 실제 미완료 Goal `P3-R2` 기준으로 정리했다. 문서 정합성 복구만 수행해 TDD 예외로 처리했다. `git diff --check -- docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md`는 출력 없이 통과했고, `./gradlew tasks --all`은 `BUILD SUCCESSFUL`로 통과했다. diff --git a/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-5-review.md b/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-5-review.md index 48996e24..b6de91ac 100644 --- a/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-5-review.md +++ b/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-5-review.md @@ -32,6 +32,7 @@ | ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | |---|---|---|---|---|---| | `REV-P5-001` | Low | 보완 완료 | 동일 섹션 single-flight와 lock 내부 double-check의 직접 회귀 테스트가 없다 | Task R5.1 | `P5-R1` | +| `REV-P5-002` | Medium | 보완 완료 | single-flight 테스트가 worker 완료 순서에 의존해 Jenkins에서 timeout된다 | Task R5.2 | `P5-R2` | ## 6. 발견 사항 상세 @@ -102,3 +103,16 @@ sleep 대신 latch와 결정적 fake를 사용해 두 경쟁 조건을 고정하 - **plan·goal 전환:** 전환 항목 없음. - **최종 결론:** 확정 발견 사항 없음. - **남은 항목:** 없음. + +--- + +## 11. 4차 리뷰 기록 — 2026-08-03 + +- **리뷰 대상:** Phase 5 / Task R5.1의 `shouldShareSingleCheerCreatorRefreshFutureForConcurrentRequests` Jenkins 실패. +- **검토 근거:** Jenkins `RecommendationSnapshotFallbackServiceTest.kt:322 TimeoutException`, `RecommendationSnapshotFallbackService.kt:141-149`, 테스트의 `CapturingExecutor`와 latch 흐름, 로컬 `cleanTest` focused test 실행, JDK 17 `CompletableFuture`·`CountDownLatch` 계약. +- **확정 발견 사항:** `REV-P5-002`. `taskSubmitted` latch는 worker task가 executor에 전달됐다는 사실만 보장하며, refresh future가 `refreshFutures`에 게시됐거나 두 요청이 같은 future에서 대기 중임을 보장하지 않는다. Jenkins에서 첫 worker가 먼저 완료되면 reference가 제거되고 두 번째 요청은 실행되지 않는 새 captured task를 기다려 바깥 `get(1s)`에서 timeout된다. +- **판정:** production single-flight 결함 근거는 없다. timeout 확대는 경쟁을 숨기므로 제외하고, refresh를 latch로 pending 상태에 유지한 채 두 요청의 refresh 호출 횟수 1회를 검증하도록 테스트만 수정한다. +- **plan·goal 전환:** `plan-task.md` Phase 5에 Task R5.2 / `P5-R2`를 추가했다. +- **검증 결과:** focused test 최초 1회와 연속 10회, `RecommendationSnapshotFallbackServiceTest` 전체, `ktlintCheck`, `tasks --all`, 전체 `cleanTest test`, `git diff --check`가 모두 통과했다. Oracle 고강도 리뷰도 blocker 없이 승인했다. +- **최종 결론:** `P5-R2` 보완 완료. +- **남은 항목:** 없음. diff --git a/docs/20260804_관리자정산크리에이터번호적용/plan-task.md b/docs/20260804_관리자정산크리에이터번호적용/plan-task.md new file mode 100644 index 00000000..d8eed174 --- /dev/null +++ b/docs/20260804_관리자정산크리에이터번호적용/plan-task.md @@ -0,0 +1,170 @@ +# 관리자 정산 크리에이터 번호 적용 구현 계획 + +| 문서 항목 | 내용 | +|---|---| +| 상태 | 구현 완료 | +| 작성일 | 2026-08-04 | +| 요구사항 기준 | `docs/20260804_관리자정산크리에이터번호적용/prd.md` | +| 현재 Phase | Phase 1 완료 | +| 현재 활성 Goal | 없음 | + +## 목표 + +이메일이 없는 크리에이터도 관리자 크리에이터별 정산에서 오류 없이 조회되고 `creatorId`로 구분된다. + +## 현재 상태 + +| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 | +|---:|---|---:|---|---| +| 1 | 완료 | `2/2` | 없음 | 없음 | + +## 범위 + +### 포함 + +- 관리자 라이브·콘텐츠·커뮤니티 크리에이터별 정산 QueryDSL projection 변경 +- 정산 query/response DTO의 `email` 제거 및 `creatorId` 추가 +- 크리에이터별 정산 엑셀 이메일 컬럼 제거 및 크리에이터 번호 컬럼 추가 +- null 이메일 크리에이터 QueryDSL 회귀 테스트와 엑셀 출력 테스트 + +### 제외 + +- 에이전트·채널후원 정산 변경 +- `Member.email` 정책 변경 +- 정산 공식·집계·필터·정렬·페이지네이션 변경 +- dependency 추가와 관련 없는 리팩터링 + +## 기술적 제약 + +- Kotlin, Spring Boot 2.7.14, QueryDSL 5.0.0, JUnit 5의 기존 패턴을 유지한다. +- `@QueryProjection` 생성자 변경은 Gradle KAPT가 생성 코드를 갱신하도록 하고 생성 파일은 직접 수정하지 않는다. +- 구현은 RED → GREEN → REFACTOR 순서로 진행한다. +- Gradle 명령은 QueryDSL 생성 코드 충돌을 피하기 위해 순차 실행한다. + +### Phase 1: 정산 projection 및 출력 변경 + +**Phase 결과:** null 이메일 크리에이터 정산 조회와 creatorId 기반 API·엑셀 출력이 동작한다. + +**선행조건:** PRD와 본 계획 문서 작성 완료. + +#### Task 1.1 QueryDSL 정산 응답에 creatorId 적용 + +**Goal 실행 `P1-T1`:** null 이메일 크리에이터의 세 정산 조회가 creatorId를 반환하도록 한다. + +- **시작 조건:** `CALC-001`, `CALC-002`, `CALC-004` 확정. +- **완료 증거:** RED/GREEN 결과와 focused test 기록. +- **범위 밖:** 엑셀 출력 변경은 `P1-T2`에서 처리한다. + +**Files:** + +- Create: `src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepositoryTest.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateByCreatorQueryData.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateByCreatorItem.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt` + +- [x] **RED:** email이 null인 크리에이터의 라이브·콘텐츠·커뮤니티 정산 조회가 `creatorId`를 반환하는 통합 테스트를 작성한다. +- [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateQueryRepositoryTest`를 실행해 기존 projection의 null email 생성 오류 또는 미구현 `creatorId` 계약 실패를 확인한다. +- [x] **GREEN:** 두 DTO에서 `email`을 `creatorId: Long`으로 교체하고 세 QueryDSL projection에서 `member.id`를 선택한다. +- [x] **GREEN 확인:** 같은 focused test가 성공하는지 확인한다. +- [x] **REFACTOR:** 이번 Task가 만든 중복만 정리하고 정산 계산 회귀 테스트를 실행한다. + +#### Task 1.2 엑셀 크리에이터 번호 적용 + +**Goal 실행 `P1-T2`:** 크리에이터별 정산 엑셀에서 이메일 대신 크리에이터 번호를 출력한다. + +- **시작 조건:** `P1-T1` 완료. +- **완료 증거:** 엑셀 헤더·값 테스트와 focused test 기록. +- **범위 밖:** 다른 정산 엑셀 형식 변경. + +**Files:** + +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateServiceTest.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateService.kt` + +- [x] **RED:** 크리에이터별 정산 엑셀의 첫 헤더와 값이 크리에이터 번호인지 검증하는 테스트를 작성한다. +- [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateServiceTest`를 실행해 남아 있는 `item.email` 계약 때문에 컴파일이 실패하는지 확인한다. +- [x] **GREEN:** 엑셀 첫 헤더를 `크리에이터 번호`로 교체하고 `creatorId`를 기록한다. +- [x] **GREEN 확인:** 같은 focused test가 성공하는지 확인한다. +- [x] **REFACTOR:** 나머지 엑셀 컬럼과 계산 결과가 유지되는지 회귀 확인한다. + +### 완료 조건 + +- [x] `P1-T1`, `P1-T2`의 체크박스와 완료 증거가 충족됐다. +- [x] 문서와 구현의 차이가 없다. + +### Phase 1 Gate + +**Goal 실행 `P1-GATE`:** 변경 범위의 기능·컴파일·포맷을 최종 판정한다. + +```bash +./gradlew compileKotlin +./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateQueryRepositoryTest --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateServiceTest --tests kr.co.vividnext.sodalive.admin.calculate.ContentSettlementCalculationTest +./gradlew ktlintCheck +./gradlew tasks --all +git diff --check +``` + +**Expected:** 모든 Gradle 명령이 `BUILD SUCCESSFUL`, 테스트가 모두 통과하고 `git diff --check` 출력이 없다. + +전체 테스트는 관리자 크리에이터별 정산의 projection/DTO/엑셀 경계로 변경이 제한되고 focused test와 컴파일로 세 쿼리 호출부를 검증하므로 생략한다. + +## 실행 순서와 의존성 + +| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 | +|---:|---|---|---|---| +| 1 | `P1-T1` | 문서 작성 완료 | 아니요 | RED 실패 원인 재확인 | +| 2 | `P1-T2` | `P1-T1` 완료 | 아니요 | 엑셀 계약 재확인 | +| 3 | `P1-GATE` | Phase 1 Task 전체 | 아니요 | 실패 소유 Task에 회귀 수정 기록 | + +## 변경 금지 항목 + +- QueryDSL 생성 파일을 직접 수정하지 않는다. +- 정산 계산식과 쿼리 집계 조건을 변경하지 않는다. +- 테스트를 삭제·skip·완화하지 않는다. +- 요청 범위 밖의 정산 API를 변경하지 않는다. + +## Progress + +### `P1-T1` 1차 실행 — 2026-08-04 + +- 상태: 진행 중 +- 무엇을: PRD와 구현 계획을 작성하고 QueryDSL 통합 테스트 seam을 확정했다. +- 왜: production 변경 전에 요구사항과 TDD 완료 기준을 고정하기 위해서다. +- 어떻게: + - `docs/sample/sample-prd.md`, `docs/sample/sample-plan-task.md` 확인 — 완료 + - `AdminCalculateQueryRepository`와 관련 DTO 호출 경로 확인 — 완료 + - `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateQueryRepositoryTest` — RED 확인, `creatorId` 미구현으로 `compileTestKotlin` 실패 + - `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateServiceTest` — RED 확인, 남아 있는 `item.email` 참조로 `compileKotlin` 실패 + - `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateQueryRepositoryTest --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateServiceTest` — GREEN, `BUILD SUCCESSFUL` +- 남은 항목: Phase Gate 검증. +- 다음 행동: 컴파일·focused 회귀·ktlint·문서 검증을 순차 실행한다. + +### `P1-GATE` 1차 실행 — 2026-08-04 + +- 상태: 완료 +- 무엇을: creatorId projection·응답·엑셀 변경과 null 이메일 회귀 방지를 검증했다. +- 왜: `CALC-001`~`CALC-004`의 수용 기준과 기존 계산 불변성을 최종 판정하기 위해서다. +- 어떻게: + - `./gradlew compileKotlin` — `BUILD SUCCESSFUL` + - `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateQueryRepositoryTest --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateServiceTest --tests kr.co.vividnext.sodalive.admin.calculate.ContentSettlementCalculationTest` — 11 tests, failures 0, errors 0 + - `./gradlew ktlintCheck` — `BUILD SUCCESSFUL` + - `./gradlew build -x test` — `BUILD SUCCESSFUL` + - `git diff --check` — 출력 없음 + - 독립 Oracle 리뷰 — `APPROVED`, 차단 이슈 없음 +- 수동 검증: QueryDSL H2 통합 테스트로 null 이메일 세 정산 조회를 실행하고, 실제 XLSX workbook의 헤더와 numeric creatorId 셀을 확인했다. +- 전체 테스트 생략: 변경이 관리자 크리에이터별 정산의 공용 projection/DTO/엑셀 경계에 한정되어 focused 통합·서비스·계산 테스트와 전체 빌드로 직접 영향 범위를 검증했다. +- 남은 항목: 없음. +- 다음 행동: 없음. + +## Decision Log + +| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 | +|---|---|---|---|---|---| +| 2026-08-04 | `DEC-001` | 확정 | email을 제거하고 creatorId를 projection·응답에 추가한다. | PRD `DEC-001` | `P1-T1` | +| 2026-08-04 | `DEC-002` | 확정 | 엑셀 이메일 컬럼을 크리에이터 번호로 교체한다. | PRD `DEC-002` | `P1-T2` | + +## 발견된 문제 + +| ID | 심각도 | 상태 | 발견 내용 | 영향 Goal | 처리 계획 | +|---|---|---|---|---|---| +| `ISSUE-001` | High | 확정 | nullable `Member.email`이 non-null QueryProjection 생성자로 전달되어 정산 조회가 실패한다. | `P1-T1` | creatorId로 projection 계약 교체 | diff --git a/docs/20260804_관리자정산크리에이터번호적용/prd.md b/docs/20260804_관리자정산크리에이터번호적용/prd.md new file mode 100644 index 00000000..fb864756 --- /dev/null +++ b/docs/20260804_관리자정산크리에이터번호적용/prd.md @@ -0,0 +1,73 @@ +# 관리자 정산 크리에이터 번호 적용 PRD + +## 문서 정보 + +| 항목 | 내용 | +|---|---| +| 문서 상태 | 구현 완료 | +| 작성일 | 2026-08-04 | +| 최종 수정일 | 2026-08-04 | +| 대상 제품 | 관리자 크리에이터별 정산 조회 및 엑셀 | +| 작성자·결정권자 | 사용자 | +| 관련 구현 계획 | `docs/20260804_관리자정산크리에이터번호적용/plan-task.md` | + +## 1. Overview + +관리자 크리에이터별 정산에서 nullable인 `Member.email`을 필수 QueryDSL projection 값으로 사용해 발생하는 조회 오류를 제거한다. 정산 대상 식별값은 이메일 대신 non-null PK인 `creatorId`를 사용한다. + +## 2. Problem Statement + +- `Member.email`은 nullable이지만 `GetCalculateByCreatorQueryData.email`은 non-null `String`이다. +- 이메일이 없는 크리에이터가 정산 결과에 포함되면 QueryDSL이 DTO 생성 중 `ExpressionException`을 발생시킨다. +- 이메일은 정산 계산식에 사용되지 않으므로 필수 projection 값으로 유지할 이유가 없다. + +문제 해결 여부는 이메일이 null인 크리에이터의 정산 조회가 성공하고 `creatorId`를 반환하는지로 판단한다. + +## 3. Goals + +- 라이브·콘텐츠·커뮤니티 크리에이터별 정산 projection에서 `email`을 제거한다. +- 정산 응답에 `creatorId`를 추가한다. +- 크리에이터별 정산 엑셀에서 이메일을 제거하고 크리에이터 번호를 제공한다. +- 기존 정산 금액 계산, 집계, 필터, 정렬과 페이지네이션을 유지한다. + +## 4. Non-Goals + +- `Member.email`의 nullable 정책을 변경하지 않는다. +- 이메일이 없는 회원의 다른 기능을 수정하지 않는다. +- 에이전트 정산과 채널후원 정산 API를 변경하지 않는다. +- 정산 공식 또는 정산 비율을 변경하지 않는다. + +## 5. 기능 요구사항 + +| ID | 상태 | 요구사항 | 수용 기준 | Goal 연결 | +|---|---|---|---|---| +| `CALC-001` | 확정 | 크리에이터별 정산 조회는 이메일 대신 크리에이터 번호를 반환한다. | 응답 item에 `creatorId: Long`이 있고 `email`이 없다. | `P1-T1` | +| `CALC-002` | 확정 | 이메일이 없는 크리에이터도 정산 조회 대상에 포함된다. | null 이메일 크리에이터의 라이브·콘텐츠·커뮤니티 정산 QueryDSL 조회가 예외 없이 성공한다. | `P1-T1` | +| `CALC-003` | 확정 | 크리에이터별 정산 엑셀은 이메일 대신 크리에이터 번호를 제공한다. | 첫 헤더가 `크리에이터 번호`이고 데이터 셀에 `creatorId`가 기록된다. | `P1-T2` | +| `CALC-004` | 확정 | 기존 정산 계산 결과를 유지한다. | `totalCan`, 원화, 결제수수료, 정산금액, 원천세와 입금액 계산 회귀 테스트가 통과한다. | `P1-T1`, `P1-GATE` | + +## 6. API 계약 + +대상 endpoint는 다음과 같다. + +- `GET /admin/calculate/live-by-creator` +- `GET /admin/calculate/content-by-creator` +- `GET /admin/calculate/community-by-creator` +- 위 세 endpoint의 `/excel` 다운로드 + +조회 응답 item의 `email: String`을 제거하고 `creatorId: Long`을 추가한다. 요청 파라미터와 응답의 나머지 필드는 변경하지 않는다. + +## 7. 성공 기준 + +- [x] null 이메일 크리에이터에 대한 세 종류의 크리에이터별 정산 조회가 성공한다. +- [x] 조회 응답은 `creatorId`를 포함하고 `email`을 포함하지 않는다. +- [x] 엑셀은 `크리에이터 번호` 컬럼을 포함하고 이메일 컬럼을 포함하지 않는다. +- [x] 기존 정산 계산 및 관련 회귀 테스트가 통과한다. + +## 8. Decision Log + +| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·Goal | +|---|---|---|---|---|---| +| 2026-08-04 | `DEC-001` | 확정 | 정산에서 email을 제거하고 creatorId를 사용한다. | email은 계산에 사용되지 않고 nullable이라 projection 오류를 발생시킨다. | `CALC-001`, `CALC-002`, `P1-T1` | +| 2026-08-04 | `DEC-002` | 확정 | 엑셀의 이메일 컬럼을 크리에이터 번호 컬럼으로 교체한다. | 사용자가 이메일 제거와 creatorId 기반 구분을 확정했다. | `CALC-003`, `P1-T2` | +| 2026-08-04 | `DEC-003` | 확정 | 이메일 제거에 따른 추가 식별력 보완은 하지 않는다. | 사용자가 식별력 저하는 문제가 되지 않는다고 확정했다. | Non-Goals | diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt index 3f203e70..2f863361 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt @@ -391,7 +391,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { return queryFactory .select( QGetCalculateByCreatorQueryData( - member.email, + member.id, member.nickname, useCan.can.add(useCan.rewardCan).sum(), creatorSettlementRatio.liveSettlementRatio @@ -447,7 +447,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { return queryFactory .select( QGetCalculateByCreatorQueryData( - member.email, + member.id, member.nickname, order.can.sum(), creatorSettlementRatio.contentSettlementRatio @@ -504,7 +504,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { return queryFactory .select( QGetCalculateByCreatorQueryData( - member.email, + member.id, member.nickname, useCan.can.add(useCan.rewardCan).sum(), creatorSettlementRatio.communitySettlementRatio diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateService.kt index 8e06fff1..afd8d0e6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateService.kt @@ -474,7 +474,7 @@ class AdminCalculateService( return createExcelStream( sheetName = sheetName, headers = listOf( - "이메일", + "크리에이터 번호", "닉네임", "총 캔", "원화", @@ -486,7 +486,7 @@ class AdminCalculateService( ) { sheet -> items.forEachIndexed { index, item -> val row = sheet.createRow(index + 1) - row.createCell(0).setCellValue(item.email) + row.createCell(0).setCellValue(item.creatorId.toDouble()) row.createCell(1).setCellValue(item.nickname) row.createCell(2).setCellValue(item.totalCan.toDouble()) row.createCell(3).setCellValue(item.totalKrw.toDouble()) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateByCreatorItem.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateByCreatorItem.kt index 6b33b297..ce20da49 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateByCreatorItem.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateByCreatorItem.kt @@ -3,7 +3,7 @@ package kr.co.vividnext.sodalive.admin.calculate import com.fasterxml.jackson.annotation.JsonProperty data class GetCalculateByCreatorItem( - @JsonProperty("email") val email: String, + @JsonProperty("creatorId") val creatorId: Long, @JsonProperty("nickname") val nickname: String, @JsonProperty("totalCan") val totalCan: Int, @JsonProperty("totalKrw") val totalKrw: Int, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateByCreatorQueryData.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateByCreatorQueryData.kt index e392da55..7a25ac9c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateByCreatorQueryData.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateByCreatorQueryData.kt @@ -5,7 +5,7 @@ import java.math.BigDecimal import java.math.RoundingMode data class GetCalculateByCreatorQueryData @QueryProjection constructor( - val email: String, + val creatorId: Long, val nickname: String, val totalCan: Int, val settlementRatio: Int? @@ -31,7 +31,7 @@ data class GetCalculateByCreatorQueryData @QueryProjection constructor( val depositAmount = settlementAmount.subtract(tax) return GetCalculateByCreatorItem( - email = email, + creatorId = creatorId, nickname = nickname, totalCan = totalCan, totalKrw = totalKrw.toInt(), diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepositoryTest.kt new file mode 100644 index 00000000..a15d302d --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepositoryTest.kt @@ -0,0 +1,169 @@ +package kr.co.vividnext.sodalive.admin.calculate + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.can.use.UseCan +import kr.co.vividnext.sodalive.can.use.UseCanRepository +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.AudioContentRepository +import kr.co.vividnext.sodalive.content.order.Order +import kr.co.vividnext.sodalive.content.order.OrderRepository +import kr.co.vividnext.sodalive.content.order.OrderType +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository +import kr.co.vividnext.sodalive.live.room.LiveRoom +import kr.co.vividnext.sodalive.live.room.LiveRoomRepository +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@DataJpaTest( + properties = [ + "spring.cache.type=none", + "spring.jpa.database-platform=kr.co.vividnext.sodalive.support.H2MySqlFunctionDialect" + ] +) +@Import(QueryDslConfig::class) +class AdminCalculateQueryRepositoryTest @Autowired constructor( + private val queryFactory: JPAQueryFactory, + private val memberRepository: MemberRepository, + private val liveRoomRepository: LiveRoomRepository, + private val audioContentRepository: AudioContentRepository, + private val orderRepository: OrderRepository, + private val creatorCommunityRepository: CreatorCommunityRepository, + private val useCanRepository: UseCanRepository, + private val entityManager: EntityManager +) { + private lateinit var repository: AdminCalculateQueryRepository + + @BeforeEach + fun setup() { + repository = AdminCalculateQueryRepository(queryFactory) + } + + @Test + @DisplayName("이메일이 없는 크리에이터도 크리에이터별 정산에서 번호로 조회한다") + fun shouldReturnCreatorIdForByCreatorRowsWhenCreatorEmailIsNull() { + // given + val creator = saveMember(email = null, nickname = "creator", role = MemberRole.CREATOR) + val buyer = saveMember(email = "buyer@test.com", nickname = "buyer", role = MemberRole.USER) + val createdAt = LocalDateTime.of(2026, 8, 4, 12, 0, 0) + + val room = saveLiveRoom(creator) + saveUseCan(buyer, CanUsage.LIVE, 10, createdAt) { this.room = room } + + val content = saveAudioContent(creator) + saveOrder(buyer, creator, content, createdAt) + + val post = saveCommunityPost(creator) + saveUseCan(buyer, CanUsage.PAID_COMMUNITY_POST, 30, createdAt) { communityPost = post } + + entityManager.flush() + entityManager.clear() + + val startDate = LocalDateTime.of(2026, 8, 4, 0, 0, 0) + val endDate = LocalDateTime.of(2026, 8, 4, 23, 59, 59) + + // when + val liveRows = repository.getCalculateLiveByCreator(startDate, endDate, offset = 0, limit = 10) + val contentRows = repository.getCalculateContentByCreator(startDate, endDate, offset = 0, limit = 10) + val communityRows = repository.getCalculateCommunityByCreator(startDate, endDate, offset = 0, limit = 10) + + // then + assertEquals(listOf(creator.id), liveRows.map { it.creatorId }) + assertEquals(listOf(creator.id), contentRows.map { it.creatorId }) + assertEquals(listOf(creator.id), communityRows.map { it.creatorId }) + } + + private fun saveMember(email: String?, nickname: String, role: MemberRole): Member { + return memberRepository.saveAndFlush( + Member( + email = email, + password = "password", + nickname = nickname, + role = role + ) + ) + } + + private fun saveLiveRoom(creator: Member): LiveRoom { + val room = LiveRoom( + title = "live-room", + notice = "notice", + beginDateTime = LocalDateTime.of(2026, 8, 4, 8, 0, 0), + numberOfPeople = 1, + isAdult = false, + price = 10 + ) + room.member = creator + return liveRoomRepository.saveAndFlush(room) + } + + private fun saveAudioContent(creator: Member): AudioContent { + val theme = AudioContentTheme(theme = "theme", image = "theme.png") + entityManager.persist(theme) + + val content = AudioContent( + title = "content", + detail = "detail", + languageCode = "ko", + price = 20 + ) + content.theme = theme + content.member = creator + content.isActive = true + return audioContentRepository.saveAndFlush(content) + } + + private fun saveOrder(buyer: Member, creator: Member, content: AudioContent, createdAt: LocalDateTime) { + val order = Order(type = OrderType.KEEP) + order.member = buyer + order.creator = creator + order.audioContent = content + val saved = orderRepository.saveAndFlush(order) + updateCreatedAt("Order", saved.id!!, createdAt) + } + + private fun saveCommunityPost(creator: Member): CreatorCommunity { + val post = CreatorCommunity( + content = "community-content", + price = 30, + isCommentAvailable = true, + isAdult = false + ) + post.member = creator + return creatorCommunityRepository.saveAndFlush(post) + } + + private fun saveUseCan( + member: Member, + usage: CanUsage, + can: Int, + createdAt: LocalDateTime, + configure: UseCan.() -> Unit + ) { + val useCan = UseCan(canUsage = usage, can = can, rewardCan = 0) + useCan.member = member + useCan.configure() + val saved = useCanRepository.saveAndFlush(useCan) + updateCreatedAt("UseCan", saved.id!!, createdAt) + } + + private fun updateCreatedAt(entityName: String, id: Long, createdAt: LocalDateTime) { + entityManager.createQuery("update $entityName e set e.createdAt = :createdAt where e.id = :id") + .setParameter("createdAt", createdAt) + .setParameter("id", id) + .executeUpdate() + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateServiceTest.kt index 1914261f..1eae08ec 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateServiceTest.kt @@ -9,11 +9,13 @@ import kr.co.vividnext.sodalive.can.use.UseCanCalculate import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.extensions.convertLocalDateTime import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.live.room.LiveRoom import kr.co.vividnext.sodalive.live.room.LiveRoomRepository import kr.co.vividnext.sodalive.member.Member +import org.apache.poi.xssf.usermodel.XSSFWorkbook import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertTrue @@ -24,6 +26,8 @@ import org.mockito.Mockito.atLeastOnce import org.mockito.Mockito.mock import org.mockito.Mockito.verify import org.mockito.Mockito.`when` +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream import java.util.Optional class AdminCalculateServiceTest { @@ -45,6 +49,35 @@ class AdminCalculateServiceTest { langContext ) + @Test + @DisplayName("크리에이터별 정산 엑셀은 이메일 대신 크리에이터 번호를 출력한다") + fun shouldWriteCreatorIdInsteadOfEmailToCreatorSettlementExcel() { + // given + val startDate = "2026-08-04".convertLocalDateTime() + val endDate = "2026-08-04".convertLocalDateTime(hour = 23, minute = 59, second = 59) + val queryData = GetCalculateByCreatorQueryData( + creatorId = 42L, + nickname = "creator", + totalCan = 100, + settlementRatio = 70 + ) + + `when`(repository.getCalculateLiveByCreatorTotalCount(startDate, endDate)).thenReturn(1) + `when`(repository.getCalculateLiveByCreator(startDate, endDate, 0L, 1L)).thenReturn(listOf(queryData)) + + // when + val output = ByteArrayOutputStream() + adminCalculateService.downloadCalculateLiveByCreatorExcel("2026-08-04", "2026-08-04").writeTo(output) + + // then + XSSFWorkbook(ByteArrayInputStream(output.toByteArray())).use { workbook -> + val sheet = workbook.getSheetAt(0) + assertEquals("크리에이터 번호", sheet.getRow(0).getCell(0).stringCellValue) + assertEquals(42.0, sheet.getRow(1).getCell(0).numericCellValue) + assertEquals("닉네임", sheet.getRow(0).getCell(1).stringCellValue) + } + } + @Test @DisplayName("라이브 환불 성공 테스트") fun shouldRefundLiveSuccessfully() { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackServiceTest.kt index 1619ef09..f1649bef 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackServiceTest.kt @@ -14,8 +14,8 @@ import java.util.concurrent.CountDownLatch import java.util.concurrent.Executor import java.util.concurrent.Executors import java.util.concurrent.Future -import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger class RecommendationSnapshotFallbackServiceTest { @Test @@ -265,10 +265,18 @@ class RecommendationSnapshotFallbackServiceTest { homeWaitMillis = 50 ) - val first = service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)) - assertEquals(true, refreshStarted.await(1, TimeUnit.SECONDS)) - allowRefreshComplete.countDown() - executor.shutdown() + val first = try { + val result = service.refreshCheerCreatorIfMissing( + offset = 0, + limit = 16, + nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0) + ) + assertEquals(true, refreshStarted.await(1, TimeUnit.SECONDS)) + result + } finally { + allowRefreshComplete.countDown() + executor.shutdown() + } assertEquals(true, executor.awaitTermination(1, TimeUnit.SECONDS)) val second = service.refreshCheerCreatorIfMissing( offset = 0, @@ -283,46 +291,43 @@ class RecommendationSnapshotFallbackServiceTest { @Test @DisplayName("응원 크리에이터 fallback 동시 요청은 하나의 refresh future를 공유한다") fun shouldShareSingleCheerCreatorRefreshFutureForConcurrentRequests() { - val existsSnapshotEntered = CountDownLatch(2) - val existsSnapshotReturned = CountDownLatch(2) - val snapshotPort = FakeRecommendationFallbackSnapshotPort( - existsSnapshotEntered = existsSnapshotEntered, - existsSnapshotReturned = existsSnapshotReturned - ) - val refreshService = FastCheerRefreshService(snapshotPort) + val snapshotPort = FakeRecommendationFallbackSnapshotPort() + val refreshStarted = CountDownLatch(1) + val allowRefreshComplete = CountDownLatch(1) + val refreshService = BlockingCheerRefreshService(snapshotPort, refreshStarted, allowRefreshComplete) val redissonClient = Mockito.mock(RedissonClient::class.java) val lock = Mockito.mock(RLock::class.java) Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(lock) Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true) Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true) - val workerExecutor = CapturingExecutor() + val workerTaskCount = AtomicInteger() + val workerPool = Executors.newFixedThreadPool(2) + val workerExecutor = Executor { command -> + workerTaskCount.incrementAndGet() + workerPool.execute(command) + } val requestExecutor = Executors.newFixedThreadPool(2) val service = RecommendationSnapshotFallbackService( snapshotPort, refreshService, redissonClient, workerExecutor, - homeWaitMillis = 1_000 + homeWaitMillis = 50 ) val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0) try { val first = requestExecutor.submitCheerRefresh(service, nowUtc) val second = requestExecutor.submitCheerRefresh(service, nowUtc) - assertEquals(true, existsSnapshotEntered.await(1, TimeUnit.SECONDS)) - assertEquals(true, existsSnapshotReturned.await(1, TimeUnit.SECONDS)) - assertEquals(true, workerExecutor.taskSubmitted.await(1, TimeUnit.SECONDS)) - assertEquals(false, first.isDone) - assertEquals(false, second.isDone) - assertEquals(1, workerExecutor.taskCount) - - workerExecutor.runNext() - - assertEquals(listOf(99L), first.get(1, TimeUnit.SECONDS).map { it.targetId }) - assertEquals(listOf(99L), second.get(1, TimeUnit.SECONDS).map { it.targetId }) + assertEquals(true, refreshStarted.await(5, TimeUnit.SECONDS)) + assertEquals(emptyList(), first.get(5, TimeUnit.SECONDS)) + assertEquals(emptyList(), second.get(5, TimeUnit.SECONDS)) + assertEquals(1, workerTaskCount.get()) assertEquals(1, refreshService.cheerRefreshCount) } finally { + allowRefreshComplete.countDown() requestExecutor.shutdownNow() + workerPool.shutdownNow() } } @@ -408,23 +413,6 @@ class RecommendationSnapshotFallbackServiceTest { } } -private class CapturingExecutor : Executor { - val taskSubmitted = CountDownLatch(1) - private val tasks = LinkedBlockingQueue() - - val taskCount: Int - get() = tasks.size - - override fun execute(command: Runnable) { - tasks.add(command) - taskSubmitted.countDown() - } - - fun runNext() { - tasks.poll(1, TimeUnit.SECONDS)!!.run() - } -} - private class BlockingCheerRefreshService( private val snapshotPort: RecommendationSnapshotPort, private val refreshStarted: CountDownLatch, @@ -438,26 +426,7 @@ private class BlockingCheerRefreshService( override fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime): Int { cheerRefreshCount += 1 refreshStarted.countDown() - allowRefreshComplete.await(1, TimeUnit.SECONDS) - snapshotPort.replaceSnapshots( - RecommendedSectionType.CHEER_CREATOR, - LocalDateTime.of(2026, 7, 9, 14, 59, 59), - listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 99L)) - ) - return 1 - } -} - -private class FastCheerRefreshService( - private val snapshotPort: RecommendationSnapshotPort -) : RecommendationSnapshotRefreshService( - snapshotPort, - Mockito.mock(kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQueryPort::class.java) -) { - var cheerRefreshCount: Int = 0 - - override fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime): Int { - cheerRefreshCount += 1 + allowRefreshComplete.await() snapshotPort.replaceSnapshots( RecommendedSectionType.CHEER_CREATOR, LocalDateTime.of(2026, 7, 9, 14, 59, 59), @@ -496,10 +465,7 @@ private class BlockingAiAndFastCheerRefreshService( } } -private class FakeRecommendationFallbackSnapshotPort( - private val existsSnapshotEntered: CountDownLatch? = null, - private val existsSnapshotReturned: CountDownLatch? = null -) : RecommendationSnapshotPort { +private class FakeRecommendationFallbackSnapshotPort : RecommendationSnapshotPort { private val snapshots = mutableListOf() override fun findLatestSnapshots( @@ -531,12 +497,7 @@ private class FakeRecommendationFallbackSnapshotPort( } override fun existsSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean { - existsSnapshotEntered?.countDown() - existsSnapshotEntered?.await(1, TimeUnit.SECONDS) - val exists = snapshots.any { it.sectionType == sectionType && it.snapshotAt == snapshotAt } - existsSnapshotReturned?.countDown() - existsSnapshotReturned?.await(1, TimeUnit.SECONDS) - return exists + return snapshots.any { it.sectionType == sectionType && it.snapshotAt == snapshotAt } } override fun replaceSnapshots(