diff --git a/AGENTS.md b/AGENTS.md index 80ac2da9..e5c20988 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -196,6 +196,7 @@ single-context 구성으로 루트 `GLOSSARY.md`와 `docs/adr/`를 사용한다. - 필요한 문서/도구/참조가 없으면 어느 검토 부분이 미검증인지 보고한다. 공유 checklist 결손을 조용히 무시하거나 global checklist를 만들거나 설치해 full sign-off로 바꾸지 않는다. - 기존 PRD/plan 경로와 project guides가 변경 범위·테스트·검증 기준이다. 이미 완료된 분석을 다른 skill workflow로 반복하지 않는다. - 구현 시 focused test부터 시작하고 프로젝트의 TDD, 보안 및 사람 승인 경계를 지킨다. 실패한 테스트를 삭제/완화하지 않는다. +- 에이전트는 구현 중인 기능과 직접 영향받는 범위만 각각 테스트한다. 전체 테스트는 사용자가 실행하며, 에이전트는 실행 명령과 미검증 범위를 안내한다. `test` 무필터 실행 및 전체 테스트를 포함하는 `check`/`build`/`clean build`도 자동 실행하지 않는다. 상세 기준은 `docs/agent-guides/실행명령어.md`를 따른다. - 새 session은 갱신된 `AGENTS.md`를 안정적으로 다시 읽을 수 있도록 시작한다. skill 단계는 논리적 routing guidance이며 runtime 자동 invocation 성공을 보장하지 않는다. - worktree는 필요성을 확인하되 자동 생성하지 않는다. branch 종료/merge는 실제 해당 작업일 때만 다루며 commit/push/merge/delete는 명시적 승인 없이는 하지 않는다. - 사용자가 이미 명확하게 승인한 범위는 다시 묻지 않는다. 승인 경계 밖의 새로운 결정이나 scope 확장만 확인한다. diff --git a/docs/20261104_크리에이터채널_비밀후원_익명조회/plan-task.md b/docs/20261104_크리에이터채널_비밀후원_익명조회/plan-task.md new file mode 100644 index 00000000..44b4354a --- /dev/null +++ b/docs/20261104_크리에이터채널_비밀후원_익명조회/plan-task.md @@ -0,0 +1,390 @@ +# 크리에이터 채널 비밀 후원 익명 조회 구현 계획 + +| 문서 항목 | 내용 | +|---|---| +| 상태 | Phase 1 및 `P2-T1`·`P2-GATE` 완료. 전체 테스트는 사용자 실행 | +| 작성일 | 2026-11-04 | +| 요구사항 기준 | [prd.md](prd.md) | +| API 기준 | [prd.md §8](prd.md#8-api-계약) | +| 현재 Phase | Phase 2: 응답 isSecret UI 표시 의미 변경 | +| 현재 활성 Goal | 없음 | +| 다음 Goal | 없음 | + +> 실행 준비 기준: goal별로 한 번에 하나씩 실행하며, 각 Task의 명령과 체크박스는 실행 후에만 완료 처리한다. 구현 방법은 본 계획의 TDD 순서를 따른다. + +**실행 방식:** 사용자 지시에 따라 worktree 없이 현재 `test` 브랜치에서 Goal을 순차 실행한다. 커밋·푸시·배포는 하지 않는다. + +**Architecture:** persistence record는 실제 `isSecret`을 유지한다. 서비스의 기존 `toDomain` mapper가 조회자별 `isMasked`로 표시 필드와 응답 domain의 `isSecret`을 정한다. DTO 필드명·타입과 기존 이미지 URL 조합은 바꾸지 않는다. + +**Spec:** [prd.md](prd.md), 특히 §7~§10. + +**Review Focus:** + +- 공개 행은 다른 인증 viewer에게도 원본 표시: `P1-T1`, `P1-T2` 공개 후원 E2E assertion. +- 비밀 행은 USER 및 본인이 아닌 CREATOR role도 익명: 양 Task의 role별 HTTP E2E. +- null/empty 메시지, localized nickname, 마스킹 이미지 URL과 원본/default-profile 회귀: 양 Task의 mapper/service 검증. +- 월 경계·정렬·limit/page의 비밀 행 포함: repository와 HTTP E2E의 경계·초과 건수 검증. +- 응답 `isSecret`은 조회자별 마스킹 Boolean, DB/record는 원래 비밀 Boolean이다. 양 endpoint 역할별 JSON과 저장값 불변을 검증하고 랭킹은 유지한다. + +## 목표 + +크리에이터 채널 홈과 후원 탭에서 현재 KST 월 비밀 후원을 유지하며, 크리에이터와 후원자 외의 사용자에게는 식별 정보 대신 익명 필드를 반환한다. + +## 현재 상태 + +| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 | +|---:|---|---:|---|---| +| 1 | 범위 한정 완료 | `2/2` | 없음 | 전체 suite는 600000ms timeout으로 결과 미확정. 전체 suite 통과로 간주하지 않음 | +| 2 | 완료 | `1/1` | 없음 | 관련 196개 회귀 및 최종 HTTP 55개 통과. 전체 테스트는 사용자 실행 | + +- 구현 단계의 실행 체크박스는 실제 테스트 및 검토 증거가 확보된 뒤에만 완료 처리한다. +- 미완료 Goal은 동시에 하나만 실행한다. +- 파일명과 URL 구성 계약 및 설정된 CDN 이미지 접근은 확인했다. 전체 프로젝트 suite는 timeout으로 미완료 상태이며 범위 한정 Gate 성공과 분리한다. + +## 범위 + +### 포함 + +- 홈 및 후원 탭의 현재 KST 월 비밀 후원 조회, DB 비밀 설정 보존, 조회자별 UI flag `isSecret` 및 서비스 계층 마스킹. +- 양쪽 DTO 직렬화와 repository, service, controller, facade 및 HTTP MockMvc E2E 검증. +- 기존 접근 제어, 정렬, count/page, 랭킹, 날짜·금액 계약 보존. + +### 제외 + +- 후원 생성, 수정, 취소, DB/DDL, 공개 후원 표시 변경. +- 랭킹 변경 또는 전체 익명성 보장. +- 신규 dependency, 공통 masking abstraction, port signature 변경(실제 필요가 발견된 경우 별도 결정 전까지 금지). +- 새 config, fallback, 공통 helper architecture 또는 외부 번역 서비스. + +## 기술적 제약 + +- Kotlin, Spring Boot 2.7.14, Java 17, Gradle Wrapper, QueryDSL 및 기존 `SodaMessageSource`/CDN URL 변환을 사용한다. +- 이번 변경에서 공개 API 필드를 추가하지 않는다. 내부 `memberId`, 현재 채널 주인 전용 `donationId` 및 숨김 정책은 유지한다. +- persistence에서 비밀 row를 유지하되 서비스가 response domain을 만들기 전 익명화한다. 두 계층 모두 수정과 테스트를 하나의 end-to-end Task로 수행한다. +- 각 Task의 `isSecret` 추가는 해당 탭의 port record, domain model, QueryDSL constructor projection 순서와 해당 테스트 fixture constructor를 함께 갱신한다. +- 로컬 변환 함수 `toDomain`이 이미 있으므로 기존 함수가 있는 service 안에서 viewer/creator/lang 판정을 수행한다. 새 공유 추상화는 만들지 않는다. +- 신규 로직은 먼저 요구 동작을 검증하는 assertion 실패를 관찰한 뒤 구현한다. 필드 추가로 발생한 컴파일 오류는 의도한 RED 증거가 아니다. +- focused test와 직접 영향 package 회귀를 우선한다. 전체 테스트·빌드는 이 계획의 미래 Gate에서만 실행하며, 실제 수행한 결과만 Progress에 기록한다. +- 마스킹 대상 비밀 후원의 이미지 URL은 기존 `defaultProfileImageUrl()`처럼 `cloud.aws.cloud-front.host`에 `/profile/anonymous-profile.png`를 직접 붙여 만든다. 임의 사용자 제공 경로에 `toCdnUrl`을 적용하지 않는다. 테스트 host `https://cdn.test`의 정확한 기대 URL은 `https://cdn.test/profile/anonymous-profile.png`다. +- 익명 URL은 마스킹 대상 비밀 donation item에만 적용한다. 공개 및 creator/donor 본인의 원본 profile image, 기존 `default-profile.png` fallback은 그대로 유지한다. 테스트용 host/URL은 실제 운영 자산 접근 검증을 대신하지 않는다. + +### Phase 1: 홈과 후원 탭 응답에서 비밀 후원 마스킹 + +**Phase 결과:** 두 API가 비밀 후원을 count/list에 포함하고 권한별 원본 또는 익명 표시를 반환한다. + +**선행조건:** PRD `SDA-001`~`SDA-007` 및 §8 계약 확인. 파일명과 URL 구성 계약이 확정되어 있어 이미지 제공 대기 없이 RED와 GREEN을 진행할 수 있다. 운영 CDN 접근 가능 여부는 최종 Gate에서 검증한다. + +**Phase 완료 조건:** `P1-T1`, `P1-T2` 및 `P1-GATE`의 증거 충족. 운영 CDN에서 이미지에 접근할 수 없으면 Gate는 blocked 상태다. + +### 구현 항목 + +- [x] **Task 1.1: 홈 후원 목록 조회와 익명화** + +**Goal 실행 `P1-T1`:** 홈 API는 현재 KST 월 비밀 후원을 포함하고, 그 비밀 행만 크리에이터·해당 후원자는 원본, 다른 조회자는 익명 표시를 받으며 공개 행은 기존 원본 표시를 유지한다. + +- **시작 조건:** 없음. PRD `SDA-001`, `SDA-003`~`SDA-007`과 §8.1~§8.3 확인. 이미지 URL 계약은 확정되어 있다. +- **완료 증거:** 홈 DTO `isSecret`/응답 계약 및 서비스 마스킹 테스트, repository query 행 검증, 실제 HTTP E2E와 정확한 이미지 URL 및 원본/default-profile 회귀 검증 기록. +- **범위 밖:** 후원 탭 변경, 랭킹 변경, 실제 이미지 자산 생성. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHome.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHomeQueryPolicyTest.kt` (관련 동작이 있으면 수정) + +**Interfaces:** + +- Consumes: 기존 `CreatorChannelHomeQueryPort.findChannelDonations(creatorId, viewerId, now, limit, lang)`, `SodaMessageSource.getMessage(key, lang)` 및 `cloud.aws.cloud-front.host`. +- Produces: `CreatorChannelDonationRecord.isSecret: Boolean`, home domain donation의 `isSecret: Boolean`, response JSON `channelDonations[].isSecret`. +- Port method signature를 유지한다. record의 새 `isSecret`을 QueryDSL projection과 모든 constructor fixture에 추가한다. Service mapper signature는 `CreatorChannelDonationRecord.toDomain(creatorId: Long, viewerId: Long, lang: Lang): CreatorChannelDonation`으로 둔다. + +- [x] **RED:** E2E에서 `isSecret` 누락 assertion 실패를 production 변경 전에 확인하고 viewer role 및 공개/비밀 행 cases를 추가했다. +- [x] **RED 확인:** JSON path `$.data.channelDonations[0].isSecret` 누락 assertion 실패 확인. +- [x] **RED 추가 검증:** role, 현지화, 원본/default/익명 이미지 URL 및 Boolean 응답 검증 cases 작성. +- [x] **GREEN:** repository filter 제거, persisted `isSecret` 전달 및 서비스 마스킹을 구현했다. 공개/본인 원본과 기존 default-profile 동작을 보존했다. +- [x] **GREEN 확인:** 홈 package 89 tests 통과. 이후 홈 E2E 18 + service 23 및 `ktlintCheck` 통과. +- [x] **REFACTOR:** KST 월 경계 회귀를 2026년 12월 고정 검증으로 보강했고, 테스트 lint 위반을 수정했다. 역할/언어/월 경계/최신 8건 및 민감 필드 경계를 검증했다. + +**Task 1.1 검증 명령:** + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorChannelHomeFacadeTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest' +``` + +**Expected:** 각 명령 exit code 0. response의 **마스킹 대상 비밀 donation item만** 원본 nickname/message/profile path가 없어야 한다. 공개/본인 후원과 ranking identity는 response에 기존대로 남는다. 구현 시 실행할 명령이며 현재 검증 결과가 아니다. + +**Task 1.1 Progress:** 완료. 세부 RED/GREEN 및 2026년 12월 월 경계 수정 증거는 Progress의 `P1-T1 구현 및 검토`와 후속 수정 기록을 참조한다. + +- [x] **Task 1.2: 후원 탭 목록과 count 익명화** + +**Goal 실행 `P1-T2`:** 후원 탭은 현재 월 비밀 후원을 목록·count에 포함하고, 비밀 row만 권한별로 마스킹하며 공개 row의 기존 원본 표시를 유지한다. + +- **시작 조건:** `P1-T1` 완료. 별도 domain mapper를 사용하되 `P1-T1`에서 등록한 i18n key를 재사용한다. +- **완료 증거:** donation DTO의 `isSecret`, query count/list, service masking, E2E의 viewer 역할별 응답과 pagination 회귀 기록. +- **범위 밖:** 기존 랭킹 쿼리, 랭킹 결과 익명화 또는 랭킹 합계 변경. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationTab.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/dto/CreatorChannelDonationTabResponse.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepositoryTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicyTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryServiceTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacadeTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationEndToEndTest.kt` + +**Interfaces:** + +- Consumes: 기존 `CreatorChannelDonationQueryPort.countChannelDonations/findChannelDonations`, `SodaMessageSource.getMessage(key, lang)`, 그리고 `P1-T1`이 추가하는 `explorer.channel_donation.anonymous.nickname`, `explorer.channel_donation.anonymous.message`. +- Produces: `CreatorChannelDonationRecord.isSecret: Boolean`, donation domain item의 `isSecret: Boolean`, `donations[].isSecret` JSON 필드. +- Port method signature를 유지한다. record constructor projection와 모든 test fixture를 갱신한다. mapper signature는 `CreatorChannelDonationRecord.toDomain(creatorId: Long, viewerId: Long, lang: Lang): CreatorChannelDonation`이다. + +- [x] **RED:** E2E의 `isSecret` JSON assertion을 추가하고 production 변경 전 field 누락 실패를 확인했다. USER/별도 CREATOR role, 공개·본인·타인 비밀 및 out-of-range page cases를 추가했다. +- [x] **RED 확인:** `$.data.donations[0].isSecret` 누락 assertion 실패 확인. +- [x] **RED 추가 검증:** donor/creator 원본, 기본·익명 이미지, 타 역할 마스킹을 위한 검증 cases 작성. +- [x] **GREEN:** secret filter만 제거하고 count/list, paging 및 KST 계약을 유지했다. persisted `isSecret`과 권한별 마스킹을 구현했으며 ranking은 변경하지 않았다. +- [x] **GREEN 확인:** donation package 63 tests 및 `ktlintCheck` 통과. +- [x] **REFACTOR:** mixed 25행 pagination, 전체 count, 월 경계, 역할/언어/메시지 및 기존 ranking 동작을 검증했다. + +**Task 1.2 검증 명령:** + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryServiceTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultCreatorChannelDonationQueryRepositoryTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationControllerTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacadeTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicyTest' +``` + +**Expected:** 구현 시 각 명령 exit code 0. 타인 비밀 항목만 익명화되고 공개/본인 항목 및 랭킹은 유지된다. 100/200/300 fixture의 count는 3, 최신순은 300/200/100이다. 같은 3건을 조회할 때 모든 viewer의 count는 같고, 범위 밖 page는 빈 목록과 count 3을 반환한다. can/createdAtUtc는 모든 역할에서 저장값과 정확히 일치한다. + +**Task 1.2 Progress:** 완료. 상세 결과는 Progress의 `P1-T2 구현 및 검토`와 통합 Gate 기록을 참조한다. + +### Phase 1 Gate + +**Goal 실행 `P1-GATE`:** 두 조회 endpoint의 계약, 권한 마스킹 및 직접 영향 회귀를 범위 한정으로 검증한다. 전체 프로젝트 suite는 timeout으로 미완료이며 성공으로 간주하지 않는다. + +- **시작 조건:** `P1-T1`, `P1-T2` 구현 완료. 파일명과 URL 계약은 확정되어 있으며 실제 운영 CDN 이미지 접근 가능 여부를 검증한다. +- **완료 증거:** focused 패키지 테스트, `ktlintCheck`, `bootJar`, SpringBootTest MockMvc 경로 및 설정된 CDN의 실제 이미지 응답 결과를 Progress에 기록한다. 전체 suite는 별도 제한 사항이다. +- **범위 밖:** 랭킹 수정, 테스트 삭제·완화, 전체 테스트를 무조건 실행하는 것. +- **TDD 예외 사유:** Gate는 제품 로직을 바꾸지 않고 기존 자동 테스트와 제공된 자산 URL을 검증한다. +- **대체 검증 방법:** focused/package test, MockMvc 응답 field와 설정된 이미지 URL 접근 결과를 대조해 기록한다. MockMvc는 live socket/server 검증이 아니다. 코드 수정이 필요하면 owning Task로 되돌린다. + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.*' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.*' +./gradlew ktlintCheck +./gradlew bootJar +``` + +위 명령은 구현 후 실행할 미래 Gate 검증이며 `bootJar`는 전체 test suite를 암묵 실행하지 않는다. 전체 회귀는 광범위 공통 코드/인증/보안 변경, 여러 domain 변경, release/final 전체 상태 증거 요구, targeted tests만으로 영향 범위를 판단할 수 없는 실패 또는 사용자 명시 요청 때만 별도로 실행한다. 기존 message group에 두 i18n key를 추가한 사실만으로 전체 회귀를 요구하지 않는다. 생략 시 근거와 focused/package 회귀 결과를 기록한다. + +```bash +./gradlew test +``` + +**Gate Expected:** E2E와 해당 패키지 테스트·lint·`bootJar`의 실제 성공 결과를 확인한다. 정확한 URL `"$cloudFrontHost/profile/anonymous-profile.png"`가 마스킹 대상 donation item의 `profileImageUrl`이 되고 CDN에서 실제 접근 가능하면 범위 한정 Gate를 완료한다. 전체 suite 결과는 별도로 기록한다. + +**수동 검증:** + +- [x] SpringBootTest/H2/MockMvc 요청으로 creator, donor, USER, 별도 CREATOR role을 검증했다. 별도 live server HTTP 요청은 실행하지 않았다. +- [x] 타인 응답에서 마스킹 대상 secret item의 원문 부재, public/own donation 보존, 목록 DTO의 신규 ID 부재를 검증했다. +- [x] 설정된 CDN URL의 접근 결과는 HTTP 200, `image/png`, 19,331 bytes다. 업로드 작업은 하지 않았다. +- [x] 랭킹의 기존 식별 정보 및 secret contribution 집계와 전역 익명성이 아니라는 제한을 유지했다. + +## 실행 순서와 의존성 + +| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 | +|---:|---|---|---|---| +| 1 | `P1-T1` | 없음 | 아니요 | RED/GREEN 테스트의 실패 위치 판정 | +| 2 | `P1-T2` | `P1-T1` 완료 | 아니요 | donation 변경과 직접 테스트에서 실패 위치 판정 | +| 3 | `P1-GATE` | `P1-T1`, `P1-T2` 완료 | 아니요 | 운영 CDN 접근 실패 시 blocker 유지 | + +```text + P1-T1 → P1-T2 → P1-GATE 범위 한정 완료 +``` + +## 변경 금지 항목 + +- Query의 secret filtering을 제거하는 시점에 service masking과 E2E 증거를 함께 완성한다. raw 비밀 필드를 공개하는 중간 배포를 하지 않는다. +- 월 경계, 홈 limit 8, donation default/page size, 최신순 tie-breaker, 인증·block·creator validation을 바꾸지 않는다. +- 기존 랭킹 query/DTO를 수정하지 않는다. +- 익명 이미지 URL은 `"$cloudFrontHost/profile/anonymous-profile.png"`로 확정되었고 설정된 CDN에서 접근 확인했다. 업로드는 수행하지 않았다. 공개/본인 profile 및 기존 default-profile fallback은 변경하지 않는다. +- 신규 memberId/donationId를 노출하거나 테스트를 삭제·skip·완화하지 않는다. +- [`docs/20260622_크리에이터_채널_후원_탭_API/prd.md`](../20260622_크리에이터_채널_후원_탭_API/prd.md)는 편집하지 않는다. + +## 의사결정 및 중단 규칙 + +- `P1-T1`과 `P1-T2`는 확정된 파일명·URL 계약으로 구현 완료했다. 설정된 CDN 접근 검증을 마쳤다. 전체 suite timeout은 범위 한정 Gate의 성공과 구분한다. +- EN/JA는 사용자 승인 문구가 아닌 작성자 기술 초안이다. implementation review에서 승인/수정 확인을 기록하며 사용자 확정 문구로 간주하지 않는다. +- 회귀가 발견되면 해당 Task의 새 회귀 수정 체크박스와 증거를 계획에 추가한 뒤 수정한다. 성공을 추정하지 않는다. + +### Phase 2: 응답 isSecret을 익명 UI 표시 여부로 변경 + +**결과:** 두 탭에서 익명 표시가 필요한 타인의 비밀 후원만 `isSecret=true`를 반환한다. Phase 1 완료 기록은 당시 저장값 전달 계약의 이력으로 보존하며 현재 의미는 이 Phase가 우선한다. + +- [x] **Task 2.1: 조회자별 UI flag 전달** + +**Goal `P2-T1`:** 기존 마스킹 판정과 응답 flag를 일치시키되 DB 비밀 설정과 나머지 응답·권한 정책을 유지한다. +- 시작 조건: PRD `DEC-008` 승인. 현재 `test` 브랜치에서 진행, worktree·commit·push 없음. +- 완료 증거: 기존 HTTP 역할/언어 매트릭스 RED→GREEN, 두 기능 회귀 성공, DB 비밀값 불변과 owner 전용 donationId/숨김 회귀 통과. +- 범위 밖: DB/record/쿼리 수정, 추가 응답 필드, 숨김·랭킹·인증·시각 정책 변경. + +**Files:** +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryServiceTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationEndToEndTest.kt` + +**Interface:** 로컬 mapper에서 응답 domain 생성 인자 `isSecret = isMasked`를 사용한다. 입력 record의 실제 비밀값·기존 mapper signature 및 DTO signature는 유지한다. + +- [x] **RED:** 기존 두 HTTP E2E 및 service 테스트에서 내 채널/본인 비밀 후원의 flag 기대값을 false로, 타인 비밀은 true로 변경했다. 공개·타인 CREATOR·언어·내용·이미지·금액·시각·donationId assertion은 유지했다. +- [x] **RED 확인:** 두 E2E 메서드 실행은 컴파일 성공 후 `:test` 단계에서 600000ms timeout으로 assertion 결과를 얻지 못했다. 두 service의 역할별 메서드만 실행해 22개 중 8개에서 expected false / actual true 실패를 확인했다. HTTP 검증은 환경 지연 원인을 조사한 뒤 별도로 완료한다. +- [x] **GREEN:** 두 서비스의 반환 `isSecret` 값만 `isMasked`로 바꿨다. 테스트는 fixture/id/can으로 원본 공개·비밀 row를 선택하며 소유자의 응답 flag로 원본 비밀 여부를 추정하지 않는다. +- [x] **GREEN 확인:** 두 service 46개, 관련 패키지 196개 및 최종 HTTP E2E 55개가 통과했다. 조회 후 별도 트랜잭션 clear/재조회로 DB 비밀값 보존을 확인했다. +- [x] **REFACTOR:** 추가 추상화 없이 기능별 회귀·lint 성공과 독립 검토 승인을 기록했다. repository record의 실제 비밀값 assertion은 변경하지 않았다. + +**Goal `P2-GATE`:** 두 기능의 새 flag 계약 및 직접 영향 회귀를 판정한다. +- 시작 조건: `P2-T1` 구현 및 검증 완료. +- 완료 증거: 아래 명령 성공과 독립 검토. 전체 테스트는 사용자가 `./gradlew test`로 실행한다. +- TDD 예외 사유: 검증 전용 Goal이며 제품 코드를 추가하지 않는다. +- 대체 검증: 실제 SpringBootTest/H2/MockMvc 요청 결과와 DB 재조회 assertion, 코드 리뷰 대조. + +```bash +./gradlew --no-daemon -Pkotlin.compiler.execution.strategy=in-process test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.*' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.*' ktlintCheck +``` + +실행 순서: `P2-T1` → `P2-GATE`. 이번 변경에서는 전체 테스트를 자동 실행하지 않는다. + +- [x] `P2-GATE`: 관련 기능 회귀, 실제 HTTP/MockMvc 응답, DB 불변, ktlint 및 독립 검토 통과. + +## Progress + +### 응답 isSecret 의미 변경 커밋 전 재검증: 2026-11-05 + +- 사용자 `/commit-with-validation` 요청에 따라 기존 구현 변경을 커밋한다. 구현 단계의 커밋 미실행 기록은 당시 이력으로 보존한다. +- 실행 명령: `./gradlew --no-daemon -Pkotlin.compiler.execution.strategy=in-process test --tests '*CreatorChannelHomeQueryServiceTest' --tests '*CreatorChannelDonationQueryServiceTest' --tests '*CreatorChannelHomeEndToEndTest' --tests '*CreatorChannelDonationEndToEndTest' ktlintCheck --console=plain`. +- 결과: exit 0, `BUILD SUCCESSFUL`, 2분 27초. 네 클래스 101개 테스트, 실패·오류·skip 0. `ktlintCheck`는 `UP-TO-DATE`로 통과했다. +- `git diff --check` 통과. 전체 테스트는 실행하지 않았으며 사용자 실행 명령은 `./gradlew test`다. + +### P2-GATE 완료 증거 + +- 관련 service 두 클래스 46개 및 `ktlintCheck` 성공. 이어 네 home/donation 패키지를 위 명령으로 검증했다 (`--tests` 패턴 앞에 `*` 사용, `--info --console=plain` 추가). 15개 클래스·196개 테스트, 실패·오류·skip 0, exit 0, 1분 32초. 기존 숨김·owner donationId·정산·랭킹 회귀 포함. +- 독립 정적 리뷰 APPROVE: production 변경은 두 반환 할당뿐이며, 저장값과 익명 내용·기존 권한/필드 구조 보존을 확인했다. +- 부모 최종 실행: `./gradlew --no-daemon -Pkotlin.compiler.execution.strategy=in-process test --tests '*CreatorChannelHomeEndToEndTest' --tests '*CreatorChannelDonationEndToEndTest' ktlintCheck bootJar --console=plain`. exit 0, 1분 45초. home 18 + donation 37 = 55개, 실패·오류·skip 0. `bootJar` 성공, lint 직전 성공 결과 재사용. +- Surface: RANDOM_PORT/TestRestTemplate/실제 JWT 요청과 MockMvc 역할·언어 매트릭스를 구분해 검증했다. 동일 비밀행의 owner/donor GET은 원문과 false, outsider GET은 익명과 true다. 기존 owner-only donationId, 숨김 후 최신 목록 보충 및 DB 원본 불변 assertion 통과. +- 초기 HTTP timeout은 재현되지 않았다. 진단 실행의 donation/home Spring 시작은 각각 약 25.7초/11.7초였으며, 소유 Test Executor 스레드 덤프는 repository 초기화 중 RUNNABLE 상태였고 이후 정상 종료했다. 과거 timeout 원인은 미확정이며 설정을 변경하지 않았다. +- 범위 밖 관찰: H2의 background scheduler에서 `FOR UPDATE SKIP LOCKED` 문법 오류 로그가 있었으나 관련 테스트 실패는 없었다. 원인을 이번 변경이나 timeout으로 단정하거나 수정하지 않았다. +- Kotlin LSP 미설치/도구 부재로 LSP 통과 주장은 하지 않는다. 관련 Kotlin 컴파일과 lint로 검증했다. 전체 테스트·운영 데이터/CDN 재검증·커밋·푸시는 수행하지 않았다. 전체 테스트 사용자 명령: `./gradlew test`. + +### P2-T1 RED 및 최소 변경 + +- HTTP RED 명령: `./gradlew --no-daemon -Pkotlin.compiler.execution.strategy=in-process test --tests '*CreatorChannelHomeEndToEndTest.shouldMaskOnlyOtherMembersSecretDonationsThroughHttp*' --tests '*CreatorChannelDonationEndToEndTest.shouldMaskOnlyOtherMembersSecretDonationsThroughHttp*'`. 컴파일 성공 후 `:test`에서 10분 timeout; RED assertion 증거로 간주하지 않는다. +- 대체 RED 명령: `./gradlew --no-daemon -Pkotlin.compiler.execution.strategy=in-process test --tests '*CreatorChannelHomeQueryServiceTest.shouldMaskSecretDonationOnlyForOtherViewers*' --tests '*CreatorChannelDonationQueryServiceTest.shouldMaskSecretDonationOnlyForOtherViewers*'`. 55초, 22개 중 8개 expected false / actual true 실패. 본인 및 채널 주인의 저장된 비밀 후원에서 기대와 다름을 확인했다. +- 그 뒤 production은 두 서비스의 `isSecret = isSecret`을 `isSecret = isMasked`로 바꾼 2줄만 수정했다. repository/record/DB 및 기존 donationId 판정은 변경하지 않았다. +- 전체 테스트는 실행하지 않는다. 기존 기능별 HTTP/서비스 테스트와 직접 영향 package 회귀만 실행하며, 전체 검증은 사용자 명령 `./gradlew test`로 분리한다. + +### 커밋 전 재검증: 2026-11-04 + +- 사용자 `/commit-with-validation` 요청으로 기존 변경의 커밋을 진행한다. 이전 구현 단계의 커밋 미실행 기록은 당시 상태로 보존한다. +- 실행: `./gradlew --no-daemon -Pkotlin.compiler.execution.strategy=in-process test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.*' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.*' ktlintCheck` +- 결과: exit 0, `BUILD SUCCESSFUL` 1분 56초. XML 13개에서 home 89개와 donation 63개, 총 152개 테스트의 실패·오류·skip 0을 확인했다. lint는 `UP-TO-DATE`이며 `git diff --check`도 통과했다. +- 이번 검증에서 전체 테스트, `bootJar`, live server 및 CDN 접근은 재실행하지 않았다. 기존 전체 suite timeout 이력은 유지하며, 최신 실행 지침에 따라 전체 테스트는 사용자가 `./gradlew test`로 실행한다. + +### 현재 완료 상태: 2026-11-04 + +- 상태: `P1-T1`, `P1-T2` 구현 및 독립 리뷰 승인 완료. `P1-GATE`는 아래 범위 한정 검증 완료. 전체 프로젝트 suite는 timeout으로 결과 미확정이며 전체 suite 통과 또는 전체 release 검증 완료로 표시하지 않는다. +- 통합 검증: `./gradlew --no-daemon -Pkotlin.compiler.execution.strategy=in-process test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.*' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.*' ktlintCheck bootJar` exit 0, `BUILD SUCCESSFUL` 49s. 총 152 tests (home 89, donation 63), failures/errors/skipped 0. 출력 artifact: `build/libs/sodalive-0.0.1-SNAPSHOT.jar`. +- 이미지 접근: `CLOUD_FRONT_HOST/profile/anonymous-profile.png` 요청은 host를 출력하거나 기록하지 않고 HTTP 200, `image/png`, 19,331 bytes를 반환했다. 자산 업로드는 하지 않았다. +- Surface 검증은 SpringBootTest/H2/MockMvc의 실제 endpoint 요청 경로다. 별도 live server/curl 요청은 하지 않았다. +- 전체 프로젝트 `./gradlew --no-daemon -Pkotlin.compiler.execution.strategy=in-process test`는 `:test`에서 600000ms tool timeout에 도달했다. 최종 결과가 없고 실패 assertion도 출력되지 않았으므로 성공/실패 어느 쪽으로도 판정하지 않는다. 이후 Gradle/Test/Redis 프로세스가 남지 않았음을 확인했다. +- 독립 reviewer는 P1-T2와 전체 feature를 승인했으며 blocker가 없었다. 홈의 2026년 12월 월 경계 결함 수정은 T1 Progress 기록에 보존되어 있다. +- Kotlin LSP 진단은 도구 부재로 수행할 수 없었다. home은 11개 변경 파일, donation은 executor에서 진단 도구를 사용할 수 없었다. 컴파일·lint 성공은 LSP 진단 성공으로 간주하지 않는다. +- EN/JA 번역은 기존 문서 결정대로 기술 초안이며 제품 승인 문구로 간주하지 않는다. 커밋, worktree, push, 배포, live application server 실행은 하지 않았다. + +### 이전 실행 기록 + +### 문서 작성: 2026-11-04 + +- 상태: 계획 작성 완료, 구현 미착수 +- 무엇을: 요구사항과 구현 경로를 확인하고 PRD 및 goal 실행 계획을 작성했다. +- 왜: 구현 전 요구사항과 테스트 가능한 완료 기준을 고정하기 위해서다. +- 어떻게: + - 샘플 `docs/sample/sample-prd.md`, `docs/sample/sample-plan-task.md` 및 `docs/agent-guides/문서유지보수.md`/`작업절차.md`를 읽었다. 과거 기준 문서는 [`docs/20260622_크리에이터_채널_후원_탭_API/prd.md`](../20260622_크리에이터_채널_후원_탭_API/prd.md)다. + - Kotlin source 및 두 기존 `SpringBootTest` MockMvc E2E 파일을 읽고 현재 persistence filter, service mapper, DTO 필드, test fixture 구조를 확인했다. + - 문서 전용 작업이므로 Gradle, 테스트, 빌드, 서버 요청은 실행하지 않았다. +- 남은 항목: 실제 코드 변경 및 모든 구현 체크박스, 외부 의존 `EXT-001` 이미지 제공/검증. +- 다음 행동: `P1-T1`의 첫 RED 테스트를 작성한다. 구현 착수 시 실제 자산 제공 여부를 다시 확인한다. + +### 문서 정합성 검증: 2026-11-04 + +- 두 문서를 직접 읽고 인터뷰 결정 및 현재 source/test 구조와 대조했다. 읽기 전용 독립 검토 결과는 APPROVE이며 차단 지적은 없었다. +- Node 정적 검사로 문서의 Kotlin 경로 23개와 로컬 링크 8개의 존재, trailing whitespace 없음, 끝 개행, 구현 체크박스 미완료 상태를 확인했다. +- 두 Markdown 파일에 `lsp_diagnostics`를 시도했으나 `.md` 서버 미설정으로 진단할 수 없었다. 문서 간 대조와 정적 검사를 대체 근거로 사용했다. +- 문서 전용 변경이므로 프로젝트 문서 유지보수 규칙에 따라 Gradle 테스트·빌드·API 실행 검증은 생략했다. 위 미래 구현 검증 명령의 성공을 의미하지 않는다. + +### 이미지 계약 정정: 2026-11-04 + +- 기존 Progress 및 `ISSUE-001` 기록은 작성 당시의 filename/path 미제공 상태를 나타내는 역사 기록이다. 이후 사용자 입력으로 filename/path 계약이 제공되어 해당 blocker 상태는 현재 유효하지 않다. +- 파일명은 `anonymous-profile.png`이며 URL은 기존 `defaultProfileImageUrl()`과 동일한 직접 host/path 조합인 `"$cloudFrontHost/profile/anonymous-profile.png"`로 확정했다. 테스트 host `https://cdn.test`의 기대 URL은 `https://cdn.test/profile/anonymous-profile.png`다. +- 이 계약은 마스킹 대상 비밀 donation item에만 적용한다. 공개 및 본인 원본 profile, 기존 default-profile fallback은 그대로 둔다. 운영 파일 업로드와 실제 CDN 접근 가능성은 검증하지 않았으며 `P1-GATE`에 남는다. +- 실행 체크박스는 모두 미완료이며 테스트, 빌드, API 요청, CDN 접근 검증은 수행하지 않았다. + +### P1-T1 구현 및 검토: 2026-11-04 + +- 완료: 홈 repository의 비밀행 제외 제거, persisted `isSecret` 전달, ID 기반 마스킹, KO/EN/JA 문구와 익명 프로필 URL을 구현했다. +- RED: 기존 E2E에서 `No value at JSON path "$.data.channelDonations[0].isSecret"` assertion 실패를 먼저 확인했다. 확장 테스트에서도 기존 필터의 비밀행 누락을 확인했다. +- GREEN: `./gradlew --no-daemon -Pkotlin.compiler.execution.strategy=in-process test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.*'` 성공. 89개, 실패·오류·skip 0 (E2E 18, repository 38, service 23, policy 5, controller 3, facade 2). +- 독립 확인: 부모 세션에서 홈 E2E를 같은 옵션으로 재실행하여 성공했다. 변경 파일 11개를 읽고 검토했다. +- 리뷰 수정: 테스트의 UTC 월초 `plusMonths` 계산이 12월에 잘못되는 문제를 고정 assertion으로 재현하고 KST 기준 월 이동으로 수정했다. 추가된 테스트 2파일의 ktlint 포맷 위반도 수정했다. +- 수정 후 `test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest' ktlintCheck` (위 공통 Gradle 옵션) 성공. E2E 18 + service 23, 실패·skip 0. 새 읽기 전용 검토에서 이전 P2 지적 ADDRESSED 및 새 결함 없음 확인. +- Kotlin LSP 미설치로 11개 파일 진단 불가. Gradle Kotlin 컴파일 및 ktlint 결과를 기록하며 LSP 통과로 간주하지 않는다. +- 실제 API surface는 SpringBootTest/H2/MockMvc 전체 요청 경로로 확인했다. 운영 CDN 접근은 아직 미검증이며 Gate에서 별도로 판정한다. + +### P1-GATE 이미지 사전 검증: 2026-11-04 + +- 환경의 `CLOUD_FRONT_HOST`가 설정되어 있음을 확인하고, host 값을 출력하지 않은 채 `curl --silent --show-error --max-time 20 --output /dev/null --write-out 'HTTP_STATUS=%{http_code}\nCONTENT_TYPE=%{content_type}\nSIZE_DOWNLOAD=%{size_download}\n' "$CLOUD_FRONT_HOST/profile/anonymous-profile.png"`를 실행했다. +- 결과: HTTP 200, Content-Type `image/png`, 다운로드 크기 19,331바이트. 설정된 CDN의 실제 익명 이미지 접근 가능성을 확인했다. 자산 업로드·수정은 하지 않았다. +- 이미지 접근 조건만 충족했으며, `P1-T2`와 통합 회귀·리뷰가 끝나기 전 전체 Gate는 완료하지 않는다. + +### P1-T2 구현 및 통합 Gate: 2026-11-04 + +- T2 RED: production 변경 전 MockMvc E2E 5개 중 1개가 `$.data.donations[0].isSecret` 누락 assertion으로 실패했다. T2 GREEN: donation 패키지 63 tests와 `ktlintCheck` 통과. 역할별 마스킹, count/page, KST 경계, KO/EN/JA 및 legacy ranking 회귀를 검증했다. E2E 21, repository 14, service 17, policy 5, controller 2, facade 2, legacy ranking adapter 2개가 통과했다. 결과 XML과 HTML은 `build/test-results/test/`, `build/reports/tests/test/`에 있다. +- 통합 명령은 위 현재 완료 상태에 기록한 home/donation 4 package 패턴, `ktlintCheck`, `bootJar`를 함께 실행했으며 152/152 tests, lint 및 jar 성공으로 끝났다. jar 경로는 `build/libs/sodalive-0.0.1-SNAPSHOT.jar`다. +- CDN 이미지 확인은 HTTP 200, `image/png`, 19,331 bytes였다. host는 로그에 기록하지 않았다. 자산 업로드는 하지 않았다. +- 독립 reviewer는 P1-T2와 전체 feature를 승인했고 blocker가 없었다. +- 전체 프로젝트 suite 추가 실행은 `:test` 단계에서 600000ms timeout에 도달해 최종 결과를 얻지 못했다. 실패 assertion 출력은 없었지만 suite 통과로 간주하지 않는다. 프로세스 확인에서 남은 Gradle/Test/Redis 프로세스는 없었다. +- LSP 진단은 home 11개 파일 및 donation에서 사용 가능한 도구가 없어 완료되지 않았다. Kotlin 컴파일과 ktlint 결과만 검증 근거다. 실제 live server 요청은 없으며 surface QA는 MockMvc 경로로 한정한다. +- EN/JA는 기존 기술 초안 상태를 유지하며 제품 승인으로 변경하지 않는다. commit/worktree/push/deploy는 없었다. + +## Decision Log + +| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 | +|---|---|---|---|---|---| +| 2026-11-04 | `DEC-001` | 확정 | 홈 및 후원 탭을 각각 저장소·service·DTO·E2E까지 완료하는 bounded Task로 나눈다. | 서로 다른 domain port와 DTO, 독립 E2E가 존재 | `P1-T1`, `P1-T2` | +| 2026-11-04 | `DEC-002` | 확정 | 기존 local mapper와 CDN 변환을 사용하고 공용 masking abstraction 및 port signature 변경을 만들지 않는다. | 현재 service 구현 방식과 최소 변경 | `P1-T1`, `P1-T2` | +| 2026-11-04 | `DEC-003` | 확정 (과거 기록, `DEC-004`로 정정) | 익명 이미지 자산은 P0 외부 의존이며 경로 검증 전 Gate를 통과시키지 않는다. | 사용자 제공 자산 필요 | `EXT-001`, `P1-GATE` | +| 2026-11-04 | `DEC-004` | 확정 | 사용자가 제공한 파일명은 `anonymous-profile.png`; 기존 default-profile 방식대로 `cloud.aws.cloud-front.host`에 `/profile/anonymous-profile.png`를 직접 붙인다. URL 계약은 확정됐지만 운영 CDN 접근은 미검증이다. | 사용자 입력과 두 query service의 기존 defaultProfileImageUrl 근거 | `P1-T1`, `P1-T2`, `P1-GATE`, `ISSUE-001` | +| 2026-11-04 | `DEC-005` | 확정 | 익명 이미지는 마스킹 대상 비밀 donation item에만 쓰고 공개/본인 원본 profile과 기존 default-profile fallback은 보존한다. | 기존 표시 정책 유지 | `P1-T1`, `P1-T2` | + +## 발견된 문제 + +| ID | 심각도 | 상태 | 발견 내용 | 영향 Goal | 처리 계획 | +|---|---|---|---|---|---| +| `ISSUE-001` | Blocker | 해결 | 파일명·URL 계약 및 설정된 CDN 접근 검증을 완료했다. 별도의 전체 suite timeout은 제품 버그로 분류하지 않고 현재 검증 한계로 기록한다. | `P1-GATE` | 추가 조치 없음. 전체 suite 결과는 미확정이며 통과로 주장하지 않는다. | diff --git a/docs/20261104_크리에이터채널_비밀후원_익명조회/prd.md b/docs/20261104_크리에이터채널_비밀후원_익명조회/prd.md new file mode 100644 index 00000000..b2ee7b4c --- /dev/null +++ b/docs/20261104_크리에이터채널_비밀후원_익명조회/prd.md @@ -0,0 +1,184 @@ +# 크리에이터 채널 비밀 후원 익명 조회 PRD + +## 문서 정보 + +| 항목 | 내용 | +|---|---| +| 문서 상태 | 응답 isSecret UI 표시 의미 변경 및 관련 기능 검증 완료 (`P2-GATE`) | +| 작성일 | 2026-11-04 | +| 최종 수정일 | 2026-11-04 | +| 대상 제품 | 크리에이터 채널 비밀 후원 익명 조회 | +| 작성자·결정권자 | 제품 요청 기준 | +| 관련 API Contract | 이 문서 §8 | +| 관련 구현 계획 | [plan-task.md](plan-task.md) | +| 관련 review | 없음 | + +## 1. Overview + +크리에이터 채널 홈과 후원 탭에서 비밀 후원 행은 그대로 보이되, 크리에이터와 후원자 본인 외의 조회자에게 후원자의 식별 정보와 메시지를 숨긴다. 응답 `isSecret`은 현재 조회자에게 익명 UI로 표시해야 하는지를 나타낸다. 마스킹 대상 비밀 후원 이미지 URL은 기존 `default-profile`과 같은 방식으로 `cloud.aws.cloud-front.host`에 `/profile/anonymous-profile.png`를 붙여 구성한다. 최초 구현 검증 이력은 보존하고, 후속 의미 변경은 `P2-T1`에서 별도로 검증한다. + +## 2. Problem Statement + +현재 두 조회 경로는 비밀 후원을 일반 조회자에게 결과에서 제거한다. 그 결과 후원 건수와 목록의 의미가 조회자에 따라 달라지고, 사용자는 비밀 후원이 있었는지 확인할 수 없다. 해결 후에는 건수와 행은 유지하면서 권한에 맞게 표시 필드만 가려야 한다. + +## 3. Goals + +### 3.1 제품 목표 + +- 홈의 `channelDonations`와 후원 탭 `donations`에 현재 KST 월의 비밀 후원을 포함한다. +- 비밀 후원에 한해 크리에이터 본인 또는 해당 후원자 본인에게 원래 닉네임, 메시지, 프로필 이미지를 보여 준다. 공개 후원은 기존처럼 모든 인증 조회자에게 원본을 보여 준다. +- 비밀 후원의 나머지 조회자에게 확정된 익명 표시 문구와 `"$cloudFrontHost/profile/anonymous-profile.png"` 이미지를 보여 준다. 공개 후원과 크리에이터·후원자 본인의 프로필 이미지 및 기존 default-profile fallback은 변경하지 않는다. +- 두 응답의 `isSecret`은 현재 조회자에게 익명 후원 UI로 표시해야 하는지를 나타낸다. DB의 실제 비밀 여부는 변경하지 않는다. + +### 3.2 UX 목표 + +- 익명화된 후원도 목록의 건수와 정렬을 유지해 숨겨진 행으로 오해되지 않게 한다. +- 메시지가 null 또는 빈 문자열이어도 비공개 문구를 표시한다. + +## 4. Non-Goals + +- 후원 작성, 수정, 취소, DB 스키마 또는 DDL 변경. +- 공개 후원의 표시 변경. +- 랭킹의 노출·집계·익명성 변경. 기존 랭킹 식별 정보와 비밀 후원 집계는 유지한다. +- 플랫폼 전체에서 후원자를 알아볼 수 없게 하는 익명성 보장. 이 변경은 두 목록의 특정 필드만 가린다. +- 인증, 차단, 크리에이터 검증, API route, pagination 정책 변경. +- 기존 `20260622` 문서 수정. 본 문서는 목록의 비밀 후원 노출, 건수 및 `isSecret` 표시 범위에 한해 후속 기준이며 그 외 역사적 결정은 대체하지 않는다. + +## 5. Target Users and Permissions + +| 사용자 | 조회 결과 | +|---|---| +| 채널 크리에이터 (`viewerId == creatorId`) | 공개 및 비밀 후원 모두 원본 필드 | +| 후원자 본인 (`viewerId == donor.memberId`) | 자신의 공개 및 비밀 후원 원본 필드 | +| 그 외 인증 조회자 | 공개 후원은 원본 필드, 비밀 후원은 행을 유지하고 닉네임·메시지·프로필 이미지를 익명 표시 | + +- 기존 인증 및 크리에이터 조회 권한을 그대로 적용한다. +- 기존 차단 확인과 크리에이터 역할 검증을 변경하지 않는다. +- 이번 후속 변경은 새 `memberId`나 식별 필드를 추가하지 않으며, 현재 구현의 채널 주인 전용 `donationId` 반환 정책을 유지한다. + +## 6. 핵심 사용자 흐름 + +1. 인증 사용자가 크리에이터 채널 홈 또는 후원 탭을 조회한다. +2. 서버는 기존 권한·차단·크리에이터 검증을 수행하고 현재 KST 월 후원 목록을 가져온다. +3. 비밀 후원은 크리에이터와 해당 후원자만 원본 필드를 받고, 그 외 조회자는 익명화된 값을 받는다. 공개 후원은 기존대로 인증 조회자에게 원본 필드를 제공한다. +4. 클라이언트는 응답 `isSecret` 하나로 익명 후원 UI를 선택한다. 기존 정렬·건수·pagination은 유지한다. + +## 7. 기능 요구사항 + +요구사항과 파일명·URL 구성 계약은 확정됐고 구현됐다. 설정된 CDN에서 익명 이미지 URL의 실제 접근도 확인됐다. + +| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 | +|---|---|---|---|---| +| `SDA-001` | 확정 | 홈 `channelDonations`에 현재 KST 월의 공개·비밀 후원을 포함한다. | 현재 월 경계 포함, 이전·다음 월 제외, 전체 후보에서 최신 최대 8건, `createdAt`/id 내림차순. 홈 count 필드는 추가하지 않는다. | §8.1~§8.2, `P1-T1` | +| `SDA-002` | 확정 | 후원 탭 `donations`와 `donationCount`에 현재 KST 월의 공개·비밀 후원을 포함한다. | count가 공개/비밀 전체와 일치하고 기존 페이지 경계를 유지 | §8.1~§8.2, `P1-T2` | +| `SDA-003` | 확정 | 비밀 후원은 본인 또는 크리에이터에게 원본 필드를 반환하고, 공개 후원은 기존처럼 모든 인증 조회자에게 원본을 반환한다. | 비밀의 경우 `viewerId == creatorId` 또는 `viewerId == donor.memberId`; 공개 행은 인증 viewer 모두 nickname/message/profile 원본 | §8.3, `P1-T1`, `P1-T2` | +| `SDA-004` | 확정 | 비밀 후원의 나머지 조회자에게 해당 행 필드만 익명화한다. | 닉네임 `익명`, 메시지 `크리에이터에게만 보여요`, 프로필 URL은 `"$cloudFrontHost/profile/anonymous-profile.png"` | §8.3, `P1-T1`, `P1-T2`, `EXT-001` | +| `SDA-005` | 정정 확정 | 응답 `isSecret`은 현재 조회자에게 익명 처리된 항목일 때만 true다. | 공개·본인 후원·내 채널 후원은 false, 다른 채널의 타인 비밀 후원은 true. DB 원본은 보존 | §8.1~§8.2, `P2-T1` | +| `SDA-006` | 확정 | 익명 닉네임과 메시지 문구를 요청 언어에 맞춰 반환한다. | `Lang.KO/EN/JA`와 기존 `SodaMessageSource` 사용, 두 key 모두 언어별 값, KO 문구 정확 일치 | §8.3, `P1-T1`, `P1-T2` | +| `SDA-007` | 완료 | 익명 프로필 이미지 파일명은 `anonymous-profile.png`; URL은 `"$cloudFrontHost/profile/anonymous-profile.png"`로 구성한다. | 설정된 CDN에서 HTTP 200, `image/png`, 19,331 bytes를 확인했다. | `EXT-001`, `P1-GATE` | + +## 8. API 계약 + +### 8.1 공통 동작 + +- 기존 인증, block 검사, creator 검증과 응답 envelope를 유지한다. +- 현재 KST 월 시작은 inclusive, 다음 KST 월 시작은 exclusive이며 저장·비교 시간 변환 규칙은 기존 구현을 유지한다. +- 최신순은 `createdAt DESC, donation.id DESC`다. +- 응답 `isSecret = 저장된 isSecret && viewerId != creatorId && viewerId != donorId`다. 공개 후원·본인이 한 비밀 후원·내 채널의 비밀 후원은 false, 다른 채널에서 타인이 한 비밀 후원만 true다. +- 응답값은 실제 비밀 설정을 설명하지 않고 익명 UI 표시 여부만 나타낸다. DB와 repository record의 `isSecret`은 저장값 그대로 유지한다. `isMyDonation` 등 추가 필드는 만들지 않는다. +- `can`과 `createdAtUtc`는 실제 값 그대로 반환한다. +- 새 donor `memberId`나 추가 식별 필드를 만들지 않는다. 후속 기능으로 도입된 채널 주인 전용 `donationId` 노출 및 후원 숨김 정책은 이번 변경에서 그대로 유지한다. + +### 8.2 Endpoint 계약 + +| 조회 대상 | 응답 `isSecret` | 표시 정보 | +|---|---|---| +| 공개 후원 | false | 원본 | +| 내가 한 비밀 후원 | false | 원본 | +| 내 채널의 비밀 후원 | false | 원본 | +| 다른 채널에서 타인이 한 비밀 후원 | true | 익명 처리 | + +| Method 및 경로 | 응답 필드 | 목록 및 page 규칙 | +|---|---|---| +| `GET /api/v2/creator-channels/{creatorId}/home` | `data.channelDonations[]`에 `nickname`, `profileImageUrl`, `can`, `message`, `createdAtUtc`, `isSecret` | 현재 월 전체 대상 중 최신 최대 8건 | +| `GET /api/v2/creator-channels/{creatorId}/donations` | `data.donations[]`에 같은 후원 필드와 `isSecret`, `data.donationCount` | 기본 page `0`, 기본 size `20`, size `20..50`, hasNext 기존 규칙 | + +### 8.3 익명화 및 현지화 + +마스킹 대상 비밀 후원의 `profileImageUrl`은 `cloud.aws.cloud-front.host`를 사용하는 기존 `defaultProfileImageUrl()`과 동일한 직접 문자열 조합으로 `"$cloudFrontHost/profile/anonymous-profile.png"`를 만든다. 이는 임의 사용자 제공 경로에 `toCdnUrl`을 적용하는 계약이 아니다. 기존 구현 근거는 `CreatorChannelHomeQueryService.kt:258`과 `CreatorChannelDonationQueryService.kt:114`의 `defaultProfileImageUrl(): String = "$cloudFrontHost/profile/default-profile.png"`이다. `https://cdn.test`를 host로 쓰는 테스트 기대 URL은 `https://cdn.test/profile/anonymous-profile.png`다. + +이 익명 URL은 **마스킹 대상 비밀 후원 행에만** 사용한다. 공개 후원과 크리에이터·해당 후원자 본인의 원본 프로필 이미지 및 기존 `default-profile.png` fallback은 그대로 유지한다. 설정된 CDN에서 해당 경로의 이미지 접근을 확인했다. 이 검증은 업로드 주체나 별도 운영 배포 상태를 확인한 것은 아니다. + +기존 `SodaMessageSource`에서 다음 key를 `Lang.KO`, `Lang.EN`, `Lang.JA`별로 제공한다. + +| Key | KO | EN 기술 초안 | JA 기술 초안 | +|---|---|---|---| +| `explorer.channel_donation.anonymous.nickname` | `익명` | `Anonymous` | `匿名` | +| `explorer.channel_donation.anonymous.message` | `크리에이터에게만 보여요` | `Only visible to the creator` | `クリエイターにのみ表示されます` | + +EN/JA 문구는 사용자가 명시 승인한 문구가 아니며 작성자의 기술 초안이다. 구현 review에서 제품 문구 확인 대상으로 고정하고 `SodaMessageSource` 외부 번역 서비스를 도입하지 않는다. null 또는 빈 원본 메시지에도 익명 조회자는 위 문구를 받는다. + +### 8.4 랭킹 제한 + +랭킹 query·응답·가시성·합계를 변경하지 않는다. 랭킹의 `userId`, 닉네임, 프로필은 기존 규칙대로 노출될 수 있고 집계에는 비밀 후원 기여분이 포함된다. 따라서 이 기능만으로 전체 서비스 차원의 후원자 익명성이 보장되지 않는다. + +## 9. 외부 의존 + +| ID | 우선순위 | 제공 필요 계약 | 담당 주체 | 구현 영향 | 재개 조건 | +|---|---:|---|---|---|---| +| `EXT-001` | 완료 | 파일명 `anonymous-profile.png`; URL 계약 `"$cloudFrontHost/profile/anonymous-profile.png"` (`cloud.aws.cloud-front.host` 사용) | 사용자/제품 자산 제공자 | 익명 조회자의 이미지 필드. 설정된 CDN에서 접근 확인 | HTTP 200, `image/png`, 19,331 bytes 확인. 업로드 작업은 수행하지 않음 | + +테스트 host `https://cdn.test`의 정확한 기대 URL은 `https://cdn.test/profile/anonymous-profile.png`다. 신규 config, fallback, 공통 helper 또는 임의 사용자 경로용 `toCdnUrl`은 추가하지 않는다. 설정된 CDN 접근 검증은 통과했다. 저장소 필터를 없애 raw secret 필드를 반환하는 코드를 먼저 배포해서는 안 된다. + +## 10. 보안과 데이터 취급 + +- 익명화는 응답 DTO 이전 서버 서비스 변환에서 수행한다. 익명 뷰어에게 raw 비밀 필드를 보내지 않는다. +- 저장소가 비밀 행을 조회하도록 바뀌어도 원본 값이 API 출력·로그·오류에 노출되지 않게 서비스 경계를 검증한다. +- 이 변경으로 donor ID를 새로 반환하지 않는다. +- 랭킹의 기존 식별 정보가 목록 마스킹을 우회해 보일 수 있음을 문서화하며 전역 익명성을 약속하지 않는다. + +## 11. 성공 기준과 추적 + +### 11.1 수용 기준 + +- [x] 홈 `channelDonations`는 전체 현재 월 공개·비밀 후보 중 최신 8건을 유지하며 새 home count 필드를 추가하지 않는다 (`SDA-001`). +- [x] 후원 탭 공개/비밀 모두 건수·페이지·hasNext에 포함된다 (`SDA-002`). +- [x] 비밀 후원은 크리에이터와 해당 후원자 본인만 원본을 보고 다른 사용자는 익명 문구와 이미지 자산을 본다. 공개 후원은 모두 원본을 본다 (`SDA-003`, `SDA-004`). +- [x] 최초 구현에서 저장된 비밀 상태를 Boolean으로 직렬화했다 (과거 기준, `DEC-008`로 의미 변경). +- [x] 현재 조회자에게 익명 처리된 항목만 응답 `isSecret=true`이며 원본 반환 항목은 false다. 조회 후에도 DB의 비밀 설정은 동일하다 (`SDA-005`, `P2-T1`). +- [x] `can`, `createdAtUtc`, 기존 인증·차단·크리에이터 오류 및 랭킹 동작이 유지된다. +- [x] URL 계약과 설정된 CDN 접근을 검증했다. HTTP 200, `image/png`, 19,331 bytes (`SDA-007`). +- [x] HTTP MockMvc E2E로 홈과 후원 탭의 익명/본인/크리에이터 응답을 검증했다. 별도 live application server 요청은 수행하지 않았다. + +범위 한정 feature gate는 package tests, `ktlintCheck`, `bootJar`, CDN 접근 검증으로 완료했다. 전체 프로젝트 test suite는 제한 시간 timeout으로 완료되지 않았으며 전체 suite 통과를 뜻하지 않는다. + +### 11.2 추적표 + +| 요구사항 | 구현 계획 | 자동 검증 | +|---|---|---| +| `SDA-001`, `SDA-003`, `SDA-005`, `SDA-006` | `P1-T1` | 홈 repository/service/controller 및 `CreatorChannelHomeEndToEndTest` | +| `SDA-002`~`SDA-006` | `P1-T2` | donation repository/service/controller 및 `CreatorChannelDonationEndToEndTest` | +| `SDA-007` | `P1-T1`, `P1-T2`, `P1-GATE` | 두 Task는 확정 URL 계약과 기존 이미지 회귀를 검증하고, Gate는 운영 CDN 접근 가능 여부를 검증 | +| `SDA-005` 정정 | `P2-T1`, `P2-GATE` | 홈·후원 service/HTTP E2E의 역할별 UI flag, DB 원본 및 기존 donationId/숨김 회귀 | + +## 12. 과거 문서와의 관계 + +[`docs/20260622_크리에이터_채널_후원_탭_API/prd.md`](../20260622_크리에이터_채널_후원_탭_API/prd.md)의 문서는 작성 당시의 역사적 기록으로 남긴다. 본 PRD는 홈 및 후원 탭에서 비밀 후원을 제거하던 기존 visibility 동작, 그에 따른 목록 건수, 응답 `isSecret` 부재에 대해서만 후속 기준을 정한다. 랭킹을 비롯한 본 문서에 명시하지 않은 기존 결정은 바꾸지 않는다. + +## 13. Decision Log + +| 날짜 | ID | 상태 | 결정 | 근거 | 영향 | +|---|---|---|---|---|---| +| 2026-11-04 | `DEC-001` | 확정 | 두 목록은 전체 현재 월 비밀 후원을 포함하고 표시 필드만 권한별 마스킹한다. | 요청에서 확정된 제품 동작 | `SDA-001`~`SDA-006`, §8 | +| 2026-11-04 | `DEC-002` | 확정 | `isSecret`은 persisted 비밀 상태를 보여 주며 모든 사용자에게 제공한다. | 요청에서 확정된 API 동작 | `SDA-005` | +| 2026-11-04 | `DEC-003` | 확정 | 랭킹은 변경하지 않고 전역 익명성을 보장하지 않는다. | 요청에서 확정된 범위 | §8.4 | +| 2026-11-04 | `DEC-004` | 외부 의존 (과거 기록, `DEC-006`으로 정정) | 익명 프로필 이미지는 자산 경로를 받기 전까지 운영 구현 및 완료를 차단한다. | 실제 이미지 제공 필요 | `EXT-001`, `P1-GATE` | +| 2026-11-04 | `DEC-005` | 기술 초안 | EN/JA 문구는 작성자 초안으로 구현 review 때 제품 확인한다. | 한국어만 정확 문구 승인됨 | `SDA-006` | +| 2026-11-04 | `DEC-006` | 확정 | 사용자가 제공한 익명 이미지 파일명은 `anonymous-profile.png`; 기존 `defaultProfileImageUrl()`과 동일하게 `cloud.aws.cloud-front.host`를 사용해 `"$cloudFrontHost/profile/anonymous-profile.png"`로 직접 구성한다. | 파일명 및 default-profile 구성 방식 사용자 확정. 운영 CDN 접근성은 별도 미검증 | `SDA-007`, `EXT-001`, §8.3 | +| 2026-11-04 | `DEC-007` | 확정 | 익명 이미지는 마스킹 대상 비밀 후원 행에만 적용하고 공개/본인 원본 이미지와 기존 default-profile fallback은 보존한다. | 기존 표시 정책 유지 | `SDA-003`, `SDA-004`, §8.3 | + +| 2026-11-04 | `DEC-008` | 정정 확정 | `DEC-002`의 저장값 전달 계약을 대체한다. 응답 isSecret은 익명 UI 표시 여부이며 공개·본인·내 채널은 false, 타 채널 타인 비밀 후원만 true다. DB 저장값, 원본/익명 내용, 기존 donationId와 숨김 정책은 유지한다. | UI에서 실제 비밀 여부 구분이 필요 없다는 사용자 확정 및 수정 요청 | `SDA-005`, §8.1, `P2-T1` | + +## 14. 변경 관리 + +요구사항 변경은 Decision Log, 이 PRD, 이 문서 안 API 계약, [plan-task.md](plan-task.md) 순서로 반영한다. 구현 시작 전에는 외부 의존 경계를 다시 확인하고, 진행 체크박스 및 검증 결과를 실제 증거에 따라 누적한다. diff --git a/docs/20261105_채널후원메시지숨김/channel_donation_message_is_hidden_ddl.sql b/docs/20261105_채널후원메시지숨김/channel_donation_message_is_hidden_ddl.sql new file mode 100644 index 00000000..05a378a9 --- /dev/null +++ b/docs/20261105_채널후원메시지숨김/channel_donation_message_is_hidden_ddl.sql @@ -0,0 +1,2 @@ +ALTER TABLE channel_donation_message + ADD COLUMN is_hidden TINYINT(1) NOT NULL DEFAULT 0 COMMENT '채널 후원 표시 숨김 여부'; diff --git a/docs/20261105_채널후원메시지숨김/plan-task.md b/docs/20261105_채널후원메시지숨김/plan-task.md new file mode 100644 index 00000000..24794e0e --- /dev/null +++ b/docs/20261105_채널후원메시지숨김/plan-task.md @@ -0,0 +1,577 @@ +# 채널 후원 메시지 숨김 구현 계획 + +| 문서 항목 | 내용 | +|---|---| +| 상태 | 서버 구현·기능 범위 검증·최종 리뷰 완료 | +| 작성일 | 2026-11-05 | +| 요구사항 기준 | [prd.md](prd.md) | +| API 기준 | [prd.md §8](prd.md#8-제안-api-경계-author-technical-draft) | +| 현재 Phase | 전체 Phase 완료 | +| 현재 활성 Goal | 없음 | +| 다음 Goal | 없음. 운영 DDL/배포·프론트엔드는 별도 작업 | + +## 목표와 실행 경계 + +**목표:** 소유 CREATOR가 채널 후원 항목을 soft-hide하면 모든 사용자에게 V2 채널 홈과 후원 탭에서 보이지 않고, 원본 후원과 재무 영향은 보존된다. + +사용자의 "문서대로 진행" 요청으로 PRD `DEC-006`의 기술안과 아래 계획 실행을 승인했다. 과거 기록의 초안/미착수 표시는 해당 기록 시점의 상태이며 현재 상태는 상단 표를 따른다. 동시에 하나의 미완료 Goal만 실행하며, Phase 중간 결과를 개별 배포하지 않는다. 배포 시 DDL을 선반영하고 숨김 API와 두 조회 필터를 함께 배포한다. 서버 검증과 프론트엔드 연동 완료는 구분하며, 운영 DDL 실행·배포는 별도 승인 대상이다. + +## 현재 상태 + +| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 | +|---:|---|---:|---|---| +| 1 | 완료 | `2/2` | 없음 | Gate 및 독립 리뷰 통과 | +| 2 | 완료 | `2/2` | 없음 | Gate 및 독립 리뷰 통과 | +| 3 | 완료 | `1/1` 및 `P3-R1` | 없음 | 최종 리뷰 승인, 운영 적용 별도 | + +## 범위 + +### 포함 + +- `ChannelDonationMessage.isHidden` soft-hide 필드 및 운영 반영용 DDL 파일 추가 Task. +- 소유 CREATOR 한 명의 단일 donation 숨김 use case와 bodyless DELETE API. +- 홈과 탭 목록의 local hidden filter, 탭 count/pagination 및 홈 최대 8건 refill. +- 두 V2 응답에 owner-only `donationId` 추가 및 회귀 assertion 조정. +- HTTP/repository 통합 테스트로 권한, 데이터 보존, 기존 랭킹/재무 효과 및 legacy API 불변 검증. + +### 제외 + +- 운영 DB DDL 실행, 배포 및 공유 인프라 변경. 코드·테스트·DDL 문서 작성은 사용자 승인으로 포함한다. +- 복원, hard delete, audit metadata/reason/history, bulk hide, 실시간 push. +- legacy/financial/ranking 전역 query 필터, Hibernate 전역 soft-delete annotation. +- frontend 파일, 화면/UX 디자인. + +## 기술 초안과 경계 + +- 현행 엔티티 `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessage.kt`는 `BaseEntity` ID/시간만 상속한다. 권고 변경은 `isHidden: Boolean = false`와 운영 MySQL DDL `is_hidden TINYINT(1) NOT NULL DEFAULT 0 COMMENT '채널 후원 표시 숨김 여부'`다. 구현 전 DDL 위치/운영 schema 적용 절차를 확인한다. +- donation 탭 list/count query의 현재 predicate는 creator 및 KST 월 범위다. `isHidden=false`를 두 query 모두에 적용한다. 홈 query도 local `isHidden=false` 필터를 적용하고 기존 8건 한도를 숨김 제외 후 적용한다. +- 양 query에 공유 도메인 전역 filter를 만들지 않는다. 숨김 predicate는 공개 후원 탭 및 홈 전용이다. 랭킹 port/adapter, 정산, can usage와 `explorer.profile.channelDonation` legacy query에 추가하지 않는다. +- visible 행은 기존 `isSecret` 값과 masking을 보존한다. hidden은 공개/비밀 모두 제외한다. +- owner-only `donationId` nullable/omitted JSON 정책을 검증한다. donor `memberId`는 응답하지 않는다. 기존 E2E에서 전 역할 donation ID 부재를 단정한 부분은 소유자 케이스만 ID 기대값으로 바꾸고 비소유 케이스의 부재 assertion을 유지한다. +- API 초안은 `DELETE /api/v2/creator-channels/{creatorId}/donations/{donationId}`, body 없음, `ApiResponse.ok` data 없음이다. 인증/도메인 거부는 기존 `SodaException`, i18n, 공통 handler를 재사용하며 HTTP 200 `success=false` 관례와 충돌하는 generic REST 403/404는 만들지 않는다. +- `actor.role == CREATOR`, `actor.id == creatorId`, donation ID와 creator ID의 동일 소속을 모두 검증한다. 존재하지 않음 또는 다른 채널의 대상도 변화 없이 거부하고 존재 여부 정보 노출은 기존 domain 오류 패턴으로 제한한다. +- 숨김 재요청은 이미 `isHidden=true`이면 성공한다. 원본 저장값을 덮어쓰지 않고 can/refund/ranking/settlement/usage 경계를 호출하지 않는다. + +## 검증 명령 정책 + +- 미래 구현에서는 각 Task 아래 명시된 실제 테스트 클래스만 `--tests`로 지정한다. 아래 RED/GREEN 명령은 향후 실행 명령이며 현재 실행하지 않는다. +- Phase Gate도 관련 donation/home/legacy HTTP 및 repository 테스트 클래스만 `--tests`로 지정한다. 무필터 테스트를 실행하지 않는다. +- 프로젝트 전체 테스트는 사용자가 직접 `./gradlew test`로 실행한다. `check`, `build`, `clean build`도 자동 실행하지 않는다. 미래 빌드/포맷 확인은 필요한 경우 `./gradlew bootJar`, `./gradlew ktlintCheck`만 사용한다. +- 미래 구현 전용 명령 예시는 실행 사실이나 성공 기록이 아니다. 테스트 결과, SQL 운영 적용, H2 통합 근거를 실행 후 실제로 기록한다. + +## Review Focus + +- 소유자가 아닌 CREATOR, donor, USER, ADMIN 및 anonymous 요청의 권한/상태 변경 없음: Task 1.2 HTTP 통합 검증. +- 경로의 creator와 실제 donation 소속 불일치 또는 ID 미존재: Task 1.2의 forged ID HTTP 검증. +- hidden row가 count, offset/page 결과에 남거나 페이지를 덜 채우지 않는지: Task 2.1 repository/HTTP 검증. +- home hidden 후보가 최신 8건을 줄이지 않고 visible 최대 8건으로 refill하는지: Task 2.2 home repository/HTTP 검증. +- 기록·랭킹·정산·can 사용 및 legacy 출력 불변: `P3-GATE`. owner-only ID와 donor ID 비노출: Task 2.1/2.2 통합 검증. + +### Phase 1: 저장과 소유자 전용 숨김 API + +**Phase 결과:** 소유 CREATOR가 한 donation을 기록 보존형으로 숨기며, 권한 실패/중복 요청도 안전하다. + +**선행조건:** 별도 명시 구현 요청. PRD 제품 결정을 유지하고 §7~§8 기술 초안의 실제 패턴과 DDL 반영 절차를 재확인한다. + +**Phase 완료 조건:** `P1-T1`, `P1-T2`, `P1-GATE` 완료 증거 기록. + +#### Task 1.1: 숨김 저장 모델과 persistence port + +- [x] **Task 1.1 완료:** 아래 TDD 단계와 완료 증거를 충족한다. + +**Goal 실행 `P1-T1`:** donation record에 기본 비숨김 상태를 저장하고 포트가 donation을 채널 범위로 숨길 수 있게 한다. + +- **시작 조건:** 구현 요청 및 `ChannelDonationMessage.kt`, `BaseEntity.kt`, schema/DDL 관례 재확인. +- **완료 증거:** entity 매핑, MySQL DDL 파일, repository integration에서 기본 false/true 저장, 동일 채널 한정 및 원본 보존이 확인됨. +- **범위 밖:** API wiring, 조회 filter 및 금융 동작 변경. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessage.kt` +- Create: `docs/20261105_채널후원메시지숨김/channel_donation_message_is_hidden_ddl.sql` (작성 완료, 운영 미적용) +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/HideCreatorChannelDonationPort.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultHideCreatorChannelDonationRepository.kt` +- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultHideCreatorChannelDonationRepositoryTest.kt` + +**Interfaces:** + +- Consumes: existing `ChannelDonationMessage.id`, `creator.id`, mutable JPA entity and project DB mappings. +- Produces: entity `isHidden: Boolean = false`; `HideCreatorChannelDonationPort.hideOwnedDonation(creatorId: Long, donationId: Long): Boolean`, true when matching donation exists and is already/now hidden, false otherwise. Repository update must condition by both IDs and leave other fields untouched. + +- [x] **RED:** `shouldHideOnlyDonationOwnedByCreatorAndRetainOriginalValues`에서 기본 false와 숨김 후 can/message/isSecret/member/creator/id 보존을 검증한다. +- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultHideCreatorChannelDonationRepositoryTest'`를 실행해 새 필드/동작 누락으로 assertion 실패를 확인한다. 컴파일/fixture 오류는 RED 증거가 아니다. +- [x] **GREEN:** boolean field, port, 채널+donation 제한 update 및 DDL을 최소 구현한다. +- [x] **GREEN 확인:** 같은 focused 명령을 실행해 H2 DB 왕복과 다른 channel ID 무변경이 통과하는지 확인한다. +- [x] **REFACTOR:** 이번 Task의 중복만 정리하고 같은 focused test 및 `./gradlew ktlintCheck`를 실행해 결과를 기록한다. + +**P1-T1 실제 실행 기록: 2026-11-05 (상위 리뷰 전, Task 최종 완료 미체크)** + +- 범위: 이 Task의 entity/port/repository/test/DDL과 이 기록만 변경한다. `P1-T2`, 조회 filter, API 및 금융 경계는 시작하지 않는다. +- 테스트 작성 후 컴파일 가능한 최소 bootstrap으로 `isHidden=false` 매핑, port와 항상 false를 반환하는 repository stub을 추가했다. 숨김 동작 구현 전 assertion RED를 확인했다. +- RED 명령: Java 17(`liberica-17.0.15+10`)에서 `./gradlew --no-daemon -Pkotlin.compiler.execution.strategy=in-process test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultHideCreatorChannelDonationRepositoryTest'`. 최초 120초 실행은 `compileKotlin` 도중 도구 timeout으로 종료되어 RED 근거로 사용하지 않았다. 동일 명령을 600초 제한으로 재실행해 exit 1, 6건 중 assertion 실패 3건, errors/skip 0을 확인했다. +- RED 실패: `shouldHideOnlyDonationOwnedByCreatorAndRetainOriginalValues(false/true)`의 52행과 `shouldSucceedWhenHidingAgainWithoutChangingOriginalValues`의 108행에서 `expected: but was: `. 컴파일 및 H2 fixture 생성은 성공했다. +- MySQL 멱등성 근거: Connector/J의 [`useAffectedRows` 설명](https://github.com/mysql/mysql-connector-j/blob/release/8.x/src/main/resources/com/mysql/cj/LocalizedErrorMessages.properties)은 `CLIENT_FOUND_ROWS`에 따라 matched/affected count가 달라짐을 명시한다. update는 두 ID 및 `isHidden=false`로 제한하고 true만 설정한다. 따라서 최초 변경은 양 설정 모두 1건이고 반복 숨김은 양 설정 모두 0건이며, 같은 두 ID의 존재 확인으로 true를 반환한다. H2에서도 반복 요청이 0건 update 경로를 통과하므로 H2의 무변경 update count를 MySQL 근거로 사용하지 않는다. +- 트랜잭션: repository의 쓰기 메서드에 `@Transactional`을 두어 독립 포트 호출도 update/존재 조회를 한 트랜잭션에서 수행한다. 후속 service의 트랜잭션이 있으면 참여하며 service/API 구현은 이 Task에 추가하지 않는다. +- 임시 경로: `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/channel-hide.aeTJdH/p1-t1.SxYFSF`. 각 shell 실행에 이 경로의 `TMPDIR`, `NODE_COMPILE_CACHE`, `BUN_RUNTIME_TRANSPILER_CACHE_PATH`를 지정한다. 운영 SQL은 실행하지 않는다. +- GREEN 명령: 위 RED와 동일한 focused 명령을 구현 후 실행해 exit 0, 신규 6건 통과(failures/errors/skip 0)를 확인했다. `compileKotlin`, `compileJava`, `compileTestKotlin`도 성공했다. 실제 DB는 캐시를 끈 `@DataJpaTest`의 H2이며 `QueryDslConfig`와 실제 repository bean을 import했다. mock은 사용하지 않았다. +- DB 동작 증거: 신규 기본 false, 공개/비밀 각각 선택한 행만 true, 같은 채널의 다른 행 및 다른 채널 행은 false, wrong creator/missing ID는 false 반환, 반복 숨김 true 반환을 확인했다. 매번 flush/clear/find로 DB에서 다시 읽어 `id/can/additionalMessage/isSecret/member/creator/createdAt`을 고정 기대값과 정확히 비교했다. 선택 항목 테스트에서 3행, 나머지 거부/반복 테스트에서 1행이 유지됐다. 생성 시각은 기존 DDL의 초 정밀도에 맞는 고정값 `2026-11-05T00:00:00`을 사용했고 비교값 절삭이나 허용오차는 사용하지 않았다. +- SQL 관찰: 테스트 XML의 Hibernate 출력에서 hide는 `UPDATE channel_donation_message SET is_hidden=? WHERE id=? AND creator_id=? AND is_hidden=?`만 실행하고, 반복 숨김과 거부는 같은 두 ID로 `SELECT 1`을 수행했다. hide SQL에 원본 필드 update, DELETE 또는 전역 filter는 없었다. fixture 준비의 createdAt update와 hide update를 구분했다. +- 회귀/포맷 명령: `./gradlew --no-daemon -Pkotlin.compiler.execution.strategy=in-process test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultHideCreatorChannelDonationRepositoryTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultCreatorChannelDonationQueryRepositoryTest' ktlintCheck` 실행, exit 0. XML 기준 신규 6건 + 기존 query 14건 = 20건, failures/errors/skip 모두 0. `ktlintMainSourceSetCheck`, `ktlintTestSourceSetCheck`, Kotlin script 검사 및 `ktlintCheck` 성공. 추가 리팩터링은 필요하지 않아 범위를 확장하지 않았다. +- 검증 한계: MySQL 서버에서 DDL 및 repository를 실행하지 않았고 운영 SQL은 미적용이다. MySQL matched/affected 차이는 위 Connector/J 근거와 조건부 update로 처리했다. `lsp_diagnostics` 도구가 없어 컴파일/ktlint로 대체했으며 LSP 성공을 주장하지 않는다. 기존 무관 코드의 `!!`/unchecked-cast/unused-parameter 컴파일 경고와 Gradle 9 비호환 deprecation 안내는 수정하지 않았다. +- 중단 상태: P1-T1 실행 substep만 체크하고 Task 1.1 최종 완료는 상위 리뷰 대기로 남긴다. API/조회 filter는 미구현이므로 실제 화면 숨김 완료로 보고하지 않는다. 전체 테스트·`check`/`build`/`clean build`·`bootJar`·리뷰어·커밋·stage·push는 실행하지 않았다. 전체 테스트는 사용자가 `./gradlew test`로 별도 실행한다. +- 변경 범위/공백: read-only git status/diff에서 P1-T1 지정 파일과 이 계획의 실행 기록만 변경했고, 원래 untracked인 PRD는 수정하지 않았다. tracked diff 및 신규 5개 파일(entity 외 port/repository/test/DDL/계획)의 `git diff --no-index --check /dev/null `를 검사해 모두 exit 0·출력 없음이었다. 프로세스 종료와 임시 루트의 열린 파일 없음, 빈 디렉터리임을 확인했으며 기존 상위 Gradle daemon은 종료하지 않는다. + +#### Task 1.2: use case와 API 권한 경계 + +선행 검증: P1-T1 독립 리뷰에서 명세/품질 PASS. 상위에서 신규 repository 테스트를 재실행해 6건, failures/errors/skip 0, BUILD SUCCESSFUL을 확인했다. 저장 단계만 완료이며 조회 숨김은 아직 미구현이다. + +- [x] **Task 1.2 완료:** 아래 TDD 단계와 완료 증거를 충족한다. + +**Goal 실행 `P1-T2`:** 소유 CREATOR 전용 bodyless DELETE endpoint가 대상 항목을 멱등 숨김 처리하고 도메인 오류를 기존 envelope로 반환한다. + +- **시작 조건:** `P1-T1` 완료. 현재 `CreatorChannelDonationController`, 기존 authentication/member role 및 `SodaExceptionHandler`의 실제 오류 패턴을 재확인한다. +- **완료 증거:** 서비스/HTTP 테스트가 성공, role 및 owner/channel mismatch 거부, hidden duplicate 성공, response data 없음, 금융 효과 불변을 확인함. +- **범위 밖:** 조회 filter, DTO ID 및 frontend 연동. + +**Files:** + +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/HideCreatorChannelDonationService.kt` +- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/HideCreatorChannelDonationServiceTest.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacade.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationEndToEndTest.kt` +- Fixture: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacadeTest.kt` (생성자 주입 인자 변경만 반영) + +**Interfaces:** + +- Consumes: `HideCreatorChannelDonationPort.hideOwnedDonation(creatorId, donationId)` from `P1-T1`; authenticated `Member`; existing `ApiResponse`/`SodaException` handler. +- Produces: `HideCreatorChannelDonationService.hide(creatorId: Long, donationId: Long, actor: Member): Unit`; `DELETE /api/v2/creator-channels/{creatorId}/donations/{donationId}` bodyless, success `ApiResponse.ok` with no data. + +- [x] **RED:** `shouldAllowOnlyOwningCreatorToHideDonationThroughHttp`와 역할별 denial, forged creator/donation ID, not-found, repeated hide 통합 cases를 작성한다. 승인된 역할만 성공하며 실패는 모든 원본/금융 fixture를 유지해야 한다. +- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest'`를 실행해 endpoint 미구현 또는 정책 assertion 실패를 확인한다. +- [x] **GREEN:** service/facade/controller를 구현하고 기존 error key/handler로 role, owner, 같은 channel 소속을 검증한다. 같은 소유자의 중복 요청은 성공 처리하되 금융 변경은 하지 않는다. +- [x] **GREEN 확인:** 같은 focused 명령으로 소유 성공, donor/other creator/user/admin 거부, forged ID 거부, 중복 성공, HTTP-200 domain error envelope와 금융/원본 불변을 확인한다. +- [x] **REFACTOR:** 이번 Task의 중복만 정리하고 focused controller/E2E 테스트 및 `./gradlew ktlintCheck` 결과를 기록한다. + +**P1-T2 실제 실행 기록: 2026-11-05 (상위 리뷰 대기, Task 최종 완료 미체크)** + +- 승인 범위: `P1-T2` 및 `P1-GATE`까지만 실행한다. `P1-T1` entity/port/repository/DDL은 보존하며 P2 조회 filter/응답 ID는 시작하지 않는다. 기존 Facade 직접 생성 fixture도 주입 변경에 맞춘다. +- 경계 판단: 인증은 기존 `SecurityConfig`/`TokenProvider`/`requireMember`를 그대로 사용한다. 외부 입력인 두 경로 ID는 service의 CREATOR role/actor ID 확인 후 기존 채널 범위 port에 전달한다. role/owner 거부는 `common.error.access_denied`, 대상 소속 불일치/미존재는 같은 `common.error.invalid_request`로 처리해 존재 여부를 구별하지 않는다. 기존 read-only Facade의 hide 진입점은 명시적인 쓰기 트랜잭션으로 재정의한다. +- 임시 루트: 부모 경로를 `ls -ld`로 확인하고 `mktemp -d`로 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/channel-hide.aeTJdH/p1-t2.jkumZp`를 만들었다. shell 실행마다 해당 루트의 `TMPDIR`, `NODE_COMPILE_CACHE`, `BUN_RUNTIME_TRANSPILER_CACHE_PATH`를 지정한다. Java는 `17.0.15`다. +- RED 명령: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest'`, 도구 timeout 600000ms. 정상 컴파일 후 exit 1, 30건 중 failures 8, errors/skip 0, 3분 11초. 신규 인증 DELETE 8건은 endpoint 미구현으로 183행에서 `expected: <200 OK> but was: <404 NOT_FOUND>` assertion 실패했다. 기존 21건 및 신규 anonymous 401 검증 1건은 통과했다. +- RED 실제 HTTP: `RANDOM_PORT`의 Tomcat `50634`에서 실제 JWT와 `TestRestTemplate`로 body 없는 DELETE를 전송했다. 예: `/api/v2/creator-channels/90/donations/104` 소유 CREATOR는 `404` 및 `status:404,error:Not Found`, `/api/v2/creator-channels/131/donations/162` anonymous는 `401` 및 `status:401,error:Unauthorized`를 반환했다. JWT는 출력하지 않았다. `@DirtiesContext`의 Tomcat 종료 로그를 확인했다. +- 구현 후 첫 동일 E2E 실행은 exit 1, 30건 중 failures 8, errors/skip 0, 4분 35초였다. 실제 owner 응답은 이미 `200 {"success":true,"message":null,"data":null,"errorProperty":null}`이고 domain 거부도 `200 success=false`였지만 신규 테스트가 data 키 자체의 생략을 잘못 가정했다. `ApiResponse.kt`의 nullable 필드와 기존 `ApiResponse.ok()` 사용을 확인해 공통 envelope를 변경하지 않고 신규 assertion을 `data`의 정확한 JSON null 검증으로 정정했다. 이는 payload가 없다는 계약이며 `{}`/Unit 객체는 허용하지 않는다. 기존 테스트는 완화하지 않았다. +- GREEN 및 포맷 1차 명령: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.application.HideCreatorChannelDonationServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationControllerTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacadeTest' ktlintCheck`. XML 기준 E2E 30 + 실제 DB service 9 + controller 4 + facade fixture 회귀 2 = 45건, failures/errors/skip 모두 0. 명령 전체는 신규 service test의 `listOf` 인자 줄바꿈 ktlint 위반 5건으로 exit 1(1분 21초)이며, 해당 신규 코드 포맷만 수정하고 Gate에서 다시 확인한다. +- 최종 GREEN: 아래 P1-GATE 명령에서 E2E 30건, service 9건, controller 4건, facade fixture 2건이 다시 통과했다. GET 동작/응답 ID 부재를 검증하던 기존 테스트 21건은 그대로 유지했다. `HideCreatorChannelDonationServiceTest`는 실제 Spring bean/기존 repository/H2를 사용하고 외부 mock 없이 검증한다. USER/ADMIN은 자기 ID와 경로가 같아도 거부하며, 비소유 USER/ADMIN/CREATOR는 대상 미존재 여부보다 먼저 access_denied를 반환한다. +- 실제 socket HTTP 최종 증거: 기존 E2E의 `RANDOM_PORT` Tomcat `50765`, 실제 `TokenProvider` 발급 JWT, 기존 운영 인증 체인을 사용했다. body 없는 DELETE 총 13요청을 실행했고 JWT는 로그에 출력하지 않았다. 공개 `/api/v2/creator-channels/90/donations/104`, 비밀 `/api/v2/creator-channels/95/donations/109` 및 중복 `/api/v2/creator-channels/1/donations/2` 2회는 `200 {"success":true,"message":null,"data":null,"errorProperty":null}`였다. donor/outsider/other CREATOR/ADMIN 및 경로 소유권 위조는 `200 success=false,message=권한이 없습니다.,data=null`; 다른 채널 `/api/v2/creator-channels/106/donations/145`와 미존재 `/api/v2/creator-channels/106/donations/9223372036854775807`는 동일한 `200 success=false,message=잘못된 요청입니다.,data=null`이었다. 이미 숨긴 항목의 donor 요청도 거부됐다. anonymous `/api/v2/creator-channels/131/donations/162`는 실제 인증 체인에서 `401` 및 `status:401,error:Unauthorized`였다. +- DB 보존 증거: 각 HTTP fixture는 해당 채널 3행과 다른 채널 1행, 실제 `UseCan` 1행(`can=450,rewardCan=50,isRefund=false`) 및 `UseCanCalculate` 1행(`can=500,status=RECEIVED`)을 생성해 commit했다. JDBC로 전체 컬럼을 요청 전후 다시 읽어 donation의 `is_hidden`만 제외한 원본/연결/시각과 재무 두 테이블의 모든 컬럼·행 수가 정확히 동일함을 확인했다. 별도 transaction/clear/find로 선택 ID만 hidden=true이고 나머지는 false인 것도 확인했다. 거부는 전 행 false, 중복은 첫 요청 이후 같은 상태였다. 고정 donation 생성 시각은 `2026-11-05T00:00:00`이며 비교값 절삭/허용오차를 사용하지 않았다. +- 정리 영수증: 각 socket 테스트의 발급 token은 해당 member ID만 `@AfterEach`에서 제거했다. `@DirtiesContext`로 `50765` Tomcat 및 E2E/service Hikari 종료 로그를 확인했다. 이번 실행들의 Test worker PID `73880,75780,76195,76844`를 `ps`로 조회해 잔존 프로세스가 없었고 `lsof +D`는 열린 파일 없이 종료했다. 임시 루트는 0 entries였으며 symlink 해석 후 부모 allowlist, 현재 UID 소유권과 빈 상태를 확인해 `rmdir`로 `p1-t2.jkumZp`만 삭제했다. shared Gradle/Kotlin daemon은 종료하지 않았다. +- 한계/중단: LSP 도구가 없어 실제 Kotlin/Java 컴파일 및 ktlint로 대체했다. MySQL/운영 DDL 적용, 전체 테스트, `check`/`build`/`clean build`/`bootJar`, dependency audit, reviewer/subagent, commit/stage/push는 수행하지 않았다. 전체 테스트는 사용자 실행 명령 `./gradlew test`다. P2 filter/ID는 미구현이므로 두 V2 목록은 아직 hidden 항목을 제외하지 않는다. P3의 실제 legacy/ranking/정산 조회 불변 최종 Gate는 수행하지 않았으며 이번 증거는 원본·재무 record 보존까지다. Task 1.2와 Phase 최종 완료 판정은 상위 리뷰에 남기고 여기서 중단한다. + +#### Phase 1 Gate + +**Goal 실행 `P1-GATE`:** 숨김 저장 및 owner-only API 권한/불변 조건을 확인한다. + +- **시작 조건:** `P1-T1`, `P1-T2` 완료. +- **완료 증거:** 두 focused 테스트 통과, entity/repository 데이터 보존 및 E2E 권한/멱등성 증거 기록. +- **범위 밖:** 전체 suite, 조회 필터 및 legacy query 수정. + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultHideCreatorChannelDonationRepositoryTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.application.HideCreatorChannelDonationServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationControllerTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest' +./gradlew ktlintCheck +``` + +**Expected:** 구현 후 각 명령 exit 0, 실패/skip 0. DB 원본 및 can/정산 관련 fixture 불변. 현재 실행하지 않음. + +**P1-GATE 실제 실행 기록: 2026-11-05 (실행 통과, 최종 완료는 상위 리뷰 대기)** + +- [x] 위 Gate의 4개 클래스와 주입 변경 직접 영향 범위인 `CreatorChannelDonationFacadeTest`를 함께 focused 실행했다. +- [x] 같은 invocation에서 `ktlintCheck`를 실행해 main/test/Kotlin script 검사 성공을 확인했다. +- [x] Phase 1 최종 완료 판정: 독립 리뷰 명세/품질 PASS. 상위에서 실제 HTTP를 포함한 E2E 30건을 재실행해 failures/errors/skip 0, BUILD SUCCESSFUL(26초)을 확인했다. 로그에서 Tomcat·DB 풀 종료도 확인했다. + +실제 명령: + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultHideCreatorChannelDonationRepositoryTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.application.HideCreatorChannelDonationServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationControllerTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacadeTest' ktlintCheck +``` + +- 결과: exit 0, `BUILD SUCCESSFUL in 1m 9s`. XML 기준 repository 6 + service 9 + controller 4 + E2E 30 = Gate 49건, 추가 facade 2건으로 총 51건이며 failures/errors/skipped는 전부 0이다. 앞선 실행에서 변경된 production Kotlin/Java와 test Kotlin 컴파일이 성공했고 최종 실행의 test Kotlin 컴파일도 성공했다. 기존 무관 컴파일 경고와 Gradle deprecation 안내는 범위 밖으로 유지한다. +- 증거 경로: `build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest.xml`의 HTTP EVIDENCE 13행과 각 클래스 XML의 testsuite 집계를 직접 확인했다. 실제 HTTP/DB/정리 내용은 바로 위 P1-T2 기록에 누적했다. 전체 테스트 통과나 화면 숨김 완료를 뜻하지 않는다. + +### Phase 2: V2 조회 필터, count 및 owner-only ID + +**Phase 결과:** 홈/탭은 hidden item을 모든 사용자에게 제외하고 count/page/refill 및 ID 공개 범위를 올바르게 유지한다. + +**선행조건:** `P1-GATE` 완료. `P1-T2` 성공 이후 API 조회 필드 노출과 기존 JSON assertion을 갱신한다. + +**Phase 완료 조건:** `P2-T1`, `P2-T2`, `P2-GATE` 완료 증거 기록. + +#### Task 2.1: donation 탭 filter, count, pagination, ID + +- [x] **Task 2.1 완료:** 아래 TDD 단계와 완료 증거를 충족한다. + +**Goal 실행 `P2-T1`:** donation 탭은 hidden 제외 count/list를 동일 조건으로 제공하고 owner에게만 donationId를 제공한다. + +- **시작 조건:** Phase 1 Gate 완료. 현재 query/service/domain/response 및 기존 E2E ID 부재 assertion 확인. +- **완료 증거:** repository와 HTTP 통합에서 역할별 hidden 제외, count/page/hasNext, visible 공개/비밀 masking 유지, owner ID 및 non-owner 부재가 검증됨. +- **범위 밖:** 홈 목록 filter 및 legacy/ranking query 변경. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationTab.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/dto/CreatorChannelDonationTabResponse.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepositoryTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryServiceTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationEndToEndTest.kt` +- Fixture/contract: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicyTest.kt` +- Fixture/contract: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacadeTest.kt` +- Fixture/contract: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt` + +**Interfaces:** + +- Consumes: `ChannelDonationMessage.isHidden`; actor/owner context available to V2 response assembly. Existing `countChannelDonations` and `findChannelDonations` page API. +- Produces: donation tab item response `donationId: Long?` serialized only for owning CREATOR; count/list share hidden predicate before pagination. No donor member ID. + +- [x] **RED:** `shouldExcludeHiddenRowsFromDonationTabCountAndPagesForEveryViewer` 및 `shouldExposeDonationIdOnlyToOwningCreator`를 추가한다. mixed 25 rows, hidden within first page, all-hidden empty, and owner/non-owner JSON boundary cases를 포함한다. +- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultCreatorChannelDonationQueryRepositoryTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest'`를 실행해 hidden filter 및 owner ID 부재/노출 assertion 실패를 확인한다. +- [x] **GREEN:** count와 목록에 동일 local predicate를 적용하고 pagination 전에 제외한다. response mapping에서 소유 CREATOR에만 persisted ID를 제공한다. +- [x] **GREEN 확인:** 같은 focused 명령을 실행해 viewer 전원 hidden 제외, `donationCount`, page/hasNext, 공개/비밀 masking 및 ID JSON을 검증한다. +- [x] **REFACTOR:** 현재 ID 없음만 가정하던 owner assertion은 owner-only ID 기대값으로 수정하고 non-owner 부재 assertion은 보존한다. focused donation query/service/E2E 및 `./gradlew ktlintCheck` 결과 기록. + +**P2-T1 실행 증거: 2026-11-05 (최종 Task 판정은 상위 리뷰 대기)** + +- RED: 위 두 클래스 명령, 정상 컴파일 후 48건 중 assertion 실패 7건, errors/skip 0, `BUILD FAILED in 36s`. repository는 hidden 혼합 count `22 != 25`, all-hidden `0 != 2`; 실제 socket GET은 count `22 != 28`, owner ID `[3,2,1] != [null,null,null]`, 기존 언어별 owner JSON은 `donationId` 누락으로 실패했다. 최초 fixture의 숨김 플래그 변경이 managed createdAt을 덮어쓰는 문제를 바로잡아 persist 전에 플래그를 설정하고 RED를 재확인했다. 중간 생성자 컴파일 오류는 RED 근거에서 제외했다. +- GREEN: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultCreatorChannelDonationQueryRepositoryTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryServiceTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicyTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.*' ktlintCheck`, exit 0, `BUILD SUCCESSFUL in 1m`. repository 16 + service 20 + policy 5 + E2E 32 + controller 4 + facade 2 = 79건, failures/errors/skip 0; Kotlin/Java/test 컴파일 및 ktlint 통과. +- 검증: 공개·비밀 25행 중 최신 3행 숨김 후 22행을 20+2로 조회, HTTP 두 건 DELETE 후 정확히 20행/hasNext=false, 전부 숨김 후 count=0/empty/false를 owner/donor/outsider/other CREATOR/ADMIN 모두 확인했다. record ID는 실제 저장 행의 ID와 비교하며 서비스는 같은 ID의 USER/ADMIN에도 ID를 주지 않는다. 기존 월 경계·정렬·다국어 masking assertion과 non-owner 필드 부재를 유지했다. +- 기존 RANDOM_PORT/TestRestTemplate/JWT helper를 GET에도 재사용했다. 토큰을 출력하지 않고 기존 token 삭제와 DirtiesContext 종료를 유지했다. 홈/P3/legacy/재무 query와 P1 production은 수정하지 않았다. LSP 도구가 없어 컴파일/ktlint로 검증한다. + +#### Task 2.2: 홈 filter, refill, owner-only ID + +- [x] **Task 2.2 완료:** 아래 TDD 단계와 완료 증거를 충족한다. + +**Goal 실행 `P2-T2`:** 채널 홈은 hidden donation을 제외한 최신 visible 최대 8건을 제공하고 소유 CREATOR에게만 donationId를 제공한다. + +- **시작 조건:** `P2-T1` 완료. 홈 query/service/domain/response와 현재 KST 월/최대 8개 테스트 확인. +- **완료 증거:** repository 및 HTTP 통합으로 hidden 전체 제외, hidden 섞인 최신 8개 refill, owner ID 범위 및 visible `isSecret` 표시 유지. +- **범위 밖:** donation tab count, home 새 count 필드, legacy API 변경. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHome.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt` +- Fixture/contract: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt` +- Fixture/contract: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt` +- Fixture/contract: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt` + +**Interfaces:** + +- Consumes: `ChannelDonationMessage.isHidden`; existing home `findChannelDonations(creatorId, viewerId, now, limit, lang)` and owner context in response assembly. +- Produces: `channelDonations[]` hidden-excluded, maximum 8 visible rows after filter; `donationId: Long?` only for owning CREATOR, no donor member ID. + +- [x] **RED:** `shouldRefillHomeWithLatestEightVisibleDonationsAfterHiddenRowsAreExcluded` 및 `shouldExposeHomeDonationIdOnlyToOwningCreator`를 작성한다. hidden newest candidates, tied timestamps, public/secret visible rows, all-hidden empty를 포함한다. +- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest'`로 숨김 누락 또는 owner ID assertion 실패를 확인한다. +- [x] **GREEN:** home query의 channel/month 조건과 함께 `isHidden=false`를 적용하고 기존 정렬 뒤 최대 8개를 조회한다. 소유자 응답에만 ID를 전달한다. +- [x] **GREEN 확인:** 같은 focused 명령으로 모든 viewer의 hidden 부재, visible 최대 8개 refill, 기존 월 경계/정렬/secret masking 및 ID 응답 범위를 검증한다. +- [x] **REFACTOR:** owner-only ID 회귀를 유지하며 현재 donation ID 부재만 강제하던 assertion을 owner/non-owner 구분 검증으로 갱신한다. focused home tests 및 `./gradlew ktlintCheck` 결과 기록. + +**P2-T2 실행 증거: 2026-11-05 (최종 Task 판정은 상위 리뷰 대기)** + +- RED: 위 두 클래스에 실제 socket home GET을 추가한 `CreatorChannelDonationEndToEndTest`까지 focused 실행. 정상 컴파일 후 93건 중 assertion 실패 8건, errors/skip 0, `BUILD FAILED in 51s`. repository는 visible 기대 `[9,8,7,6,5,4,3,2]` 대신 hidden 포함 `[12,11,10,9,8,7,6,5]`, all-hidden empty 실패; socket home은 hidden 원본 포함 `[300,200,100,12,11,10,9,8]`, owner ID `[179,178,177]` 대신 null 세 건. 기존 다국어 owner field-set assertion 3건도 ID 누락으로 실패했다. +- GREEN: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest' ktlintCheck`, exit 0, `BUILD SUCCESSFUL in 1m 31s`. home repository 40 + service 26 + policy 5 + E2E 18 + controller 3 + facade 2 + socket donation E2E 35 = 129건, failures/errors/skip 0; 컴파일 및 ktlint 통과. +- 검증: 최신 숨김 공개·비밀 후보 3건을 제외하고 동시각 ID 내림차순 visible 8건을 모든 5역할에 보충했다. owner가 홈에서 얻은 실제 ID로 공개/비밀 각각 DELETE한 뒤 홈 8건 refill 및 탭 count=8/hasNext=false, 두 JSON item 배열의 완전 일치를 검증했다. 전부 숨김 후 모든 역할의 홈은 빈 배열이다. 3행 별도 fixture에서도 양 조회면 persisted ID가 정확히 같고 선택한 비밀 행을 숨긴 뒤 모든 역할에서 두 목록이 일치한다. +- 새 월 의존 fixture는 현재 UTC 시각으로 계산한 KST 월 범위를 사용한다. 기존 P1 고정 fixture/원본 snapshot은 유지했다. 홈 `isViewerCreator`의 role-only live 접근 정책은 변경하거나 소유권에 재사용하지 않았다. 양 도메인에 소유자 ID만 전달하며 API property NON_NULL로 non-owner 키 자체를 생략한다. home E2E에도 DirtiesContext 종료를 명시했다. + +#### Phase 2 Gate + +**Goal 실행 `P2-GATE`:** 양 V2 조회 표면에서 숨김, page/count/refill과 owner ID를 통합 확인한다. + +- **시작 조건:** `P2-T1`, `P2-T2` 완료. +- **완료 증거:** 아래 focused test, `ktlintCheck`, `bootJar` 결과. HTTP integration에서 creator/donor/USER/other CREATOR/ADMIN별 숨김 부재 확인. +- **범위 밖:** 전체 test suite 및 frontend UI 구현. + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultCreatorChannelDonationQueryRepositoryTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest' +./gradlew ktlintCheck +./gradlew bootJar +``` + +**Expected:** 구현 후 지정 focused tests, lint, jar 성공. hidden 공개/비밀 item은 어떤 role에도 없고 count/page/home fill 모두 visible 기준이다. donor ID는 추가되지 않는다. 현재 실행하지 않음. + +**P2-GATE 실행 증거: 2026-11-05 (실행 통과·상위 리뷰 대기)** + +- [x] 지정 Gate 4개 클래스 109건 및 직접 영향받는 service/policy/controller/facade 67건, 합계 176건 통과. failures/errors/skipped 모두 0. +- [x] 같은 invocation에서 `ktlintCheck`, `bootJar` 성공, exit 0, `BUILD SUCCESSFUL in 1m 14s`. bootJar는 실제 생성했고 lint는 직전 GREEN 결과와 동일 입력의 UP-TO-DATE 판정이다. 마지막 fixture UTC 조정 후 test Kotlin 재컴파일도 성공했다. +- [x] 기존 socket E2E에서 두 V2 GET → owner ID로 DELETE → 두 GET을 실제 인증 체인으로 확인했다. 모든 역할의 hidden 부재·count/page/refill·non-owner ID 키 생략·donor memberId 부재를 검증했다. +- [x] Phase 2 Task 및 Gate 최종 승인: 독립 리뷰에서 명세/품질 PASS. 상위가 홈·후원 E2E 53건을 재실행해 실패/오류/skip 0과 BUILD SUCCESSFUL(40초), 실제 HTTP 및 종료 로그를 확인했다. + +실제 최종 명령 (Java 17, timeout 600000ms): + +```bash +./gradlew test \ + --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultCreatorChannelDonationQueryRepositoryTest' \ + --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest' \ + --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest' \ + --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest' \ + --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryServiceTest' \ + --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicyTest' \ + --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationControllerTest' \ + --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacadeTest' \ + --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest' \ + --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelHomeQueryPolicyTest' \ + --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest' \ + --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorChannelHomeFacadeTest' \ + ktlintCheck bootJar +``` + +실제 HTTP 응답 증거는 `build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest.xml`의 `HTTP EVIDENCE`에 있다. Tomcat port는 `51039`, worker PID는 `84091`이다. 아래는 실제 응답의 관련 필드만 발췌한 것이며 token/header는 기록하지 않았다. + +| 요청 | HTTP | 실제 응답 관련 필드 | +|---|---:|---| +| owner GET `/api/v2/creator-channels/120/home` 및 `/donations` | 200 | 양 목록 `donationId=[179,178,177]`; 탭 `donationCount=3` | +| owner DELETE `/api/v2/creator-channels/120/donations/178` | 200 | `{"success":true,"message":null,"data":null,"errorProperty":null}` | +| 같은 두 GET 재요청 | 200 | 양 목록 `donationId=[179,177]`, `can=[300,100]`; 탭 `donationCount=2,hasNext=false` | +| owner 홈 GET → 공개 ID 12 DELETE → 두 GET (`creatorId=1`) | 200 | 홈 `can=[9,8,7,6,5,4,3,2]` → `[9,7,6,5,4,3,2,1]`; 두 배열 동일, 탭 count=8 | +| owner 홈 GET → 비밀 ID 29 DELETE → 두 GET (`creatorId=6`) | 200 | 홈 `can=[9,8,7,6,5,4,3,2]` → `[8,7,6,5,4,3,2,1]`; 두 배열 동일, 탭 count=8 | + +- donor/outsider/other CREATOR/ADMIN도 같은 hidden 제외 목록을 받으며 non-owner JSON에는 `donationId` 키가 없었다. 채널 소유자와 같은 ID의 USER/ADMIN은 service matrix로도 ID 부재를 확인했다. 신규 로그와 최종 XML에 JWT 패턴 검색 결과 0건이며, 토큰은 기존 AfterEach에서 발급 member ID별 삭제했다. +- 변경 검토: production diff는 두 query의 local predicate/ID projection, 두 port record/domain/service/DTO뿐이다. P1 저장·숨김 API, 공통 직렬화, 랭킹/정산/legacy query와 의존성·설정은 변경하지 않았다. `git diff --check` 통과. PRD는 수정하지 않았다. +- 정리 영수증: 부모 경로를 먼저 확인하고 생성한 전용 루트 `channel-hide.aeTJdH/p2.1mgEdJ`만 사용했다. Tomcat 51039와 두 E2E Hikari 종료 로그를 확인했고 PID 84091·Gradle Test Executor·redis-server 및 해당 port listener 잔존 없음, `lsof +D` 열린 파일 없음이었다. UID 501 소유권·canonical path·빈 상태를 확인해 자체 로그 5개와 전용 루트만 삭제했다. 부모와 기존 `p1-t1.SxYFSF`, shared Gradle/Kotlin daemon PID 53466/73431은 보존했다. 최종 XML/HTML 테스트 결과는 `build/`에 남겼다. +- 검증 한계: LSP 도구가 없어 Kotlin/Java 컴파일 및 ktlint로 대체했다. 전체 테스트·`check`/`build`/`clean build`, P3 settlement/legacy 회귀, MySQL/운영 DDL 적용, subagent/reviewer, commit/stage/push는 수행하지 않았다. 전체 테스트는 사용자가 `./gradlew test`로 실행한다. 기존 deprecation/unchecked-cast 경고는 범위 밖이다. + +**Phase 2의 정확한 변경 파일 (22개, 기존 P1 변경은 별도 보존):** + +```text +src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt +src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt +src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationTab.kt +src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt +src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/dto/CreatorChannelDonationTabResponse.kt +src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt +src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt +src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHome.kt +src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt +src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt +src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepositoryTest.kt +src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryServiceTest.kt +src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicyTest.kt +src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationEndToEndTest.kt +src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt +src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacadeTest.kt +src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt +src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt +src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt +src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt +src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt +docs/20261105_채널후원메시지숨김/plan-task.md +``` + +### Phase 3: 보존, legacy 및 최종 회귀 증거 + +**Phase 결과:** 숨김이 표시 조정에 한정되고 원본/재무/legacy 동작을 바꾸지 않았다는 증거가 남는다. + +**선행조건:** `P2-GATE` 완료. + +**Phase 완료 조건:** `P3-GATE` 증거 기록. + +#### Task 3.1: 재무 불변과 legacy 경계 Gate + +- [x] **Task 3.1 완료:** 아래 회귀 검증과 완료 증거를 충족했다. 최종 리뷰의 P3-R1 보정 및 재리뷰도 통과했다. + +**Goal 실행 `P3-GATE`:** 실제 persistence/HTTP 증거로 DB 보존, 랭킹/정산/can 사용 불변 및 legacy 목록의 기존 출력 유지를 확인한다. + +- **시작 조건:** Phase 2 완료. 기존 legacy 채널 후원 query/controller 및 랭킹 adapter의 실제 경로를 구현 전 다시 확인한다. +- **완료 증거:** 통합 테스트에서 숨김 전후 원본 행과 금융 record 동일, 랭킹 동일, legacy 결과에서 같은 donation 표시, 문서에 global hide가 아님을 확인. +- **범위 밖:** legacy API를 숨김 처리하거나 재무 query를 변경하는 것. +- **TDD 예외 사유:** Gate 자체는 별도 production behavior를 추가하지 않고 앞선 integration test의 회귀 범위를 판정한다. 필요한 실패 assertion은 소유 Task에 추가 후 재검증한다. +- **대체 검증 방법:** repository/HTTP integration test, DB flush/clear/reload, legacy endpoint response 대조 및 focused lint/build를 기록한다. + +**Files:** + +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationEndToEndTest.kt` +- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationControllerTest.kt` (기존 mock 기반 controller 회귀이며 DB 보존/조회 증거를 대체하지 않음) +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/legacy/LegacyCreatorChannelDonationRankingAdapterTest.kt` + +- [x] 실제 persistence를 사용하는 `CreatorChannelDonationEndToEndTest`에서 숨김 API 성공 후 legacy GET을 호출해 동일 행이 유지되는지 확인한다. mock 응답만으로 판정하지 않는다. +- [x] entity를 DB에서 재조회해 ID/can/message/isSecret/createdAt/member/creator 값이 숨김 전후 동일한지 확인한다. +- [x] `UseCan`, `UseCanCalculate` 및 실제 ranking/정산 조회 결과가 숨김 전후 바뀌지 않는지 확인한다. mock 위임 테스트만으로 불변을 주장하지 않는다. +- [x] 관련 donation/home/legacy 클래스만 `--tests`로 실행하고 `./gradlew ktlintCheck`, `./gradlew bootJar` 결과를 기록한다. + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest' --tests 'kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationControllerTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.legacy.LegacyCreatorChannelDonationRankingAdapterTest' +``` + +**Expected:** 지정 테스트 exit 0, legacy 실제 조회와 재무 원본/집계 결과가 숨김 전후 동일. 현재 실행하지 않음. + +**P3-GATE 실행 증거: 2026-11-05 (실행 통과, Task 최종 승인은 상위 전체 변경 리뷰 대기)** + +- 변경은 위 E2E 파일과 이 기록뿐이며 production/PRD/P1·P2 기존 테스트는 보존했다. `shouldPreserveLegacyRankingAndSettlementAfterHidingDonationThroughHttp(false/true)` 2건을 추가하고 기존 HTTP/JWT·snapshot·hidden 재조회 helper를 재사용했다. Gate의 승인된 TDD 예외에 따라 production을 인위적으로 되돌리지 않았다. +- 금융 기여 연결: 채널별 메시지는 선택할 500캔 한 건뿐이고, 동일 donor/creator·비밀 여부·시각의 `UseCan(can=450,rewardCan=50,isRefund=false,CHANNEL_DONATION)` 및 `UseCanCalculate(can=500,status=RECEIVED)` 한 건을 commit했다. 별도 채널의 400캔 행도 보존한다. DB flush/clear/reload로 금액·연결·상태를 대조했으며 P1의 독립 500캔 fixture를 보존 증거로 재사용하지 않았다. +- 월/격리: 현재 UTC로 구한 KST 월 시작에 2시간을 더한 초 정밀도 입력을 사용했다. 공개 `2026-10-31T17:00:00Z`, 비밀 `2026-10-31T17:00:01Z`는 KST 11월이다. 날짜 설정 후 baseline을 읽었다. 정산은 실제 주입된 `AdminChannelDonationCalculateQueryRepository.getChannelDonationByCreatorTotal` 및 `getChannelDonationByCreator`를 동일 초의 inclusive 범위로 전후 호출해 다른 fixture와 격리했다. 날짜 포맷 함수/alias/mock은 사용하지 않았다. +- 신규 2건 우선 명령: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest.shouldPreserveLegacyRankingAndSettlementAfterHidingDonationThroughHttp' ktlintCheck`. exit 0, `BUILD SUCCESSFUL in 31s`, tests=2, failures/errors/skipped=0. test Kotlin 컴파일과 변경 test source ktlint 검사를 실제 실행했다. + +최종 Gate 명령 (Java 17.0.15, timeout 600000ms, 표준 wrapper, 강제 재실행 없음): + +```bash +./gradlew test \ + --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest' \ + --tests 'kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationControllerTest' \ + --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.legacy.LegacyCreatorChannelDonationRankingAdapterTest' \ + --tests 'kr.co.vividnext.sodalive.admin.calculate.channelDonation.AdminChannelDonationCalculateQueryRepositoryTest' \ + ktlintCheck bootJar +``` + +- 결과: exit 0, `BUILD SUCCESSFUL in 33s`. 지정 Gate는 E2E 37 + legacy controller 2 + ranking adapter 2 = 41건, 직접 호출한 정산 repository 회귀 2건을 더해 총 43건, failures/errors/skipped 모두 0. 기존 E2E 35건도 전부 유지·통과했다. `ktlintCheck`와 `bootJar`는 동일 입력의 UP-TO-DATE 성공이며 기존 `build/libs/sodalive-0.0.1-SNAPSHOT.jar`(141 MiB)를 확인했다. 새 production 재컴파일/새 jar 생성으로 표현하지 않는다. +- 실제 socket 증거: Tomcat `51284`, worker PID `88424`, 실제 JWT 인증 체인의 GET → owner 응답 ID로 bodyless DELETE → GET 순서다. 공개 `creatorId=125,donationId=181`, 비밀 `creatorId=130,donationId=183` 모두 DELETE `200 {"success":true,"message":null,"data":null,"errorProperty":null}`. 양 경우 탭 `donationCount=1→0,donations=[]/hasNext=false`, 홈 `channelDonations` 1건→빈 배열을 확인했다. +- legacy 증거: owner GET `/explorer/profile/channel-donation?creatorId=125&page=0&size=20` 및 creatorId=130의 **전체 JSON envelope**가 전후 정확히 동일했다. `totalCount=1`, 같은 ID 181/183·500캔·공개/비밀 플래그·원문/생성 시각이 남는다. 이 기능은 두 V2 표시 목록만 숨기며 global hide가 아니다. +- 랭킹 증거: 탭 owner 응답 전체 `rankings`가 전후 동일하고 donor 126/131의 `donationCan=500→500`이었다. DELETE 후 해당 fixture의 정확한 Redis ranking key만 제거하고 제거 성공을 확인해, stale cache가 아닌 실제 legacy query 재조회로 검증했다. +- 정산 증거: 공개·비밀 모두 실제 total/count 및 creator별 query 결과 전체와 `toResponseItem()` 전체가 전후 동일했다. 각각 `count=1,totalCan=500,krw=50000,fee=3300,settlementAmount=39695,withholdingTax=1310,depositAmount=38385`. 0건/0원 불변을 통과 근거로 쓰지 않았다. +- DB 증거: JDBC 전체 컬럼 snapshot에서 donation 2행(자기 채널 1+다른 채널 1)은 `is_hidden` 외 모든 값, `UseCan` 1행과 `UseCanCalculate` 1행은 모든 컬럼·행 수가 동일했다. 별도 transaction/clear/find로 선택 ID만 hidden=true임을 확인했다. 행 삭제나 비교 정밀도 완화는 없다. +- 증거 파일: `build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest.xml`의 `HTTP EVIDENCE`, `SETTLEMENT EVIDENCE`, testsuite 집계 및 나머지 3개 클래스 XML. JWT 패턴 검색 결과는 0건이며 토큰은 기존 AfterEach에서 발급 member ID별 삭제했다. +- 정리: 확인한 부모 아래 `mktemp -d`로 생성한 `channel-hide.aeTJdH/p3.OBzvWM`의 TMPDIR/NODE_COMPILE_CACHE/BUN_RUNTIME_TRANSPILER_CACHE_PATH를 모든 shell에 지정했다. 두 실행의 Tomcat `51262/51284` 및 Hikari 종료 로그, worker PID `88133/88424`·Test Executor·redis-server·port listener 잔존 없음, `lsof +D` 열린 파일 없음과 0 entries를 확인했다. canonical 부모 allowlist·현재 UID 소유권·symlink 아님을 검사한 뒤 `rmdir`로 해당 빈 루트만 삭제했다. shared Gradle/Kotlin daemon PID `53466/73431`과 부모/다른 Task 임시 경로는 보존했다. +- 한계/인계: LSP 도구가 없어 test Kotlin 컴파일·ktlint로 대체했다. MySQL/운영 DDL, 전체 테스트·`check`/`build`/`clean build`, subagent/reviewer, commit/stage/push는 미실행이다. 기존 Gradle deprecation 안내는 유지했다. 전체 테스트의 사용자 명령은 `./gradlew test`이며, 상위의 broad scoped donation/home 회귀와 최종 전체 변경 리뷰·Task 승인에 넘긴다. + +## Phase Gate와 전체 테스트 정책 + +### 회귀 수정 `P3-R1`: MySQL 자동 수정 시각 보존 + +- [x] **Task P3-R1 완료:** 숨김 UPDATE가 기존 `updated_at`을 명시적으로 유지함을 회귀 검사하고 최종 재리뷰를 통과했다. +- **목표:** MySQL의 자동 갱신 시각 설정에서도 숨김 상태 외 원본 컬럼을 바꾸지 않는다. +- **시작 조건:** 최종 Oracle 리뷰에서 기존 DDL의 `updated_at ... ON UPDATE CURRENT_TIMESTAMP`와 현재 UPDATE의 시각 자기 대입 누락을 확인했다. +- **Files:** Modify `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultHideCreatorChannelDonationRepository.kt`; Test `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultHideCreatorChannelDonationRepositoryTest.kt`. +- **범위 밖:** 기존 테이블 DDL 수정, 시각 초기화, 공통 시간 변환 변경, snapshot assertion 완화. +- [x] **RED:** 실제 생성 UPDATE SQL에 `updated_at=updated_at` 보존식이 없는 실패를 확인한다. +- [x] **GREEN:** QueryDSL UPDATE에 기존 `updatedAt` 자기 대입만 추가한다. +- [x] **GREEN 확인:** repository 회귀와 실제 HTTP 원본 snapshot 검사 통과, 생성 SQL 보존식 확인. +- [x] **REFACTOR 검증:** 추가 리팩터링 없이 이번 변경 범위의 lint/컴파일 및 `bootJar` 결과를 기록한다. +- [x] **최종 재리뷰:** Oracle가 P3-R1을 ADDRESSED로 판정하고 전체 변경을 승인했다. 새로운 명세·품질·보안 차단 사항은 없다. +- **검증 명령:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultHideCreatorChannelDonationRepositoryTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest' ktlintCheck bootJar`. +- **검증 한계:** H2의 Hibernate 생성 스키마에는 기존 MySQL 자동 시각 설정이 없으므로 기존 green만으로 MySQL 원본 시각 보존을 보장하지 않는다. 실제 MySQL 실행 여부는 결과에 별도로 기록한다. + +**P3-R1 실행 증거: 2026-11-05 (RED/GREEN·검증 완료, 최종 재리뷰 대기)** + +- 기존 DDL `docs/plan-task/20260223_channel_donation_message_ddl.sql`의 `updated_at TIMESTAMP ... ON UPDATE CURRENT_TIMESTAMP`를 확인했다. production 변경은 `.set(channelDonationMessage.updatedAt, channelDonationMessage.updatedAt)` 한 줄뿐이다. 기존 HTTP 전체 컬럼 snapshot·다른 행·멱등성 검사는 변경하지 않았다. +- 신규 `shouldPreserveUpdatedAtWithSelfAssignmentInGeneratedHideSql`은 테스트 내부 `TestConfiguration`/`HibernatePropertiesCustomizer`/`StatementInspector`로 실제 repository가 실행하는 SQL을 수집한다. fixture flush/clear/reload 후 수집값을 비우고 숨김 UPDATE의 자기 대입을 검사하며, DB에서 다시 읽은 `updatedAt`도 정확히 비교한다. 소스 문자열·mock 검증 또는 H2의 MySQL 자동 갱신 모방은 없다. +- RED 명령: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultHideCreatorChannelDonationRepositoryTest.shouldPreserveUpdatedAtWithSelfAssignmentInGeneratedHideSql'`. Java 17.0.15, timeout 600000ms, exit 1, `BUILD FAILED in 14s`, tests=1/failures=1/errors=0/skipped=0. 정상 컴파일 후 60행의 실제 SQL assertion이 `update channel_donation_message set is_hidden=? where id=? and creator_id=? and is_hidden=? ==> expected: but was: `로 실패했다. +- GREEN: 위 두 클래스 지정 검증 명령을 그대로 실행했다(timeout 600000ms, 표준 daemon·캐시 사용, 강제 재실행 없음). exit 0, `BUILD SUCCESSFUL in 49s`, repository 7 + HTTP E2E 37 = 44건, failures/errors/skipped 모두 0. production/test Kotlin 컴파일, main/test ktlint 검사 및 `bootJar`가 실제 실행됐고 Kotlin script 검사는 UP-TO-DATE 성공이다. 새 jar `build/libs/sodalive-0.0.1-SNAPSHOT.jar`는 141 MiB다. +- 실제 GREEN SQL(공백만 정리): `update channel_donation_message set is_hidden=?, updated_at=updated_at where id=? and creator_id=? and is_hidden=?`. 증거는 `build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultHideCreatorChannelDonationRepositoryTest.xml`의 신규 testcase 및 Hibernate 출력이다. +- 실제 HTTP: Tomcat `51419`에서 JWT 인증 DELETE 성공·중복 성공, 권한/ID 거부 및 anonymous 401을 확인했다. 공개/비밀 `/api/v2/creator-channels/125/donations/181`, `/api/v2/creator-channels/130/donations/183`은 모두 `200 success=true,data=null`이며 원본/재무 전체 컬럼 snapshot, legacy 전체 JSON, 랭킹 500캔 및 정산 39,695원·입금 38,385원 전후 동일 검사가 통과했다. 증거는 같은 디렉터리의 `TEST-kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest.xml`이다. +- 정리: 부모를 확인한 뒤 만든 전용 루트 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/channel-hide.aeTJdH/p3-r1.tm8TPt`에 shell별 TMPDIR/NODE_COMPILE_CACHE/BUN_RUNTIME_TRANSPILER_CACHE_PATH를 지정했다. worker PID `91431/91649` 종료, Tomcat·Hikari 종료 로그, port 51419 listener·Test Executor·redis-server 잔존 없음, `lsof +D` 열린 파일 없음과 0 entries를 확인했다. canonical 부모·현재 UID·symlink 아님을 확인하고 `rmdir`로 전용 루트만 삭제했다. shared Gradle/Kotlin daemon PID `53466/73431`, 부모와 다른 작업 경로는 보존했다. +- 한계: 실제 MySQL/운영 DDL, 전체 테스트, subagent/reviewer, commit/stage/push는 실행하지 않았다. LSP 도구는 미제공이며 Kotlin 컴파일·ktlint 결과만 보고한다. 기존 Gradle deprecation 안내는 유지했다. 전체 테스트의 사용자 실행 명령은 `./gradlew test`다. Task P3-R1 및 최종 재리뷰 체크는 상위 판정까지 미완료로 남긴다. + +모든 Phase Gate는 구현 경로를 직접 검증하는 focused feature test만 실행한다. 무필터 `test` 또는 전체 suite를 포함하는 `check`/`build`/`clean build`를 실행하지 않는다. 전체 테스트는 사용자 실행 항목이며 명령은 `./gradlew test`다. Phase Gate의 성공은 전체 suite 성공을 뜻하지 않는다. + +## 프론트엔드 후속 범위 + +프론트엔드 작업은 이 backend 저장소 범위 밖이다. 별도 작업에서 소유 CREATOR에게만 전달되는 `donationId`를 사용해 숨김 API를 호출하고, 성공 뒤 홈과 탭 데이터를 다시 조회해야 한다. 프론트엔드 파일 경로, UI 디자인, 추가 확인 dialog/interaction을 이 계획에서 정하지 않는다. + +## 실행 순서와 의존성 + +| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 | +|---:|---|---|---|---| +| 1 | `P1-T1` | 별도 명시 요청 | 아니요 | DDL/schema 근거 확인 | +| 2 | `P1-T2` | `P1-T1` | 아니요 | 기존 auth/error 관례 재확인 | +| 3 | `P1-GATE` | `P1-T1`, `P1-T2` | 아니요 | 실패 소유 Task에 회귀 추가 | +| 4 | `P2-T1` | `P1-GATE` | 아니요 | count/list 동일 predicate 확인 | +| 5 | `P2-T2` | `P2-T1` | 아니요 | 홈 fill 및 owner ID 검증 보강 | +| 6 | `P2-GATE` | `P2-T1`, `P2-T2` | 아니요 | 실패 소유 Task에 회귀 추가 | +| 7 | `P3-GATE` | `P2-GATE` | 아니요 | legacy/financial 경계 확인 | + +```text +P1-T1 -> P1-T2 -> P1-GATE -> P2-T1 -> P2-T2 -> P2-GATE -> P3-GATE +``` + +## 변경 금지와 중단 조건 + +- 별도 구현 요청 전에는 이 계획의 체크박스를 시작하거나 코드를 수정하지 않는다. +- soft-hide 행과 원본 값을 보존한다. delete SQL, 필드 초기화, 숨김 metadata/history를 추가하지 않는다. +- `isHidden` 조건을 global entity filter, legacy list, ranking, settlement 또는 can use query에 적용하지 않는다. +- 권한을 UI에만 맡기지 않는다. 인증, CREATOR role, channel ownership, donation-channel match를 서버에서 검증한다. +- 상태 오류를 generic HTTP 403/404로 바꾸지 않고 기존 `SodaException`/handler 응답 관례를 확인한다. +- 사용자 결정과 기술 제안을 합치지 않는다. 구현 단계에서 제안 변경이 필요하면 구현 전에 PRD/plan Decision Log를 먼저 갱신한다. + +## Progress 및 문서 검증 기록 + +### 최종 완료 판정: 2026-11-05 + +- P3-R1 수정 후 상위에서 아래 「최종 기능 범위 재검증」과 동일한 명령을 다시 실행했다. `BUILD SUCCESSFUL in 1m 7s`, XML 19개 클래스 총 **208건**, failures/errors/skipped 모두 0이다. repository 회귀 1건이 추가됐으며 기존 207건도 통과했다. +- `ktlintCheck`·`bootJar`는 수정 후 생성 산출물의 UP-TO-DATE 성공이다. 직전 P3-R1 실행에서는 production/test 컴파일·lint·jar 생성이 실제 수행됐다. 전체 suite를 실행한 것은 아니다. +- 최초 최종 리뷰에서 MySQL 자동 `updated_at` 갱신 위험을 발견했다. 보존식 1줄과 실제 생성 SQL RED/GREEN을 추가했고, scoped 재리뷰에서 **ADDRESSED / 전체 변경 승인**을 받았다. 실제 MySQL 실행 한계는 유지한다. +- 최종 테스트 worker PID 92754 잔존 없음과 임시 루트 열린 파일 없음을 확인했다. 테스트/HTTP/SQL 증거는 `build/test-results/test/`, 빌드 산출물은 `build/libs/sodalive-0.0.1-SNAPSHOT.jar`에 남긴다. +- 최종 정리 영수증: 소유권·symlink 아님·빈 상태를 확인해 남은 `p1-t1.SxYFSF`와 부모 `channel-hide.aeTJdH`를 `rmdir`로 제거했다. 임시 notepad도 제거했고 셸에서 세 경로 부재를 확인했다(exit 0). 작업용 서버/worker는 종료됐으며 공유 캐시·기존 daemon은 건드리지 않았다. 최종 tracked 및 신규 파일 공백 검사도 exit 0이었다. +- 현재 변경은 서버 소스·관련 테스트·본 기능 문서/DDL에 한정한다. 커밋·push·운영 SQL·배포는 하지 않았다. 전체 테스트 사용자 명령은 `./gradlew test`다. +- 프론트엔드 연동은 범위 밖이며 서버 구현 완료와 화면 클라이언트 반영 완료를 구분한다. 기존 프로필 API에서는 숨긴 행이 계속 보일 수 있다. + +### 최종 기능 범위 재검증: 2026-11-05 + +- 상위 세션에서 아래 명령을 직접 실행했다. exit 0, `BUILD SUCCESSFUL in 1m 5s`, XML 19개 클래스 합계 207건, failures/errors/skipped 모두 0이다. 이는 변경 기능과 직접 영향 회귀이며 전체 suite가 아니다. +- `ktlintCheck` 및 `bootJar`는 동일 입력의 UP-TO-DATE 성공이다. production jar는 Phase 2에서 생성했고 이후 production 변경은 없다. +- 실제 socket HTTP에서 공개/비밀 500캔 각각 DELETE 성공, V2 목록/건수 감소, legacy 전체 JSON 유지, cache 제거 후 랭킹 500캔 유지, 실제 정산 39,695원·입금액 38,385원 유지 로그를 다시 확인했다. +- 최종 worker PID 89370의 Tomcat·Hikari 종료 로그와 `ps -p 89370` 잔존 없음, `git diff --check` exit 0을 확인했다. 전체 변경 Oracle 리뷰는 별도로 대기한다. + +```bash +./gradlew test \ + --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.*' \ + --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.*' \ + --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.*' \ + --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.*' \ + --tests 'kr.co.vividnext.sodalive.explorer.profile.channelDonation.*' \ + --tests 'kr.co.vividnext.sodalive.admin.calculate.channelDonation.AdminChannelDonationCalculateQueryRepositoryTest' \ + ktlintCheck bootJar --console=plain +``` + +- 전체 테스트 `./gradlew test`는 사용자 실행 항목이다. 운영 MySQL/DDL 실행·배포·프론트엔드 연동은 수행하지 않았다. `lsp_diagnostics` 미제공으로 Kotlin 컴파일/ktlint를 사용했으며 LSP 검증을 주장하지 않는다. + +### 구현 착수: 2026-11-05 + +- 사용자 "문서대로 진행" 승인으로 기술 초안과 미래 구현 계획의 실행을 시작한다. 문서 전용 제한/미요청 문구는 과거 작성 시점 기록이며, 현재 제품 코드·테스트·DDL 문서 변경을 허용한다. 실제 운영 DDL 실행과 배포는 하지 않는다. +- 현재 `test` 브랜치에서 작업한다. 기존 source 변경이 없고 별도 worktree를 자동 생성하지 않는다. 검증은 프로젝트 기준의 focused test, HTTP 경로, lint 및 bootJar를 사용한다. +- 제공된 도구에 `lsp_diagnostics`가 없어 Kotlin 컴파일·ktlint로 대체하며 LSP 검증 성공을 주장하지 않는다. + +### 문서 작성: 2026-11-05 + +- 상태: PRD와 미래 계획 작성 완료. 현재 Goal 없음, 구현/테스트 미착수. +- 무엇을: 사용자 인터뷰 결정과 현행 donation/home V2 query, DTO, controller, tests를 근거로 지정 문서 두 개를 작성한다. +- 어떻게: 코드 스타일/테스트/작업 절차/문서 유지보수/실행 명령어 지침, 두 sample 문서, 기존 비밀 후원 PRD/계획, `ChannelDonationMessage`, donation/home repository, response DTO 및 donation HTTP E2E test를 읽었다. +- 문서 전용이므로 Gradle, API 호출, SQL 실행 및 runtime/test/build 검증은 하지 않았다. 향후 계획의 명령은 실행 결과가 아니다. +- 최초 초안에서는 콘텐츠 일관성 및 공백 검사가 검증 대기였으며, 후속 결과는 아래 문서 검증 기록에 누적한다. +- 남은 조건: 사용자의 별도 명시 구현 요청. frontend 작업은 backend와 별도 범위. + +### 문서 검증: 2026-11-05 + +- 상태: 문서 작성·검토 완료. 제품 구현은 미착수이며 활성 구현 Goal은 없다. +- 무엇을/왜: 인터뷰 결정, PRD 요구사항, Goal·파일·검증 명령의 연결을 대조하고 기술 초안이 사용자 확정사항으로 오인되지 않는지 확인했다. +- 내용 검증: 두 문서 전체 읽기, 실제 source/test 경로 대조, 요구사항 추적표와 상대 링크 확인. 날짜는 `date '+%Y-%m-%d %Z'`의 `2026-11-05 KST`에 맞췄다. +- 독립 검토: 최초 검토 에이전트는 지원되지 않는 모델로 시작 실패했다. 다른 실행 경로의 검토자는 읽기 전용 builder check 후 중요한 미해결 질문·모순·실행 차단 결함이 없다고 판정했다. +- 공백 검사: 두 문서 각각 `GIT_MASTER=1 git diff --no-index --check /dev/null "docs/20261105_채널후원메시지숨김/prd.md"`, `GIT_MASTER=1 git diff --no-index --check /dev/null "docs/20261105_채널후원메시지숨김/plan-task.md"` 실행, 각각 exit 0·출력 없음. untracked 문서도 검사 대상에 포함했다. +- 변경 범위: `GIT_MASTER=1 git status --short --untracked-files=all`로 이 작업의 신규 문서 두 개만 확인했다. 제품 코드·테스트·설정·SQL은 변경하지 않았다. +- 미실행: 문서 전용 변경이므로 테스트·빌드·Gradle·API·SQL 실행과 런타임/운영 DB 검증은 생략했다. 실제 기능이 동작한다는 완료 주장은 하지 않는다. +- 다음 조건: 기술안 검토 및 별도의 구현 요청. 전체 테스트 명령 `./gradlew test`는 향후 구현 후 사용자가 직접 실행한다. + +## Decision Log + +| 날짜 | ID | 상태 | 결정 | 근거 | 영향 | +|---|---|---|---|---|---| +| 2026-11-05 | `PLAN-DEC-001` | 사용자 확정 | 후원 항목 하나 soft-hide, 원본/재무 보존, 두 V2 목록만 숨김. | 사용자 인터뷰 | `CDH-001`~`CDH-008` | +| 2026-11-05 | `PLAN-DEC-002` | 작성자 기술 초안 | 단일 boolean, 로컬 query filter, owner-only ID, bodyless DELETE API, 멱등 재요청. | 현행 패키지 및 query 경계 | `CDH-009`, `P1-T1`~`P2-T2` | + +## 최종 문서 전용 확인 + +- [x] 두 문서 간 ID/범위/기술 제안 정합성 및 trailing whitespace를 확인했다. +- [x] 변경 파일이 이 디렉터리의 `prd.md`와 `plan-task.md` 두 개뿐인지 확인했다. +- 문서 전용 변경이므로 프로젝트 지침에 따라 Gradle 테스트/빌드는 실행하지 않는다. 런타임 동작은 미검증이다. + +## 커밋 전 검증: 2026-11-05 + +- 사용자 `/commit-with-validation` 요청으로 변경 파일과 전역 커밋 절차 및 저장소 메시지 규칙을 확인했다. +- Java 17.0.15에서 위 「최종 기능 범위 재검증」의 명령을 동일하게 실행했다. `BUILD SUCCESSFUL in 2s`, exit 0이며 test·ktlintCheck·bootJar를 포함한 19개 task 모두 UP-TO-DATE였다. 테스트를 새로 실행하거나 jar를 새로 생성한 결과로 표현하지 않는다. +- 현재 XML 19개 클래스의 208건, failures/errors/skipped 모두 0을 확인했다. `git diff --check`도 통과했다. +- 전체 테스트와 운영 MySQL/DDL 적용은 수행하지 않았다. 전체 테스트 사용자 명령은 `./gradlew test`이며, 배포 전 DDL 선반영 조건은 유지한다. diff --git a/docs/20261105_채널후원메시지숨김/prd.md b/docs/20261105_채널후원메시지숨김/prd.md new file mode 100644 index 00000000..e9439297 --- /dev/null +++ b/docs/20261105_채널후원메시지숨김/prd.md @@ -0,0 +1,162 @@ +# 채널 후원 메시지 항목 숨김 PRD + +## 문서 정보 + +| 항목 | 내용 | +|---|---| +| 문서 상태 | 서버 구현·기능 범위 검증·최종 리뷰 완료. 운영 DDL/프론트엔드 연동 미수행 | +| 작성일 | 2026-11-05 | +| 최종 수정일 | 2026-11-05 | +| 대상 제품 | 크리에이터 채널 후원 메시지 숨김 | +| 작성자·결정권자 | 요구사항은 사용자 인터뷰로 확정, 기술안은 문서 작성자 제안 | +| 관련 API Contract | 이 문서 §8, `DEC-006`으로 구현 승인 | +| 관련 구현 계획 | [plan-task.md](plan-task.md) | +| 관련 review | 없음 | + +## 1. 개요 + +크리에이터는 자신의 채널에 표시되는 단일 후원 항목을 숨길 수 있다. 숨김은 원본 후원 기록을 보존하는 표시 조정이며, 채널 홈의 `channelDonations`와 후원 탭의 `donations`에서만 항목을 제외한다. 최초에는 문서만 작성했으며, 이후 사용자의 "문서대로 진행" 승인(`DEC-006`)에 따라 구현했다. 현재 실행 상태와 검증 결과는 [plan-task.md](plan-task.md)를 기준으로 한다. + +## 2. 문제와 목표 + +채널에 표시된 후원 메시지가 부적절하거나 더 이상 게시하기 적절하지 않을 수 있지만, 이를 제거하면서 후원 및 정산 이력을 변경해서는 안 된다. 크리에이터가 해당 후원 항목을 숨기면 모든 조회자에게 두 V2 채널 표시 위치에서 보이지 않고 원본 기록과 재무 효과는 그대로 남아야 한다. + +## 3. 목표와 범위 밖 + +### 목표 + +- 채널의 소유 CREATOR만 해당 채널의 후원 항목 하나를 숨길 수 있다. +- 숨긴 항목은 채널 소유자와 후원자를 포함한 모든 조회자에게 두 V2 표시 목록에서 제외된다. +- `donationCount`는 숨기지 않은 후원 항목만 센다. +- 데이터베이스 원본 값, 기여도 랭킹, 정산 및 사용한 can은 그대로 보존된다. + +### 범위 밖 + +- 복원, 물리 삭제, 후원 취소, 환불, can 사용 취소 또는 정산 역분개. +- legacy 프로필/후원 목록 API의 숨김 처리. 해당 경로는 같은 원본 기록을 계속 노출할 수 있다. 이 기능은 전역 삭제나 전체 서비스 비노출을 약속하지 않는다. +- 관리자 추가 권한, 후원자 권한, 다른 크리에이터 권한, 일반 회원 권한. +- 숨김 사유, 수행자/시각 이력, UI 이력, 일괄 API, 실시간 push. +- 프론트엔드 구현 및 화면 디자인. 별도 프론트엔드 작업은 소유자 전용 ID로 숨김 API를 호출하고 두 표시 경로를 다시 조회해야 한다. + +## 4. 사용자와 권한 + +| 주체 | 숨김 가능 여부 | 두 V2 목록의 숨긴 항목 | +|---|---:|---| +| 해당 채널의 소유 CREATOR | 가능 | 제외 | +| 후원자 본인 | 불가 | 제외 | +| 다른 CREATOR | 불가 | 제외 | +| 일반 회원 | 불가 | 제외 | +| 관리자 | 이 요청으로 추가 권한 없음 | 제외 | + +서버는 인증 회원, `CREATOR` role, 요청 경로의 `creatorId`와 인증 회원의 채널 소유권, 후원 항목과 동일 채널 소속을 검증한다. 존재하지 않는 항목 또는 다른 채널의 ID를 안전하게 거부하고 어떠한 기록도 변경하지 않는다. 현재 `SodaException` 및 공통 오류 handler를 따른다. 프로젝트 관례상 도메인 실패는 HTTP 200 응답의 `success=false` envelope일 수 있으며, 별도의 REST 403/404 계약을 만들지 않는다. + +## 5. 핵심 흐름 (기술 제안 포함) + +1. 소유 CREATOR가 채널 홈 또는 후원 탭에서 항목을 선택한다. +2. 프론트엔드는 소유자에게만 응답되는 안정적인 `donationId`로 숨김 요청을 보낸다. +3. 서버가 인증, role, 채널 소유권 및 후원 소속을 확인한 뒤 해당 항목의 숨김 상태만 기록한다. +4. 같은 소유자가 이미 숨긴 항목을 다시 요청해도 성공하는 멱등 동작을 제공한다. +5. 프론트엔드는 홈과 후원 탭을 다시 조회한다. 숨긴 항목은 양쪽에서 보이지 않으며 count도 감소한다. + +## 6. 제품 요구사항 + +| ID | 상태 | 요구사항 | 수용 기준 | +|---|---|---|---| +| `CDH-001` | 확정 | 소유 CREATOR는 채널 후원 한 항목을 숨긴다. | ID로 단일 항목을 지정하며 소유권과 채널 소속이 모두 일치할 때만 성공한다. | +| `CDH-002` | 확정 | 숨김 상태는 soft-hide다. | DB 행, ID, can, 메시지, donor/creator 연결, 생성 시각, `isSecret`이 보존된다. | +| `CDH-003` | 확정 | 숨긴 항목은 모든 사용자에게 숨긴다. | V2 홈 `channelDonations` 및 V2 후원 탭 `donations`에서 creator와 donor 본인 포함 전원에게 제외된다. | +| `CDH-004` | 확정 | 후원 탭의 `donationCount`는 보이는 항목 수다. | 숨김 항목은 count에서 빠지며 pagination 및 `hasNext`도 같은 숨김 제외 조건을 쓴다. | +| `CDH-005` | 확정 | 랭킹과 재무 효과는 보존한다. | 기존 랭킹, 정산, 원본 can 사용 및 후원 기록의 값/행이 달라지지 않는다. | +| `CDH-006` | 확정 | 권한은 소유 CREATOR에 한정한다. | donor, 타 CREATOR, USER, ADMIN 요청은 거부되며 기록 변화가 없다. | +| `CDH-007` | 확정 | 숨김은 지정된 두 V2 표시 경로만 바꾼다. | legacy 프로필/후원 목록은 변경하지 않으며 같은 행을 계속 반환할 수 있음을 명시한다. | +| `CDH-008` | 확정 | 현재 공개/비밀 후원 표시 정책을 유지한다. | 숨기지 않은 공개 및 비밀 항목은 현재 `isSecret` 마스킹 규칙으로 계속 표시된다. | +| `CDH-009` | 작성자 기술 초안 | 숨김 재요청은 안전하게 성공하도록 제안한다. | 같은 소유자의 중복 숨김 요청은 성공하며 금융 효과를 재적용하지 않는다. | + +## 7. 기술 상태와 근거 + +다음 기술 선택은 최초 **AUTHOR TECHNICAL DRAFT**로 작성했고 `DEC-006`으로 구현 승인됐다. 아래 제안 표현은 작성 당시의 결정 근거이며 현재 구현 계약으로 사용한다. + +- 변경 전 엔티티 `ChannelDonationMessage`는 `BaseEntity`의 ID/시간을 상속하며 숨김 필드가 없었다. 구현은 boolean `isHidden=false`를 추가하고 MySQL의 `is_hidden TINYINT(1) NOT NULL DEFAULT 0 COMMENT '채널 후원 표시 숨김 여부'`를 사용한다. [운영 반영용 DDL](channel_donation_message_is_hidden_ddl.sql)을 작성했으며 운영 DB에는 실행하지 않았다. +- 기존 donation 탭 QueryDSL은 채널과 KST 월 조건으로 count와 page 조회를 나눠 수행하고, 홈 QueryDSL은 같은 기간의 최신 8건을 조회한다. 숨김 조건은 각 조회 경로의 `offset/limit` 적용 전에 둔다. 홈은 기존 limit 8을 유지해 숨긴 항목 다음의 표시 가능 항목으로 최대 8개를 채운다. +- donation 탭의 count와 list가 동일한 숨김 제외 predicate를 사용한다. legacy query, 랭킹/정산/금융 query에는 전역 filter를 추가하지 않는다. Hibernate 전역 `@Where`/`@SQLDelete`는 사용하지 않는다. +- 현재 홈/탭 후원 응답은 후원 항목의 ID와 donor member ID를 노출하지 않으며 기존 테스트도 ID 부재를 검증한다. 숨김 동작을 위해 안정적인 `donationId`를 소유 CREATOR 응답에만 nullable/생략 방식으로 추가하는 방안을 제안한다. 일반 조회자, donor 및 다른 사용자의 JSON에는 ID가 없어야 하며 donor `memberId`는 누구에게도 추가 노출하지 않는다. 기존 owner ID 부재 assertion은 owner-only ID 노출 검증으로 갱신하고 non-owner 부재 검증은 유지한다. +- 제안 endpoint는 bodyless `DELETE /api/v2/creator-channels/{creatorId}/donations/{donationId}`이며 `ApiResponse.ok` envelope에 data 없이 성공을 반환한다. 기존 인증 주입, `SodaException`, message key/i18n 및 handler를 재사용한다. generic REST 403/404를 새로 도입하지 않는다. +- 공개 후원 및 비밀 후원의 기존 `isSecret` 표시/masking은 visible row에만 그대로 적용한다. 숨긴 항목은 공개/비밀 구분 없이 전체 제외한다. + +## 8. 제안 API 경계 (AUTHOR TECHNICAL DRAFT) + +| Method / path | 입력 | 성공 | 실패 및 제한 | +|---|---|---|---| +| `DELETE /api/v2/creator-channels/{creatorId}/donations/{donationId}` | 인증 사용자, 경로 ID, body 없음 | 기존 `ApiResponse.ok` envelope, data 없음 | 현재 `SodaException`/handler 및 현행 HTTP-200 `success=false` 도메인 오류 패턴. 비소유/불일치/미존재는 상태 변경 없이 거부 | + +제안된 application 경계는 재사용 패키지 `kr.co.vividnext.sodalive.v2.creator.channel.donation`의 `HideCreatorChannelDonationService.hide(creatorId: Long, donationId: Long, actor: Member)`다. 출력 port는 `HideCreatorChannelDonationPort.hideOwnedDonation(creatorId: Long, donationId: Long): Boolean`로 채널에 속하는 항목만 원자적으로 숨긴다. 소유자 role/identity 검증은 service 경계에서 수행한다. adapter 패키지 `kr.co.vividnext.sodalive.v2.api.creator.channel.donation`은 controller/facade와 공개 응답의 소유자 전용 `donationId`를 조립한다. 구체 파일과 인터페이스는 plan의 기술 초안에 적는다. + +숨김 행 재요청은 이미 숨김 상태면 성공으로 끝낸다. 첫 요청과 재요청 모두 can 사용, 환불, 랭킹, 정산을 호출하거나 수정하지 않는다. 소유권·같은 채널 소속·항목 존재 여부가 일치하지 않으면 성공 처리하지 않는다. + +## 9. 비기능, 보안과 데이터 + +- 사용자 제공 ID는 신뢰하지 않는다. 인증 회원, `CREATOR` role, 채널 소유자 ID 및 donation의 channel ID를 서버에서 대조한다. +- 다른 채널/존재하지 않는 donation에 대한 거부가 데이터 존재 여부를 불필요하게 노출하지 않도록 기존 도메인 오류 패턴을 검토한다. +- `isHidden` 외에 `hiddenAt`, `hiddenBy`, reason, history를 추가하지 않는다. +- 변경 범위 밖의 구형 API가 원본을 노출할 수 있다는 제한을 제품/운영 설명에서 숨기지 않는다. +- 테스트는 repository/HTTP 통합 근거를 포함하고, 기존 테스트 관례인 JUnit 5, `@SpringBootTest`, 실제 H2/MockMvc 경로를 따른다. + +## 10. 성공 기준과 추적 + +- [x] `CDH-001`~`CDH-009`의 미래 구현 테스트와 계획 Goal이 연결되어 있다. 구현 및 테스트 통과를 뜻하지 않는다. +- [x] 역할별 권한, 다른 채널 및 존재하지 않는 ID, 숨김 중복 요청을 HTTP 통합으로 확인했다. +- [x] 모든 viewer role의 홈/탭 결과에서 숨긴 공개/비밀 행이 제외된다. +- [x] count, page, `hasNext`, 홈 최신 8개 보충 결과가 숨김 predicate와 일치한다. +- [x] 원본 행과 값, ranking, settlement, can 사용 내역의 불변을 H2/실제 HTTP로 확인했다. MySQL 자동 수정 시각 보존은 생성 SQL 자기 대입 회귀로 확인했으며 운영 MySQL 실행은 미검증이다. +- [x] legacy 출력이 변경되지 않고 그 제한을 문서화했다. +- [x] donor member ID는 추가 노출되지 않으며 donation ID는 소유 CREATOR에만 응답된다. + +| 요구사항 | 계획 Goal | 주요 완료 증거 | +|---|---|---| +| `CDH-001`, `CDH-006` | `P1-T1`, `P1-T2`, `P1-GATE` | 소유 CREATOR 성공, 비소유/위조 ID/미존재 거부 | +| `CDH-002`, `CDH-005`, `CDH-007` | `P1-T1`, `P3-GATE` | DB 재조회, 실제 랭킹/정산 및 legacy 조회 전후 비교 | +| `CDH-003`, `CDH-004`, `CDH-008` | `P2-T1`, `P2-T2`, `P2-GATE` | 역할별 숨김 제외, count/page/refill, visible 비밀 후원 마스킹 | +| `CDH-009` (기술 초안) | `P1-T1`, `P1-T2` | 동일 소유자의 중복 요청 성공 및 추가 변경 없음 | +| 소유자 전용 ID (기술 초안) | `P2-T1`, `P2-T2` | 양 응답의 동일 persisted ID, 비소유자 JSON의 ID 부재 | + +## 11. 인터뷰 기록과 미결 사항 + +사용자 인터뷰 결정: 단일 항목 soft-hide, 소유 CREATOR만 권한 보유, 모든 viewer에게 두 V2 목록에서 제외, 복원 없음, donationCount 제외, 랭킹/정산/can 사용 보존, legacy API 불변 및 전역 삭제가 아님. + +점수: Goal 1.00, Scope 1.00, Constraints 0.90, Success 0.85, Context 0.85. 기존 코드베이스 가중치 `.30/.20/.15/.20/.15` 적용 모호성은 **0.0675 (약 0.07)**다. 제품 질문은 모두 해결되었다. 기술안은 사용자 결정으로 승격하지 않는다. + +| 분류 | 상태 | 항목 | +|---|---|---| +| 제품 질문 | 없음 | 구현 범위·권한·보존 정책·조회 범위는 인터뷰로 확정 | +| 기술 제안 | `DEC-006` 승인 | §7~§9의 필드, predicate, ID 노출 및 endpoint 계약을 구현 기준으로 확정 | +| 외부 의존 | 프론트엔드 연동 필요 | 실제 화면의 숨김 조작과 양쪽 데이터 갱신은 별도 프론트엔드 작업. 이 계획의 backend 완료와 화면 기능 완료를 구분 | + +## 12. Decision Log + +| 날짜 | ID | 상태 | 결정 | 근거 | +|---|---|---|---|---| +| 2026-11-05 | `DEC-001` | 사용자 확정 | 단일 채널 후원 항목을 soft-hide하고 원본 기록/값을 보존한다. | 사용자 인터뷰 | +| 2026-11-05 | `DEC-002` | 사용자 확정 | 소유 CREATOR만 숨김할 수 있고, 숨긴 항목은 두 V2 목록에서 모든 viewer에게 제외한다. | 사용자 인터뷰 | +| 2026-11-05 | `DEC-003` | 사용자 확정 | donationCount에서 제외하되 랭킹, 정산, can 사용은 그대로 둔다. | 사용자 인터뷰 | +| 2026-11-05 | `DEC-004` | 사용자 확정 | legacy 프로필/후원 목록, 복원 및 기타 global hide는 범위 밖이다. | 사용자 인터뷰 | +| 2026-11-05 | `DEC-005` | 작성자 기술 초안 | `isHidden` 필드, 로컬 query filter, owner-only ID, DELETE route, 멱등 재요청을 제안한다. | 기존 코드 경계와 최소 구현 검토. 사용자 승인 아님 | + +## 13. 기준 문서와 변경 관리 + +[`docs/20261104_크리에이터채널_비밀후원_익명조회/prd.md`](../20261104_크리에이터채널_비밀후원_익명조회/prd.md)는 현재 visible row의 비밀 여부 마스킹 기준이다. 본 문서는 기존 `isSecret` 마스킹 정책을 변경하지 않고, 숨김 상태의 두 V2 query 제외 및 `donationCount`에 한해 후속 요구를 더한다. 과거 PRD의 목록 DTO ID 비노출 요구는 **owner-only `donationId` 기술안이 승인될 경우에만** 소유 CREATOR 응답 범위에서 좁게 대체한다. 현재 공개 계약을 이미 변경한 것은 아니다. donor member ID 부재 및 모든 non-owner의 ID 부재 요구는 유지한다. 과거 문서는 수정하지 않는다. + +구현 착수 전 별도의 명시적 요청이 필요하다. 구현 선택을 확정하면 이 PRD의 기술 제안을 승인된 계약과 구분해 Decision Log에 반영하고 계획을 동기화한다. + +### 구현 승인: 2026-11-05 + +사용자의 "문서대로 진행" 요청으로 이 문서의 기술 제안과 계획을 구현 기준으로 승인했다. 위 초안/미요청 표기는 작성 당시 이력이며 현재는 `isHidden`, 소유 CREATOR 전용 `donationId`, bodyless DELETE 및 멱등 재요청을 구현한다. 프론트엔드·운영 DDL 실행·배포는 포함하지 않는다. + +| 날짜 | ID | 상태 | 결정 | 근거 | +|---|---|---|---|---| +| 2026-11-05 | `DEC-006` | 사용자 승인 | `DEC-005` 기술 초안을 본 구현의 계약으로 사용한다. | "문서대로 진행" | + +### 서버 구현 완료: 2026-11-05 + +- 관련 기능 19개 클래스 208건이 실패/오류/skip 없이 통과했다. 실제 JWT socket HTTP, 원본 DB·랭킹·정산·legacy 보존과 SQL 시각 보존을 검증했고 최종 리뷰가 승인됐다. +- `ktlintCheck`, `bootJar` 성공. 전체 suite는 미실행이며 사용자가 `./gradlew test`로 검증한다. 상세 명령·실패 이력·최종 증거는 [plan-task.md](plan-task.md)에 있다. +- [DDL](channel_donation_message_is_hidden_ddl.sql)은 작성만 했으며 배포 전에 반영해야 한다. 프론트엔드는 소유자 전용 `donationId`로 DELETE를 호출하고 두 조회 결과를 갱신해야 한다. 화면 클라이언트·운영 배포 완료를 의미하지 않는다. diff --git a/docs/20261106_국가강제지정테스트격리/plan-task.md b/docs/20261106_국가강제지정테스트격리/plan-task.md new file mode 100644 index 00000000..db340e67 --- /dev/null +++ b/docs/20261106_국가강제지정테스트격리/plan-task.md @@ -0,0 +1,110 @@ +# 국가 강제 지정 테스트 fixture 격리 구현 계획 + +> 실행: subagent-driven-development. 기존 승인 범위를 구현하며 추가 인터뷰 없이 진행한다. + +## 현재 상태와 목표 +- 작성일: 2026-11-06 +- 상태: 구현 완료 (기능 범위 검증 완료, 전체 테스트는 사용자 실행) +- 요구사항: [prd.md](prd.md) +- 활성 Goal: 없음 (P1-T1, P1-GATE 완료) +- 목표: 운영 국가 ID 추가 시 일반 회원 fixture의 목록을 수정하지 않아도 되고, 기존 독립된 정책 검증은 유지된다. +- 기술: Kotlin 1.6.21, JDK 17, JUnit 5, Spring Boot 2.7.14, Gradle Wrapper. +- 시작 조건: 사용자 수정 승인, 관련 테스트/가이드 읽기 완료. +- 범위 밖: production, API, DB schema, dependency, 사용자 기존 변경, 전체 테스트 자동 실행, commit/push. + +### Phase 1: 목록 복제 없는 fixture + +- [x] **Task 1.1: 공통 fixture 판별과 세 호출부 전환 (P1-T1)** + +**Objective:** 세 테스트가 강제 지정 ID 목록을 복사하지 않고 일반 회원을 준비한다. + +**Files:** +- Create: `src/test/kotlin/kr/co/vividnext/sodalive/support/CountryMemberFixtures.kt` +- Create: `src/test/kotlin/kr/co/vividnext/sodalive/support/CountryMemberFixturesTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceIntegrationTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt` +- Verify unchanged: `src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceServiceTest.kt` + +**Interface:** test-only `internal fun Member.hasForcedCountryMapping(): Boolean`. 기존 resolver에 KR/JP를 각각 전달해 요청 국가가 유지되는지 확인하며, 어느 한 결과라도 다르면 true. 예상 응답 계산에는 사용 금지. + +- [x] **Baseline:** 기존 service/integration 테스트와 아래 두 HTTP 메서드가 수정 전 통과하는지 확인한다. +- [x] **RED:** helper와 리터럴 기대값의 parameterized test를 작성한다. 기존 통합 테스트의 복제 목록 동작을 helper로 옮긴 상태에서 17958이 제외되지 않는 실패를 재현한다. +- [x] **RED 확인:** `CountryMemberFixturesTest`에서 17958의 expected true / actual false를 확인한다. +- [x] **GREEN:** helper를 resolver 기반 판별로 바꾸고 16/17958/29721은 true, 100은 false인지 확인한다. +- [x] **GREEN 확인:** helper focused test 통과. +- [x] **REFACTOR:** 세 호출부 조건을 helper로 교체하고 목록 복사본을 제거한다. 기존 저장 루프와 정책/응답 assertions는 보존한다. + +**완료 증거:** 다음 세 시나리오 PASS 및 아래 명령 결과 기록. + +| 시나리오 | PASS 조건 | 실제 검증 표면 | +|---|---|---| +| 일반 회원 | 100은 강제 지정 아님, 기존 US/JP/기본값 처리 보존 | helper JUnit + H2 integration | +| 강제 지정/누락 경계 | 16/17958/29721 제외, 헤더보다 강제 지정 우선 | helper JUnit + 기존 service policy assertions | +| 인접 HTTP 회귀 | 홈/오디오의 기존 8개 국가·인증 조합별 상태/스키마/이벤트 ID assertion 통과 | 기존 Spring MockMvc 요청과 실제 H2 persistence | + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.support.CountryMemberFixturesTest' --console=plain +./gradlew test --tests 'kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest' --tests 'kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest' --console=plain +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest.shouldApplyEventBannerDetailPolicyForHomeAggregate' --tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest.shouldApplyEventBannerDetailPolicyAndKeepAudioResponseSchema' --console=plain +``` + +**P1-T1 실행 결과 (2026-11-06):** +- 모든 실제 Gradle 실행은 `JAVA_HOME`을 Liberica 17.0.15로 지정하고 `--no-daemon --console=plain`을 사용했다. Baseline은 기존 두 클래스와 위 HTTP 두 메서드를 한 번에 선택해 74건 통과했다(service 36, integration 22, 홈 8, 오디오 8). +- RED/GREEN 명령은 `./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.support.CountryMemberFixturesTest' --console=plain`이다. 기존 통합 테스트의 복제 목록 동작에서는 4건 중 17958만 expected true / actual false로 실패했고(exit 1), KR/JP resolver probe 전환 후 4건 모두 통과했다(exit 0). +- 최종 focused 회귀는 baseline의 네 `--tests` 선택자에 helper를 추가해 78건 통과했다(실패/오류/skip 0). 실제 H2 persistence와 홈/오디오 MockMvc의 기존 국가·인증별 상태/스키마/이벤트 ID assertion을 유지한 채 검증했다. 세 시나리오 모두 PASS다. +- `compileTestKotlin`이 변경 후 실제 실행되어 통과했다. `./gradlew --no-daemon ktlintTestSourceSetCheck -PinternalKtlintGitFilter=... --console=plain`은 변경한 다섯 Kotlin 파일을 줄바꿈으로 지정해 통과했다(빈 위반 보고서). LSP 도구가 없어 `lsp_diagnostics`는 미실행이다. +- production 전체 diff가 시작 시점과 같고 resolver/service test/PRD의 SHA-256도 보존됐다. 저장 재시도 루프와 기존 assertion은 변경하지 않았다. 작업 소유 Gradle/Kotlin/test JVM 및 Redis 잔류 프로세스는 없었다. 전체 테스트, build/check, 독립 리뷰, 임시 증거 삭제는 실행하지 않았고 P1-GATE는 변경하지 않았다. 전체 테스트는 사용자가 `./gradlew test`로 실행한다. + +실제 전체 명령·결과·RED 상세: `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/country-fixture.zPNREy/implementation-report.md`. +로그와 단계별 원본 JUnit XML: 같은 경로의 `implementation.igrAaj/{baseline,red,green,focused}-test-results/` 및 `*.log`. Baseline과 최종 회귀에서 Redis 종료 중 Lettuce 재연결 경고가 있었고, Gradle deprecation 경고도 보존했다. 해당 경고로 인한 테스트 실패는 없었다. + +- [x] **Phase Gate (P1-GATE): 검증과 독립 리뷰** +- 시작 조건: P1-T1 검증 통과. +- 완료 증거: 변경 test Kotlin compile 및 scoped lint, diff 검사, 독립 리뷰 승인, 소유 임시 자원 정리. +- TDD 예외 사유: Gate는 읽기 전용 검증이다. 기존 테스트/XML과 코드 diff로 판정한다. +- LSP 도구는 현재 미등록이므로 실행 불가를 기록하고 Kotlin test compilation/scoped lint로 대체한다. +- 전체 테스트는 미실행이며 사용자 명령은 `./gradlew test`이다. 전체 성공으로 표시하지 않는다. +- 종료 조건: 세 시나리오와 focused tests 통과, 독립 reviewer 승인, 범위 밖 변경 없음, 검증 기록과 QA 자원 정리 완료 시 즉시 종료한다. + +## 검증 기록 +- 시작 시 production resolver에 사용자 수정 `45459L`이 있었다. 이번 작업은 해당 파일을 변경하지 않는다. +- 기본 macOS Java 검색은 실패했지만 `$JAVA_HOME/bin/java -version`은 Liberica 17.0.15로 성공했다. + +### 부모 세션 재검증 (2026-11-06) + +아래 명령으로 캐시된 test 결과를 사용하지 않고 같은 78건을 직접 재실행했다. + +```bash +./gradlew --no-daemon test --rerun \ + --tests 'kr.co.vividnext.sodalive.support.CountryMemberFixturesTest' \ + --tests 'kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest' \ + --tests 'kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest' \ + --tests 'kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest.shouldApplyEventBannerDetailPolicyForHomeAggregate' \ + --tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest.shouldApplyEventBannerDetailPolicyAndKeepAudioResponseSchema' \ + --console=plain +``` + +- 결과: exit 0, `BUILD SUCCESSFUL in 2m 30s`, test task 실제 실행. 원본 `build/test-results/test/TEST-*.xml`의 4/36/22/8/8건 합계 78건, 실패/오류/skip 모두 0을 직접 확인했다. +- 홈/오디오 두 메서드의 MockMvc 요청과 H2 persistence가 실제 실행됐다. 운영 서버 호출이나 외부 DB 접근은 없다. +- 변경된 Kotlin 다섯 파일을 모두 읽고 diff를 대조했다. 기존 assertions와 저장 루프는 그대로이고 세 조건만 공통 helper를 사용한다. +- 운영 resolver SHA-256은 시작과 동일한 `5b562826c7cef1da40406c49c5105a534afb39a64fb915ea2a4ca9ac44dab9df`다. +- `git diff --check` 통과. 테스트 후 Gradle/test/Kotlin JVM과 Redis 프로세스 검색 결과 0건. +- scoped lint 재현 명령(다섯 변경 파일 전체 포함): + +```bash +./gradlew --no-daemon ktlintTestSourceSetCheck \ + -PinternalKtlintGitFilter=$'src/test/kotlin/kr/co/vividnext/sodalive/support/CountryMemberFixtures.kt\nsrc/test/kotlin/kr/co/vividnext/sodalive/support/CountryMemberFixturesTest.kt\nsrc/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceIntegrationTest.kt\nsrc/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt\nsrc/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt' \ + --console=plain +``` + +- 위 lint는 구현 세션에서 exit 0, `BUILD SUCCESSFUL in 1m 11s`; 원본 로그와 빈 위반 보고서를 부모가 확인했다. +- 기존 종료 시 Lettuce 재연결 경고와 Gradle deprecation 경고가 재현됐다. 테스트 실패는 없으며 이번 범위 밖 코드는 수정하지 않았다. +- 전체 테스트와 LSP는 미실행이다. 전체 테스트 사용자 실행 명령: `./gradlew test`. + +### 독립 리뷰 (2026-11-06) +- [fixture 격리 리뷰](reviews/fixture격리-review.md): 명세 준수 및 코드 품질 승인. 차단 사항/수정 권고 없음. +- reviewer가 코드/diff와 원본 RED/GREEN/XML/lint 로그를 직접 대조했다. 부모의 fresh 78건 재실행 결과도 위에 기록했다. +- P1-T1에 적힌 임시 증거 경로는 실행 당시 기록이다. 소유 임시 파일 정리 후에는 위 재현 명령·결과와 리뷰 문서를 영구 기록으로 사용한다. 마지막 JUnit XML/HTML은 `build/test-results/test/`와 `build/reports/tests/test/`에 있으며 다음 Gradle 실행으로 갱신될 수 있다. +- QA 정리 완료: Gradle/test/Kotlin JVM 및 Redis 프로세스 0건을 확인한 뒤 이번 작업의 `country-fixture.zPNREy` 디렉터리(하위 implementation/review 포함)와 임시 notepad만 삭제했다. 두 경로의 부재를 `test ! -e`로 재확인했다. 공유 cache와 작업 트리는 변경하지 않았으며 commit/push는 하지 않았다. +- Gate 판정: 세 시나리오와 focused 78건, scoped lint, 독립 리뷰, 사용자 변경 보존 및 임시 자원 정리 완료. LSP와 전체 suite 미실행 범위는 위와 같다. diff --git a/docs/20261106_국가강제지정테스트격리/prd.md b/docs/20261106_국가강제지정테스트격리/prd.md new file mode 100644 index 00000000..ba2ffb3a --- /dev/null +++ b/docs/20261106_국가강제지정테스트격리/prd.md @@ -0,0 +1,33 @@ +# 국가 강제 지정 테스트 fixture 격리 PRD + +## 문서 정보 +- 상태: 구현 완료 +- 작성일: 2026-11-06 +- 결정권자: 사용자. 기존 리뷰의 권장 방향으로 수정 승인. +- 구현 계획: [plan-task.md](plan-task.md) + +## 문제와 목표 +일반 회원 fixture를 만드는 세 테스트가 운영 국가 강제 지정 ID 목록을 복사한다. +복사본 누락으로 일반 회원이 강제 지정 회원으로 선택될 수 있으며, 운영 목록 추가가 관련 없는 테스트 수정으로 이어진다. +목표는 세 fixture가 운영 ID를 중복 관리하지 않고 일반 회원을 선택하면서 독립된 정책/응답 기대값을 유지하는 것이다. + +## 요구사항 +| ID | 상태 | 요구사항 | 수용 기준 | +|---|---|---|---| +| COUNTRY-FIXTURE-001 | 확정 | 운영 ID 목록 복제 제거 | 세 fixture가 공통 test-only 판별 함수를 사용하고 목록 복사본이 없다. | +| COUNTRY-FIXTURE-002 | 확정 | 일반 회원과 강제 지정 회원 구분 | 일반 회원 100은 허용하고 KR 16/17958 및 JP 29721은 제외한다. 17958은 기존 통합 테스트 복사본 누락 회귀 사례다. | +| COUNTRY-FIXTURE-003 | 확정 | 독립된 검증 유지 | 기존 강제 지정 우선순위, 정규화, 기본값 및 홈/오디오 응답 assertion을 변경하지 않는다. | +| COUNTRY-FIXTURE-004 | 확정 | 운영 정책 보존 | production 파일/API/DB/config 변경이 없고 사용자의 기존 JP 45459 추가를 보존한다. | + +## 구현 결정과 범위 밖 +- test-only `Member.hasForcedCountryMapping()`은 서로 다른 KR/JP 헤더에 대해 기존 resolver가 헤더를 그대로 반환하는지 판별한다. 어느 하나라도 다르면 일반 회원 후보에서 제외한다. +- 이 함수는 fixture 선택용이다. 국가 정책과 응답의 기대값 계산에는 사용하지 않는다. +- 기존 세 후보 생성 반복문과 persistence 경로는 유지한다. 신규 dependency, 설정, production 추상화는 만들지 않는다. +- 개별 운영 ID 전체를 테스트에 열거하지 않는다. 회귀 입력 ID는 대표 사례이지 동기화할 운영 목록이 아니다. +- 전체 테스트는 저장소 규칙에 따라 사용자가 실행한다. 이번 작업은 focused policy 및 직접 영향받는 HTTP 테스트를 실행한다. + +## 완료 기준 +- [x] 세 fixture의 운영 목록 복사본 제거. +- [x] 누락 ID 회귀 RED/GREEN과 일반/강제 회원 판별 검증. +- [x] 기존 policy/DB 및 홈/오디오 MockMvc 검증 통과. +- [x] production 보존, 독립 리뷰, 검증 한계 기록 및 소유 QA 자원 정리. diff --git a/docs/20261106_국가강제지정테스트격리/reviews/fixture격리-review.md b/docs/20261106_국가강제지정테스트격리/reviews/fixture격리-review.md new file mode 100644 index 00000000..2f53ad70 --- /dev/null +++ b/docs/20261106_국가강제지정테스트격리/reviews/fixture격리-review.md @@ -0,0 +1,21 @@ +# 국가 fixture 격리 리뷰 + +## 리뷰 정보 +- 일자: 2026-11-06 +- 범위: P1-T1의 test-only Kotlin 5개 파일과 기존 정책/HTTP 검증 보존. +- 기준: [PRD](../prd.md), [구현 계획](../plan-task.md), working tree diff. +- 리뷰어: 별도 read-only reviewer (`ses_e4f9ca68effegn7SKxWexCfjMl`). +- 판정: 명세 준수 승인, 코드 품질 승인. 확정 발견 사항 없음, 회귀 수정 Task 전환 항목 없음. + +## 근거 +- 세 호출부에서 운영 ID 복사본을 제거했다. 저장·재시도·index·flush/clear와 기존 assertion은 변경하지 않았다. +- test-only helper는 KR/JP 요청값 유지 여부로 강제 지정을 판별한다. 일반 회원 100, KR 16, JP 29721, 기존 복사본 누락 17958을 독립 리터럴 기대값으로 검증한다. +- helper 결과는 일반 회원 fixture 선택에만 쓰인다. 국가 우선순위·정규화·기본값 및 HTTP 응답의 기대값은 resolver에서 계산하지 않는다. +- production diff는 시작 시점과 동일한 사용자의 JP 45459 추가뿐이다. Resolver SHA-256 `5b562826c7cef1da40406c49c5105a534afb39a64fb915ea2a4ca9ac44dab9df` 일치를 직접 확인했다. +- 원본 XML/log에서 baseline 74 PASS, RED 4 중 17958만 expected true / actual false 실패, GREEN 4 PASS, 최종 78 PASS 및 scoped lint 5파일 PASS를 대조했다. +- 부모도 동일 78건을 `test --rerun`으로 재실행하여 PASS를 확인했다. 상세 명령과 결과는 구현 계획에 보존한다. + +## 한계 +- reviewer는 읽기 전용으로 검토했으며 테스트를 중복 실행하지 않았다. +- LSP 도구는 미등록이다. 실제 Kotlin test compilation과 scoped lint를 확인했다. +- 전체 suite는 미실행이다. 사용자 실행 명령은 `./gradlew test`. diff --git a/docs/20261106_이벤트수정_누락필드보존/plan-task.md b/docs/20261106_이벤트수정_누락필드보존/plan-task.md new file mode 100644 index 00000000..270ba3fb --- /dev/null +++ b/docs/20261106_이벤트수정_누락필드보존/plan-task.md @@ -0,0 +1,88 @@ +# 이벤트 수정 누락 필드 보존 구현 계획 + +## 목표와 상태 +- 작성일: 2026-11-06 +- 요구사항: [prd.md](prd.md) +- 상태: 기능 범위 구현 및 검증 완료 +- 현재 활성 Goal: 없음 +- 기술: Kotlin 1.6.21, Java 17, Spring Boot 2.7.14, JUnit 5, MockMvc, H2. +- 구조: 기존 두 서비스의 대입 조건에 null 가드만 추가한다. API 시그니처와 명시값 처리 방식은 바꾸지 않는다. +- 실행: subagent-driven-development. 기존 작업 디렉터리를 사용하고 worktree는 생성하지 않는다. + +### Phase 1: 두 API의 누락 값 보존 + +- [x] **Task 1.1: 요청 누락 회귀 테스트와 최소 수정 (P1-T1)** + +**Objective:** 두 이벤트 수정 API가 누락된 link/isAdult를 보존하면서 명시값은 기존대로 저장한다. +- 시작 조건: EVENT-001~005 확정, 기존 코드·테스트 확인 완료. +- 완료 증거: RED assertion 실패, GREEN 통합 테스트 통과, DB 재조회 검증. +- 범위 밖: 다른 필드 처리 변경, 공개 스키마 변경, 운영 데이터 접근. + +**Files:** +- `src/main/kotlin/kr/co/vividnext/sodalive/event/EventService.kt` +- `src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerService.kt` +- `src/test/kotlin/kr/co/vividnext/sodalive/event/EventControllerIntegrationTest.kt` +- `src/test/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerControllerIntegrationTest.kt` + +- [x] **RED:** 기존 통합 테스트에 관리자 인증 PUT 요청과 DB 재조회 assertion을 추가한다. +- [x] **RED 확인:** 아래 focused 명령으로 기존 link/isAdult가 null로 바뀌어 실패하는지 확인한다. +- [x] **GREEN:** 두 서비스의 link/isAdult 대입 조건에 각 인자 `!= null` 가드를 추가한다. +- [x] **GREEN 확인:** 동일 focused 테스트에서 모든 시나리오가 통과한다. +- [x] **REFACTOR:** 불필요한 리팩터링 없이 기존 테스트를 포함한 직접 영향 회귀를 확인한다. + +```bash +export TMPDIR=/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/event-omission.qeHSia/implementer.ZukrpN +export NODE_COMPILE_CACHE=$TMPDIR/node-compile-cache BUN_RUNTIME_TRANSPILER_CACHE_PATH=$TMPDIR/bun-transpiler +./gradlew test --no-daemon --tests 'kr.co.vividnext.sodalive.event.EventControllerIntegrationTest' --tests 'kr.co.vividnext.sodalive.admin.event.banner.AdminEventBannerControllerIntegrationTest' +``` + +**P1-T1 검증 결과 (2026-11-06):** +- RED: production 변경 전 위 명령 exit 1, 2분 23초. 23개 중 10개 assertion 실패, error/skip 0. 일반 클래스 12개 중 5개, 관리자 클래스 11개 중 5개 실패했다. 각 API의 제목만 수정한 true/false/null 3개 사례에서 기존 링크 대신 null이 저장됐으며 true/false도 null이 됐다. 빈 문자열/공백 링크 2개 사례에서는 링크의 기존 처리 방식은 맞았지만 생략한 isAdult가 true 대신 null이 됐다. 기존 테스트 9개와 명시값 변경 4개는 통과했다. +- GREEN: 네 비교문에만 `link != null &&`, `isAdult != null &&`를 추가한 뒤 동일 명령 exit 0, 3분 35초. 일반 12개와 관리자 11개, 총 23개 모두 통과하고 failure/error/skip은 0이었다. 기존 테스트를 삭제·변경하지 않았고 리팩터링하지 않았다. +- S1~S3는 실제 컨트롤러·보안·서비스와 기존 S3 mock을 사용했다. 모든 요청의 HTTP 200 및 `success=true`를 확인한 뒤 별도 repository 호출로 H2를 재조회했다. 제목만 수정하는 사례는 이미지 3종·isPopup·기간·lang·isActive까지 Event 스냅샷으로 완전 비교했다. 명시값은 Boolean을 반대 값에서 true/false로 각각 변경했고, 빈 문자열/공백 링크는 일반 API의 원문 저장과 관리자 API의 null 저장을 확인했다. 시각은 `2099-01-01T00:00:00`, `2099-01-02T23:59:59`로 고정했다. +- 임시 RED/GREEN 보고서: `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/event-omission.qeHSia/implementer.ZukrpN/p1-t1-red-green.md`. GREEN XML에서도 각 클래스의 test/failure/error/skip 수를 확인했다. LSP 진단 도구가 제공되지 않아 실행하지 못했으며 Gradle의 production/test Kotlin 컴파일이 성공했다. 변경 파일의 컴파일 경고는 없고, 기존 다른 파일의 불필요한 `!!`·unchecked cast·미사용 인자 및 Gradle deprecation 경고는 남아 있다. +- 한계: MockMvc/H2 통합 검증이며 실제 네트워크·운영 MySQL 검증은 아니다. 전체 테스트는 미실행이며 사용자 실행 명령은 `./gradlew test`이다. QA·변경 범위 lint·빌드·리뷰·임시 리소스 정리는 상위 에이전트의 P1-GATE에 남겼다. 커밋·스테이징·푸시와 운영 앱/서비스 실행은 하지 않았다. + +## 시나리오 계약 +| 시나리오 | PASS 조건 | 검증 표면 | +|---|---|---| +| S1 누락 보존 | 제목만 수정해도 link와 isAdult=true/false/null 및 다른 생략 필드가 DB 재조회에서 유지된다. | 두 실제 컨트롤러의 MockMvc PUT + H2 | +| S2 명시값 반영 | 새 링크, isAdult=true/false가 저장되고 응답 success=true이다. | 두 컨트롤러 + H2 | +| S3 빈값 호환 | 빈 문자열·공백 link는 일반 API에서 원문, 관리자 API에서 null로 저장된다. isAdult 생략값은 유지된다. | 두 컨트롤러 + H2 | +| S4 인접 회귀 | 기존 이벤트 조회·언어·생성 테스트가 통과한다. | 기존 focused 테스트 | + +- [x] **Phase Gate (P1-GATE): 검증 및 리뷰** +- 시작 조건: P1-T1 완료. +- 완료 증거: focused 회귀, `./gradlew bootJar`, 변경 diff 리뷰, 검증 한계 기록, 임시 리소스 정리. +- 범위 밖: 전체 테스트 자동 실행, 관련 없는 lint 수정, 커밋/푸시. +- TDD 예외 사유: Gate는 구현이 아닌 검증 단계이다. +- 대체 검증: 실제 HTTP QA가 가능한 로컬 테스트 환경이면 endpoint 응답과 DB 결과를 확인한다. 불가하면 MockMvc와 실제 네트워크 검증을 구분해 기록한다. +- LSP 도구가 제공되지 않으면 그 사실을 명시하고 Kotlin 컴파일과 변경 범위 lint를 대체 증거로 남긴다. + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.event.*' --tests 'kr.co.vividnext.sodalive.admin.event.banner.*' +./gradlew bootJar +``` + +## 종료 조건 +S1~S4의 관찰 가능한 동작과 focused 테스트가 통과하고, 빌드·변경 리뷰·임시 리소스 정리가 완료되면 즉시 종료한다. 전체 테스트는 저장소 규칙에 따라 사용자가 `./gradlew test`로 실행하며, 미검증 범위를 명시한다. + +## 검증 기록 +- 초기 조사: 두 서비스에서 link/isAdult만 누락 null을 대입하고, 기존 세 테스트 클래스에 update 검증이 없음을 확인했다. +- 계획 에이전트 후속 호출은 timeout으로 종료됐다. 기존 조사와 승인된 요구사항으로 위 최소 변경을 확정했다. + +### P1-GATE 검증 결과 (2026-11-06) +- 실제 HTTP QA: 임시 `EventOmissionHttpQaTest`를 별도 Gradle init script로 테스트 소스에 연결했다. `./gradlew --no-daemon --console=plain -I "$TMPDIR/http-qa.init.gradle" test --tests 'kr.co.vividnext.sodalive.event.EventOmissionHttpQaTest'` exit 0. RANDOM_PORT 서버와 TestRestTemplate, 실제 TokenProvider 관리자 JWT, H2·embedded Redis를 사용했다. MockMvc 요청으로 대체하지 않았다. +- 실제 네트워크: 두 경로 각각 기존 성인 여부 true/false/null에서 제목만 수정, 링크만 수정, isAdult=true, isAdult=false, 빈 링크, 공백 링크를 차례로 전송했다. 총 36개 PUT 모두 HTTP 200, `{"success":true,"message":"수정되었습니다.","data":{},"errorProperty":null}` 응답이며, 6개 조합 모두 DB 재조회 assertion을 통과했다. 제목 외 필드 보존·독립 필드 수정·빈값 호환을 확인했다. +- 1차 Gate: `./gradlew --no-daemon --console=plain test --tests 'kr.co.vividnext.sodalive.event.*' --tests 'kr.co.vividnext.sodalive.admin.event.banner.*' ktlintCheck bootJar --continue` exit 0, 1분 30초. 저장소 39개 및 앞선 임시 QA 컴파일 클래스 2개가 실행됐다. `ktlintCheck` 실제 실행 통과, `bootJar` 생성 및 `build/libs/sodalive-0.0.1-SNAPSHOT.jar` 확인. +- 임시 QA 컴파일 클래스가 일반 실행에도 남은 것을 확인해 해당 소유 class 파일만 제거했다. 이후 임시 init script 없이 아래 명령으로 저장소 테스트만 재실행했다. + +```bash +./gradlew --no-daemon --console=plain test --rerun --tests 'kr.co.vividnext.sodalive.event.*' --tests 'kr.co.vividnext.sodalive.admin.event.banner.*' ktlintCheck bootJar --continue +``` + +- 최종 결과: exit 0, 2분 57초. XML 5개에서 일반 컨트롤러 12 + 관리자 컨트롤러 11 + 언어 통합 7 + 저장소 5 + 서비스 4 = **39개**, 실패·오류·skip 모두 0. XML 시각은 `2026-11-06T04:14:52`~`04:15:19`. 임시 QA class는 최종 결과에 없다. Kotlin test 컴파일 및 테스트는 실제 실행됐고, 동일 입력의 lint·bootJar는 직전 통과 결과를 재사용한 UP-TO-DATE였다. +- 독립 리뷰: `bg_8220e32c`가 PRD EVENT-001~005, 실제 diff, 연결 코드와 테스트, HTTP/DB 원본을 대조해 **APPROVE**. 차단 사항 없음. [리뷰 기록](reviews/event-omission-review.md). +- 진단: LSP 도구가 제공되지 않아 미실행이며 Kotlin 컴파일·ktlintCheck로 대체했다. `git diff --check` 통과. 기존 무관 코드 경고와 테스트 context 종료 경고는 이번 수정 범위 밖이다. +- 검증 한계: 전체 테스트와 운영 MySQL·실제 관리자 UI는 미검증이다. 전체 검증은 사용자가 `./gradlew test`로 실행한다. 운영 데이터 복원·접속 및 커밋·푸시는 하지 않았다. +- 정리: Gradle wrapper/daemon/test worker·embedded Redis 프로세스가 남아 있지 않음을 확인했다. 이번 작업 소유 `event-omission.qeHSia` 루트와 두 자식 루트, 임시 HTTP QA 소스·init script·로그를 삭제했고, 루트·임시 class·임시 XML 부재 검사 `CLEANUP_PASS`를 확인했다. 앞서 기록된 임시 보고서 경로는 실행 당시 경로이며 현재는 위 문서와 리뷰에 요약 근거를 보존한다. 공유 캐시·기존 worktree·운영 리소스는 건드리지 않았다. diff --git a/docs/20261106_이벤트수정_누락필드보존/prd.md b/docs/20261106_이벤트수정_누락필드보존/prd.md new file mode 100644 index 00000000..e93029b4 --- /dev/null +++ b/docs/20261106_이벤트수정_누락필드보존/prd.md @@ -0,0 +1,27 @@ +# 이벤트 수정 누락 필드 보존 PRD + +## 문서 정보 +- 작성일: 2026-11-06 +- 상태: 구현 및 기능 범위 검증 완료 +- 결정권자: 사용자. 두 API 모두 파라미터 미전송 시 기존 값을 유지하도록 명시 승인. +- 구현 계획: [plan-task.md](plan-task.md) + +## 문제와 목표 +`PUT /event`, `PUT /admin/event/banner`에서 `link`, `isAdult`를 생략하면 기존 값이 null로 덮어써진다. 부분 수정 시 생략된 값을 보존하고, 명시적으로 전달한 값은 기존 방식으로 반영한다. + +## 요구사항과 수용 기준 +| ID | 상태 | 요구사항 | 수용 기준 | +|---|---|---|---| +| EVENT-001 | 확정 | 두 API의 link 생략은 변경하지 않는다. | 제목만 수정한 뒤 DB 재조회에서 기존 링크가 유지된다. | +| EVENT-002 | 확정 | 두 API의 isAdult 생략은 변경하지 않는다. | 기존 true, false, null 각각이 유지된다. | +| EVENT-003 | 확정 | 명시한 값의 기존 동작을 유지한다. | 새 링크 및 isAdult=true/false가 저장된다. | +| EVENT-004 | 확정 | 빈 링크의 기존 API별 동작을 유지한다. | /event는 빈 문자열·공백을 그대로 저장하고 관리자 API는 null로 저장한다. | +| EVENT-005 | 확정 | 다른 생략 필드와 API 계약을 유지한다. | 이미지·isPopup·기간·lang·isActive는 제목만 수정해도 유지되고 응답 성공 형식과 ADMIN 권한은 변하지 않는다. | + +## 범위 밖 +- 새 삭제 파라미터, JSON PATCH, DTO·스키마 변경, 서비스 통합/리팩터링. +- 기존 데이터 복원, 관리자 프런트엔드 변경, 운영 DB 접근, 날짜 처리 변경. +- isAdult를 null로 초기화하는 새 기능. 생략은 이제 보존이며 true/false 전송은 기존대로 지원한다. + +## 검증 경계 +기존 SpringBootTest/MockMvc와 H2 재조회로 요청 바인딩부터 저장까지 검증한다. S3는 기존 mock을 유지한다. 실제 관리자 UI 및 운영 MySQL 검증과 전체 테스트는 별도이며, 전체 테스트 실행 명령은 `./gradlew test`이다. diff --git a/docs/20261106_이벤트수정_누락필드보존/reviews/event-omission-review.md b/docs/20261106_이벤트수정_누락필드보존/reviews/event-omission-review.md new file mode 100644 index 00000000..fdf4ad16 --- /dev/null +++ b/docs/20261106_이벤트수정_누락필드보존/reviews/event-omission-review.md @@ -0,0 +1,29 @@ +# 이벤트 수정 누락 필드 보존 리뷰 + +## 리뷰 정보 +- 일자: 2026-11-06 +- 대상: 현재 working tree의 서비스 2개·통합 테스트 2개, P1-T1/P1-GATE +- 기준: [PRD](../prd.md), [구현 계획](../plan-task.md) +- 리뷰어: 독립 검토 에이전트 `bg_8220e32c` 및 상위 에이전트 실행 검증 +- 상태: 판정 완료, APPROVE + +## 검토 근거 +- `EventService.update`, `AdminEventBannerService.update`의 production 변경은 null 가드 네 곳뿐이다. +- 두 컨트롤러의 선택 파라미터, ADMIN 권한, 응답 구조, Event 엔티티·저장소를 대조했다. +- 기존 통합 테스트에 추가한 14개 사례는 실제 컨트롤러와 서비스 및 별도 H2 재조회로 누락·명시값·빈값 처리를 확인한다. +- nullable Boolean 세 상태 및 다른 생략 필드는 Event 스냅샷으로 비교한다. 명시한 false도 별도 값으로 반영한다. +- 빈 링크는 기존대로 일반 API에서 원문을 저장하고 관리자 API에서 null로 저장한다. + +## 실행 증거 +| 검증 | 결과 | +|---|---| +| TDD RED | 구현자 기록: 23개 중 의도한 assertion 실패 10개. 리뷰어가 RED 원본을 별도로 재실행하지는 않았다. | +| focused GREEN | 리뷰어가 원본 XML의 일반 12개·관리자 11개, 실패·오류·skip 0을 확인했다. | +| 실제 HTTP/DB | 부모가 실제 관리자 JWT로 36개 PUT·6개 DB 상태 조합을 실행했고 리뷰어가 QA 소스와 XML을 대조했다. 모두 PASS. | +| 최종 인접 회귀 | 부모가 임시 QA class 제거 후 저장소 5개 클래스 39개를 재실행, 실패·오류·skip 0 확인. | +| 빌드·정적 검사 | 부모의 Kotlin 컴파일, ktlintCheck, bootJar, diff 공백 검사 통과. | + +## 판정 +확정 발견 사항 없음. EVENT-001~005에 부합하고 API 스키마·권한 변경이나 범위 확장이 없다. 독립 리뷰의 명세 준수 및 코드 품질 판정은 APPROVE다. + +전체 테스트, 운영 MySQL, 실제 관리자 UI, LSP는 미검증이다. 전체 테스트는 사용자 실행 항목이며 위 검증을 전체 성공으로 표현하지 않는다. diff --git a/docs/agent-guides/문서유지보수.md b/docs/agent-guides/문서유지보수.md index 9262a4ef..51533f19 100644 --- a/docs/agent-guides/문서유지보수.md +++ b/docs/agent-guides/문서유지보수.md @@ -20,10 +20,9 @@ - 각 task에는 TDD 절차를 명시한다. 기본 형식은 `RED: 실패 테스트 작성/실패 확인`, `GREEN: 최소 구현/통과 확인`, `REFACTOR: 정리/회귀 확인`을 포함한다. - 테스트 작성이 현실적으로 불가능한 task는 `TDD 예외 사유`와 `대체 검증 방법`을 task에 명시한다. - 각 phase 또는 task에는 실행 명령, 기대 결과, 수동 확인 항목 등 검증 기준을 함께 작성한다. -- 각 task의 검증 기준에는 focused test와 직접 영향받는 회귀 명령을 우선 명시한다. 전체 회귀는 광범위한 공통 코드 변경, - 여러 domain/phase 변경, release/final Gate에서 전체 상태 증거를 별도로 요구하는 경우, targeted test만으로 영향 범위를 판단할 - 수 없는 경우 또는 사용자 요청일 때만 포함한다. -- 계획에서 전체 회귀를 조건부로 두면 실행 조건, 생략 시 기록할 근거와 대체 focused/영향 범위 회귀 명령을 함께 명시한다. +- 각 Task와 Gate의 에이전트 검증 명령은 구현 기능의 focused test와 직접 영향받는 기능별 회귀로 한정한다. 전체 테스트는 사용자 실행 항목으로 분리한다. +- 공통 코드·여러 domain/phase 변경·release/final Gate를 자동 전체 테스트 실행 조건으로 작성하지 않는다. `check`/`build`/`clean build`의 간접 전체 테스트도 피하고 필요한 빌드는 `bootJar`로 명시한다. +- 계획과 결과 보고에 기능별 실행 명령·결과, 전체 테스트 미실행 사실 및 사용자 실행 명령 `./gradlew test`를 남긴다. 기능 범위 완료와 사용자 전체 검증 상태를 구분하며 기존 timeout·실패 이력은 보존한다. - 구현 완료 즉시 해당 task 체크박스를 `- [x]`로 갱신한다. - 작업 도중 범위가 변경되면 계획 문서 체크리스트를 먼저 업데이트한 뒤 구현한다. - 결과 보고 시 개별 task 검증 기록(무엇/왜/어떻게, 실행 명령, 결과)은 해당 task 아래에 한국어로 남긴다. diff --git a/docs/agent-guides/실행명령어.md b/docs/agent-guides/실행명령어.md index 40afc69d..f4880b61 100644 --- a/docs/agent-guides/실행명령어.md +++ b/docs/agent-guides/실행명령어.md @@ -3,25 +3,33 @@ ## 실행 기준 - 아래 명령은 저장소 루트(`/Users/klaus/Develop/sodalive/Server/sodalive`)에서 실행한다. - 변경 범위에 맞는 최소 명령으로 검증하고, 결과는 계획 문서 하단 검증 기록에 남긴다. -- 검증은 focused test → 영향받는 package/feature 회귀 → 전체 회귀 순서로 범위를 넓힌다. -- 전체 회귀 테스트(`./gradlew test`, `./gradlew check`, 전체 `build`)는 실행 시간이 길므로 매 Task나 일반적인 작은 변경에서 - 관성적으로 실행하지 않는다. -- 공통 인증·보안·예외·설정·serialization처럼 영향 범위가 넓은 코드 변경, 여러 domain/phase에 걸친 변경, release/final Gate에서 - 전체 상태 증거를 별도로 요구하는 경우, targeted test만으로 영향 범위를 판단할 수 없는 실패 또는 사용자 명시 요청일 때만 - 전체 회귀를 실행한다. -- 전체 회귀를 생략하면 실행하지 않은 사실, 생략 근거와 대신 실행한 focused/영향 범위 회귀 명령을 계획 문서에 기록한다. +- 에이전트는 구현 중인 기능의 focused test와 직접 영향받는 package/feature 회귀만 각각 실행한다. `--tests`로 대상 클래스·메서드·패키지를 명시한다. +- 전체 테스트는 10분 실행 제한으로 완료되지 않는 경우가 많으므로 사용자가 직접 실행한다. 에이전트는 무필터 `./gradlew test`를 자동 실행하거나 timeout 후 재시도하지 않는다. +- 공통 코드 변경, 여러 domain/phase 변경, release/final Gate라도 자동으로 전체 테스트로 확대하지 않는다. 영향받는 기능을 식별해 각각 검증하고 확인하지 못한 범위를 보고한다. +- `./gradlew check`, `./gradlew build`, `./gradlew clean build`도 전체 테스트를 포함하므로 에이전트가 자동 실행하지 않는다. 빌드 산출물 검증이 필요하면 `./gradlew bootJar`를 사용하고 기능별 테스트와 구분해 기록한다. +- 계획 문서와 최종 보고에는 실행한 기능별 테스트 명령·결과, 전체 테스트 미실행 사실, 사용자 실행 명령 `./gradlew test`를 남긴다. 기능별 성공을 전체 테스트 성공으로 표현하지 않는다. +- 전체 테스트의 사용자 실행 대기는 기능 범위 완료를 막지 않는다. 기능별 테스트 실패나 미검증 위험은 별도로 명시하며 실패를 무시하거나 테스트를 완화하지 않는다. ## Build/Lint/Test -아래는 사용 가능한 명령 목록이며 모든 변경에서 전부 실행하라는 의미가 아니다. +에이전트는 변경 범위에 필요한 명령만 선택한다. 아래 `--tests`는 기존 홈 기능의 예시이며 실제 작업 대상에 맞게 바꾼다. ```bash ./gradlew tasks --all ./gradlew bootRun -./gradlew build -./gradlew clean build -./gradlew test -./gradlew check +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.*' +./gradlew bootJar ./gradlew ktlintCheck ./gradlew ktlintFormat ``` + +사용자가 직접 실행하는 전체 검증 명령: + +```bash +./gradlew test +./gradlew check +./gradlew build +./gradlew clean build +``` + +위 명령은 목적에 맞게 하나를 선택한다. 전체 테스트 확인만 필요하면 `./gradlew test`로 충분하다. diff --git a/docs/agent-guides/작업절차.md b/docs/agent-guides/작업절차.md index f995d0ca..5d4a8341 100644 --- a/docs/agent-guides/작업절차.md +++ b/docs/agent-guides/작업절차.md @@ -29,8 +29,8 @@ 전에는 goal이나 Phase Gate를 완료 처리하지 않는다. - 변경 중: 완료된 Task와 기존 검증 기록은 되돌리거나 삭제하지 않는다. 후속 리뷰에서 발견한 회귀는 별도 Task/Goal로 누적한다. - 변경 후: 변경 범위의 focused test를 우선 실행하고, 공유 경계와 직접 영향받는 package/feature 회귀까지만 단계적으로 확장한다. -- 변경 후: 전체 회귀 테스트는 공통 인증·보안·예외·설정 등 광범위한 변경, 여러 domain/phase 변경, release/final Gate에서 전체 - 상태 증거를 별도로 요구하는 경우, targeted test만으로 영향 범위를 판단할 수 없는 실패 또는 사용자 명시 요청일 때만 실행한다. -- 변경 후: 전체 회귀 테스트를 실행하지 않으면 생략 사실과 근거, 대신 실행한 focused/영향 범위 회귀 명령을 검증 기록에 남긴다. +- 변경 후: 전체 테스트는 사용자가 직접 수행한다. 공통 코드·여러 domain/phase 변경이나 release/final Gate에서도 에이전트는 관련 기능을 각각 검증하며 전체 테스트로 자동 확대하지 않는다. +- 변경 후: 전체 테스트를 포함하는 무필터 `test`, `check`, `build`, `clean build`를 자동 실행하지 않는다. 필요한 빌드 검증은 `bootJar`로 분리한다. +- 변경 후: 검증 기록과 최종 보고에 실행한 기능별 명령·결과, 미검증 범위와 사용자용 전체 테스트 명령 `./gradlew test`를 남긴다. 전체 테스트 사용자 실행 대기만으로 기능 범위 완료를 막거나 전체 성공으로 표시하지 않는다. - 변경 후: 각 task의 검증 결과는 해당 task 아래에 무엇을, 왜, 어떻게 검증했는지, 실행 명령과 결과를 한국어로 누적 기록한다. - 변경 후: 여러 task/phase에 걸친 회귀 검증, 전체 빌드/포맷 검증, 문서 변경 범위 확인처럼 전체에 해당하는 검증은 계획 문서 하단의 검증 기록에 누적한다. diff --git a/docs/sample/sample-plan-task.md b/docs/sample/sample-plan-task.md index 4a062dae..75397c5d 100644 --- a/docs/sample/sample-plan-task.md +++ b/docs/sample/sample-plan-task.md @@ -50,10 +50,9 @@ - 계약: 제공되지 않은 endpoint, DTO, enum, 오류 status/key와 validation 상한을 추정하지 않는다. - backend 구현 전 UI 확인이 필요하면 제공 계약 기반 explicit mock mode를 사용하고 실제 404 자동 fallback·production mock을 금지하며 mock/server 완료 증거를 분리한다. - 구현: 모든 구현 Task는 아래 `RED → GREEN → REFACTOR` 순서를 체크박스에 명시하고, 가장 작은 실패 test에서 시작해 최소 구현으로 통과시킨다. -- 검증: focused test에서 시작해 영향받는 package/feature 회귀로 확장한다. 전체 회귀는 공통 경계 변경, 여러 domain/phase 변경, - release/final Gate에서 전체 상태 증거를 별도로 요구하는 경우, targeted test만으로 영향 범위를 판단할 수 없는 실패 또는 사용자 - 명시 요청이 있을 때만 실행한다. -- 전체 회귀를 생략하면 생략 사실·근거와 대신 실행한 focused/영향 범위 회귀 명령을 Progress에 기록한다. +- 검증: 에이전트는 구현 기능의 focused test와 직접 영향받는 package/feature 회귀만 `--tests`로 지정해 각각 실행한다. 공통 경계·여러 domain/phase 변경과 release/final Gate도 전체 테스트 자동 실행 사유가 아니다. +- 전체 테스트는 사용자가 직접 실행한다. 에이전트는 무필터 `test` 및 전체 테스트를 포함하는 `check`/`build`/`clean build`를 실행하지 않으며, 필요한 빌드 검증은 `bootJar`로 분리한다. +- Progress와 최종 보고에 실행한 기능별 명령·결과, 전체 테스트 미실행 및 미검증 범위, 사용자 실행 명령 `./gradlew test`를 기록한다. 전체 테스트 사용자 실행 대기는 기능 범위 완료와 별도 상태로 관리한다. ## Task TDD 작성 규칙 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerService.kt index 250fc4bd..eef0a186 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerService.kt @@ -167,7 +167,7 @@ class AdminEventBannerService( ) } - if (event.link != link) { + if (link != null && event.link != link) { event.link = if (link.isNullOrBlank()) null else link } @@ -179,7 +179,7 @@ class AdminEventBannerService( event.isPopup = isPopup } - if (isAdult != event.isAdult) { + if (isAdult != null && isAdult != event.isAdult) { event.isAdult = isAdult } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/event/EventService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/event/EventService.kt index ff4ea05f..604b631b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/event/EventService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/event/EventService.kt @@ -228,7 +228,7 @@ class EventService( ) } - if (event.link != link) { + if (link != null && event.link != link) { event.link = link } @@ -240,7 +240,7 @@ class EventService( event.isPopup = isPopup } - if (isAdult != event.isAdult) { + if (isAdult != null && isAdult != event.isAdult) { event.isAdult = isAdult } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessage.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessage.kt index 3b2e1002..b0642ae3 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessage.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessage.kt @@ -15,6 +15,9 @@ class ChannelDonationMessage( @Column(columnDefinition = "TEXT") var additionalMessage: String? = null ) : BaseEntity() { + @Column(nullable = false) + var isHidden: Boolean = false + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id", nullable = false) var member: Member? = null diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt index 7907600b..9e71a620 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -1921,6 +1921,16 @@ class SodaMessageSource { Lang.KO to "%s캔을 비밀후원하셨습니다.", Lang.EN to "You secretly sponsored %s cans.", Lang.JA to "%sCANをシークレット支援しました。" + ), + "explorer.channel_donation.anonymous.nickname" to mapOf( + Lang.KO to "익명", + Lang.EN to "Anonymous", + Lang.JA to "匿名" + ), + "explorer.channel_donation.anonymous.message" to mapOf( + Lang.KO to "크리에이터에게만 보여요", + Lang.EN to "Only visible to the creator", + Lang.JA to "クリエイターにのみ表示されます" ) ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceCountryResolver.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceCountryResolver.kt index 596441df..8c9f9684 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceCountryResolver.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceCountryResolver.kt @@ -3,7 +3,7 @@ package kr.co.vividnext.sodalive.member.contentpreference import kr.co.vividnext.sodalive.member.Member private val FORCED_KR_MEMBER_IDS = setOf(16L, 17L, 17958L, 44144L) -private val FORCED_JP_MEMBER_IDS = setOf(29721L, 32050L, 37543L, 40850L) +private val FORCED_JP_MEMBER_IDS = setOf(29721L, 32050L, 37543L, 40850L, 45459L) fun resolveCountryCodeWithForcedMapping(member: Member?, requestCountryCode: String?): String { val memberId = member?.id diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt index c618884f..73590e7f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt @@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacade import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping @@ -18,6 +19,16 @@ class CreatorChannelDonationController( private val creatorChannelDonationFacade: CreatorChannelDonationFacade, private val langContext: LangContext ) { + @DeleteMapping("/{creatorId}/donations/{donationId}") + fun hideDonation( + @PathVariable creatorId: Long, + @PathVariable donationId: Long, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ): ApiResponse { + creatorChannelDonationFacade.hide(creatorId, donationId, requireMember(member)) + return ApiResponse.ok() + } + @GetMapping("/{creatorId}/donations") fun getDonationTab( @PathVariable creatorId: Long, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacade.kt index 14d409f9..32d1d018 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacade.kt @@ -4,6 +4,7 @@ import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto.CreatorChannelDonationTabResponse import kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryService +import kr.co.vividnext.sodalive.v2.creator.channel.donation.application.HideCreatorChannelDonationService import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -13,8 +14,14 @@ import java.time.LocalDateTime @Transactional(readOnly = true) class CreatorChannelDonationFacade( private val creatorChannelDonationQueryService: CreatorChannelDonationQueryService, - private val creatorLocalizedNameService: CreatorLocalizedNameService + private val creatorLocalizedNameService: CreatorLocalizedNameService, + private val hideCreatorChannelDonationService: HideCreatorChannelDonationService ) { + @Transactional + fun hide(creatorId: Long, donationId: Long, actor: Member) { + hideCreatorChannelDonationService.hide(creatorId, donationId, actor) + } + fun getDonationTab( creatorId: Long, viewer: Member, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/dto/CreatorChannelDonationTabResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/dto/CreatorChannelDonationTabResponse.kt index bf0a535d..483a02c7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/dto/CreatorChannelDonationTabResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/dto/CreatorChannelDonationTabResponse.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto +import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.annotation.JsonProperty import kr.co.vividnext.sodalive.extensions.toUtcIso import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonation @@ -48,20 +49,26 @@ data class MemberDonationRankingResponse( } data class CreatorChannelDonationResponse( + @JsonInclude(JsonInclude.Include.NON_NULL) + val donationId: Long?, val nickname: String, val profileImageUrl: String, val can: Int, val message: String, - val createdAtUtc: String + val createdAtUtc: String, + @JsonProperty("isSecret") + val isSecret: Boolean ) { companion object { fun from(donation: CreatorChannelDonation): CreatorChannelDonationResponse { return CreatorChannelDonationResponse( + donationId = donation.donationId, nickname = donation.nickname, profileImageUrl = donation.profileImageUrl, can = donation.can, message = donation.message, - createdAtUtc = donation.createdAt.toUtcIso() + createdAtUtc = donation.createdAt.toUtcIso(), + isSecret = donation.isSecret ) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt index 6e14c92a..47b64253 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.home.dto +import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.annotation.JsonProperty import kr.co.vividnext.sodalive.v2.api.creator.channel.common.dto.CreatorChannelAudioContentResponse import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType @@ -109,20 +110,26 @@ data class CreatorChannelLiveResponse( } data class CreatorChannelDonationResponse( + @JsonInclude(JsonInclude.Include.NON_NULL) + val donationId: Long?, val nickname: String, val profileImageUrl: String, val can: Int, val message: String, - val createdAtUtc: String + val createdAtUtc: String, + @JsonProperty("isSecret") + val isSecret: Boolean ) { companion object { fun from(donation: CreatorChannelDonation): CreatorChannelDonationResponse { return CreatorChannelDonationResponse( + donationId = donation.donationId, nickname = donation.nickname, profileImageUrl = donation.profileImageUrl, can = donation.can, message = donation.message, - createdAtUtc = donation.createdAt.toUtcIso() + createdAtUtc = donation.createdAt.toUtcIso(), + isSecret = donation.isSecret ) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt index 289f9633..936b5e75 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt @@ -68,7 +68,7 @@ class DefaultCreatorChannelDonationQueryRepository( return queryFactory .select(channelDonationMessage.id.count()) .from(channelDonationMessage) - .where(channelDonationCondition(creatorId, viewerId, now)) + .where(channelDonationCondition(creatorId, now)) .fetchOne() ?.toInt() ?: 0 @@ -87,19 +87,21 @@ class DefaultCreatorChannelDonationQueryRepository( .select( Projections.constructor( CreatorChannelDonationRecord::class.java, + channelDonationMessage.id, channelDonationMessage.member.id, donorName.displayName, channelDonationMessage.member.profileImage, channelDonationMessage.can, channelDonationMessage.additionalMessage, channelDonationMessage.createdAt, + channelDonationMessage.isSecret, donorName.name.id.isNotNull ) ) .from(channelDonationMessage) .join(channelDonationMessage.member, member) .leftJoin(donorName.name).on(donorName.condition) - .where(channelDonationCondition(creatorId, viewerId, now)) + .where(channelDonationCondition(creatorId, now)) .orderBy(channelDonationMessage.createdAt.desc(), channelDonationMessage.id.desc()) .offset(offset) .limit(limit.toLong()) @@ -108,22 +110,12 @@ class DefaultCreatorChannelDonationQueryRepository( private fun channelDonationCondition( creatorId: Long, - viewerId: Long, now: LocalDateTime ): BooleanExpression { val monthRange = queryPolicy.currentKstMonthRange(now) return channelDonationMessage.creator.id.eq(creatorId) + .and(channelDonationMessage.isHidden.isFalse) .and(channelDonationMessage.createdAt.goe(monthRange.startInclusiveUtc)) .and(channelDonationMessage.createdAt.lt(monthRange.endExclusiveUtc)) - .and(donationVisibilityCondition(creatorId, viewerId)) - } - - private fun donationVisibilityCondition(creatorId: Long, viewerId: Long): BooleanExpression { - return if (creatorId == viewerId) { - channelDonationMessage.id.isNotNull - } else { - channelDonationMessage.isSecret.isFalse - .or(channelDonationMessage.member.id.eq(viewerId)) - } } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultHideCreatorChannelDonationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultHideCreatorChannelDonationRepository.kt new file mode 100644 index 00000000..d05aaad2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultHideCreatorChannelDonationRepository.kt @@ -0,0 +1,30 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.explorer.profile.channelDonation.QChannelDonationMessage.channelDonationMessage +import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.HideCreatorChannelDonationPort +import org.springframework.stereotype.Repository +import org.springframework.transaction.annotation.Transactional + +@Repository +class DefaultHideCreatorChannelDonationRepository( + private val queryFactory: JPAQueryFactory +) : HideCreatorChannelDonationPort { + @Transactional + override fun hideOwnedDonation(creatorId: Long, donationId: Long): Boolean { + val ownedDonation = channelDonationMessage.id.eq(donationId) + .and(channelDonationMessage.creator.id.eq(creatorId)) + val updatedCount = queryFactory + .update(channelDonationMessage) + .set(channelDonationMessage.isHidden, true) + .set(channelDonationMessage.updatedAt, channelDonationMessage.updatedAt) + .where(ownedDonation, channelDonationMessage.isHidden.isFalse) + .execute() + + return updatedCount > 0 || queryFactory + .selectOne() + .from(channelDonationMessage) + .where(ownedDonation) + .fetchFirst() != null + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt index 0b3fbd20..b729f929 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt @@ -68,7 +68,7 @@ class CreatorChannelDonationQueryService( return CreatorChannelDonationTab( donationCount = queryPort.countChannelDonations(creatorId, viewerId, now), rankings = findRankings(creator, viewerId), - donations = queryPolicy.limitItems(fetchedDonations, donationPage).map { it.toDomain() }, + donations = queryPolicy.limitItems(fetchedDonations, donationPage).map { it.toDomain(creatorId, viewer, lang) }, page = donationPage, hasNext = queryPolicy.hasNext(fetchedDonations, donationPage) ) @@ -95,14 +95,32 @@ class CreatorChannelDonationQueryService( ).map { it.toDomain() } } - private fun CreatorChannelDonationRecord.toDomain() = CreatorChannelDonation( - memberId = memberId, - nickname = if (isLocalizedName) nickname else nickname.removeDeletedNicknamePrefix(), - profileImageUrl = profileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(), - can = can, - message = message.orEmpty(), - createdAt = createdAt - ) + private fun CreatorChannelDonationRecord.toDomain(creatorId: Long, viewer: Member, lang: Lang): CreatorChannelDonation { + val viewerId = viewer.id!! + val isMasked = isSecret && viewerId != creatorId && viewerId != memberId + return CreatorChannelDonation( + donationId = donationId.takeIf { viewer.role == MemberRole.CREATOR && viewerId == creatorId }, + memberId = memberId, + nickname = when { + isMasked -> messageSource.getMessage("explorer.channel_donation.anonymous.nickname", lang).orEmpty() + isLocalizedName -> nickname + else -> nickname.removeDeletedNicknamePrefix() + }, + profileImageUrl = if (isMasked) { + "$cloudFrontHost/profile/anonymous-profile.png" + } else { + profileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl() + }, + can = can, + message = if (isMasked) { + messageSource.getMessage("explorer.channel_donation.anonymous.message", lang).orEmpty() + } else { + message.orEmpty() + }, + createdAt = createdAt, + isSecret = isMasked + ) + } private fun CreatorChannelDonationRankingRecord.toDomain() = CreatorChannelDonationRanking( userId = userId, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/HideCreatorChannelDonationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/HideCreatorChannelDonationService.kt new file mode 100644 index 00000000..65e9684b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/HideCreatorChannelDonationService.kt @@ -0,0 +1,23 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.donation.application + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.HideCreatorChannelDonationPort +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class HideCreatorChannelDonationService( + private val hideCreatorChannelDonationPort: HideCreatorChannelDonationPort +) { + @Transactional + fun hide(creatorId: Long, donationId: Long, actor: Member) { + if (actor.role != MemberRole.CREATOR || actor.id != creatorId) { + throw SodaException(messageKey = "common.error.access_denied") + } + if (!hideCreatorChannelDonationPort.hideOwnedDonation(creatorId, donationId)) { + throw SodaException(messageKey = "common.error.invalid_request") + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationTab.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationTab.kt index 3ead9d17..d987f69c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationTab.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationTab.kt @@ -19,10 +19,12 @@ data class CreatorChannelDonationRanking( ) data class CreatorChannelDonation( + val donationId: Long?, val memberId: Long, val nickname: String, val profileImageUrl: String, val can: Int, val message: String, - val createdAt: LocalDateTime + val createdAt: LocalDateTime, + val isSecret: Boolean ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt index 3f50af07..736b999d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt @@ -35,11 +35,13 @@ data class CreatorChannelDonationCreatorRecord( ) data class CreatorChannelDonationRecord( + val donationId: Long, val memberId: Long, val nickname: String, val profileImagePath: String?, val can: Int, val message: String?, val createdAt: LocalDateTime, + val isSecret: Boolean, val isLocalizedName: Boolean = false ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/HideCreatorChannelDonationPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/HideCreatorChannelDonationPort.kt new file mode 100644 index 00000000..252aa9de --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/HideCreatorChannelDonationPort.kt @@ -0,0 +1,5 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out + +interface HideCreatorChannelDonationPort { + fun hideOwnedDonation(creatorId: Long, donationId: Long): Boolean +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt index 36ab9621..6e653e15 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt @@ -195,12 +195,14 @@ class DefaultCreatorChannelHomeQueryRepository( .select( Projections.constructor( CreatorChannelDonationRecord::class.java, + channelDonationMessage.id, channelDonationMessage.member.id, donorName.displayName, channelDonationMessage.member.profileImage, channelDonationMessage.can, channelDonationMessage.additionalMessage.coalesce(""), channelDonationMessage.createdAt, + channelDonationMessage.isSecret, donorName.name.id.isNotNull ) ) @@ -209,9 +211,9 @@ class DefaultCreatorChannelHomeQueryRepository( .leftJoin(donorName.name).on(donorName.condition) .where( channelDonationMessage.creator.id.eq(creatorId), + channelDonationMessage.isHidden.isFalse, channelDonationMessage.createdAt.goe(start), - channelDonationMessage.createdAt.lt(end), - donationVisibilityCondition(creatorId, viewerId) + channelDonationMessage.createdAt.lt(end) ) .orderBy(channelDonationMessage.createdAt.desc(), channelDonationMessage.id.desc()) .limit(limit.toLong()) @@ -737,16 +739,6 @@ class DefaultCreatorChannelHomeQueryRepository( .not() } - private fun donationVisibilityCondition(creatorId: Long, viewerId: Long?): BooleanExpression? { - return if (viewerId == null) { - channelDonationMessage.isSecret.isFalse - } else if (viewerId == creatorId) { - null - } else { - channelDonationMessage.isSecret.isFalse.or(channelDonationMessage.member.id.eq(viewerId)) - } - } - private fun seriesContentStats( seriesIds: List, now: LocalDateTime, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt index 1476ed04..6869fa6d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt @@ -102,7 +102,8 @@ class CreatorChannelHomeQueryService( effectiveViewerGender = effectiveViewerGender )?.toDomain(), latestAudioContent = latestAudioContent, - channelDonations = queryPort.findChannelDonations(creatorId, viewerId, now, lang = lang).map { it.toDomain() }, + channelDonations = queryPort.findChannelDonations(creatorId, viewerId, now, lang = lang) + .map { it.toDomain(creatorId, viewer, lang) }, notices = communityQueryService.findHomeCommunityPosts( creatorId = creatorId, viewerId = viewerId, @@ -196,14 +197,32 @@ class CreatorChannelHomeQueryService( isRented = isRented ) - private fun CreatorChannelDonationRecord.toDomain() = CreatorChannelDonation( - memberId = memberId, - nickname = if (isLocalizedName) nickname else nickname.removeDeletedNicknamePrefix(), - profileImageUrl = profileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(), - can = can, - message = message, - createdAt = createdAt - ) + private fun CreatorChannelDonationRecord.toDomain(creatorId: Long, viewer: Member, lang: Lang): CreatorChannelDonation { + val viewerId = viewer.id!! + val isMasked = isSecret && viewerId != creatorId && viewerId != memberId + return CreatorChannelDonation( + donationId = donationId.takeIf { viewer.role == MemberRole.CREATOR && viewerId == creatorId }, + memberId = memberId, + nickname = when { + isMasked -> messageSource.getMessage("explorer.channel_donation.anonymous.nickname", lang).orEmpty() + isLocalizedName -> nickname + else -> nickname.removeDeletedNicknamePrefix() + }, + profileImageUrl = if (isMasked) { + "$cloudFrontHost/profile/anonymous-profile.png" + } else { + profileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl() + }, + can = can, + message = if (isMasked) { + messageSource.getMessage("explorer.channel_donation.anonymous.message", lang).orEmpty() + } else { + message + }, + createdAt = createdAt, + isSecret = isMasked + ) + } private fun CreatorChannelScheduleRecord.toDomain() = CreatorChannelSchedule( scheduledAt = scheduledAt, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHome.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHome.kt index e36d61da..b95bac01 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHome.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHome.kt @@ -43,12 +43,14 @@ data class CreatorChannelLive( ) data class CreatorChannelDonation( + val donationId: Long?, val memberId: Long, val nickname: String, val profileImageUrl: String, val can: Int, val message: String, - val createdAt: LocalDateTime + val createdAt: LocalDateTime, + val isSecret: Boolean ) data class CreatorChannelSchedule( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt index 5325e485..d26d33dc 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt @@ -114,12 +114,14 @@ data class CreatorChannelAudioContentRecord( ) data class CreatorChannelDonationRecord( + val donationId: Long, val memberId: Long, val nickname: String, val profileImagePath: String?, val can: Int, val message: String, val createdAt: LocalDateTime, + val isSecret: Boolean, val isLocalizedName: Boolean = false ) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerControllerIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerControllerIntegrationTest.kt index 154b545e..00424ca6 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerControllerIntegrationTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerControllerIntegrationTest.kt @@ -11,6 +11,9 @@ 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.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.NullSource +import org.junit.jupiter.params.provider.ValueSource import org.mockito.Mockito import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc @@ -24,6 +27,7 @@ import org.springframework.test.context.ContextConfiguration import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import java.io.InputStream @@ -117,6 +121,72 @@ class AdminEventBannerControllerIntegrationTest @Autowired constructor( .andExpect(jsonPath("$.data[*].lang").value(containsInAnyOrder("KO", "EN", "JA"))) } + @ParameterizedTest + @NullSource + @ValueSource(booleans = [true, false]) + @DisplayName("관리자 이벤트 제목만 수정하면 생략한 링크와 성인 여부 등 나머지 필드를 보존한다") + fun shouldPreserveOmittedFieldsWhenUpdatingOnlyTitle(isAdult: Boolean?) { + val event = saveEventForUpdate(isAdult) + + mockMvc.perform(updateRequest(event).param("title", "updated")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + + assertEquals(event.copy(title = "updated"), eventRepository.findById(requireNotNull(event.id)).orElseThrow()) + } + + @ParameterizedTest + @ValueSource(booleans = [true, false]) + @DisplayName("관리자 이벤트 수정은 명시한 링크와 성인 여부를 저장한다") + fun shouldPersistExplicitLinkAndAdultFlag(isAdult: Boolean) { + val event = saveEventForUpdate(!isAdult) + + mockMvc.perform( + updateRequest(event) + .param("link", "https://event.test/updated") + .param("isAdult", isAdult.toString()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + + assertEquals( + event.copy(link = "https://event.test/updated", isAdult = isAdult), + eventRepository.findById(requireNotNull(event.id)).orElseThrow() + ) + } + + @ParameterizedTest + @ValueSource(strings = ["", " "]) + @DisplayName("관리자 이벤트 수정은 빈 링크를 null로 저장하고 생략한 성인 여부를 보존한다") + fun shouldClearBlankLinkWithoutChangingOmittedAdultFlag(link: String) { + val event = saveEventForUpdate() + + mockMvc.perform(updateRequest(event).param("link", link)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + + assertEquals(event.copy(link = null), eventRepository.findById(requireNotNull(event.id)).orElseThrow()) + } + + private fun updateRequest(event: Event) = + put("/admin/event/banner") + .param("id", event.id.toString()) + .with(user("admin").roles("ADMIN")) + + private fun saveEventForUpdate(isAdult: Boolean? = true): Event { + return eventRepository.saveAndFlush( + activeEvent("original", Lang.EN).apply { + popupImage = "original-popup.png" + link = "https://event.test/original" + this.isAdult = isAdult + isPopup = true + isActive = false + startDate = LocalDateTime.of(2099, 1, 1, 0, 0, 0) + endDate = LocalDateTime.of(2099, 1, 2, 23, 59, 59) + } + ) + } + private fun createRequest() = multipart("/admin/event/banner") .file(MockMultipartFile("thumbnail", "thumbnail.png", "image/png", "thumbnail".toByteArray())) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/event/EventControllerIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/event/EventControllerIntegrationTest.kt index 98f20c63..79845357 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/event/EventControllerIntegrationTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/event/EventControllerIntegrationTest.kt @@ -5,19 +5,25 @@ import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberAdapter import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +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.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.NullSource +import org.junit.jupiter.params.provider.ValueSource import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user import org.springframework.test.annotation.DirtiesContext import org.springframework.test.context.ContextConfiguration import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import java.time.LocalDateTime @@ -108,6 +114,70 @@ class EventControllerIntegrationTest @Autowired constructor( .andExpect(status().isUnauthorized) } + @ParameterizedTest + @NullSource + @ValueSource(booleans = [true, false]) + @DisplayName("이벤트 제목만 수정하면 생략한 링크와 성인 여부 등 나머지 필드를 보존한다") + fun shouldPreserveOmittedFieldsWhenUpdatingOnlyTitle(isAdult: Boolean?) { + val event = saveEventForUpdate(isAdult) + + mockMvc.perform(updateRequest(event).param("title", "updated")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + + assertEquals(event.copy(title = "updated"), eventRepository.findById(requireNotNull(event.id)).orElseThrow()) + } + + @ParameterizedTest + @ValueSource(booleans = [true, false]) + @DisplayName("이벤트 수정은 명시한 링크와 성인 여부를 저장한다") + fun shouldPersistExplicitLinkAndAdultFlag(isAdult: Boolean) { + val event = saveEventForUpdate(!isAdult) + + mockMvc.perform( + updateRequest(event) + .param("link", "https://event.test/updated") + .param("isAdult", isAdult.toString()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + + assertEquals( + event.copy(link = "https://event.test/updated", isAdult = isAdult), + eventRepository.findById(requireNotNull(event.id)).orElseThrow() + ) + } + + @ParameterizedTest + @ValueSource(strings = ["", " "]) + @DisplayName("이벤트 수정은 빈 링크 원문을 저장하고 생략한 성인 여부를 보존한다") + fun shouldPersistBlankLinkWithoutChangingOmittedAdultFlag(link: String) { + val event = saveEventForUpdate() + + mockMvc.perform(updateRequest(event).param("link", link)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + + assertEquals(event.copy(link = link), eventRepository.findById(requireNotNull(event.id)).orElseThrow()) + } + + private fun updateRequest(event: Event) = + put("/event") + .param("id", event.id.toString()) + .with(user("admin").roles("ADMIN")) + + private fun saveEventForUpdate(isAdult: Boolean? = true): Event { + return eventRepository.saveAndFlush( + saveControllerEvent("original", Lang.EN).apply { + link = "https://event.test/original" + this.isAdult = isAdult + isActive = false + startDate = LocalDateTime.of(2099, 1, 1, 0, 0, 0) + endDate = LocalDateTime.of(2099, 1, 2, 23, 59, 59) + } + ) + } + private fun saveControllerEvent(seed: String, lang: Lang): Event { return eventRepository.saveAndFlush( Event( diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceIntegrationTest.kt index 0a527dcb..994084b9 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceIntegrationTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceIntegrationTest.kt @@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.member.auth.Auth import kr.co.vividnext.sodalive.member.auth.AuthRepository +import kr.co.vividnext.sodalive.support.hasForcedCountryMapping import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNotNull @@ -30,10 +31,6 @@ class MemberContentPreferenceIntegrationTest @Autowired constructor( private val preferenceRepository: MemberContentPreferenceRepository, private val entityManager: EntityManager ) { - companion object { - private val FORCED_MEMBER_IDS = setOf(16L, 17L, 29721L, 32050L, 37543L, 40850L) - } - private lateinit var service: MemberContentPreferenceService private lateinit var countryContext: CountryContext @@ -273,7 +270,7 @@ class MemberContentPreferenceIntegrationTest @Autowired constructor( var index = 0 while (true) { val candidate = saveMember("$seed-$index") - if (!FORCED_MEMBER_IDS.contains(candidate.id)) { + if (!candidate.hasForcedCountryMapping()) { return candidate } index++ diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/support/CountryMemberFixtures.kt b/src/test/kotlin/kr/co/vividnext/sodalive/support/CountryMemberFixtures.kt new file mode 100644 index 00000000..d0c5bf0f --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/support/CountryMemberFixtures.kt @@ -0,0 +1,10 @@ +package kr.co.vividnext.sodalive.support + +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.contentpreference.resolveCountryCodeWithForcedMapping + +// Fixture selection only: two distinct headers detect forced countries without copying member IDs. +internal fun Member.hasForcedCountryMapping(): Boolean { + return resolveCountryCodeWithForcedMapping(this, "KR") != "KR" || + resolveCountryCodeWithForcedMapping(this, "JP") != "JP" +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/support/CountryMemberFixturesTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/support/CountryMemberFixturesTest.kt new file mode 100644 index 00000000..e8f3bf74 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/support/CountryMemberFixturesTest.kt @@ -0,0 +1,22 @@ +package kr.co.vividnext.sodalive.support + +import kr.co.vividnext.sodalive.member.Member +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource + +class CountryMemberFixturesTest { + @ParameterizedTest + @CsvSource("16, true", "17958, true", "29721, true", "100, false") + @DisplayName("일반 회원 fixture는 강제 국가 매핑 회원과 기존 목록에서 누락된 회원을 제외한다") + fun shouldIdentifyForcedCountryMappingsForFixtureSelection(memberId: Long, expected: Boolean) { + val member = Member( + email = "member$memberId@test.com", + password = "password", + nickname = "member$memberId" + ).apply { id = memberId } + + assertEquals(expected, member.hasForcedCountryMapping()) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt index 9837f9ee..d965cc50 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt @@ -19,6 +19,7 @@ import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.auth.Auth import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import kr.co.vividnext.sodalive.support.hasForcedCountryMapping import kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshot import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType import org.junit.jupiter.api.DisplayName @@ -84,7 +85,7 @@ class AudioRecommendationEndToEndTest @Autowired constructor( } else { val role = if (viewerType == "ADMIN") MemberRole.ADMIN else MemberRole.USER var candidate = saveMember("audio-event-viewer", role) - while (candidate.id in setOf(16L, 17L, 17958L, 44144L, 29721L, 32050L, 37543L, 40850L)) { + while (candidate.hasForcedCountryMapping()) { candidate = saveMember("audio-event-viewer-${candidate.id}", role) } candidate diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt index 540e730a..25c8c495 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt @@ -28,6 +28,7 @@ import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequ import org.springframework.security.web.SecurityFilterChain import org.springframework.security.web.authentication.HttpStatusEntryPoint import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status @@ -83,6 +84,31 @@ class CreatorChannelDonationControllerTest @Autowired constructor( .andExpect(status().isUnauthorized) } + @Test + @DisplayName("후원 숨김 DELETE는 body 없이 경로 ID와 인증 회원을 전달하고 data 없는 성공을 반환한다") + fun shouldHideDonationWithoutRequestBodyOrResponseData() { + val creator = createMember(id = 1L).apply { role = MemberRole.CREATOR } + + mockMvc.perform( + delete("/api/v2/creator-channels/1/donations/7") + .with(user(MemberAdapter(creator))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").doesNotExist()) + + Mockito.verify(facade).hide(1L, 7L, creator) + } + + @Test + @DisplayName("후원 숨김 DELETE는 비회원 요청을 거부한다") + fun shouldRejectAnonymousHideDonationRequest() { + mockMvc.perform(delete("/api/v2/creator-channels/1/donations/7").with(anonymous())) + .andExpect(status().isUnauthorized) + + Mockito.verifyNoInteractions(facade) + } + @Test @DisplayName("크리에이터 채널 후원 탭 조회는 query parameter를 facade에 전달하고 성공 응답을 반환한다") fun shouldReturnCreatorChannelDonationTabForAuthenticatedMember() { @@ -116,6 +142,9 @@ class CreatorChannelDonationControllerTest @Autowired constructor( .andExpect(jsonPath("$.data.donations[0].can").value(50)) .andExpect(jsonPath("$.data.donations[0].message").value("thanks")) .andExpect(jsonPath("$.data.donations[0].createdAtUtc").value("2026-06-21T03:30:00Z")) + .andExpect(jsonPath("$.data.donations[0].isSecret").value(true)) + .andExpect(jsonPath("$.data.donations[0].memberId").doesNotExist()) + .andExpect(jsonPath("$.data.donations[0].id").doesNotExist()) .andExpect(jsonPath("$.data.page").value(1)) .andExpect(jsonPath("$.data.size").value(20)) .andExpect(jsonPath("$.data.hasNext").value(false)) @@ -163,11 +192,13 @@ class CreatorChannelDonationControllerTest @Autowired constructor( ), donations = listOf( CreatorChannelDonationResponse( + donationId = null, nickname = "donor", profileImageUrl = "https://cdn.test/donor.png", can = 50, message = "thanks", - createdAtUtc = "2026-06-21T03:30:00Z" + createdAtUtc = "2026-06-21T03:30:00Z", + isSecret = true ) ), page = page, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationEndToEndTest.kt index f9084953..6beb6696 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationEndToEndTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationEndToEndTest.kt @@ -1,23 +1,46 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.`in`.web +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.admin.calculate.channelDonation.AdminChannelDonationCalculateQueryRepository import kr.co.vividnext.sodalive.can.payment.PaymentGateway import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.can.use.UseCan import kr.co.vividnext.sodalive.can.use.UseCanCalculate import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.jwt.TokenProvider import kr.co.vividnext.sodalive.member.DonationRankingPeriod import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberAdapter import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.token.MemberTokenRepository import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicy +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorLocalizedNameEntity +import org.junit.jupiter.api.AfterEach +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 +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user +import org.springframework.test.annotation.DirtiesContext import org.springframework.test.context.ContextConfiguration import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get @@ -25,9 +48,11 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPat import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.transaction.support.TransactionTemplate import java.time.LocalDateTime +import java.time.ZoneOffset import javax.persistence.EntityManager @SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = [ "cloud.aws.cloud-front.host=https://cdn.test", "spring.cache.type=none", @@ -36,11 +61,279 @@ import javax.persistence.EntityManager ) @AutoConfigureMockMvc @ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) class CreatorChannelDonationEndToEndTest @Autowired constructor( private val mockMvc: MockMvc, private val entityManager: EntityManager, - private val transactionTemplate: TransactionTemplate + private val transactionTemplate: TransactionTemplate, + private val objectMapper: ObjectMapper, + private val restTemplate: TestRestTemplate, + private val tokenProvider: TokenProvider, + private val tokenRepository: MemberTokenRepository, + private val jdbcTemplate: JdbcTemplate, + private val settlementRepository: AdminChannelDonationCalculateQueryRepository, + private val redisTemplate: RedisTemplate ) { + private val tokenMemberIds = mutableSetOf() + + @AfterEach + fun removeIssuedTokens() { + tokenMemberIds.forEach { tokenRepository.deleteById(it) } + } + + @ParameterizedTest + @ValueSource(booleans = [false, true]) + @DisplayName("소유 CREATOR의 실제 HTTP DELETE는 공개·비밀 한 건만 숨기고 원본과 재무 기록을 보존한다") + fun shouldAllowOnlyOwningCreatorToHideDonationThroughHttp(isSecret: Boolean) { + val fixture = createHideFixture() + val before = readHideRecords(fixture) + val donationId = fixture.donationIds[if (isSecret) 1 else 0] + + deleteThroughHttp(fixture.creator.id!!, donationId, fixture.creator, true) + + assertEquals(before, readHideRecords(fixture)) + assertHiddenDonations(fixture, setOf(donationId)) + } + + @ParameterizedTest + @ValueSource(booleans = [false, true]) + @DisplayName("실제 HTTP 숨김은 단일 공개·비밀 후원의 legacy 응답과 양수 랭킹·정산을 그대로 보존한다") + fun shouldPreserveLegacyRankingAndSettlementAfterHidingDonationThroughHttp(isSecret: Boolean) { + val createdAt = CreatorChannelDonationQueryPolicy() + .currentKstMonthRange(LocalDateTime.now(ZoneOffset.UTC)).startInclusiveUtc + .plusHours(2).plusSeconds(if (isSecret) 1 else 0) + val fixture = transactionTemplate.execute { + val prefix = "preservation-$isSecret" + val creator = saveMember("$prefix-creator", MemberRole.CREATOR) + val donor = saveMember("$prefix-donor", MemberRole.USER) + val outsider = saveMember("$prefix-outsider", MemberRole.USER) + val otherCreator = saveMember("$prefix-other-creator", MemberRole.CREATOR) + val admin = saveMember("$prefix-admin", MemberRole.ADMIN) + val donation = saveDonation(creator, donor, 500, createdAt, isSecret, "preserved contribution") + val otherDonation = saveDonation(otherCreator, donor, 400, createdAt, false, "other channel original") + val usage = saveRankingDonation(donor, creator, can = 450, rewardCan = 50, isSecret = isSecret) + entityManager.flush() + updateCreatedAt("UseCan", usage.id!!, createdAt) + updateCreatedAt("UseCanCalculate", usage.useCanCalculates.single().id!!, createdAt) + entityManager.clear() + val stored = entityManager.find(ChannelDonationMessage::class.java, donation.id!!) + val storedUsage = entityManager.find(UseCan::class.java, usage.id!!) + val calculation = storedUsage.useCanCalculates.single() + assertEquals(stored.can, storedUsage.can + storedUsage.rewardCan) + assertEquals(stored.member!!.id, storedUsage.member!!.id) + assertEquals(stored.isSecret, storedUsage.isSecret) + assertEquals(createdAt, stored.createdAt) + assertEquals(stored.createdAt, storedUsage.createdAt) + assertEquals(stored.can, calculation.can) + assertEquals(stored.creator!!.id, calculation.recipientCreatorId) + assertEquals(UseCanCalculateStatus.RECEIVED, calculation.status) + assertEquals(CanUsage.CHANNEL_DONATION, storedUsage.canUsage) + assertFalse(storedUsage.isRefund) + HideFixture(creator, donor, outsider, otherCreator, admin, listOf(donation.id!!), otherDonation.id!!) + }!! + val before = readHideRecords(fixture) + val path = "/api/v2/creator-channels/${fixture.creator.id}" + val legacyPath = "/explorer/profile/channel-donation?creatorId=${fixture.creator.id}&page=0&size=20" + val tabBefore = requestThroughHttp("$path/donations?page=0&size=20", HttpMethod.GET, fixture.creator)["data"] + val selected = tabBefore["donations"].single() + val donationId = selected["donationId"].asLong() + assertEquals(fixture.donationIds.single(), donationId) + assertEquals(500, selected["can"].asInt()) + assertFalse(selected["isSecret"].asBoolean()) + assertEquals(1, tabBefore["donationCount"].asInt()) + assertEquals( + tabBefore["donations"], + requestThroughHttp("$path/home", HttpMethod.GET, fixture.creator)["data"]["channelDonations"] + ) + val legacyBefore = requestThroughHttp(legacyPath, HttpMethod.GET, fixture.creator) + assertTrue(legacyBefore["success"].asBoolean()) + assertEquals(1, legacyBefore["data"]["totalCount"].asInt()) + assertEquals(donationId, legacyBefore["data"]["items"].single()["id"].asLong()) + val rankingsBefore = tabBefore["rankings"] + assertEquals(fixture.donor.id, rankingsBefore.single()["userId"].asLong()) + assertEquals(500, rankingsBefore.single()["donationCan"].asInt()) + val totalBefore = settlementRepository.getChannelDonationByCreatorTotal(createdAt, createdAt) + val settlementBefore = settlementRepository.getChannelDonationByCreator(createdAt, createdAt, 0, 20) + val settlement = settlementBefore.single().toResponseItem() + assertEquals(fixture.creator.nickname, settlement.creator) + assertEquals(1L, totalBefore.count) + assertEquals(500, totalBefore.totalCan) + assertEquals(1, settlement.count) + assertEquals(500, settlement.totalCan) + assertEquals(39695, settlement.settlementAmount) + assertEquals(38385, settlement.depositAmount) + println("SETTLEMENT EVIDENCE before creatorId=${fixture.creator.id} at=$createdAt total=$totalBefore item=$settlement") + + deleteThroughHttp(fixture.creator.id!!, donationId, fixture.creator, true) + + assertTrue(redisTemplate.delete("creator_donation_ranking_v2:${fixture.creator.id}:CUMULATIVE:0:8:true")) + val tabAfter = requestThroughHttp("$path/donations?page=0&size=20", HttpMethod.GET, fixture.creator)["data"] + assertEquals(0, tabAfter["donationCount"].asInt()) + assertTrue(tabAfter["donations"].isEmpty) + assertFalse(tabAfter["hasNext"].asBoolean()) + assertTrue(requestThroughHttp("$path/home", HttpMethod.GET, fixture.creator)["data"]["channelDonations"].isEmpty) + assertEquals(rankingsBefore, tabAfter["rankings"]) + assertEquals(legacyBefore, requestThroughHttp(legacyPath, HttpMethod.GET, fixture.creator)) + val totalAfter = settlementRepository.getChannelDonationByCreatorTotal(createdAt, createdAt) + val settlementAfter = settlementRepository.getChannelDonationByCreator(createdAt, createdAt, 0, 20) + assertEquals(totalBefore, totalAfter) + assertEquals(settlementBefore, settlementAfter) + assertEquals(settlement, settlementAfter.single().toResponseItem()) + println( + "SETTLEMENT EVIDENCE after creatorId=${fixture.creator.id} at=$createdAt " + + "total=$totalAfter item=${settlementAfter.single().toResponseItem()}" + ) + assertEquals(before, readHideRecords(fixture)) + assertHiddenDonations(fixture, setOf(donationId)) + } + + @ParameterizedTest + @ValueSource(strings = ["donor", "outsider", "otherCreator", "admin"]) + @DisplayName("후원자·일반 회원·다른 CREATOR·ADMIN의 실제 HTTP DELETE는 기록 변경 없이 거부된다") + fun shouldRejectNonOwnersThroughHttpWithoutChangingRecords(actorType: String) { + val fixture = createHideFixture() + val before = readHideRecords(fixture) + val actor = when (actorType) { + "donor" -> fixture.donor + "outsider" -> fixture.outsider + "otherCreator" -> fixture.otherCreator + else -> fixture.admin + } + + deleteThroughHttp(fixture.creator.id!!, fixture.donationIds.first(), actor, false, "권한이 없습니다.") + + assertEquals(before, readHideRecords(fixture)) + assertHiddenDonations(fixture, emptySet()) + } + + @Test + @DisplayName("실제 HTTP DELETE는 경로 소유권 위조·다른 채널 후원·미존재 ID를 동일한 원본을 유지하며 거부한다") + fun shouldRejectMismatchedOrMissingIdsThroughHttp() { + val fixture = createHideFixture() + val before = readHideRecords(fixture) + val creatorId = fixture.creator.id!! + val requests = listOf( + Triple(fixture.otherCreator.id!!, fixture.donationIds.first(), "권한이 없습니다."), + Triple(creatorId, fixture.otherDonationId, "잘못된 요청입니다."), + Triple(creatorId, Long.MAX_VALUE, "잘못된 요청입니다.") + ) + + requests.forEach { (pathCreatorId, donationId, message) -> + deleteThroughHttp(pathCreatorId, donationId, fixture.creator, false, message) + assertEquals(before, readHideRecords(fixture)) + assertHiddenDonations(fixture, emptySet()) + } + } + + @Test + @DisplayName("실제 HTTP 중복 숨김은 성공하고 이미 숨긴 항목의 비소유자 요청은 거부하며 재무를 보존한다") + fun shouldSucceedWhenHidingAgainThroughHttpWithoutChangingRecords() { + val fixture = createHideFixture() + val before = readHideRecords(fixture) + val donationId = fixture.donationIds[1] + + repeat(2) { + deleteThroughHttp(fixture.creator.id!!, donationId, fixture.creator, true) + assertEquals(before, readHideRecords(fixture)) + assertHiddenDonations(fixture, setOf(donationId)) + } + deleteThroughHttp(fixture.creator.id!!, donationId, fixture.donor, false, "권한이 없습니다.") + assertEquals(before, readHideRecords(fixture)) + assertHiddenDonations(fixture, setOf(donationId)) + } + + @Test + @DisplayName("인증 없는 실제 HTTP DELETE는 운영 인증 체인에서 401로 거부되고 기록을 보존한다") + fun shouldRejectUnauthenticatedDeleteThroughRealSecurityChain() { + val fixture = createHideFixture() + val before = readHideRecords(fixture) + val path = "/api/v2/creator-channels/${fixture.creator.id}/donations/${fixture.donationIds.first()}" + + val response = restTemplate.exchange(path, HttpMethod.DELETE, HttpEntity(HttpHeaders()), String::class.java) + + println("HTTP EVIDENCE DELETE $path actor=anonymous ${response.statusCodeValue}: ${response.body}") + assertEquals(HttpStatus.UNAUTHORIZED, response.statusCode) + assertEquals(before, readHideRecords(fixture)) + assertHiddenDonations(fixture, emptySet()) + } + + private fun deleteThroughHttp( + creatorId: Long, + donationId: Long, + actor: Member, + success: Boolean, + message: String? = null + ) { + val path = "/api/v2/creator-channels/$creatorId/donations/$donationId" + val body = requestThroughHttp(path, HttpMethod.DELETE, actor) + assertEquals(success, body["success"].asBoolean(), body.toString()) + assertTrue(body["data"].isNull, body.toString()) + if (message != null) assertEquals(message, body["message"].asText()) + } + + private fun requestThroughHttp(path: String, method: HttpMethod, actor: Member): JsonNode { + val principal = MemberAdapter(actor) + val token = tokenProvider.createToken( + UsernamePasswordAuthenticationToken(principal, "", principal.authorities), + actor.id!! + ) + tokenMemberIds.add(actor.id!!) + val headers = HttpHeaders().apply { + setBearerAuth(token) + set(HttpHeaders.ACCEPT_LANGUAGE, "ko") + } + val response = restTemplate.exchange(path, method, HttpEntity(headers), String::class.java) + println("HTTP EVIDENCE $method $path actor=${actor.role} ${response.statusCodeValue}: ${response.body}") + assertEquals(HttpStatus.OK, response.statusCode, response.body) + return objectMapper.readTree(response.body) + } + + private fun createHideFixture(): HideFixture = transactionTemplate.execute { + val creator = saveMember("hide-http-creator", MemberRole.CREATOR) + val donor = saveMember("hide-http-donor", MemberRole.USER) + val outsider = saveMember("hide-http-outsider", MemberRole.USER) + val otherCreator = saveMember("hide-http-other-creator", MemberRole.CREATOR) + val admin = saveMember("hide-http-admin", MemberRole.ADMIN) + val createdAt = LocalDateTime.of(2026, 11, 5, 0, 0) + val donations = listOf( + saveDonation(creator, donor, 100, createdAt, false, "public original"), + saveDonation(creator, donor, 200, createdAt, true, "secret original"), + saveDonation(creator, donor, 300, createdAt, false, "untouched original") + ) + val otherDonation = saveDonation(otherCreator, donor, 400, createdAt, true, "other channel original") + saveRankingDonation(donor, creator, can = 450, rewardCan = 50) + entityManager.flush() + entityManager.clear() + HideFixture(creator, donor, outsider, otherCreator, admin, donations.map { it.id!! }, otherDonation.id!!) + }!! + + private fun readHideRecords(fixture: HideFixture): List>> { + val donations = jdbcTemplate.queryForList( + "select * from channel_donation_message where creator_id in (?, ?) order by id", + fixture.creator.id, + fixture.otherCreator.id + ).map { row -> row.filterKeys { !it.equals("is_hidden", ignoreCase = true) } } + val usages = jdbcTemplate.queryForList("select * from use_can where member_id = ? order by id", fixture.donor.id) + val calculations = jdbcTemplate.queryForList( + "select c.* from use_can_calculate c join use_can u on u.id = c.use_can_id where u.member_id = ? order by c.id", + fixture.donor.id + ) + assertEquals(fixture.donationIds.size + 1, donations.size) + assertEquals(1, usages.size) + assertEquals(1, calculations.size) + return listOf(donations, usages, calculations) + } + + private fun assertHiddenDonations(fixture: HideFixture, hiddenIds: Set) { + transactionTemplate.executeWithoutResult { + entityManager.clear() + (fixture.donationIds + fixture.otherDonationId).forEach { donationId -> + val stored = requireNotNull(entityManager.find(ChannelDonationMessage::class.java, donationId)) + assertEquals(donationId in hiddenIds, stored.isHidden) + } + } + } + @Test @DisplayName("후원 탭 API는 controller-service-repository를 거쳐 후원 목록과 랭킹을 반환한다") fun shouldReturnDonationTabThroughControllerServiceRepositoryAndLegacyRanking() { @@ -54,22 +347,28 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor( ) .andExpect(status().isOk) .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.donationCount").value(2)) + .andExpect(jsonPath("$.data.donationCount").value(3)) .andExpect(jsonPath("$.data.rankings.length()").value(1)) .andExpect(jsonPath("$.data.rankings[0].userId").value(fixture.viewer.id!!)) .andExpect(jsonPath("$.data.rankings[0].nickname").value(fixture.viewer.nickname)) .andExpect(jsonPath("$.data.rankings[0].profileImage").value("https://cdn.test/${fixture.viewer.profileImage}")) .andExpect(jsonPath("$.data.rankings[0].donationCan").value(0)) - .andExpect(jsonPath("$.data.donations.length()").value(2)) - .andExpect(jsonPath("$.data.donations[0].nickname").value("donation-e2e-success-viewer")) - .andExpect(jsonPath("$.data.donations[0].profileImageUrl").value("https://cdn.test/donation-e2e-success-viewer.png")) - .andExpect(jsonPath("$.data.donations[0].can").value(200)) - .andExpect(jsonPath("$.data.donations[0].message").value("own secret")) + .andExpect(jsonPath("$.data.donations.length()").value(3)) + .andExpect(jsonPath("$.data.donations[0].isSecret").value(true)) + .andExpect(jsonPath("$.data.donations[0].nickname").value("익명")) + .andExpect(jsonPath("$.data.donations[0].profileImageUrl").value("https://cdn.test/profile/anonymous-profile.png")) + .andExpect(jsonPath("$.data.donations[0].can").value(300)) + .andExpect(jsonPath("$.data.donations[0].message").value("크리에이터에게만 보여요")) .andExpect(jsonPath("$.data.donations[0].createdAtUtc").exists()) - .andExpect(jsonPath("$.data.donations[1].nickname").value("donation-e2e-success-other")) - .andExpect(jsonPath("$.data.donations[1].can").value(100)) - .andExpect(jsonPath("$.data.donations[1].message").value("public")) - .andExpect(jsonPath("$.data.donations[?(@.message == 'hidden')]").isEmpty) + .andExpect(jsonPath("$.data.donations[1].isSecret").value(false)) + .andExpect(jsonPath("$.data.donations[1].nickname").value("donation-e2e-success-viewer")) + .andExpect(jsonPath("$.data.donations[1].profileImageUrl").value("https://cdn.test/donation-e2e-success-viewer.png")) + .andExpect(jsonPath("$.data.donations[1].can").value(200)) + .andExpect(jsonPath("$.data.donations[1].message").value("own secret")) + .andExpect(jsonPath("$.data.donations[2].isSecret").value(false)) + .andExpect(jsonPath("$.data.donations[2].nickname").value("donation-e2e-success-other")) + .andExpect(jsonPath("$.data.donations[2].can").value(100)) + .andExpect(jsonPath("$.data.donations[2].message").value("public")) .andExpect(jsonPath("$.data.page").value(0)) .andExpect(jsonPath("$.data.size").value(20)) .andExpect(jsonPath("$.data.hasNext").value(false)) @@ -88,7 +387,7 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor( ) .andExpect(status().isOk) .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.donationCount").value(2)) + .andExpect(jsonPath("$.data.donationCount").value(3)) .andExpect(jsonPath("$.data.donations.length()").value(0)) .andExpect(jsonPath("$.data.page").value(1)) .andExpect(jsonPath("$.data.size").value(20)) @@ -110,7 +409,8 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor( .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.data.page").value(0)) .andExpect(jsonPath("$.data.size").value(50)) - .andExpect(jsonPath("$.data.donations.length()").value(2)) + .andExpect(jsonPath("$.data.donationCount").value(3)) + .andExpect(jsonPath("$.data.donations.length()").value(3)) } @Test @@ -125,8 +425,8 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor( .andExpect(status().isOk) .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.data.rankings.length()").value(0)) - .andExpect(jsonPath("$.data.donationCount").value(2)) - .andExpect(jsonPath("$.data.donations.length()").value(2)) + .andExpect(jsonPath("$.data.donationCount").value(3)) + .andExpect(jsonPath("$.data.donations.length()").value(3)) .andExpect(jsonPath("$.data.page").value(0)) .andExpect(jsonPath("$.data.size").value(20)) .andExpect(jsonPath("$.data.hasNext").value(false)) @@ -151,6 +451,354 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor( .andExpect(jsonPath("$.data.donations[?(@.message == 'hidden')]").isNotEmpty) } + @ParameterizedTest(name = "{0}, {1}") + @CsvSource( + "creator, ko", "donor, ko", "defaultDonor, ko", "outsider, ko", "otherCreator, ko", + "creator, en", "donor, en", "defaultDonor, en", "outsider, en", "otherCreator, en", + "creator, ja", "donor, ja", "defaultDonor, ja", "outsider, ja", "otherCreator, ja" + ) + @DisplayName("후원 탭은 비밀 후원을 ID로 마스킹하며 요청 언어와 원본 표시 및 저장값을 보존한다") + fun shouldMaskOnlyOtherMembersSecretDonationsThroughHttp(viewerRole: String, language: String) { + val fixture = transactionTemplate.execute { + val creator = saveMember("secret-tab-creator", MemberRole.CREATOR) + val donor = saveMember("deleted_secret-tab-donor", MemberRole.CREATOR) + val defaultDonor = saveMember("secret-tab-default-donor", MemberRole.USER).apply { profileImage = null } + val viewers = mapOf( + "creator" to creator, + "donor" to donor, + "defaultDonor" to defaultDonor, + "outsider" to saveMember("secret-tab-outsider", MemberRole.USER), + "otherCreator" to saveMember("secret-tab-other-creator", MemberRole.CREATOR) + ) + listOf(Lang.EN, Lang.JA).forEach { lang -> + entityManager.persist(CreatorLocalizedNameEntity(donor.id!!, lang, "deleted_localized-${lang.code}")) + } + val start = CreatorChannelDonationQueryPolicy().currentKstMonthRange(LocalDateTime.now()).startInclusiveUtc + val rows = listOf( + saveDonation(creator, donor, 100, start.plusMinutes(1), additionalMessage = "public thanks"), + saveDonation(creator, defaultDonor, 200, start.plusMinutes(2), additionalMessage = null), + saveDonation(creator, donor, 300, start.plusMinutes(3), true, "private thanks"), + saveDonation(creator, donor, 400, start.plusMinutes(4), true, null), + saveDonation(creator, donor, 500, start.plusMinutes(5), true, ""), + saveDonation(creator, defaultDonor, 600, start.plusMinutes(6), true, "other private thanks") + ) + entityManager.flush() + entityManager.clear() + val persistedRows = rows.map { entityManager.find(ChannelDonationMessage::class.java, it.id!!) } + persistedRows.forEach { row -> assertEquals(start.plusMinutes((row.can / 100).toLong()), row.createdAt) } + DonationFixture(creator.id!!, donor.id!!, viewers.getValue(viewerRole), persistedRows) + }!! + val result = mockMvc.perform( + get("/api/v2/creator-channels/${fixture.creatorId}/donations") + .header("Accept-Language", language) + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.donationCount").value(6)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + .andReturn() + val donations = objectMapper.readTree(result.response.contentAsByteArray)["data"]["donations"] + assertEquals(listOf(600, 500, 400, 300, 200, 100), donations.map { it["can"].asInt() }) + val anonymousNickname = mapOf("ko" to "익명", "en" to "Anonymous", "ja" to "匿名").getValue(language) + val anonymousMessage = mapOf( + "ko" to "크리에이터에게만 보여요", + "en" to "Only visible to the creator", + "ja" to "クリエイターにのみ表示されます" + ).getValue(language) + fixture.donations.sortedByDescending { it.can }.zip(donations).forEach { (stored, item) -> + val isOwner = fixture.viewer.role == MemberRole.CREATOR && fixture.viewer.id == fixture.creatorId + assertEquals( + setOf("nickname", "profileImageUrl", "can", "message", "createdAtUtc", "isSecret") + + if (isOwner) setOf("donationId") else emptySet(), + item.fieldNames().asSequence().toSet() + ) + if (isOwner) assertEquals(stored.id, item["donationId"]?.asLong()) + assertFalse(item.has("memberId")) + assertTrue(item["isSecret"].isBoolean) + assertEquals(stored.can, item["can"].asInt()) + assertEquals( + requireNotNull(stored.createdAt).atOffset(ZoneOffset.UTC).toInstant().toString(), + item["createdAtUtc"].asText() + ) + val masked = stored.isSecret && fixture.viewer.id != fixture.creatorId && fixture.viewer.id != stored.member?.id + assertEquals(masked, item["isSecret"].asBoolean()) + if (masked) { + assertEquals(anonymousNickname, item["nickname"].asText()) + assertEquals(anonymousMessage, item["message"].asText()) + assertEquals("https://cdn.test/profile/anonymous-profile.png", item["profileImageUrl"].asText()) + assertFalse(item.toString().contains("secret-tab-")) + assertFalse(item.toString().contains("localized-")) + assertFalse(item.toString().contains("private thanks")) + } else { + val isDonor = stored.member?.id == fixture.donorId + val nickname = if (isDonor) { + if (language == "ko") "secret-tab-donor" else "deleted_localized-$language" + } else { + "secret-tab-default-donor" + } + val profile = if (isDonor) "deleted_secret-tab-donor.png" else "profile/default-profile.png" + assertEquals(nickname, item["nickname"].asText()) + assertEquals(stored.additionalMessage.orEmpty(), item["message"].asText()) + assertEquals("https://cdn.test/$profile", item["profileImageUrl"].asText()) + } + } + transactionTemplate.execute { + entityManager.clear() + fixture.donations.forEach { stored -> + assertEquals(stored.isSecret, entityManager.find(ChannelDonationMessage::class.java, stored.id).isSecret) + } + } + } + + @Test + @DisplayName("실제 HTTP 후원 탭은 숨김 제외 후 20건과 hasNext를 채우며 전부 숨기면 0건을 반환한다") + fun shouldExcludeHiddenRowsFromDonationTabCountAndPagesForEveryViewerThroughHttp() { + val fixture = createHideFixture() + val rows = transactionTemplate.execute { + val start = CreatorChannelDonationQueryPolicy() + .currentKstMonthRange(LocalDateTime.now(ZoneOffset.UTC)).startInclusiveUtc + fixture.donationIds.forEach { id -> + entityManager.find(ChannelDonationMessage::class.java, id).isHidden = true + } + val donations = (1..25).map { index -> + saveDonation( + fixture.creator, + fixture.donor, + index, + start.plusHours(1), + index % 2 == 1, + "row$index", + isHidden = index >= 23 + ) + } + entityManager.flush() + entityManager.clear() + donations + }!! + val viewers = listOf(fixture.creator, fixture.donor, fixture.outsider, fixture.otherCreator, fixture.admin) + val path = "/api/v2/creator-channels/${fixture.creator.id}/donations" + viewers.forEach { viewer -> + listOf((22 downTo 3).toList(), listOf(2, 1), emptyList()).forEachIndexed { page, expected -> + val body = requestThroughHttp("$path?page=$page&size=20", HttpMethod.GET, viewer) + assertTrue(body["success"].asBoolean()) + val data = body["data"] + assertEquals(22, data["donationCount"].asInt()) + assertEquals(page, data["page"].asInt()) + assertEquals(20, data["size"].asInt()) + assertEquals(page == 0, data["hasNext"].asBoolean()) + assertEquals(expected, data["donations"].map { it["can"].asInt() }) + data["donations"].forEach { item -> + assertFalse(item.has("memberId")) + if (viewer.id == fixture.creator.id) { + assertEquals(rows[item["can"].asInt() - 1].id, item["donationId"]?.asLong()) + } else { + assertFalse(item.has("donationId")) + } + } + } + } + rows.take(2).forEach { deleteThroughHttp(fixture.creator.id!!, it.id!!, fixture.creator, true) } + viewers.forEach { viewer -> + val data = requestThroughHttp("$path?size=20", HttpMethod.GET, viewer)["data"] + assertEquals(20, data["donationCount"].asInt()) + assertEquals((22 downTo 3).toList(), data["donations"].map { it["can"].asInt() }) + assertFalse(data["hasNext"].asBoolean()) + } + transactionTemplate.executeWithoutResult { + rows.forEach { entityManager.find(ChannelDonationMessage::class.java, it.id!!).isHidden = true } + } + viewers.forEach { viewer -> + val data = requestThroughHttp(path, HttpMethod.GET, viewer)["data"] + assertEquals(0, data["donationCount"].asInt()) + assertTrue(data["donations"].isEmpty) + assertFalse(data["hasNext"].asBoolean()) + } + } + + @Test + @DisplayName("실제 HTTP에서 소유 CREATOR만 영속 후원 ID를 얻어 숨길 수 있다") + fun shouldExposeDonationIdOnlyToOwningCreator() { + val fixture = createHideFixture() + transactionTemplate.executeWithoutResult { + val start = CreatorChannelDonationQueryPolicy() + .currentKstMonthRange(LocalDateTime.now(ZoneOffset.UTC)).startInclusiveUtc + fixture.donationIds.forEachIndexed { index, id -> + updateCreatedAt("ChannelDonationMessage", id, start.plusMinutes(index.toLong())) + } + } + val path = "/api/v2/creator-channels/${fixture.creator.id}/donations" + val data = requestThroughHttp(path, HttpMethod.GET, fixture.creator)["data"] + assertEquals(fixture.donationIds.reversed(), data["donations"].map { it["donationId"]?.asLong() }) + val donationId = data["donations"][0]["donationId"].asLong() + deleteThroughHttp(fixture.creator.id!!, donationId, fixture.creator, true) + val after = requestThroughHttp(path, HttpMethod.GET, fixture.creator)["data"] + assertEquals(2, after["donationCount"].asInt()) + assertEquals(fixture.donationIds.dropLast(1).reversed(), after["donations"].map { it["donationId"].asLong() }) + } + + @Test + @DisplayName("실제 홈 HTTP 응답도 소유 CREATOR에만 탭과 동일한 영속 후원 ID를 제공한다") + fun shouldExposeHomeDonationIdOnlyToOwningCreator() { + val fixture = createHideFixture() + transactionTemplate.executeWithoutResult { + val start = CreatorChannelDonationQueryPolicy() + .currentKstMonthRange(LocalDateTime.now(ZoneOffset.UTC)).startInclusiveUtc + fixture.donationIds.forEachIndexed { index, id -> + updateCreatedAt("ChannelDonationMessage", id, start.plusMinutes(index.toLong())) + } + } + val path = "/api/v2/creator-channels/${fixture.creator.id}" + val home = requestThroughHttp("$path/home", HttpMethod.GET, fixture.creator)["data"]["channelDonations"] + val tab = requestThroughHttp("$path/donations", HttpMethod.GET, fixture.creator)["data"]["donations"] + assertEquals(fixture.donationIds.reversed(), home.map { it["donationId"]?.asLong() }) + assertEquals(tab, home) + val donationId = home[1]["donationId"].asLong() + deleteThroughHttp(fixture.creator.id!!, donationId, fixture.creator, true) + listOf(fixture.creator, fixture.donor, fixture.outsider, fixture.otherCreator, fixture.admin).forEach { viewer -> + val homeAfter = requestThroughHttp("$path/home", HttpMethod.GET, viewer)["data"]["channelDonations"] + val tabAfter = requestThroughHttp("$path/donations", HttpMethod.GET, viewer)["data"] + assertEquals(listOf(300, 100), homeAfter.map { it["can"].asInt() }) + assertEquals(tabAfter["donations"], homeAfter) + assertEquals(2, tabAfter["donationCount"].asInt()) + homeAfter.forEach { item -> + assertFalse(item.has("memberId")) + if (viewer.id == fixture.creator.id) { + assertTrue(item.has("donationId")) + } else { + assertFalse(item.has("donationId")) + } + } + } + } + + @ParameterizedTest + @ValueSource(booleans = [false, true]) + @DisplayName("실제 HTTP 홈은 숨김 제외 후 8건을 보충하고 공개·비밀 DELETE를 모든 역할에 반영한다") + fun shouldRefillHomeWithLatestEightVisibleDonationsAfterHiddenRowsAreExcludedThroughHttp(isSecret: Boolean) { + val fixture = createHideFixture() + val start = CreatorChannelDonationQueryPolicy() + .currentKstMonthRange(LocalDateTime.now(ZoneOffset.UTC)).startInclusiveUtc + val rows = transactionTemplate.execute { + fixture.donationIds.forEach { entityManager.find(ChannelDonationMessage::class.java, it).isHidden = true } + val donations = (1..12).map { index -> + saveDonation( + fixture.creator, + fixture.donor, + index, + start.plusHours((index / 2).toLong()), + index % 2 == 1, + "visible$index", + isHidden = index >= 10 + ) + } + entityManager.flush() + entityManager.clear() + donations + }!! + val path = "/api/v2/creator-channels/${fixture.creator.id}" + val viewers = listOf(fixture.creator, fixture.donor, fixture.outsider, fixture.otherCreator, fixture.admin) + viewers.forEach { viewer -> + val data = requestThroughHttp("$path/home", HttpMethod.GET, viewer)["data"] + assertFalse(data.has("donationCount")) + val items = data["channelDonations"] + assertEquals((9 downTo 2).toList(), items.map { it["can"].asInt() }) + items.forEach { item -> + val can = item["can"].asInt() + val secret = can % 2 == 1 + assertEquals( + start.plusHours((can / 2).toLong()).atOffset(ZoneOffset.UTC).toInstant().toString(), + item["createdAtUtc"].asText() + ) + assertFalse(item.has("memberId")) + if (viewer.id == fixture.creator.id) { + assertEquals(rows[can - 1].id, item["donationId"]?.asLong()) + } else { + assertFalse(item.has("donationId")) + } + val masked = secret && viewer.id != fixture.creator.id && viewer.id != fixture.donor.id + assertEquals(masked, item["isSecret"].asBoolean()) + assertEquals(if (masked) "익명" else fixture.donor.nickname, item["nickname"].asText()) + assertEquals(if (masked) "크리에이터에게만 보여요" else "visible$can", item["message"].asText()) + } + } + val ownerHome = requestThroughHttp("$path/home", HttpMethod.GET, fixture.creator)["data"]["channelDonations"] + val selectedCan = if (isSecret) 9 else 8 + val selected = ownerHome.first { it["can"].asInt() == selectedCan } + assertFalse(selected["isSecret"].asBoolean()) + assertEquals(selectedCan, selected["can"].asInt()) + assertEquals(rows[selectedCan - 1].id, selected["donationId"].asLong()) + deleteThroughHttp(fixture.creator.id!!, selected["donationId"].asLong(), fixture.creator, true) + val expected = if (isSecret) (8 downTo 1).toList() else listOf(9, 7, 6, 5, 4, 3, 2, 1) + viewers.forEach { viewer -> + val home = requestThroughHttp("$path/home", HttpMethod.GET, viewer)["data"]["channelDonations"] + val tab = requestThroughHttp("$path/donations", HttpMethod.GET, viewer)["data"] + assertEquals(expected, home.map { it["can"].asInt() }) + assertEquals(8, tab["donationCount"].asInt()) + assertFalse(tab["hasNext"].asBoolean()) + assertEquals(tab["donations"], home) + } + transactionTemplate.executeWithoutResult { + rows.forEach { entityManager.find(ChannelDonationMessage::class.java, it.id!!).isHidden = true } + } + viewers.forEach { viewer -> + val home = requestThroughHttp("$path/home", HttpMethod.GET, viewer)["data"]["channelDonations"] + assertTrue(home.isEmpty) + } + } + + @Test + @DisplayName("후원 탭은 KST 월 공개·비밀 전체를 최신순으로 페이지화하고 범위 밖에서도 count를 유지한다") + fun shouldPaginateAllCurrentMonthDonationsThroughHttp() { + val range = CreatorChannelDonationQueryPolicy().currentKstMonthRange(LocalDateTime.now()) + val start = range.startInclusiveUtc + val end = range.endExclusiveUtc + val fixture = transactionTemplate.execute { + val creator = saveMember("page-tab-creator", MemberRole.CREATOR) + val donor = saveMember("page-tab-donor", MemberRole.USER) + val viewer = saveMember("page-tab-viewer", MemberRole.USER) + saveDonation(creator, donor, 900, start.minusSeconds(1), true, "previous month") + saveDonation(creator, donor, 901, start, true, "start") + saveDonation(creator, donor, 902, end, true, "next month") + saveDonation(creator, donor, 903, end.minusSeconds(1), true, "last second") + (1..23).forEach { index -> + saveDonation(creator, donor, index, start.plusHours((index / 2).toLong()), index % 2 == 1, "row$index") + } + entityManager.flush() + entityManager.clear() + val rows = entityManager.createQuery( + "select d from ChannelDonationMessage d where d.creator.id = :creatorId", + ChannelDonationMessage::class.java + ).setParameter("creatorId", creator.id).resultList + DonationFixture(creator.id!!, donor.id!!, viewer, rows) + }!! + val persisted = fixture.donations.associateBy { it.can } + val expectedPages = listOf(listOf(903) + (23 downTo 5), listOf(4, 3, 2, 1, 901), emptyList()) + expectedPages.forEachIndexed { page, expectedCans -> + val result = mockMvc.perform( + get("/api/v2/creator-channels/${fixture.creatorId}/donations") + .param("page", page.toString()) + .param("size", "20") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.donationCount").value(25)) + .andExpect(jsonPath("$.data.page").value(page)) + .andExpect(jsonPath("$.data.size").value(20)) + .andExpect(jsonPath("$.data.hasNext").value(page == 0)) + .andReturn() + val items = objectMapper.readTree(result.response.contentAsByteArray)["data"]["donations"] + assertEquals(expectedCans, items.map { it["can"].asInt() }) + items.forEach { item -> + val stored = persisted.getValue(item["can"].asInt()) + assertEquals(stored.isSecret, item["isSecret"].asBoolean()) + assertEquals( + requireNotNull(stored.createdAt).atOffset(ZoneOffset.UTC).toInstant().toString(), + item["createdAtUtc"].asText() + ) + } + } + } + private fun createFixture(prefix: String, isVisibleDonationRank: Boolean): Fixture { return transactionTemplate.execute { val monthStart = CreatorChannelDonationQueryPolicy() @@ -204,9 +852,11 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor( can: Int, createdAt: LocalDateTime, isSecret: Boolean = false, - additionalMessage: String + additionalMessage: String?, + isHidden: Boolean = false ): ChannelDonationMessage { val donation = ChannelDonationMessage(can = can, isSecret = isSecret, additionalMessage = additionalMessage) + donation.isHidden = isHidden donation.creator = creator donation.member = donor entityManager.persist(donation) @@ -215,8 +865,14 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor( return donation } - private fun saveRankingDonation(donor: Member, creator: Member, can: Int, rewardCan: Int) { - val useCan = UseCan(CanUsage.CHANNEL_DONATION, can = can, rewardCan = rewardCan, isRefund = false) + private fun saveRankingDonation( + donor: Member, + creator: Member, + can: Int, + rewardCan: Int, + isSecret: Boolean = false + ): UseCan { + val useCan = UseCan(CanUsage.CHANNEL_DONATION, can = can, rewardCan = rewardCan, isRefund = false, isSecret = isSecret) useCan.member = donor entityManager.persist(useCan) @@ -228,6 +884,7 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor( calculate.useCan = useCan calculate.recipientCreatorId = creator.id entityManager.persist(calculate) + return useCan } private fun updateCreatedAt(entityName: String, id: Long, createdAt: LocalDateTime) { @@ -242,4 +899,21 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor( val creator: Member, val creatorId: Long ) + + private data class HideFixture( + val creator: Member, + val donor: Member, + val outsider: Member, + val otherCreator: Member, + val admin: Member, + val donationIds: List, + val otherDonationId: Long + ) + + private data class DonationFixture( + val creatorId: Long, + val donorId: Long, + val viewer: Member, + val donations: List + ) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacadeTest.kt index fd352035..69266ec5 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacadeTest.kt @@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto.CreatorChannelDonationTabResponse import kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryService +import kr.co.vividnext.sodalive.v2.creator.channel.donation.application.HideCreatorChannelDonationService import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonation import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationRanking import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationTab @@ -36,6 +37,7 @@ class CreatorChannelDonationFacadeTest { assertEquals(50, response.donations.first().can) assertEquals("thanks", response.donations.first().message) assertEquals("2026-06-21T03:30:00Z", response.donations.first().createdAtUtc) + assertTrue(response.donations.first().isSecret) assertEquals(1, response.page) assertEquals(20, response.size) assertTrue(response.hasNext) @@ -51,6 +53,12 @@ class CreatorChannelDonationFacadeTest { assertEquals(50, json["donations"][0]["can"].asInt()) assertEquals("thanks", json["donations"][0]["message"].asText()) assertEquals("2026-06-21T03:30:00Z", json["donations"][0]["createdAtUtc"].asText()) + assertTrue(json["donations"][0]["isSecret"].isBoolean) + assertTrue(json["donations"][0]["isSecret"].asBoolean()) + assertEquals( + setOf("nickname", "profileImageUrl", "can", "message", "createdAtUtc", "isSecret"), + json["donations"][0].fieldNames().asSequence().toSet() + ) assertTrue(json["hasNext"].asBoolean()) assertFalse(json.has("languageCode")) } @@ -61,7 +69,8 @@ class CreatorChannelDonationFacadeTest { val service = Mockito.mock(CreatorChannelDonationQueryService::class.java) val facade = CreatorChannelDonationFacade( service, - Mockito.mock(CreatorLocalizedNameService::class.java) + Mockito.mock(CreatorLocalizedNameService::class.java), + Mockito.mock(HideCreatorChannelDonationService::class.java) ) val viewer = createMember(id = 10L) val now = LocalDateTime.of(2026, 6, 21, 12, 0) @@ -86,6 +95,7 @@ class CreatorChannelDonationFacadeTest { assertEquals(3, response.donationCount) assertEquals(10L, response.rankings.first().userId) assertEquals("https://cdn.test/donor.png", response.donations.first().profileImageUrl) + assertTrue(response.donations.first().isSecret) assertEquals(1, response.page) assertEquals(20, response.size) assertTrue(response.hasNext) @@ -113,12 +123,14 @@ class CreatorChannelDonationFacadeTest { ), donations = listOf( CreatorChannelDonation( + donationId = null, memberId = 10L, nickname = "donor", profileImageUrl = "https://cdn.test/donor.png", can = 50, message = "thanks", - createdAt = LocalDateTime.of(2026, 6, 21, 3, 30) + createdAt = LocalDateTime.of(2026, 6, 21, 3, 30), + isSecret = true ) ), page = CreatorChannelPage(page = 1, size = 20), diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt index 6f62841b..8e43ff02 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.home +import com.fasterxml.jackson.databind.ObjectMapper import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre import kr.co.vividnext.sodalive.common.CountryContext import kr.co.vividnext.sodalive.content.AudioContent @@ -11,6 +12,7 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer import kr.co.vividnext.sodalive.live.room.LiveRoom import kr.co.vividnext.sodalive.live.room.visit.LiveRoomVisit @@ -19,14 +21,21 @@ import kr.co.vividnext.sodalive.member.MemberAdapter import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslation +import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorLocalizedNameEntity +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 +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource import org.mockito.Mockito import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user +import org.springframework.test.annotation.DirtiesContext import org.springframework.test.context.ContextConfiguration import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get @@ -46,10 +55,12 @@ import javax.persistence.EntityManager ) @AutoConfigureMockMvc @ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) class CreatorChannelHomeEndToEndTest @Autowired constructor( private val mockMvc: MockMvc, private val entityManager: EntityManager, - private val transactionTemplate: TransactionTemplate + private val transactionTemplate: TransactionTemplate, + private val objectMapper: ObjectMapper ) { @MockBean private lateinit var countryContext: CountryContext @@ -67,6 +78,7 @@ class CreatorChannelHomeEndToEndTest @Autowired constructor( .andExpect(jsonPath("$.data.currentLive.liveId").value(fixture.currentLiveId)) .andExpect(jsonPath("$.data.latestAudioContent.audioContentId").value(fixture.latestAudioId)) .andExpect(jsonPath("$.data.channelDonations[0].can").value(500)) + .andExpect(jsonPath("$.data.channelDonations[0].isSecret").value(false)) .andExpect(jsonPath("$.data.notices[0].postId").value(fixture.noticeId)) .andExpect(jsonPath("$.data.notices[0].isPinned").value(true)) .andExpect(jsonPath("$.data.notices[0].isCommentAvailable").value(true)) @@ -106,6 +118,173 @@ class CreatorChannelHomeEndToEndTest @Autowired constructor( .andExpect(jsonPath("$.data.communities[0].content").value("translated community")) } + @ParameterizedTest(name = "{0}, {1}") + @CsvSource( + "creator, ko", "donor, ko", "defaultDonor, ko", "outsider, ko", "otherCreator, ko", + "creator, en", "donor, en", "defaultDonor, en", "outsider, en", "otherCreator, en", + "creator, ja", "donor, ja", "defaultDonor, ja", "outsider, ja", "otherCreator, ja" + ) + @DisplayName("홈 비밀 후원은 본인과 채널 주인만 원본을 보고 타인은 요청 언어로 익명 표시한다") + fun shouldMaskOnlyOtherMembersSecretDonationsThroughHttp(viewerRole: String, language: String) { + val fixture = transactionTemplate.execute { + val creator = saveMember("secret-home-creator", MemberRole.CREATOR) + val donor = saveMember("deleted_secret-home-donor", MemberRole.CREATOR) + val defaultDonor = saveMember("secret-home-default-donor", MemberRole.USER).apply { profileImage = null } + val viewers = mapOf( + "creator" to creator, + "donor" to donor, + "defaultDonor" to defaultDonor, + "outsider" to saveMember("secret-home-outsider", MemberRole.USER), + "otherCreator" to saveMember("secret-home-other-creator", MemberRole.CREATOR) + ) + listOf(Lang.EN, Lang.JA).forEach { lang -> + entityManager.persist(CreatorLocalizedNameEntity(donor.id!!, lang, "deleted_localized-${lang.code}")) + } + val start = currentKstMonthStartUtc() + val rows = listOf( + saveDonation(creator, donor, start.plusMinutes(1), 100, false, "public thanks"), + saveDonation(creator, defaultDonor, start.plusMinutes(2), 200, false, null), + saveDonation(creator, donor, start.plusMinutes(3), 300, true, "private thanks"), + saveDonation(creator, donor, start.plusMinutes(4), 400, true, null), + saveDonation(creator, donor, start.plusMinutes(5), 500, true, ""), + saveDonation(creator, defaultDonor, start.plusMinutes(6), 600, true, "other private thanks") + ) + entityManager.flush() + entityManager.clear() + val persistedRows = rows.map { entityManager.find(ChannelDonationMessage::class.java, it.id!!) } + persistedRows.forEach { row -> assertEquals(start.plusMinutes((row.can / 100).toLong()), row.createdAt) } + DonationFixture(creator.id!!, donor.id!!, viewers.getValue(viewerRole), persistedRows) + } ?: throw IllegalStateException("donation fixture creation failed") + val result = mockMvc.perform( + get("/api/v2/creator-channels/${fixture.creatorId}/home") + .header("Accept-Language", language) + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.donationCount").doesNotExist()) + .andReturn() + val donations = objectMapper.readTree(result.response.contentAsByteArray)["data"]["channelDonations"] + assertEquals(listOf(600, 500, 400, 300, 200, 100), donations.map { it["can"].asInt() }) + val anonymousNickname = mapOf("ko" to "익명", "en" to "Anonymous", "ja" to "匿名").getValue(language) + val anonymousMessage = mapOf( + "ko" to "크리에이터에게만 보여요", + "en" to "Only visible to the creator", + "ja" to "クリエイターにのみ表示されます" + ).getValue(language) + fixture.donations.sortedByDescending { it.can }.zip(donations).forEach { (stored, item) -> + assertTrue(item.path("isSecret").isBoolean) + assertEquals(stored.can, item["can"].asInt()) + assertEquals( + requireNotNull(stored.createdAt).atOffset(ZoneOffset.UTC).toInstant().toString(), + item["createdAtUtc"].asText() + ) + assertEquals( + setOf("nickname", "profileImageUrl", "can", "message", "createdAtUtc", "isSecret") + + if (viewerRole == "creator") setOf("donationId") else emptySet(), + item.fieldNames().asSequence().toSet() + ) + if (viewerRole == "creator") assertEquals(stored.id, item["donationId"]?.asLong()) + assertFalse(item.has("memberId")) + val masked = stored.isSecret && fixture.viewer.id != fixture.creatorId && fixture.viewer.id != stored.member?.id + assertEquals(masked, item["isSecret"].asBoolean()) + if (masked) { + assertEquals(anonymousNickname, item["nickname"].asText()) + assertEquals(anonymousMessage, item["message"].asText()) + assertEquals("https://cdn.test/profile/anonymous-profile.png", item["profileImageUrl"].asText()) + assertFalse(item.toString().contains("secret-home-donor")) + assertFalse(item.toString().contains("localized-")) + assertFalse(item.toString().contains("private thanks")) + } else { + val isDonor = stored.member?.id == fixture.donorId + val nickname = if (isDonor) { + if (language == "ko") "secret-home-donor" else "deleted_localized-$language" + } else { + "secret-home-default-donor" + } + val profile = if (isDonor) "deleted_secret-home-donor.png" else "profile/default-profile.png" + assertEquals(nickname, item["nickname"].asText()) + assertEquals(stored.additionalMessage.orEmpty(), item["message"].asText()) + assertEquals("https://cdn.test/$profile", item["profileImageUrl"].asText()) + } + } + transactionTemplate.execute { + entityManager.clear() + fixture.donations.forEach { stored -> + assertEquals(stored.isSecret, entityManager.find(ChannelDonationMessage::class.java, stored.id).isSecret) + } + } + } + + @Test + @DisplayName("홈은 KST 월 시작을 포함하고 다음 월을 제외하며 공개·비밀 전체에서 최신 8건을 반환한다") + fun shouldKeepKstMonthBoundsAndLatestEightMixedDonationsThroughHttp() { + fun nextMonthStartUtc(monthStartUtc: LocalDateTime): LocalDateTime = + monthStartUtc.plusHours(9).plusMonths(1).minusHours(9) + + assertEquals( + LocalDateTime.of(2026, 12, 31, 15, 0), + nextMonthStartUtc(LocalDateTime.of(2026, 11, 30, 15, 0)) + ) + val start = currentKstMonthStartUtc() + val end = nextMonthStartUtc(start) + val members = transactionTemplate.execute { + val creator = saveMember("month-home-creator", MemberRole.CREATOR) + val donor = saveMember("month-home-donor", MemberRole.USER) + val viewer = saveMember("month-home-viewer", MemberRole.USER) + saveDonation(creator, donor, start.minusSeconds(1), 900, true) + saveDonation(creator, donor, start, 901, true) + saveDonation(creator, donor, end, 902, true) + saveDonation(creator, donor, end.minusSeconds(1), 903, false) + Triple(creator, donor, viewer) + } ?: throw IllegalStateException("month fixture creation failed") + + val boundaryResult = mockMvc.perform( + get("/api/v2/creator-channels/${members.first.id}/home").with(user(MemberAdapter(members.third))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.channelDonations[0].createdAtUtc").value("${end.minusSeconds(1)}Z")) + .andExpect(jsonPath("$.data.channelDonations[1].createdAtUtc").value("$start:00Z")) + .andExpect(jsonPath("$.data.channelDonations[1].isSecret").value(true)) + .andReturn() + val boundaryItems = objectMapper.readTree(boundaryResult.response.contentAsByteArray)["data"]["channelDonations"] + assertEquals(listOf(903, 901), boundaryItems.map { it["can"].asInt() }) + + transactionTemplate.execute { + val creator = entityManager.find(Member::class.java, members.first.id) + val donor = entityManager.find(Member::class.java, members.second.id) + (1..10).forEach { index -> + saveDonation(creator, donor, start.plusHours((index / 2).toLong()), index, index % 2 == 1) + } + entityManager.flush() + entityManager.clear() + } + + val result = mockMvc.perform( + get("/api/v2/creator-channels/${members.first.id}/home").with(user(MemberAdapter(members.third))) + ) + .andExpect(status().isOk) + .andReturn() + val items = objectMapper.readTree(result.response.contentAsByteArray)["data"]["channelDonations"] + assertEquals(listOf(903, 10, 9, 8, 7, 6, 5, 4), items.map { it["can"].asInt() }) + assertEquals(listOf(false, false, true, false, true, false, true, false), items.map { it["isSecret"].asBoolean() }) + val persisted = transactionTemplate.execute { + entityManager.createQuery( + "select d from ChannelDonationMessage d where d.creator.id = :creatorId order by d.createdAt desc, d.id desc", + ChannelDonationMessage::class.java + ).setParameter("creatorId", members.first.id).resultList.associateBy { it.can } + }!! + items.forEach { item -> + val stored = persisted.getValue(item["can"].asInt()) + assertEquals( + requireNotNull(stored.createdAt).atOffset(ZoneOffset.UTC).toInstant().toString(), + item["createdAtUtc"].asText() + ) + } + } + + private fun currentKstMonthStartUtc(): LocalDateTime = + LocalDateTime.now(ZoneOffset.UTC).plusHours(9).toLocalDate().withDayOfMonth(1).atStartOfDay().minusHours(9) + private fun createFixture(): Fixture { return transactionTemplate.execute { val now = LocalDateTime.now(ZoneOffset.UTC) @@ -231,8 +410,15 @@ class CreatorChannelHomeEndToEndTest @Autowired constructor( return order } - private fun saveDonation(creator: Member, donor: Member, createdAt: LocalDateTime): ChannelDonationMessage { - val donation = ChannelDonationMessage(can = 500, isSecret = false, additionalMessage = "e2e thanks") + private fun saveDonation( + creator: Member, + donor: Member, + createdAt: LocalDateTime, + can: Int = 500, + isSecret: Boolean = false, + message: String? = "e2e thanks" + ): ChannelDonationMessage { + val donation = ChannelDonationMessage(can = can, isSecret = isSecret, additionalMessage = message) donation.creator = creator donation.member = donor entityManager.persist(donation) @@ -301,6 +487,13 @@ class CreatorChannelHomeEndToEndTest @Autowired constructor( .executeUpdate() } + private data class DonationFixture( + val creatorId: Long, + val donorId: Long, + val viewer: Member, + val donations: List + ) + private data class Fixture( val viewer: Member, val creatorId: Long, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt index 8b305a21..a77fb6b9 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt @@ -139,7 +139,7 @@ class CreatorChannelHomeControllerTest @Autowired constructor( .andExpect(jsonPath("$.data.schedules[0].isAdult").doesNotExist()) .andExpect(jsonPath("$.data.channelDonations[0].donationId").doesNotExist()) .andExpect(jsonPath("$.data.channelDonations[0].memberId").doesNotExist()) - .andExpect(jsonPath("$.data.channelDonations[0].isSecret").doesNotExist()) + .andExpect(jsonPath("$.data.channelDonations[0].isSecret").value(true)) .andExpect(jsonPath("$.data.notices[0].dateUtc").value("2026-06-12T04:00:00Z")) .andExpect(jsonPath("$.data.notices[0].imageUrl").doesNotExist()) .andExpect(jsonPath("$.data.notices[0].audioUrl").doesNotExist()) @@ -254,12 +254,14 @@ class CreatorChannelHomeControllerTest @Autowired constructor( ), channelDonations = listOf( CreatorChannelDonation( + donationId = null, memberId = 20L, nickname = "fan", profileImageUrl = "fan.png", can = 50, message = "thanks", - createdAt = LocalDateTime.of(2026, 6, 12, 2, 0) + createdAt = LocalDateTime.of(2026, 6, 12, 2, 0), + isSecret = true ) ), notices = listOf(post), diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt index 9dfb070f..e67a104a 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt @@ -64,6 +64,7 @@ class CreatorChannelHomeFacadeTest { assertTrue(response.latestAudioContent?.isOwned == true) assertFalse(response.latestAudioContent?.isRented == true) assertEquals("thanks", response.channelDonations.first().message) + assertTrue(response.channelDonations.first().isSecret) assertEquals(301L, response.notices.first().postId) assertEquals("2026-06-12T04:00:00Z", response.notices.first().dateUtc) assertNull(response.notices.first().imageUrl) @@ -185,12 +186,14 @@ class CreatorChannelHomeFacadeTest { ), channelDonations = listOf( CreatorChannelDonation( + donationId = null, memberId = 20L, nickname = "fan", profileImageUrl = "fan.png", can = 50, message = "thanks", - createdAt = LocalDateTime.of(2026, 6, 12, 2, 0) + createdAt = LocalDateTime.of(2026, 6, 12, 2, 0), + isSecret = true ) ), notices = listOf(post), diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt index 54b581c5..4ca57e45 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt @@ -19,6 +19,7 @@ import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference import kr.co.vividnext.sodalive.member.following.CreatorFollowing import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import kr.co.vividnext.sodalive.support.hasForcedCountryMapping import kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacade import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityDisplayContent import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService @@ -421,7 +422,7 @@ class HomeRecommendationControllerTest @Autowired constructor( } else { val role = if (viewerType == "ADMIN") MemberRole.ADMIN else MemberRole.USER var candidate = saveMember("home-event-viewer", role) - while (candidate.id in setOf(16L, 17L, 17958L, 44144L, 29721L, 32050L, 37543L, 40850L)) { + while (candidate.hasForcedCountryMapping()) { candidate = saveMember("home-event-viewer-${candidate.id}", role) } candidate diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepositoryTest.kt index adc88c78..8fe08311 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepositoryTest.kt @@ -8,6 +8,7 @@ import kr.co.vividnext.sodalive.member.DonationRankingPeriod import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.block.BlockMember +import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicy import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNotNull @@ -15,6 +16,8 @@ import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.context.annotation.Import @@ -78,33 +81,59 @@ class DefaultCreatorChannelDonationQueryRepositoryTest @Autowired constructor( assertFalse(repository.existsBlockedBetween(viewer.id!!, otherCreator.id!!)) } - @Test - @DisplayName("크리에이터 본인은 현재 KST 월 범위의 공개/비공개 채널 후원을 모두 조회한다") - fun shouldCountAndFindAllCurrentMonthDonationsForCreatorSelf() { - val now = LocalDateTime.of(2026, 6, 22, 3, 0) + @ParameterizedTest(name = "{0}, month={1}") + @CsvSource( + "creator, 6", + "donor, 6", + "outsider, 6", + "otherCreator, 6", + "creator, 12", + "donor, 12", + "outsider, 12", + "otherCreator, 12" + ) + @DisplayName("모든 조회자는 KST 월 시작 포함·다음 월 제외 조건으로 공개·비밀 count와 목록을 동일하게 조회한다") + fun shouldCountAndFindAllCurrentMonthDonationsForEveryViewer(viewerRole: String, month: Int) { + val now = LocalDateTime.of(2026, month, 22, 3, 0) + val range = CreatorChannelDonationQueryPolicy().currentKstMonthRange(now) + val start = LocalDateTime.of(2026, month - 1, if (month == 6) 31 else 30, 15, 0) + val end = LocalDateTime.of(2026, month, if (month == 6) 30 else 31, 15, 0) + assertEquals(start, range.startInclusiveUtc) + assertEquals(end, range.endExclusiveUtc) val creator = saveMember("donation-self-creator", MemberRole.CREATOR) val donor = saveMember("donation-self-donor", MemberRole.USER, profileImage = "self-donor.png") - val monthStartCreatedAt = LocalDateTime.of(2026, 5, 31, 15, 0) - val monthStart = saveDonation(creator, donor, 100, monthStartCreatedAt, additionalMessage = null) - val secret = saveDonation(creator, donor, 200, LocalDateTime.of(2026, 6, 22, 2, 0), isSecret = true) - saveDonation(creator, donor, 300, LocalDateTime.of(2026, 5, 31, 14, 59, 59)) - saveDonation(creator, donor, 400, LocalDateTime.of(2026, 6, 30, 15, 0)) + val viewers = mapOf( + "creator" to creator, + "donor" to donor, + "outsider" to saveMember("donation-boundary-outsider", MemberRole.USER), + "otherCreator" to saveMember("donation-boundary-other-creator", MemberRole.CREATOR) + ) + val viewerId = viewers.getValue(viewerRole).id!! + saveDonation(creator, donor, 100, start, isSecret = true, additionalMessage = null) + saveDonation(creator, donor, 200, now.minusHours(1)) + saveDonation(creator, donor, 300, end.minusSeconds(1), isSecret = true) + saveDonation(creator, donor, 400, start.minusSeconds(1), isSecret = true) + saveDonation(creator, donor, 500, end, isSecret = true) + val otherChannel = saveMember("donation-boundary-other-channel", MemberRole.CREATOR) + saveDonation(otherChannel, donor, 600, now, isSecret = true) flushAndClear() - val count = repository.countChannelDonations(creator.id!!, creator.id!!, now) - val records = repository.findChannelDonations(creator.id!!, creator.id!!, now, offset = 0, limit = 10, lang = Lang.KO) + val count = repository.countChannelDonations(creator.id!!, viewerId, now) + val records = repository.findChannelDonations(creator.id!!, viewerId, now, offset = 0, limit = 10, lang = Lang.KO) - assertEquals(2, count) - assertEquals(listOf(secret.can, monthStart.can), records.map { it.can }) + assertEquals(3, count) + assertEquals(count, records.size) + assertEquals(listOf(300, 200, 100), records.map { it.can }) + assertEquals(listOf(true, false, true), records.map { it.isSecret }) assertEquals(donor.nickname, records.last().nickname) assertEquals(donor.profileImage, records.last().profileImagePath) assertNull(records.last().message) - assertEquals(monthStartCreatedAt, records.last().createdAt) + assertEquals(listOf(end.minusSeconds(1), now.minusHours(1), start), records.map { it.createdAt }) } @Test - @DisplayName("일반 조회자는 현재 KST 월 범위의 공개 후원과 본인 비공개 후원만 조회한다") - fun shouldCountAndFindOnlyVisibleCurrentMonthDonationsForViewer() { + @DisplayName("일반 조회자도 공개 후원과 본인·타인 비밀 후원을 모두 조회한다") + fun shouldCountAndFindAllCurrentMonthDonationsForViewer() { val now = LocalDateTime.of(2026, 6, 22, 12, 0) val creator = saveMember("donation-visible-creator", MemberRole.CREATOR) val viewer = saveMember("donation-visible-viewer", MemberRole.USER) @@ -118,15 +147,23 @@ class DefaultCreatorChannelDonationQueryRepositoryTest @Autowired constructor( isSecret = true, additionalMessage = "own secret" ) - saveDonation(creator, otherDonor, 300, now.minusHours(1), isSecret = true, additionalMessage = "hidden") + val otherSecretDonation = saveDonation( + creator, + otherDonor, + 300, + now.minusHours(1), + isSecret = true, + additionalMessage = "hidden" + ) flushAndClear() val count = repository.countChannelDonations(creator.id!!, viewer.id!!, now) val records = repository.findChannelDonations(creator.id!!, viewer.id!!, now, offset = 0, limit = 10, lang = Lang.KO) - assertEquals(2, count) - assertEquals(listOf(ownSecretDonation.can, publicDonation.can), records.map { it.can }) - assertEquals(listOf("own secret", "public"), records.map { it.message }) + assertEquals(3, count) + assertEquals(listOf(otherSecretDonation.can, ownSecretDonation.can, publicDonation.can), records.map { it.can }) + assertEquals(listOf(true, true, false), records.map { it.isSecret }) + assertEquals(listOf("hidden", "own secret", "public"), records.map { it.message }) } @Test @@ -138,7 +175,7 @@ class DefaultCreatorChannelDonationQueryRepositoryTest @Autowired constructor( val donor = saveMember("donation-order-donor", MemberRole.USER) val sameCreatedAt = now.minusHours(1) val first = saveDonation(creator, donor, 100, sameCreatedAt, additionalMessage = "first") - val second = saveDonation(creator, donor, 200, sameCreatedAt, additionalMessage = "second") + val second = saveDonation(creator, donor, 200, sameCreatedAt, isSecret = true, additionalMessage = "second") saveDonation(creator, donor, 300, sameCreatedAt.plusMinutes(1), additionalMessage = "newest") flushAndClear() @@ -147,6 +184,94 @@ class DefaultCreatorChannelDonationQueryRepositoryTest @Autowired constructor( assertEquals(listOf(second.can, first.can), records.map { it.can }) } + @Test + @DisplayName("공개·비밀 23건의 실제 QueryDSL 페이지 합계와 count는 모든 조회자에서 일치한다") + fun shouldKeepCountAndMixedPagesIdenticalForEveryViewer() { + val now = LocalDateTime.of(2026, 12, 22, 12, 0) + val start = CreatorChannelDonationQueryPolicy().currentKstMonthRange(now).startInclusiveUtc + val creator = saveMember("donation-page-creator", MemberRole.CREATOR) + val donor = saveMember("donation-page-donor", MemberRole.USER) + val viewers = listOf( + creator, + donor, + saveMember("donation-page-outsider", MemberRole.USER), + saveMember("donation-page-other-creator", MemberRole.CREATOR) + ) + (1..23).forEach { index -> + saveDonation(creator, donor, index, start.plusHours((index / 2).toLong()), isSecret = index % 2 == 1) + } + flushAndClear() + + viewers.forEach { viewer -> + val count = repository.countChannelDonations(creator.id!!, viewer.id!!, now) + val first = repository.findChannelDonations(creator.id!!, viewer.id!!, now, 0, 20, Lang.KO) + val second = repository.findChannelDonations(creator.id!!, viewer.id!!, now, 20, 20, Lang.KO) + val outOfRange = repository.findChannelDonations(creator.id!!, viewer.id!!, now, 40, 20, Lang.KO) + + assertEquals(23, count) + assertEquals((23 downTo 4).toList(), first.map { it.can }) + assertEquals(listOf(3, 2, 1), second.map { it.can }) + assertTrue(outOfRange.isEmpty()) + assertEquals(count, first.size + second.size) + (first + second).forEach { record -> + assertEquals(record.can % 2 == 1, record.isSecret) + assertEquals(start.plusHours((record.can / 2).toLong()), record.createdAt) + } + } + } + + @Test + @DisplayName("숨긴 공개·비밀 후원은 모든 역할의 count와 페이지 경계에서 제외된다") + fun shouldExcludeHiddenRowsFromDonationTabCountAndPagesForEveryViewer() { + val now = LocalDateTime.of(2026, 12, 22, 12, 0) + val creator = saveMember("hidden-page-creator", MemberRole.CREATOR) + val donor = saveMember("hidden-page-donor", MemberRole.USER) + val viewers = listOf( + creator, + donor, + saveMember("hidden-page-outsider", MemberRole.USER), + saveMember("hidden-page-other-creator", MemberRole.CREATOR), + saveMember("hidden-page-admin", MemberRole.ADMIN) + ) + val rows = (1..25).map { index -> + saveDonation(creator, donor, index, now, isSecret = index % 2 == 1, isHidden = index >= 23) + } + flushAndClear() + + viewers.forEach { viewer -> + assertEquals(22, repository.countChannelDonations(creator.id!!, viewer.id!!, now)) + val first = repository.findChannelDonations(creator.id!!, viewer.id!!, now, 0, 20, Lang.KO) + val second = repository.findChannelDonations(creator.id!!, viewer.id!!, now, 20, 20, Lang.KO) + assertEquals((22 downTo 3).toList(), first.map { it.can }) + assertEquals(listOf(2, 1), second.map { it.can }) + assertEquals(rows.take(22).reversed().map { it.id }, (first + second).map { it.donationId }) + } + rows.take(2).forEach { row -> + entityManager.find(ChannelDonationMessage::class.java, row.id!!).isHidden = true + } + flushAndClear() + assertEquals(20, repository.countChannelDonations(creator.id!!, donor.id!!, now)) + assertEquals(20, repository.findChannelDonations(creator.id!!, donor.id!!, now, 0, 21, Lang.KO).size) + assertTrue(repository.findChannelDonations(creator.id!!, donor.id!!, now, 20, 21, Lang.KO).isEmpty()) + } + + @Test + @DisplayName("모든 후원이 숨김이면 모든 역할의 count는 0이고 목록은 비어 있다") + fun shouldReturnZeroCountAndEmptyPagesWhenAllDonationsAreHidden() { + val now = LocalDateTime.of(2026, 12, 22, 12, 0) + val creator = saveMember("all-hidden-creator", MemberRole.CREATOR) + val donor = saveMember("all-hidden-donor", MemberRole.USER) + listOf(false, true).forEach { secret -> + saveDonation(creator, donor, 100, now, isSecret = secret, isHidden = true) + } + flushAndClear() + + listOf(creator, donor).forEach { viewer -> + assertEquals(0, repository.countChannelDonations(creator.id!!, viewer.id!!, now)) + assertTrue(repository.findChannelDonations(creator.id!!, viewer.id!!, now, 0, 21, Lang.KO).isEmpty()) + } + } + @Test @DisplayName("후원 탭 repository 목록 조회는 entity 전체 fetch 없이 필요한 컬럼 projection만 사용한다") fun shouldUseProjectionForDonationList() { @@ -203,9 +328,11 @@ class DefaultCreatorChannelDonationQueryRepositoryTest @Autowired constructor( can: Int, createdAt: LocalDateTime, isSecret: Boolean = false, - additionalMessage: String? = "thanks" + additionalMessage: String? = "thanks", + isHidden: Boolean = false ): ChannelDonationMessage { val donation = ChannelDonationMessage(can = can, isSecret = isSecret, additionalMessage = additionalMessage) + donation.isHidden = isHidden donation.creator = creator donation.member = donor entityManager.persist(donation) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultHideCreatorChannelDonationRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultHideCreatorChannelDonationRepositoryTest.kt new file mode 100644 index 00000000..36af5bb2 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultHideCreatorChannelDonationRepositoryTest.kt @@ -0,0 +1,200 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence + +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.HideCreatorChannelDonationPort +import org.hibernate.resource.jdbc.spi.StatementInspector +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 +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Import +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@DataJpaTest(properties = ["spring.cache.type=none"]) +@Import( + QueryDslConfig::class, + DefaultHideCreatorChannelDonationRepository::class, + DefaultHideCreatorChannelDonationRepositoryTest.SqlCaptureConfiguration::class +) +class DefaultHideCreatorChannelDonationRepositoryTest @Autowired constructor( + private val entityManager: EntityManager, + private val repository: HideCreatorChannelDonationPort, + private val sqlCapture: SqlCaptureConfiguration +) { + @Test + @DisplayName("새 후원은 기본 비숨김 상태로 저장된다") + fun shouldPersistDonationAsVisibleByDefault() { + val creator = saveMember("hide-default-creator", MemberRole.CREATOR) + val donor = saveMember("hide-default-donor", MemberRole.USER) + val donationId = saveDonation(creator, donor) + + val donation = reloadDonation(donationId) + + assertFalse(donation.isHidden) + assertOriginalValues(donation, donationId, creator.id!!, donor.id!!, false) + } + + @Test + @DisplayName("숨김 UPDATE는 MySQL 자동 갱신을 막도록 수정 시각을 자기 대입한다") + fun shouldPreserveUpdatedAtWithSelfAssignmentInGeneratedHideSql() { + val creator = saveMember("hide-sql-creator", MemberRole.CREATOR) + val donor = saveMember("hide-sql-donor", MemberRole.USER) + val donationId = saveDonation(creator, donor) + val updatedAt = requireNotNull(reloadDonation(donationId).updatedAt) + sqlCapture.statements.clear() + + assertTrue(repository.hideOwnedDonation(creator.id!!, donationId)) + + val updateSql = sqlCapture.statements.single { it.startsWith("update channel_donation_message ", ignoreCase = true) } + assertTrue(Regex("""(?i)\bupdated_at\s*=\s*(?:\w+\.)?updated_at\b""").containsMatchIn(updateSql), updateSql) + val donation = reloadDonation(donationId) + assertTrue(donation.isHidden) + assertEquals(updatedAt, donation.updatedAt) + } + + @ParameterizedTest + @ValueSource(booleans = [false, true]) + @DisplayName("소유 채널의 선택한 공개·비밀 후원 한 건만 숨기고 원본 값을 보존한다") + fun shouldHideOnlyDonationOwnedByCreatorAndRetainOriginalValues(isSecret: Boolean) { + val creator = saveMember("hide-owned-creator", MemberRole.CREATOR) + val otherCreator = saveMember("hide-other-creator", MemberRole.CREATOR) + val donor = saveMember("hide-owned-donor", MemberRole.USER) + val donationId = saveDonation(creator, donor, isSecret) + val sameChannelDonationId = saveDonation(creator, donor) + val otherChannelDonationId = saveDonation(otherCreator, donor) + assertFalse(reloadDonation(donationId).isHidden) + + assertTrue(repository.hideOwnedDonation(creator.id!!, donationId)) + + val donation = reloadDonation(donationId) + assertTrue(donation.isHidden) + assertOriginalValues(donation, donationId, creator.id!!, donor.id!!, isSecret) + val sameChannelDonation = reloadDonation(sameChannelDonationId) + assertFalse(sameChannelDonation.isHidden) + assertOriginalValues(sameChannelDonation, sameChannelDonationId, creator.id!!, donor.id!!, false) + val otherChannelDonation = reloadDonation(otherChannelDonationId) + assertFalse(otherChannelDonation.isHidden) + assertOriginalValues(otherChannelDonation, otherChannelDonationId, otherCreator.id!!, donor.id!!, false) + assertEquals(3L, donationCount()) + } + + @Test + @DisplayName("다른 채널의 후원은 숨기지 않고 실패를 반환한다") + fun shouldRejectDonationFromAnotherCreatorWithoutChangingIt() { + val creator = saveMember("hide-reject-creator", MemberRole.CREATOR) + val otherCreator = saveMember("hide-reject-other", MemberRole.CREATOR) + val donor = saveMember("hide-reject-donor", MemberRole.USER) + val donationId = saveDonation(creator, donor, isSecret = true) + reloadDonation(donationId) + + assertFalse(repository.hideOwnedDonation(otherCreator.id!!, donationId)) + + val donation = reloadDonation(donationId) + assertFalse(donation.isHidden) + assertOriginalValues(donation, donationId, creator.id!!, donor.id!!, true) + assertEquals(1L, donationCount()) + } + + @Test + @DisplayName("존재하지 않는 후원 ID는 기존 후원을 바꾸지 않고 실패를 반환한다") + fun shouldRejectMissingDonationWithoutChangingExistingDonation() { + val creator = saveMember("hide-missing-creator", MemberRole.CREATOR) + val donor = saveMember("hide-missing-donor", MemberRole.USER) + val donationId = saveDonation(creator, donor) + reloadDonation(donationId) + + assertFalse(repository.hideOwnedDonation(creator.id!!, Long.MAX_VALUE)) + + val donation = reloadDonation(donationId) + assertFalse(donation.isHidden) + assertOriginalValues(donation, donationId, creator.id!!, donor.id!!, false) + assertEquals(1L, donationCount()) + } + + @Test + @DisplayName("같은 후원의 반복 숨김은 성공하며 다른 채널 요청은 거부하고 원본 값을 보존한다") + fun shouldSucceedWhenHidingAgainWithoutChangingOriginalValues() { + val creator = saveMember("hide-repeat-creator", MemberRole.CREATOR) + val otherCreator = saveMember("hide-repeat-other", MemberRole.CREATOR) + val donor = saveMember("hide-repeat-donor", MemberRole.USER) + val donationId = saveDonation(creator, donor, isSecret = true) + reloadDonation(donationId) + + assertTrue(repository.hideOwnedDonation(creator.id!!, donationId)) + assertTrue(reloadDonation(donationId).isHidden) + assertTrue(repository.hideOwnedDonation(creator.id!!, donationId)) + assertFalse(repository.hideOwnedDonation(otherCreator.id!!, donationId)) + + val donation = reloadDonation(donationId) + assertTrue(donation.isHidden) + assertOriginalValues(donation, donationId, creator.id!!, donor.id!!, true) + assertEquals(1L, donationCount()) + } + + private fun saveMember(nickname: String, role: MemberRole): Member { + val member = Member(email = "$nickname@test.com", password = "password", nickname = nickname, role = role) + entityManager.persist(member) + return member + } + + private fun saveDonation(creator: Member, donor: Member, isSecret: Boolean = false): Long { + val donation = ChannelDonationMessage(can = 120, isSecret = isSecret, additionalMessage = "original message") + donation.creator = creator + donation.member = donor + entityManager.persist(donation) + donation.createdAt = LocalDateTime.of(2026, 11, 5, 0, 0) + return requireNotNull(donation.id) + } + + private fun reloadDonation(donationId: Long): ChannelDonationMessage { + entityManager.flush() + entityManager.clear() + return requireNotNull(entityManager.find(ChannelDonationMessage::class.java, donationId)) + } + + private fun assertOriginalValues( + donation: ChannelDonationMessage, + donationId: Long, + creatorId: Long, + donorId: Long, + isSecret: Boolean + ) { + assertEquals(donationId, donation.id) + assertEquals(120, donation.can) + assertEquals("original message", donation.additionalMessage) + assertEquals(isSecret, donation.isSecret) + assertEquals(creatorId, donation.creator?.id) + assertEquals(donorId, donation.member?.id) + assertEquals(LocalDateTime.of(2026, 11, 5, 0, 0), donation.createdAt) + } + + private fun donationCount(): Long { + return entityManager.createQuery("select count(d) from ChannelDonationMessage d", java.lang.Long::class.java) + .singleResult.toLong() + } + + @TestConfiguration + class SqlCaptureConfiguration : HibernatePropertiesCustomizer, StatementInspector { + val statements = mutableListOf() + + override fun customize(hibernateProperties: MutableMap) { + hibernateProperties["hibernate.session_factory.statement_inspector"] = this + } + + override fun inspect(sql: String): String { + statements.add(sql) + return sql + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryServiceTest.kt index b7807554..414d5e93 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryServiceTest.kt @@ -17,6 +17,8 @@ import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource import org.mockito.Mockito import org.springframework.beans.factory.ObjectProvider import java.time.LocalDateTime @@ -154,6 +156,58 @@ class CreatorChannelDonationQueryServiceTest { assertEquals("thanks", tab.donations[1].message) } + @ParameterizedTest + @CsvSource( + "1, CREATOR, true", + "20, USER, true", + "10, USER, true", + "30, CREATOR, true", + "1, USER, true", + "1, ADMIN, true", + "40, ADMIN, true", + "1, CREATOR, false", + "20, USER, false", + "10, USER, false", + "30, CREATOR, false" + ) + @DisplayName("후원 mapper는 비밀 여부와 ID로만 마스킹하고 원본 이름·프로필·null 메시지 계약을 유지한다") + fun shouldMaskSecretDonationOnlyForOtherViewers(viewerId: Long, role: MemberRole, isSecret: Boolean) { + listOf(false, true).forEach { isLocalized -> + listOf(null, "profile/donor.png").forEach { profilePath -> + val messages = listOf(null, "", "private thanks") + val port = FakeDonationQueryPort( + donations = messages.map { message -> + createDonationRecord( + memberId = 20L, + nickname = "deleted_donor", + profileImagePath = profilePath, + message = message, + isSecret = isSecret, + isLocalizedName = isLocalized + ) + }, + donationCount = messages.size + ) + val tab = createService(port).getDonationTab(CREATOR_ID, createMember(viewerId, role), 0, 20, NOW, Lang.KO) + + assertEquals(3, tab.donationCount) + assertEquals(3, tab.donations.size) + val masked = isSecret && viewerId != CREATOR_ID && viewerId != 20L + tab.donations.zip(messages).forEach { (donation, message) -> + val originalNickname = if (isLocalized) "deleted_donor" else "donor" + assertEquals(if (masked) "익명" else originalNickname, donation.nickname) + assertEquals(if (masked) "크리에이터에게만 보여요" else message.orEmpty(), donation.message) + val profile = if (masked) "profile/anonymous-profile.png" else profilePath ?: "profile/default-profile.png" + assertEquals("https://cdn.test/$profile", donation.profileImageUrl) + assertEquals(masked, donation.isSecret) + assertEquals(100, donation.can) + assertEquals(NOW, donation.createdAt) + assertEquals(if (role == MemberRole.CREATOR && viewerId == CREATOR_ID) 101L else null, donation.donationId) + } + } + } + } + @Test @DisplayName("조회자가 크리에이터 본인이면 순위 공개 여부와 무관하게 donationCan 포함 랭킹을 조회한다") fun shouldFetchRankingsWithDonationCanForCreatorViewer() { @@ -280,19 +334,25 @@ class CreatorChannelDonationQueryServiceTest { } private fun createDonationRecord( + memberId: Long = 20L, nickname: String = "donor", profileImagePath: String? = "profile/donor.png", can: Int = 100, message: String? = "thanks", - createdAt: LocalDateTime = NOW + createdAt: LocalDateTime = NOW, + isSecret: Boolean = false, + isLocalizedName: Boolean = false ): CreatorChannelDonationRecord { return CreatorChannelDonationRecord( - memberId = VIEWER_ID, + donationId = 101L, + memberId = memberId, nickname = nickname, profileImagePath = profileImagePath, can = can, message = message, - createdAt = createdAt + createdAt = createdAt, + isSecret = isSecret, + isLocalizedName = isLocalizedName ) } @@ -305,12 +365,12 @@ class CreatorChannelDonationQueryServiceTest { ) } - private fun createMember(id: Long): Member { + private fun createMember(id: Long, role: MemberRole = MemberRole.USER): Member { return Member( email = "viewer$id@test.com", password = "password", nickname = "viewer$id", - role = MemberRole.USER + role = role ).apply { this.id = id } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/HideCreatorChannelDonationServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/HideCreatorChannelDonationServiceTest.kt new file mode 100644 index 00000000..69c97ed4 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/HideCreatorChannelDonationServiceTest.kt @@ -0,0 +1,159 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.donation.application + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource +import org.junit.jupiter.params.provider.ValueSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.context.ContextConfiguration +import org.springframework.transaction.support.TransactionTemplate +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@SpringBootTest( + properties = [ + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:hide-donation-service;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class HideCreatorChannelDonationServiceTest @Autowired constructor( + private val service: HideCreatorChannelDonationService, + private val entityManager: EntityManager, + private val transactionTemplate: TransactionTemplate +) { + @ParameterizedTest + @ValueSource(booleans = [false, true]) + @DisplayName("서비스는 소유 CREATOR의 공개·비밀 후원을 숨기고 DB 원본을 보존한다") + fun shouldHideOwnedPublicAndSecretDonationAndPreserveOriginalValues(isSecret: Boolean) { + val fixture = createFixture(isSecret = isSecret) + val before = originalValues(fixture.donationId) + + service.hide(fixture.creator.id!!, fixture.donationId, fixture.creator) + + assertEquals(before, originalValues(fixture.donationId)) + assertHidden(fixture.donationId, true) + } + + @ParameterizedTest + @EnumSource(value = MemberRole::class, names = ["USER", "ADMIN"]) + @DisplayName("서비스는 채널 ID가 같아도 CREATOR가 아닌 actor를 거부한다") + fun shouldRejectNonCreatorEvenWhenActorIdMatchesChannel(role: MemberRole) { + val fixture = createFixture(role = role) + val before = originalValues(fixture.donationId) + + val exception = assertThrows(SodaException::class.java) { + service.hide(fixture.creator.id!!, fixture.donationId, fixture.creator) + } + + assertEquals("common.error.access_denied", exception.messageKey) + assertEquals(before, originalValues(fixture.donationId)) + assertHidden(fixture.donationId, false) + } + + @ParameterizedTest + @EnumSource(value = MemberRole::class, names = ["USER", "ADMIN", "CREATOR"]) + @DisplayName("서비스는 비소유 actor를 대상 존재 여부 확인보다 먼저 거부한다") + fun shouldRejectNonOwnerBeforeCheckingDonationExistence(role: MemberRole) { + val fixture = createFixture() + val actor = transactionTemplate.execute { saveMember("hide-service-outsider", role) }!! + val before = originalValues(fixture.donationId) + + listOf(fixture.donationId, Long.MAX_VALUE).forEach { donationId -> + val exception = assertThrows(SodaException::class.java) { + service.hide(fixture.creator.id!!, donationId, actor) + } + assertEquals("common.error.access_denied", exception.messageKey) + } + + assertEquals(before, originalValues(fixture.donationId)) + assertHidden(fixture.donationId, false) + } + + @Test + @DisplayName("서비스는 소속이 다른 후원과 미존재 ID를 같은 오류로 거부한다") + fun shouldRejectOtherChannelAndMissingDonationWithSameError() { + val fixture = createFixture() + val otherCreator = transactionTemplate.execute { saveMember("hide-service-other", MemberRole.CREATOR) }!! + val before = originalValues(fixture.donationId) + + listOf(otherCreator to fixture.donationId, fixture.creator to Long.MAX_VALUE).forEach { (actor, donationId) -> + val exception = assertThrows(SodaException::class.java) { + service.hide(actor.id!!, donationId, actor) + } + assertEquals("common.error.invalid_request", exception.messageKey) + } + + assertEquals(before, originalValues(fixture.donationId)) + assertHidden(fixture.donationId, false) + } + + @Test + @DisplayName("서비스의 중복 숨김은 성공하고 원본을 보존한다") + fun shouldSucceedWhenHidingAgainWithoutChangingOriginalValues() { + val fixture = createFixture(isSecret = true) + val before = originalValues(fixture.donationId) + + repeat(2) { + service.hide(fixture.creator.id!!, fixture.donationId, fixture.creator) + assertEquals(before, originalValues(fixture.donationId)) + assertHidden(fixture.donationId, true) + } + } + + private fun createFixture(role: MemberRole = MemberRole.CREATOR, isSecret: Boolean = false): Fixture { + return transactionTemplate.execute { + val creator = saveMember("hide-service-creator", role) + val donor = saveMember("hide-service-donor", MemberRole.USER) + val donation = ChannelDonationMessage(120, isSecret, "service original").apply { + this.creator = creator + member = donor + } + entityManager.persist(donation) + donation.createdAt = LocalDateTime.of(2026, 11, 5, 0, 0) + entityManager.flush() + entityManager.clear() + Fixture(creator, donation.id!!) + }!! + } + + private fun saveMember(nickname: String, role: MemberRole): Member { + return Member(email = "$nickname@test.com", password = "password", nickname = nickname, role = role) + .also { entityManager.persist(it) } + } + + private fun originalValues(donationId: Long): List = transactionTemplate.execute { + entityManager.clear() + val donation = requireNotNull(entityManager.find(ChannelDonationMessage::class.java, donationId)) + listOf( + donation.id, + donation.can, + donation.additionalMessage, + donation.isSecret, + donation.creator?.id, + donation.member?.id, + donation.createdAt + ) + }!! + + private fun assertHidden(donationId: Long, hidden: Boolean) { + transactionTemplate.executeWithoutResult { + entityManager.clear() + val donation = requireNotNull(entityManager.find(ChannelDonationMessage::class.java, donationId)) + assertEquals(hidden, donation.isHidden) + } + } + + private data class Fixture(val creator: Member, val donationId: Long) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicyTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicyTest.kt index e990c9c3..0517bd60 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicyTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicyTest.kt @@ -78,12 +78,14 @@ class CreatorChannelDonationQueryPolicyTest { donationCan = 100 ) val donation = CreatorChannelDonation( + donationId = 101L, memberId = 10L, nickname = "donor", profileImageUrl = "https://cdn.test/donor.png", can = 50, message = "thanks", - createdAt = createdAt + createdAt = createdAt, + isSecret = true ) val tab = CreatorChannelDonationTab( donationCount = 1, @@ -100,12 +102,14 @@ class CreatorChannelDonationQueryPolicyTest { donationRankingPeriod = DonationRankingPeriod.CUMULATIVE ) val donationRecord = CreatorChannelDonationRecord( + donationId = 101L, memberId = 10L, nickname = "donor", profileImagePath = null, can = 50, message = "thanks", - createdAt = createdAt + createdAt = createdAt, + isSecret = true ) val rankingRecord = CreatorChannelDonationRankingRecord( userId = 10L, @@ -122,6 +126,8 @@ class CreatorChannelDonationQueryPolicyTest { assertEquals(MemberRole.CREATOR, creatorRecord.role) assertEquals(DonationRankingPeriod.CUMULATIVE, creatorRecord.donationRankingPeriod) assertNull(donationRecord.profileImagePath) + assertTrue(donationRecord.isSecret) + assertTrue(tab.donations.first().isSecret) assertEquals(100, rankingRecord.donationCan) } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt index a49b84c4..74b4ffa0 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt @@ -779,13 +779,16 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor( } @Test - @DisplayName("채널 후원은 KST 기준 이번 달과 크리에이터의 비밀 후원 열람을 반영한다") - fun shouldFindKstMonthDonationsAndExposeSecretDonationToCreator() { + @DisplayName("채널 후원은 KST 기준 이번 달 공개·비밀 후원을 조회자와 무관하게 포함한다") + fun shouldFindAllKstMonthDonationsForEveryViewer() { val now = LocalDateTime.of(2026, 5, 31, 15, 30) val creator = saveMember("kst-donation-creator", MemberRole.CREATOR) val donor = saveMember("kst-donation-donor", MemberRole.USER) saveDonation(creator, donor, 300, LocalDateTime.of(2026, 5, 31, 15, 10), isSecret = true) saveDonation(creator, donor, 100, LocalDateTime.of(2026, 5, 31, 14, 50), isSecret = false) + saveDonation(creator, donor, 200, LocalDateTime.of(2026, 5, 31, 15, 0), isSecret = true) + saveDonation(creator, donor, 400, LocalDateTime.of(2026, 6, 30, 14, 59, 59), isSecret = true) + saveDonation(creator, donor, 500, LocalDateTime.of(2026, 6, 30, 15, 0), isSecret = true) val thirdParty = saveMember("kst-donation-third-party", MemberRole.USER) flushAndClear() @@ -793,9 +796,78 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor( val donorView = repository.findChannelDonations(creator.id!!, donor.id!!, now, limit = 8, lang = Lang.KO) val thirdPartyView = repository.findChannelDonations(creator.id!!, thirdParty.id!!, now, limit = 8, lang = Lang.KO) - assertEquals(listOf(300), creatorView.map { it.can }) - assertEquals(listOf(300), donorView.map { it.can }) - assertEquals(emptyList(), thirdPartyView.map { it.can }) + assertEquals(listOf(400, 300, 200), creatorView.map { it.can }) + assertEquals(listOf(400, 300, 200), donorView.map { it.can }) + assertEquals(listOf(400, 300, 200), thirdPartyView.map { it.can }) + assertEquals(listOf(true, true, true), thirdPartyView.map { it.isSecret }) + assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), thirdPartyView.last().createdAt) + } + + @Test + @DisplayName("홈 후원은 공개·비밀 후보 전체 중 생성 시각과 id 내림차순으로 최신 8건을 조회한다") + fun shouldFindLatestEightAcrossPublicAndSecretDonations() { + val now = LocalDateTime.of(2026, 6, 12, 12, 0) + val creator = saveMember("mixed-home-creator", MemberRole.CREATOR) + val donor = saveMember("mixed-home-donor", MemberRole.USER) + val viewer = saveMember("mixed-home-viewer", MemberRole.USER) + (1..10).forEach { index -> + saveDonation(creator, donor, index, now.minusMinutes((index / 2).toLong()), isSecret = index % 2 == 1) + } + flushAndClear() + + val records = repository.findChannelDonations(creator.id!!, viewer.id!!, now, limit = 8, lang = Lang.KO) + + assertEquals(listOf(1, 3, 2, 5, 4, 7, 6, 9), records.map { it.can }) + assertEquals(listOf(true, true, false, true, false, true, false, true), records.map { it.isSecret }) + } + + @Test + @DisplayName("홈은 최신 숨김 공개·비밀 후보를 제외한 뒤 동시각 ID순으로 visible 8건을 보충한다") + fun shouldRefillHomeWithLatestEightVisibleDonationsAfterHiddenRowsAreExcluded() { + val now = LocalDateTime.of(2026, 6, 12, 12, 0) + val creator = saveMember("hidden-home-creator", MemberRole.CREATOR) + val donor = saveMember("hidden-home-donor", MemberRole.USER) + val viewers = listOf( + creator, + donor, + saveMember("hidden-home-outsider", MemberRole.USER), + saveMember("hidden-home-other-creator", MemberRole.CREATOR), + saveMember("hidden-home-admin", MemberRole.ADMIN) + ) + val rows = (1..12).map { index -> + saveDonation( + creator, + donor, + index, + now.plusMinutes((index / 2).toLong()), + isSecret = index % 2 == 1, + isHidden = index >= 10 + ) + } + flushAndClear() + + viewers.forEach { viewer -> + val records = repository.findChannelDonations(creator.id!!, viewer.id!!, now, limit = 8, lang = Lang.KO) + assertEquals((9 downTo 2).toList(), records.map { it.can }) + assertEquals(rows.take(9).drop(1).reversed().map { it.id }, records.map { it.donationId }) + assertEquals(listOf(true, false, true, false, true, false, true, false), records.map { it.isSecret }) + } + } + + @Test + @DisplayName("모든 후원이 숨김이면 홈 후원은 빈 목록이다") + fun shouldReturnEmptyHomeDonationsWhenAllRowsAreHidden() { + val now = LocalDateTime.of(2026, 6, 12, 12, 0) + val creator = saveMember("all-hidden-home-creator", MemberRole.CREATOR) + val donor = saveMember("all-hidden-home-donor", MemberRole.USER) + listOf(false, true).forEach { secret -> + saveDonation(creator, donor, 100, now, isSecret = secret, isHidden = true) + } + flushAndClear() + + listOf(creator, donor).forEach { viewer -> + assertTrue(repository.findChannelDonations(creator.id!!, viewer.id!!, now, limit = 8, lang = Lang.KO).isEmpty()) + } } @Test @@ -1353,9 +1425,11 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor( can: Int, createdAt: LocalDateTime, isSecret: Boolean = false, - additionalMessage: String? = "thanks" + additionalMessage: String? = "thanks", + isHidden: Boolean = false ): ChannelDonationMessage { val donation = ChannelDonationMessage(can = can, isSecret = isSecret, additionalMessage = additionalMessage) + donation.isHidden = isHidden donation.creator = creator donation.member = donor entityManager.persist(donation) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt index 94ccaf13..2627f0d6 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt @@ -54,6 +54,8 @@ import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource import org.mockito.Mockito import org.springframework.beans.factory.ObjectProvider import java.time.LocalDateTime @@ -196,6 +198,53 @@ class CreatorChannelHomeQueryServiceTest { assertEquals("donor", home.channelDonations.first().nickname) } + @ParameterizedTest + @CsvSource( + "1, CREATOR, true", + "20, USER, true", + "10, USER, true", + "30, CREATOR, true", + "1, USER, true", + "1, ADMIN, true", + "40, ADMIN, true", + "1, CREATOR, false", + "20, USER, false", + "10, USER, false", + "30, CREATOR, false" + ) + @DisplayName("홈 mapper는 비밀 여부와 조회자 ID로만 익명화하며 원본 이름과 프로필 기본값을 보존한다") + fun shouldMaskSecretDonationOnlyForOtherViewers(viewerId: Long, role: MemberRole, isSecret: Boolean) { + listOf(false, true).forEach { isLocalized -> + listOf(null, "profile/fan.png").forEach { profilePath -> + val port = FakeCreatorChannelHomeQueryPort(donorId = 20L).apply { + channelDonationNickname = "deleted_donor" + channelDonationIsSecret = isSecret + channelDonationIsLocalizedName = isLocalized + channelDonationProfilePath = profilePath + } + val service = createService(port) + val home = service.getHome( + 1L, + createMember(viewerId, role = role), + LocalDateTime.of(2026, 6, 13, 10, 0), + Lang.KO + ) + val donation = home.channelDonations.single() + val masked = isSecret && viewerId != 1L && viewerId != 20L + val expectedNickname = if (isLocalized) "deleted_donor" else "donor" + assertEquals(if (masked) "익명" else expectedNickname, donation.nickname) + assertEquals(if (masked) "크리에이터에게만 보여요" else "thanks", donation.message) + val originalProfile = profilePath ?: "profile/default-profile.png" + val expectedProfile = if (masked) "profile/anonymous-profile.png" else originalProfile + assertEquals("https://cdn.test/$expectedProfile", donation.profileImageUrl) + assertEquals(masked, donation.isSecret) + assertEquals(30, donation.can) + assertEquals(LocalDateTime.of(2026, 6, 13, 8, 0), donation.createdAt) + assertEquals(if (role == MemberRole.CREATOR && viewerId == 1L) 101L else null, donation.donationId) + } + } + } + @Test @DisplayName("creatorId에 해당하는 회원이 없으면 user_not_found 예외를 던진다") fun shouldThrowUserNotFoundWhenCreatorMemberDoesNotExist() { @@ -350,7 +399,9 @@ class CreatorChannelHomeQueryServiceTest { assertEquals("thanks", json["channelDonations"][0]["message"].asText()) assertFalse(json["channelDonations"][0].has("donation" + "Id")) assertFalse(json["channelDonations"][0].has("member" + "Id")) - assertFalse(json["channelDonations"][0].has("is" + "Secret")) + assertTrue(json["channelDonations"][0]["isSecret"].isBoolean) + assertFalse(json["channelDonations"][0]["isSecret"].asBoolean()) + assertFalse(json["channelDonations"][0].has("secret")) } private fun createHome(): CreatorChannelHome { @@ -408,12 +459,14 @@ class CreatorChannelHomeQueryServiceTest { ), channelDonations = listOf( CreatorChannelDonation( + donationId = null, memberId = 20L, nickname = "fan", profileImageUrl = "fan.png", can = 50, message = "thanks", - createdAt = LocalDateTime.of(2026, 6, 12, 2, 0) + createdAt = LocalDateTime.of(2026, 6, 12, 2, 0), + isSecret = false ) ), notices = listOf(post), @@ -564,7 +617,7 @@ class CreatorChannelHomeQueryServiceTest { } } -private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort { +private class FakeCreatorChannelHomeQueryPort(private val donorId: Long = 20L) : CreatorChannelHomeQueryPort { var creator: CreatorChannelCreatorRecord? = CreatorChannelCreatorRecord( creatorId = 1L, role = MemberRole.CREATOR, @@ -593,6 +646,9 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort { var audioContentsLocale: String? = null var seriesLocale: String? = null var channelDonationNickname: String = "fan" + var channelDonationIsSecret: Boolean = false + var channelDonationIsLocalizedName: Boolean = false + var channelDonationProfilePath: String? = "profile/fan.png" override fun findCreator(creatorId: Long, viewerId: Long?, lang: Lang): CreatorChannelCreatorRecord? = creator @@ -639,12 +695,15 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort { lang: Lang ): List = listOf( CreatorChannelDonationRecord( - memberId = 20L, + donationId = 101L, + memberId = donorId, nickname = channelDonationNickname, - profileImagePath = "profile/fan.png", + profileImagePath = channelDonationProfilePath, can = 30, message = "thanks", - createdAt = LocalDateTime.of(2026, 6, 13, 8, 0) + createdAt = LocalDateTime.of(2026, 6, 13, 8, 0), + isSecret = channelDonationIsSecret, + isLocalizedName = channelDonationIsLocalizedName ) )