test #443
@@ -36,7 +36,7 @@
|
||||
- 최근 댓글 많은 오디오 점수: 댓글 수 80%, 댓글 최신성 20%.
|
||||
- 최근 댓글 많은 오디오 스냅샷 저장 정책: 같은 크리에이터의 오디오 후보가 여러 개이면 최근 댓글 많은 오디오 점수가 가장 높은 1개만 저장한다.
|
||||
- 조회수/좋아요/댓글 수는 후보 내 정규화 없이 원본 count를 그대로 사용한다.
|
||||
- 무료/포인트/추천 오디오 섹션 사이에는 같은 콘텐츠가 중복 노출될 수 있다.
|
||||
- 무료 오디오와 포인트 오디오는 가격 조건으로 분리하며, 무료/추천 또는 포인트/추천 섹션 사이의 중복만 허용한다.
|
||||
- `isOriginalSeries`는 시리즈 미소속 오디오이면 `false`로 내려준다.
|
||||
- 전체보기/페이징 API, 관리자 화면, 수동 편집 기능은 이번 범위에 포함하지 않는다.
|
||||
|
||||
@@ -357,7 +357,7 @@ interface AudioRecommendationQueryPort {
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
|
||||
- RED: 무료 오디오는 `price = 0` 공개 오디오 중 최대 15개, 포인트 오디오는 `isPointAvailable = true` 공개 오디오 중 최대 15개를 반환하고 두 섹션 간 중복을 제거하지 않는 테스트를 작성한다.
|
||||
- RED: 무료 오디오는 `price = 0` 공개 오디오 중 최대 15개, 포인트 오디오는 `isPointAvailable = true` 공개 오디오 중 최대 15개를 반환하고 두 섹션 간 중복을 제거하지 않는 테스트를 작성한다. 이 문장은 2026-06-23 당시 기준이며 2026-07-31 후속 요구사항 정정에서 두 섹션을 가격 조건으로 분리한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
|
||||
- GREEN: `findFreeAudios`, `findPointAudios`를 구현하고 DB 랜덤 정렬은 기존 repository 관례에 맞춰 `Expressions.numberTemplate(Double::class.java, "function('rand')")` 또는 동일 프로젝트에서 쓰는 랜덤 정렬 방식을 사용한다.
|
||||
- REFACTOR: 무료/포인트 조회가 같은 공통 projection 함수를 사용하게 정리한다.
|
||||
@@ -721,3 +721,10 @@ interface AudioRecommendationQueryPort {
|
||||
- 2026-07-12 문서 전용 후속 정책 기록: `mostCommentedAudios` 스냅샷 저장 시 같은 크리에이터의 오디오 후보는 최근 댓글 많은 오디오 점수가 가장 높은 1개만 저장하도록 PRD Feature H와 조회 정책, plan-task 확정 정책과 Phase 9 후속 구현 task를 갱신했다. 이번 작업은 문서만 작성했으므로 source code와 test code는 변경하지 않았고, 관련 RED/GREEN/REFACTOR 구현은 Task 9.1 미완료 상태로 남겼다.
|
||||
|
||||
- 2026-07-12 Phase 9 선행 구현 및 Phase 8 회귀 검증: 최근 댓글 많은 오디오 스냅샷 후보에서 같은 크리에이터의 여러 오디오 중 최고 점수 1개만 남기도록 repository dedupe를 구현했다. RED에서 같은 크리에이터 낮은 점수 후보가 함께 반환되는 실패를 확인했고, GREEN 후 Phase 9 focused test, Phase 8 관련 test/ktlint/tasks 검증이 모두 `BUILD SUCCESSFUL`로 통과했다.
|
||||
|
||||
## 2026-07-31 후속 요구사항 정정
|
||||
|
||||
- 기존 완료 Task와 검증 기록은 당시 구현 기준의 이력으로 보존한다.
|
||||
- 포인트 오디오를 `isPointAvailable == true`만으로 조회하던 계약은 `isPointAvailable == true && price > 0`으로 정정한다.
|
||||
- 무료 오디오의 공개 응답 `isPointAvailable`은 저장값이 true여도 false로 보정한다.
|
||||
- 후속 RED/GREEN/REFACTOR와 완료 증거는 `docs/20260731_무료_콘텐츠_포인트_결제_불가/plan-task.md`의 `P1-T1`, `P2-T1`에서 추적한다.
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
- `mostCommentedAudios`: 최대 5개
|
||||
- `recommendedAudios`: 최대 20개
|
||||
- 특정 섹션 데이터가 부족하면 가능한 개수만 내려주고 전체 API는 성공 처리한다.
|
||||
- 무료/포인트/추천 오디오 섹션 사이에는 같은 오디오가 중복 노출될 수 있다.
|
||||
- 무료 오디오와 포인트 오디오는 가격 조건상 중복 노출하지 않는다. 무료/추천 또는 포인트/추천 섹션 사이의 중복은 허용한다.
|
||||
|
||||
#### Edge Cases
|
||||
- 한 섹션 조회 실패가 전체 API 실패로 이어질지는 구현 계획 단계에서 기존 v2 통합 조회 API의 로깅/실패 정책과 비교해 결정한다.
|
||||
@@ -134,7 +134,7 @@
|
||||
|
||||
#### Requirements
|
||||
- 포인트 사용 가능 오디오 중 랜덤으로 최대 15개 조회한다.
|
||||
- 포인트 오디오는 `isPointAvailable = true`인 공개 오디오로 정의한다.
|
||||
- 포인트 오디오는 `isPointAvailable = true`이면서 `price > 0`인 공개 오디오로 정의한다.
|
||||
- Response는 공통 오디오 카드 응답을 사용한다.
|
||||
|
||||
### Feature H. 최근 댓글이 많은 오디오
|
||||
@@ -285,7 +285,8 @@ data class CommentedAudioResponse(
|
||||
- 최신성 점수의 일수는 날짜 경계가 아니라 시간까지 포함한 24시간 경과 일수 기준으로 계산한다.
|
||||
- New & Hot lazy 보강은 스냅샷 row가 없을 때 Redis marker 기준 KST 날짜별 1회만 시도하고, 보강 후 후보가 0개인 정상 상황에서는 같은 날짜의 다음 조회가 전체 refresh를 반복하지 않는다.
|
||||
- 공통 오디오 카드 응답의 `isOriginalSeries`는 시리즈 미소속 오디오이면 클라이언트 편의를 위해 `false`로 내려준다.
|
||||
- 무료/포인트/추천 오디오처럼 서로 다른 추천 섹션에 같은 콘텐츠가 동시에 포함되어도 서버에서 중복 제거하지 않는다.
|
||||
- 무료 오디오와 포인트 오디오는 각각 `price == 0`, `isPointAvailable == true && price > 0` 조건으로 분리한다.
|
||||
- 무료/추천 또는 포인트/추천처럼 그 밖의 추천 섹션 간 중복은 서버에서 제거하지 않는다.
|
||||
- `mostCommentedAudios` 스냅샷 저장은 같은 크리에이터의 오디오를 최대 1개만 포함한다. 같은 크리에이터 후보가 여러 개이면 `findMostCommentedSnapshots`의 최근 댓글 많은 오디오 점수가 가장 높은 후보를 남기고, `AudioRecommendationSnapshotRefreshService.replaceMostCommentedSnapshots`는 그 결과만 저장한다.
|
||||
|
||||
---
|
||||
@@ -302,3 +303,14 @@ data class CommentedAudioResponse(
|
||||
|
||||
## 12. Open Questions
|
||||
- 없음
|
||||
|
||||
---
|
||||
|
||||
## 13. 후속 요구사항 정정
|
||||
|
||||
| 날짜 | 상태 | 정정 내용 | 기준 문서 |
|
||||
|---|---|---|---|
|
||||
| 2026-07-31 | 확정 | 무료 콘텐츠와 포인트 결제 가능 콘텐츠를 분리하고 `pointAudios`에서 `price == 0`을 제외한다 | `docs/20260731_무료_콘텐츠_포인트_결제_불가/prd.md` |
|
||||
|
||||
- 공통 `AudioCardResponse.isPointAvailable`은 저장값을 그대로 노출하지 않고 `storedIsPointAvailable && price > 0`으로 응답한다.
|
||||
- 이 정정은 기존 완료 기록을 삭제하지 않으며 후속 구현과 검증은 새 통합 `plan-task.md`에서 추적한다.
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
- `type=ORIGINAL`에는 `dayOfWeek`를 적용하지 않는다.
|
||||
- `type=AUDIO`는 `price > 0`인 유료 공개 오디오만 조회한다.
|
||||
- `type=FREE`는 `price == 0`인 무료 공개 오디오만 조회하며 `type=AUDIO` 결과와 겹치지 않는다.
|
||||
- `type=POINT`는 `isPointAvailable == true` 조건을 유지하고 `type=AUDIO`의 유료 조건을 상속하지 않는다.
|
||||
- `type=POINT`는 `isPointAvailable == true && price > 0` 조건을 사용하고 목록과 count에 동일하게 적용한다.
|
||||
- 전체 응답은 `totalCount`, `audios`, `series`, `sort`, `dayOfWeek`, `page`, `size`, `hasNext`를 포함한다.
|
||||
- `AUDIO`, `FREE`, `POINT`는 `audios`만 채우고 `series`는 빈 배열로 내려준다.
|
||||
- `SERIES`, `ORIGINAL`은 `series`만 채우고 `audios`는 빈 배열로 내려준다.
|
||||
@@ -493,7 +493,7 @@ interface MainContentAllQueryPort {
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt`
|
||||
- RED: 공개 오디오만 조회하고 비회원은 성인 오디오를 제외하며 차단 관계 크리에이터의 오디오를 제외하는 repository 테스트를 작성한다.
|
||||
- RED: `FREE` 조회는 `price == 0`, `POINT` 조회는 `isPointAvailable == true` 필터가 적용되는 테스트를 작성한다.
|
||||
- RED: `FREE` 조회는 `price == 0`, `POINT` 조회는 `isPointAvailable == true` 필터가 적용되는 테스트를 작성한다. 이 문장은 2026-06-25 당시 기준이며 2026-07-31 후속 요구사항 정정에서 유료 조건을 추가한다.
|
||||
- RED: `LATEST`, `POPULAR`, `PRICE_HIGH`, `PRICE_LOW` 정렬 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest`
|
||||
- GREEN: `DefaultAudioRecommendationQueryRepository.audioRows(...)`, `DefaultCreatorChannelAudioQueryRepository.findAudioContentRows(...)` 패턴을 참고해 audio count/list를 구현한다.
|
||||
@@ -587,7 +587,7 @@ interface MainContentAllQueryPort {
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt`
|
||||
- RED: repository 테스트에 유료 오디오와 무료 오디오 fixture를 함께 만들고, `onlyPaid=true`인 `countAudios(...)`와 `findAudios(...)`가 `price == 0` 오디오를 제외하는 실패 테스트를 작성한다.
|
||||
- RED: `onlyFree=true`는 기존처럼 `price == 0`만 반환하고, `onlyPointAvailable=true`는 `isPointAvailable == true` 조건을 유지하는 회귀 테스트를 함께 확인한다.
|
||||
- RED: `onlyFree=true`는 기존처럼 `price == 0`만 반환하고, `onlyPointAvailable=true`는 `isPointAvailable == true` 조건을 유지하는 회귀 테스트를 함께 확인한다. 이 문장은 2026-07-10 당시 기준이며 2026-07-31 후속 요구사항 정정에서 유료 조건을 추가한다.
|
||||
- RED: E2E 테스트에서 `GET /api/v2/audio/contents?type=AUDIO`와 type 미지정 기본 조회가 무료 오디오를 반환하지 않는 실패 테스트를 작성한다.
|
||||
- 실패 확인:
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest`
|
||||
@@ -667,3 +667,11 @@ interface MainContentAllQueryPort {
|
||||
- GREEN: `./gradlew test` 성공.
|
||||
- GREEN: `./gradlew ktlintCheck` 성공.
|
||||
- GREEN: `git diff --check` 성공.
|
||||
|
||||
## 2026-07-31 후속 요구사항 정정
|
||||
|
||||
- 기존 완료 Task와 검증 기록은 당시 구현 기준의 이력으로 보존한다.
|
||||
- `type=POINT`가 `isPointAvailable == true`만 사용하던 계약은 `isPointAvailable == true && price > 0`으로 정정한다.
|
||||
- POINT 목록, count와 `hasNext` 후보는 같은 보정 조건을 사용하고 무료 오디오는 제외한다.
|
||||
- 무료 오디오의 공개 응답 `isPointAvailable`은 저장값이 true여도 false로 보정한다.
|
||||
- 후속 RED/GREEN/REFACTOR와 완료 증거는 `docs/20260731_무료_콘텐츠_포인트_결제_불가/plan-task.md`의 `P1-T1`, `P2-T1`에서 추적한다.
|
||||
|
||||
@@ -176,9 +176,9 @@
|
||||
|
||||
#### Requirements
|
||||
- `type=POINT`는 차단 관계가 아닌 모든 크리에이터의 포인트 사용 가능 오디오 콘텐츠를 조회한다.
|
||||
- 포인트 오디오는 `isPointAvailable == true`인 공개 오디오로 정의한다.
|
||||
- 포인트 오디오는 `isPointAvailable == true`이면서 `price > 0`인 공개 오디오로 정의한다.
|
||||
- 공개/차단/성인 콘텐츠 정책과 정렬, 페이징, 전체 개수 산정 방식은 오디오 공통 정책을 따른다.
|
||||
- `type=POINT`는 `type=AUDIO`의 유료 조건을 상속하지 않고 `isPointAvailable == true` 조건만 추가한다.
|
||||
- `type=POINT`는 `isPointAvailable == true && price > 0` 조건을 사용하고 목록과 전체 개수에 동일하게 적용한다.
|
||||
- 응답 목록은 `audios`에 내려주고 `series`는 빈 배열로 내려준다.
|
||||
|
||||
#### Edge Cases
|
||||
@@ -317,7 +317,7 @@ data class MainContentSeriesResponse(
|
||||
|
||||
### 구현 주의사항
|
||||
- `type=AUDIO`는 `price > 0` 조건을 적용하고, `type=FREE`는 `price == 0` 조건을 적용해 두 구분의 결과가 겹치지 않게 한다.
|
||||
- `type=POINT`는 `isPointAvailable == true` 조건을 유지하며, `AUDIO` 전용 유료 필터를 암묵적으로 재사용하지 않는다.
|
||||
- `type=POINT`는 `isPointAvailable == true && price > 0` 조건을 사용하며, 목록과 count가 같은 조건 함수를 공유한다.
|
||||
- 기존 추천 탭의 무료/포인트 오디오는 랜덤 조회지만, 전체 탭은 사용자가 선택한 `sort` 기준으로 조회한다.
|
||||
- 기존 legacy 요일별 시리즈 API는 `dayOfWeek` query parameter로 `SeriesPublishedDaysOfWeek` enum을 받으므로 v2 전체 탭도 같은 parameter 이름과 enum 값을 사용한다.
|
||||
- 기존 v2 채널 오디오/시리즈 탭처럼 invalid parameter fallback을 유지하려면 controller에서는 `dayOfWeek: String?`으로 받고 policy/service 경계에서 `SeriesPublishedDaysOfWeek`로 보정한다.
|
||||
@@ -339,3 +339,14 @@ data class MainContentSeriesResponse(
|
||||
|
||||
## 12. Open Questions
|
||||
- 없음. endpoint는 기존 메인 콘텐츠 v2 endpoint 축에 맞춰 `GET /api/v2/audio/contents`로 확정한다.
|
||||
|
||||
---
|
||||
|
||||
## 13. 후속 요구사항 정정
|
||||
|
||||
| 날짜 | 상태 | 정정 내용 | 기준 문서 |
|
||||
|---|---|---|---|
|
||||
| 2026-07-31 | 확정 | `type=POINT`에서 무료 콘텐츠를 제외하고 목록·`totalCount`·`hasNext` 후보에 같은 조건을 적용한다 | `docs/20260731_무료_콘텐츠_포인트_결제_불가/prd.md` |
|
||||
|
||||
- `MainContentAudioResponse.isPointAvailable`은 `storedIsPointAvailable && price > 0`으로 응답한다.
|
||||
- 이 정정은 기존 완료 기록을 삭제하지 않으며 후속 구현과 검증은 새 통합 `plan-task.md`에서 추적한다.
|
||||
|
||||
410
docs/20260731_무료_콘텐츠_포인트_결제_불가/plan-task.md
Normal file
410
docs/20260731_무료_콘텐츠_포인트_결제_불가/plan-task.md
Normal file
@@ -0,0 +1,410 @@
|
||||
# 무료 콘텐츠 포인트 결제 불가 구현 계획
|
||||
|
||||
| 문서 항목 | 내용 |
|
||||
|---|---|
|
||||
| 상태 | 리뷰 후속 작업 완료 |
|
||||
| 작성일 | 2026-07-31 |
|
||||
| 요구사항 기준 | `docs/20260731_무료_콘텐츠_포인트_결제_불가/prd.md` |
|
||||
| API 기준 | PRD `8. API 계약` |
|
||||
| 현재 Phase | Phase 1 회귀 수정 완료 |
|
||||
| 현재 활성 Goal | 없음 |
|
||||
|
||||
## 목표
|
||||
|
||||
무료 오디오 콘텐츠가 소비자 조회에서 포인트 결제 가능으로 노출되거나 POINT 전용 목록에 포함되지 않게 한다.
|
||||
|
||||
## 구현 방식
|
||||
|
||||
- 응답 DTO의 공개 필드와 내부 조회 record는 변경하지 않는다.
|
||||
- 각 소비자 응답 조립 지점에서 `isPointAvailable && price > 0`을 적용한다.
|
||||
- 추천과 전체 탭 POINT repository 조건에 `price > 0`을 추가한다.
|
||||
- 관리자 mapper와 저장 로직은 수정하지 않는다.
|
||||
- 단일 식 적용을 위한 새 공통 abstraction이나 dependency는 만들지 않는다.
|
||||
|
||||
## 현재 상태
|
||||
|
||||
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
||||
|---:|---|---:|---|---|
|
||||
| 1. 소비자 응답 정규화 | 완료 | `3/3` | 없음 | 없음 |
|
||||
| 2. POINT 조회 조건 보정 | 완료 | `1/1` | 없음 | 없음 |
|
||||
|
||||
- 동시에 하나의 미완료 goal만 운용한다.
|
||||
- 완료된 Task와 검증 기록은 되돌리거나 삭제하지 않는다.
|
||||
- goal에는 token budget을 설정하지 않는다.
|
||||
|
||||
## 범위
|
||||
|
||||
### 포함
|
||||
|
||||
- legacy 콘텐츠 상세 `isAvailableUsePoint` 보정
|
||||
- v2 콘텐츠 overview·전체 탭·추천 탭 응답 보정
|
||||
- v2 홈 추천 첫 오디오 응답 보정
|
||||
- v2 크리에이터 채널 홈·오디오·라이브의 공통 오디오 응답 보정
|
||||
- 추천 `pointAudios`와 전체 탭 `type=POINT`의 유료 조건 보강
|
||||
- 전체 탭 POINT count·pagination 회귀 검증
|
||||
- AI 캐릭터 관리자 조회 원본값 유지 회귀 검증
|
||||
|
||||
### 제외
|
||||
|
||||
- DB 저장값과 기존 데이터 변경
|
||||
- 콘텐츠 생성·수정 validation 변경
|
||||
- 관리자 콘텐츠 mapper·service 변경
|
||||
- legacy 콘텐츠 상세 이외의 legacy 조회 API 변경
|
||||
- 결제·주문·포인트 차감 로직 변경
|
||||
- API schema와 dependency 변경
|
||||
|
||||
## 기술적 제약
|
||||
|
||||
- 기술 스택: Kotlin, Java 17, Spring Boot 2.7.14, JUnit 5, QueryDSL/JPA.
|
||||
- 기존 package와 mapper/repository 책임을 유지한다.
|
||||
- 공개 API 필드명 `isAvailableUsePoint`, `isPointAvailable`을 유지한다.
|
||||
- `price == 0`은 무료, 실질 포인트 가능 여부는 `configured && price > 0`으로 고정한다.
|
||||
- POINT 조회의 목록과 count는 같은 repository 조건을 사용한다.
|
||||
- 모든 구현 Task는 `RED → RED 확인 → GREEN → GREEN 확인 → REFACTOR` 순서로 실행한다.
|
||||
- focused test부터 실행하고 최종 Gate에서 영향 범위 회귀와 전체 `test`를 실행한다.
|
||||
|
||||
## Phase 1: 소비자 응답 정규화
|
||||
|
||||
**Phase 결과:** 무료·저장값 true인 콘텐츠가 대상 소비자 응답에서는 false로 보이지만 관리자 조회에서는 true를 유지한다.
|
||||
|
||||
**선행조건:** PRD `POINT-001~004`, `POINT-007~008` 확정.
|
||||
|
||||
**Phase 완료 조건:** `P1-T1`과 `P1-GATE` 완료, 검증 기록 누적.
|
||||
|
||||
**후속 리뷰 완료 조건:** `P1-R1` 완료, `P1-GATE` 재검증과 수정 후 검증 기록 누적.
|
||||
|
||||
### Task 1.1 소비자 응답의 실질 포인트 가능 여부 적용
|
||||
|
||||
**Goal 실행 `P1-T1`:** legacy 상세와 모든 대상 v2 응답 조립 경계에서 가격을 반영한 포인트 가능 여부를 반환한다.
|
||||
|
||||
- **시작 조건:** PRD `DEC-001`, `DEC-003` 확인.
|
||||
- **완료 증거:** RED/GREEN 체크박스, 대상 mapper 테스트, 관리자 원본값 회귀 테스트와 Progress 기록.
|
||||
- **범위 밖:** POINT 전용 repository 필터와 콘텐츠 저장값 변경.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponse.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponse.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/common/dto/CreatorChannelAudioContentResponse.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponseTest.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponseTest.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt`
|
||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/common/dto/CreatorChannelAudioContentResponseTest.kt`
|
||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: 각 mapper가 이미 받는 `price: Int`와 저장된 Boolean 필드.
|
||||
- Produces: schema를 바꾸지 않고 `configured && price > 0`으로 보정된 소비자 응답 Boolean.
|
||||
- Preserves: AI 캐릭터 관리자 목록·상세의 원본 Boolean.
|
||||
|
||||
- [x] **RED:** 각 응답 경계에 무료·저장값 true fixture를 추가하고 소비자 응답은 false, 유료·저장값 true 응답은 true로 기대한다. 관리자 상세에는 무료·저장값 true가 true로 유지되는 회귀 assertion을 추가한다.
|
||||
- [x] **RED 확인:** 아래 focused test를 실행해 소비자 응답이 현재 true를 전달하여 발생하는 assertion 실패를 확인하고, 관리자 회귀 assertion은 기존 동작으로 통과하는지 구분해 기록한다.
|
||||
|
||||
```bash
|
||||
./gradlew test \
|
||||
--tests 'kr.co.vividnext.sodalive.content.AudioContentServiceTest' \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewPageResponseTest' \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponseTest' \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.application.AudioRecommendationFacadeTest' \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.common.dto.CreatorChannelAudioContentResponseTest' \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacadeTest' \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentControllerTest'
|
||||
```
|
||||
|
||||
- [x] **GREEN:** 소비자 응답 조립 지점의 Boolean 대입을 `storedValue && price > 0`으로 바꾼다. `AiCharacterAdminAudioContentMapper`와 관리자 service는 수정하지 않는다.
|
||||
- [x] **GREEN 확인:** 같은 focused test 명령을 다시 실행해 무료 true → false, 유료 true → true, 관리자 무료 true → true가 모두 통과하는지 확인한다.
|
||||
- [x] **REFACTOR:** 동일한 한 줄 식을 유지하고 새 helper나 구조 변경을 추가하지 않는다. 변경 파일에 `./gradlew ktlintCheck`를 실행하고 결과를 Progress에 기록한다.
|
||||
|
||||
### Phase 1 Gate
|
||||
|
||||
**Goal 실행 `P1-GATE`:** 대상 소비자 응답 계약과 관리자 제외 계약을 최종 판정한다.
|
||||
|
||||
- **시작 조건:** `P1-T1` 완료.
|
||||
- **완료 증거:** 아래 명령 성공, 공개 필드명 유지 확인과 Progress 기록.
|
||||
- **범위 밖:** POINT repository 필터 구현과 관련 없는 응답 리팩터링.
|
||||
|
||||
```bash
|
||||
./gradlew test \
|
||||
--tests 'kr.co.vividnext.sodalive.content.AudioContentServiceTest' \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.api.content.*' \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.*' \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.api.home.*'
|
||||
./gradlew ktlintCheck
|
||||
```
|
||||
|
||||
**Expected:** 대상 소비자 응답에서 무료 콘텐츠가 포인트 가능으로 노출되지 않고 기존 JSON 필드 집합이 유지된다.
|
||||
|
||||
### Task 1.2 소비자·관리자 포인트 가능 계약의 회귀 증거 보강
|
||||
|
||||
**Goal 실행 `P1-R1`:** 소비자 응답의 저장값 false 조건과 무료 관리자 응답의 원본값 유지 조건을 자동 회귀 테스트로 증명한다.
|
||||
|
||||
- **시작 조건:** `REV-P1-001`, `REV-P1-002` 확정과 기존 `P1-GATE` 완료.
|
||||
- **완료 증거:** 대상 소비자 경계의 `price > 0, storedIsPointAvailable == false` assertion, 관리자 목록·상세의
|
||||
`price == 0, storedIsPointAvailable == true` assertion, focused test와 `P1-GATE` 재검증 기록.
|
||||
- **범위 밖:** production 코드 변경, 관리자 응답 보정, 공개 API schema 변경.
|
||||
- **TDD 예외 사유:** production 구현은 정적 검토상 계약을 이미 충족하며, 누락된 것은 완료 근거인 회귀 assertion이다.
|
||||
의도적인 production 결함을 만들어 RED를 재현하지 않는다.
|
||||
- **대체 검증 방법:** 기존 fixture가 누락한 진리표 조건을 추가하고 focused test와 `P1-GATE`를 통과시킨 뒤 mapper 식과
|
||||
관리자 원본 전달 식을 다시 대조한다.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponseTest.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponseTest.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/common/dto/CreatorChannelAudioContentResponseTest.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt`
|
||||
- Modify: `docs/20260731_무료_콘텐츠_포인트_결제_불가/plan-task.md`
|
||||
|
||||
- [x] 대상 소비자 응답 경계마다 `price > 0, storedIsPointAvailable == false`가 false로 유지되는 assertion을 추가한다.
|
||||
- [x] AI 캐릭터 관리자 목록·상세 fixture를 `price == 0, storedIsPointAvailable == true`로 구성하고 저장값 true가 그대로
|
||||
반환되는지 확인한다.
|
||||
- [x] Task 1.1 focused test와 `P1-GATE`를 재실행하고 결과를 Progress에 누적한다.
|
||||
- [x] `REV-P1-001`, `REV-P1-002`의 수정 후 검증 기록과 이 문서의 현재 상태를 갱신한다.
|
||||
|
||||
### Task 1.3 legacy 상세의 유료 포인트 가능 positive 회귀 증거 보강
|
||||
|
||||
**Goal 실행 `P1-R2`:** legacy 상세에서 `price > 0, storedIsPointAvailable == true`가 true를 반환하는 계약을 자동 회귀 테스트로 증명한다.
|
||||
|
||||
- **시작 조건:** `REV-P1-003` 확정과 `P1-R1` 완료.
|
||||
- **완료 증거:** legacy 상세의 무료·저장값 true → false, 유료·저장값 false → false,
|
||||
유료·저장값 true → true assertion, focused test와 `P1-GATE` 재검증 기록.
|
||||
- **범위 밖:** production 코드, v2 응답 test, 관리자 mapper/service 변경.
|
||||
- **TDD 예외 사유:** production 구현은 정적 검토상 positive 계약을 이미 충족하며, 누락된 것은 회귀 assertion이다.
|
||||
의도적인 production 결함을 만들어 RED를 재현하지 않는다.
|
||||
- **대체 검증 방법:** 기존 legacy 상세 test의 진리표를 완성하고 focused test와 `P1-GATE`를 통과시킨다.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
|
||||
- Modify: `docs/20260731_무료_콘텐츠_포인트_결제_불가/plan-task.md`
|
||||
|
||||
- [x] legacy 상세 test에 `price > 0, storedIsPointAvailable == true` 응답이 true인 assertion을 추가한다.
|
||||
- [x] 하나의 test가 세 계약 조건을 드러내도록 `DisplayName`과 test 함수명을 맞춘다.
|
||||
- [x] `AudioContentServiceTest`, `P1-GATE`, `ktlintCheck`를 실행하고 결과를 Progress에 누적한다.
|
||||
- [x] `REV-P1-003`의 수정 후 검증 기록과 이 문서의 현재 상태를 갱신한다.
|
||||
|
||||
## Phase 2: POINT 조회 조건 보정
|
||||
|
||||
**Phase 결과:** 추천과 전체 탭 POINT 목록, count와 pagination 후보에서 무료 콘텐츠가 제외된다.
|
||||
|
||||
**선행조건:** `P1-GATE` 완료와 PRD `POINT-005~006` 확정.
|
||||
|
||||
**Phase 완료 조건:** `P2-T1`과 `P2-GATE` 완료, 검증 기록 누적.
|
||||
|
||||
### Task 2.1 추천·전체 탭 POINT 조회에 유료 조건 적용
|
||||
|
||||
**Goal 실행 `P2-T1`:** 두 POINT 조회가 `isPointAvailable == true && price > 0` 조건을 공통으로 사용한다.
|
||||
|
||||
- **시작 조건:** `P1-GATE` 완료, PRD `DEC-002` 확인.
|
||||
- **완료 증거:** repository RED/GREEN, endpoint E2E와 Progress 기록.
|
||||
- **범위 밖:** FREE/AUDIO/추천 점수·랜덤 정렬·공개/성인/차단 조건 변경.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: 기존 `audioContent.isPointAvailable`과 `audioContent.price` QueryDSL 필드.
|
||||
- Produces: 추천 `pointAudios`, 전체 탭 POINT 목록과 count에 공통 적용되는 `isPointAvailable.isTrue.and(price.gt(0))` 조건.
|
||||
- Preserves: FREE 목록은 `price.eq(0)`, AUDIO 목록은 `price.gt(0)`인 기존 조건.
|
||||
|
||||
- [x] **RED:** 두 repository fixture에 `price = 0, isPointAvailable = true`와 `price > 0, isPointAvailable = true`를 함께 두고 POINT 결과에는 유료 항목만 포함되도록 기대한다. 전체 탭 E2E는 `totalCount`, `audios`, `hasNext`가 같은 후보 집합을 반영하도록 기대한다.
|
||||
- [x] **RED 확인:** 아래 focused test를 실행해 무료 true 콘텐츠가 POINT 결과에 포함되어 발생하는 목록 또는 count assertion 실패를 확인한다.
|
||||
|
||||
```bash
|
||||
./gradlew test \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest' \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest' \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest' \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest'
|
||||
```
|
||||
|
||||
- [x] **GREEN:** `findPointAudios` 조건과 `optionalAudioPointCondition`에 `price.gt(0)`을 결합한다. 전체 탭 count와 목록은 기존 `audioCondition`을 계속 공유한다.
|
||||
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 POINT 결과에서 무료가 제외되고 유료 true는 유지되며 `totalCount`와 `hasNext`가 일치하는지 확인한다.
|
||||
- [x] **REFACTOR:** FREE/AUDIO 조건, 랜덤/가격/인기 정렬과 공통 visibility 조건이 바뀌지 않았는지 직접 영향 회귀와 `ktlintCheck`로 확인한다.
|
||||
|
||||
### Phase 2 Gate
|
||||
|
||||
**Goal 실행 `P2-GATE`:** 소비자 응답과 POINT 조회의 전체 요구사항을 최종 판정한다.
|
||||
|
||||
- **시작 조건:** `P2-T1` 완료.
|
||||
- **완료 증거:** focused·전체 회귀·lint 성공, 문서 Progress와 최종 검증 기록.
|
||||
- **범위 밖:** 실패를 숨기기 위한 test 삭제·skip·완화와 관련 없는 코드 수정.
|
||||
|
||||
```bash
|
||||
./gradlew test \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.content.recommendation.*' \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.content.all.*' \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.api.content.*'
|
||||
./gradlew test
|
||||
./gradlew ktlintCheck
|
||||
```
|
||||
|
||||
**Expected:** 모든 테스트와 lint가 성공하고 무료 콘텐츠가 어떤 대상 소비자 응답이나 POINT 전용 결과에서도 포인트 결제 가능으로 취급되지 않는다.
|
||||
|
||||
## 실행 순서와 의존성
|
||||
|
||||
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|
||||
|---:|---|---|---|---|
|
||||
| 1 | `P1-T1` | 없음 | 아니요 | 실패 응답 경계와 fixture를 다시 대조 |
|
||||
| 2 | `P1-GATE` | `P1-T1` | 아니요 | 실패 소유 mapper의 회귀 수정 goal 추가 |
|
||||
| 3 | `P2-T1` | `P1-GATE` | 아니요 | 목록·count 조건 공유 여부 확인 |
|
||||
| 4 | `P2-GATE` | `P2-T1` | 아니요 | 실패 소유 repository 또는 mapper로 되돌림 |
|
||||
| 5 | `P1-R1` | Phase별 review 판정 완료 | 아니요 | 누락된 계약 fixture와 assertion 범위를 다시 대조 |
|
||||
| 6 | `P1-R2` | `P1-R1` | 아니요 | legacy 상세의 세 계약 조건을 다시 대조 |
|
||||
|
||||
```text
|
||||
P1-T1 → P1-GATE → P2-T1 → P2-GATE
|
||||
```
|
||||
|
||||
리뷰 후속 실행 순서:
|
||||
|
||||
```text
|
||||
P1-R1 → P1-R2
|
||||
```
|
||||
|
||||
## 변경 금지 항목
|
||||
|
||||
- 관리자 조회와 DB 저장값을 보정하지 않는다.
|
||||
- 공개 DTO 필드를 추가·삭제·이름 변경하지 않는다.
|
||||
- POINT 조건 보강을 FREE/AUDIO/추천 점수·정렬 변경으로 확장하지 않는다.
|
||||
- 새 dependency, schema migration과 공통 abstraction을 추가하지 않는다.
|
||||
- 기존 완료 문서의 체크박스와 검증 기록을 삭제하거나 덮어쓰지 않는다.
|
||||
- test를 삭제·skip·완화해 Gate를 통과시키지 않는다.
|
||||
|
||||
## Progress
|
||||
|
||||
실제 구현 시 기존 기록을 삭제하거나 덮어쓰지 않고 Goal 실행 결과를 차수별로 누적한다.
|
||||
|
||||
### 문서 작성 검증 — 2026-07-31
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: 확정 요구사항을 새 PRD와 goal 실행형 계획에 기록하고 기존 추천·전체 탭 문서의 충돌 계약을 정정했다.
|
||||
- 왜: 구현 전에 단일 기준 문서, 범위, 제외 조건과 완료 증거를 확정하기 위해서다.
|
||||
- 어떻게:
|
||||
- `rg` placeholder·요구사항/Goal 추적 검색 — placeholder 없음, `POINT-001~008`과 `P1-T1`·`P2-T1` 연결 확인.
|
||||
- 계획에 기록된 production 파일 존재 확인 — 누락 없음.
|
||||
- `git diff --check` — 출력 없음.
|
||||
- `./gradlew --no-daemon tasks --all` — `BUILD SUCCESSFUL`, exit code 0.
|
||||
- 구현 test: 문서만 변경했으므로 실행하지 않았다.
|
||||
- 남은 항목: `P1-T1`부터 구현 실행.
|
||||
- 다음 행동: 사용자가 구현을 요청하면 `P1-T1`의 RED부터 시작한다.
|
||||
|
||||
### 구현 검증 — 2026-07-31
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: 무료 콘텐츠 포인트 결제 불가 정책을 대상 소비자 응답과 POINT 전용 조회 조건에 적용했다.
|
||||
- 왜: `price == 0` 콘텐츠가 포인트 결제 가능 상태와 동시에 노출되거나 POINT 목록·개수·페이징 후보에 포함되지 않도록 하기 위해서다.
|
||||
- 어떻게:
|
||||
- P1 RED focused test — 소비자 응답 경계에서 무료·저장값 true가 기존 true로 전달되어 assertion 실패 확인.
|
||||
- P1 GREEN focused test — `BUILD SUCCESSFUL`, 무료 true → false, 유료 true → true, 관리자 상세 원본 true 유지 확인.
|
||||
- P1-GATE — 원 wildcard 명령은 600초 제한 초과로 분할 실행했고, content 범위 `BUILD SUCCESSFUL`(9m31s), creator/home 범위 `BUILD SUCCESSFUL`(4m27s), `./gradlew ktlintCheck` `BUILD SUCCESSFUL`(1m02s).
|
||||
- P2 RED focused test — recommendation/main-all repository와 E2E에서 무료·저장값 true가 POINT 후보에 포함되어 4개 assertion 실패 확인.
|
||||
- P2 GREEN focused test — `BUILD SUCCESSFUL`, 추천 `pointAudios`와 전체 탭 POINT 목록·`totalCount`·`hasNext`가 유료 포인트 후보만 반영함을 확인.
|
||||
- P2-GATE 영향 범위 — `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.recommendation.*' --tests 'kr.co.vividnext.sodalive.v2.content.all.*' --tests 'kr.co.vividnext.sodalive.v2.api.content.*'` `BUILD SUCCESSFUL`(1m48s).
|
||||
- 최종 Gate — `./gradlew test` `BUILD SUCCESSFUL`(13m27s), `./gradlew ktlintCheck` `BUILD SUCCESSFUL`(40s).
|
||||
- 결정: 관리자 mapper/service, DB 저장값, 공개 DTO 필드명과 구조는 변경하지 않았다.
|
||||
- 남은 항목: 없음.
|
||||
|
||||
### Phase별 리뷰 — 2026-07-31
|
||||
|
||||
- 상태: Phase 1 수정 goal 필요, Phase 2 확정 발견 사항 없음.
|
||||
- 무엇을: PRD, 구현 계획, staged production/test diff와 관련 호출 경계를 Phase별로 대조했다.
|
||||
- 왜: 완료 체크와 실제 계약 증거가 일치하는지 판정하기 위해서다.
|
||||
- 어떻게:
|
||||
- `git diff --cached --check` — 출력 없음.
|
||||
- `rg`로 대상 응답 mapper, POINT repository 조건과 관련 test assertion을 대조했다.
|
||||
- `./gradlew --no-daemon tasks --all` — `BUILD SUCCESSFUL`, exit code 0.
|
||||
- 사용자 지시에 따라 compile과 test는 다시 실행하지 않았으며 기존 구현 검증 기록을 근거로만 확인했다.
|
||||
- 후속: `REV-P1-001`, `REV-P1-002`를 `P1-R1`로 전환했다.
|
||||
- 리뷰 문서:
|
||||
- `docs/20260731_무료_콘텐츠_포인트_결제_불가/reviews/phase-1-review.md`
|
||||
- `docs/20260731_무료_콘텐츠_포인트_결제_불가/reviews/phase-2-review.md`
|
||||
|
||||
### 리뷰 후속 검증 — 2026-07-31
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: `REV-P1-001`, `REV-P1-002`의 누락된 회귀 증거를 `P1-R1`로 보강했다.
|
||||
- 왜: 소비자 응답의 저장값 false 조건과 AI 캐릭터 관리자 무료 원본값 유지 조건을 자동 테스트로 고정하기 위해서다.
|
||||
- 어떻게:
|
||||
- P1-R1 focused test — `./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewPageResponseTest' --tests 'kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponseTest' --tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.application.AudioRecommendationFacadeTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.common.dto.CreatorChannelAudioContentResponseTest' --tests 'kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacadeTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentControllerTest'` `BUILD SUCCESSFUL`(3m11s).
|
||||
- P1-GATE — `./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.content.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*'` `BUILD SUCCESSFUL`(12m15s).
|
||||
- `./gradlew ktlintCheck` — `BUILD SUCCESSFUL`(47s).
|
||||
- 결정: production 코드, 관리자 mapper/service, 공개 DTO schema는 변경하지 않았다.
|
||||
- 남은 항목: 없음.
|
||||
|
||||
### 2차 Phase 1 리뷰 — 2026-07-31
|
||||
|
||||
- 상태: `REV-P1-001`, `REV-P1-002` 수정 확인, 추가 수정 goal 필요.
|
||||
- 무엇을: `P1-R1` test diff, Phase 1 production mapper, 관리자 제외 경계와 완료 기록을 다시 대조했다.
|
||||
- 왜: 기존 확정 발견 사항이 실제로 수정됐는지, 계약 진리표에 다른 누락은 없는지 판정하기 위해서다.
|
||||
- 어떻게:
|
||||
- staged diff와 `rg`로 6개 소비자 조립 경계의 유료·저장값 false assertion을 확인했다.
|
||||
- 관리자 목록·상세의 `price == 0, stored == true` fixture와 true assertion을 확인했다.
|
||||
- `git diff --check`, `git diff --cached --check` — 출력 없음.
|
||||
- `./gradlew --no-daemon tasks --all` — `BUILD SUCCESSFUL`, exit code 0.
|
||||
- 사용자 지시에 따라 compile과 test는 다시 실행하지 않았다.
|
||||
- 후속: legacy 상세의 유료·저장값 true positive assertion 누락을 `REV-P1-003`, `P1-R2`로 전환했다.
|
||||
|
||||
### 리뷰 후속 검증 2차 — 2026-07-31
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: `REV-P1-003`의 누락된 legacy 상세 positive 회귀 증거를 `P1-R2`로 보강했다.
|
||||
- 왜: legacy 상세에서 무료·저장값 true, 유료·저장값 false, 유료·저장값 true의 포인트 사용 가능 계약을 하나의 test로 고정하기 위해서다.
|
||||
- 어떻게:
|
||||
- P1-R2 focused test — `./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentServiceTest'` `BUILD SUCCESSFUL`(14s).
|
||||
- P1-GATE 단위 범위 — `./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.content.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*'` `BUILD SUCCESSFUL`(2m 26s).
|
||||
- `./gradlew ktlintCheck` — `BUILD SUCCESSFUL`(14s).
|
||||
- 결정: production 코드, 관리자 mapper/service, 공개 DTO schema는 변경하지 않았다.
|
||||
- 전체 테스트: 사용자 지시에 따라 실행하지 않았다.
|
||||
- 남은 항목: 없음.
|
||||
|
||||
## Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-07-31 | `PLAN-DEC-001` | 확정 | 응답 조립 지점에서 `configured && price > 0`을 직접 적용하고 새 abstraction을 만들지 않는다 | 기존 mapper 책임과 최소 변경 원칙 | `P1-T1` |
|
||||
| 2026-07-31 | `PLAN-DEC-002` | 확정 | POINT repository 조건에 `price > 0`을 결합하고 전체 탭 목록과 count의 기존 조건 공유 구조를 유지한다 | 목록·count 일관성 | `P2-T1` |
|
||||
| 2026-07-31 | `PLAN-DEC-003` | 확정 | 여러 소비자 API 경계를 변경하므로 최종 Gate에서 전체 `test`를 실행한다 | 영향 범위 회귀 증거 필요 | `P2-GATE` |
|
||||
|
||||
## 발견된 문제
|
||||
|
||||
초기 구현 시 발견된 문제 없음.
|
||||
|
||||
### 2026-07-31 Phase별 리뷰
|
||||
|
||||
- `REV-P1-001` — 소비자 응답 테스트에 `price > 0, storedIsPointAvailable == false` 계약 증거가 없다.
|
||||
- `REV-P1-002` — 관리자 회귀 테스트가 무료 fixture로 저장값 유지 계약을 검증하지 않는다.
|
||||
- `REV-P1-003` — legacy 상세 test가 유료·저장값 true의 positive 계약을 검증하지 않는다.
|
||||
- 후속 Goal: `P1-R1`
|
||||
- 처리: 2026-07-31 `P1-R1` 완료.
|
||||
- 추가 후속 Goal: `P1-R2`
|
||||
- 처리: 2026-07-31 `P1-R2` 완료.
|
||||
- Phase 2: 확정 발견 사항 없음.
|
||||
|
||||
## 최종 보고 형식
|
||||
|
||||
```markdown
|
||||
구현 결과: 무료 콘텐츠 포인트 결제 불가 정책을 소비자 응답과 POINT 조회에 적용
|
||||
|
||||
- 변경: 대상 응답 mapper와 추천·전체 탭 POINT 조회 조건
|
||||
- 결정: 관리자·DB 원본 유지, 공개 schema 유지
|
||||
- 검증: focused test, 영향 범위 회귀, 전체 test, ktlintCheck 결과
|
||||
- 남은 항목: 없음 또는 실패·외부 조건
|
||||
- 문서: PRD와 이 plan-task.md의 Progress·검증 기록
|
||||
```
|
||||
171
docs/20260731_무료_콘텐츠_포인트_결제_불가/prd.md
Normal file
171
docs/20260731_무료_콘텐츠_포인트_결제_불가/prd.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# PRD: 무료 콘텐츠 포인트 결제 불가
|
||||
|
||||
## 문서 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 문서 상태 | 구현 기준 확정 |
|
||||
| 작성일 | 2026-07-31 |
|
||||
| 최종 수정일 | 2026-07-31 |
|
||||
| 대상 제품 | 소비자용 오디오 콘텐츠 조회 API |
|
||||
| 작성자·결정권자 | 사용자 |
|
||||
| 관련 API Contract | 별도 문서 없음. 이 문서의 `8. API 계약`을 기준으로 사용 |
|
||||
| 관련 구현 계획 | `docs/20260731_무료_콘텐츠_포인트_결제_불가/plan-task.md` |
|
||||
| 관련 review | `reviews/phase-1-review.md`, `reviews/phase-2-review.md` |
|
||||
|
||||
## 1. Overview
|
||||
|
||||
무료 오디오 콘텐츠와 포인트 결제 가능 상태가 소비자 화면에서 동시에 노출되지 않도록 조회 계약을 보정한다.
|
||||
저장된 포인트 결제 가능 설정은 유지하되, 소비자용 응답과 포인트 전용 목록에서는 가격을 함께 반영한 실질 상태를 사용한다.
|
||||
|
||||
## 2. Problem Statement
|
||||
|
||||
- 현재 일부 조회 응답은 `price == 0`이면서 저장된 `isPointAvailable == true`인 콘텐츠를 그대로 포인트 결제 가능으로 노출한다.
|
||||
- 포인트 추천과 전체 탭 POINT 조회는 저장된 `isPointAvailable`만 필터링해 무료 콘텐츠가 포함될 수 있다.
|
||||
- 무료와 포인트 결제 가능 상태가 함께 노출되면 클라이언트의 가격 표시와 결제 진입 판단이 서로 모순될 수 있다.
|
||||
|
||||
문제를 해결했다는 판단은 소비자용 모든 대상 응답에서 무료 콘텐츠의 포인트 결제 가능 여부가 `false`이고,
|
||||
POINT 전용 목록과 개수에서 무료 콘텐츠가 제외되는 것으로 한다.
|
||||
|
||||
## 3. Goals
|
||||
|
||||
- 무료 콘텐츠의 소비자용 포인트 결제 가능 여부를 항상 `false`로 응답한다.
|
||||
- 유료이면서 저장된 포인트 결제 가능 설정이 `true`인 콘텐츠는 기존처럼 `true`로 응답한다.
|
||||
- 포인트 전용 목록, 전체 개수와 페이징 판단에서 무료 콘텐츠를 제외한다.
|
||||
- 기존 공개 API 필드명, 응답 구조와 DB 저장값을 변경하지 않는다.
|
||||
|
||||
## 4. Non-Goals
|
||||
|
||||
- 콘텐츠 생성·수정 시 `isPointAvailable` 저장값을 강제로 변경하지 않는다.
|
||||
- 기존 데이터의 일괄 수정이나 DB migration을 수행하지 않는다.
|
||||
- `/api/v2/admin/ai-characters/**/audio-contents` 관리자 목록·상세의 저장값 표현을 변경하지 않는다.
|
||||
- `/audio-content/{id}` 상세를 제외한 legacy 목록·추천·랭킹 API는 변경하지 않는다.
|
||||
- 콘텐츠 구매·대여·소장·포인트 차감 로직은 변경하지 않는다.
|
||||
- 공개 DTO의 필드 추가·삭제·이름 변경을 수행하지 않는다.
|
||||
|
||||
## 5. Target Users and Permissions
|
||||
|
||||
| 사용자 | 목표 | 주요 작업 | 적용 범위 |
|
||||
|---|---|---|---|
|
||||
| 소비자 | 무료 콘텐츠를 포인트 결제 대상으로 오인하지 않는다 | 콘텐츠 상세·목록·추천 조회 | 대상 소비자용 API |
|
||||
| 관리자 | 저장된 콘텐츠 설정을 그대로 확인한다 | AI 캐릭터 콘텐츠 목록·상세 조회 | 변경 제외 |
|
||||
|
||||
기존 endpoint별 인증·성인 노출·차단 관계·공개 상태 정책은 변경하지 않는다.
|
||||
|
||||
## 6. 핵심 정책
|
||||
|
||||
### 6.1 가격과 포인트 결제 가능 여부
|
||||
|
||||
- 무료 콘텐츠는 `price == 0`으로 정의한다.
|
||||
- 소비자에게 노출하는 실질 포인트 결제 가능 여부는 다음 식으로 정의한다.
|
||||
|
||||
```text
|
||||
effectivePointAvailable = storedIsPointAvailable && price > 0
|
||||
```
|
||||
|
||||
- `price == 0`이고 저장값이 `true`이면 소비자 응답은 `false`다.
|
||||
- `price > 0`이고 저장값이 `true`이면 소비자 응답은 `true`다.
|
||||
- 저장값이 `false`이면 가격과 관계없이 소비자 응답은 `false`다.
|
||||
- 이 정책은 응답 조립 시 적용하며 엔티티의 저장값은 변경하지 않는다.
|
||||
|
||||
### 6.2 포인트 전용 조회
|
||||
|
||||
- 포인트 전용 콘텐츠는 `isPointAvailable == true && price > 0`인 공개 오디오로 정의한다.
|
||||
- `GET /api/v2/audio/recommendations`의 `pointAudios`는 이 조건을 사용한다.
|
||||
- `GET /api/v2/audio/contents?type=POINT`의 목록과 `totalCount`는 동일한 조건을 사용한다.
|
||||
- `hasNext`는 보정된 목록 조건으로 조회한 `size + 1` 결과를 기준으로 기존 방식대로 계산한다.
|
||||
- 무료 콘텐츠는 `isPointAvailable == true`로 저장되어 있어도 POINT 목록, 개수와 페이징 후보에서 제외한다.
|
||||
|
||||
## 7. 기능 요구사항
|
||||
|
||||
| ID | 상태 | 요구사항 | 수용 기준 | 계획 연결 |
|
||||
|---|---|---|---|---|
|
||||
| `POINT-001` | 확정 | 무료 기준은 `price == 0`이다 | 무료 fixture가 가격 0으로 판정된다 | `P1-T1`, `P2-T1` |
|
||||
| `POINT-002` | 확정 | 소비자용 실질 포인트 가능 여부는 `storedIsPointAvailable && price > 0`이다 | 무료·저장값 true 응답이 false이고 유료·저장값 true 응답이 true다 | `P1-T1` |
|
||||
| `POINT-003` | 확정 | legacy 콘텐츠 상세의 `isAvailableUsePoint`에 실질 상태를 적용한다 | `GET /audio-content/{id}` 응답 회귀 테스트가 통과한다 | `P1-T1` |
|
||||
| `POINT-004` | 확정 | 대상 v2 소비자 응답의 `isPointAvailable`에 실질 상태를 적용한다 | 각 응답 변환 테스트가 무료 true 저장값을 false로 보정한다 | `P1-T1` |
|
||||
| `POINT-005` | 확정 | 추천 `pointAudios`에서 무료 콘텐츠를 제외한다 | 추천 repository·E2E 테스트에서 가격 0 항목이 없다 | `P2-T1` |
|
||||
| `POINT-006` | 확정 | 전체 탭 POINT 목록·`totalCount`·`hasNext`가 같은 유료 포인트 조건을 사용한다 | repository·E2E 테스트의 목록과 페이징 메타데이터가 일치한다 | `P2-T1` |
|
||||
| `POINT-007` | 확정 | AI 캐릭터 관리자 콘텐츠 조회는 저장값을 그대로 반환한다 | 무료·저장값 true인 관리자 상세가 true를 유지한다 | `P1-T1` |
|
||||
| `POINT-008` | 확정 | 공개 API 스키마와 DB 저장값을 유지한다 | DTO 필드 집합과 관리자 저장값 회귀 테스트가 통과한다 | `P1-GATE`, `P2-GATE` |
|
||||
|
||||
## 8. API 계약
|
||||
|
||||
### 8.1 응답 보정 대상
|
||||
|
||||
| Method | Path | 응답 경계 | 보정 필드 |
|
||||
|---|---|---|---|
|
||||
| GET | `/audio-content/{id}` | `GetAudioContentDetailResponse` | `isAvailableUsePoint` |
|
||||
| GET | `/api/v2/contents` | `ContentOverviewItemResponse` | `isPointAvailable` |
|
||||
| GET | `/api/v2/audio/contents` | `MainContentAudioResponse` | `isPointAvailable` |
|
||||
| GET | `/api/v2/audio/recommendations` | `AudioCardResponse` | `isPointAvailable` |
|
||||
| GET | `/api/v2/home/recommendations` | `HomeFirstAudioContentItem` | `isPointAvailable` |
|
||||
| GET | `/api/v2/creator-channels/{creatorId}/home` | `CreatorChannelAudioContentResponse` | `isPointAvailable` |
|
||||
| GET | `/api/v2/creator-channels/{creatorId}/audio` | `CreatorChannelAudioContentResponse` | `isPointAvailable` |
|
||||
| GET | `/api/v2/creator-channels/{creatorId}/live` | `CreatorChannelAudioContentResponse` | `isPointAvailable` |
|
||||
|
||||
- `GET /api/v2/creator-channels/{creatorId}/series`는 콘텐츠 가격과 포인트 가능 필드를 반환하지 않아 코드 변경 대상이 아니다.
|
||||
- 현재 v2 소비자용 API에 동일 필드를 반환하는 새 경로가 발견되면 같은 식을 적용하고 계획 범위를 먼저 갱신한다.
|
||||
|
||||
### 8.2 포인트 전용 조회 조건 보정 대상
|
||||
|
||||
| Method | Path/section | 변경 전 | 변경 후 |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v2/audio/recommendations`의 `pointAudios` | `isPointAvailable == true` | `isPointAvailable == true && price > 0` |
|
||||
| GET | `/api/v2/audio/contents?type=POINT` | `isPointAvailable == true` | `isPointAvailable == true && price > 0` |
|
||||
|
||||
### 8.3 변경 제외 관리자 계약
|
||||
|
||||
| Method | Path | 정책 |
|
||||
|---|---|---|
|
||||
| GET | `/api/v2/admin/ai-characters/{characterId}/audio-contents` | 저장된 `isPointAvailable`을 그대로 반환 |
|
||||
| GET | `/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}` | 저장된 값을 `isAvailableUsePoint`에 그대로 반환 |
|
||||
|
||||
## 9. 기술적 제약
|
||||
|
||||
- Kotlin, Java 17, Spring Boot 2.7.14와 현재 QueryDSL/JPA 구조를 유지한다.
|
||||
- 새 dependency, DB schema, API endpoint와 DTO를 추가하지 않는다.
|
||||
- 응답 변환 경계에서는 `configured && price > 0` 식을 직접 사용해 현재 파일 책임 안에서 최소 변경한다.
|
||||
- POINT 조회 조건은 기존 repository 조건 함수에 `price > 0`을 결합해 목록과 count가 같은 조건을 공유하게 한다.
|
||||
- 관리자 mapper와 관리자 조회 service는 변경하지 않는다.
|
||||
- 관련 없는 콘텐츠 가격·결제·추천 점수·정렬·성인·차단 정책은 변경하지 않는다.
|
||||
|
||||
## 10. 테스트와 품질 요구사항
|
||||
|
||||
- TDD 순서로 무료·저장값 true fixture의 실패 테스트를 먼저 작성하고 실패 원인이 기존 원본 전달임을 확인한다.
|
||||
- 소비자 응답 경계별로 무료 true → false와 유료 true → true를 검증한다.
|
||||
- 추천 POINT와 전체 탭 POINT에 무료 true fixture를 추가해 목록 제외를 검증한다.
|
||||
- 전체 탭은 POINT `totalCount`와 `hasNext`가 목록 조건과 일치하는지 검증한다.
|
||||
- 관리자 상세는 무료 true 저장값을 그대로 true로 응답하는 회귀 테스트를 유지한다.
|
||||
- focused test 후 직접 영향받는 v2 콘텐츠·홈·크리에이터 채널 회귀와 `ktlintCheck`를 실행한다.
|
||||
- 여러 API 경계를 변경하므로 최종 Gate에서 전체 `test`를 실행한다.
|
||||
|
||||
## 11. 성공 기준
|
||||
|
||||
- [ ] `price == 0`, 저장값 `true`인 콘텐츠가 모든 대상 소비자 응답에서 `false`다. (`POINT-002~004`)
|
||||
- [ ] `price > 0`, 저장값 `true`인 콘텐츠가 대상 소비자 응답에서 `true`다. (`POINT-002`)
|
||||
- [ ] 저장값 `false`인 콘텐츠는 가격과 관계없이 `false`다. (`POINT-002`)
|
||||
- [ ] 무료·저장값 true 콘텐츠가 추천 `pointAudios`에서 제외된다. (`POINT-005`)
|
||||
- [ ] 무료·저장값 true 콘텐츠가 전체 탭 POINT 목록·`totalCount`·`hasNext` 후보에서 제외된다. (`POINT-006`)
|
||||
- [ ] 관리자 목록·상세와 DB 저장값은 변경되지 않는다. (`POINT-007~008`)
|
||||
- [ ] 공개 응답 필드명과 구조가 변경되지 않는다. (`POINT-008`)
|
||||
|
||||
## 12. Open Questions
|
||||
|
||||
없음.
|
||||
|
||||
## 13. 요구사항 추적표
|
||||
|
||||
| 요구사항 | 계획 Phase | Goal | 자동 검증 |
|
||||
|---|---:|---|---|
|
||||
| `POINT-001~004`, `POINT-007~008` | 1 | `P1-T1`, `P1-GATE` | legacy 상세·v2 응답 mapper·관리자 회귀 테스트 |
|
||||
| `POINT-005~006`, `POINT-008` | 2 | `P2-T1`, `P2-GATE` | 추천/전체 탭 repository·E2E 테스트 |
|
||||
|
||||
## 14. Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·Goal |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-07-31 | `DEC-001` | 확정 | 무료 기준을 `price == 0`으로 고정하고 소비자용 포인트 가능 여부를 `storedIsPointAvailable && price > 0`으로 계산한다 | 사용자 인터뷰 | `POINT-001~004`, `P1-T1` |
|
||||
| 2026-07-31 | `DEC-002` | 확정 | 추천과 전체 탭 POINT 조회에서 무료 콘텐츠를 제외한다 | 사용자 선택 A | `POINT-005~006`, `P2-T1` |
|
||||
| 2026-07-31 | `DEC-003` | 확정 | AI 캐릭터 관리자 조회와 DB 저장값은 변경하지 않는다 | 사용자 선택 A | `POINT-007~008`, `P1-T1` |
|
||||
| 2026-07-31 | `DEC-004` | 확정 | 새 통합 문서를 기준으로 만들고 충돌하는 기존 추천·전체 탭 문서에는 정정 기록을 누적한다 | 사용자 승인 | 관련 문서 전체 |
|
||||
229
docs/20260731_무료_콘텐츠_포인트_결제_불가/reviews/phase-1-review.md
Normal file
229
docs/20260731_무료_콘텐츠_포인트_결제_불가/reviews/phase-1-review.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# Phase 1 코드 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 1 / `P1-T1`, `P1-GATE` |
|
||||
| 기준 commit 또는 working tree | `eb0ff7537e5fa6b083be21df3319be0ff2ecda51` + staged working tree |
|
||||
| 리뷰 일자 | 2026-07-31 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | `docs/20260731_무료_콘텐츠_포인트_결제_불가/prd.md`, `plan-task.md` |
|
||||
| 리뷰 상태 | 판정 완료 |
|
||||
|
||||
## 2. 리뷰 목적과 범위
|
||||
|
||||
### 목적
|
||||
|
||||
- `POINT-001~004`, `POINT-007~008` 구현과 완료 증거가 일치하는지 확인한다.
|
||||
- 대상 소비자 응답과 변경 제외 관리자 응답이 각각 확정 계약을 유지하는지 확인한다.
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- 코드: Phase 1에서 변경한 consumer response mapper와 `AudioContentService`
|
||||
- 테스트: Phase 1의 unit/facade test와 AI 캐릭터 관리자 controller test
|
||||
- 문서: PRD Phase 1 요구사항, `P1-T1`, `P1-GATE`, Progress
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- Phase 2 POINT 전용 repository 조건
|
||||
- legacy 상세 외 legacy 목록·추천·랭킹 API
|
||||
- compile과 test 재실행
|
||||
|
||||
## 3. 판정 기준
|
||||
|
||||
| 심각도 | 기준 |
|
||||
|---|---|
|
||||
| Blocker | 보안·데이터 손실 위험, 핵심 흐름 불능, 완료 판정을 무효화하는 문제 |
|
||||
| High | 확정 요구사항 또는 공개 API 계약 위반 |
|
||||
| Medium | 제한된 조건의 기능 회귀 또는 핵심 계약의 자동 검증 누락 |
|
||||
| Low | 문서 정합성 또는 비핵심 회귀 증거 누락 |
|
||||
|
||||
## 4. 검토한 근거
|
||||
|
||||
### 문서와 코드
|
||||
|
||||
- 요구사항: `POINT-001~004`, `POINT-007~008`
|
||||
- 계획: `P1-T1`, `P1-GATE`
|
||||
- 코드:
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt:970`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponse.kt:49`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponse.kt:63`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt:72`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/common/dto/CreatorChannelAudioContentResponse.kt:35`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt:270`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentMapper.kt:69`
|
||||
- 테스트:
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt:286`
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt:495`
|
||||
|
||||
### 실행한 검증
|
||||
|
||||
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|
||||
|---|---|---|
|
||||
| `git diff --cached --check` | 성공 | 출력 없음 |
|
||||
| staged diff와 `rg` 기반 호출·assertion 대조 | 성공 | 6개 소비자 조립 경계는 모두 `stored && price > 0` 적용 |
|
||||
| `./gradlew --no-daemon tasks --all` | 성공 | `BUILD SUCCESSFUL`, exit code 0 |
|
||||
| compile/test | 미실행 | 사용자 지시에 따라 기존 성공 기록만 확인 |
|
||||
|
||||
## 5. 발견 사항 요약
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `REV-P1-001` | Medium | 수정 완료 | 소비자 테스트가 저장값 false 조건을 증명하지 않음 | `Task 1.2` | `P1-R1` |
|
||||
| `REV-P1-002` | Low | 수정 완료 | 관리자 회귀 테스트가 무료 원본값 유지 조건을 증명하지 않음 | `Task 1.2` | `P1-R1` |
|
||||
| `REV-P1-003` | Medium | 수정 완료 | legacy 상세 test가 유료·저장값 true positive 계약을 증명하지 않음 | `Task 1.3` | `P1-R2` |
|
||||
|
||||
## 6. 발견 사항 상세
|
||||
|
||||
### REV-P1-001 — 소비자 테스트가 저장값 false 조건을 증명하지 않음
|
||||
|
||||
- **심각도:** Medium
|
||||
- **상태:** 수정 완료
|
||||
- **관련 요구사항:** `POINT-002`, `POINT-004`
|
||||
- **소유 Task:** `Task 1.2`, `P1-R1`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
변경된 소비자 테스트는 주로 `price == 0, stored == true → false`와
|
||||
`price > 0, stored == true → true`만 검증한다. 따라서 구현이 실수로 `price > 0`만 반환해도 해당 두 조건은 통과한다.
|
||||
PRD 성공 기준인 `stored == false → false`를 자동으로 구분할 수 없다.
|
||||
|
||||
**근거**
|
||||
|
||||
- 코드: 각 소비자 조립 경계는 현재 `stored && price > 0`으로 올바르게 구현되어 있다.
|
||||
- 테스트: Phase 1 변경 test에는 각 변경 경계의 `price > 0, stored == false` assertion이 없다.
|
||||
- 문서: PRD `POINT-002`와 성공 기준은 저장값 false가 가격과 무관하게 false일 것을 요구한다.
|
||||
|
||||
**영향**
|
||||
|
||||
현재 production 동작 결함은 확인되지 않았다. 다만 저장값 조건이 제거되는 회귀가 발생해도 Phase 1 test가 탐지하지 못한다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
각 변경 경계의 기존 test fixture에 `price > 0, stored == false` 사례를 최소 추가하고 `P1-GATE`를 재검증한다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-07-31 — staged 구현식은 정상이나 확정 계약의 자동 검증 누락으로 판정해 `P1-R1`로 전환했다.
|
||||
- 2026-07-31 — `P1-R1`에서 대상 소비자 경계에 `price > 0, stored == false` assertion을 추가하고 focused test, `P1-GATE`, `ktlintCheck` 성공을 확인했다.
|
||||
|
||||
### REV-P1-002 — 관리자 회귀 테스트가 무료 원본값 유지 조건을 증명하지 않음
|
||||
|
||||
- **심각도:** Low
|
||||
- **상태:** 수정 완료
|
||||
- **관련 요구사항:** `POINT-007`, `POINT-008`
|
||||
- **소유 Task:** `Task 1.2`, `P1-R1`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
계획과 Progress는 관리자 상세의 “무료·저장값 true → true” 확인을 완료 증거로 기록했지만,
|
||||
`AiCharacterAdminAudioContentControllerTest`의 공통 fixture는 `price = 100`이다. 상세 assertion은 저장값 true 전달만 검증하며
|
||||
무료 조건에서 소비자 보정이 관리자 경계로 번지지 않았는지는 증명하지 않는다. 관리자 목록의 동일 조건 assertion도 없다.
|
||||
|
||||
**근거**
|
||||
|
||||
- 코드: `AiCharacterAdminAudioContentMapper`는 현재 `content.isPointAvailable`을 그대로 전달한다.
|
||||
- 테스트: 관리자 helper의 `price = 100`, 상세의 `isAvailableUsePoint == true` assertion.
|
||||
- 문서: `P1-T1`과 구현 Progress는 무료 관리자 원본값 유지 확인을 완료 증거로 기록한다.
|
||||
|
||||
**영향**
|
||||
|
||||
현재 관리자 mapper의 기능 결함은 확인되지 않았다. 그러나 완료 기록과 실제 test fixture가 불일치하며 관리자 제외 계약의
|
||||
핵심 경계가 회귀 test로 고정되지 않았다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
관리자 목록·상세 fixture를 `price == 0, stored == true`로 구성해 목록 `isPointAvailable`과 상세
|
||||
`isAvailableUsePoint`가 true를 유지하는지 검증한다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-07-31 — 구현은 정상이나 완료 증거 불일치가 확인되어 `P1-R1`로 전환했다.
|
||||
- 2026-07-31 — `P1-R1`에서 관리자 목록·상세 fixture를 `price == 0, stored == true`로 보강하고 원본 true 유지 assertion 통과를 확인했다.
|
||||
|
||||
### REV-P1-003 — legacy 상세 test가 유료·저장값 true positive 계약을 증명하지 않음
|
||||
|
||||
- **심각도:** Medium
|
||||
- **상태:** 수정 완료
|
||||
- **관련 요구사항:** `POINT-002`, `POINT-003`
|
||||
- **소유 Task:** `Task 1.3`, `P1-R2`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
`AudioContentServiceTest` 상세 회귀 test는 무료·저장값 true와 유료·저장값 false가 false인 것만 검증한다.
|
||||
`price > 0, stored == true → true` assertion이 없어 legacy 상세 구현이 항상 false로 회귀해도 해당 test가 통과한다.
|
||||
|
||||
**근거**
|
||||
|
||||
- 코드: `AudioContentService` 상세 응답은 현재 `audioContent.isPointAvailable && audioContent.price > 0`으로 올바르게 구현되어 있다.
|
||||
- 테스트: `AudioContentServiceTest` 상세 포인트 assertion은 false 사례 2건만 포함한다.
|
||||
- 문서: `P1-T1`은 각 응답 경계의 유료·저장값 true 유지를 완료 증거로 요구한다.
|
||||
|
||||
**영향**
|
||||
|
||||
현재 production 동작 결함은 확인되지 않았다. 다만 legacy 상세의 positive 계약이 회귀 test로 고정되지 않았다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
기존 상세 test에 유료·저장값 true 응답 assertion을 추가해 세 계약 조건을 완성한다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-07-31 — staged production 구현은 정상이나 legacy 상세 positive 회귀 증거 누락으로 판정해 `P1-R2`로 전환했다.
|
||||
- 2026-07-31 — `P1-R2`에서 legacy 상세 test에 유료·저장값 true assertion을 추가해 세 계약 조건을 고정하고 focused test, P1-GATE 단위 범위, `ktlintCheck` 성공을 확인했다.
|
||||
|
||||
## 7. 확정 항목의 plan·goal 전환
|
||||
|
||||
- 신규 회귀 수정 Task: `plan-task.md`의 `Task 1.2`
|
||||
- 후속 goal: `P1-R1`
|
||||
- objective: 소비자 응답의 저장값 false 조건과 무료 관리자 응답의 원본값 유지 조건을 자동 회귀 테스트로 증명한다.
|
||||
- 추가 회귀 수정 Task: `plan-task.md`의 `Task 1.3`
|
||||
- 추가 후속 goal: `P1-R2`
|
||||
- objective: legacy 상세의 유료·저장값 true positive 계약을 자동 회귀 테스트로 증명한다.
|
||||
|
||||
## 8. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | Phase 1 production/test/document diff와 관련 mapper 확인 |
|
||||
| 후보 항목 판정 완료 | 충족 | 3건 모두 판정 완료 |
|
||||
| 확정 항목 plan 반영 | 충족 | `Task 1.2`, `P1-R1`, `Task 1.3`, `P1-R2` |
|
||||
| 보류 항목의 담당·재개 조건 기록 | 해당 없음 | 보류 없음 |
|
||||
| 검증 명령과 결과 기록 | 충족 | 정적 검증 기록, test 미실행 사유 명시 |
|
||||
|
||||
**최종 결론:** 수정 완료
|
||||
|
||||
**남은 항목:** 없음
|
||||
|
||||
## 9. 수정 후 검증 기록
|
||||
|
||||
기존 기록을 삭제하거나 덮어쓰지 않고 차수별로 누적한다.
|
||||
|
||||
### 1차 수정 검증 — 2026-07-31
|
||||
|
||||
- 무엇을: `REV-P1-001`, `REV-P1-002`의 수정 내용과 회귀 증거를 재검토했다.
|
||||
- 왜: 소비자 저장값 false 계약과 관리자 무료 원본값 유지 계약의 자동 검증 누락을 해소했는지 확인하기 위해서다.
|
||||
- 어떻게:
|
||||
- staged diff와 `rg` — 6개 소비자 조립 경계의 유료·저장값 false assertion 확인.
|
||||
- staged diff — 관리자 목록·상세의 무료·저장값 true fixture와 true assertion 확인.
|
||||
- `git diff --check`, `git diff --cached --check` — 출력 없음.
|
||||
- `./gradlew --no-daemon tasks --all` — `BUILD SUCCESSFUL`, exit code 0.
|
||||
- compile/test — 사용자 지시에 따라 재실행하지 않고 `plan-task.md`의 기존 성공 기록만 확인.
|
||||
- 판정: `REV-P1-001`, `REV-P1-002` 수정 완료.
|
||||
- 남은 항목: `REV-P1-003`, `P1-R2`.
|
||||
|
||||
### 2차 수정 검증 — 2026-07-31
|
||||
|
||||
- 무엇을: `REV-P1-003`의 수정 내용과 회귀 증거를 재검토했다.
|
||||
- 왜: legacy 상세의 유료·저장값 true positive 계약이 자동 검증으로 고정됐는지 확인하기 위해서다.
|
||||
- 어떻게:
|
||||
- `AudioContentServiceTest` — 무료·저장값 true → false, 유료·저장값 false → false, 유료·저장값 true → true assertion 확인.
|
||||
- `AudioContentService` — 응답식 `audioContent.isPointAvailable && audioContent.price > 0`과 세 assertion 대조.
|
||||
- `plan-task.md`의 기존 완료 기록 — focused test `BUILD SUCCESSFUL`(14s), P1-GATE 단위 범위
|
||||
`BUILD SUCCESSFUL`(2m 26s), `ktlintCheck` `BUILD SUCCESSFUL`(14s) 확인.
|
||||
- `git diff --check`, `git diff --cached --check` — 출력 없음.
|
||||
- `./gradlew --no-daemon tasks --all` — `BUILD SUCCESSFUL`, exit code 0.
|
||||
- compile/test — 사용자 지시에 따라 이번 재검토에서는 실행하지 않음.
|
||||
- 판정: `REV-P1-003` 수정 완료.
|
||||
- 남은 항목: 없음.
|
||||
111
docs/20260731_무료_콘텐츠_포인트_결제_불가/reviews/phase-2-review.md
Normal file
111
docs/20260731_무료_콘텐츠_포인트_결제_불가/reviews/phase-2-review.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Phase 2 코드 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 2 / `P2-T1`, `P2-GATE` |
|
||||
| 기준 commit 또는 working tree | `eb0ff7537e5fa6b083be21df3319be0ff2ecda51` + staged working tree |
|
||||
| 리뷰 일자 | 2026-07-31 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | `docs/20260731_무료_콘텐츠_포인트_결제_불가/prd.md`, `plan-task.md` |
|
||||
| 리뷰 상태 | 판정 완료 |
|
||||
|
||||
## 2. 리뷰 목적과 범위
|
||||
|
||||
### 목적
|
||||
|
||||
- `POINT-005~006`, `POINT-008` 구현과 완료 증거가 일치하는지 확인한다.
|
||||
- 추천과 전체 탭 POINT 조회가 무료 콘텐츠를 목록·count·pagination 후보에서 제외하는지 확인한다.
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- 코드: 추천·전체 탭 QueryDSL repository 변경
|
||||
- 테스트: 두 repository test와 추천·전체 탭 E2E test
|
||||
- 문서: 관련 PRD, 기존 추천·전체 탭 후속 정정, `P2-T1`, `P2-GATE`, Progress
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- Phase 1 응답 mapper
|
||||
- FREE/AUDIO/정렬 정책의 신규 변경
|
||||
- compile과 test 재실행
|
||||
|
||||
## 3. 판정 기준
|
||||
|
||||
| 심각도 | 기준 |
|
||||
|---|---|
|
||||
| Blocker | 보안·데이터 손실 위험, 핵심 흐름 불능, 완료 판정을 무효화하는 문제 |
|
||||
| High | 확정 요구사항 또는 공개 API 계약 위반 |
|
||||
| Medium | 제한된 조건의 목록·count·pagination 불일치 |
|
||||
| Low | 문서 정합성 또는 비핵심 회귀 증거 누락 |
|
||||
|
||||
## 4. 검토한 근거
|
||||
|
||||
### 문서와 코드
|
||||
|
||||
- 요구사항: `POINT-005~006`, `POINT-008`
|
||||
- 계획: `P2-T1`, `P2-GATE`
|
||||
- 코드:
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt:135`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt:35`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt:409`
|
||||
- 테스트:
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt:87`
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt:43`
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt:42`
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt:97`
|
||||
|
||||
### 실행한 검증
|
||||
|
||||
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|
||||
|---|---|---|
|
||||
| `git diff --cached --check` | 성공 | 출력 없음 |
|
||||
| staged diff와 `rg` 기반 조건·호출 대조 | 성공 | 추천은 유료 POINT 조건, 전체 탭 list/count는 같은 `audioCondition` 공유 |
|
||||
| `./gradlew --no-daemon tasks --all` | 성공 | `BUILD SUCCESSFUL`, exit code 0 |
|
||||
| compile/test | 미실행 | 사용자 지시에 따라 기존 성공 기록만 확인 |
|
||||
|
||||
## 5. 발견 사항 요약
|
||||
|
||||
확정 발견 사항 없음.
|
||||
|
||||
## 6. 검토 결과
|
||||
|
||||
- 추천 `findPointAudios`는 `isPointAvailable.isTrue.and(price.gt(0))`을 조회 전에 적용해 limit 후보에서도 무료 콘텐츠를 제외한다.
|
||||
- 전체 탭 `countAudios`와 `findAudios`는 동일한 `audioCondition`과 `optionalAudioPointCondition`을 사용한다.
|
||||
- 전체 탭 E2E는 유료 1건과 무료·저장값 true 1건에서 `size=1`로 조회해 `totalCount=1`, 목록 1건,
|
||||
`hasNext=false`를 함께 검증한다.
|
||||
- repository test는 유료·저장값 false, 유료·저장값 true, 무료·저장값 true를 구분한다.
|
||||
- 기존 추천·전체 탭 문서는 2026-07-31 후속 요구사항 정정을 누적해 현재 PRD와 일치한다.
|
||||
|
||||
## 7. 확정 항목의 plan·goal 전환
|
||||
|
||||
전환 항목 없음.
|
||||
|
||||
## 8. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | Phase 2 production/test/document diff와 관련 호출 경계 확인 |
|
||||
| 후보 항목 판정 완료 | 충족 | 후보 없음 |
|
||||
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
|
||||
| 보류 항목의 담당·재개 조건 기록 | 해당 없음 | 보류 없음 |
|
||||
| 검증 명령과 결과 기록 | 충족 | 정적 검증 기록, test 미실행 사유 명시 |
|
||||
|
||||
**최종 결론:** 확정 발견 사항 없음
|
||||
|
||||
**남은 항목:** 없음
|
||||
|
||||
## 9. 후속 상태 확인
|
||||
|
||||
### 1차 재확인 — 2026-07-31
|
||||
|
||||
- 무엇을: Phase 1의 `P1-R2` 수정이 Phase 2 POINT 조회 계약에 영향을 주지 않았는지 재검토했다.
|
||||
- 왜: 후속 test 변경 뒤에도 POINT 목록·count·pagination 조건과 기존 Phase 2 판정이 유효한지 확인하기 위해서다.
|
||||
- 어떻게:
|
||||
- production diff — `P1-R2`에 따른 Phase 2 repository 변경 없음 확인.
|
||||
- repository와 E2E test 정적 대조 — 추천 유료 POINT 조건과 전체 탭 list/count 공통 조건 유지 확인.
|
||||
- `git diff --check`, `git diff --cached --check` — 출력 없음.
|
||||
- `./gradlew --no-daemon tasks --all` — `BUILD SUCCESSFUL`, exit code 0.
|
||||
- compile/test — 사용자 지시에 따라 이번 재검토에서는 실행하지 않음.
|
||||
- 판정: 기존 Phase 2 판정 유지.
|
||||
- 남은 항목: 없음.
|
||||
@@ -967,7 +967,7 @@ class AudioContentService(
|
||||
previousContent = previousContent,
|
||||
nextContent = nextContent,
|
||||
buyerList = buyerList,
|
||||
isAvailableUsePoint = audioContent.isPointAvailable,
|
||||
isAvailableUsePoint = audioContent.isPointAvailable && audioContent.price > 0,
|
||||
translated = translated
|
||||
)
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ data class MainContentAudioResponse(
|
||||
imageUrl = audio.imageUrl,
|
||||
price = audio.price,
|
||||
isAdult = audio.isAdult,
|
||||
isPointAvailable = audio.isPointAvailable,
|
||||
isPointAvailable = audio.isPointAvailable && audio.price > 0,
|
||||
isFirstContent = audio.isFirstContent,
|
||||
isOriginalSeries = audio.isOriginalSeries,
|
||||
creatorNickname = audio.creatorNickname
|
||||
|
||||
@@ -46,7 +46,7 @@ data class ContentOverviewItemResponse(
|
||||
title = audio.title,
|
||||
coverImage = audio.imageUrl,
|
||||
price = audio.price,
|
||||
isPointAvailable = audio.isPointAvailable,
|
||||
isPointAvailable = audio.isPointAvailable && audio.price > 0,
|
||||
creatorNickname = audio.creatorNickname,
|
||||
isAdult = audio.isAdult,
|
||||
isFirstContent = audio.isFirstContent,
|
||||
@@ -65,7 +65,7 @@ data class ContentOverviewItemResponse(
|
||||
title = audio.title,
|
||||
coverImage = coverImage,
|
||||
price = audio.price,
|
||||
isPointAvailable = audio.isPointAvailable,
|
||||
isPointAvailable = audio.isPointAvailable && audio.price > 0,
|
||||
creatorNickname = audio.creatorNickname,
|
||||
isAdult = isAdult,
|
||||
isFirstContent = true,
|
||||
|
||||
@@ -69,7 +69,7 @@ data class AudioCardResponse(
|
||||
imageUrl = audio.imageUrl,
|
||||
price = audio.price,
|
||||
isAdult = audio.isAdult,
|
||||
isPointAvailable = audio.isPointAvailable,
|
||||
isPointAvailable = audio.isPointAvailable && audio.price > 0,
|
||||
isFirstContent = audio.isFirstContent,
|
||||
isOriginalSeries = audio.isOriginalSeries,
|
||||
creatorNickname = audio.creatorNickname
|
||||
|
||||
@@ -32,7 +32,7 @@ data class CreatorChannelAudioContentResponse(
|
||||
imageUrl = audioContent.imageUrl,
|
||||
price = audioContent.price,
|
||||
isAdult = audioContent.isAdult,
|
||||
isPointAvailable = audioContent.isPointAvailable,
|
||||
isPointAvailable = audioContent.isPointAvailable && audioContent.price > 0,
|
||||
isFirstContent = audioContent.isFirstContent,
|
||||
seriesName = audioContent.seriesName,
|
||||
isOriginalSeries = audioContent.isOriginalSeries,
|
||||
|
||||
@@ -267,7 +267,7 @@ class HomeRecommendationFacade(
|
||||
title = title,
|
||||
price = price,
|
||||
coverImage = imageUrl(cloudFrontHost, coverImage),
|
||||
isPointAvailable = isPointAvailable
|
||||
isPointAvailable = isPointAvailable && price > 0
|
||||
)
|
||||
|
||||
private fun HomeAiCharacterRecommendationRecord.toItem() = HomeAiCharacterItem(
|
||||
|
||||
@@ -406,7 +406,7 @@ class DefaultMainContentAllQueryRepository(
|
||||
}
|
||||
|
||||
private fun optionalAudioPointCondition(onlyPointAvailable: Boolean): BooleanExpression? {
|
||||
return if (onlyPointAvailable) audioContent.isPointAvailable.isTrue else null
|
||||
return if (onlyPointAvailable) audioContent.isPointAvailable.isTrue.and(audioContent.price.gt(0)) else null
|
||||
}
|
||||
|
||||
private fun optionalOriginalCondition(onlyOriginal: Boolean): BooleanExpression? {
|
||||
|
||||
@@ -135,7 +135,12 @@ class DefaultAudioRecommendationQueryRepository(
|
||||
now: LocalDateTime
|
||||
): List<AudioCard> {
|
||||
val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')")
|
||||
val rows = audioRows(memberId, canViewAdultContent, now, audioContent.isPointAvailable.isTrue) {
|
||||
val rows = audioRows(
|
||||
memberId,
|
||||
canViewAdultContent,
|
||||
now,
|
||||
audioContent.isPointAvailable.isTrue.and(audioContent.price.gt(0))
|
||||
) {
|
||||
orderBy(randomTieBreaker.asc()).limit(limit.toLong())
|
||||
}
|
||||
return rows.toAudioCards(now, canViewAdultContent)
|
||||
|
||||
@@ -26,6 +26,7 @@ import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryService
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
@@ -282,6 +283,50 @@ class AudioContentServiceTest {
|
||||
assertTrue(output.out.contains("contentId=${audioContent.id}"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("콘텐츠 상세는 가격과 저장값을 함께 반영해 포인트 사용 가능 여부를 반환한다")
|
||||
fun shouldReturnEffectivePointAvailabilityForAudioDetail() {
|
||||
val viewer = createMember(id = 1004L, nickname = "free-detail-viewer")
|
||||
val creator = createMember(id = 2004L, nickname = "free-detail-creator")
|
||||
val audioContent = createAudioContent(creator)
|
||||
audioContent.price = 0
|
||||
audioContent.isPointAvailable = true
|
||||
stubSuccessfulDetailDependencies(viewer, creator, audioContent)
|
||||
|
||||
val response = service.getDetail(
|
||||
id = audioContent.id!!,
|
||||
member = viewer,
|
||||
isAdultContentVisible = false,
|
||||
timezone = "Asia/Seoul"
|
||||
)
|
||||
|
||||
assertFalse(response.isAvailableUsePoint)
|
||||
|
||||
audioContent.price = 100
|
||||
audioContent.isPointAvailable = false
|
||||
|
||||
val paidUnavailableResponse = service.getDetail(
|
||||
id = audioContent.id!!,
|
||||
member = viewer,
|
||||
isAdultContentVisible = false,
|
||||
timezone = "Asia/Seoul"
|
||||
)
|
||||
|
||||
assertFalse(paidUnavailableResponse.isAvailableUsePoint)
|
||||
|
||||
audioContent.price = 100
|
||||
audioContent.isPointAvailable = true
|
||||
|
||||
val paidAvailableResponse = service.getDetail(
|
||||
id = audioContent.id!!,
|
||||
member = viewer,
|
||||
isAdultContentVisible = false,
|
||||
timezone = "Asia/Seoul"
|
||||
)
|
||||
|
||||
assertTrue(paidAvailableResponse.isAvailableUsePoint)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("업로드 완료 시 즉시 공개 콘텐츠는 최근 소식을 발행한다")
|
||||
fun shouldPublishNewsWhenUploadCompleteMakesContentPublicImmediately() {
|
||||
|
||||
@@ -339,7 +339,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
|
||||
val content = saveAudioContent(
|
||||
owner = character.creatorMember!!,
|
||||
title = "target night walk",
|
||||
contentPath = "private/target-night-walk.mp3"
|
||||
contentPath = "private/target-night-walk.mp3",
|
||||
price = 0
|
||||
)
|
||||
val otherCharacter = chatCharacterService.createChatCharacterWithDetails(
|
||||
characterUUID = "v2-audio-other-character",
|
||||
@@ -374,6 +375,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
|
||||
.andExpect(jsonPath("$.data.totalCount").value(1))
|
||||
.andExpect(jsonPath("$.data.items[0].audioContentId").value(content.id))
|
||||
.andExpect(jsonPath("$.data.items[0].title").value("target night walk"))
|
||||
.andExpect(jsonPath("$.data.items[0].isPointAvailable").value(true))
|
||||
.andExpect(
|
||||
jsonPath("$.data.items[0].contentUrl").value(
|
||||
"https://signed.example.com/private/target-night-walk.mp3?Expires=1"
|
||||
@@ -495,7 +497,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
|
||||
val content = saveAudioContent(
|
||||
owner = character.creatorMember!!,
|
||||
title = "detail night walk",
|
||||
contentPath = "private/detail-night-walk.mp3"
|
||||
contentPath = "private/detail-night-walk.mp3",
|
||||
price = 0
|
||||
)
|
||||
entityManager.flush()
|
||||
Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong()))
|
||||
@@ -899,7 +902,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
|
||||
owner: Member,
|
||||
title: String,
|
||||
contentPath: String,
|
||||
releaseDate: LocalDateTime = Instant.now().minusSeconds(60).atOffset(ZoneOffset.UTC).toLocalDateTime()
|
||||
releaseDate: LocalDateTime = Instant.now().minusSeconds(60).atOffset(ZoneOffset.UTC).toLocalDateTime(),
|
||||
price: Int = 100
|
||||
): AudioContent {
|
||||
val theme = AudioContentTheme(theme = "theme-$title", image = "theme.png")
|
||||
entityManager.persist(theme)
|
||||
@@ -907,7 +911,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
|
||||
title = title,
|
||||
detail = "detail",
|
||||
languageCode = "ko",
|
||||
price = 100,
|
||||
price = price,
|
||||
isAdult = false,
|
||||
isPointAvailable = true,
|
||||
isCommentAvailable = true
|
||||
|
||||
@@ -94,6 +94,27 @@ class MainContentAllEndToEndTest @Autowired constructor(
|
||||
.andExpect(jsonPath("$.data.audios.length()").value(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("전체 탭 POINT API는 무료 포인트 콘텐츠를 목록과 페이징 후보에서 제외한다")
|
||||
fun shouldExcludeFreePointAudioContentsForPointType() {
|
||||
val fixture = createAudioFixture("main-all-point-paid-e2e", freeControl = true)
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/audio/contents")
|
||||
.param("type", "POINT")
|
||||
.param("sort", "LATEST")
|
||||
.param("page", "0")
|
||||
.param("size", "1")
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.type").value("POINT"))
|
||||
.andExpect(jsonPath("$.data.totalCount").value(1))
|
||||
.andExpect(jsonPath("$.data.audios.length()").value(1))
|
||||
.andExpect(jsonPath("$.data.audios[0].audioContentId").value(fixture.audioContentId))
|
||||
.andExpect(jsonPath("$.data.audios[0].price").value(100))
|
||||
.andExpect(jsonPath("$.data.hasNext").value(false))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("전체 탭 SERIES API는 dayOfWeek 조건으로 시리즈 응답과 빈 audios를 반환한다")
|
||||
fun shouldReturnSeriesContentsFilteredByDayOfWeekThroughControllerServiceAndRepository() {
|
||||
|
||||
@@ -67,6 +67,28 @@ class MainContentAllTabResponseTest {
|
||||
isFirstContent = true,
|
||||
isOriginalSeries = false,
|
||||
creatorNickname = "creator"
|
||||
),
|
||||
MainContentAllAudio(
|
||||
audioContentId = 2L,
|
||||
title = "free audio",
|
||||
imageUrl = "https://cdn/free-audio.jpg",
|
||||
price = 0,
|
||||
isAdult = false,
|
||||
isPointAvailable = true,
|
||||
isFirstContent = false,
|
||||
isOriginalSeries = false,
|
||||
creatorNickname = "creator"
|
||||
),
|
||||
MainContentAllAudio(
|
||||
audioContentId = 3L,
|
||||
title = "point unavailable audio",
|
||||
imageUrl = "https://cdn/point-unavailable-audio.jpg",
|
||||
price = 100,
|
||||
isAdult = false,
|
||||
isPointAvailable = false,
|
||||
isFirstContent = false,
|
||||
isOriginalSeries = false,
|
||||
creatorNickname = "creator"
|
||||
)
|
||||
),
|
||||
series = emptyList(),
|
||||
@@ -83,6 +105,8 @@ class MainContentAllTabResponseTest {
|
||||
assertEquals(true, json["audios"][0]["isPointAvailable"].asBoolean())
|
||||
assertEquals(true, json["audios"][0]["isFirstContent"].asBoolean())
|
||||
assertEquals(false, json["audios"][0]["isOriginalSeries"].asBoolean())
|
||||
assertEquals(false, json["audios"][1]["isPointAvailable"].asBoolean())
|
||||
assertEquals(false, json["audios"][2]["isPointAvailable"].asBoolean())
|
||||
assertEquals(true, json["hasNext"].asBoolean())
|
||||
assertFalse(json["audios"][0].has("duration"))
|
||||
assertFalse(json["series"].any { it.has("publishedDaysOfWeek") })
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.content.overview.dto
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeFirstAudioContentRecord
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@@ -77,4 +80,71 @@ class ContentOverviewPageResponseTest {
|
||||
assertEquals(true, response.isAdult)
|
||||
assertEquals(true, response.isOriginalSeries)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("NEW_AND_HOT 응답은 무료 콘텐츠의 포인트 가능 여부를 false로 보정한다")
|
||||
fun shouldNormalizePointAvailabilityForFreeNewAndHotAudio() {
|
||||
val freeAudio = audioCard(price = 0, isPointAvailable = true)
|
||||
val paidAudio = audioCard(price = 100, isPointAvailable = true)
|
||||
val paidUnavailableAudio = audioCard(price = 100, isPointAvailable = false)
|
||||
|
||||
assertFalse(ContentOverviewItemResponse.fromNewAndHot(freeAudio).isPointAvailable)
|
||||
assertTrue(ContentOverviewItemResponse.fromNewAndHot(paidAudio).isPointAvailable)
|
||||
assertFalse(ContentOverviewItemResponse.fromNewAndHot(paidUnavailableAudio).isPointAvailable)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("첫 오디오 응답은 무료 콘텐츠의 포인트 가능 여부를 false로 보정한다")
|
||||
fun shouldNormalizePointAvailabilityForFreeFirstAudioContent() {
|
||||
val freeAudio = firstAudio(price = 0, isPointAvailable = true)
|
||||
val paidAudio = firstAudio(price = 100, isPointAvailable = true)
|
||||
val paidUnavailableAudio = firstAudio(price = 100, isPointAvailable = false)
|
||||
|
||||
assertFalse(
|
||||
ContentOverviewItemResponse.fromFirstAudioContent(freeAudio, "https://cdn.test/free.png", false, false)
|
||||
.isPointAvailable
|
||||
)
|
||||
assertTrue(
|
||||
ContentOverviewItemResponse.fromFirstAudioContent(paidAudio, "https://cdn.test/paid.png", false, false)
|
||||
.isPointAvailable
|
||||
)
|
||||
assertFalse(
|
||||
ContentOverviewItemResponse.fromFirstAudioContent(
|
||||
paidUnavailableAudio,
|
||||
"https://cdn.test/paid-unavailable.png",
|
||||
false,
|
||||
false
|
||||
).isPointAvailable
|
||||
)
|
||||
}
|
||||
|
||||
private fun audioCard(price: Int, isPointAvailable: Boolean): AudioCard {
|
||||
return AudioCard(
|
||||
audioContentId = price.toLong() + 1,
|
||||
title = "audio",
|
||||
duration = "00:01",
|
||||
imageUrl = "https://cdn.test/audio.png",
|
||||
price = price,
|
||||
isAdult = false,
|
||||
isPointAvailable = isPointAvailable,
|
||||
isFirstContent = true,
|
||||
isOriginalSeries = false,
|
||||
creatorNickname = "creator"
|
||||
)
|
||||
}
|
||||
|
||||
private fun firstAudio(price: Int, isPointAvailable: Boolean): HomeFirstAudioContentRecord {
|
||||
return HomeFirstAudioContentRecord(
|
||||
contentId = price.toLong() + 1,
|
||||
creatorId = 10L,
|
||||
creatorNickname = "creator",
|
||||
creatorProfileImage = null,
|
||||
title = "first audio",
|
||||
price = price,
|
||||
coverImage = "cover/audio.png",
|
||||
isPointAvailable = isPointAvailable,
|
||||
isAdult = false,
|
||||
isOriginalSeries = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,9 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
|
||||
.andExpect(jsonPath("$.data.latestAudios[0].isOriginalSeries").value(true))
|
||||
.andExpect(jsonPath("$.data.recommendedAudios").isArray)
|
||||
.andExpect(jsonPath("$.data.recommendedAudios[0].audioContentId").value(fixture.audioContentId))
|
||||
.andExpect(jsonPath("$.data.pointAudios.length()").value(1))
|
||||
.andExpect(jsonPath("$.data.pointAudios[0].audioContentId").value(fixture.paidPointAudioContentId))
|
||||
.andExpect(jsonPath("$.data.pointAudios[0].price").value(100))
|
||||
.andExpect(jsonPath("$.data.mostCommentedAudios[0].latestComment").value("latest e2e comment"))
|
||||
.andExpect(
|
||||
jsonPath("$.data.mostCommentedAudios[0].latestCommentWriterProfileImageUrl")
|
||||
@@ -68,6 +71,7 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
|
||||
val writer = saveMember("audio-recommendation-e2e-writer", MemberRole.USER, profileImage = "comment-writer.png")
|
||||
val theme = saveTheme()
|
||||
val audio = saveAudio(creator, theme, now)
|
||||
val paidPointAudio = saveAudio(creator, theme, now.minusMinutes(10), "paid-point", 100)
|
||||
val series = saveSeries(creator)
|
||||
saveSeriesContent(series, audio)
|
||||
saveComment(audio, writer, "latest e2e comment", now.plusMinutes(10))
|
||||
@@ -79,7 +83,8 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
|
||||
|
||||
Fixture(
|
||||
seriesId = series.id!!,
|
||||
audioContentId = audio.id!!
|
||||
audioContentId = audio.id!!,
|
||||
paidPointAudioContentId = paidPointAudio.id!!
|
||||
)
|
||||
}!!
|
||||
}
|
||||
@@ -102,14 +107,20 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
|
||||
return theme
|
||||
}
|
||||
|
||||
private fun saveAudio(creator: Member, theme: AudioContentTheme, releaseDate: LocalDateTime): AudioContent {
|
||||
private fun saveAudio(
|
||||
creator: Member,
|
||||
theme: AudioContentTheme,
|
||||
releaseDate: LocalDateTime,
|
||||
title: String = "audio-recommendation-e2e",
|
||||
price: Int = 0
|
||||
): AudioContent {
|
||||
val audio = AudioContent(
|
||||
title = "audio-recommendation-e2e",
|
||||
title = title,
|
||||
detail = "detail",
|
||||
languageCode = "ko",
|
||||
releaseDate = releaseDate,
|
||||
isAdult = false,
|
||||
price = 0,
|
||||
price = price,
|
||||
isPointAvailable = true
|
||||
)
|
||||
audio.member = creator
|
||||
@@ -174,6 +185,7 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
|
||||
|
||||
private data class Fixture(
|
||||
val seriesId: Long,
|
||||
val audioContentId: Long
|
||||
val audioContentId: Long,
|
||||
val paidPointAudioContentId: Long
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,10 +33,13 @@ class AudioRecommendationFacadeTest {
|
||||
assertEquals(1, response.mostCommentedAudios.size)
|
||||
assertEquals(1, response.recommendedAudios.size)
|
||||
assertEquals(false, response.latestAudios[0].isOriginalSeries)
|
||||
assertEquals(false, response.latestAudios[0].isPointAvailable)
|
||||
assertEquals(true, response.newAndHotAudios[0].isPointAvailable)
|
||||
assertEquals(false, response.recommendedAudios[0].isPointAvailable)
|
||||
|
||||
val json = objectMapper.readTree(objectMapper.writeValueAsString(response))
|
||||
assertEquals(false, json["latestAudios"][0]["isAdult"].asBoolean())
|
||||
assertEquals(true, json["latestAudios"][0]["isPointAvailable"].asBoolean())
|
||||
assertEquals(false, json["latestAudios"][0]["isPointAvailable"].asBoolean())
|
||||
assertEquals(true, json["latestAudios"][0]["isFirstContent"].asBoolean())
|
||||
assertEquals(false, json["latestAudios"][0]["isOriginalSeries"].asBoolean())
|
||||
assertEquals(false, json["latestAudios"][0].has("adult"))
|
||||
@@ -61,7 +64,7 @@ class AudioRecommendationFacadeTest {
|
||||
banners = listOf(RecommendationBanner("https://cdn.test/banner.png", null, null, null, "https://link.test")),
|
||||
originalSeries = listOf(OriginalSeries(2L, "https://cdn.test/series.png")),
|
||||
latestAudios = listOf(card),
|
||||
newAndHotAudios = listOf(card.copy(audioContentId = 3L)),
|
||||
newAndHotAudios = listOf(card.copy(audioContentId = 3L, price = 100)),
|
||||
freeAudios = listOf(card.copy(audioContentId = 4L)),
|
||||
pointAudios = listOf(card.copy(audioContentId = 5L)),
|
||||
mostCommentedAudios = listOf(
|
||||
@@ -73,7 +76,7 @@ class AudioRecommendationFacadeTest {
|
||||
latestCommentWriterProfileImageUrl = "https://cdn.test/profile.png"
|
||||
)
|
||||
),
|
||||
recommendedAudios = listOf(card.copy(audioContentId = 7L))
|
||||
recommendedAudios = listOf(card.copy(audioContentId = 7L, price = 100, isPointAvailable = false))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.creator.channel.common.dto
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.common.domain.CreatorChannelAudioContent
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class CreatorChannelAudioContentResponseTest {
|
||||
@Test
|
||||
@DisplayName("크리에이터 채널 오디오 응답은 무료 콘텐츠의 포인트 가능 여부를 false로 보정한다")
|
||||
fun shouldNormalizePointAvailabilityForFreeAudioContent() {
|
||||
val freeAudio = audioContent(price = 0, isPointAvailable = true)
|
||||
val paidAudio = audioContent(price = 100, isPointAvailable = true)
|
||||
val paidUnavailableAudio = audioContent(price = 100, isPointAvailable = false)
|
||||
|
||||
assertFalse(CreatorChannelAudioContentResponse.from(freeAudio).isPointAvailable)
|
||||
assertTrue(CreatorChannelAudioContentResponse.from(paidAudio).isPointAvailable)
|
||||
assertFalse(CreatorChannelAudioContentResponse.from(paidUnavailableAudio).isPointAvailable)
|
||||
}
|
||||
|
||||
private fun audioContent(price: Int, isPointAvailable: Boolean): CreatorChannelAudioContent {
|
||||
return CreatorChannelAudioContent(
|
||||
audioContentId = price.toLong() + 1,
|
||||
title = "audio",
|
||||
duration = "00:10:00",
|
||||
imageUrl = "audio.png",
|
||||
price = price,
|
||||
isAdult = false,
|
||||
isPointAvailable = isPointAvailable,
|
||||
isFirstContent = true,
|
||||
seriesName = "series",
|
||||
isOriginalSeries = true,
|
||||
isOwned = false,
|
||||
isRented = false
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.home.application
|
||||
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeBannerRecommendationRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeCheerCreatorRecommendationRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeFirstAudioContentRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeGenreCreatorRecommendationGroup
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeLiveRecommendationRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomePopularCommunityRecommendationRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQueryPort
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentDebutCreatorRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentlyActiveCreatorRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class HomeRecommendationFacadeTest {
|
||||
private val queryPort = FakeHomeRecommendationQueryPort()
|
||||
private val queryService = HomeRecommendationQueryService(queryPort, EmptyRecommendationSnapshotPort())
|
||||
private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
|
||||
private val facade = HomeRecommendationFacade(queryService, preferenceService, "https://cdn.test")
|
||||
|
||||
@Test
|
||||
@DisplayName("홈 첫 오디오 응답은 무료 콘텐츠의 포인트 가능 여부를 false로 보정한다")
|
||||
fun shouldNormalizePointAvailabilityForFreeFirstAudioContent() {
|
||||
queryPort.firstAudioContents = listOf(
|
||||
firstAudio(price = 0, isPointAvailable = true),
|
||||
firstAudio(price = 100, isPointAvailable = true),
|
||||
firstAudio(price = 100, isPointAvailable = false)
|
||||
)
|
||||
|
||||
val response = facade.getHomeRecommendations(null)
|
||||
|
||||
assertFalse(response.firstAudioContents[0].isPointAvailable)
|
||||
assertTrue(response.firstAudioContents[1].isPointAvailable)
|
||||
assertFalse(response.firstAudioContents[2].isPointAvailable)
|
||||
}
|
||||
|
||||
private fun firstAudio(price: Int, isPointAvailable: Boolean): HomeFirstAudioContentRecord {
|
||||
return HomeFirstAudioContentRecord(
|
||||
contentId = price.toLong() + 1,
|
||||
creatorId = 10L,
|
||||
creatorNickname = "creator",
|
||||
creatorProfileImage = null,
|
||||
title = "first audio",
|
||||
price = price,
|
||||
coverImage = "cover/audio.png",
|
||||
isPointAvailable = isPointAvailable,
|
||||
isAdult = false,
|
||||
isOriginalSeries = false
|
||||
)
|
||||
}
|
||||
|
||||
private class FakeHomeRecommendationQueryPort : HomeRecommendationQueryPort {
|
||||
var firstAudioContents: List<HomeFirstAudioContentRecord> = emptyList()
|
||||
|
||||
override fun findLiveRecommendations(
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
memberId: Long?,
|
||||
includeAdultLives: Boolean
|
||||
): List<HomeLiveRecommendationRecord> = emptyList()
|
||||
|
||||
override fun findHomeBanners(limit: Int, memberId: Long?): List<HomeBannerRecommendationRecord> = emptyList()
|
||||
|
||||
override fun findRecentlyActiveCreators(
|
||||
limit: Int,
|
||||
memberId: Long?,
|
||||
includeAdultActivities: Boolean
|
||||
): List<RecentlyActiveCreatorRecord> = emptyList()
|
||||
|
||||
override fun findRecentDebutCreators(
|
||||
now: LocalDateTime,
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
memberId: Long?,
|
||||
includeAdultContents: Boolean
|
||||
): List<RecentDebutCreatorRecord> = emptyList()
|
||||
|
||||
override fun findFirstAudioContents(
|
||||
now: LocalDateTime,
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
memberId: Long?,
|
||||
includeAdultContents: Boolean
|
||||
): List<HomeFirstAudioContentRecord> = firstAudioContents
|
||||
|
||||
override fun findAiCharacterSnapshots(
|
||||
windowStart: LocalDateTime,
|
||||
windowEndExclusive: LocalDateTime,
|
||||
limit: Int
|
||||
): List<RecommendationSnapshotRecord> = emptyList()
|
||||
|
||||
override fun findCheerCreatorSnapshots(
|
||||
windowStart: LocalDateTime,
|
||||
windowEndExclusive: LocalDateTime,
|
||||
limit: Int
|
||||
): List<RecommendationSnapshotRecord> = emptyList()
|
||||
|
||||
override fun findPopularCommunitySnapshots(
|
||||
windowStart: LocalDateTime,
|
||||
windowEndExclusive: LocalDateTime,
|
||||
limit: Int
|
||||
): List<RecommendationSnapshotRecord> = emptyList()
|
||||
|
||||
override fun findAiCharacterRecommendationDetails(
|
||||
characterIds: List<Long>
|
||||
): List<HomeAiCharacterRecommendationRecord> = emptyList()
|
||||
|
||||
override fun findRandomAiCharacterRecommendationIds(excludeCharacterIds: List<Long>, limit: Int): List<Long> = emptyList()
|
||||
|
||||
override fun findCheerCreatorRecommendationDetails(
|
||||
creatorIds: List<Long>,
|
||||
memberId: Long?
|
||||
): List<HomeCheerCreatorRecommendationRecord> = emptyList()
|
||||
|
||||
override fun findPopularCommunityRecommendationDetails(
|
||||
communityIds: List<Long>,
|
||||
memberId: Long?,
|
||||
includeAdultCommunities: Boolean
|
||||
): List<HomePopularCommunityRecommendationRecord> = emptyList()
|
||||
|
||||
override fun findGenreCreatorRecommendations(
|
||||
memberId: Long?,
|
||||
includeAdultGenres: Boolean,
|
||||
genreLimit: Int,
|
||||
creatorLimit: Int
|
||||
): List<HomeGenreCreatorRecommendationGroup> = emptyList()
|
||||
}
|
||||
|
||||
private class EmptyRecommendationSnapshotPort : RecommendationSnapshotPort {
|
||||
override fun findLatestSnapshots(
|
||||
sectionType: RecommendedSectionType,
|
||||
offset: Long,
|
||||
limit: Int
|
||||
): List<RecommendationSnapshotRecord> = emptyList()
|
||||
|
||||
override fun findSnapshots(
|
||||
sectionType: RecommendedSectionType,
|
||||
snapshotAt: LocalDateTime,
|
||||
offset: Long,
|
||||
limit: Int
|
||||
): List<RecommendationSnapshotRecord> = emptyList()
|
||||
|
||||
override fun existsLatestSnapshot(sectionType: RecommendedSectionType): Boolean = true
|
||||
|
||||
override fun existsSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean = true
|
||||
|
||||
override fun replaceSnapshots(
|
||||
sectionType: RecommendedSectionType,
|
||||
snapshotAt: LocalDateTime,
|
||||
newSnapshots: List<RecommendationSnapshotRecord>
|
||||
) = Unit
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ class DefaultMainContentAllQueryRepositoryTest @Autowired constructor(
|
||||
val inactiveTheme = saveTheme("inactive-audio-theme", isActive = false)
|
||||
val free = saveAudioContent(creator, theme, now.minusDays(3), isAdult = false, price = 0)
|
||||
val point = saveAudioContent(creator, theme, now.minusDays(2), isAdult = false, price = 100, isPointAvailable = true)
|
||||
val freePoint = saveAudioContent(creator, theme, now.minusHours(12), isAdult = false, price = 0, isPointAvailable = true)
|
||||
saveAudioContent(creator, theme, now.minusDays(1), isAdult = true, price = 200)
|
||||
saveAudioContent(blockedCreator, theme, now.minusDays(1), isAdult = false, price = 100)
|
||||
saveAudioContent(inactiveCreator, theme, now.minusDays(1), isAdult = false, price = 100)
|
||||
@@ -66,11 +67,12 @@ class DefaultMainContentAllQueryRepositoryTest @Autowired constructor(
|
||||
val freeAudios = repository.findAudios(null, false, now, ContentSort.LATEST, 0, 20, onlyFree = true)
|
||||
val pointAudios = repository.findAudios(null, false, now, ContentSort.LATEST, 0, 20, onlyPointAvailable = true)
|
||||
|
||||
assertEquals(2, repository.countAudios(viewer.id, canViewAdultContent = false, now))
|
||||
assertEquals(3, repository.countAudios(viewer.id, canViewAdultContent = false, now))
|
||||
assertEquals(1, repository.countAudios(viewer.id, false, now, onlyPaid = true))
|
||||
assertEquals(listOf(point.id, free.id), visible.map { it.audioContentId })
|
||||
assertEquals(1, repository.countAudios(null, false, now, onlyPointAvailable = true))
|
||||
assertEquals(listOf(freePoint.id, point.id, free.id), visible.map { it.audioContentId })
|
||||
assertEquals(listOf(point.id), paidAudios.map { it.audioContentId })
|
||||
assertEquals(listOf(free.id), freeAudios.map { it.audioContentId })
|
||||
assertEquals(listOf(freePoint.id, free.id), freeAudios.map { it.audioContentId })
|
||||
assertEquals(listOf(point.id), pointAudios.map { it.audioContentId })
|
||||
assertEquals("https://cdn.test/audio.png", visible.first().imageUrl)
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
|
||||
val now = LocalDateTime.of(2026, 6, 23, 12, 0)
|
||||
val creator = saveMember("audio-creator", MemberRole.CREATOR)
|
||||
val theme = saveTheme()
|
||||
val first = saveAudio(
|
||||
saveAudio(
|
||||
creator = creator,
|
||||
theme = theme,
|
||||
title = "first",
|
||||
@@ -108,6 +108,15 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
|
||||
isPointAvailable = false,
|
||||
coverImage = "latest.png"
|
||||
)
|
||||
val paidPoint = saveAudio(
|
||||
creator = creator,
|
||||
theme = theme,
|
||||
title = "paid-point",
|
||||
releaseDate = now.minusDays(2),
|
||||
price = 20,
|
||||
isPointAvailable = true,
|
||||
coverImage = "paid-point.png"
|
||||
)
|
||||
saveAudio(creator, theme, "adult", now.minusHours(1), isAdult = true)
|
||||
saveAudio(creator, theme, "future", now.plusDays(1))
|
||||
saveAudio(creator, theme, "inactive", now.minusHours(2)).isActive = false
|
||||
@@ -139,11 +148,11 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
|
||||
val pointAudios = repository.findPointAudios(10, viewer.id, canViewAdultContent = false, now = now)
|
||||
|
||||
assertEquals(12, latestAudios.size)
|
||||
assertEquals(listOf(latest.id, first.id), latestAudios.take(2).map { it.audioContentId })
|
||||
assertEquals(listOf(latest.id, paidPoint.id), latestAudios.take(2).map { it.audioContentId })
|
||||
assertEquals(10, freeAudios.size)
|
||||
assertEquals(true, freeAudios.all { it.price == 0 })
|
||||
assertEquals(10, pointAudios.size)
|
||||
assertEquals(true, pointAudios.all { it.isPointAvailable })
|
||||
assertEquals(listOf(paidPoint.id), pointAudios.map { it.audioContentId })
|
||||
assertEquals(true, pointAudios.all { it.isPointAvailable && it.price > 0 })
|
||||
val latestCard = latestAudios.first()
|
||||
assertEquals("latest", latestCard.title)
|
||||
assertEquals("00:01", latestCard.duration)
|
||||
@@ -154,8 +163,8 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
|
||||
assertEquals(false, latestCard.isFirstContent)
|
||||
assertEquals(true, latestCard.isOriginalSeries)
|
||||
assertEquals(creator.nickname, latestCard.creatorNickname)
|
||||
assertEquals(true, latestAudios[1].isFirstContent)
|
||||
assertEquals(false, latestAudios[1].isOriginalSeries)
|
||||
assertEquals(true, latestAudios[2].isFirstContent)
|
||||
assertEquals(false, latestAudios[2].isOriginalSeries)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user