Merge pull request 'test' (#428) from test into main

Reviewed-on: #428
This commit is contained in:
2026-06-29 08:06:51 +00:00
21 changed files with 1468 additions and 91 deletions

View File

@@ -0,0 +1,114 @@
# 관리자 라이브 추천 크리에이터 배너 LazyInitializationException 수정 Implementation Plan
> **For agentic workers:** 각 task는 TDD 기준으로 RED 실패 확인 후 GREEN 최소 구현을 진행한다. 구현 완료 즉시 체크박스와 검증 기록을 갱신한다.
**Goal:** `spring.jpa.open-in-view=false` 환경에서 관리자 라이브 추천 크리에이터 배너 목록 조회가 `RecommendLiveCreatorBanner.creator` lazy proxy 접근 때문에 실패하지 않게 한다.
**Architecture:** 기존 관리자 API 응답 DTO와 URL은 유지한다. `AdminLiveService.getRecommendCreator(...)`에 read-only 트랜잭션 경계를 적용하고, 그 경계 안에서 배너 조회와 `GetAdminRecommendCreatorResponse` 생성을 완료해 lazy proxy 접근을 안전하게 처리한다. 등록/수정/정렬/라이브 취소 쓰기 메서드의 기존 메서드 레벨 `@Transactional`은 유지한다.
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring Data JPA, QueryDSL, JUnit 5, Gradle Wrapper
---
## 0. 구현 전 확정 사항
- API 응답 스키마는 변경하지 않는다.
- OSIV 설정을 켜지 않는다.
- `RecommendLiveCreatorBanner.creator`를 eager로 바꾸지 않는다.
- 사용자용 라이브 추천 크리에이터 조회 흐름은 변경하지 않는다.
- QueryDSL projection/fetch join 전면 개편은 이번 범위에서 제외한다.
- 원인 확인:
- `AdminLiveRoomQueryRepository.getRecommendCreatorList(...)``RecommendLiveCreatorBanner` 엔티티를 반환한다.
- `RecommendLiveCreatorBanner.creator`는 lazy association이다.
- `AdminLiveService.getRecommendCreator(...)``it.creator!!.id/nickname`을 읽는다.
- `AdminLiveService.getRecommendCreator(...)`에 조회 트랜잭션 경계가 없어 OSIV off 환경에서는 lazy proxy 초기화가 실패할 수 있다.
---
## 1. 파일 구조 계획
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveService.kt`
- `getRecommendCreator(pageable: Pageable)``@Transactional(readOnly = true)`를 추가한다.
- 기존 쓰기 메서드의 메서드 레벨 `@Transactional`은 유지한다.
- DTO 변환, 이미지 URL 조합, 시간 포맷, 정렬 흐름은 변경하지 않는다.
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveServiceIntegrationTest.kt`
- OSIV off JPA 환경에서 서비스가 관리자 라이브 추천 크리에이터 배너 목록 response를 생성할 때 lazy 초기화 예외가 발생하지 않는지 검증한다.
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveControllerIntegrationTest.kt`
- 실제 Spring Context, `MockMvc`, JPA fixture로 `/admin/live/recommend-creator` API 응답과 관리자 권한을 검증한다.
- Verify: `src/test/resources/application.yml`
- `spring.jpa.open-in-view: false` 테스트 설정을 그대로 사용한다.
---
### Phase 1: LazyInitializationException 재현 테스트
- [x] **Task 1.1: 서비스 통합 실패 테스트 작성**
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveServiceIntegrationTest.kt`
- RED: `@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://cdn.test", ...])` 통합 테스트를 추가한다.
- RED: `@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])`를 추가해 기존 Spring Boot 통합 테스트 Redis 패턴을 따른다.
- RED: 테스트 클래스에는 `@Transactional`을 붙이지 않아 테스트 트랜잭션이 lazy 문제를 가리지 않게 한다.
- RED: `TransactionTemplate` 안에서 `Member(role = CREATOR)``RecommendLiveCreatorBanner`를 저장하고 `EntityManager.flush()`, `EntityManager.clear()`로 영속성 컨텍스트를 비운다.
- RED: `service.getRecommendCreator(PageRequest.of(0, 20))`를 호출해 `totalCount`, `creatorId`, `creatorNickname`, `image`, `startDate`, `endDate`, `isAdult`를 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.admin.live.AdminLiveServiceIntegrationTest`
- 기대 결과: production code 수정 전에는 `LazyInitializationException`으로 테스트가 실패한다.
- 검증 기록: production code 수정 전 `./gradlew test --tests kr.co.vividnext.sodalive.admin.live.AdminLiveServiceIntegrationTest`를 실행했고, `AdminLiveServiceIntegrationTest.kt:39`에서 `org.hibernate.LazyInitializationException`으로 실패해 RED를 확인했다.
---
### Phase 2: 서비스 조회 트랜잭션 보강
- [x] **Task 2.1: 추천 크리에이터 배너 목록 조회 read-only 트랜잭션 적용**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveService.kt`
- GREEN: `getRecommendCreator(pageable: Pageable)``@Transactional(readOnly = true)`를 추가한다.
- GREEN: 기존 DTO 변환 로직은 유지하고, 추가적인 fetch 전략 변경이나 응답 구조 변경을 하지 않는다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.admin.live.AdminLiveServiceIntegrationTest`
- 기대 결과: `BUILD SUCCESSFUL`
- REFACTOR: 불필요한 import/format 변경이 생기지 않았는지 확인한다.
- 검증 기록: `getRecommendCreator(pageable: Pageable)`에만 `@Transactional(readOnly = true)`를 추가한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.admin.live.AdminLiveServiceIntegrationTest`를 재실행했고, `BUILD SUCCESSFUL`을 확인했다.
---
### Phase 3: 실제 Spring Context 기반 관리자 목록 API 검증
- [x] **Task 3.1: 관리자 목록 API 통합 테스트 작성**
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveControllerIntegrationTest.kt`
- RED/GREEN: 서비스 수정 후 실제 API 경로가 같은 응답을 반환하는 회귀 테스트를 작성한다.
- GREEN: `@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://cdn.test", ...])``@AutoConfigureMockMvc`를 사용한다.
- GREEN: `@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])`를 추가한다.
- GREEN: 테스트 데이터 생성은 `TransactionTemplate` 안에서 수행하고 `EntityManager.flush()`, `EntityManager.clear()`로 영속성 컨텍스트를 비운다.
- GREEN: `MockMvc``GET /admin/live/recommend-creator?page=0&size=20`을 호출하고 `with(user("admin").roles("ADMIN"))`로 관리자 권한을 부여한다.
- GREEN: `$.success = true`, `$.data.totalCount = 1`, `$.data.recommendCreatorList[0].creatorNickname`, `$.data.recommendCreatorList[0].image`, `$.data.recommendCreatorList[0].startDate`, `$.data.recommendCreatorList[0].endDate`, `$.data.recommendCreatorList[0].isAdult`를 검증한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.admin.live.AdminLiveControllerIntegrationTest`
- 기대 결과: `BUILD SUCCESSFUL`
- 검증 기록: `./gradlew test --tests kr.co.vividnext.sodalive.admin.live.AdminLiveControllerIntegrationTest`를 실행했고, 관리자 권한 MockMvc `GET /admin/live/recommend-creator?page=0&size=20` 응답 검증이 `BUILD SUCCESSFUL`로 통과했다.
- [x] **Task 3.2: 관련 검증 실행 및 문서 기록**
- Verify: `./gradlew test --tests kr.co.vividnext.sodalive.admin.live.AdminLiveServiceIntegrationTest`
- Verify: `./gradlew test --tests kr.co.vividnext.sodalive.admin.live.AdminLiveControllerIntegrationTest`
- Verify: `./gradlew test --tests kr.co.vividnext.sodalive.admin.live.AdminLiveServiceTest`
- Verify: `./gradlew --no-daemon ktlintCheck`
- Verify: `./gradlew --no-daemon tasks --all`
- 문서 기록: 각 task 아래에 실행 명령, 결과, 검증 이유를 한국어로 누적한다.
- 기대 결과: 모든 명령이 `BUILD SUCCESSFUL`로 종료된다.
- 검증 기록:
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.live.AdminLiveServiceIntegrationTest`: `BUILD SUCCESSFUL`. 서비스 트랜잭션 경계 안에서 lazy creator 접근과 DTO 변환이 완료되는지 재검증했다.
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.live.AdminLiveControllerIntegrationTest`: 병렬 Gradle 실행 중 XML test result 파일 쓰기 충돌로 1회 실패했으나, 순차 재실행에서 `BUILD SUCCESSFUL`. MockMvc 관리자 API 응답 surface를 검증했다.
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.live.AdminLiveServiceTest`: `BUILD SUCCESSFUL`. 기존 관리자 라이브 서비스 단위 테스트 회귀를 확인했다.
- `./gradlew --no-daemon ktlintCheck`: `BUILD SUCCESSFUL`. Kotlin 포맷/스타일 위반이 없음을 확인했다.
- `./gradlew --no-daemon tasks --all`: `BUILD SUCCESSFUL`. 문서에 기재된 Gradle 명령 유효성을 확인했다.
---
## 검증 기록
- 구현 전 문서 작성 단계에서는 아직 테스트를 실행하지 않았다.
- 2026-06-29: 문서 변경 후 명령 유효성 확인을 위해 `./gradlew --no-daemon tasks --all`을 실행했다.
- 1차 실행: sandbox에서 `/Users/klaus/.gradle/wrapper/dists/.../gradle-8.1.1-bin.zip.lck` 접근이 차단되어 실패했다.
- escalated 재실행: `BUILD SUCCESSFUL`을 확인했다.
- 2026-06-29: 구현 후 `AdminLiveServiceIntegrationTest`, `AdminLiveControllerIntegrationTest`, `AdminLiveServiceTest`, `ktlintCheck`, `tasks --all`을 실행했고 모두 최종적으로 `BUILD SUCCESSFUL`을 확인했다.
- 2026-06-29: 코드 리뷰 및 재검증 요청에 따라 현재 워크트리 기준으로 관련 검증을 재실행했다.
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.live.AdminLiveServiceIntegrationTest`: `BUILD SUCCESSFUL`
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.live.AdminLiveControllerIntegrationTest`: `BUILD SUCCESSFUL`
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.live.AdminLiveServiceTest`: `BUILD SUCCESSFUL`
- `./gradlew --no-daemon ktlintCheck`: `BUILD SUCCESSFUL`
- `./gradlew --no-daemon tasks --all`: sandbox에서 wrapper lock 접근 제한으로 1차 실패했고, escalated 재실행에서 `BUILD SUCCESSFUL`

View File

@@ -0,0 +1,82 @@
# PRD: 관리자 라이브 추천 크리에이터 배너 LazyInitializationException 수정
## 1. Overview
`spring.jpa.open-in-view=false` 환경에서 관리자 라이브 추천 크리에이터 배너 목록 조회 시 `RecommendLiveCreatorBanner.creator` lazy proxy 접근으로 발생하는 `LazyInitializationException`을 방지한다.
---
## 2. Problem
- `AdminLiveController.getRecommendCreatorBanner(...)``AdminLiveService.getRecommendCreator(...)`가 만든 관리자 추천 크리에이터 배너 목록 응답을 반환한다.
- `AdminLiveRoomQueryRepository.getRecommendCreatorList(...)``RecommendLiveCreatorBanner` 엔티티를 조회한다.
- `RecommendLiveCreatorBanner.creator``@ManyToOne(fetch = FetchType.LAZY)`이다.
- `AdminLiveService.getRecommendCreator(...)`는 조회 결과를 응답 DTO로 변환하며 `it.creator!!.id`, `it.creator!!.nickname`을 읽는다.
- 현재 `AdminLiveService.getRecommendCreator(...)`에는 조회 트랜잭션 경계가 없어, repository 호출 이후 영속성 컨텍스트가 닫힌 상태에서 `creator` lazy proxy 초기화를 시도하면 `org.hibernate.LazyInitializationException: could not initialize proxy [kr.co.vividnext.sodalive.member.Member#289] - no Session`이 발생할 수 있다.
---
## 3. Goals
- OSIV off 환경에서도 관리자 라이브 추천 크리에이터 배너 목록 조회가 예외 없이 응답된다.
- 기존 관리자 추천 크리에이터 배너 목록 API의 URL과 응답 스키마를 변경하지 않는다.
- 기존 이미지 URL 조합, 기간 표시 포맷, 정렬 기준을 유지한다.
- 실패 재현 테스트를 먼저 작성하고, 최소 수정으로 통과시킨다.
---
## 4. Non-Goals
- OSIV 설정을 다시 켜지 않는다.
- `RecommendLiveCreatorBanner.creator` fetch 전략을 전역 eager로 바꾸지 않는다.
- 관리자 라이브 추천 크리에이터 배너 등록/수정/정렬 API 동작을 변경하지 않는다.
- 사용자용 라이브 추천 크리에이터 조회 정책을 변경하지 않는다.
- QueryDSL projection 기반으로 관리자 라이브 목록 조회 전체를 재설계하지 않는다.
---
## 5. Target Users
- 관리자: 관리자 화면에서 라이브 추천 크리에이터 배너 목록을 조회하고 정렬/수정 대상을 확인하는 사용자
- 운영자: OSIV off 운영 환경에서도 관리자 배너 목록이 안정적으로 열리기를 기대하는 사용자
---
## 6. User Stories
- 관리자는 추천 크리에이터가 연결된 배너 목록을 조회할 때 서버 오류를 만나지 않아야 한다.
- 관리자는 기존과 동일한 응답 필드로 크리에이터 ID, 닉네임, 이미지, 시작/종료 시간, 성인 여부를 확인할 수 있어야 한다.
- 운영자는 OSIV off 설정을 유지하면서 lazy 초기화 예외를 회피할 수 있어야 한다.
---
## 7. Core Features
### Feature A. 관리자 라이브 추천 크리에이터 배너 조회 트랜잭션 보강
#### Requirements
- `AdminLiveService.getRecommendCreator(...)`는 read-only 트랜잭션 경계 안에서 배너 조회와 `GetAdminRecommendCreatorResponse` 변환을 완료한다.
- `AdminLiveService.getRecommendCreator(...)`는 기존처럼 `repository.getRecommendCreatorTotalCount()``repository.getRecommendCreatorList(pageable)`를 사용한다.
- `GetAdminRecommendCreatorResponse.totalCount`, `recommendCreatorList[].id`, `image`, `creatorId`, `creatorNickname`, `startDate`, `endDate`, `isAdult` 필드는 유지한다.
- `startDate`, `endDate`는 기존처럼 UTC 저장값을 Asia/Seoul 기준 `yyyy-MM-dd HH:mm` 문자열로 변환한다.
- 이미지 경로 조합은 기존처럼 `"$coverImageHost/${banner.image}"` 형식을 유지한다.
#### Edge Cases
- 배너가 없으면 `totalCount = 0`, `recommendCreatorList = []`를 반환한다.
- 배너의 `creator`가 lazy proxy 상태로 조회되어도 서비스 트랜잭션 안에서 DTO 변환이 완료된다.
- 페이지 파라미터에 따른 offset/limit와 기존 정렬(`orders asc`, `id desc`)을 유지한다.
---
## 8. Technical Constraints
- Kotlin + Spring Boot 2.7.14 + Java 17 + Spring Data JPA 기준으로 구현한다.
- 테스트 환경의 `spring.jpa.open-in-view=false` 설정을 유지한다.
- 변경 범위는 관리자 라이브 추천 크리에이터 배너 목록 조회 흐름과 해당 테스트로 제한한다.
- lazy proxy 재현은 실제 JPA 환경에서 확인할 수 있도록 서비스 통합 테스트로 검증한다.
- 관리자 목록 API 응답은 실제 Spring Context, `MockMvc`, JPA fixture를 연결한 통합 테스트로 검증한다.
---
## 9. Metrics
- `AdminLiveServiceIntegrationTest`에서 OSIV off 조건의 관리자 라이브 추천 크리에이터 배너 목록 응답 생성 테스트가 통과한다.
- `AdminLiveControllerIntegrationTest`에서 관리자 라이브 추천 크리에이터 배너 목록 API 테스트가 통과한다.
- 관련 단일 테스트와 `ktlintCheck`가 통과한다.
---
## 10. Open Questions
- 없음. 해결 범위는 기존 채팅 배너 LazyInitializationException 수정과 같은 패턴의 관리자 목록 조회 안정화로 한정한다.

View File

@@ -0,0 +1,208 @@
# 관리자 시리즈 배너 LazyInitializationException 수정 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` 또는 동등한 TDD 절차로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
**Goal:** `spring.jpa.open-in-view=false` 환경에서 관리자 콘텐츠 시리즈 배너 목록 조회가 `SeriesBanner.series` lazy proxy 접근 때문에 실패하지 않게 한다.
**Architecture:** 기존 관리자 API 응답 DTO와 URL은 유지한다. `ContentSeriesBannerService` 클래스 레벨에 read-only 트랜잭션을 적용하고, `getActiveBanners(...)`가 그 경계 안에서 배너 조회와 `SeriesBannerListPageResponse` 생성을 완료하게 하여 lazy proxy 접근을 서비스 트랜잭션 경계 내부로 이동한다. 컨트롤러는 pageable 생성과 `ApiResponse.ok(...)` 래핑만 담당한다.
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring Data JPA, JUnit 5, Gradle Wrapper
---
## 0. 구현 전 확정 사항
- API 응답 스키마는 변경하지 않는다.
- OSIV 설정을 켜지 않는다.
- `SeriesBanner.series`를 eager로 바꾸지 않는다.
- `getDisplayBanners(...)` 등 공개 사용자용 시리즈 배너 조회 흐름은 변경하지 않는다.
- 리포지토리 fetch join/projection 전면 개편은 이번 범위에서 제외한다.
- 기존 채팅 배너 수정 문서(`docs/20260629_관리자_채팅배너_LazyInitializationException_수정/prd.md`)는 참고만 하며, 이번 대상은 관리자 콘텐츠 시리즈 배너이다.
- 원인 확인:
- `AdminContentSeriesBannerController.getBannerList(...)`가 컨트롤러에서 DTO 변환을 수행한다.
- `SeriesBanner.series`는 lazy association이다.
- `SeriesBannerResponse.from(...)``banner.series.id/title`을 읽는다.
- OSIV off 환경에서는 컨트롤러 변환 시점에 영속성 컨텍스트가 없어 lazy proxy 초기화가 실패할 수 있다.
---
## 1. 파일 구조 계획
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerService.kt`
- `getActiveBanners(...)``imageHost`를 받아 `SeriesBannerListPageResponse`를 반환하도록 변경한다.
- 클래스 레벨에 `@Transactional(readOnly = true)`를 추가한다.
- 기존 쓰기 메서드의 메서드 레벨 `@Transactional`은 유지한다.
- `SeriesBannerResponse.from(..., appendLanguageToSeriesTitle = true)`로 기존 목록 응답 표시를 유지한다.
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerController.kt`
- `getBannerList(...)`에서 컨트롤러 내 DTO 변환을 제거하고 서비스 응답을 `ApiResponse.ok(...)`로 반환한다.
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerControllerTest.kt`
- mock 기반 목록 테스트 `shouldAppendBannerLanguageToSeriesTitleInBannerList`를 서비스 반환 타입 변경에 맞게 제거하거나 갱신한다.
- 배너 등록/언어 역직렬화 단위 테스트는 유지한다.
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerServiceIntegrationTest.kt`
- OSIV off JPA 환경에서 서비스가 관리자 시리즈 배너 목록 response를 생성할 때 lazy 초기화 예외가 발생하지 않는지 검증한다.
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerControllerIntegrationTest.kt`
- 실제 Spring Context, `MockMvc`, JPA fixture로 `/admin/audio-content/series/banner/list` API 응답과 보안 권한을 검증한다.
- Verify: `src/test/resources/application.yml`
- `spring.jpa.open-in-view: false` 테스트 설정을 그대로 사용한다.
---
### Phase 1: LazyInitializationException 재현 테스트
- [x] **Task 1.1: 서비스 통합 실패 테스트 작성**
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerServiceIntegrationTest.kt`
- RED: `@SpringBootTest` 통합 테스트를 추가하고, 기존 통합 테스트처럼 테스트 전용 H2 datasource URL을 지정한다.
- RED: `@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])`를 추가해 기존 Spring Boot 통합 테스트 Redis 패턴을 따른다.
- RED: 테스트 클래스에는 `@Transactional`을 붙이지 않아 테스트 트랜잭션이 lazy 문제를 가리지 않게 한다.
- RED: `Member`, `SeriesGenre`, `Series`, `SeriesBanner` fixture를 저장한다. `Series.member``Series.genre`는 DB nullable 제약을 만족하도록 설정한다.
- RED: `EntityManager.clear()`로 영속성 컨텍스트를 비운 뒤 `service.getActiveBanners(PageRequest.of(0, 20), "https://cdn.test")`를 호출한다.
- RED: `totalCount`, `seriesId`, `seriesTitle`, `imagePath`를 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerServiceIntegrationTest`
- 기대 결과: production code 수정 전에는 기존 `getActiveBanners(pageable)` 시그니처와 반환 타입 불일치로 테스트가 실패한다. 메서드 시그니처를 먼저 테스트 기대 형태로 작성한 경우에는 `LazyInitializationException`이 RED 기준이다.
- 2026-06-29 실행: `./gradlew test --tests kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerServiceIntegrationTest`
- 결과: `BUILD FAILED`
- RED 근거: `Too many arguments for public open fun getActiveBanners(pageable: Pageable): Page<SeriesBanner>``totalCount`, `seriesId`, `seriesTitle` unresolved reference로 기존 서비스 계약 불일치를 확인했다.
---
### Phase 2: 서비스 응답 생성과 트랜잭션 보강
- [x] **Task 2.1: 서비스 클래스 레벨 read-only 트랜잭션과 관리자 목록 response 반환 적용**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerService.kt`
- GREEN: `ContentSeriesBannerService` 클래스 레벨에 `@Transactional(readOnly = true)`를 추가한다.
- GREEN: `getActiveBanners(pageable: Pageable, imageHost: String): SeriesBannerListPageResponse` 형태로 변경한다.
- GREEN: `registerBanner`, `updateBanner`, `deleteBanner`, `updateBannerOrders`의 기존 메서드 레벨 `@Transactional`은 유지한다.
- GREEN: `bannerRepository.findByIsActiveTrueOrderBySortOrderAsc(pageable)` 결과를 같은 메서드 안에서 `SeriesBannerListPageResponse`로 변환한다.
- GREEN: `SeriesBannerResponse.from(banner = it, imageHost = imageHost, appendLanguageToSeriesTitle = true)`를 사용한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerServiceIntegrationTest`
- 기대 결과: `BUILD SUCCESSFUL`
- REFACTOR: 불필요한 import/format 변경이 생기지 않았는지 확인한다.
- 2026-06-29 실행: `./gradlew test --tests kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerServiceIntegrationTest`
- 결과: `BUILD SUCCESSFUL`
- 검증 이유: OSIV off 및 `EntityManager.clear()` 이후에도 서비스 트랜잭션 안에서 `SeriesBannerListPageResponse` 생성과 `SeriesBanner.series` lazy 접근이 완료되는지 확인했다.
- [x] **Task 2.2: 컨트롤러 목록 응답 조립 제거**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerController.kt`
- GREEN: `getBannerList(...)`에서 `val response = bannerService.getActiveBanners(pageable, imageHost)`만 호출한다.
- GREEN: 컨트롤러의 `banners.content.map { ... }` 변환 코드를 제거한다.
- GREEN: 사용하지 않게 된 `SeriesBannerListPageResponse`, `SeriesBannerResponse` import를 제거한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerTest`
- 기대 결과: `BUILD SUCCESSFUL`
- REFACTOR: 컨트롤러의 다른 register/update/detail 응답 생성 흐름은 변경하지 않는다.
- 2026-06-29 실행: `./gradlew test --tests kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerTest`
- 결과: `BUILD SUCCESSFUL`
- 검증 이유: 컨트롤러가 목록 DTO 조립을 하지 않고 `bannerService.getActiveBanners(pageable, imageHost)` 결과를 `ApiResponse.ok(...)`로 감싸는지 확인했다.
---
### Phase 3: 실제 Spring Context 기반 관리자 목록 API 검증
- [x] **Task 3.1: mock 기반 목록 테스트 정리**
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerControllerTest.kt`
- GREEN: `shouldAppendBannerLanguageToSeriesTitleInBannerList` 테스트를 제거하거나, 서비스가 `SeriesBannerListPageResponse`를 반환하는 단위 테스트로 갱신한다.
- GREEN: 목록 테스트 제거 또는 갱신 후 사용하지 않게 된 `PageImpl`, `PageRequest` import를 제거한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerTest`
- 기대 결과: `BUILD SUCCESSFUL`
- REFACTOR: 기존 배너 등록 언어 테스트와 `Lang` 역직렬화 테스트는 변경하지 않는다.
- 2026-06-29 실행: `./gradlew test --tests kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerTest`
- 결과: `BUILD SUCCESSFUL`
- 검증 이유: 목록 테스트를 서비스 반환 타입 기준으로 갱신했고, 기존 배너 등록 언어 테스트와 `Lang` 역직렬화 테스트가 유지되는지 확인했다.
- [x] **Task 3.2: 관리자 목록 API 통합 실패 테스트 작성**
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerControllerIntegrationTest.kt`
- RED: `@SpringBootTest`를 사용하고 `cloud.aws.cloud-front.host=https://cdn.test`와 테스트 전용 H2 datasource URL을 지정한다.
- RED: `@AutoConfigureMockMvc`를 사용해 실제 controller/service/repository bean을 연결한다.
- RED: `@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])`를 추가해 기존 Spring Boot 통합 테스트 Redis 패턴을 따른다.
- RED: 테스트 클래스에는 `@Transactional`을 붙이지 않는다. 테스트 데이터 생성은 `TransactionTemplate` 안에서 수행하고 `EntityManager.flush()`, `EntityManager.clear()`로 영속성 컨텍스트를 비운다.
- RED: `Member`, `SeriesGenre`, `Series`, `SeriesBanner(lang = Lang.JA, imagePath = "banner/jp.png")`를 저장한다.
- RED: `MockMvc``GET /admin/audio-content/series/banner/list?page=0&size=20`을 호출하고 `with(user("admin").roles("ADMIN"))`로 관리자 권한을 부여한다.
- RED: `$.success = true`, `$.data.totalCount = 1`, `$.data.content[0].seriesTitle = "series-admin-banner (일본어)"`, `$.data.content[0].imagePath = "https://cdn.test/banner/jp.png"`를 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerIntegrationTest`
- 기대 결과: production code 수정 전에는 `LazyInitializationException` 또는 기존 서비스 시그니처/응답 생성 위치 불일치로 테스트가 실패한다.
- 2026-06-29 production code 수정 전 실행: `./gradlew test --tests kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerIntegrationTest`
- 결과: `BUILD FAILED`
- RED 근거: 서비스 통합 테스트와 동일하게 새 `getActiveBanners(pageable, imageHost)` 계약 부재로 컴파일 실패했다.
- 2026-06-29 production code 수정 후 실행: `./gradlew test --tests kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerIntegrationTest`
- 결과: `BUILD SUCCESSFUL`
- 검증 이유: 실제 Spring Context, MockMvc, JPA fixture, 관리자 권한으로 `/admin/audio-content/series/banner/list`가 OSIV off 환경에서 `success=true`, `totalCount=1`, 언어 suffix, CDN 이미지 경로를 반환하는지 확인했다.
- [x] **Task 3.3: 관련 검증 실행 및 문서 기록**
- Verify: `./gradlew test --tests kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerServiceIntegrationTest`
- Verify: `./gradlew test --tests kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerTest`
- Verify: `./gradlew test --tests kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerIntegrationTest`
- Verify: `./gradlew --no-daemon ktlintCheck`
- Verify: `./gradlew --no-daemon tasks --all`
- 문서 기록: 각 task 아래에 실행 명령, 결과, 검증 이유를 한국어로 누적한다.
- 기대 결과: 모든 명령이 `BUILD SUCCESSFUL`로 종료된다.
- 2026-06-29 실행: `./gradlew test --tests kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerServiceIntegrationTest`
- 결과: `BUILD SUCCESSFUL`
- 2026-06-29 실행: `./gradlew test --tests kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerTest`
- 결과: `BUILD SUCCESSFUL`
- 2026-06-29 실행: `./gradlew test --tests kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerIntegrationTest`
- 결과: `BUILD SUCCESSFUL`
- 2026-06-29 추가 회귀 확인: `./gradlew test --tests kr.co.vividnext.sodalive.content.series.main.SeriesMainControllerTest`
- 결과: `BUILD SUCCESSFUL`
- 검증 이유: 공개 사용자용 시리즈 메인 흐름이 여전히 `getDisplayBanners(...)`를 사용하고 관리자용 `getActiveBanners(...)`를 호출하지 않는지 확인했다.
- 2026-06-29 실행: `./gradlew --no-daemon ktlintCheck`
- 결과: `BUILD SUCCESSFUL`
- 2026-06-29 실행: `./gradlew --no-daemon tasks --all`
- 결과: `BUILD SUCCESSFUL`
### Phase 4: 리뷰 후속 상세 조회 LazyInitializationException 리스크 반영
- [x] **Task 4.1: 서비스 상세 응답 생성 테스트 작성**
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerServiceIntegrationTest.kt`
- RED: OSIV off 환경에서 `EntityManager.clear()` 이후 `service.getBannerDetailResponse(bannerId, "https://cdn.test")`를 호출한다.
- RED: `id`, `seriesId`, `seriesTitle`, `imagePath`를 검증한다.
- 2026-06-29 production code 수정 전 실행: `./gradlew test --tests kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerServiceIntegrationTest`
- 결과: `BUILD FAILED`
- RED 근거: `getBannerDetailResponse`가 아직 없어 `Unresolved reference: getBannerDetailResponse` 컴파일 실패를 확인했다.
- [x] **Task 4.2: 서비스 트랜잭션 내부 상세 DTO 변환 적용**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerService.kt`
- GREEN: `getBannerDetailResponse(bannerId: Long, imageHost: String): SeriesBannerResponse`를 추가한다.
- GREEN: class-level `@Transactional(readOnly = true)` 경계 안에서 `getBannerById(...)` 조회와 `SeriesBannerResponse.from(...)` 변환을 완료한다.
- 2026-06-29 실행: `./gradlew test --tests kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerServiceIntegrationTest`
- 결과: `BUILD SUCCESSFUL`
- 검증 이유: 목록/상세 응답 모두 OSIV off 및 영속성 컨텍스트 clear 이후 lazy 초기화 예외 없이 생성되는지 확인했다.
- [x] **Task 4.3: 컨트롤러 상세 응답 조립 제거 및 API 통합 검증**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerController.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerControllerIntegrationTest.kt`
- GREEN: `getBannerDetail(...)`에서 `val response = bannerService.getBannerDetailResponse(bannerId, imageHost)`만 호출한다.
- GREEN: MockMvc로 `GET /admin/audio-content/series/banner/{bannerId}`를 관리자 권한으로 호출하고 `success`, `id`, `seriesTitle`, `imagePath`를 검증한다.
- 2026-06-29 실행: `./gradlew test --tests kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerIntegrationTest`
- 결과: `BUILD SUCCESSFUL`
- 검증 이유: 실제 Spring Context, MockMvc, JPA fixture로 상세 API가 OSIV off 환경에서 lazy 초기화 예외 없이 응답하는지 확인했다.
- [x] **Task 4.4: mock 기반 상세 위임 테스트 갱신**
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerControllerTest.kt`
- GREEN: 상세 조회 컨트롤러가 `bannerService.getBannerDetailResponse(bannerId, imageHost)`를 호출하고 결과를 `ApiResponse.ok(...)`로 감싸는지 검증한다.
- 2026-06-29 실행: `./gradlew test --tests kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerTest`
- 결과: `BUILD SUCCESSFUL`
- 검증 이유: 컨트롤러 상세 조회의 DTO 조립 제거와 서비스 위임을 단위 수준에서 확인했다.
---
## 검증 기록
- 구현 전 문서 작성 단계에서는 아직 테스트를 실행하지 않았다.
- 2026-06-29: 문서 변경 후 명령 유효성 확인을 위해 `./gradlew --no-daemon tasks --all`을 실행했다.
- 1차 실행: sandbox에서 `/Users/klaus/.gradle/wrapper/dists/.../gradle-8.1.1-bin.zip.lck` 접근이 차단되어 실패했다.
- escalated 재실행: `BUILD SUCCESSFUL`을 확인했다.
- 2026-06-29: 서비스 통합 테스트와 관리자 MockMvc 통합 테스트를 먼저 추가한 뒤 production code 수정 전 RED를 확인했다.
- 서비스 통합 테스트: 기존 `getActiveBanners(pageable)` 시그니처 및 `Page<SeriesBanner>` 반환 타입과 새 기대 계약이 맞지 않아 컴파일 실패했다.
- 관리자 MockMvc 통합 테스트: 동일한 서비스 계약 불일치로 컴파일 실패했다.
- 2026-06-29: `ContentSeriesBannerService`에 class-level `@Transactional(readOnly = true)`를 추가하고, `getActiveBanners(pageable, imageHost)``SeriesBannerListPageResponse`를 생성하도록 변경했다.
- 2026-06-29: `AdminContentSeriesBannerController.getBannerList(...)`에서 DTO 조립을 제거하고 서비스 응답을 `ApiResponse.ok(...)`로 감싸도록 변경했다.
- 2026-06-29: 다음 검증이 모두 `BUILD SUCCESSFUL`로 종료됐다.
- `./gradlew test --tests kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerServiceIntegrationTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerIntegrationTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.content.series.main.SeriesMainControllerTest`
- `./gradlew --no-daemon ktlintCheck`
- `./gradlew --no-daemon tasks --all`
- 2026-06-29: 리뷰에서 지적된 상세 조회 잔여 리스크를 후속 반영했다.
- `AdminContentSeriesBannerController.getBannerDetail(...)`도 컨트롤러 DTO 변환을 제거하고 `ContentSeriesBannerService.getBannerDetailResponse(...)`로 위임했다.
- 서비스 통합 테스트와 MockMvc 통합 테스트에 상세 조회 OSIV off 회귀 검증을 추가했다.
- `./gradlew test --tests kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerServiceIntegrationTest`, `./gradlew test --tests kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerIntegrationTest`, `./gradlew test --tests kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerTest`가 모두 `BUILD SUCCESSFUL`로 종료됐다.

View File

@@ -0,0 +1,79 @@
# PRD: 관리자 시리즈 배너 LazyInitializationException 수정
## 1. Overview
`spring.jpa.open-in-view=false` 환경에서 관리자 콘텐츠 시리즈 배너 목록 조회 시 `SeriesBanner.series` lazy proxy 접근으로 발생하는 `LazyInitializationException`을 방지한다.
---
## 2. Problem
- `AdminContentSeriesBannerController.getBannerList(...)``bannerService.getActiveBanners(...)``Page<SeriesBanner>`를 받은 뒤 컨트롤러에서 `SeriesBannerResponse.from(...)`으로 응답을 만든다.
- `SeriesBanner.series``@ManyToOne(fetch = FetchType.LAZY)`이다.
- `SeriesBannerResponse.from(...)``banner.series.id`, `banner.series.title`을 읽는다.
- `spring.jpa.open-in-view=false` 환경에서는 서비스/리포지토리 조회 후 영속성 컨텍스트가 닫힌 상태에서 컨트롤러가 lazy proxy를 초기화하려 하므로 `org.hibernate.LazyInitializationException: could not initialize proxy [kr.co.vividnext.sodalive.creator.admin.content.series.Series#124] - no Session`이 발생할 수 있다.
---
## 3. Goals
- OSIV off 환경에서도 관리자 콘텐츠 시리즈 배너 목록 조회가 예외 없이 응답된다.
- 사용자가 요청한 방향대로 `ContentSeriesBannerService.getActiveBanners(...)` 안에서 관리자 목록 response를 생성한다.
- 기존 관리자 콘텐츠 시리즈 배너 목록 API의 응답 스키마를 변경하지 않는다.
- 배너 언어 라벨을 시리즈 제목 뒤에 붙이는 기존 동작을 유지한다.
- 실패 재현 테스트를 먼저 작성하고, 최소 수정으로 통과시킨다.
---
## 4. Non-Goals
- OSIV 설정을 다시 켜지 않는다.
- `SeriesBanner.series` fetch 전략을 전역 eager로 바꾸지 않는다.
- 관리자 콘텐츠 시리즈 배너 목록 API의 URL, 요청 파라미터, 응답 필드를 변경하지 않는다.
- 배너 등록/수정/삭제/정렬 API 동작을 변경하지 않는다.
- 공개 사용자용 시리즈 배너 조회(`getDisplayBanners`) 응답 구조를 변경하지 않는다.
- QueryDSL/projection 기반으로 배너 조회 전체를 재설계하지 않는다.
---
## 5. Target Users
- 관리자: 관리자 화면에서 콘텐츠 시리즈 배너 목록을 조회하고 정렬/수정 대상을 확인하는 사용자
- 운영자: OSIV off 운영 환경에서도 관리자 시리즈 배너 목록이 안정적으로 열리기를 기대하는 사용자
---
## 6. User Stories
- 관리자는 시리즈가 연결된 활성 배너 목록을 조회할 때 서버 오류를 만나지 않아야 한다.
- 관리자는 한국어/영어/일본어 배너가 섞여 있어도 시리즈 제목 뒤에 언어 라벨이 붙은 목록을 확인할 수 있어야 한다.
- 운영자는 OSIV off 설정을 유지하면서 lazy 초기화 예외를 회피할 수 있어야 한다.
---
## 7. Core Features
### Feature A. 관리자 시리즈 배너 목록 응답 생성 위치 이동
#### Requirements
- `ContentSeriesBannerService.getActiveBanners(...)`는 관리자 목록 응답인 `SeriesBannerListPageResponse`를 생성해 반환한다.
- `ContentSeriesBannerService`는 클래스 레벨 `@Transactional(readOnly = true)`로 조회 기본 트랜잭션을 제공하고, `getActiveBanners(...)`는 그 경계 안에서 배너 조회와 `SeriesBannerResponse.from(...)` 변환을 완료한다.
- `SeriesBannerResponse.from(...)` 호출 시 `appendLanguageToSeriesTitle = true`를 유지한다.
- `AdminContentSeriesBannerController.getBannerList(...)`는 pageable 생성 후 서비스가 만든 response를 그대로 `ApiResponse.ok(...)`로 감싼다.
- 기존 `SeriesBannerListPageResponse.totalCount`, `content[].id`, `content[].imagePath`, `content[].seriesId`, `content[].seriesTitle` 필드는 유지한다.
#### Edge Cases
- 활성 배너가 없으면 `totalCount = 0`, `content = []`를 반환한다.
- 배너 언어가 `KO`, `EN`, `JA`인 경우 기존처럼 각각 `한국어`, `영어`, `일본어` 라벨을 시리즈 제목 뒤에 붙인다.
- 이미지 경로 조합은 기존처럼 `"$imageHost/${banner.imagePath}"` 형식을 유지한다.
---
## 8. Technical Constraints
- Kotlin + Spring Boot 2.7.14 + Java 17 + Spring Data JPA 기준으로 구현한다.
- 테스트 환경의 `spring.jpa.open-in-view=false` 설정을 유지한다.
- `ContentSeriesBannerService` 클래스 레벨에 `@Transactional(readOnly = true)`를 사용하고, 기존 쓰기 메서드의 메서드 레벨 `@Transactional`은 유지한다.
- 변경 범위는 관리자 콘텐츠 시리즈 배너 목록 조회 흐름과 해당 테스트로 제한한다.
- lazy proxy 재현은 실제 JPA 환경에서 확인할 수 있도록 서비스 통합 테스트로 검증한다.
- 관리자 목록 API 응답은 mock 기반 컨트롤러 테스트가 아니라 실제 Spring Context, `MockMvc`, JPA fixture를 연결한 통합 테스트로 검증한다.
---
## 9. Metrics
- `ContentSeriesBannerServiceIntegrationTest`에서 OSIV off 조건의 관리자 시리즈 배너 목록 응답 생성 테스트가 통과한다.
- `AdminContentSeriesBannerControllerIntegrationTest`의 관리자 시리즈 배너 목록 API 테스트가 통과한다.
- 관련 단일 테스트와 `ktlintCheck`가 통과한다.

View File

@@ -0,0 +1,149 @@
# 관리자 채팅 배너 LazyInitializationException 수정 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` 또는 동등한 TDD 절차로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
**Goal:** `spring.jpa.open-in-view=false` 환경에서 관리자 채팅 배너 목록 조회가 `ChatCharacterBanner.chatCharacter` lazy proxy 접근 때문에 실패하지 않게 한다.
**Architecture:** 기존 관리자 API 응답 DTO와 URL은 유지한다. `ChatCharacterBannerService` 클래스 레벨에 read-only 트랜잭션을 적용하고, `getActiveBanners(...)`가 그 경계 안에서 배너 조회와 `ChatCharacterBannerListPageResponse` 생성을 완료하게 하여 lazy proxy 접근을 서비스 트랜잭션 경계 내부로 이동한다. 컨트롤러는 pageable 생성과 `ApiResponse.ok(...)` 래핑만 담당한다.
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring Data JPA, JUnit 5, Gradle Wrapper
---
## 0. 구현 전 확정 사항
- API 응답 스키마는 변경하지 않는다.
- OSIV 설정을 켜지 않는다.
- `ChatCharacterBanner.chatCharacter`를 eager로 바꾸지 않는다.
- `getDisplayBanners(...)` 등 공개 사용자용 배너 조회 흐름은 변경하지 않는다.
- 리포지토리 fetch join/projection 전면 개편은 이번 범위에서 제외한다.
- 원인 확인:
- `AdminChatBannerController.getBannerList(...)`가 컨트롤러에서 DTO 변환을 수행한다.
- `ChatCharacterBanner.chatCharacter`는 lazy association이다.
- `ChatCharacterBannerResponse.from(...)``banner.chatCharacter.id/name`을 읽는다.
- OSIV off 환경에서는 컨트롤러 변환 시점에 영속성 컨텍스트가 없어 lazy proxy 초기화가 실패할 수 있다.
---
## 1. 파일 구조 계획
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt`
- `getActiveBanners(...)``imageHost`를 받아 `ChatCharacterBannerListPageResponse`를 반환하도록 변경한다.
- 클래스 레벨에 `@Transactional(readOnly = true)`를 추가한다.
- 기존 쓰기 메서드의 메서드 레벨 `@Transactional`은 유지한다.
- `ChatCharacterBannerResponse.from(..., appendLanguageToCharacterName = true)`로 기존 목록 응답 표시를 유지한다.
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt`
- `getBannerList(...)`에서 컨트롤러 내 DTO 변환을 제거하고 서비스 응답을 `ApiResponse.ok(...)`로 반환한다.
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerControllerTest.kt`
- mock 기반 목록 테스트 `shouldAppendBannerLanguageToCharacterNameInBannerList`를 제거한다.
- 배너 등록/언어 역직렬화 단위 테스트는 유지한다.
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerServiceIntegrationTest.kt`
- OSIV off JPA 환경에서 서비스가 관리자 배너 목록 response를 생성할 때 lazy 초기화 예외가 발생하지 않는지 검증한다.
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerControllerIntegrationTest.kt`
- 실제 Spring Context, `MockMvc`, JPA fixture로 `/admin/chat/banner/list` API 응답과 보안 권한을 검증한다.
- Verify: `src/test/resources/application.yml`
- `spring.jpa.open-in-view: false` 테스트 설정을 그대로 사용한다.
---
### Phase 1: LazyInitializationException 재현 테스트
- [x] **Task 1.1: 서비스 통합 실패 테스트 작성**
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerServiceIntegrationTest.kt`
- RED: `@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://cdn.test"])` 통합 테스트를 추가한다.
- RED: `@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])`를 추가해 기존 Spring Boot 통합 테스트 Redis 패턴을 따른다.
- RED: 테스트 클래스에는 `@Transactional`을 붙이지 않아 테스트 트랜잭션이 lazy 문제를 가리지 않게 한다.
- RED: `ChatCharacterRepository`로 활성 `ChatCharacter`를 저장한다.
- RED: `ChatCharacterBannerRepository`로 활성 `ChatCharacterBanner`를 저장한다.
- RED: `service.getActiveBanners(PageRequest.of(0, 20), "https://cdn.test")`를 호출해 `totalCount`, `characterId`, `characterName`, `imagePath`를 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerServiceIntegrationTest`
- 기대 결과: production code 수정 전에는 `LazyInitializationException` 또는 기존 메서드 시그니처 불일치로 테스트가 실패한다. 메서드 시그니처를 먼저 테스트 기대 형태로 작성한 경우에는 lazy proxy 초기화 실패가 RED 기준이다.
- 구현 기록(2026-06-29): `ChatCharacterBannerServiceIntegrationTest`를 추가해 OSIV off 환경에서 서비스가 관리자 배너 목록 response를 생성하도록 테스트했다.
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerServiceIntegrationTest` 실행 결과 기존 `getActiveBanners(pageable)` 시그니처와 반환 타입 불일치로 `compileTestKotlin`이 실패했다.
- 보정: 실제 DB 제약에 맞게 `ChatCharacter.creatorMember` fixture를 추가했다.
---
### Phase 2: 서비스 응답 생성과 트랜잭션 보강
- [x] **Task 2.1: 서비스 클래스 레벨 read-only 트랜잭션과 관리자 목록 response 반환 적용**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt`
- GREEN: `ChatCharacterBannerService` 클래스 레벨에 `@Transactional(readOnly = true)`를 추가한다.
- GREEN: `getActiveBanners(pageable: Pageable, imageHost: String): ChatCharacterBannerListPageResponse` 형태로 변경한다.
- GREEN: `registerBanner`, `updateBanner`, `deleteBanner`, `updateBannerOrders`의 기존 메서드 레벨 `@Transactional`은 유지한다.
- GREEN: `bannerRepository.findByIsActiveTrueOrderBySortOrderAsc(pageable)` 결과를 같은 메서드 안에서 `ChatCharacterBannerListPageResponse`로 변환한다.
- GREEN: `ChatCharacterBannerResponse.from(banner = it, imageHost = imageHost, appendLanguageToCharacterName = true)`를 사용한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerServiceIntegrationTest`
- 기대 결과: `BUILD SUCCESSFUL`
- REFACTOR: 불필요한 import/format 변경이 생기지 않았는지 확인한다.
- 구현 기록(2026-06-29): `ChatCharacterBannerService` 클래스 레벨에 `@Transactional(readOnly = true)`를 추가하고 `getActiveBanners(pageable, imageHost)``ChatCharacterBannerListPageResponse`를 반환하도록 변경했다.
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerServiceIntegrationTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 검증 이유: 서비스 트랜잭션 안에서 `ChatCharacterBanner.chatCharacter` lazy proxy를 읽어 관리자 목록 response를 생성하는지 확인했다.
- [x] **Task 2.2: 컨트롤러 목록 응답 조립 제거**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt`
- GREEN: `getBannerList(...)`에서 `val response = bannerService.getActiveBanners(pageable, imageHost)`만 호출한다.
- GREEN: 컨트롤러의 `banners.content.map { ... }` 변환 코드를 제거한다.
- GREEN: 사용하지 않게 된 `ChatCharacterBannerListPageResponse`, `ChatCharacterBannerResponse` import를 제거한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerTest`
- 기대 결과: `BUILD SUCCESSFUL`
- REFACTOR: 컨트롤러의 다른 register/update/detail 응답 생성 흐름은 변경하지 않는다.
- 구현 기록(2026-06-29): `AdminChatBannerController.getBannerList(...)`에서 DTO 변환을 제거하고 `bannerService.getActiveBanners(pageable, imageHost)` 응답을 그대로 반환하도록 변경했다.
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
---
### Phase 3: 실제 Spring Context 기반 관리자 목록 API 검증
- [x] **Task 3.1: mock 기반 목록 테스트 제거**
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerControllerTest.kt`
- GREEN: `shouldAppendBannerLanguageToCharacterNameInBannerList` 테스트를 제거한다.
- GREEN: 목록 테스트 제거 후 사용하지 않게 된 `PageImpl`, `PageRequest` import를 제거한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerTest`
- 기대 결과: `BUILD SUCCESSFUL`
- REFACTOR: 기존 배너 등록 언어 테스트와 `Lang` 역직렬화 테스트는 변경하지 않는다.
- 구현 기록(2026-06-29): mock 기반 목록 테스트 `shouldAppendBannerLanguageToCharacterNameInBannerList`를 제거하고, 기존 등록/언어 역직렬화 단위 테스트는 유지했다.
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- [x] **Task 3.2: 관리자 목록 API 통합 실패 테스트 작성**
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerControllerIntegrationTest.kt`
- RED: `@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://cdn.test"])`를 사용한다.
- RED: `@AutoConfigureMockMvc`를 사용해 실제 controller/service/repository bean을 연결한다.
- RED: `@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])`를 추가해 기존 Spring Boot 통합 테스트 Redis 패턴을 따른다.
- RED: 테스트 클래스에는 `@Transactional`을 붙이지 않는다. 테스트 데이터 생성은 `TransactionTemplate` 안에서 수행하고 `EntityManager.flush()`, `EntityManager.clear()`로 영속성 컨텍스트를 비운다.
- RED: `ChatCharacter``ChatCharacterBanner(lang = Lang.JA, imagePath = "banner/jp.png")`를 저장한다.
- RED: `MockMvc``GET /admin/chat/banner/list?page=0&size=20`을 호출하고 `with(user("admin").roles("ADMIN"))`로 관리자 권한을 부여한다.
- RED: `$.success = true`, `$.data.totalCount = 1`, `$.data.content[0].characterName = "character-admin-banner (일본어)"`, `$.data.content[0].imagePath = "https://cdn.test/banner/jp.png"`를 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerIntegrationTest`
- 기대 결과: production code 수정 전에는 `LazyInitializationException` 또는 기존 서비스 시그니처/응답 생성 위치 불일치로 테스트가 실패한다.
- 구현 기록(2026-06-29): `AdminChatBannerControllerIntegrationTest`를 추가해 실제 Spring Context, `MockMvc`, JPA fixture로 관리자 목록 API를 검증했다.
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerIntegrationTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 검증 이유: mock 없이 `/admin/chat/banner/list` 요청이 OSIV off 환경에서 lazy 초기화 예외 없이 기존 응답 스키마와 언어 라벨을 반환하는지 확인했다.
- [x] **Task 3.3: 관련 검증 실행 및 문서 기록**
- Verify: `./gradlew test --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerServiceIntegrationTest`
- Verify: `./gradlew test --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerTest`
- Verify: `./gradlew test --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerIntegrationTest`
- Verify: `./gradlew --no-daemon ktlintCheck`
- Verify: `./gradlew --no-daemon tasks --all`
- 문서 기록: 각 task 아래에 실행 명령, 결과, 검증 이유를 한국어로 누적한다.
- 기대 결과: 모든 명령이 `BUILD SUCCESSFUL`로 종료된다.
- 구현 기록(2026-06-29): 관련 테스트, ktlint, Gradle task 목록 검증을 실행했다.
- 관련 테스트 묶음: `./gradlew test --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerServiceIntegrationTest --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerTest --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerIntegrationTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- ktlint 1차: `./gradlew --no-daemon ktlintCheck` 실행 결과 새 통합 테스트 2개 파일의 datasource URL line length 초과로 실패했다.
- ktlint 재실행: line length를 수정한 뒤 `./gradlew --no-daemon ktlintCheck`를 재실행했고 `BUILD SUCCESSFUL`을 확인했다.
- 명령 유효성 1차: `./gradlew --no-daemon tasks --all` 실행 결과 sandbox에서 `/Users/klaus/.gradle/wrapper/dists/.../gradle-8.1.1-bin.zip.lck` 접근이 차단되어 실패했다.
- 명령 유효성 재실행: 같은 명령을 escalated로 재실행했고 `BUILD SUCCESSFUL`을 확인했다.
---
## 검증 기록
- 구현 전 문서 작성 단계에서는 아직 테스트를 실행하지 않았다.
- 2026-06-29: 문서 변경 후 명령 유효성 확인을 위해 `./gradlew --no-daemon tasks --all`을 실행했다.
- 1차 실행: sandbox에서 `/Users/klaus/.gradle/wrapper/dists/.../gradle-8.1.1-bin.zip.lck` 접근이 차단되어 실패했다.
- escalated 재실행: `BUILD SUCCESSFUL`을 확인했다.
- 2026-06-29: 최종 관련 테스트 묶음 `./gradlew test --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerServiceIntegrationTest --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerTest --tests kr.co.vividnext.sodalive.admin.chat.AdminChatBannerControllerIntegrationTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 2026-06-29: `./gradlew --no-daemon ktlintCheck` 1차 실행은 line length 위반으로 실패했고, 수정 후 재실행에서 `BUILD SUCCESSFUL`을 확인했다.
- 2026-06-29: `./gradlew --no-daemon tasks --all`은 sandbox에서 wrapper lock 접근 제한으로 실패했고, escalated 재실행에서 `BUILD SUCCESSFUL`을 확인했다.
- 2026-06-29: 마무리 검증으로 `./gradlew --no-daemon test`를 실행했고 `BUILD SUCCESSFUL`을 확인했다.

View File

@@ -0,0 +1,79 @@
# PRD: 관리자 채팅 배너 LazyInitializationException 수정
## 1. Overview
`spring.jpa.open-in-view=false` 환경에서 관리자 채팅 배너 목록 조회 시 `ChatCharacterBanner.chatCharacter` lazy proxy 접근으로 발생하는 `LazyInitializationException`을 방지한다.
---
## 2. Problem
- `AdminChatBannerController.getBannerList(...)``bannerService.getActiveBanners(...)``Page<ChatCharacterBanner>`를 받은 뒤 컨트롤러에서 `ChatCharacterBannerResponse.from(...)`으로 응답을 만든다.
- `ChatCharacterBanner.chatCharacter``@ManyToOne(fetch = FetchType.LAZY)`이다.
- `ChatCharacterBannerResponse.from(...)``banner.chatCharacter.id`, `banner.chatCharacter.name`을 읽는다.
- `spring.jpa.open-in-view=false` 환경에서는 서비스/리포지토리 조회 후 영속성 컨텍스트가 닫힌 상태에서 컨트롤러가 lazy proxy를 초기화하려 하므로 `org.hibernate.LazyInitializationException: could not initialize proxy [kr.co.vividnext.sodalive.chat.character.ChatCharacter#7] - no Session`이 발생할 수 있다.
---
## 3. Goals
- OSIV off 환경에서도 관리자 채팅 배너 목록 조회가 예외 없이 응답된다.
- 사용자가 요청한 방향대로 `bannerService.getActiveBanners(...)` 안에서 관리자 목록 response를 생성한다.
- 기존 관리자 채팅 배너 목록 API의 응답 스키마를 변경하지 않는다.
- 배너 언어 라벨을 캐릭터명 뒤에 붙이는 기존 동작을 유지한다.
- 실패 재현 테스트를 먼저 작성하고, 최소 수정으로 통과시킨다.
---
## 4. Non-Goals
- OSIV 설정을 다시 켜지 않는다.
- `ChatCharacterBanner.chatCharacter` fetch 전략을 전역 eager로 바꾸지 않는다.
- 관리자 채팅 배너 목록 API의 URL, 요청 파라미터, 응답 필드를 변경하지 않는다.
- 배너 등록/수정/삭제/정렬 API 동작을 변경하지 않는다.
- 공개 사용자용 배너 조회(`getDisplayBanners`) 응답 구조를 변경하지 않는다.
- QueryDSL/projection 기반으로 배너 조회 전체를 재설계하지 않는다.
---
## 5. Target Users
- 관리자: 관리자 화면에서 채팅 캐릭터 배너 목록을 조회하고 정렬/수정 대상을 확인하는 사용자
- 운영자: OSIV off 운영 환경에서도 관리자 배너 목록이 안정적으로 열리기를 기대하는 사용자
---
## 6. User Stories
- 관리자는 채팅 캐릭터가 연결된 활성 배너 목록을 조회할 때 서버 오류를 만나지 않아야 한다.
- 관리자는 한국어/영어/일본어 배너가 섞여 있어도 캐릭터명 뒤에 언어 라벨이 붙은 목록을 확인할 수 있어야 한다.
- 운영자는 OSIV off 설정을 유지하면서 lazy 초기화 예외를 회피할 수 있어야 한다.
---
## 7. Core Features
### Feature A. 관리자 채팅 배너 목록 응답 생성 위치 이동
#### Requirements
- `ChatCharacterBannerService.getActiveBanners(...)`는 관리자 목록 응답인 `ChatCharacterBannerListPageResponse`를 생성해 반환한다.
- `ChatCharacterBannerService`는 클래스 레벨 `@Transactional(readOnly = true)`로 조회 기본 트랜잭션을 제공하고, `getActiveBanners(...)`는 그 경계 안에서 배너 조회와 `ChatCharacterBannerResponse.from(...)` 변환을 완료한다.
- `ChatCharacterBannerResponse.from(...)` 호출 시 `appendLanguageToCharacterName = true`를 유지한다.
- `AdminChatBannerController.getBannerList(...)`는 pageable 생성 후 서비스가 만든 response를 그대로 `ApiResponse.ok(...)`로 감싼다.
- 기존 `ChatCharacterBannerListPageResponse.totalCount`, `content[].id`, `content[].imagePath`, `content[].characterId`, `content[].characterName` 필드는 유지한다.
#### Edge Cases
- 활성 배너가 없으면 `totalCount = 0`, `content = []`를 반환한다.
- 배너 언어가 `KO`, `EN`, `JA`인 경우 기존처럼 각각 `한국어`, `영어`, `일본어` 라벨을 캐릭터명 뒤에 붙인다.
- 이미지 경로 조합은 기존처럼 `"$imageHost/${banner.imagePath}"` 형식을 유지한다.
---
## 8. Technical Constraints
- Kotlin + Spring Boot 2.7.14 + Java 17 + Spring Data JPA 기준으로 구현한다.
- 테스트 환경의 `spring.jpa.open-in-view=false` 설정을 유지한다.
- `ChatCharacterBannerService` 클래스 레벨에 `@Transactional(readOnly = true)`를 사용하고, 기존 쓰기 메서드의 메서드 레벨 `@Transactional`은 유지한다.
- 변경 범위는 관리자 채팅 배너 목록 조회 흐름과 해당 테스트로 제한한다.
- lazy proxy 재현은 실제 JPA 환경에서 확인할 수 있도록 서비스 통합 테스트로 검증한다.
- 관리자 목록 API 응답은 mock 기반 컨트롤러 테스트가 아니라 실제 Spring Context, `MockMvc`, JPA fixture를 연결한 통합 테스트로 검증한다.
---
## 9. Metrics
- `ChatCharacterBannerServiceIntegrationTest`에서 OSIV off 조건의 관리자 배너 목록 응답 생성 테스트가 통과한다.
- `AdminChatBannerControllerIntegrationTest`의 관리자 배너 목록 API 테스트가 통과한다.
- 관련 단일 테스트와 `ktlintCheck`가 통과한다.

View File

@@ -4,7 +4,6 @@ import com.amazonaws.services.s3.model.ObjectMetadata
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterSearchListPageResponse import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterSearchListPageResponse
import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService
import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerListPageResponse
import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerRegisterRequest import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerRegisterRequest
import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerResponse import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerResponse
import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerUpdateRequest import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerUpdateRequest
@@ -59,17 +58,7 @@ class AdminChatBannerController(
@RequestParam(defaultValue = "20") size: Int @RequestParam(defaultValue = "20") size: Int
) = run { ) = run {
val pageable = adminCharacterService.createDefaultPageRequest(page, size) val pageable = adminCharacterService.createDefaultPageRequest(page, size)
val banners = bannerService.getActiveBanners(pageable) val response = bannerService.getActiveBanners(pageable, imageHost)
val response = ChatCharacterBannerListPageResponse(
totalCount = banners.totalElements,
content = banners.content.map {
ChatCharacterBannerResponse.from(
banner = it,
imageHost = imageHost,
appendLanguageToCharacterName = true
)
}
)
ApiResponse.ok(response) ApiResponse.ok(response)
} }

View File

@@ -3,9 +3,7 @@ package kr.co.vividnext.sodalive.admin.content.series.banner
import com.amazonaws.services.s3.model.ObjectMetadata import com.amazonaws.services.s3.model.ObjectMetadata
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.content.banner.UpdateBannerOrdersRequest import kr.co.vividnext.sodalive.admin.content.banner.UpdateBannerOrdersRequest
import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerListPageResponse
import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerRegisterRequest import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerRegisterRequest
import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerResponse
import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerUpdateRequest import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerUpdateRequest
import kr.co.vividnext.sodalive.aws.s3.S3Uploader import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
@@ -53,13 +51,7 @@ class AdminContentSeriesBannerController(
@RequestParam(defaultValue = "20") size: Int @RequestParam(defaultValue = "20") size: Int
) = run { ) = run {
val pageable = PageRequest.of(page, size) val pageable = PageRequest.of(page, size)
val banners = bannerService.getActiveBanners(pageable) val response = bannerService.getActiveBanners(pageable, imageHost)
val response = SeriesBannerListPageResponse(
totalCount = banners.totalElements,
content = banners.content.map {
SeriesBannerResponse.from(it, imageHost, appendLanguageToSeriesTitle = true)
}
)
ApiResponse.ok(response) ApiResponse.ok(response)
} }
@@ -68,8 +60,7 @@ class AdminContentSeriesBannerController(
*/ */
@GetMapping("/{bannerId}") @GetMapping("/{bannerId}")
fun getBannerDetail(@PathVariable bannerId: Long) = run { fun getBannerDetail(@PathVariable bannerId: Long) = run {
val banner = bannerService.getBannerById(bannerId) val response = bannerService.getBannerDetailResponse(bannerId, imageHost)
val response = SeriesBannerResponse.from(banner, imageHost)
ApiResponse.ok(response) ApiResponse.ok(response)
} }
@@ -86,8 +77,7 @@ class AdminContentSeriesBannerController(
val banner = bannerService.registerBanner(seriesId = request.seriesId, imagePath = "", lang = request.lang) val banner = bannerService.registerBanner(seriesId = request.seriesId, imagePath = "", lang = request.lang)
val imagePath = saveImage(banner.id!!, image) val imagePath = saveImage(banner.id!!, image)
val updatedBanner = bannerService.updateBanner(banner.id!!, imagePath) val response = bannerService.updateBannerResponse(banner.id!!, imagePath, imageHost = imageHost)
val response = SeriesBannerResponse.from(updatedBanner, imageHost)
ApiResponse.ok(response) ApiResponse.ok(response)
} }
@@ -104,12 +94,12 @@ class AdminContentSeriesBannerController(
// 배너 존재 확인 // 배너 존재 확인
bannerService.getBannerById(request.bannerId) bannerService.getBannerById(request.bannerId)
val imagePath = saveImage(request.bannerId, image) val imagePath = saveImage(request.bannerId, image)
val updated = bannerService.updateBanner( val response = bannerService.updateBannerResponse(
bannerId = request.bannerId, bannerId = request.bannerId,
imagePath = imagePath, imagePath = imagePath,
seriesId = request.seriesId seriesId = request.seriesId,
imageHost = imageHost
) )
val response = SeriesBannerResponse.from(updated, imageHost)
ApiResponse.ok(response) ApiResponse.ok(response)
} }

View File

@@ -82,6 +82,7 @@ class AdminLiveService(
) )
} }
@Transactional(readOnly = true)
fun getRecommendCreator(pageable: Pageable): GetAdminRecommendCreatorResponse { fun getRecommendCreator(pageable: Pageable): GetAdminRecommendCreatorResponse {
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")

View File

@@ -1,5 +1,7 @@
package kr.co.vividnext.sodalive.chat.character.service package kr.co.vividnext.sodalive.chat.character.service
import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerListPageResponse
import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerResponse
import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterBannerRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterBannerRepository
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
@@ -11,6 +13,7 @@ import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
@Service @Service
@Transactional(readOnly = true)
class ChatCharacterBannerService( class ChatCharacterBannerService(
private val bannerRepository: ChatCharacterBannerRepository, private val bannerRepository: ChatCharacterBannerRepository,
private val characterRepository: ChatCharacterRepository private val characterRepository: ChatCharacterRepository
@@ -18,8 +21,18 @@ class ChatCharacterBannerService(
/** /**
* 활성화된 모든 배너 조회 (정렬 순서대로) * 활성화된 모든 배너 조회 (정렬 순서대로)
*/ */
fun getActiveBanners(pageable: Pageable): Page<ChatCharacterBanner> { fun getActiveBanners(pageable: Pageable, imageHost: String): ChatCharacterBannerListPageResponse {
return bannerRepository.findByIsActiveTrueOrderBySortOrderAsc(pageable) val banners = bannerRepository.findByIsActiveTrueOrderBySortOrderAsc(pageable)
return ChatCharacterBannerListPageResponse(
totalCount = banners.totalElements,
content = banners.content.map {
ChatCharacterBannerResponse.from(
banner = it,
imageHost = imageHost,
appendLanguageToCharacterName = true
)
}
)
} }
fun getDisplayBanners(pageable: Pageable, lang: Lang): Page<ChatCharacterBanner> { fun getDisplayBanners(pageable: Pageable, lang: Lang): Page<ChatCharacterBanner> {

View File

@@ -1,6 +1,5 @@
package kr.co.vividnext.sodalive.content.series.main package kr.co.vividnext.sodalive.content.series.main
import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerResponse
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.series.ContentSeriesService import kr.co.vividnext.sodalive.content.series.ContentSeriesService
@@ -35,11 +34,7 @@ class SeriesMainController(
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member) val preference = resolvePreference(member)
val banners = bannerService.getDisplayBanners(PageRequest.of(0, 10), langContext.lang) val banners = bannerService.getDisplayBannerResponses(PageRequest.of(0, 10), langContext.lang, imageHost)
.content
.map {
SeriesBannerResponse.from(it, imageHost)
}
val completedSeriesList = contentSeriesService.getSeriesList( val completedSeriesList = contentSeriesService.getSeriesList(
creatorId = null, creatorId = null,

View File

@@ -1,6 +1,8 @@
package kr.co.vividnext.sodalive.content.series.main.banner package kr.co.vividnext.sodalive.content.series.main.banner
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.series.banner.dto.SeriesBannerListPageResponse
import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerResponse
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.i18n.Lang
import org.springframework.data.domain.Page import org.springframework.data.domain.Page
@@ -9,23 +11,54 @@ import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
@Service @Service
@Transactional(readOnly = true)
class ContentSeriesBannerService( class ContentSeriesBannerService(
private val bannerRepository: SeriesBannerRepository, private val bannerRepository: SeriesBannerRepository,
private val seriesRepository: AdminContentSeriesRepository private val seriesRepository: AdminContentSeriesRepository
) { ) {
fun getActiveBanners(pageable: Pageable): Page<SeriesBanner> { fun getActiveBanners(pageable: Pageable, imageHost: String): SeriesBannerListPageResponse {
return bannerRepository.findByIsActiveTrueOrderBySortOrderAsc(pageable) val banners = bannerRepository.findByIsActiveTrueOrderBySortOrderAsc(pageable)
return SeriesBannerListPageResponse(
totalCount = banners.totalElements,
content = banners.content.map {
SeriesBannerResponse.from(
banner = it,
imageHost = imageHost,
appendLanguageToSeriesTitle = true
)
}
)
} }
fun getDisplayBanners(pageable: Pageable, lang: Lang): Page<SeriesBanner> { fun getDisplayBanners(pageable: Pageable, lang: Lang): Page<SeriesBanner> {
return bannerRepository.findByIsActiveTrueAndLangOrderBySortOrderAsc(lang, pageable) return bannerRepository.findByIsActiveTrueAndLangOrderBySortOrderAsc(lang, pageable)
} }
fun getDisplayBannerResponses(pageable: Pageable, lang: Lang, imageHost: String): List<SeriesBannerResponse> {
return getDisplayBanners(pageable, lang).content.map {
SeriesBannerResponse.from(it, imageHost)
}
}
fun getBannerById(bannerId: Long): SeriesBanner { fun getBannerById(bannerId: Long): SeriesBanner {
return bannerRepository.findById(bannerId) return bannerRepository.findById(bannerId)
.orElseThrow { SodaException(messageKey = "series.banner.error.not_found") } .orElseThrow { SodaException(messageKey = "series.banner.error.not_found") }
} }
fun getBannerDetailResponse(bannerId: Long, imageHost: String): SeriesBannerResponse {
return SeriesBannerResponse.from(getBannerById(bannerId), imageHost)
}
@Transactional
fun updateBannerResponse(
bannerId: Long,
imagePath: String? = null,
seriesId: Long? = null,
imageHost: String
): SeriesBannerResponse {
return SeriesBannerResponse.from(updateBanner(bannerId, imagePath, seriesId), imageHost)
}
@Transactional @Transactional
fun registerBanner(seriesId: Long, imagePath: String, lang: Lang? = null): SeriesBanner { fun registerBanner(seriesId: Long, imagePath: String, lang: Lang? = null): SeriesBanner {
val series = seriesRepository.findByIdAndActiveTrue(seriesId) val series = seriesRepository.findByIdAndActiveTrue(seriesId)

View File

@@ -0,0 +1,90 @@
package kr.co.vividnext.sodalive.admin.chat
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner
import kr.co.vividnext.sodalive.i18n.Lang
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.DisplayName
import org.junit.jupiter.api.Test
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.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.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.support.TransactionTemplate
import javax.persistence.EntityManager
@SpringBootTest(
properties = [
"cloud.aws.cloud-front.host=https://cdn.test",
"spring.datasource.url=jdbc:h2:mem:admin-chat-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 AdminChatBannerControllerIntegrationTest @Autowired constructor(
private val mockMvc: MockMvc,
private val entityManager: EntityManager,
private val transactionTemplate: TransactionTemplate
) {
@Test
@DisplayName("관리자 채팅 배너 목록 API는 OSIV off 환경에서 lazy 초기화 예외 없이 응답한다")
fun shouldReturnAdminChatBannerListWhenOpenInViewIsDisabled() {
createBannerFixture()
mockMvc.perform(
get("/admin/chat/banner/list")
.param("page", "0")
.param("size", "20")
.with(user("admin").roles("ADMIN"))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.totalCount").value(1))
.andExpect(jsonPath("$.data.content[0].characterName").value("character-admin-banner (일본어)"))
.andExpect(jsonPath("$.data.content[0].imagePath").value("https://cdn.test/banner/jp.png"))
}
private fun createBannerFixture() {
transactionTemplate.execute {
val creator = Member(
email = "admin-chat-banner-controller@test.com",
password = "password",
nickname = "admin-chat-banner-creator",
role = MemberRole.CREATOR
)
entityManager.persist(creator)
val character = ChatCharacter(
characterUUID = "character-admin-banner-controller",
name = "character-admin-banner",
description = "description",
systemPrompt = "system-prompt"
).apply {
creatorMember = creator
}
entityManager.persist(character)
entityManager.persist(
ChatCharacterBanner(
imagePath = "banner/jp.png",
chatCharacter = character,
sortOrder = 1,
lang = Lang.JA
)
)
entityManager.flush()
entityManager.clear()
}
}
}

View File

@@ -14,8 +14,6 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.mockito.Mockito import org.mockito.Mockito
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
import org.springframework.mock.web.MockMultipartFile import org.springframework.mock.web.MockMultipartFile
import java.net.URL import java.net.URL
@@ -109,21 +107,6 @@ class AdminChatBannerControllerTest {
Mockito.verify(bannerService).registerBanner(2L, "", null) Mockito.verify(bannerService).registerBanner(2L, "", null)
} }
@Test
fun shouldAppendBannerLanguageToCharacterNameInBannerList() {
val pageable = PageRequest.of(0, 20)
val japaneseBanner = createBanner(id = 12L, lang = Lang.JA, imagePath = "banner/jp.png")
Mockito.`when`(adminCharacterService.createDefaultPageRequest(0, 20)).thenReturn(pageable)
Mockito.`when`(bannerService.getActiveBanners(pageable))
.thenReturn(PageImpl(listOf(japaneseBanner), pageable, 1))
val response = controller.getBannerList(page = 0, size = 20)
assertTrue(response.success)
assertEquals("character-12 (일본어)", response.data?.content?.first()?.characterName)
}
private fun createBanner(id: Long, lang: Lang, imagePath: String): ChatCharacterBanner { private fun createBanner(id: Long, lang: Lang, imagePath: String): ChatCharacterBanner {
val character = ChatCharacter( val character = ChatCharacter(
characterUUID = "character-$id", characterUUID = "character-$id",

View File

@@ -0,0 +1,113 @@
package kr.co.vividnext.sodalive.admin.content.series.banner
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
import kr.co.vividnext.sodalive.content.series.main.banner.SeriesBanner
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.i18n.Lang
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.DisplayName
import org.junit.jupiter.api.Test
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.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.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.support.TransactionTemplate
import javax.persistence.EntityManager
@SpringBootTest(
properties = [
"cloud.aws.cloud-front.host=https://cdn.test",
"spring.datasource.url=jdbc:h2:mem:admin-series-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_EACH_TEST_METHOD)
class AdminContentSeriesBannerControllerIntegrationTest @Autowired constructor(
private val mockMvc: MockMvc,
private val entityManager: EntityManager,
private val transactionTemplate: TransactionTemplate
) {
@Test
@DisplayName("관리자 시리즈 배너 목록 API는 OSIV off 환경에서 lazy 초기화 예외 없이 응답한다")
fun shouldReturnAdminSeriesBannerListWhenOpenInViewIsDisabled() {
createBannerFixture()
mockMvc.perform(
get("/admin/audio-content/series/banner/list")
.param("page", "0")
.param("size", "20")
.with(user("admin").roles("ADMIN"))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.totalCount").value(1))
.andExpect(jsonPath("$.data.content[0].seriesTitle").value("series-admin-banner (일본어)"))
.andExpect(jsonPath("$.data.content[0].imagePath").value("https://cdn.test/banner/jp.png"))
}
@Test
@DisplayName("관리자 시리즈 배너 상세 API는 OSIV off 환경에서 lazy 초기화 예외 없이 응답한다")
fun shouldReturnAdminSeriesBannerDetailWhenOpenInViewIsDisabled() {
val bannerId = createBannerFixture()
mockMvc.perform(
get("/admin/audio-content/series/banner/{bannerId}", bannerId)
.with(user("admin").roles("ADMIN"))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.id").value(bannerId))
.andExpect(jsonPath("$.data.seriesTitle").value("series-admin-banner"))
.andExpect(jsonPath("$.data.imagePath").value("https://cdn.test/banner/jp.png"))
}
private fun createBannerFixture(): Long {
return transactionTemplate.execute {
val creator = Member(
email = "admin-series-banner-controller@test.com",
password = "password",
nickname = "admin-series-banner-creator",
role = MemberRole.CREATOR
)
entityManager.persist(creator)
val genre = SeriesGenre(genre = "admin-series-banner-genre")
entityManager.persist(genre)
val series = Series(
title = "series-admin-banner",
introduction = "introduction",
languageCode = "ko"
).apply {
member = creator
this.genre = genre
}
entityManager.persist(series)
val banner = SeriesBanner(
imagePath = "banner/jp.png",
series = series,
sortOrder = 1,
lang = Lang.JA
)
entityManager.persist(
banner
)
entityManager.flush()
val bannerId = banner.id!!
entityManager.clear()
bannerId
}!!
}
}

View File

@@ -2,6 +2,8 @@ package kr.co.vividnext.sodalive.admin.content.series.banner
import com.amazonaws.services.s3.AmazonS3Client import com.amazonaws.services.s3.AmazonS3Client
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerListPageResponse
import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerResponse
import kr.co.vividnext.sodalive.aws.s3.S3Uploader import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerService import kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerService
import kr.co.vividnext.sodalive.content.series.main.banner.SeriesBanner import kr.co.vividnext.sodalive.content.series.main.banner.SeriesBanner
@@ -13,7 +15,6 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.mockito.Mockito import org.mockito.Mockito
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest import org.springframework.data.domain.PageRequest
import org.springframework.mock.web.MockMultipartFile import org.springframework.mock.web.MockMultipartFile
import java.net.URL import java.net.URL
@@ -35,7 +36,12 @@ class AdminContentSeriesBannerControllerTest {
fun shouldRegisterJapaneseBannerThroughAdminApi() { fun shouldRegisterJapaneseBannerThroughAdminApi() {
val image = MockMultipartFile("image", "banner.png", "image/png", "image".toByteArray()) val image = MockMultipartFile("image", "banner.png", "image/png", "image".toByteArray())
val registeredBanner = createBanner(id = 10L, lang = Lang.JA, imagePath = "") val registeredBanner = createBanner(id = 10L, lang = Lang.JA, imagePath = "")
val updatedBanner = createBanner(id = 10L, lang = Lang.JA, imagePath = "") val updatedResponse = SeriesBannerResponse(
id = 10L,
imagePath = "https://cdn.test/series_banner/10/banner.png",
seriesId = 10L,
seriesTitle = "series-10"
)
Mockito.`when`(amazonS3Client.getUrl(Mockito.eq("test-bucket"), Mockito.anyString())) Mockito.`when`(amazonS3Client.getUrl(Mockito.eq("test-bucket"), Mockito.anyString()))
.thenAnswer { URL("https://cdn.test/${it.arguments[1]}") } .thenAnswer { URL("https://cdn.test/${it.arguments[1]}") }
@@ -47,11 +53,14 @@ class AdminContentSeriesBannerControllerTest {
lang = Lang.JA lang = Lang.JA
) )
).thenReturn(registeredBanner) ).thenReturn(registeredBanner)
Mockito.doAnswer { Mockito.`when`(
updatedBanner.apply { bannerService.updateBannerResponse(
imagePath = it.arguments[1] as String bannerId = eqLong(10L),
} imagePath = anyStringValue(),
}.`when`(bannerService).updateBanner(Mockito.eq(10L), Mockito.anyString(), Mockito.isNull()) seriesId = nullLongValue(),
imageHost = eqString("https://cdn.test")
)
).thenReturn(updatedResponse)
val response = controller.registerBanner( val response = controller.registerBanner(
image = image, image = image,
@@ -60,8 +69,52 @@ class AdminContentSeriesBannerControllerTest {
assertTrue(response.success) assertTrue(response.success)
assertEquals(10L, response.data?.id) assertEquals(10L, response.data?.id)
assertTrue(response.data?.imagePath?.startsWith("https://cdn.test/series_banner/10/") == true) assertEquals("https://cdn.test/series_banner/10/banner.png", response.data?.imagePath)
Mockito.verify(bannerService).registerBanner(1L, "", Lang.JA) Mockito.verify(bannerService).registerBanner(1L, "", Lang.JA)
Mockito.verify(bannerService).updateBannerResponse(
bannerId = eqLong(10L),
imagePath = anyStringValue(),
seriesId = nullLongValue(),
imageHost = eqString("https://cdn.test")
)
}
@Test
fun shouldDelegateUpdateBannerResponseToService() {
val image = MockMultipartFile("image", "banner.png", "image/png", "image".toByteArray())
val serviceResponse = SeriesBannerResponse(
id = 10L,
imagePath = "https://cdn.test/series_banner/10/banner.png",
seriesId = 20L,
seriesTitle = "series-20"
)
Mockito.`when`(amazonS3Client.getUrl(Mockito.eq("test-bucket"), Mockito.anyString()))
.thenAnswer { URL("https://cdn.test/${it.arguments[1]}") }
Mockito.`when`(bannerService.getBannerById(10L)).thenReturn(createBanner(id = 10L, lang = Lang.JA, imagePath = "old.png"))
Mockito.`when`(
bannerService.updateBannerResponse(
bannerId = eqLong(10L),
imagePath = anyStringValue(),
seriesId = eqLong(20L),
imageHost = eqString("https://cdn.test")
)
).thenReturn(serviceResponse)
val response = controller.updateBanner(
image = image,
requestString = "{\"bannerId\":10,\"seriesId\":20}"
)
assertTrue(response.success)
assertEquals("series-20", response.data?.seriesTitle)
Mockito.verify(bannerService).getBannerById(10L)
Mockito.verify(bannerService).updateBannerResponse(
bannerId = eqLong(10L),
imagePath = anyStringValue(),
seriesId = eqLong(20L),
imageHost = eqString("https://cdn.test")
)
} }
@Test @Test
@@ -77,15 +130,45 @@ class AdminContentSeriesBannerControllerTest {
@Test @Test
fun shouldAppendBannerLanguageToSeriesTitleInBannerList() { fun shouldAppendBannerLanguageToSeriesTitleInBannerList() {
val pageable = PageRequest.of(0, 20) val pageable = PageRequest.of(0, 20)
val japaneseBanner = createBanner(id = 12L, lang = Lang.JA, imagePath = "banner/jp.png") val serviceResponse = SeriesBannerListPageResponse(
totalCount = 1,
content = listOf(
SeriesBannerResponse(
id = 12L,
imagePath = "https://cdn.test/banner/jp.png",
seriesId = 12L,
seriesTitle = "series-12 (일본어)"
)
)
)
Mockito.`when`(bannerService.getActiveBanners(pageable)) Mockito.`when`(bannerService.getActiveBanners(pageable, "https://cdn.test"))
.thenReturn(PageImpl(listOf(japaneseBanner), pageable, 1)) .thenReturn(serviceResponse)
val response = controller.getBannerList(page = 0, size = 20) val response = controller.getBannerList(page = 0, size = 20)
assertTrue(response.success) assertTrue(response.success)
assertEquals("series-12 (일본어)", response.data?.content?.first()?.seriesTitle) assertEquals("series-12 (일본어)", response.data?.content?.first()?.seriesTitle)
Mockito.verify(bannerService).getActiveBanners(pageable, "https://cdn.test")
}
@Test
fun shouldDelegateBannerDetailResponseToService() {
val serviceResponse = SeriesBannerResponse(
id = 12L,
imagePath = "https://cdn.test/banner/jp.png",
seriesId = 12L,
seriesTitle = "series-12"
)
Mockito.`when`(bannerService.getBannerDetailResponse(12L, "https://cdn.test"))
.thenReturn(serviceResponse)
val response = controller.getBannerDetail(bannerId = 12L)
assertTrue(response.success)
assertEquals("series-12", response.data?.seriesTitle)
Mockito.verify(bannerService).getBannerDetailResponse(12L, "https://cdn.test")
} }
private fun createBanner(id: Long, lang: Lang, imagePath: String): SeriesBanner { private fun createBanner(id: Long, lang: Lang, imagePath: String): SeriesBanner {
@@ -105,4 +188,12 @@ class AdminContentSeriesBannerControllerTest {
it.id = id it.id = id
} }
} }
private fun anyStringValue(): String = Mockito.anyString() ?: ""
private fun eqLong(value: Long): Long = Mockito.eq(value)
private fun eqString(value: String): String = Mockito.eq(value) ?: value
private fun nullLongValue(): Long? = Mockito.isNull()
} }

View File

@@ -0,0 +1,86 @@
package kr.co.vividnext.sodalive.admin.live
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.live.recommend.RecommendLiveCreatorBanner
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.DisplayName
import org.junit.jupiter.api.Test
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.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.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.support.TransactionTemplate
import java.time.LocalDateTime
import javax.persistence.EntityManager
@SpringBootTest(
properties = [
"cloud.aws.cloud-front.host=https://cdn.test",
"spring.datasource.url=jdbc:h2:mem:admin-live-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 AdminLiveControllerIntegrationTest @Autowired constructor(
private val mockMvc: MockMvc,
private val entityManager: EntityManager,
private val transactionTemplate: TransactionTemplate
) {
@Test
@DisplayName("관리자 라이브 추천 크리에이터 목록 API는 OSIV off 환경에서 lazy 초기화 예외 없이 응답한다")
fun shouldReturnAdminRecommendCreatorListWhenOpenInViewIsDisabled() {
createBannerFixture()
mockMvc.perform(
get("/admin/live/recommend-creator")
.param("page", "0")
.param("size", "20")
.with(user("admin").roles("ADMIN"))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.totalCount").value(1))
.andExpect(jsonPath("$.data.recommendCreatorList[0].creatorNickname").value("admin-live-api-creator"))
.andExpect(jsonPath("$.data.recommendCreatorList[0].image").value("https://cdn.test/recommend/api.png"))
.andExpect(jsonPath("$.data.recommendCreatorList[0].startDate").value("2026-06-29 10:00"))
.andExpect(jsonPath("$.data.recommendCreatorList[0].endDate").value("2026-06-30 10:00"))
.andExpect(jsonPath("$.data.recommendCreatorList[0].isAdult").value(false))
}
private fun createBannerFixture() {
transactionTemplate.execute {
val creator = Member(
email = "admin-live-api-creator@test.com",
password = "password",
nickname = "admin-live-api-creator",
profileImage = "profile/default-profile.png",
role = MemberRole.CREATOR
)
entityManager.persist(creator)
val banner = RecommendLiveCreatorBanner(
startDate = LocalDateTime.of(2026, 6, 29, 1, 0),
endDate = LocalDateTime.of(2026, 6, 30, 1, 0),
isAdult = false,
lang = Lang.KO,
orders = 1,
image = "recommend/api.png"
)
banner.creator = creator
entityManager.persist(banner)
entityManager.flush()
entityManager.clear()
}
}
}

View File

@@ -0,0 +1,83 @@
package kr.co.vividnext.sodalive.admin.live
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.live.recommend.RecommendLiveCreatorBanner
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.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.data.domain.PageRequest
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 = [
"cloud.aws.cloud-front.host=https://cdn.test",
"spring.datasource.url=jdbc:h2:mem:admin-live-service-integration;" +
"MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"
]
)
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class AdminLiveServiceIntegrationTest @Autowired constructor(
private val service: AdminLiveService,
private val entityManager: EntityManager,
private val transactionTemplate: TransactionTemplate
) {
@Test
@DisplayName("관리자 라이브 추천 크리에이터 목록은 OSIV off 환경에서 lazy 초기화 예외 없이 응답한다")
fun shouldReturnRecommendCreatorListWhenOpenInViewIsDisabled() {
val fixture = createBannerFixture()
val response = service.getRecommendCreator(PageRequest.of(0, 20))
val item = response.recommendCreatorList.single()
assertEquals(1, response.totalCount)
assertEquals(fixture.creatorId, item.creatorId)
assertEquals("admin-live-recommend-creator", item.creatorNickname)
assertEquals("https://cdn.test/recommend/live.png", item.image)
assertEquals("2026-06-29 10:00", item.startDate)
assertEquals("2026-06-30 10:00", item.endDate)
assertEquals(false, item.isAdult)
}
private fun createBannerFixture(): Fixture {
return transactionTemplate.execute {
val creator = Member(
email = "admin-live-recommend-creator@test.com",
password = "password",
nickname = "admin-live-recommend-creator",
profileImage = "profile/default-profile.png",
role = MemberRole.CREATOR
)
entityManager.persist(creator)
val banner = RecommendLiveCreatorBanner(
startDate = LocalDateTime.of(2026, 6, 29, 1, 0),
endDate = LocalDateTime.of(2026, 6, 30, 1, 0),
isAdult = false,
lang = Lang.KO,
orders = 1,
image = "recommend/live.png"
)
banner.creator = creator
entityManager.persist(banner)
entityManager.flush()
val fixture = Fixture(creatorId = creator.id!!)
entityManager.clear()
fixture
}!!
}
private data class Fixture(
val creatorId: Long
)
}

View File

@@ -0,0 +1,76 @@
package kr.co.vividnext.sodalive.chat.character.service
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterBannerRepository
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
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.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.data.domain.PageRequest
import org.springframework.test.annotation.DirtiesContext
import org.springframework.test.context.ContextConfiguration
import javax.persistence.EntityManager
@SpringBootTest(
properties = [
"cloud.aws.cloud-front.host=https://cdn.test",
"spring.datasource.url=jdbc:h2:mem:chat-banner-service-integration;" +
"MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"
]
)
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class ChatCharacterBannerServiceIntegrationTest @Autowired constructor(
private val service: ChatCharacterBannerService,
private val characterRepository: ChatCharacterRepository,
private val bannerRepository: ChatCharacterBannerRepository,
private val memberRepository: MemberRepository,
private val entityManager: EntityManager
) {
@Test
@DisplayName("OSIV off 환경에서 관리자 배너 목록 응답 생성 시 lazy 초기화 예외가 발생하지 않는다")
fun shouldCreateAdminBannerListResponseWhenOpenInViewIsDisabled() {
val creator = memberRepository.saveAndFlush(
Member(
email = "character-admin-banner-service@test.com",
password = "password",
nickname = "character-admin-banner-creator",
role = MemberRole.CREATOR
)
)
val character = characterRepository.saveAndFlush(
ChatCharacter(
characterUUID = "character-admin-banner-service",
name = "character-admin-banner",
description = "description",
systemPrompt = "system-prompt"
).apply {
creatorMember = creator
}
)
bannerRepository.saveAndFlush(
ChatCharacterBanner(
imagePath = "banner/jp.png",
chatCharacter = character,
sortOrder = 1,
lang = Lang.JA
)
)
entityManager.clear()
val response = service.getActiveBanners(PageRequest.of(0, 20), "https://cdn.test")
assertEquals(1, response.totalCount)
assertEquals(character.id, response.content.first().characterId)
assertEquals("character-admin-banner (일본어)", response.content.first().characterName)
assertEquals("https://cdn.test/banner/jp.png", response.content.first().imagePath)
}
}

View File

@@ -1,11 +1,10 @@
package kr.co.vividnext.sodalive.content.series.main package kr.co.vividnext.sodalive.content.series.main
import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerResponse
import kr.co.vividnext.sodalive.content.ContentType import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.series.ContentSeriesService import kr.co.vividnext.sodalive.content.series.ContentSeriesService
import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse
import kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerService import kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerService
import kr.co.vividnext.sodalive.content.series.main.banner.SeriesBanner
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
@@ -15,7 +14,6 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.mockito.Mockito import org.mockito.Mockito
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest import org.springframework.data.domain.PageRequest
class SeriesMainControllerTest { class SeriesMainControllerTest {
@@ -41,18 +39,16 @@ class SeriesMainControllerTest {
isAdult = true isAdult = true
) )
val pageable = PageRequest.of(0, 10) val pageable = PageRequest.of(0, 10)
val japaneseBanner = SeriesBanner( val japaneseBanner = SeriesBannerResponse(
imagePath = "banner/jp.png", id = 100L,
series = createSeries(id = 10L), imagePath = "https://cdn.test/banner/jp.png",
sortOrder = 1, seriesId = 10L,
lang = Lang.JA seriesTitle = "series-10"
).also { )
it.id = 100L
}
Mockito.`when`(memberContentPreferenceService.resolveForQuery(member)).thenReturn(preference) Mockito.`when`(memberContentPreferenceService.resolveForQuery(member)).thenReturn(preference)
Mockito.`when`(bannerService.getDisplayBanners(pageable, Lang.JA)) Mockito.`when`(bannerService.getDisplayBannerResponses(pageable, Lang.JA, "https://cdn.test"))
.thenReturn(PageImpl(listOf(japaneseBanner), pageable, 1)) .thenReturn(listOf(japaneseBanner))
Mockito.`when`( Mockito.`when`(
contentSeriesService.getSeriesList( contentSeriesService.getSeriesList(
null, null,
@@ -79,8 +75,8 @@ class SeriesMainControllerTest {
assertTrue(response.success) assertTrue(response.success)
assertEquals(1, response.data?.banners?.size) assertEquals(1, response.data?.banners?.size)
assertEquals("series-10", response.data?.banners?.first()?.seriesTitle) assertEquals("series-10", response.data?.banners?.first()?.seriesTitle)
Mockito.verify(bannerService).getDisplayBanners(pageable, Lang.JA) Mockito.verify(bannerService).getDisplayBannerResponses(pageable, Lang.JA, "https://cdn.test")
Mockito.verify(bannerService, Mockito.never()).getActiveBanners(pageable) Mockito.verify(bannerService, Mockito.never()).getActiveBanners(pageable, "https://cdn.test")
} }
private fun createMember(id: Long): Member { private fun createMember(id: Long): Member {
@@ -92,14 +88,4 @@ class SeriesMainControllerTest {
it.id = id it.id = id
} }
} }
private fun createSeries(id: Long): Series {
return Series(
title = "series-$id",
introduction = "introduction-$id",
languageCode = "ja"
).also {
it.id = id
}
}
} }

View File

@@ -0,0 +1,137 @@
package kr.co.vividnext.sodalive.content.series.main.banner
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.i18n.Lang
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.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.data.domain.PageRequest
import org.springframework.test.annotation.DirtiesContext
import org.springframework.test.context.ContextConfiguration
import org.springframework.transaction.support.TransactionTemplate
import javax.persistence.EntityManager
@SpringBootTest(
properties = [
"cloud.aws.cloud-front.host=https://cdn.test",
"spring.datasource.url=jdbc:h2:mem:series-banner-service-integration;" +
"MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"
]
)
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class ContentSeriesBannerServiceIntegrationTest @Autowired constructor(
private val service: ContentSeriesBannerService,
private val entityManager: EntityManager,
private val transactionTemplate: TransactionTemplate
) {
@Test
@DisplayName("OSIV off 환경에서 관리자 시리즈 배너 목록 응답 생성 시 lazy 초기화 예외가 발생하지 않는다")
fun shouldCreateAdminSeriesBannerListResponseWhenOpenInViewIsDisabled() {
val fixtureIds = createBannerFixture(suffix = "list", sortOrder = 1)
val response = service.getActiveBanners(PageRequest.of(0, 20), "https://cdn.test")
assertEquals(1, response.totalCount)
assertEquals(fixtureIds.seriesId, response.content.first().seriesId)
assertEquals("series-admin-banner (일본어)", response.content.first().seriesTitle)
assertEquals("https://cdn.test/banner/jp.png", response.content.first().imagePath)
}
@Test
@DisplayName("OSIV off 환경에서 관리자 시리즈 배너 상세 응답 생성 시 lazy 초기화 예외가 발생하지 않는다")
fun shouldCreateAdminSeriesBannerDetailResponseWhenOpenInViewIsDisabled() {
val fixtureIds = createBannerFixture(suffix = "detail", sortOrder = 2)
val response = service.getBannerDetailResponse(fixtureIds.bannerId, "https://cdn.test")
assertEquals(fixtureIds.bannerId, response.id)
assertEquals(fixtureIds.seriesId, response.seriesId)
assertEquals("series-admin-banner", response.seriesTitle)
assertEquals("https://cdn.test/banner/jp.png", response.imagePath)
}
@Test
@DisplayName("OSIV off 환경에서 공개 시리즈 메인 배너 응답 생성 시 lazy 초기화 예외가 발생하지 않는다")
fun shouldCreatePublicMainSeriesBannerResponseWhenOpenInViewIsDisabled() {
val fixtureIds = createBannerFixture(suffix = "public", sortOrder = 3)
val response = service.getDisplayBannerResponses(PageRequest.of(0, 10), Lang.JA, "https://cdn.test")
assertEquals(1, response.size)
assertEquals(fixtureIds.seriesId, response.first().seriesId)
assertEquals("series-admin-banner", response.first().seriesTitle)
assertEquals("https://cdn.test/banner/jp.png", response.first().imagePath)
}
@Test
@DisplayName("OSIV off 환경에서 관리자 시리즈 배너 수정 응답 생성 시 lazy 초기화 예외가 발생하지 않는다")
fun shouldCreateAdminSeriesBannerUpdateResponseWhenOpenInViewIsDisabled() {
val fixtureIds = createBannerFixture(suffix = "update", sortOrder = 4)
val response = service.updateBannerResponse(
bannerId = fixtureIds.bannerId,
imagePath = "banner/updated.png",
imageHost = "https://cdn.test"
)
assertEquals(fixtureIds.bannerId, response.id)
assertEquals(fixtureIds.seriesId, response.seriesId)
assertEquals("series-admin-banner", response.seriesTitle)
assertEquals("https://cdn.test/banner/updated.png", response.imagePath)
}
private fun createBannerFixture(suffix: String, sortOrder: Int): BannerFixtureIds {
return transactionTemplate.execute {
val creator = Member(
email = "series-admin-banner-service-$suffix@test.com",
password = "password",
nickname = "series-admin-banner-creator",
role = MemberRole.CREATOR
)
entityManager.persist(creator)
val genre = SeriesGenre(genre = "series-admin-banner-genre")
entityManager.persist(genre)
val series = Series(
title = "series-admin-banner",
introduction = "introduction",
languageCode = "ko"
).apply {
member = creator
this.genre = genre
}
entityManager.persist(series)
val banner = SeriesBanner(
imagePath = "banner/jp.png",
series = series,
sortOrder = sortOrder,
lang = Lang.JA
)
entityManager.persist(
banner
)
entityManager.flush()
val fixtureIds = BannerFixtureIds(
seriesId = series.id!!,
bannerId = banner.id!!
)
entityManager.clear()
fixtureIds
}!!
}
private data class BannerFixtureIds(
val seriesId: Long,
val bannerId: Long
)
}