@@ -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`
|
||||
@@ -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 수정과 같은 패턴의 관리자 목록 조회 안정화로 한정한다.
|
||||
@@ -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`로 종료됐다.
|
||||
@@ -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`가 통과한다.
|
||||
@@ -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`을 확인했다.
|
||||
79
docs/20260629_관리자_채팅배너_LazyInitializationException_수정/prd.md
Normal file
79
docs/20260629_관리자_채팅배너_LazyInitializationException_수정/prd.md
Normal 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`가 통과한다.
|
||||
@@ -4,7 +4,6 @@ import com.amazonaws.services.s3.model.ObjectMetadata
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
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.dto.ChatCharacterBannerListPageResponse
|
||||
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.ChatCharacterBannerUpdateRequest
|
||||
@@ -59,17 +58,7 @@ class AdminChatBannerController(
|
||||
@RequestParam(defaultValue = "20") size: Int
|
||||
) = run {
|
||||
val pageable = adminCharacterService.createDefaultPageRequest(page, size)
|
||||
val banners = bannerService.getActiveBanners(pageable)
|
||||
val response = ChatCharacterBannerListPageResponse(
|
||||
totalCount = banners.totalElements,
|
||||
content = banners.content.map {
|
||||
ChatCharacterBannerResponse.from(
|
||||
banner = it,
|
||||
imageHost = imageHost,
|
||||
appendLanguageToCharacterName = true
|
||||
)
|
||||
}
|
||||
)
|
||||
val response = bannerService.getActiveBanners(pageable, imageHost)
|
||||
|
||||
ApiResponse.ok(response)
|
||||
}
|
||||
|
||||
@@ -3,9 +3,7 @@ package kr.co.vividnext.sodalive.admin.content.series.banner
|
||||
import com.amazonaws.services.s3.model.ObjectMetadata
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
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.SeriesBannerResponse
|
||||
import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerUpdateRequest
|
||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
@@ -53,13 +51,7 @@ class AdminContentSeriesBannerController(
|
||||
@RequestParam(defaultValue = "20") size: Int
|
||||
) = run {
|
||||
val pageable = PageRequest.of(page, size)
|
||||
val banners = bannerService.getActiveBanners(pageable)
|
||||
val response = SeriesBannerListPageResponse(
|
||||
totalCount = banners.totalElements,
|
||||
content = banners.content.map {
|
||||
SeriesBannerResponse.from(it, imageHost, appendLanguageToSeriesTitle = true)
|
||||
}
|
||||
)
|
||||
val response = bannerService.getActiveBanners(pageable, imageHost)
|
||||
ApiResponse.ok(response)
|
||||
}
|
||||
|
||||
@@ -68,8 +60,7 @@ class AdminContentSeriesBannerController(
|
||||
*/
|
||||
@GetMapping("/{bannerId}")
|
||||
fun getBannerDetail(@PathVariable bannerId: Long) = run {
|
||||
val banner = bannerService.getBannerById(bannerId)
|
||||
val response = SeriesBannerResponse.from(banner, imageHost)
|
||||
val response = bannerService.getBannerDetailResponse(bannerId, imageHost)
|
||||
ApiResponse.ok(response)
|
||||
}
|
||||
|
||||
@@ -86,8 +77,7 @@ class AdminContentSeriesBannerController(
|
||||
|
||||
val banner = bannerService.registerBanner(seriesId = request.seriesId, imagePath = "", lang = request.lang)
|
||||
val imagePath = saveImage(banner.id!!, image)
|
||||
val updatedBanner = bannerService.updateBanner(banner.id!!, imagePath)
|
||||
val response = SeriesBannerResponse.from(updatedBanner, imageHost)
|
||||
val response = bannerService.updateBannerResponse(banner.id!!, imagePath, imageHost = imageHost)
|
||||
ApiResponse.ok(response)
|
||||
}
|
||||
|
||||
@@ -104,12 +94,12 @@ class AdminContentSeriesBannerController(
|
||||
// 배너 존재 확인
|
||||
bannerService.getBannerById(request.bannerId)
|
||||
val imagePath = saveImage(request.bannerId, image)
|
||||
val updated = bannerService.updateBanner(
|
||||
val response = bannerService.updateBannerResponse(
|
||||
bannerId = request.bannerId,
|
||||
imagePath = imagePath,
|
||||
seriesId = request.seriesId
|
||||
seriesId = request.seriesId,
|
||||
imageHost = imageHost
|
||||
)
|
||||
val response = SeriesBannerResponse.from(updated, imageHost)
|
||||
ApiResponse.ok(response)
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ class AdminLiveService(
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getRecommendCreator(pageable: Pageable): GetAdminRecommendCreatorResponse {
|
||||
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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.repository.ChatCharacterBannerRepository
|
||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||
@@ -11,6 +13,7 @@ import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@Service
|
||||
@Transactional(readOnly = true)
|
||||
class ChatCharacterBannerService(
|
||||
private val bannerRepository: ChatCharacterBannerRepository,
|
||||
private val characterRepository: ChatCharacterRepository
|
||||
@@ -18,8 +21,18 @@ class ChatCharacterBannerService(
|
||||
/**
|
||||
* 활성화된 모든 배너 조회 (정렬 순서대로)
|
||||
*/
|
||||
fun getActiveBanners(pageable: Pageable): Page<ChatCharacterBanner> {
|
||||
return bannerRepository.findByIsActiveTrueOrderBySortOrderAsc(pageable)
|
||||
fun getActiveBanners(pageable: Pageable, imageHost: String): ChatCharacterBannerListPageResponse {
|
||||
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> {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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.SodaException
|
||||
import kr.co.vividnext.sodalive.content.series.ContentSeriesService
|
||||
@@ -35,11 +34,7 @@ class SeriesMainController(
|
||||
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
|
||||
val preference = resolvePreference(member)
|
||||
|
||||
val banners = bannerService.getDisplayBanners(PageRequest.of(0, 10), langContext.lang)
|
||||
.content
|
||||
.map {
|
||||
SeriesBannerResponse.from(it, imageHost)
|
||||
}
|
||||
val banners = bannerService.getDisplayBannerResponses(PageRequest.of(0, 10), langContext.lang, imageHost)
|
||||
|
||||
val completedSeriesList = contentSeriesService.getSeriesList(
|
||||
creatorId = null,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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.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.i18n.Lang
|
||||
import org.springframework.data.domain.Page
|
||||
@@ -9,23 +11,54 @@ import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@Service
|
||||
@Transactional(readOnly = true)
|
||||
class ContentSeriesBannerService(
|
||||
private val bannerRepository: SeriesBannerRepository,
|
||||
private val seriesRepository: AdminContentSeriesRepository
|
||||
) {
|
||||
fun getActiveBanners(pageable: Pageable): Page<SeriesBanner> {
|
||||
return bannerRepository.findByIsActiveTrueOrderBySortOrderAsc(pageable)
|
||||
fun getActiveBanners(pageable: Pageable, imageHost: String): SeriesBannerListPageResponse {
|
||||
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> {
|
||||
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 {
|
||||
return bannerRepository.findById(bannerId)
|
||||
.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
|
||||
fun registerBanner(seriesId: Long, imagePath: String, lang: Lang? = null): SeriesBanner {
|
||||
val series = seriesRepository.findByIdAndActiveTrue(seriesId)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,6 @@ import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.data.domain.PageImpl
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.mock.web.MockMultipartFile
|
||||
import java.net.URL
|
||||
|
||||
@@ -109,21 +107,6 @@ class AdminChatBannerControllerTest {
|
||||
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 {
|
||||
val character = ChatCharacter(
|
||||
characterUUID = "character-$id",
|
||||
|
||||
@@ -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
|
||||
}!!
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package kr.co.vividnext.sodalive.admin.content.series.banner
|
||||
|
||||
import com.amazonaws.services.s3.AmazonS3Client
|
||||
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.content.series.main.banner.ContentSeriesBannerService
|
||||
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.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.data.domain.PageImpl
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.mock.web.MockMultipartFile
|
||||
import java.net.URL
|
||||
@@ -35,7 +36,12 @@ class AdminContentSeriesBannerControllerTest {
|
||||
fun shouldRegisterJapaneseBannerThroughAdminApi() {
|
||||
val image = MockMultipartFile("image", "banner.png", "image/png", "image".toByteArray())
|
||||
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()))
|
||||
.thenAnswer { URL("https://cdn.test/${it.arguments[1]}") }
|
||||
@@ -47,11 +53,14 @@ class AdminContentSeriesBannerControllerTest {
|
||||
lang = Lang.JA
|
||||
)
|
||||
).thenReturn(registeredBanner)
|
||||
Mockito.doAnswer {
|
||||
updatedBanner.apply {
|
||||
imagePath = it.arguments[1] as String
|
||||
}
|
||||
}.`when`(bannerService).updateBanner(Mockito.eq(10L), Mockito.anyString(), Mockito.isNull())
|
||||
Mockito.`when`(
|
||||
bannerService.updateBannerResponse(
|
||||
bannerId = eqLong(10L),
|
||||
imagePath = anyStringValue(),
|
||||
seriesId = nullLongValue(),
|
||||
imageHost = eqString("https://cdn.test")
|
||||
)
|
||||
).thenReturn(updatedResponse)
|
||||
|
||||
val response = controller.registerBanner(
|
||||
image = image,
|
||||
@@ -60,8 +69,52 @@ class AdminContentSeriesBannerControllerTest {
|
||||
|
||||
assertTrue(response.success)
|
||||
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).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
|
||||
@@ -77,15 +130,45 @@ class AdminContentSeriesBannerControllerTest {
|
||||
@Test
|
||||
fun shouldAppendBannerLanguageToSeriesTitleInBannerList() {
|
||||
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))
|
||||
.thenReturn(PageImpl(listOf(japaneseBanner), pageable, 1))
|
||||
Mockito.`when`(bannerService.getActiveBanners(pageable, "https://cdn.test"))
|
||||
.thenReturn(serviceResponse)
|
||||
|
||||
val response = controller.getBannerList(page = 0, size = 20)
|
||||
|
||||
assertTrue(response.success)
|
||||
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 {
|
||||
@@ -105,4 +188,12 @@ class AdminContentSeriesBannerControllerTest {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
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.series.ContentSeriesService
|
||||
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.SeriesBanner
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
|
||||
import kr.co.vividnext.sodalive.i18n.Lang
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
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.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.data.domain.PageImpl
|
||||
import org.springframework.data.domain.PageRequest
|
||||
|
||||
class SeriesMainControllerTest {
|
||||
@@ -41,18 +39,16 @@ class SeriesMainControllerTest {
|
||||
isAdult = true
|
||||
)
|
||||
val pageable = PageRequest.of(0, 10)
|
||||
val japaneseBanner = SeriesBanner(
|
||||
imagePath = "banner/jp.png",
|
||||
series = createSeries(id = 10L),
|
||||
sortOrder = 1,
|
||||
lang = Lang.JA
|
||||
).also {
|
||||
it.id = 100L
|
||||
}
|
||||
val japaneseBanner = SeriesBannerResponse(
|
||||
id = 100L,
|
||||
imagePath = "https://cdn.test/banner/jp.png",
|
||||
seriesId = 10L,
|
||||
seriesTitle = "series-10"
|
||||
)
|
||||
|
||||
Mockito.`when`(memberContentPreferenceService.resolveForQuery(member)).thenReturn(preference)
|
||||
Mockito.`when`(bannerService.getDisplayBanners(pageable, Lang.JA))
|
||||
.thenReturn(PageImpl(listOf(japaneseBanner), pageable, 1))
|
||||
Mockito.`when`(bannerService.getDisplayBannerResponses(pageable, Lang.JA, "https://cdn.test"))
|
||||
.thenReturn(listOf(japaneseBanner))
|
||||
Mockito.`when`(
|
||||
contentSeriesService.getSeriesList(
|
||||
null,
|
||||
@@ -79,8 +75,8 @@ class SeriesMainControllerTest {
|
||||
assertTrue(response.success)
|
||||
assertEquals(1, response.data?.banners?.size)
|
||||
assertEquals("series-10", response.data?.banners?.first()?.seriesTitle)
|
||||
Mockito.verify(bannerService).getDisplayBanners(pageable, Lang.JA)
|
||||
Mockito.verify(bannerService, Mockito.never()).getActiveBanners(pageable)
|
||||
Mockito.verify(bannerService).getDisplayBannerResponses(pageable, Lang.JA, "https://cdn.test")
|
||||
Mockito.verify(bannerService, Mockito.never()).getActiveBanners(pageable, "https://cdn.test")
|
||||
}
|
||||
|
||||
private fun createMember(id: Long): Member {
|
||||
@@ -92,14 +88,4 @@ class SeriesMainControllerTest {
|
||||
it.id = id
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSeries(id: Long): Series {
|
||||
return Series(
|
||||
title = "series-$id",
|
||||
introduction = "introduction-$id",
|
||||
languageCode = "ja"
|
||||
).also {
|
||||
it.id = id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user