Merge pull request 'Test' (#461) from test into main

Reviewed-on: #461
This commit was merged in pull request #461.
This commit is contained in:
2026-10-27 12:21:34 +00:00
22 changed files with 1280 additions and 18 deletions
@@ -0,0 +1,3 @@
ALTER TABLE content_banner
ADD COLUMN start_date TIMESTAMP NULL DEFAULT NULL COMMENT '배너 노출 시작 시각 (UTC, NULL이면 시작 제한 없음)',
ADD COLUMN end_date TIMESTAMP NULL DEFAULT NULL COMMENT '배너 노출 종료 시각 (UTC, NULL이면 종료 제한 없음)';
@@ -0,0 +1,299 @@
# 오디오 콘텐츠 배너 예약 기간 구현 계획
| 문서 항목 | 내용 |
|---|---|
| 상태 | 구현 완료 |
| 작성일 | 2026-10-27 |
| 요구사항 기준 | `docs/20261027_콘텐츠배너예약기간/prd.md` |
| API 기준 | 관리자 예약 필드 추가, 기존 공개 API 응답 schema 유지 |
| 현재 Phase | Phase 1: 예약 저장과 노출 판정 |
| 현재 활성 Goal | 없음 (`P1-GATE` 완료) |
## 목표
관리자는 오디오 콘텐츠 배너 예약 기간을 저장·관리하고, 네 공개 오디오 배너 조회는 현재 시각에 노출 가능한 항목만 기존 조건과 순서로 반환한다.
## 현재 상태
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 조건 |
|---:|---|---:|---|---|
| 1 | 완료 | `2/2` | 없음 (`P1-GATE` 완료) | 없음 |
- 동시에 하나의 미완료 goal만 실행한다. 순서: `P1-T1` → `P1-T2` → `P1-GATE`.
- 완료 체크박스와 검증 기록은 구현 때 실제 결과로 갱신하며, 이 계획 작성 시 검증을 실행한 것으로 간주하지 않는다.
## 범위
### 포함
- `content_banner` 예약 시각 nullable 필드와 MySQL DDL, 관리자 입력·응답, 값 검증 및 부분 수정 semantics.
- 네 공개 조회의 기간 필터 및 focused repository/service 테스트.
- DDL 선행 적용을 포함한 rollout 순서 문서화.
### 제외
- 오디오 이외 banner, scheduler, 공개 response 변경, security·권한 변경, 새 dependency, 전역 시간 설정 변경.
- live DB 실행 또는 기존 레코드 backfill.
## 기술 제약 및 구현 결정
- Kotlin/Spring/JPA/QueryDSL 기존 구조를 유지한다. 신규 architecture/dependency는 추가하지 않는다. 네 조회가 재사용하는 작은 기간 predicate는 허용한다.
- 필드는 `startDate`, `endDate`, 컬럼은 `start_date`, `end_date`, 각각 `TIMESTAMP NULL DEFAULT NULL` 초 정밀도다. 모든 컬럼에 한국어 COMMENT를 기록한다.
- 기존 관리자 이벤트 입력 관례의 KST `yyyy-MM-dd HH:mm` 문자열을 사용한다. UTC 저장 및 KST 동일 포맷 응답을 적용하고, 종료는 해당 입력 분의 `:59`로 정규화한다.
- update는 기존 `UpdateContentBannerRequest` DTO를 유지하면서 raw JSON에서 필드 존재 여부를 확인해 생략은 유지, 명시 null은 clear 처리한다. 역전 범위와 형식 검증은 이미지 upload 전에 수행하고 `common.error.invalid_request`를 사용한다.
- 공개 조회 조건은 NULL 경계의 inclusive 규칙을 따르며 `.limit()`보다 앞선 DB `.where()`에 포함한다. 기존 모든 필터·정렬을 보존한다.
- DDL migration이 애플리케이션 rollout보다 먼저다. DB 작업은 운영 자동화에 연결하지 않고 별도 승인된 절차로 실행한다.
- 구현 테스트는 JUnit 5 TDD. 공개 banner port에는 now 인자가 없으므로, 기존 nowProvider 기본 인자 관례를 참고해 해당 query 경계의 최소 clock seam을 테스트에서 고정한다. 공개 조회의 UTC now는 초 단위로 비교한다. persistence 검증은 `TIMESTAMP(0)` 컬럼에 저장 후 `flush`/`clear`/재조회한다. 초 정밀도는 완화 비교하지 않는다.
- 이 host에는 `lsp_diagnostics`가 없어 Kotlin main/test 컴파일과 ktlint로 정적 검증을 대체한다. LSP 검증으로 보고하지 않는다.
- focused 명령은 아래 Gate에 기록한 여섯 테스트 클래스와 Progress의 실제 combined 명령을 기준으로 실행한다.
### Phase 1: 예약 저장과 노출 판정
**Phase 결과:** 관리자 설정에서 시작/종료 시각을 저장하고 공개 경로가 기간 경계에 맞는 배너만 반환한다.
**선행조건:** 없음. PRD 요구사항 SCH-001~007 확정.
### Task 1.1 관리자·저장·응답
**Goal 실행 `P1-T1`:** 예약 범위를 생성·조회·부분 수정할 수 있고 DB에 정확히 왕복 저장한다.
- **시작 조건:** PRD §7~8 확정, 기존 관리자 이벤트 날짜 입력 convention 확인.
- **완료 증거:** 모든 체크박스 완료, focused test 통과, DDL 파일 작성, DB 왕복 및 업로드 전 검증 결과 기록.
- **범위 밖:** 공개 조회 필터, 공개 응답 변경, 운영 DB 적용.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBanner.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/CreateContentBannerRequest.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/UpdateContentBannerRequest.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/GetAdminContentBannerResponse.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerRepository.kt`
- Create: `docs/20261027_콘텐츠배너예약기간/alter-content-banner-reservation.sql`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerServiceTest.kt`
- Create/Test: `src/test/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerControllerIntegrationTest.kt`
**Interfaces:**
- Consumes: 기존 `createAudioContentMainBanner(image, requestString)`, `updateAudioContentMainBanner(image, requestString)`, `getAudioContentMainBannerList(tabId)`와 `GetAdminContentBannerResponse` projection.
- Produces: entity `startDate/endDate: LocalDateTime?`; create/update 선택 필드 `startDate/endDate: String?`; update 생략/null 구분; admin response `startDate/endDate: String?`. 포맷 `yyyy-MM-dd HH:mm` KST.
- [x] **RED:** create 값 저장, update 생략 유지 및 explicit null clear, 포맷/역전 오류와 업로드 미호출을 검증하는 focused test를 작성한다.
- [x] **RED 확인:** `./gradlew test --tests '*AdminContentBannerControllerIntegrationTest'`로 미구현 동작의 HTTP/assertion 실패를 확인한다. 기존 mock 서비스 테스트는 회귀 확인으로 유지한다.
- [x] **GREEN:** nullable entity 컬럼, 선택적 create 필드, 최소 raw JSON presence 판독, KST/UTC parsing·formatting, effective 범위 검증, 응답 projection을 구현하고 DDL을 추가한다.
- [x] **GREEN 확인:** 같은 focused test를 통과시킨다. persistence 테스트는 고정 시각으로 저장 후 `flush`/`clear`/재조회해 두 경계가 초 단위로 왕복하는지 검증한다.
- [x] **REFACTOR:** 이번 Task가 만든 중복만 정리하고 관리자 test 및 `./gradlew ktlintCheck`를 실행한다. DDL의 nullable default/comment와 PRD 일치 여부를 확인한다.
#### Task 1.1 회귀 수정: 예약 JSON 타입 오류 (`P1-T1-R1`)
- **Objective:** 예약 값이 문자열/null이 아닌 JSON 객체·배열이어도 업로드·저장 전에 `common.error.invalid_request`로 거부한다.
- **시작 조건:** P1-T1 리뷰에서 DTO 역직렬화가 예약 검증보다 먼저 실패해 `common.error.unknown`이 되는 경로 확인.
- **Files:** `AdminContentBannerService.kt`, `AdminContentBannerControllerIntegrationTest.kt` (위 Task 1.1의 전체 경로와 동일).
- **완료 증거:** create/update × start/end × 객체/배열의 실제 HTTP RED/GREEN 및 기존 focused 회귀 통과, scoped 재검토.
- **범위 밖:** 다른 DTO 필드/전역 JSON 오류 처리 변경.
- [x] **RED:** 예약 객체·배열 입력의 HTTP 공통 오류 계약 실패를 확인한다.
- [x] **GREEN:** 해당 예약 필드의 타입 검증만 최소 보강해 같은 테스트를 통과시킨다.
- [x] **REFACTOR:** `./gradlew test --tests '*AdminContentBannerControllerIntegrationTest' --tests '*AdminContentBannerServiceTest' --tests '*AudioContentBannerRepositoryTest' --console=plain` 및 ktlint 결과를 누적한다.
### Task 1.2 공개 조회 기간 필터
**Goal 실행 `P1-T2`:** 공개 네 조회가 현재 시각의 예약 가능 여부를 DB에서 limit 전에 판정한다.
- **시작 조건:** `P1-T1` 완료, entity QueryDSL metamodel 재생성 또는 빌드 convention 반영.
- **완료 증거:** 네 경로 각각의 경계·무기한·조건 보존 테스트, focused test 통과.
- **범위 밖:** 관리자 목록 예약 필터, 공개 응답 schema 또는 기존 필터/정렬 변경.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt` (`AudioContentQueryRepositoryImpl.getAudioContentMainBannerList(isAdult)`)
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerVisibility.kt` (네 query의 NULL 허용 기간 조건과 초 정밀도 비교를 공유하는 작은 QueryDSL 함수)
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerRepositoryTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepositoryBannerTest.kt` (해당 query method에 대한 신규 통합 테스트)
- Create/Test fixture: `src/test/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerReservationFixtures.kt` (네 JPA 테스트가 같은 고정 UTC 예약 경계 matrix만 공유하며 기존 fixture/flush·clear를 재사용)
**Interfaces:**
- Consumes: `AudioContentBanner.startDate/endDate` UTC 저장 값; 구형 `AudioContentQueryRepositoryImpl.getAudioContentMainBannerList(isAdult)`와 `AudioContentBannerQueryRepositoryImpl.getAudioContentMainBannerList(tabId, isAdult, lang)`에 해당 query 경계의 테스트 가능한 now seam, v2의 `findHomeBanners(limit, memberId, includeAdultBanners, lang)`와 `findBanners(limit, memberId, canViewAdultContent, lang)`도 같은 고정 시각 seam. 공개 API signature는 유지.
- Produces: 기존 공개 method signature와 반환 타입 유지. 예약 기간 필터는 공개 조회 결과에만 적용.
- [x] **RED:** 네 경로에서 양쪽 NULL, 시작만/종료만, inclusive 경계, 기간 밖 배너 및 limit 전에 걸러짐을 테스트한다. 기존 성인/언어/탭/active/target 조건과 정렬 유지 사례를 포함한다.
- [x] **RED 확인:** 네 repository 클래스의 신규 예약 테스트를 combined `./gradlew test --tests '*AudioContentBannerRepositoryTest.shouldExcludeFutureAndExpiredReservations' --tests '*AudioContentRepositoryBannerTest.shouldExcludeFutureAndExpiredReservations' --tests '*DefaultHomeRecommendationQueryRepositoryTest.shouldExcludeFutureAndExpiredReservationsBeforeLimit' --tests '*DefaultAudioRecommendationQueryRepositoryTest.shouldExcludeFutureAndExpiredReservationsBeforeLimit' --console=plain`로 실행해 예약 조건 assertion 실패를 확인한다.
- [x] **GREEN:** 각 기존 QueryDSL query의 `.where(...)`에 현재 시각 기준 nullable inclusive 조건을 추가한다. 별도 조회 후 메모리 필터링은 하지 않는다.
- [x] **GREEN 확인:** 각 focused test를 통과시킨다. 시각은 고정된 UTC now를 사용하고 시작·종료 동등 경계를 포함한다.
- [x] **REFACTOR:** 기존 where 조건·정렬·limit 순서와 공개 response 변화를 확인하고 네 focused test 및 `./gradlew ktlintCheck`를 다시 실행한다.
### Phase 1 Gate
**Goal 실행 `P1-GATE`:** 관리자 설정, DB migration 및 공개 조회 전체 계약이 맞는지 판정한다.
- **시작 조건:** `P1-T1`, `P1-T2` 완료.
- **완료 증거:** 아래 focused 테스트 통과, DDL과 rollout 순서 검토, 문서/변경 범위 확인 결과 기록.
- **범위 밖:** 전체 build/test 실행, 운영 DB 변경, 구현 범위 외 수정.
```bash
./gradlew test --tests 'kr.co.vividnext.sodalive.admin.content.banner.AdminContentBannerServiceTest'
./gradlew test --tests 'kr.co.vividnext.sodalive.admin.content.banner.AdminContentBannerControllerIntegrationTest'
./gradlew test --tests 'kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerRepositoryTest'
./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentRepositoryBannerTest'
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest'
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest'
./gradlew ktlintCheck
```
**Expected:** 모든 명령 exit code 0. 공개 네 경로가 예약으로 선필터링되고 기존 조건/정렬 및 response schema가 보존된다. 관리자 목록에서 예약과 무관하게 기존 isActive/tab 조건으로 행이 조회된다. DDL은 nullable NULL 기본값이며 앱 rollout 전 적용이 필요하고 live DB 자동 실행은 없다.
- [x] **자동 검증:** focused 테스트 재실행, Kotlin 컴파일, ktlint 및 `bootJar` 패키징이 통과한다.
- [x] **계약 대조:** NULL 경계 네 조합, 관리자 예약 무관 조회, 네 공개 query의 limit 전 조건과 기존 필터 보존을 확인한다.
- [x] **리뷰:** P1-T2와 전체 변경 통합 검토에서 미해결 blocking finding이 없다.
- [x] **배포/문서:** DDL 선행 적용과 미검증 운영 환경 경계를 명시하고 PRD·계획·리뷰 상태를 갱신한다.
## 실행 순서
| 순서 | Goal | 선행조건 | 차단 시 행동 |
|---:|---|---|---|
| 1 | `P1-T1` | 없음 | PRD/기존 convention의 불일치만 문서화하고 임의 계약 금지 |
| 2 | `P1-T2` | `P1-T1` | 기존 query 조건을 유지하며 공개 경로 단위로 계속 검증 |
| 3 | `P1-GATE` | Task 1.1, 1.2 전체 | 실패 소유 Task에 회귀 검증 추가 |
```text
P1-T1 → P1-T2 → P1-GATE
```
## 변경 금지 항목
- 예약 기간으로 관리자 목록을 필터링하지 않는다. 기존 `isActive` 및 tab 조건은 그대로 유지한다.
- 공개 response schema, 권한, security, 다른 banner 유형은 변경하지 않는다.
- 실패 테스트를 삭제·skip·완화하지 않는다. 새 dependency·scheduler·전역 시간대 설정을 추가하지 않는다.
- 운영 DB에 DDL을 자동 실행하지 않는다. migration 적용은 애플리케이션 rollout보다 먼저다.
## Progress
### 구현 전 확인 — 2026-10-27
- 변경 전 working tree는 clean이고 현재 branch는 `test`다. 저장소 규칙에 따라 worktree를 자동 생성하지 않고 현재 작업 공간에서 진행한다.
- `./gradlew tasks --all test --tests '*AdminContentBannerServiceTest' --tests '*AudioContentBannerRepositoryTest' --console=plain` — exit 0, 기존 관리자/배너 테스트와 명령 유효성 확인.
- `./gradlew test --tests '*DefaultHomeRecommendationQueryRepositoryTest' --tests '*DefaultAudioRecommendationQueryRepositoryTest' --console=plain` — exit 0, 기존 v2 repository 회귀 baseline 확인.
- `./gradlew ktlintCheck --console=plain` — exit 0, 변경 전 포맷 baseline 확인.
- 사전 대조: P1-T1은 nullable UTC entity와 관리자 필드를 제공하고 P1-T2는 같은 필드를 소비하므로 순차 실행한다. P1-T1의 HTTP 테스트와 DB 왕복, P1-T2의 고정 now/limit 전 필터가 각 요구사항과 일치한다.
- 계획 초안의 `startAt/endAt` 및 `start_at/end_at`은 구현 전에 사용자 명명 `startDate/endDate`, `start_date/end_date`로 보정했다. 제품 동작 변경은 없다.
### P1-T1 실행 증거 — 2026-10-27
**TDD 실행 이력**
| 순서 | 명령/대상 | 실제 결과 및 판정 |
|---|---|---|
| RED 1 | `./gradlew test --tests '*AdminContentBannerControllerIntegrationTest' --console=plain` | exit 1, 6건 중 6건 실패, errors 0. 정상 multipart 생성과 GET 목록 요청 후 `Admin HTTP response must include startDate ==> expected: <true> but was: <false>` assertion으로 실패. 미구현 entity 참조나 없는 JDBC 컬럼에 의한 실패가 아니다. |
| GREEN 1 | 위와 같은 focused 명령 | exit 0, `BUILD SUCCESSFUL in 1m 59s`, 생성·목록 6건 통과. nullable 저장·KST 응답 projection 구현 후 H2 컬럼을 `TIMESTAMP(0)`으로 맞추고 고정 기대 UTC 값과 실제 재조회 값을 정확히 비교했다. |
| 테스트 작성 중 보정 | `compileTestKotlin` | multipart builder의 `.with(...)` 반환 타입이 `MockHttpServletRequestBuilder`인데 helper에서 더 좁은 타입을 선언해 3곳 컴파일 오류가 발생했다. 테스트 helper의 타입만 바로잡았으며 이 실패는 유효 RED에 포함하지 않는다. Redis/DB 설정 변경이나 production 우회는 없었다. |
| RED 2 | 위와 같은 focused 명령 | exit 1, 60건 중 52건 실패. 수정 null 해제·새 경계 적용의 HTTP 날짜 assertion 실패, 역전/잘못된 수정의 `$.success` expected false/actual true, 잘못된 생성 날짜가 `SodaException`이 아닌 예외로 처리되는 assertion 실패를 확인했다. |
| GREEN 2 | 아래 최종 focused 명령과 동일 | exit 0, `BUILD SUCCESSFUL in 37s`, 통합 60건 + 기존 서비스 1건 + 공개 repository 1건 = 62건 통과. raw `JsonNode.has` 병합과 파싱 오류 변환, 업로드 전 effective 범위 검증을 구현했다. |
| RED 3 | 통합 focused 명령 | exit 1, 64건 중 3건 실패. strict `uuuu` parser도 `+02026-10-27 10:00`을 허용해 잘못된 형식의 create start/end 및 update start에서 `$.success`가 true였다. 계약의 16자리 형식 검증을 추가하기 전에 확인한 별도 RED다. |
| 최종 GREEN | 아래 최종 focused 명령 | exit 0, `BUILD SUCCESSFUL in 37s`, 66건 통과. XML 보고서의 failures/errors/skipped는 모두 0이다. |
```bash
./gradlew test --tests '*AdminContentBannerControllerIntegrationTest' --tests '*AdminContentBannerServiceTest' --tests '*AudioContentBannerRepositoryTest' --console=plain
```
**최종 테스트 집계 및 실제 검증 범위**
| 테스트 클래스 | tests | failures | errors | skipped |
|---|---:|---:|---:|---:|
| `AdminContentBannerControllerIntegrationTest` | 64 | 0 | 0 | 0 |
| `AdminContentBannerServiceTest` | 1 | 0 | 0 | 0 |
| `AudioContentBannerRepositoryTest` | 1 | 0 | 0 | 0 |
| 합계 | 66 | 0 | 0 | 0 |
- 새 `@SpringBootTest`는 실제 controller/service/repository와 MockMvc multipart POST/PUT 및 GET을 사용하고 S3 외부 업로드만 mock한다. 기존 `EmbeddedRedisInitializer`를 해당 클래스에만 opt-in하고, 전용 H2 DB와 매 테스트 배너/탭 정리를 사용한다. 기존 서비스·공개 repository 테스트 파일은 수정하지 않았다.
- 생성 6건은 양쪽 생략/명시 null/시작만/종료만/양쪽/같은 분을 포함한다. 수정 9건은 생략 보존, 각 경계·양쪽 null 해제, 단일 경계 변경, 같은 분 및 다른 경계 해제를 반영한 병합을 검증한다. 미래·만료·무기한 예약은 관리자 목록에서 계속 보이고 기존 active/tab 조건은 보존된다.
- 형식/달력 오류는 11개 입력 × start/end × create/update = 44건이다. 생성 역전 1건 및 기존 값과 병합한 수정 역전 3건도 검증한다. 실패마다 `SodaException.messageKey == common.error.invalid_request`, S3 미호출, 생성 행 0개 또는 수정 전후 전체 DB row 완전 일치를 확인했다.
- persistence는 HTTP 서비스 트랜잭션 완료 후 별도 트랜잭션에서 `flush`/`clear`/재조회했다. H2 `start_date/end_date`는 명시적 `TIMESTAMP(0)`이다. `2026-10-27 00:00` KST의 전날 UTC 변환, `23:59` 입력의 종료 `:59`, 나노초 0, 목록의 KST 문자열/실제 JSON null을 허용오차나 절삭 비교 없이 확인했다.
- production 변경은 계획된 entity/관리자 DTO·service·repository 6파일에 한정했다. QueryDSL 보조 생성자에서 UTC를 KST 문자열로 변환해 기존 data class와 응답 필드를 유지하며 global serializer/timezone/dependency/security와 공개 query/response는 변경하지 않았다.
**DDL 및 검증 경계**
- `alter-content-banner-reservation.sql`은 MySQL `start_date/end_date TIMESTAMP NULL DEFAULT NULL`과 각 컬럼의 한국어 COMMENT만 추가한다. 기존 행은 NULL 유지, backfill 없음이며 파일 검토만 했다. 운영 적용은 별도 승인 절차로 DDL을 먼저 적용한 뒤 앱을 rollout해야 한다.
- 실제 MySQL DDL 실행, 운영 JDBC/session timezone과 MySQL TIMESTAMP 허용 범위에 대한 왕복 검증은 수행하지 않았다. H2 초 정밀도 및 Java KST/UTC 변환 검증을 운영 MySQL 검증으로 간주하지 않는다.
- `P1-T1`만 완료했다. `P1-T2` 공개 예약 필터와 `P1-GATE`는 미실행·미완료이며 해당 체크박스를 변경하지 않았다.
### P1-T1-R1 실행 증거 — 2026-10-27
- RED: 기존 잘못된 날짜의 parameterized HTTP 테스트에 객체 `{}`/배열 `[]` × `startDate/endDate` × create/update 8건만 추가했다. `./gradlew test --tests '*AdminContentBannerControllerIntegrationTest' --console=plain` — exit 1, `BUILD FAILED in 27s`, 72건 중 신규 8건만 assertion 실패, 기존 64건 통과, errors/skipped 0. HTTP 실패 응답의 resolved exception이 `SodaException`이어야 한다는 assertion에서 실패했으며 실제 원인은 DTO 매핑의 `MismatchedInputException`이었다. 컴파일/설정 오류는 없었다.
- GREEN: create/update 모두 JSON 트리를 한 번 읽고 DTO 매핑 전에 두 예약 필드만 검사한다. 생략·JSON null·문자열을 허용하고 그 외 JSON 타입을 `common.error.invalid_request`로 거부한다. 같은 트리를 `treeToValue`와 update의 `has` 판독에 재사용했다. 다른 DTO 필드나 전역 handler는 변경하지 않았고 mapping 예외를 포괄 catch하지 않는다.
- 회귀 명령: `./gradlew test --tests '*AdminContentBannerControllerIntegrationTest' --tests '*AdminContentBannerServiceTest' --tests '*AudioContentBannerRepositoryTest' --console=plain` — exit 0, `BUILD SUCCESSFUL in 38s`. 실제 `:test`가 실행됐으며 통합 72건 + 기존 서비스 1건 + 공개 repository 1건 = 74건 통과했다. `build/test-results/test/TEST-*.xml`에서 failures/errors/skipped 모두 0임을 확인했다.
- 신규 8건은 실제 MockMvc multipart 요청으로 `SodaException.messageKey == common.error.invalid_request`, S3 미호출, 생성 행 0개 또는 수정 전후 전체 DB row 일치를 검증한다. 기존 64건으로 생략 보존·명시 null 해제·날짜 정밀도/시간대·관리자 목록 회귀도 재검증했다. S3 이외 mock은 추가하지 않았다.
- scoped 자체 재검토: production 변경은 `AdminContentBannerService.kt`의 예약 JSON 타입 경계뿐이다. 나머지 변경은 기존 통합 테스트의 공통 입력 source 확장과 이 R1 체크/증거이며 P1-T2·Gate 및 기존 완료 기록은 변경하지 않았다. 전체 test/build와 운영 MySQL은 이 회귀 목표의 검증 범위에서 제외했고 Kotlin main/test 컴파일을 focused 실행으로 확인했다.
- 정적 검사 및 문서 명령 확인: `./gradlew ktlintCheck tasks --all --console=plain` — exit 0, `BUILD SUCCESSFUL in 15s`. main/test ktlint 오류는 없었으며 기존 Gradle deprecation 안내만 남았다. LSP 도구는 제공되지 않아 Kotlin 컴파일/ktlint로 대체했다. R1의 RED/GREEN/REFACTOR만 완료 처리했다.
- parent 독립 검증: `./gradlew test --rerun --tests '*AdminContentBannerControllerIntegrationTest' --tests '*AdminContentBannerServiceTest' --tests '*AudioContentBannerRepositoryTest' --console=plain` — exit 0, `BUILD SUCCESSFUL in 36s`, 실제 test 재실행. XML의 72 + 1 + 1 = 74건, failures/errors/skipped 모두 0을 직접 확인했다.
- scoped 리뷰 및 재검토: 최초 예약 JSON 객체/배열 오류 경계 P2 1건을 R1로 보완했다. 재검토에서 finding ADDRESSED, 명세 준수/코드 품질 모두 APPROVE이며 신규 actionable finding은 없었다. P1-T1/R1 완료 후 P1-T2로 이동한다.
### P1-T2 실행 증거 — 2026-10-27
- 실행 순서: 네 조회의 미구현 필터 RED 확인 → 공유 predicate/기본 UTC nowProvider 및 고정 시각 matrix → 공개 4클래스/관리자 2클래스 회귀와 ktlint. 관리자 파일 및 기존 완료 증거는 변경하지 않는다.
- 최초 RED 명령: `./gradlew test --tests '*AudioContentBannerRepositoryTest.shouldExcludeFutureAndExpiredReservations' --tests '*AudioContentRepositoryBannerTest.shouldExcludeFutureAndExpiredReservations' --tests '*DefaultHomeRecommendationQueryRepositoryTest.shouldExcludeFutureAndExpiredReservationsBeforeLimit' --tests '*DefaultAudioRecommendationQueryRepositoryTest.shouldExcludeFutureAndExpiredReservationsBeforeLimit' --console=plain`.
- 최초 RED 결과: exit 1, `BUILD FAILED in 23s`, 4건 실행/4건 assertion 실패, errors/skipped 0. 기존 생성자를 사용하고 실제 UTC 현재 시각의 ±1년으로 저장 후 flush/clear했다. 두 구형 조회는 expected `[visible]`, actual `[future, expired, visible]`; 홈 v2는 expected `[visible-1, visible-2]`, actual `[future, expired]`; 오디오 v2는 같은 이름의 CDN URL expected/actual로 실패했다. 컴파일/없는 seam/설정 오류가 아닌 실제 H2 조회 결과의 실패다.
- 중복 축소 결정: 구현 전 Files에 test-only `AudioContentBannerReservationFixtures.kt`를 추가했다. 고정 UTC now·nullable 시작/종료·기대 노출 여부만 공유하며 production test 분기나 공통 테스트 프레임워크는 추가하지 않는다.
- 종료 초 RED 명령: `./gradlew test --tests '*AudioContentBannerRepositoryTest.should*Reservation*' --tests '*AudioContentRepositoryBannerTest.should*Reservation*' --tests '*DefaultHomeRecommendationQueryRepositoryTest.should*Reservation*' --tests '*DefaultAudioRecommendationQueryRepositoryTest.should*Reservation*' --console=plain`.
- 종료 초 RED 결과: 최초 nullable inclusive predicate와 선택적 UTC seam 추가 후, 초 절삭을 추가하기 전에 실행했다. exit 1, `BUILD FAILED in 1m 22s`, 68건 중 8건 실패, errors/skipped 0. 네 경로 모두 `end-second-123456789`/`end-second-999999999`에서 expected `[candidate]`(오디오 v2는 CDN URL), actual `[]`였다. 나머지 60건은 통과해 최초 필터 RED의 4건도 GREEN을 확인했다. `TIMESTAMP(0)` 저장/flush/clear/재조회 값은 정확히 일치했으며 실패 원인은 미정규화 now 비교였다. 이후 공통 helper 내부에서 now를 한 번만 `truncatedTo(ChronoUnit.SECONDS)` 처리한다.
- GREEN 구현: `AudioContentBannerVisibility.kt`의 `QAudioContentBanner.isVisibleAt(now)`가 `(startDate IS NULL OR startDate <= currentSecond) AND (endDate IS NULL OR endDate >= currentSecond)`를 반환한다. Files의 네 query 구현에서 마지막 optional 생성자 인자 `nowProvider = { LocalDateTime.now(ZoneOffset.UTC) }`를 추가하고 해당 조회의 `.where(...)`에서 helper와 provider를 각각 한 번만 호출한다. 기존 positional 생성자 호출과 Spring 주입은 컴파일/통합 테스트로 확인했다. 기존 join/필터/order/limit/projection 및 port/method/response signature는 그대로다.
- 최종 고정 시각 검증: 공유 16-case matrix × 4경로 = 64건이다. 양쪽 NULL, 시작/종료 단독의 전·동등·후, 양쪽 범위 안·이전·이후·동등, `23:59:59.123456789`/`.999999999` 종료 초 포함, 다음 초 시작 전 제외, UTC 자정 시작 동등·전날 만료를 검증한다. H2 두 컬럼을 각 matrix에서 `TIMESTAMP(0)`로 맞춘 뒤 persist/flush/clear/재조회하여 원래 기대 시각과 정확히 비교한다. 최초 RED의 4건도 같은 기대 결과를 유지한 고정 UTC fixture로 전환했다.
- 기존 조건 회귀: v2의 미래/만료 두 선행 orders 행을 제외하고 `limit=2`를 정상 두 행이 채우는지 확인했다. 메인/구형에 탭·성인·활성·언어 적용 여부와 orders 정렬 보존 테스트 2건을 보강했다. 기존 홈 v2의 활성 대상·양방향 차단·언어·탭·성인·랜덤 tie-breaker/limit 테스트는 유지했고, 오디오 v2의 기존 배너 테스트에 비활성 creator 및 역방향 차단 대상도 추가했다. 실행 SQL에서도 네 경로의 괄호 묶인 nullable AND 조건과 v2의 order/limit 이전 where 적용을 확인했다.
- 첫 통합 GREEN: 아래 최종 명령에서 `ktlintCheck tasks --all`만 제외한 동일 6클래스 focused 명령 — exit 0, `BUILD SUCCESSFUL in 38s`, 237건 통과. 별도 `./gradlew ktlintCheck --console=plain`는 새 테스트의 긴 `saveBanner` 한 줄에서만 실패했다. `apply_patch`로 해당 호출의 인자 줄바꿈만 수정하고 전역 formatter는 실행하지 않았다.
```bash
./gradlew test --tests 'kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerRepositoryTest' --tests 'kr.co.vividnext.sodalive.content.AudioContentRepositoryBannerTest' --tests 'kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest' --tests 'kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest' --tests 'kr.co.vividnext.sodalive.admin.content.banner.AdminContentBannerServiceTest' --tests 'kr.co.vividnext.sodalive.admin.content.banner.AdminContentBannerControllerIntegrationTest' ktlintCheck tasks --all --console=plain
```
- 최종 재검증: 위 명령 exit 0, `BUILD SUCCESSFUL in 37s`, 실제 `:test` 재실행 및 main/test/script ktlint 통과, Gradle task 목록 유효성 확인. `build/test-results/test/TEST-*.xml`를 직접 확인한 집계는 아래와 같으며 failures/errors/skipped는 모두 0이다.
| 테스트 클래스 | tests | failures | errors | skipped |
|---|---:|---:|---:|---:|
| `AudioContentBannerRepositoryTest` | 19 | 0 | 0 | 0 |
| `AudioContentRepositoryBannerTest` | 18 | 0 | 0 | 0 |
| `DefaultHomeRecommendationQueryRepositoryTest` | 98 | 0 | 0 | 0 |
| `DefaultAudioRecommendationQueryRepositoryTest` | 29 | 0 | 0 | 0 |
| `AdminContentBannerControllerIntegrationTest` | 72 | 0 | 0 | 0 |
| `AdminContentBannerServiceTest` | 1 | 0 | 0 | 0 |
| 합계 | 237 | 0 | 0 | 0 |
- 범위/검증 경계: production은 helper 1파일과 공개 query 4파일만 변경했다. 테스트는 기존 3클래스, 신규 구형 query 1클래스와 matrix 1파일이며 관리자·DTO·설정·dependency·DDL 파일은 수정하지 않았다. 공개 조회는 실제 JPA/QueryDSL/H2로 호출했고 관리자 73건은 기존 MockMvc/service 회귀 그대로다. LSP 대신 Kotlin main/test 컴파일과 ktlint로 확인했다. 초기 재컴파일에서 기존 다른 테스트의 unnecessary `!!`/unchecked-cast 경고와 기존 Gradle deprecation 안내가 있었고 관련 없는 코드는 변경하지 않았다.
- 사용자 지정 범위대로 전체 `test`/`build`, 운영 MySQL/DDL 실행, 별도 live HTTP 공개 API 검증은 수행하지 않았다. 공개 persistence 동작은 H2 실행 결과이며 운영 JDBC/session timezone 왕복 검증을 대체하지 않는다. P1-T2만 완료하고 활성/다음 Goal은 `P1-GATE`로 이동한다. 최종 Gate의 독립 판정과 rollout 확인은 parent에게 남기며 Phase/Gate를 완료 처리하지 않는다.
## 최종 검증 기록
- 문서 작성 검토: 필수 project guide 및 sample PRD/plan과 대상 코드·테스트 경로를 대조했다.
- 구현 검증은 수행하지 않았다. 이 산출물은 요구사항/실행 계획이며 product/test 파일은 변경하지 않았다.
### P1-T1 구현 후 추가 검증 — 2026-10-27
- 최종 `./gradlew ktlintCheck tasks --all --console=plain` — exit 0, `BUILD SUCCESSFUL in 16s`. main/test/Kotlin script ktlint와 문서 변경 후 Gradle 명령 목록을 확인했다. 앞선 `./gradlew ktlintCheck --console=plain`도 exit 0, `BUILD SUCCESSFUL in 15s`였다.
- 기존 문서 작성 검토와 구현 미실행 항목은 계획 작성 시점의 이력이다. 이후 P1-T1 구현 및 실제 RED/GREEN 결과는 Progress의 실행 증거와 최종 66건 XML 집계에 기록했다. 보고서는 `build/reports/tests/test/index.html`, 원본 집계는 `build/test-results/test/TEST-*.xml`에서 확인했다.
- LSP 도구는 host에서 제공되지 않는다. focused 명령이 실행한 `compileKotlin`/`compileTestKotlin`과 ktlint로 정적 검증을 대체하며 LSP 검증으로 보고하지 않는다.
- 전체 `test`/`build`는 실행하지 않았다. 관리자 예약 저장 경계에 한정된 P1-T1이며 공통 설정·보안·직렬화를 변경하지 않아 계획/프로젝트 가이드에 따라 focused 3클래스로 회귀 범위를 제한했다. 최초 전체 테스트 소스 컴파일 시 기존 다른 테스트의 non-null/unchecked-cast 경고와 Gradle deprecation 경고가 있었으며 관련 없는 파일은 수정하지 않았다.
### P1-GATE parent 독립 실행 — 2026-10-27
```bash
./gradlew test --rerun --tests '*AdminContentBannerControllerIntegrationTest' --tests '*AdminContentBannerServiceTest' --tests '*AudioContentBannerRepositoryTest' --tests '*AudioContentRepositoryBannerTest' --tests '*DefaultHomeRecommendationQueryRepositoryTest' --tests '*DefaultAudioRecommendationQueryRepositoryTest' ktlintCheck bootJar tasks --all --console=plain
```
- 실제 `:test`를 강제 재실행해 exit 0, `BUILD SUCCESSFUL in 46s`를 확인했다. `bootJar`는 새로 패키징했고 ktlint는 변경 코드 기준 up-to-date 통과했다. Gradle 명령 유효성도 확인했다.
- XML 직접 집계는 관리자 통합 72 + 관리자 서비스 1 + 메인 배너 repository 19 + 구형 배너 repository 18 + 홈 v2 repository 98 + 콘텐츠 추천 v2 repository 29 = 237건이다. 모든 클래스의 failures/errors/skipped는 0이다.
- `git diff --check`는 exit 0이다. 최초 clean 상태 이후 변경은 계획한 관리자·공개 query·테스트 및 작업 문서에 한정되며 기존 사용자 변경을 되돌리거나 commit/push하지 않았다.
- 관리자 surface는 실제 Spring controller/service/repository의 MockMvc multipart POST/PUT와 GET을 재실행했고, 공개 query surface는 실제 QueryDSL/JPA/H2 driver 테스트로 재실행했다. 별도 실서버 curl/운영 HTTP 검증은 수행하지 않았다.
- 전체 `test`/`check`/`build`는 실행하지 않았다. 공통 보안/설정/직렬화 변경이 없고 직접 영향받는 관리자 및 네 공개 query의 focused 회귀로 범위를 한정했다. 실행한 `bootJar`는 전체 회귀 테스트를 의미하지 않는다.
- 운영 MySQL DDL 실행, JDBC/session timezone 및 MySQL TIMESTAMP 허용 범위의 실환경 왕복은 미검증이다. 운영 DB에는 연결·변경하지 않았으며 DDL은 앱 rollout 전에 승인된 배포 절차로 적용해야 한다.
- Oracle의 P1-T2/전체 변경 통합 리뷰를 요청한 상태다. 결과를 수집하고 판정하기 전에는 Gate/Phase를 완료 처리하지 않는다.
### P1-GATE 최종 판정 — 2026-10-27
- 위 parent fresh 실행의 237건/실패 0, ktlint 및 bootJar 성공 결과와 변경 파일·DDL을 다시 대조했다.
- Oracle의 P1-T2/전체 변경 통합 검토 결과는 명세 준수 APPROVE, 코드 품질 APPROVE이며 미해결 actionable finding이 없다. 네 공개 조회의 DB 선필터링, 기본 UTC nowProvider, nullable inclusive 경계, 관리자 예약 무관 조회, QueryProjection 및 기존 응답/필터/정렬의 호환성을 확인했다.
- 상세 리뷰는 `reviews/banner-reservation-final-review.md`에 기록했다. 앞선 관리자 입력 타입 문제는 R1과 `reviews/admin-reservation-review.md`로 수정·검증 완료 상태를 보존했다.
- PRD 수용 기준과 P1-T1/R1/P1-T2/Gate가 모두 충족되어 Phase 1을 완료 처리한다. 개발 범위의 blocked 항목은 없다. 운영 DDL 적용과 실환경 MySQL/timezone 검증은 코드 완료와 구분한 배포 전 조치이며 실행하지 않았다.
- 사용자의 후속 지시로 이후 git 명령은 실행하지 않는다. 그 전에도 commit/staging/branch 변경은 하지 않았고, 후속 지시 이후에는 Gradle 명령·문서 내용만 확인했다.
@@ -0,0 +1,82 @@
# 오디오 콘텐츠 배너 예약 기간 PRD
## 문서 정보
| 항목 | 내용 |
|---|---|
| 문서 상태 | 구현 완료 |
| 작성일 | 2026-10-27 |
| 최종 수정일 | 2026-10-27 |
| 대상 제품 | 관리자 오디오 콘텐츠 배너 예약 노출 |
| 작성자·결정권자 | 제품 승인 요구사항 |
| 관련 API Contract | 관리자 예약 필드 추가, 기존 공개 response 유지, 본 문서 §7 |
| 관련 구현 계획 | `docs/20261027_콘텐츠배너예약기간/plan-task.md` |
| 관련 review | `reviews/admin-reservation-review.md`, `reviews/banner-reservation-final-review.md` |
## 1. Overview
관리자는 오디오 콘텐츠 배너에 선택적인 노출 시작·종료 시각을 설정한다. 공개 배너 조회는 현재 시각이 해당 기간에 포함될 때만 배너를 반환하고, 기존 배너는 NULL 기간으로 계속 노출한다.
## 2. Problem Statement
현재 배너는 노출 시작과 종료 시점을 관리할 수 없어서 기간성 콘텐츠를 수동으로 활성화·비활성화해야 한다. 기간이 지나도 공개 조회에서 배너가 선택될 수 있다.
## 3. Goals
- 관리자가 예약 기간을 생성·조회·수정·해제할 수 있다.
- 공개 오디오 배너 조회 네 경로가 동일한 기간 조건을 적용하고 기존 필터·정렬·limit 동작을 보존한다.
- 기존 레코드의 동작과 공개 응답 스키마를 유지한다.
## 4. Non-Goals
- 오디오 이외 배너 유형, 예약 실행 scheduler, 활성 상태 자동 변경은 범위 밖이다.
- 공개 API 응답 필드, 권한·인증, 공통 시간대 설정 및 dependency 변경은 하지 않는다.
- 운영 DB에 DDL을 자동 실행하지 않는다.
## 5. 사용자 및 권한
- 사용자는 기존 관리자 배너 API의 기존 권한으로 생성·조회·수정한다. 권한 범위는 변경하지 않는다.
- 공개 조회 권한과 성인·언어·탭·비활성·차단 대상 필터는 기존 계약을 유지한다.
## 6. 핵심 흐름
1. 관리자가 생성 요청에 선택적 예약 시각을 입력한다.
2. 관리자가 목록에서 저장된 두 시각을 확인한다. 예약 여부로 관리자 목록 행을 숨기지 않는다.
3. 관리자가 수정을 생략하면 기존 값을 유지하고, 해당 필드를 JSON `null`로 보내면 그 경계만 해제한다.
4. 공개 조회는 예약 기간 안에 있는 활성 배너만 기존 조건에 따라 반환한다.
## 7. 확정 기능 요구사항 및 계약
| ID | 상태 | 요구사항 및 수용 기준 | 계약/Goal |
|---|---|---|---|
| SCH-001 | 확정 | `startDate`, `endDate`는 nullable 시각이다. 둘 다 NULL이면 항상 노출, 시작만 있으면 `startDate <= now`, 종료만 있으면 `now <= endDate`, 둘 다 있으면 `startDate <= now <= endDate`이다. | `P1-T1`, `P1-T2` |
| SCH-002 | 확정 | 관리자 생성 입력의 두 필드는 optional이다. 관리자 목록은 두 값을 반환하되 기간으로 행을 필터링하지 않으며 기존 `isActive` 및 tab 조건을 유지한다. | `P1-T1` |
| SCH-003 | 확정 | 관리자 수정은 생략한 경계를 유지하고, 명시적 JSON null은 해당 값을 해제한다. effective start가 end보다 늦으면 `common.error.invalid_request`로 거부한다. 형식 오류도 같은 공통 오류를 사용하며, 검증은 이미지 업로드 전에 끝난다. | `P1-T1` |
| SCH-004 | 확정 | 관리자 입력 형식은 기존 이벤트 입력 관례인 KST 문자열 `yyyy-MM-dd HH:mm`, 저장은 UTC다. 끝 시각은 입력 분의 `:59`까지 포함한다. 응답 시각은 KST 동일 형식이다. 이는 기존 관리자 이벤트 관례를 적용한 구현 결정이며 사용자 지정 초 형식은 아니다. | `P1-T1` |
| SCH-005 | 확정 | DB는 두 컬럼을 MySQL `TIMESTAMP NULL DEFAULT NULL` 초 정밀도로 추가하고 모든 컬럼에 설명 COMMENT를 둔다. 기존 데이터는 NULL 유지, backfill 없음. DDL은 문서로 제공하며 적용이 앱 rollout보다 먼저다. 운영 DB 자동 실행 금지. | `P1-T1`, Gate |
| SCH-006 | 확정 | 공개 경로 네 곳 모두 기간 필터를 DB 조회에 적용해 limit 전에 제외한다. 기존 조건과 정렬을 보존한다. | `P1-T2` |
| SCH-007 | 확정 | 공개 응답 schema·권한·security는 그대로 유지한다. | 전체 |
관리자 수정에서 JSON 필드 생략과 명시적 null을 구분하기 위해 raw JSON의 presence만 감지한다. 신규 dependency는 추가하지 않는다.
공개 조회 경로는 `AudioContentQueryRepositoryImpl.getAudioContentMainBannerList(isAdult)`, `AudioContentBannerQueryRepositoryImpl.getAudioContentMainBannerList(tabId, isAdult, lang)`, `DefaultHomeRecommendationQueryRepository.findHomeBanners(limit, memberId, includeAdultBanners, lang)`, `DefaultAudioRecommendationQueryRepository.findBanners(limit, memberId, canViewAdultContent, lang)`이다. 각 경로에 이미 적용된 활성·탭·언어·성인·대상 소유자/차단 조건과 기존 정렬을 보존한다.
## 8. 데이터·오류·시각
- 입력 필드명은 사용자 요청대로 `startDate`, `endDate`; 값은 `yyyy-MM-dd HH:mm` 문자열 또는 null이다. 응답은 nullable 문자열이다.
- 형식 오류 및 기간 역전은 기존 `common.error.invalid_request`로 응답한다.
- DDL 소수초 미지정 `TIMESTAMP`의 초 정밀도에 맞춰 초 단위 단일 값으로 저장·응답한다. 공개 조회의 현재 UTC 시각도 초 단위로 비교해 종료 분의 마지막 초 전체를 포함한다. 테스트는 고정 UTC 현재 시각 seam을 사용하고 실제 DB 저장 후 `flush`/`clear`/재조회로 왕복을 확인한다. H2 검증 컬럼은 `TIMESTAMP(0)`로 운영 DDL의 정밀도를 재현한다.
## 9. 성공 기준과 추적
- [x] 관리자 create/update/list에서 선택적 기간, 생략 유지와 명시적 null 해제를 검증한다. (`SCH-002~005`, `P1-T1`)
- [x] 잘못된 입력과 역전 기간이 업로드 전 공통 오류로 거부된다. (`SCH-003`, `P1-T1`, `P1-T1-R1`)
- [x] 네 공개 경로가 경계 시각 포함 여부를 판정하고 예약 외 레코드를 limit 전에 제외한다. (`SCH-001`, `SCH-006`, `P1-T2`)
- [x] 기존 공개 필터·정렬, 관리자 isActive/tab 필터, 공개 응답 schema가 보존된다. (`SCH-002`, `SCH-006~007`, Gate)
- [x] DDL과 앱 배포 순서를 문서화하고 자동 운영 DB 적용이 없다. (`SCH-005`, Gate)
## 10. 제외 항목 및 결정 기록
- 제외: 다른 banner type, scheduler, 공개 response 변경, 자동 isActive 전환, 신규 dependency, global time 설정, live DB 실행.
- 2026-10-27, DEC-001, 확정: 이벤트 관리자의 기존 KST 분 단위 입력/응답 관례를 적용하고 종료 분을 `:59`까지 포함한다. 요구사항은 timestamp 예약을 지정했으며 seconds format을 지정하지 않았으므로 기존 관리자 관례를 따른다.
- 2026-10-27, DEC-002, 확정: 기간 필터는 공개 네 조회에만 적용한다. 관리자 목록은 예약과 무관하게 기존 활성/tab 정책을 유지한다.
@@ -0,0 +1,45 @@
# 관리자 콘텐츠 배너 예약 리뷰
## 리뷰 정보
| 항목 | 내용 |
|---|---|
| 대상 | P1-T1, P1-T1-R1의 working tree 변경 |
| 일자 | 2026-10-27 |
| 리뷰어 | 독립 scoped reviewer, parent 실행 검증 |
| 기준 | `../prd.md` SCH-001~005, `../plan-task.md` |
| 상태 | 수정 검증 완료 |
## 범위와 근거
- 관리자 create/update/list, nullable entity 시각, KST/UTC 변환, MySQL DDL 및 실제 HTTP/DB 테스트를 검토했다.
- `AdminContentBannerService.kt`, `GetAdminContentBannerResponse.kt`, `AdminContentBannerRepository.kt` 및 요청 DTO와 entity의 변경을 확인했다.
- `AdminContentBannerControllerIntegrationTest.kt`의 multipart POST/PUT/GET, H2 `TIMESTAMP(0)` 별도 트랜잭션 재조회 및 S3 미호출 assertion을 확인했다.
- 공개 기간 필터는 다음 P1-T2 범위이므로 이 리뷰에서 판정하지 않았다. 운영 MySQL과 JDBC/session timezone은 검증하지 않았다.
## 발견 사항
| ID | 심각도 | 상태 | 내용 | 후속 Goal |
|---|---|---|---|---|
| REV-ADMIN-001 | Medium | 수정 완료 | 예약 필드에 JSON 객체/배열이 오면 DTO 매핑 단계에서 실패해 `common.error.unknown`으로 반환됨 | P1-T1-R1 |
### REV-ADMIN-001
- 관찰: `startDate: {}` 또는 `endDate: []`에서 Jackson 매핑 예외가 날짜 검증 전에 발생했고, 일반 예외 처리기가 예약 오류 계약과 다른 결과를 반환했다.
- 재현: create/update × start/end × 객체/배열의 HTTP 사례 8개를 추가했다. 실제 RED에서 기존 64건은 통과하고 신규 8건만 실패했다.
- 조치: 같은 JSON 트리에서 두 예약 필드의 타입을 DTO 변환 전에 검사한다. 문자열/null/생략만 허용하며 다른 필드의 기존 매핑 계약이나 전역 예외 처리는 바꾸지 않았다.
- 검증: 오류 키 `common.error.invalid_request`, S3 미호출, 생성 미저장 및 수정 전후 DB row 일치를 확인했다. 기존 생략/null·시간대·관리자 목록 테스트도 유지했다.
## 실행 증거
```bash
./gradlew test --rerun --tests '*AdminContentBannerControllerIntegrationTest' --tests '*AdminContentBannerServiceTest' --tests '*AudioContentBannerRepositoryTest' --console=plain
```
parent가 위 명령을 실행해 exit 0, `BUILD SUCCESSFUL in 36s`를 확인했다. XML 집계는 통합 72 + 기존 서비스 1 + 공개 repository 1 = 74건이며 failures/errors/skipped는 모두 0이다. 구현자의 `ktlintCheck tasks --all`도 exit 0이다. LSP 도구가 없어 Kotlin 컴파일/ktlint를 사용했다.
## 종료 판정
- 최초 발견 사항은 기존 완료 이력을 되돌리지 않고 P1-T1-R1로 누적했다.
- scoped 재검토 결과 REV-ADMIN-001은 ADDRESSED, 명세 준수와 코드 품질 모두 APPROVE다. 수정 범위의 새 actionable finding은 없다.
- 관리자 기능의 미해결 finding은 없다. P1-T2와 최종 Gate는 별도로 수행한다.
@@ -0,0 +1,46 @@
# 콘텐츠 배너 예약 최종 리뷰
## 리뷰 정보
| 항목 | 내용 |
|---|---|
| 대상 | P1-T2와 전체 working tree 변경, P1-GATE |
| 일자 | 2026-10-27 |
| 리뷰어 | Oracle 독립 통합 리뷰, parent 실행 검증 |
| 기준 | `../prd.md`, `../plan-task.md`, `admin-reservation-review.md` |
| 상태 | 판정 완료 |
## 목적과 범위
관리자 예약 설정에서 공개 배너 노출 판정까지 승인된 계약이 연결되는지 확인했다. 관리자 DTO/service/repository/응답·entity, 네 공개 query와 공유 predicate, 관련 테스트·DDL을 검토했다. 새로운 기능 설계나 다른 배너 도메인, 전역 보안·시간대 설정 변경은 범위 밖이다.
## 검토 근거
- 공개 query: `AudioContentQueryRepositoryImpl.getAudioContentMainBannerList`, `AudioContentBannerQueryRepositoryImpl.getAudioContentMainBannerList`, `DefaultHomeRecommendationQueryRepository.findHomeBanners`, `DefaultAudioRecommendationQueryRepository.findBanners` 모두 기존 조건에 공유 `isVisibleAt(now)`를 AND 적용한다. v2는 limit 전에 예약을 제외한다.
- 경계: NULL 제한 없음과 양 끝 포함, 초 단위 UTC now, 종료 분 마지막 초의 나노초·자정 경계를 확인했다. 네 생성자의 optional nowProvider는 기존 호출과 Spring 주입을 유지한다.
- 관리자: 엄격한 문자열/JSON 타입 및 effective 기간 검증이 S3·entity 변경보다 앞선다. update 생략 유지/명시 null 해제, KST 입력/응답·UTC 저장, 관리자 날짜 무관 조회와 기존 active/tab 조건을 확인했다.
- 응답: QueryProjection 보조 생성자와 생성 Q 타입의 `LocalDateTime` 인자 일치 및 UTC→KST 문자열 변환을 확인했다. 공개 서비스의 기존 DTO 매핑은 예약 필드를 공개 응답에 추가하지 않는다.
- 테스트: 공유 16-case matrix × 4 query, 실제 H2 `TIMESTAMP(0)` 저장/flush/clear/재조회, v2 limit 충족 및 관리자 multipart HTTP 계약을 확인했다.
## 실행 증거
parent가 다음 명령을 실행했다. Oracle은 같은 테스트를 중복 실행하지 않고 정적 검토를 수행했다.
```bash
./gradlew test --rerun --tests '*AdminContentBannerControllerIntegrationTest' --tests '*AdminContentBannerServiceTest' --tests '*AudioContentBannerRepositoryTest' --tests '*AudioContentRepositoryBannerTest' --tests '*DefaultHomeRecommendationQueryRepositoryTest' --tests '*DefaultAudioRecommendationQueryRepositoryTest' ktlintCheck bootJar tasks --all --console=plain
```
- exit 0, `BUILD SUCCESSFUL in 46s`; test 강제 재실행, ktlint 통과와 실행 JAR 패키징 성공.
- XML 직접 집계: 72 + 1 + 19 + 18 + 98 + 29 = 237건, failures/errors/skipped 모두 0.
- `git diff --check` exit 0. LSP 도구가 없어 main/test Kotlin 컴파일과 ktlint로 정적 검증을 대체했다.
- 관리자 surface는 MockMvc POST/PUT/GET, 공개 persistence surface는 실제 QueryDSL/JPA/H2로 실행했다. 별도 공개 live HTTP/curl과 운영 DB는 검증하지 않았다.
## 발견 사항과 종료 판정
**확정 발견 사항 없음.** 명세 준수와 코드 품질 모두 APPROVE다. P1-T2/전체 변경을 함께 검토했으며 누락된 조회 경로나 Gate를 막는 테스트 공백을 발견하지 못했다. 신규 회귀 Task 전환 항목은 없다.
앞선 관리자 JSON 객체/배열 오류는 P1-T1-R1로 수정·검증됐으며 이번 전체 검토에서도 유지됨을 확인했다. parent의 fresh 실행 결과와 함께 P1-GATE 완료 근거로 사용한다.
## 잔여 검증 경계
전체 test/check/build는 실행하지 않았고, 직접 영향받는 여섯 클래스 회귀와 bootJar 패키징만 실행했다. 운영 MySQL의 TIMESTAMP 허용 범위, JDBC/session timezone 및 실제 DDL 왕복은 미검증이며 입증된 코드 결함으로 판정하지 않았다. nullable 컬럼 추가 DDL은 backfill 없이 기존 NULL 노출을 유지하며, 앱 rollout 전에 별도 승인 절차로 적용해야 한다.
@@ -46,7 +46,9 @@ class AdminContentBannerQueryRepositoryImpl(
audioContentBanner.series.id, audioContentBanner.series.id,
audioContentBanner.series.title, audioContentBanner.series.title,
audioContentBanner.link, audioContentBanner.link,
audioContentBanner.isAdult audioContentBanner.isAdult,
audioContentBanner.startDate,
audioContentBanner.endDate
) )
) )
.from(audioContentBanner) .from(audioContentBanner)
@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.admin.content.banner package kr.co.vividnext.sodalive.admin.content.banner
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository
import kr.co.vividnext.sodalive.admin.content.tab.AdminContentMainTabRepository import kr.co.vividnext.sodalive.admin.content.tab.AdminContentMainTabRepository
@@ -15,6 +16,11 @@ import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
import java.time.format.ResolverStyle
@Service @Service
class AdminContentBannerService( class AdminContentBannerService(
@@ -31,7 +37,12 @@ class AdminContentBannerService(
) { ) {
@Transactional @Transactional
fun createAudioContentMainBanner(image: MultipartFile, requestString: String) { fun createAudioContentMainBanner(image: MultipartFile, requestString: String) {
val request = objectMapper.readValue(requestString, CreateContentBannerRequest::class.java) val fields = objectMapper.readTree(requestString)
validateReservationTypes(fields)
val request = objectMapper.treeToValue(fields, CreateContentBannerRequest::class.java)
val startDate = parseReservationDate(request.startDate)
val endDate = parseReservationDate(request.endDate)?.withSecond(59)
validateReservationRange(startDate, endDate)
if (request.type == AudioContentBannerType.CREATOR && request.creatorId == null) { if (request.type == AudioContentBannerType.CREATOR && request.creatorId == null) {
throw SodaException(messageKey = "admin.content.banner.creator_required") throw SodaException(messageKey = "admin.content.banner.creator_required")
} }
@@ -79,6 +90,8 @@ class AdminContentBannerService(
audioContentBanner.creator = creator audioContentBanner.creator = creator
audioContentBanner.series = series audioContentBanner.series = series
audioContentBanner.tab = tab audioContentBanner.tab = tab
audioContentBanner.startDate = startDate
audioContentBanner.endDate = endDate
repository.save(audioContentBanner) repository.save(audioContentBanner)
val fileName = generateFileName() val fileName = generateFileName()
@@ -92,10 +105,20 @@ class AdminContentBannerService(
@Transactional @Transactional
fun updateAudioContentMainBanner(image: MultipartFile?, requestString: String) { fun updateAudioContentMainBanner(image: MultipartFile?, requestString: String) {
val request = objectMapper.readValue(requestString, UpdateContentBannerRequest::class.java) val fields = objectMapper.readTree(requestString)
validateReservationTypes(fields)
val request = objectMapper.treeToValue(fields, UpdateContentBannerRequest::class.java)
val audioContentBanner = repository.findByIdOrNull(request.id) val audioContentBanner = repository.findByIdOrNull(request.id)
?: throw SodaException(messageKey = "common.error.invalid_request") ?: throw SodaException(messageKey = "common.error.invalid_request")
val startDate = if (fields.has("startDate")) parseReservationDate(request.startDate) else audioContentBanner.startDate
val endDate = if (fields.has("endDate")) {
parseReservationDate(request.endDate)?.withSecond(59)
} else {
audioContentBanner.endDate
}
validateReservationRange(startDate, endDate)
if (image != null) { if (image != null) {
val fileName = generateFileName() val fileName = generateFileName()
val imagePath = s3Uploader.upload( val imagePath = s3Uploader.upload(
@@ -106,6 +129,9 @@ class AdminContentBannerService(
audioContentBanner.thumbnailImage = imagePath audioContentBanner.thumbnailImage = imagePath
} }
audioContentBanner.startDate = startDate
audioContentBanner.endDate = endDate
if (request.isAdult != null) { if (request.isAdult != null) {
audioContentBanner.isAdult = request.isAdult audioContentBanner.isAdult = request.isAdult
} }
@@ -185,4 +211,32 @@ class AdminContentBannerService(
fun getAudioContentMainBannerList(tabId: Long?): List<GetAdminContentBannerResponse> { fun getAudioContentMainBannerList(tabId: Long?): List<GetAdminContentBannerResponse> {
return repository.getAudioContentMainBannerList(tabId = tabId ?: 1) return repository.getAudioContentMainBannerList(tabId = tabId ?: 1)
} }
private fun validateReservationTypes(fields: JsonNode) {
if (listOf(fields["startDate"], fields["endDate"]).any { it != null && !it.isNull && !it.isTextual }) {
throw SodaException(messageKey = "common.error.invalid_request")
}
}
private fun parseReservationDate(value: String?): LocalDateTime? {
if (value == null) return null
if (value.length != 16) throw SodaException(messageKey = "common.error.invalid_request")
return try {
LocalDateTime.parse(
value,
DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm").withResolverStyle(ResolverStyle.STRICT)
)
.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
} catch (_: DateTimeParseException) {
throw SodaException(messageKey = "common.error.invalid_request")
}
}
private fun validateReservationRange(startDate: LocalDateTime?, endDate: LocalDateTime?) {
if (startDate != null && endDate != null && startDate > endDate) {
throw SodaException(messageKey = "common.error.invalid_request")
}
}
} }
@@ -11,5 +11,7 @@ data class CreateContentBannerRequest(
val creatorId: Long?, val creatorId: Long?,
val seriesId: Long?, val seriesId: Long?,
val link: String?, val link: String?,
val isAdult: Boolean val isAdult: Boolean,
val startDate: String? = null,
val endDate: String? = null
) )
@@ -2,8 +2,11 @@ package kr.co.vividnext.sodalive.admin.content.banner
import com.querydsl.core.annotations.QueryProjection import com.querydsl.core.annotations.QueryProjection
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
data class GetAdminContentBannerResponse @QueryProjection constructor( data class GetAdminContentBannerResponse(
val id: Long, val id: Long,
val tabId: Long?, val tabId: Long?,
val type: AudioContentBannerType, val type: AudioContentBannerType,
@@ -15,5 +18,35 @@ data class GetAdminContentBannerResponse @QueryProjection constructor(
val seriesId: Long?, val seriesId: Long?,
val seriesTitle: String?, val seriesTitle: String?,
val link: String?, val link: String?,
val isAdult: Boolean val isAdult: Boolean,
) val startDate: String? = null,
val endDate: String? = null
) {
@QueryProjection
constructor(
id: Long,
tabId: Long?,
type: AudioContentBannerType,
thumbnailImageUrl: String,
eventId: Long?,
eventThumbnailImage: String?,
creatorId: Long?,
creatorNickname: String?,
seriesId: Long?,
seriesTitle: String?,
link: String?,
isAdult: Boolean,
startDate: LocalDateTime?,
endDate: LocalDateTime?
) : this(
id, tabId, type, thumbnailImageUrl, eventId, eventThumbnailImage, creatorId, creatorNickname,
seriesId, seriesTitle, link, isAdult, formatDate(startDate), formatDate(endDate)
)
companion object {
private fun formatDate(value: LocalDateTime?): String? = value
?.atZone(ZoneId.of("UTC"))
?.withZoneSameInstant(ZoneId.of("Asia/Seoul"))
?.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
}
}
@@ -11,5 +11,7 @@ data class UpdateContentBannerRequest(
val seriesId: Long?, val seriesId: Long?,
val link: String?, val link: String?,
val isAdult: Boolean?, val isAdult: Boolean?,
val isActive: Boolean? val isActive: Boolean?,
val startDate: String? = null,
val endDate: String? = null
) )
@@ -16,6 +16,7 @@ import kr.co.vividnext.sodalive.content.main.QGetAudioContentMainItem
import kr.co.vividnext.sodalive.content.main.QGetAudioContentRankingItem import kr.co.vividnext.sodalive.content.main.QGetAudioContentRankingItem
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
import kr.co.vividnext.sodalive.content.main.banner.QAudioContentBanner.audioContentBanner import kr.co.vividnext.sodalive.content.main.banner.QAudioContentBanner.audioContentBanner
import kr.co.vividnext.sodalive.content.main.banner.isVisibleAt
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCuration import kr.co.vividnext.sodalive.content.main.curation.AudioContentCuration
import kr.co.vividnext.sodalive.content.main.curation.QAudioContentCuration.audioContentCuration import kr.co.vividnext.sodalive.content.main.curation.QAudioContentCuration.audioContentCuration
import kr.co.vividnext.sodalive.content.main.curation.QAudioContentCurationItem.audioContentCurationItem import kr.co.vividnext.sodalive.content.main.curation.QAudioContentCurationItem.audioContentCurationItem
@@ -38,6 +39,7 @@ import org.springframework.beans.factory.annotation.Value
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
@Repository @Repository
interface AudioContentRepository : JpaRepository<AudioContent, Long>, AudioContentQueryRepository interface AudioContentRepository : JpaRepository<AudioContent, Long>, AudioContentQueryRepository
@@ -215,7 +217,8 @@ class AudioContentQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory, private val queryFactory: JPAQueryFactory,
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String private val imageHost: String,
private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(ZoneOffset.UTC) }
) : AudioContentQueryRepository { ) : AudioContentQueryRepository {
override fun findByIdAndActive(contentId: Long): AudioContent? { override fun findByIdAndActive(contentId: Long): AudioContent? {
return queryFactory return queryFactory
@@ -904,7 +907,7 @@ class AudioContentQueryRepositoryImpl(
.leftJoin(audioContentBanner.tab, audioContentMainTab) .leftJoin(audioContentBanner.tab, audioContentMainTab)
.leftJoin(audioContentBanner.event, event) .leftJoin(audioContentBanner.event, event)
.leftJoin(audioContentBanner.creator, member) .leftJoin(audioContentBanner.creator, member)
.where(where) .where(where, audioContentBanner.isVisibleAt(nowProvider()))
.orderBy(audioContentBanner.orders.asc()) .orderBy(audioContentBanner.orders.asc())
.fetch() .fetch()
} }
@@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.event.Event import kr.co.vividnext.sodalive.event.Event
import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import java.time.LocalDateTime
import javax.persistence.Column import javax.persistence.Column
import javax.persistence.Entity import javax.persistence.Entity
import javax.persistence.EnumType import javax.persistence.EnumType
@@ -50,6 +51,12 @@ data class AudioContentBanner(
@Column(nullable = true) @Column(nullable = true)
var link: String? = null var link: String? = null
@Column(name = "start_date", nullable = true)
var startDate: LocalDateTime? = null
@Column(name = "end_date", nullable = true)
var endDate: LocalDateTime? = null
} }
enum class AudioContentBannerType { enum class AudioContentBannerType {
@@ -7,6 +7,8 @@ import kr.co.vividnext.sodalive.event.QEvent.event
import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.QMember.member
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import java.time.LocalDateTime
import java.time.ZoneOffset
interface AudioContentBannerRepository : JpaRepository<AudioContentBanner, Long>, AudioContentBannerQueryRepository interface AudioContentBannerRepository : JpaRepository<AudioContentBanner, Long>, AudioContentBannerQueryRepository
@@ -15,7 +17,8 @@ interface AudioContentBannerQueryRepository {
} }
class AudioContentBannerQueryRepositoryImpl( class AudioContentBannerQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory private val queryFactory: JPAQueryFactory,
private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(ZoneOffset.UTC) }
) : AudioContentBannerQueryRepository { ) : AudioContentBannerQueryRepository {
override fun getAudioContentMainBannerList(tabId: Long, isAdult: Boolean, lang: Lang?): List<AudioContentBanner> { override fun getAudioContentMainBannerList(tabId: Long, isAdult: Boolean, lang: Lang?): List<AudioContentBanner> {
var where = audioContentBanner.isActive.isTrue var where = audioContentBanner.isActive.isTrue
@@ -39,7 +42,7 @@ class AudioContentBannerQueryRepositoryImpl(
.leftJoin(audioContentBanner.tab, audioContentMainTab) .leftJoin(audioContentBanner.tab, audioContentMainTab)
.leftJoin(audioContentBanner.event, event) .leftJoin(audioContentBanner.event, event)
.leftJoin(audioContentBanner.creator, member) .leftJoin(audioContentBanner.creator, member)
.where(where) .where(where, audioContentBanner.isVisibleAt(nowProvider()))
.orderBy(audioContentBanner.orders.asc()) .orderBy(audioContentBanner.orders.asc())
.fetch() .fetch()
} }
@@ -0,0 +1,11 @@
package kr.co.vividnext.sodalive.content.main.banner
import com.querydsl.core.types.dsl.BooleanExpression
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
fun QAudioContentBanner.isVisibleAt(now: LocalDateTime): BooleanExpression {
val currentSecond = now.truncatedTo(ChronoUnit.SECONDS)
return startDate.isNull.or(startDate.loe(currentSecond))
.and(endDate.isNull.or(endDate.goe(currentSecond)))
}
@@ -10,6 +10,7 @@ import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
import kr.co.vividnext.sodalive.content.main.banner.QAudioContentBanner.audioContentBanner import kr.co.vividnext.sodalive.content.main.banner.QAudioContentBanner.audioContentBanner
import kr.co.vividnext.sodalive.content.main.banner.isVisibleAt
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
@@ -34,6 +35,7 @@ import org.springframework.stereotype.Repository
import java.math.BigDecimal import java.math.BigDecimal
import java.math.BigInteger import java.math.BigInteger
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import javax.persistence.EntityManager import javax.persistence.EntityManager
@Repository @Repository
@@ -42,7 +44,8 @@ class DefaultAudioRecommendationQueryRepository(
private val entityManager: EntityManager, private val entityManager: EntityManager,
private val contentTranslationRepository: ContentTranslationRepository, private val contentTranslationRepository: ContentTranslationRepository,
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String private val cloudFrontHost: String,
private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(ZoneOffset.UTC) }
) : AudioRecommendationQueryRepository { ) : AudioRecommendationQueryRepository {
override fun findBanners( override fun findBanners(
limit: Int, limit: Int,
@@ -74,6 +77,7 @@ class DefaultAudioRecommendationQueryRepository(
audioContentBanner.isActive.isTrue, audioContentBanner.isActive.isTrue,
audioContentBanner.tab.id.eq(2L), audioContentBanner.tab.id.eq(2L),
audioContentBanner.lang.eq(lang), audioContentBanner.lang.eq(lang),
audioContentBanner.isVisibleAt(nowProvider()),
adultBannerCondition(canViewAdultContent), adultBannerCondition(canViewAdultContent),
activeBannerTargetCondition(memberId, bannerCreator, seriesOwner) activeBannerTargetCondition(memberId, bannerCreator, seriesOwner)
) )
@@ -15,6 +15,7 @@ import kr.co.vividnext.sodalive.chat.room.QChatMessage.chatMessage
import kr.co.vividnext.sodalive.chat.room.QChatParticipant.chatParticipant import kr.co.vividnext.sodalive.chat.room.QChatParticipant.chatParticipant
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
import kr.co.vividnext.sodalive.content.main.banner.QAudioContentBanner.audioContentBanner import kr.co.vividnext.sodalive.content.main.banner.QAudioContentBanner.audioContentBanner
import kr.co.vividnext.sodalive.content.main.banner.isVisibleAt
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
import kr.co.vividnext.sodalive.event.QEvent.event import kr.co.vividnext.sodalive.event.QEvent.event
@@ -49,13 +50,15 @@ import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapsho
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
import java.sql.Timestamp import java.sql.Timestamp
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import javax.persistence.EntityManager import javax.persistence.EntityManager
@Repository @Repository
class DefaultHomeRecommendationQueryRepository( class DefaultHomeRecommendationQueryRepository(
private val queryFactory: JPAQueryFactory, private val queryFactory: JPAQueryFactory,
private val entityManager: EntityManager, private val entityManager: EntityManager,
private val contentTranslationRepository: ContentTranslationRepository private val contentTranslationRepository: ContentTranslationRepository,
private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(ZoneOffset.UTC) }
) : HomeRecommendationQueryRepository { ) : HomeRecommendationQueryRepository {
override fun findLiveRecommendations( override fun findLiveRecommendations(
offset: Long, offset: Long,
@@ -132,6 +135,7 @@ class DefaultHomeRecommendationQueryRepository(
audioContentBanner.isActive.isTrue, audioContentBanner.isActive.isTrue,
audioContentBanner.tab.isNull, audioContentBanner.tab.isNull,
audioContentBanner.lang.eq(lang), audioContentBanner.lang.eq(lang),
audioContentBanner.isVisibleAt(nowProvider()),
includeAdultBannerCondition(includeAdultBanners), includeAdultBannerCondition(includeAdultBanners),
activeBannerTargetCondition(memberId, bannerCreator, seriesOwner) activeBannerTargetCondition(memberId, bannerCreator, seriesOwner)
) )
@@ -0,0 +1,342 @@
package kr.co.vividnext.sodalive.admin.content.banner
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.content.tab.AdminContentMainTabRepository
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
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.Arguments
import org.junit.jupiter.params.provider.CsvSource
import org.junit.jupiter.params.provider.MethodSource
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.jdbc.core.JdbcTemplate
import org.springframework.mock.web.MockMultipartFile
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.MockHttpServletRequestBuilder
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.support.TransactionTemplate
import java.io.InputStream
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.stream.Stream
import javax.persistence.EntityManager
@SpringBootTest(
properties = [
"cloud.aws.cloud-front.host=https://cdn.test",
"cloud.aws.s3.bucket=test-bucket",
"spring.datasource.url=jdbc:h2:mem:admin-content-banner-controller-integration;" +
"MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"
]
)
@AutoConfigureMockMvc
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class AdminContentBannerControllerIntegrationTest @Autowired constructor(
private val mockMvc: MockMvc,
private val repository: AdminContentBannerRepository,
private val objectMapper: ObjectMapper,
private val jdbcTemplate: JdbcTemplate,
private val entityManager: EntityManager,
private val transactionTemplate: TransactionTemplate,
private val tabRepository: AdminContentMainTabRepository
) {
@MockBean
private lateinit var s3Uploader: S3Uploader
@BeforeEach
fun setUp() {
repository.deleteAll()
tabRepository.deleteAll()
jdbcTemplate.execute("ALTER TABLE content_banner ALTER COLUMN start_date TIMESTAMP(0)")
jdbcTemplate.execute("ALTER TABLE content_banner ALTER COLUMN end_date TIMESTAMP(0)")
Mockito.`when`(
s3Uploader.upload(anyInputStream(), Mockito.anyString(), Mockito.anyString(), Mockito.isNull())
).thenAnswer { it.getArgument(2) }
}
@AfterEach
fun cleanUp() {
repository.deleteAll()
tabRepository.deleteAll()
}
@ParameterizedTest
@CsvSource(
"OMITTED, OMITTED, , ",
"NULL, NULL, , ",
"2026-10-27 00:00, OMITTED, 2026-10-26T15:00:00, ",
"OMITTED, 2026-10-28 23:59, , 2026-10-28T14:59:59",
"2026-10-27 00:00, 2026-10-28 23:59, 2026-10-26T15:00:00, 2026-10-28T14:59:59",
"2026-10-27 23:59, 2026-10-27 23:59, 2026-10-27T14:59:00, 2026-10-27T14:59:59"
)
@DisplayName("관리자 multipart 생성과 목록은 선택적 예약기간을 KST 문자열 또는 JSON null로 반환한다")
fun shouldCreateAndListOptionalReservation(
startDate: String,
endDate: String,
expectedStart: String?,
expectedEnd: String?
) {
val id = createBanner(dateFields(startDate, endDate))
val banner = listBanners().single()
assertDate(banner, "startDate", startDate)
assertDate(banner, "endDate", endDate)
assertEquals("LINK", banner["type"].asText())
assertEquals("https://banner.test", banner["link"].asText())
assertTrue(banner["thumbnailImageUrl"].asText().startsWith("https://cdn.test/audio_content_banner/"))
val stored = reloadBanner(id)
assertEquals(expectedStart?.let(LocalDateTime::parse), stored.startDate)
assertEquals(expectedEnd?.let(LocalDateTime::parse), stored.endDate)
assertEquals(0, stored.startDate?.nano ?: 0)
assertEquals(0, stored.endDate?.nano ?: 0)
assertEquals(Lang.JA, stored.lang)
}
@ParameterizedTest
@CsvSource(
"OMITTED, OMITTED, 2026-10-28 00:00, 2026-10-29 23:59",
"NULL, OMITTED, NULL, 2026-10-29 23:59",
"OMITTED, NULL, 2026-10-28 00:00, NULL",
"NULL, NULL, NULL, NULL",
"2026-10-27 00:00, OMITTED, 2026-10-27 00:00, 2026-10-29 23:59",
"OMITTED, 2026-10-30 23:59, 2026-10-28 00:00, 2026-10-30 23:59",
"2026-10-27 23:59, 2026-10-27 23:59, 2026-10-27 23:59, 2026-10-27 23:59",
"NULL, 2026-10-26 00:00, NULL, 2026-10-26 00:00",
"2026-10-30 00:00, NULL, 2026-10-30 00:00, NULL"
)
@DisplayName("관리자 multipart 수정은 생략한 경계를 보존하고 명시 null만 해제한다")
fun shouldMergeAndPersistReservationUpdates(
startDate: String,
endDate: String,
expectedStart: String,
expectedEnd: String
) {
val id = createScheduledBanner()
Mockito.clearInvocations(s3Uploader)
mockMvc.perform(updateRequest(id, dateFields(startDate, endDate), withImage = false))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
val banner = listBanners().single()
assertDate(banner, "startDate", expectedStart)
assertDate(banner, "endDate", expectedEnd)
val stored = reloadBanner(id)
assertEquals(expectedUtc(expectedStart, 0), stored.startDate)
assertEquals(expectedUtc(expectedEnd, 59), stored.endDate)
Mockito.verifyNoInteractions(s3Uploader)
}
@ParameterizedTest
@MethodSource("invalidReservations", "invalidReservationTypes")
@DisplayName("잘못된 생성 예약 형식과 달력 날짜는 업로드나 저장 전에 공통 오류로 거부한다")
fun shouldRejectInvalidCreateDatesBeforeUpload(field: String, value: Any) {
assertInvalidRequest(
multipart("/admin/audio-content/banner")
.file(image())
.file(requestPart(createFields() + mapOf(field to value)))
.with(user("admin").roles("ADMIN"))
)
assertEquals(0, repository.count())
Mockito.verifyNoInteractions(s3Uploader)
}
@ParameterizedTest
@MethodSource("invalidReservations", "invalidReservationTypes")
@DisplayName("잘못된 수정 예약 형식과 달력 날짜는 이미지와 기존 엔티티를 변경하지 않는다")
fun shouldRejectInvalidUpdateDatesBeforeUploadOrMutation(field: String, value: Any) {
val id = createScheduledBanner()
val before = storedRow(id)
Mockito.clearInvocations(s3Uploader)
assertInvalidRequest(updateRequest(id, mapOf(field to value, "isAdult" to true, "isActive" to false)))
assertEquals(before, storedRow(id))
Mockito.verifyNoInteractions(s3Uploader)
}
@Test
@DisplayName("역전된 생성 예약은 이미지 업로드와 저장 전에 공통 오류로 거부한다")
fun shouldRejectReversedCreateRangeBeforeUpload() {
assertInvalidRequest(
multipart("/admin/audio-content/banner")
.file(image())
.file(requestPart(createFields() + dateFields("2026-10-29 00:00", "2026-10-28 23:59")))
.with(user("admin").roles("ADMIN"))
)
assertEquals(0, repository.count())
Mockito.verifyNoInteractions(s3Uploader)
}
@ParameterizedTest
@CsvSource("2026-10-30 00:00, OMITTED", "OMITTED, 2026-10-27 23:59", "2026-10-30 00:00, 2026-10-29 23:59")
@DisplayName("수정은 기존 경계와 합친 유효 범위의 역전을 업로드나 변경 전에 거부한다")
fun shouldRejectReversedEffectiveUpdateRange(startDate: String, endDate: String) {
val id = createScheduledBanner()
val before = storedRow(id)
Mockito.clearInvocations(s3Uploader)
assertInvalidRequest(
updateRequest(id, dateFields(startDate, endDate) + mapOf("isAdult" to true, "isActive" to false))
)
assertEquals(before, storedRow(id))
Mockito.verifyNoInteractions(s3Uploader)
}
@Test
@DisplayName("관리자 목록은 미래·만료·무기한 예약을 숨기지 않고 기존 active와 tab 조건만 유지한다")
fun shouldListAllReservationPeriodsWithExistingActiveAndTabConditions() {
val future = createBanner(dateFields("2030-01-01 00:00", "2030-01-02 23:59"))
val expired = createBanner(dateFields("2020-01-01 00:00", "2020-01-02 23:59"))
val unscheduled = createBanner()
val inactive = createBanner(dateFields("2030-01-01 00:00", "OMITTED"))
mockMvc.perform(updateRequest(inactive, mapOf("isActive" to false), withImage = false))
.andExpect(jsonPath("$.success").value(true))
tabRepository.saveAndFlush(AudioContentMainTab(title = "default", isActive = true))
val tab = tabRepository.saveAndFlush(AudioContentMainTab(title = "other", isActive = true))
val tabId = tab.id!!
assertTrue(tabId > 1)
val otherTab = createBanner(dateFields("2020-01-01 00:00", "2020-01-02 23:59") + mapOf("tabId" to tabId))
assertEquals(setOf(future, expired, unscheduled), listBanners().map { it["id"].asLong() }.toSet())
assertEquals(setOf(future, expired, unscheduled), listBanners(1L).map { it["id"].asLong() }.toSet())
assertEquals(listOf(otherTab), listBanners(tabId).map { it["id"].asLong() })
}
private fun createBanner(fields: Map<String, Any?> = emptyMap()): Long {
val existingIds = repository.findAll().map { it.id }.toSet()
mockMvc.perform(
multipart("/admin/audio-content/banner")
.file(image())
.file(requestPart(createFields() + fields))
.with(user("admin").roles("ADMIN"))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
return repository.findAll().single { it.id !in existingIds }.id!!
}
private fun createFields(): Map<String, Any?> = mapOf(
"type" to "LINK",
"lang" to "ja",
"link" to "https://banner.test",
"isAdult" to false
)
private fun createScheduledBanner(): Long = createBanner(dateFields("2026-10-28 00:00", "2026-10-29 23:59"))
private fun updateRequest(
id: Long,
fields: Map<String, Any?>,
withImage: Boolean = true
): MockHttpServletRequestBuilder {
val request = multipart("/admin/audio-content/banner")
.file(requestPart(mapOf("id" to id) + fields))
if (withImage) request.file(image())
return request.with {
it.method = "PUT"
it
}.with(user("admin").roles("ADMIN"))
}
private fun assertInvalidRequest(request: MockHttpServletRequestBuilder) {
val result = mockMvc.perform(request)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(false))
.andReturn()
assertTrue(result.resolvedException is SodaException)
assertEquals("common.error.invalid_request", (result.resolvedException as SodaException).messageKey)
}
private fun image() = MockMultipartFile("image", "banner.png", "image/png", "image".toByteArray())
private fun requestPart(fields: Map<String, Any?>) =
MockMultipartFile("request", "", "application/json", objectMapper.writeValueAsBytes(fields))
private fun listBanners(tabId: Long? = null): List<JsonNode> {
val request = get("/admin/audio-content/banner").with(user("admin").roles("ADMIN"))
if (tabId != null) request.param("tabId", tabId.toString())
val result = mockMvc.perform(request)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andReturn()
return objectMapper.readTree(result.response.contentAsString)["data"].toList()
}
private fun assertDate(banner: JsonNode, field: String, expected: String) {
assertTrue(banner.has(field), "Admin HTTP response must include $field")
if (expected == "OMITTED" || expected == "NULL") {
assertTrue(banner[field].isNull, "$field must be JSON null")
} else {
assertEquals(expected, banner[field].asText())
}
}
private fun dateFields(startDate: String, endDate: String): Map<String, Any?> {
return mapOf("startDate" to startDate, "endDate" to endDate)
.filterValues { it != "OMITTED" }
.mapValues { if (it.value == "NULL") null else it.value }
}
private fun reloadBanner(id: Long): AudioContentBanner = transactionTemplate.execute {
entityManager.flush()
entityManager.clear()
repository.findById(id).orElseThrow()
}!!
private fun storedRow(id: Long): Map<String, Any> = jdbcTemplate.queryForMap("SELECT * FROM content_banner WHERE id = ?", id)
private fun expectedUtc(value: String, second: Int): LocalDateTime? = if (value == "NULL") {
null
} else {
LocalDateTime.parse(value, DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm")).minusHours(9).withSecond(second)
}
private fun anyInputStream(): InputStream = Mockito.any(InputStream::class.java) ?: byteArrayOf().inputStream()
companion object {
@JvmStatic
private fun invalidReservationTypes(): Stream<Arguments> = listOf(emptyMap<String, Any>(), emptyList<Any>())
.flatMap { value -> listOf("startDate", "endDate").map { field -> Arguments.of(field, value) } }.stream()
@JvmStatic
private fun invalidReservations(): Stream<Arguments> = listOf(
"not-a-date",
"2026-02-29 10:00",
"2026-04-31 10:00",
"2026-13-01 10:00",
"2026-10-27 24:00",
"2026-10-27T10:00",
"2026-10-27 10:00:00",
"2026-1-01 10:00",
"+02026-10-27 10:00",
" 2026-10-27 10:00",
""
).flatMap { value -> listOf("startDate", "endDate").map { field -> Arguments.of(field, value) } }.stream()
}
}
@@ -0,0 +1,90 @@
package kr.co.vividnext.sodalive.content
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerReservationFixtures
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
import kr.co.vividnext.sodalive.content.main.banner.BannerReservationCase
import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab
import kr.co.vividnext.sodalive.i18n.Lang
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import javax.persistence.EntityManager
@DataJpaTest(properties = ["spring.cache.type=none"])
@Import(QueryDslConfig::class)
class AudioContentRepositoryBannerTest @Autowired constructor(
private val entityManager: EntityManager,
queryFactory: JPAQueryFactory
) {
private var bannerNow = AudioContentBannerReservationFixtures.now
private val repository = AudioContentQueryRepositoryImpl(queryFactory, "https://cdn.test") { bannerNow }
@Test
@DisplayName("구형 공개 배너는 미래 및 만료 예약을 제외한다")
fun shouldExcludeFutureAndExpiredReservations() {
val now = bannerNow
listOf("future", "expired", "visible").forEachIndexed { index, name ->
entityManager.persist(
AudioContentBanner(thumbnailImage = name, type = AudioContentBannerType.LINK, orders = index).apply {
if (name == "future") startDate = now.plusYears(1).withNano(0)
if (name == "expired") endDate = now.minusYears(1).withNano(0)
}
)
}
entityManager.flush()
entityManager.clear()
assertEquals(listOf("visible"), repository.getAudioContentMainBannerList(false).map { it.thumbnailImage })
}
@ParameterizedTest(name = "{0}")
@MethodSource("kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerReservationFixtures#cases")
@DisplayName("구형 공개 배너는 UTC 초 정밀도의 예약 경계를 포함한다")
fun shouldApplyReservationBoundaries(case: BannerReservationCase) {
bannerNow = case.now
entityManager.createNativeQuery("alter table content_banner alter column start_date timestamp(0)").executeUpdate()
entityManager.createNativeQuery("alter table content_banner alter column end_date timestamp(0)").executeUpdate()
val banner = AudioContentBanner(thumbnailImage = "candidate", type = AudioContentBannerType.LINK).apply {
startDate = case.startDate
endDate = case.endDate
}
entityManager.persist(banner)
entityManager.flush()
entityManager.clear()
val stored = entityManager.find(AudioContentBanner::class.java, banner.id)
assertEquals(case.startDate, stored.startDate)
assertEquals(case.endDate, stored.endDate)
assertEquals(
if (case.visible) listOf("candidate") else emptyList(),
repository.getAudioContentMainBannerList(false).map { it.thumbnailImage }
)
}
@Test
@DisplayName("구형 공개 배너는 언어 제한 없이 기존 활성 성인 홈 탭 조건과 정렬을 유지한다")
fun shouldPreserveActiveAdultHomeTabAndOrderFilters() {
val tab = AudioContentMainTab(title = "tab", isActive = true)
entityManager.persist(tab)
listOf(
AudioContentBanner("ko", AudioContentBannerType.LINK, Lang.KO, orders = 2),
AudioContentBanner("ja", AudioContentBannerType.LINK, Lang.JA, orders = 1),
AudioContentBanner("adult", AudioContentBannerType.LINK, isAdult = true, orders = 0),
AudioContentBanner("inactive", AudioContentBannerType.LINK, isActive = false, orders = 0),
AudioContentBanner("tab", AudioContentBannerType.LINK, orders = 0).apply { this.tab = tab }
).forEach { entityManager.persist(it) }
entityManager.flush()
entityManager.clear()
assertEquals(listOf("ja", "ko"), repository.getAudioContentMainBannerList(false).map { it.thumbnailImage })
assertEquals(listOf("adult", "ja", "ko"), repository.getAudioContentMainBannerList(true).map { it.thumbnailImage })
}
}
@@ -1,19 +1,105 @@
package kr.co.vividnext.sodalive.content.main.banner package kr.co.vividnext.sodalive.content.main.banner
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.configs.QueryDslConfig import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab
import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.i18n.Lang
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import import org.springframework.context.annotation.Import
import javax.persistence.EntityManager
@DataJpaTest @DataJpaTest(properties = ["spring.cache.type=none"])
@Import(QueryDslConfig::class) @Import(QueryDslConfig::class)
class AudioContentBannerRepositoryTest @Autowired constructor( class AudioContentBannerRepositoryTest @Autowired constructor(
private val repository: AudioContentBannerRepository private val repository: AudioContentBannerRepository,
private val entityManager: EntityManager,
queryFactory: JPAQueryFactory
) { ) {
private var bannerNow = AudioContentBannerReservationFixtures.now
private val queryRepository = AudioContentBannerQueryRepositoryImpl(queryFactory) { bannerNow }
@Test
@DisplayName("공개 메인 배너는 미래 및 만료 예약을 제외한다")
fun shouldExcludeFutureAndExpiredReservations() {
val now = bannerNow
listOf("future", "expired", "visible").forEachIndexed { index, name ->
repository.save(
AudioContentBanner(thumbnailImage = name, type = AudioContentBannerType.LINK, orders = index).apply {
if (name == "future") startDate = now.plusYears(1).withNano(0)
if (name == "expired") endDate = now.minusYears(1).withNano(0)
}
)
}
entityManager.flush()
entityManager.clear()
assertEquals(
listOf("visible"),
queryRepository.getAudioContentMainBannerList(1L, false).map { it.thumbnailImage }
)
}
@ParameterizedTest(name = "{0}")
@MethodSource("kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerReservationFixtures#cases")
@DisplayName("공개 메인 배너는 UTC 초 정밀도의 예약 경계를 포함한다")
fun shouldApplyReservationBoundaries(case: BannerReservationCase) {
bannerNow = case.now
entityManager.createNativeQuery("alter table content_banner alter column start_date timestamp(0)").executeUpdate()
entityManager.createNativeQuery("alter table content_banner alter column end_date timestamp(0)").executeUpdate()
val banner = repository.save(
AudioContentBanner(thumbnailImage = "candidate", type = AudioContentBannerType.LINK).apply {
startDate = case.startDate
endDate = case.endDate
}
)
entityManager.flush()
entityManager.clear()
val stored = entityManager.find(AudioContentBanner::class.java, banner.id)
assertEquals(case.startDate, stored.startDate)
assertEquals(case.endDate, stored.endDate)
assertEquals(
if (case.visible) listOf("candidate") else emptyList(),
queryRepository.getAudioContentMainBannerList(1L, false).map { it.thumbnailImage }
)
}
@Test
@DisplayName("공개 메인 배너는 기존 탭 성인 활성 언어 조건과 정렬을 유지한다")
fun shouldPreserveTabAdultActiveLanguageAndOrderFilters() {
entityManager.createNativeQuery(
"insert into audio_content_main_tab (id, title, is_active) values (2, 'tab', true)"
).executeUpdate()
val tab = entityManager.getReference(AudioContentMainTab::class.java, 2L)
listOf(
AudioContentBanner("ko", AudioContentBannerType.LINK, Lang.KO, orders = 3),
AudioContentBanner("ja", AudioContentBannerType.LINK, Lang.JA, orders = 2),
AudioContentBanner("adult", AudioContentBannerType.LINK, Lang.JA, isAdult = true, orders = 0),
AudioContentBanner("inactive", AudioContentBannerType.LINK, Lang.JA, isActive = false, orders = 0),
AudioContentBanner("tab", AudioContentBannerType.LINK, Lang.JA).apply { this.tab = tab },
AudioContentBanner("future-tab", AudioContentBannerType.LINK, Lang.JA).apply {
this.tab = tab
startDate = bannerNow.plusDays(1)
}
).forEach { repository.save(it) }
entityManager.flush()
entityManager.clear()
assertEquals(listOf("ja"), queryRepository.getAudioContentMainBannerList(1L, false, Lang.JA).map { it.thumbnailImage })
assertEquals(
listOf("adult", "ja"),
queryRepository.getAudioContentMainBannerList(1L, true, Lang.JA).map { it.thumbnailImage }
)
assertEquals(listOf("ja", "ko"), queryRepository.getAudioContentMainBannerList(1L, false).map { it.thumbnailImage })
assertEquals(listOf("tab"), queryRepository.getAudioContentMainBannerList(2L, false, Lang.JA).map { it.thumbnailImage })
}
@Test @Test
@DisplayName("사용자 배너 조회는 요청 언어와 일치하는 배너만 반환한다") @DisplayName("사용자 배너 조회는 요청 언어와 일치하는 배너만 반환한다")
fun shouldReturnOnlyRequestedLanguageBanners() { fun shouldReturnOnlyRequestedLanguageBanners() {
@@ -0,0 +1,42 @@
package kr.co.vividnext.sodalive.content.main.banner
import java.time.LocalDateTime
import java.util.stream.Stream
data class BannerReservationCase(
val name: String,
val now: LocalDateTime,
val startDate: LocalDateTime?,
val endDate: LocalDateTime?,
val visible: Boolean
) {
override fun toString(): String = name
}
object AudioContentBannerReservationFixtures {
val now: LocalDateTime = LocalDateTime.of(2026, 10, 27, 23, 59, 59)
@JvmStatic
fun cases(): Stream<BannerReservationCase> {
val before = now.minusSeconds(1)
val after = now.plusSeconds(1)
return Stream.of(
BannerReservationCase("unbounded", now, null, null, true),
BannerReservationCase("start-before", now, before, null, true),
BannerReservationCase("start-exact", now, now, null, true),
BannerReservationCase("start-after", now, after, null, false),
BannerReservationCase("end-before", now, null, before, false),
BannerReservationCase("end-exact", now, null, now, true),
BannerReservationCase("end-after", now, null, after, true),
BannerReservationCase("inside", now, before, after, true),
BannerReservationCase("before-range", now, after, after.plusSeconds(1), false),
BannerReservationCase("after-range", now, before.minusSeconds(1), before, false),
BannerReservationCase("both-exact", now, now, now, true),
BannerReservationCase("end-second-123456789", now.withNano(123456789), before, now, true),
BannerReservationCase("end-second-999999999", now.withNano(999999999), before, now, true),
BannerReservationCase("next-second-not-started", now.withNano(999999999), after, null, false),
BannerReservationCase("midnight-start-exact", after, after, null, true),
BannerReservationCase("midnight-expired", after, null, now, false)
)
}
}
@@ -7,7 +7,9 @@ import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.comment.AudioContentComment import kr.co.vividnext.sodalive.content.comment.AudioContentComment
import kr.co.vividnext.sodalive.content.like.AudioContentLike import kr.co.vividnext.sodalive.content.like.AudioContentLike
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerReservationFixtures
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
import kr.co.vividnext.sodalive.content.main.banner.BannerReservationCase
import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.content.translation.ContentTranslation import kr.co.vividnext.sodalive.content.translation.ContentTranslation
@@ -26,6 +28,8 @@ import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import import org.springframework.context.annotation.Import
@@ -44,12 +48,55 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
queryFactory: JPAQueryFactory, queryFactory: JPAQueryFactory,
contentTranslationRepository: ContentTranslationRepository contentTranslationRepository: ContentTranslationRepository
) { ) {
private var bannerNow = AudioContentBannerReservationFixtures.now
private val repository = DefaultAudioRecommendationQueryRepository( private val repository = DefaultAudioRecommendationQueryRepository(
queryFactory, queryFactory,
entityManager, entityManager,
contentTranslationRepository, contentTranslationRepository,
"https://cdn.test" "https://cdn.test"
) ) { bannerNow }
@Test
@DisplayName("콘텐츠 추천 배너는 limit 전에 미래 및 만료 예약을 제외한다")
fun shouldExcludeFutureAndExpiredReservationsBeforeLimit() {
val now = bannerNow
val tab = saveContentRecommendationTab()
listOf("future", "expired", "visible-1", "visible-2").forEachIndexed { index, name ->
saveBanner(name, AudioContentBannerType.LINK, index, tab = tab).apply {
if (name == "future") startDate = now.plusYears(1).withNano(0)
if (name == "expired") endDate = now.minusYears(1).withNano(0)
}
}
flushAndClear()
assertEquals(
listOf("https://cdn.test/visible-1", "https://cdn.test/visible-2"),
repository.findBanners(2, null, false).map { it.imageUrl }
)
}
@ParameterizedTest(name = "{0}")
@MethodSource("kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerReservationFixtures#cases")
@DisplayName("콘텐츠 추천 배너는 UTC 초 정밀도의 예약 경계를 포함한다")
fun shouldApplyReservationBoundaries(case: BannerReservationCase) {
bannerNow = case.now
entityManager.createNativeQuery("alter table content_banner alter column start_date timestamp(0)").executeUpdate()
entityManager.createNativeQuery("alter table content_banner alter column end_date timestamp(0)").executeUpdate()
val tab = saveContentRecommendationTab()
val banner = saveBanner("candidate", AudioContentBannerType.LINK, 1, tab = tab).apply {
startDate = case.startDate
endDate = case.endDate
}
flushAndClear()
val stored = entityManager.find(AudioContentBanner::class.java, banner.id)
assertEquals(case.startDate, stored.startDate)
assertEquals(case.endDate, stored.endDate)
assertEquals(
if (case.visible) listOf("https://cdn.test/candidate") else emptyList(),
repository.findBanners(2, null, false).map { it.imageUrl }
)
}
@Test @Test
@DisplayName("배너는 콘텐츠 추천 탭과 성인 조회 가능 여부를 적용한다") @DisplayName("배너는 콘텐츠 추천 탭과 성인 조회 가능 여부를 적용한다")
@@ -57,6 +104,8 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
val viewer = saveMember("viewer", MemberRole.USER) val viewer = saveMember("viewer", MemberRole.USER)
val visibleCreator = saveMember("visible-creator", MemberRole.CREATOR) val visibleCreator = saveMember("visible-creator", MemberRole.CREATOR)
val blockedCreator = saveMember("blocked-creator", MemberRole.CREATOR) val blockedCreator = saveMember("blocked-creator", MemberRole.CREATOR)
val reverseBlockedCreator = saveMember("reverse-blocked-creator", MemberRole.CREATOR)
val inactiveCreator = saveMember("inactive-banner-creator", MemberRole.CREATOR, isActive = false)
val recommendationTab = saveContentRecommendationTab() val recommendationTab = saveContentRecommendationTab()
val visibleBanner = saveBanner( val visibleBanner = saveBanner(
"visible.png", "visible.png",
@@ -89,7 +138,16 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
tab = recommendationTab tab = recommendationTab
) )
saveBanner("home.png", AudioContentBannerType.LINK, 1, link = "https://home.test") saveBanner("home.png", AudioContentBannerType.LINK, 1, link = "https://home.test")
saveBanner("inactive-target.png", AudioContentBannerType.CREATOR, 0, creator = inactiveCreator, tab = recommendationTab)
saveBanner(
"reverse-blocked.png",
AudioContentBannerType.CREATOR,
0,
creator = reverseBlockedCreator,
tab = recommendationTab
)
saveBlock(viewer, blockedCreator) saveBlock(viewer, blockedCreator)
saveBlock(reverseBlockedCreator, viewer)
flushAndClear() flushAndClear()
val safeBanners = repository.findBanners(limit = 20, memberId = viewer.id, canViewAdultContent = false) val safeBanners = repository.findBanners(limit = 20, memberId = viewer.id, canViewAdultContent = false)
@@ -18,7 +18,9 @@ import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.comment.AudioContentComment import kr.co.vividnext.sodalive.content.comment.AudioContentComment
import kr.co.vividnext.sodalive.content.like.AudioContentLike import kr.co.vividnext.sodalive.content.like.AudioContentLike
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerReservationFixtures
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
import kr.co.vividnext.sodalive.content.main.banner.BannerReservationCase
import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.content.translation.ContentTranslation import kr.co.vividnext.sodalive.content.translation.ContentTranslation
@@ -49,6 +51,8 @@ import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomePopularCommunityR
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
import org.mockito.ArgumentMatchers.any import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.anyString import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mockito import org.mockito.Mockito
@@ -71,9 +75,49 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
queryFactory: JPAQueryFactory, queryFactory: JPAQueryFactory,
contentTranslationRepository: ContentTranslationRepository contentTranslationRepository: ContentTranslationRepository
) { ) {
private val repository = DefaultHomeRecommendationQueryRepository(queryFactory, entityManager, contentTranslationRepository) private var bannerNow = AudioContentBannerReservationFixtures.now
private val repository = DefaultHomeRecommendationQueryRepository(queryFactory, entityManager, contentTranslationRepository) {
bannerNow
}
private val scorePolicy = RecommendationScorePolicy() private val scorePolicy = RecommendationScorePolicy()
@Test
@DisplayName("홈 배너는 limit 전에 미래 및 만료 예약을 제외한다")
fun shouldExcludeFutureAndExpiredReservationsBeforeLimit() {
val now = bannerNow
listOf("future", "expired", "visible-1", "visible-2").forEachIndexed { index, name ->
saveBanner(name, AudioContentBannerType.LINK, index, true).apply {
if (name == "future") startDate = now.plusYears(1).withNano(0)
if (name == "expired") endDate = now.minusYears(1).withNano(0)
}
}
flushAndClear()
assertEquals(listOf("visible-1", "visible-2"), repository.findHomeBanners(limit = 2).map { it.thumbnailImage })
}
@ParameterizedTest(name = "{0}")
@MethodSource("kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerReservationFixtures#cases")
@DisplayName("홈 배너는 UTC 초 정밀도의 예약 경계를 포함한다")
fun shouldApplyReservationBoundaries(case: BannerReservationCase) {
bannerNow = case.now
entityManager.createNativeQuery("alter table content_banner alter column start_date timestamp(0)").executeUpdate()
entityManager.createNativeQuery("alter table content_banner alter column end_date timestamp(0)").executeUpdate()
val banner = saveBanner("candidate", AudioContentBannerType.LINK, 1, true).apply {
startDate = case.startDate
endDate = case.endDate
}
flushAndClear()
val stored = entityManager.find(AudioContentBanner::class.java, banner.id)
assertEquals(case.startDate, stored.startDate)
assertEquals(case.endDate, stored.endDate)
assertEquals(
if (case.visible) listOf("candidate") else emptyList(),
repository.findHomeBanners(limit = 2).map { it.thumbnailImage }
)
}
@Test @Test
@DisplayName("라이브 추천은 활성 크리에이터의 진행 라이브를 최신순 최대 20개 조회한다") @DisplayName("라이브 추천은 활성 크리에이터의 진행 라이브를 최신순 최대 20개 조회한다")
fun shouldFindLatestLiveRecommendationsWithLimit() { fun shouldFindLatestLiveRecommendationsWithLimit() {