Compare commits
14 Commits
f2be184fc9
...
24a61e4d78
| Author | SHA1 | Date | |
|---|---|---|---|
| 24a61e4d78 | |||
| 5cb69bfa6e | |||
| 79c51cf27b | |||
| 34230f5269 | |||
| b6d89397db | |||
| d304df7ddf | |||
| 9f6300624c | |||
| 107e6de3eb | |||
| e0df436fd9 | |||
| 5f09f59f53 | |||
| 99f61ed13e | |||
| df5c2c9048 | |||
| 38595ee88a | |||
| 8ae48d7e67 |
285
docs/20260626_현재진행중인라이브조회_API/plan-task.md
Normal file
285
docs/20260626_현재진행중인라이브조회_API/plan-task.md
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
# 현재 진행 중인 라이브 조회 API Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
|
||||||
|
|
||||||
|
**Goal:** 인증 회원이 `GET /api/v2/home/on-air-lives`로 현재 진행 중인 라이브를 20개씩 페이징 조회한다.
|
||||||
|
|
||||||
|
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.home.live` 조립 계층에 둔다. 도메인 조회는 기존 `kr.co.vividnext.sodalive.v2.recommendation`의 `HomeRecommendationQueryService`와 `HomeRecommendationQueryPort.findLiveRecommendations(...)`를 확장 재사용한다. 기존 추천 탭 공개 응답 DTO는 변경하지 않고, 신규 endpoint에서만 `title`, `price`, `beginDateTimeUtc`를 포함한 응답 DTO로 조립한다.
|
||||||
|
|
||||||
|
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Security, Spring Data JPA, QueryDSL, JUnit 5, MockMvc, Gradle Wrapper
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 확정 사항
|
||||||
|
|
||||||
|
- API endpoint: `GET /api/v2/home/on-air-lives`
|
||||||
|
- 인증 정책: 인증 회원만 조회 가능
|
||||||
|
- 비회원/anonymous 요청: 기존 인증 필요 API와 동일하게 인증 오류 반환
|
||||||
|
- 응답 wrapper: `ApiResponse.ok(...)`
|
||||||
|
- query parameter: `page`만 받음, 기본값 `0`
|
||||||
|
- page size: 항상 20개 고정, 클라이언트가 `size`를 지정하지 않음
|
||||||
|
- page 응답: `items`, `page`, `size`, `hasNext`
|
||||||
|
- `hasNext` 판정: 내부에서 `PAGE_SIZE + 1`개 조회 후 응답에는 최대 20개만 노출
|
||||||
|
- 현재 진행 중인 라이브 조건: `live_room.is_active = true`, `channel_name is not null`, `channel_name <> ''`
|
||||||
|
- 정렬: `live_room.begin_date_time desc`, `live_room.id desc`
|
||||||
|
- 방송자 조건: `member.is_active = true`
|
||||||
|
- 차단 정책: 요청 회원과 크리에이터의 양방향 활성 차단 관계 제외
|
||||||
|
- 성인 라이브 정책: `MemberContentPreferenceService.canViewAdultContent(member)` 결과 반영
|
||||||
|
- 시작 시간 응답: `LiveRoom.beginDateTime`을 기존 UTC ISO 문자열 변환 패턴으로 변환해 `beginDateTimeUtc`로 응답
|
||||||
|
- 프로필 이미지: 기존 홈 추천 패턴과 동일하게 CDN URL 변환, 없으면 기본 프로필 이미지 URL
|
||||||
|
- 기존 공개 API 스키마 유지:
|
||||||
|
- `GET /api/v2/home/recommendations`
|
||||||
|
- `GET /api/v2/home/recommendations/lives`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 파일 구조 계획
|
||||||
|
|
||||||
|
### 신규 API 조립 계층
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveController.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacade.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/dto/HomeOnAirLiveResponse.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveControllerTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacadeTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/dto/HomeOnAirLiveResponseTest.kt`
|
||||||
|
|
||||||
|
### 기존 도메인 조회 계층 확장
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
||||||
|
|
||||||
|
### 기존 설정 수정
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveControllerTest.kt`
|
||||||
|
|
||||||
|
### 문서
|
||||||
|
- Keep: `docs/20260626_현재진행중인라이브조회_API/prd.md`
|
||||||
|
- Modify: `docs/20260626_현재진행중인라이브조회_API/plan-task.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Response data class 초안
|
||||||
|
|
||||||
|
`src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/dto/HomeOnAirLiveResponse.kt`에 아래 DTO를 추가한다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
package kr.co.vividnext.sodalive.v2.api.home.live.dto
|
||||||
|
|
||||||
|
data class HomeOnAirLivePageResponse(
|
||||||
|
val items: List<HomeOnAirLiveResponse>,
|
||||||
|
val page: Int,
|
||||||
|
val size: Int,
|
||||||
|
val hasNext: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class HomeOnAirLiveResponse(
|
||||||
|
val roomId: Long,
|
||||||
|
val creatorNickname: String,
|
||||||
|
val creatorProfileImage: String,
|
||||||
|
val title: String,
|
||||||
|
val price: Int,
|
||||||
|
val beginDateTimeUtc: String
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`의 기존 record는 아래처럼 확장한다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
package kr.co.vividnext.sodalive.v2.recommendation.port.out
|
||||||
|
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
data class HomeLiveRecommendationRecord(
|
||||||
|
val liveRoomId: Long,
|
||||||
|
val creatorNickname: String,
|
||||||
|
val creatorProfileImage: String?,
|
||||||
|
val title: String,
|
||||||
|
val price: Int,
|
||||||
|
val beginDateTime: LocalDateTime
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
기존 `HomeRecommendationFacade.toItem()`은 `title`, `price`를 무시하고 기존 `HomeLiveItem` 필드만 매핑해 기존 API 응답 스키마를 유지한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 1: 도메인 조회 record 확장
|
||||||
|
|
||||||
|
- [x] **Task 1.1: 라이브 추천 record에 title/price/beginDateTime 포함**
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
||||||
|
- RED: `DefaultHomeRecommendationQueryRepositoryTest`에 `shouldReturnLiveTitlePriceAndBeginDateTimeForOnAirLiveQuery` 테스트를 추가한다. fixture는 `LiveRoom(title = "paid live", price = 30, beginDateTime = LocalDateTime.of(2026, 6, 26, 12, 30), channelName = "channel")`를 저장하고, `findLiveRecommendations(offset = 0, limit = 1, memberId = viewer.id, includeAdultLives = true)` 결과의 `title == "paid live"`, `price == 30`, `beginDateTime == LocalDateTime.of(2026, 6, 26, 12, 30)`을 검증한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
|
||||||
|
- GREEN: `HomeLiveRecommendationRecord`에 `title`, `price`, `beginDateTime`을 추가하고, QueryDSL projection에 `liveRoom.title`, `liveRoom.price`, `liveRoom.beginDateTime`을 추가한다.
|
||||||
|
- REFACTOR: 기존 `HomeRecommendationFacade.toItem()`과 기존 테스트 컴파일 오류를 수정하되 `HomeLiveItem` 공개 필드는 추가하지 않는다.
|
||||||
|
- 기대 결과: repository 테스트가 PASS이고 기존 추천 탭 응답 DTO에는 `title`, `price`, `beginDateTimeUtc`가 추가되지 않는다.
|
||||||
|
|
||||||
|
- [x] **Task 1.2: 기존 라이브 조회 조건 회귀 테스트 보강**
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||||
|
- RED: 기존 `shouldFindPagedLiveRecommendationsWithAdultFilter` 테스트를 확장하거나 별도 `shouldApplyOnAirLiveVisibilityPolicy` 테스트를 추가한다. 활성 방송자/비활성 방송자, `channelName = null`, 빈 `channelName`, `isActive = false`, 성인 라이브, 양방향 차단 라이브를 fixture로 만들고 조건에 맞는 라이브만 최신순으로 반환되는지 검증한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
|
||||||
|
- GREEN: 기존 조회 조건이 부족하면 `member.isActive.isTrue`, `liveRoom.channelName.isNotNull`, `liveRoom.channelName.isNotEmpty`, `includeAdultLiveCondition(...)`, `notBlockedCreatorCondition(...)`을 보강한다.
|
||||||
|
- REFACTOR: 중복 조건은 기존 private condition 함수로 유지하고 신규 abstraction은 추가하지 않는다.
|
||||||
|
- 기대 결과: 진행 중 라이브 조회 정책이 PRD의 노출 조건과 일치한다.
|
||||||
|
|
||||||
|
- [x] **Task 1.3: HomeRecommendationQueryService 위임 계약 유지**
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
|
||||||
|
- RED: `shouldDelegateLiveRecommendationQueryWithPagingAndAdultFlag` 테스트를 추가한다. mock `HomeRecommendationQueryPort`가 `HomeLiveRecommendationRecord(liveRoomId = 1L, creatorNickname = "creator", creatorProfileImage = "profile.png", title = "live", price = 10, beginDateTime = LocalDateTime.of(2026, 6, 26, 12, 30))`을 반환하도록 하고, service가 `offset`, `limit`, `memberId`, `includeAdultLives`를 그대로 port에 전달하는지 검증한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
|
||||||
|
- GREEN: 컴파일 오류가 있으면 record 생성부와 import를 갱신한다. service 메서드 시그니처는 기존 `findLiveRecommendations(offset, limit, memberId, includeAdultLives)`를 유지한다.
|
||||||
|
- REFACTOR: service에는 신규 API 전용 page 조립 로직을 넣지 않는다.
|
||||||
|
- 기대 결과: 도메인 조회 계층은 API DTO에 의존하지 않고 기존 port record만 반환한다.
|
||||||
|
|
||||||
|
### Phase 2: 신규 API 조립 계층
|
||||||
|
|
||||||
|
- [x] **Task 2.1: 신규 응답 DTO와 직렬화 테스트 추가**
|
||||||
|
- Files:
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/dto/HomeOnAirLiveResponse.kt`
|
||||||
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/dto/HomeOnAirLiveResponseTest.kt`
|
||||||
|
- RED: `shouldSerializeOnAirLivePageResponse` 테스트를 작성한다. `HomeOnAirLivePageResponse(items = listOf(HomeOnAirLiveResponse(...)), page = 0, size = 20, hasNext = true)`를 Jackson으로 직렬화하고 `items[0].roomId`, `creatorNickname`, `creatorProfileImage`, `title`, `price`, `beginDateTimeUtc`, `page`, `size`, `hasNext` 필드가 존재하는지 검증한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLiveResponseTest`
|
||||||
|
- GREEN: PRD의 Response data class와 동일한 DTO를 추가한다.
|
||||||
|
- REFACTOR: DTO에는 도메인 조회나 CDN 변환 로직을 넣지 않는다.
|
||||||
|
- 기대 결과: 공개 응답 필드명이 PRD와 일치한다.
|
||||||
|
|
||||||
|
- [x] **Task 2.2: HomeOnAirLiveFacade 작성**
|
||||||
|
- Files:
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacade.kt`
|
||||||
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacadeTest.kt`
|
||||||
|
- RED: `shouldReturnFixedSizePageAndHasNext` 테스트를 작성한다. mock `HomeRecommendationQueryService`가 21개 record를 반환하게 하고, facade가 `page = 0`, `size = 20`, `hasNext = true`, `items.size = 20`을 반환하는지 검증한다. `offset = 0`, `limit = 21`, `memberId = member.id`, `includeAdultLives = true` 호출도 검증한다.
|
||||||
|
- RED: `shouldUseDefaultProfileImageWhenCreatorProfileImageIsBlank` 테스트를 작성한다. `creatorProfileImage = null`인 record가 `https://cdn.test/profile/default-profile.png`로 매핑되는지 검증한다.
|
||||||
|
- RED: `shouldMapBeginDateTimeToUtcIsoString` 테스트를 작성한다. record의 `beginDateTime = LocalDateTime.of(2026, 6, 26, 12, 30)`가 응답 `beginDateTimeUtc = "2026-06-26T12:30:00Z"`로 변환되는지 검증한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.application.HomeOnAirLiveFacadeTest`
|
||||||
|
- GREEN: `HomeOnAirLiveFacade`를 `@Component`로 추가한다. 생성자에는 `HomeRecommendationQueryService`, `MemberContentPreferenceService`, `@Value("\${cloud.aws.cloud-front.host}") cloudFrontHost`를 주입한다.
|
||||||
|
- GREEN: `getOnAirLives(member: Member, page: Int): HomeOnAirLivePageResponse`를 구현하고, 내부 상수는 `PAGE_SIZE = 20`, `MAX_PAGE = 10_000`으로 둔다.
|
||||||
|
- GREEN: `page.coerceIn(0, MAX_PAGE)`로 page를 보정하고, `offset = normalizedPage * PAGE_SIZE`, `limit = PAGE_SIZE + 1`로 조회한다.
|
||||||
|
- REFACTOR: CDN URL 변환은 기존 홈 추천의 `profileImageUrl(cloudFrontHost, path)` 의미와 동일하게 유지한다. 시작 시간 UTC 문자열 변환은 기존 `toUtcIso` 의미와 동일하게 유지한다. 해당 helper들이 package-private이라 재사용이 어렵다면 facade 내부 private 함수로 최소 복제한다.
|
||||||
|
- 기대 결과: facade가 page 조립, 성인 노출 플래그 계산, DTO 매핑만 담당한다.
|
||||||
|
|
||||||
|
- [x] **Task 2.3: HomeOnAirLiveController 작성**
|
||||||
|
- Files:
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveController.kt`
|
||||||
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveControllerTest.kt`
|
||||||
|
- RED: `shouldRejectAnonymousRequest` 테스트를 작성한다. `GET /api/v2/home/on-air-lives`를 인증 없이 호출하면 401 Unauthorized가 반환되는지 검증한다.
|
||||||
|
- RED: `shouldPassAuthenticatedMemberAndPageToFacade` 테스트를 작성한다. `with(user(MemberAdapter(member)))`로 `GET /api/v2/home/on-air-lives?page=2`를 호출하고 facade가 member와 page 2를 받으며 `$.data.size == 20` 응답을 반환하는지 검증한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveControllerTest`
|
||||||
|
- GREEN: `@RestController`, `@RequestMapping("/api/v2/home/on-air-lives")` controller를 추가한다. `@GetMapping` 메서드는 `@RequestParam(defaultValue = "0") page: Int`와 `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?`를 받는다.
|
||||||
|
- GREEN: `member ?: throw SodaException(messageKey = "common.error.bad_credentials")`로 인증 회원을 요구하고, `ApiResponse.ok(homeOnAirLiveFacade.getOnAirLives(member, page))`를 반환한다.
|
||||||
|
- REFACTOR: controller에는 조회 조건/응답 매핑 로직을 넣지 않는다.
|
||||||
|
- 기대 결과: 신규 endpoint는 인증 회원만 접근 가능하고 기존 `ApiResponse.ok(...)` wrapper를 따른다.
|
||||||
|
|
||||||
|
### Phase 3: 보안 설정과 회귀 검증
|
||||||
|
|
||||||
|
- [x] **Task 3.1: SecurityConfig에 인증 필요 endpoint 등록**
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveControllerTest.kt`
|
||||||
|
- RED: `HomeOnAirLiveControllerTest.shouldRejectAnonymousRequest`가 `SecurityConfig` 적용 상태에서 401을 기대하도록 유지한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveControllerTest`
|
||||||
|
- GREEN: `SecurityConfig`에 `GET /api/v2/home/on-air-lives`를 `authenticated()` 경로로 추가한다. `permitAll`에는 추가하지 않는다.
|
||||||
|
- REFACTOR: 기존 `/api/v2/home/recommendations` permitAll과 `/api/v2/home/recommendations/**` authenticated 정책을 변경하지 않는다.
|
||||||
|
- 기대 결과: 현재 진행 중인 라이브 신규 API는 인증 필수이고, 기존 추천 탭 통합 조회와 전체보기 API의 기존 보안 정책은 변경되지 않는다.
|
||||||
|
|
||||||
|
- [x] **Task 3.2: 기존 추천 탭 응답 스키마 회귀 테스트**
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponseTest.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
|
||||||
|
- RED: `shouldKeepHomeLiveItemSchemaWithoutTitlePriceAndBeginDateTimeUtc` 테스트를 추가한다. `HomeLiveItem(roomId = 1L, creatorNickname = "creator", creatorProfileImage = "https://cdn.test/profile.png")`를 직렬화하고 `title`, `price`, `beginDateTimeUtc` 필드가 없음을 검증한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest`
|
||||||
|
- GREEN: `HomeRecommendationFacade`의 기존 `HomeLiveRecommendationRecord.toItem()` 매핑은 `roomId`, `creatorNickname`, `creatorProfileImage`만 사용하도록 유지한다.
|
||||||
|
- REFACTOR: 신규 API DTO와 기존 추천 탭 DTO import가 섞이지 않도록 패키지를 명확히 유지한다.
|
||||||
|
- 기대 결과: 기존 `GET /api/v2/home/recommendations/lives` 응답 스키마는 변경되지 않는다.
|
||||||
|
|
||||||
|
- [x] **Task 3.3: End-to-end 조회 검증**
|
||||||
|
- Files:
|
||||||
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveEndToEndTest.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveController.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacade.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||||
|
- RED: `shouldReturnAuthenticatedOnAirLivesWithTitlePriceAndBeginDateTimeUtc` 통합 테스트를 작성한다. 인증 회원, 활성 크리에이터, 진행 중 라이브 2개를 저장하고 `GET /api/v2/home/on-air-lives?page=0` 호출 결과에서 최신순, `roomId`, `creatorNickname`, `creatorProfileImage`, `title`, `price`, `beginDateTimeUtc`, `page = 0`, `size = 20`, `hasNext = false`를 검증한다.
|
||||||
|
- RED: `shouldExcludeAdultLiveWhenViewerCannotViewAdultContent` 통합 테스트를 작성한다. 성인 콘텐츠 노출 불가 회원 기준으로 성인 라이브가 제외되는지 검증한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveEndToEndTest`
|
||||||
|
- GREEN: controller, facade, query repository 연결을 보강해 통합 테스트를 통과시킨다.
|
||||||
|
- REFACTOR: 테스트 fixture helper는 해당 테스트 클래스 내부 private 함수로 두고, 공용 테스트 유틸은 만들지 않는다.
|
||||||
|
- 기대 결과: 실제 Spring MVC, Security, JPA/QueryDSL 경로로 신규 API 요구사항이 검증된다.
|
||||||
|
|
||||||
|
### Phase 4: 최종 검증과 문서 기록
|
||||||
|
|
||||||
|
- [x] **Task 4.1: 단일/회귀 테스트 실행 및 기록**
|
||||||
|
- Files:
|
||||||
|
- Modify: `docs/20260626_현재진행중인라이브조회_API/plan-task.md`
|
||||||
|
- RED: 신규/수정 테스트가 모두 구현된 상태에서 아래 명령을 실행한다.
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveControllerTest`
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.application.HomeOnAirLiveFacadeTest`
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLiveResponseTest`
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
|
||||||
|
- 실패 확인: 실패가 있으면 해당 task로 돌아가 원인을 수정한다.
|
||||||
|
- GREEN: 신규 API 관련 단일 테스트가 모두 PASS인지 확인한다.
|
||||||
|
- REFACTOR: `./gradlew ktlintCheck`를 실행해 포맷 위반을 확인한다.
|
||||||
|
- 회귀 확인: `./gradlew test`를 실행해 전체 테스트 회귀를 확인한다.
|
||||||
|
- 기대 결과: 단일 테스트, ktlint, 전체 테스트 결과를 이 task 아래에 한국어로 누적 기록한다.
|
||||||
|
- 검증 기록:
|
||||||
|
- 무엇을: 신규 API 관련 controller/facade/DTO/repository/query service 단일 테스트, 신규 API E2E 테스트, ktlint, 전체 회귀 테스트를 실행했다.
|
||||||
|
- 왜: Phase 1~3 구현 결과가 신규 endpoint 계약과 기존 추천 도메인 회귀 범위를 유지하는지 최종 확인하기 위해서다.
|
||||||
|
- 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveControllerTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.application.HomeOnAirLiveFacadeTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLiveResponseTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveEndToEndTest`, `./gradlew ktlintCheck`, `./gradlew test`를 순차 실행했다.
|
||||||
|
- 결과: 단일 테스트 6개 명령과 `ktlintCheck`는 모두 `BUILD SUCCESSFUL`로 통과했다. `./gradlew test`는 1029개 테스트 중 1개 실패로 종료했고, 실패 테스트는 `AudioContentServiceTest > 업로드 완료 시 예약 공개 콘텐츠는 생성 시점에 최근 소식을 발행하지 않는다`이며 `AudioContentServiceTest.kt:422`의 Mockito interaction 검증 실패다. 신규 API 관련 단일/E2E 검증은 모두 통과했으므로 전체 회귀 실패는 기존 하단 검증 기록과 같은 범위 외 잔여 실패로 기록한다.
|
||||||
|
|
||||||
|
- [x] **Task 4.2: 문서 동기화 확인**
|
||||||
|
- Files:
|
||||||
|
- Keep: `docs/20260626_현재진행중인라이브조회_API/prd.md`
|
||||||
|
- Modify: `docs/20260626_현재진행중인라이브조회_API/plan-task.md`
|
||||||
|
- RED: 구현 중 endpoint, response field, 인증 정책, page size가 바뀌었는지 확인한다.
|
||||||
|
- 실패 확인: PRD와 구현이 다르면 구현 전에 PRD와 plan-task를 먼저 갱신한다.
|
||||||
|
- GREEN: 변경 사항이 없으면 문서 경로와 검증 결과만 유지한다.
|
||||||
|
- REFACTOR: `./gradlew tasks --all`을 실행해 문서 유지보수 규칙의 명령 유효성을 확인한다.
|
||||||
|
- 기대 결과: PRD와 plan-task가 같은 endpoint, response data class, 인증 정책, 페이징 정책을 설명한다.
|
||||||
|
- 검증 기록:
|
||||||
|
- 무엇을: PRD와 plan-task의 endpoint, response field, 인증 정책, page size 설명이 구현/테스트 대상과 같은지 확인했다.
|
||||||
|
- 왜: Phase 4에서 최종 문서 계약이 실제 신규 API 구현과 어긋나지 않도록 하기 위해서다.
|
||||||
|
- 어떻게: `docs/20260626_현재진행중인라이브조회_API/prd.md`, 이 문서의 확정 사항/실행 명령, `HomeOnAirLiveControllerTest`, `HomeOnAirLiveFacadeTest`, `HomeOnAirLiveResponseTest`, `HomeOnAirLiveEndToEndTest`의 검증 범위를 대조하고 `./gradlew tasks --all`을 실행했다.
|
||||||
|
- 결과: PRD와 plan-task 모두 `GET /api/v2/home/on-air-lives`, 인증 회원 전용, page size 20, `items/page/size/hasNext`, `roomId/creatorNickname/creatorProfileImage/title/price/beginDateTimeUtc` 응답 필드를 동일하게 설명한다. `./gradlew tasks --all`은 `BUILD SUCCESSFUL`로 통과했다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 실행 명령
|
||||||
|
|
||||||
|
- 컨트롤러 단일 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveControllerTest`
|
||||||
|
- facade 단일 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.application.HomeOnAirLiveFacadeTest`
|
||||||
|
- DTO 단일 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLiveResponseTest`
|
||||||
|
- repository 단일 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
|
||||||
|
- query service 단일 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
|
||||||
|
- 신규 API E2E 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveEndToEndTest`
|
||||||
|
- 포맷 검증: `./gradlew ktlintCheck`
|
||||||
|
- 전체 회귀 테스트: `./gradlew test`
|
||||||
|
- Gradle 명령 유효성 확인: `./gradlew tasks --all`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 검증 기록
|
||||||
|
|
||||||
|
- 문서 작성 시점에는 구현을 진행하지 않았으므로 테스트 실행 기록은 없다.
|
||||||
|
- 2026-06-26 문서 작성 후 명령 유효성 확인을 위해 `./gradlew tasks --all`을 실행했고 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- 2026-06-26 `beginDateTimeUtc` 응답 필드 문서 보강 후 명령 유효성 확인을 위해 `./gradlew tasks --all`을 실행했고 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- 2026-06-26 Phase 1/2 RED 확인: 신규 테스트 추가 후 `HomeLiveRecommendationRecord.title/price/beginDateTime`, `HomeOnAirLiveResponse`, `HomeOnAirLiveFacade`, `HomeOnAirLiveController` 미구현으로 `:compileTestKotlin FAILED`를 확인했다.
|
||||||
|
- 2026-06-26 Phase 1/2 GREEN 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLiveResponseTest --tests kr.co.vividnext.sodalive.v2.api.home.live.application.HomeOnAirLiveFacadeTest --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveControllerTest`를 실행했고 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- 2026-06-26 Phase 1/2 포맷 검증: `./gradlew ktlintCheck`를 실행했고 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- 2026-06-26 전체 회귀 확인: `./gradlew test`는 1026개 테스트 중 1개 실패로 종료했다. 실패 테스트는 `kr.co.vividnext.sodalive.content.AudioContentServiceTest.shouldNotPublishNewsWhenUploadCompleteKeepsScheduledContentInactive`이며, 동일 테스트 단독 재실행도 같은 `HomeFollowingNewsPublishService` mock interaction 검증 실패를 재현했다. 이번 Phase 1/2 변경 파일은 `v2/recommendation`, `v2/api/home/live`, 문서에 한정되어 해당 실패는 범위 외 잔여 실패로 기록한다.
|
||||||
|
- 2026-06-27 Phase 3 RED 확인: `HomeOnAirLiveEndToEndTest` 신규 추가 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveEndToEndTest`를 실행했고, `$.data.items.length()`가 기대값 2가 아닌 3으로 실패하는 것을 확인했다. 실패 원인은 신규 E2E 테스트 메서드 간 H2 fixture 공유로 확인했다.
|
||||||
|
- 2026-06-27 Phase 3 GREEN 확인: `SecurityConfig`에 `GET /api/v2/home/on-air-lives` 인증 matcher를 명시하고 E2E 테스트 격리를 보강한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveControllerTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveEndToEndTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest`를 각각 실행했고 모두 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- 2026-06-27 Phase 3 포맷 검증: `./gradlew ktlintCheck`를 실행했고 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- 2026-06-27 Phase 3 회귀 묶음 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveControllerTest --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveEndToEndTest`를 실행했고 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- 2026-06-27 Phase 3 코드 리뷰 보강: `HomeRecommendationResponseTest.shouldKeepHomeLiveItemSchemaWithoutTitlePriceAndBeginDateTimeUtc`에 테스트 스타일 규칙에 맞는 `@DisplayName`을 추가했다. 이후 `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveControllerTest --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest --tests kr.co.vividnext.sodalive.v2.api.home.live.adapter.in.web.HomeOnAirLiveEndToEndTest`와 `./gradlew --no-daemon ktlintCheck`를 실행했고 모두 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- 2026-06-27 Phase 4 단일/E2E/포맷 검증: `HomeOnAirLiveControllerTest`, `HomeOnAirLiveFacadeTest`, `HomeOnAirLiveResponseTest`, `DefaultHomeRecommendationQueryRepositoryTest`, `HomeRecommendationQueryServiceTest`, `HomeOnAirLiveEndToEndTest`를 각각 `./gradlew test --tests ...`로 실행했고 모두 `BUILD SUCCESSFUL`을 확인했다. 이어서 `./gradlew ktlintCheck`도 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- 2026-06-27 Phase 4 전체 회귀 확인: `./gradlew test`는 1029개 테스트 중 1개 실패로 종료했다. 실패 테스트는 `AudioContentServiceTest > 업로드 완료 시 예약 공개 콘텐츠는 생성 시점에 최근 소식을 발행하지 않는다`이며 `AudioContentServiceTest.kt:422`의 Mockito interaction 검증 실패다. 신규 API 관련 단일/E2E 테스트는 모두 통과했고, 실패 위치가 `content.AudioContentServiceTest`로 이번 Phase 4 문서 기록 범위 및 신규 `v2/api/home/live`, `v2/recommendation` 변경 범위 밖이므로 잔여 실패로 기록한다.
|
||||||
|
- 2026-06-27 Phase 4 문서 동기화 확인: PRD와 plan-task가 `GET /api/v2/home/on-air-lives`, 인증 회원 전용, page size 20, `items/page/size/hasNext`, `roomId/creatorNickname/creatorProfileImage/title/price/beginDateTimeUtc` 응답 필드를 동일하게 설명하는지 확인했다. 문서 유지보수 규칙 확인을 위해 `./gradlew tasks --all`을 실행했고 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- 2026-06-27 Phase 4 코드 리뷰 보강: `HomeOnAirLiveFacadeTest`, `HomeOnAirLiveResponseTest`에 테스트 스타일 규칙에 맞는 `@DisplayName`을 추가했다. 이후 `HomeOnAirLiveControllerTest`, `HomeOnAirLiveFacadeTest`, `HomeOnAirLiveResponseTest`, `DefaultHomeRecommendationQueryRepositoryTest`, `HomeRecommendationQueryServiceTest`, `HomeOnAirLiveEndToEndTest`를 각각 `./gradlew test --tests ...`로 재실행했고 모두 `BUILD SUCCESSFUL`을 확인했다. `./gradlew ktlintCheck`와 `./gradlew tasks --all`도 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- 2026-06-27 Phase 4 전체 회귀 재확인: `./gradlew test`는 1029개 테스트 중 1개 실패로 종료했다. 실패 테스트는 기존 기록과 동일하게 `AudioContentServiceTest > 업로드 완료 시 예약 공개 콘텐츠는 생성 시점에 최근 소식을 발행하지 않는다`이며 `AudioContentServiceTest.kt:422`의 Mockito interaction 검증 실패다. 신규 API 관련 단일/E2E 테스트는 모두 통과했으므로 범위 외 잔여 실패로 유지한다.
|
||||||
186
docs/20260626_현재진행중인라이브조회_API/prd.md
Normal file
186
docs/20260626_현재진행중인라이브조회_API/prd.md
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
# PRD: 현재 진행 중인 라이브 조회 API
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
메인 홈에서 현재 진행 중인 라이브 목록을 20개씩 페이징 조회하는 v2 API를 제공한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Problem
|
||||||
|
- 메인 홈 추천 탭 통합 API는 상단에 현재 진행 중인 라이브를 일부 내려주지만, 별도 목록 조회에 필요한 응답 필드가 부족하다.
|
||||||
|
- 기존 `GET /api/v2/home/recommendations/lives`는 `roomId`, `creatorNickname`, `creatorProfileImage`만 내려주며 이번 요구사항의 `title`, `price`, `beginDateTimeUtc`를 포함하지 않는다.
|
||||||
|
- 기존 공개 API 스키마를 변경하면 클라이언트 회귀 영향이 생길 수 있으므로, 신규 API 계약을 별도로 명시해야 한다.
|
||||||
|
- 기존 v2 홈 추천/팔로잉 탭에는 현재 진행 중인 라이브 조회 조건과 API 조립 계층/도메인 조회 계층 분리 패턴이 있으므로 이를 우선 재활용해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Goals
|
||||||
|
- 현재 진행 중인 라이브 목록 조회 API를 `kr.co.vividnext.sodalive.v2` 하위에 제공한다.
|
||||||
|
- 한 page당 20개씩 조회한다.
|
||||||
|
- 응답 item에는 `roomId`, `creatorNickname`, `creatorProfileImage`, `title`, `price`, `beginDateTimeUtc`를 포함한다.
|
||||||
|
- 기존 패턴과 동일하게 클라이언트 공개 API 조립 계층과 도메인 조회 계층을 분리한다.
|
||||||
|
- 기존 메인 홈 추천 탭의 라이브 조회 조건을 최대한 재사용한다.
|
||||||
|
- 인증 회원만 조회할 수 있게 하고, 회원별 차단/성인 콘텐츠 노출 조건을 반영한다.
|
||||||
|
- 기존 공개 API 응답 스키마는 변경하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Non-Goals
|
||||||
|
- 기존 `GET /api/v2/home/recommendations` 응답 스키마를 변경하지 않는다.
|
||||||
|
- 기존 `GET /api/v2/home/recommendations/lives` 응답 스키마를 변경하지 않는다.
|
||||||
|
- 라이브 생성, 예약, 입장, 종료 API는 포함하지 않는다.
|
||||||
|
- 라이브 추천 산식, 스냅샷, 랭킹, 배너 정책은 변경하지 않는다.
|
||||||
|
- 앱 표시용 가격 단위, 다국어 문구, 날짜 포맷은 서버에서 처리하지 않는다.
|
||||||
|
- 20개 외 page size를 클라이언트가 지정하는 기능은 이번 범위에 포함하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Target Users
|
||||||
|
- 회원: 메인 홈에서 현재 진행 중인 라이브 목록을 더 탐색하는 사용자
|
||||||
|
- 앱 클라이언트: 현재 라이브 목록 화면 또는 추천 탭의 추가 로딩 화면을 구성하는 클라이언트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. User Stories
|
||||||
|
- 사용자는 메인 홈에서 현재 진행 중인 라이브를 20개씩 추가로 보고 싶다.
|
||||||
|
- 사용자는 라이브 제목과 가격을 목록에서 바로 확인하고 싶다.
|
||||||
|
- 앱 클라이언트는 다음 page 존재 여부를 응답에서 확인해 무한 스크롤 또는 더보기 UI를 구성하고 싶다.
|
||||||
|
- 앱 클라이언트는 기존 추천 탭 상단 라이브와 동일한 노출 정책으로 별도 목록을 조회하고 싶다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Core Features
|
||||||
|
|
||||||
|
### Feature A. 현재 진행 중인 라이브 목록 API
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 신규 API endpoint는 `GET /api/v2/home/on-air-lives`로 정의한다.
|
||||||
|
- 응답 wrapper는 기존 패턴과 동일하게 `ApiResponse.ok(...)`를 사용한다.
|
||||||
|
- `page` query parameter를 받는다.
|
||||||
|
- `page`를 보내지 않으면 기본값 `0`을 사용한다.
|
||||||
|
- `page`는 0부터 시작하는 page index로 처리한다.
|
||||||
|
- `size` query parameter는 받지 않고, page size는 항상 20으로 고정한다.
|
||||||
|
- 다음 page 존재 여부는 `size + 1`개를 조회하거나 동등한 방식으로 판단한다.
|
||||||
|
- 응답 목록에는 최대 20개만 내려준다.
|
||||||
|
- 인증 회원만 조회할 수 있다.
|
||||||
|
- 인증 회원 조회는 기존 v2 컨트롤러 패턴과 동일하게 `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?`를 사용한다.
|
||||||
|
- `member == null`이면 기존 인증 필요 API와 동일하게 `common.error.bad_credentials` 계열 오류를 반환한다.
|
||||||
|
- 현재 진행 중인 라이브는 기존 홈 추천 라이브와 동일하게 `live_room.is_active = true`, `channel_name is not null`, `channel_name <> ''` 조건을 기본으로 한다.
|
||||||
|
- 방송자는 `member.is_active = true`인 대상만 노출한다.
|
||||||
|
- 정렬은 기존 홈 추천 라이브와 동일하게 `live_room.begin_date_time desc`, `live_room.id desc`로 한다.
|
||||||
|
- 양방향 차단 관계가 있는 크리에이터의 라이브는 제외한다.
|
||||||
|
- 성인 라이브 노출 여부는 `MemberContentPreferenceService.canViewAdultContent(member)` 결과를 따른다.
|
||||||
|
- 프로필 이미지는 기존 홈 추천/팔로잉 탭과 동일하게 CDN URL로 변환하고, 값이 없으면 기본 프로필 이미지 URL을 내려준다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 조회 결과가 없으면 `items = emptyList()`, `hasNext = false`를 내려준다.
|
||||||
|
- 비회원이 조회하면 목록을 내려주지 않고 인증 오류를 반환한다.
|
||||||
|
- `page`가 0보다 작으면 기존 홈 추천 컨트롤러의 `normalizePage` 패턴과 동일하게 0으로 보정한다.
|
||||||
|
- 매우 큰 `page` 값은 기존 홈 추천 컨트롤러의 `MAX_PAGE = 10_000` 패턴과 동일하게 상한 보정한다.
|
||||||
|
- 20개보다 적게 조회되면 가능한 개수만 내려주고 성공 처리한다.
|
||||||
|
- 라이브 제목이 빈 문자열이면 별도 fallback을 만들지 않고 저장된 `LiveRoom.title` 값을 그대로 내려준다.
|
||||||
|
- 라이브 가격은 `LiveRoom.price` 값을 그대로 내려준다.
|
||||||
|
- 라이브 시작 시간은 `LiveRoom.beginDateTime`을 기존 UTC ISO 문자열 변환 패턴으로 변환해 `beginDateTimeUtc`로 내려준다.
|
||||||
|
|
||||||
|
### Feature B. 계층 분리와 재사용 정책
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 클라이언트 공개 API 조립 계층은 `kr.co.vividnext.sodalive.v2.api.home.live` 하위에 둔다.
|
||||||
|
- API 조립 계층 후보 파일은 다음과 같다.
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveController.kt`
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacade.kt`
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/dto/HomeOnAirLiveResponse.kt`
|
||||||
|
- 도메인 조회 계층은 기존 `kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService`와 `HomeRecommendationQueryPort.findLiveRecommendations(...)` 확장 재사용을 기본안으로 한다.
|
||||||
|
- 기존 `HomeLiveRecommendationRecord`에 `title`, `price`, `beginDateTime`을 추가해 신규 API DTO로 조립할 수 있게 한다.
|
||||||
|
- 기존 `HomeLiveItem`은 기존 필드만 매핑해 기존 추천 탭 공개 응답 스키마를 유지한다.
|
||||||
|
- 기존 `DefaultHomeRecommendationQueryRepository.findLiveRecommendations(...)`의 조회 조건과 정렬을 유지하되 `liveRoom.title`, `liveRoom.price`, `liveRoom.beginDateTime` select를 추가한다.
|
||||||
|
- API 조립 계층은 도메인 조회 결과를 공개 응답 DTO로 변환하고, CDN URL 변환/기본 프로필 이미지 정책은 기존 홈 추천 패턴을 따른다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- `HomeRecommendationQueryService` 확장으로 추천 도메인 결합이 과도하다고 판단되면 구현 계획 단계에서 `kr.co.vividnext.sodalive.v2.home.live` 하위 전용 query service/port/repository를 만들 수 있다. 이 경우에도 기존 조회 조건, 정렬, 테스트 케이스는 동일하게 유지한다.
|
||||||
|
- 기존 record 확장 시 생성자 projection 순서와 모든 매핑 호출부를 함께 수정해야 한다.
|
||||||
|
|
||||||
|
### Feature C. Response 스키마
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 응답 최상위 DTO 이름은 `HomeOnAirLivePageResponse`를 기본안으로 한다.
|
||||||
|
- 응답 item DTO 이름은 `HomeOnAirLiveResponse`를 기본안으로 한다.
|
||||||
|
- 응답 item은 다음 값을 포함한다.
|
||||||
|
- `roomId`: 라이브 방 id
|
||||||
|
- `creatorNickname`: 방송자 닉네임
|
||||||
|
- `creatorProfileImage`: 방송자 프로필 이미지 CDN URL
|
||||||
|
- `title`: 라이브 제목
|
||||||
|
- `price`: 라이브 입장 가격
|
||||||
|
- `beginDateTimeUtc`: 라이브 시작 시간 UTC ISO 문자열
|
||||||
|
- page metadata는 기존 `HomeRecommendationPageResponse`와 동일한 의미로 `page`, `size`, `hasNext`를 포함한다.
|
||||||
|
- `size`는 항상 `20`으로 내려준다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- `creatorProfileImage` 원본 값이 없으면 기본 프로필 이미지 CDN URL을 내려준다.
|
||||||
|
- `price`가 무료이면 `0`을 내려준다.
|
||||||
|
- `beginDateTimeUtc`는 `LiveRoom.beginDateTime`을 UTC ISO 문자열로 변환한 값으로 내려준다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. API Endpoint
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v2/home/on-air-lives?page=0
|
||||||
|
Authorization: Bearer {accessToken}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `page`: 선택값, 기본값 `0`, 0부터 시작하는 page index
|
||||||
|
- `size`: 받지 않음, 서버에서 20으로 고정
|
||||||
|
- `SecurityConfig`에 `GET /api/v2/home/on-air-lives` authenticated 설정을 추가한다.
|
||||||
|
- 회원 token이 없거나 anonymous이면 기존 인증 필요 API와 동일하게 인증 오류를 반환한다.
|
||||||
|
- 인증 회원 기준으로 차단/성인 콘텐츠 노출 조건을 반영한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Response Data Class
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
data class HomeOnAirLivePageResponse(
|
||||||
|
val items: List<HomeOnAirLiveResponse>,
|
||||||
|
val page: Int,
|
||||||
|
val size: Int,
|
||||||
|
val hasNext: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class HomeOnAirLiveResponse(
|
||||||
|
val roomId: Long,
|
||||||
|
val creatorNickname: String,
|
||||||
|
val creatorProfileImage: String,
|
||||||
|
val title: String,
|
||||||
|
val price: Int,
|
||||||
|
val beginDateTimeUtc: String
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Technical Constraints
|
||||||
|
- Kotlin, Spring Boot 2.7.14, Java 17, Gradle Wrapper 구조를 유지한다.
|
||||||
|
- 신규 코드는 기존 v2 패키지 구조와 네이밍을 따른다.
|
||||||
|
- 공개 API 조립 계층은 `kr.co.vividnext.sodalive.v2.api.*` 하위에 두고, 재사용 가능한 조회 책임은 API 패키지 밖 도메인 조회 계층에 둔다.
|
||||||
|
- 기존 `ApiResponse.ok(...)` 응답 wrapper를 사용한다.
|
||||||
|
- QueryDSL 기반 조회 패턴을 유지한다.
|
||||||
|
- 공개 API 스키마 변경은 신규 endpoint에만 한정한다.
|
||||||
|
- 구현 계획 단계에서는 TDD 기준으로 controller/facade/query repository 테스트를 작성한 뒤 최소 구현한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Reuse Candidates
|
||||||
|
- `HomeRecommendationController`: page 정규화, `ApiResponse.ok(...)`, `requireMember(...)` 인증 필수 패턴 참고
|
||||||
|
- `HomeRecommendationFacade`: `size + 1` 조회 후 `hasNext`를 판단하는 page 응답 조립 패턴 참고
|
||||||
|
- `HomeRecommendationPageResponse`: page metadata 의미 참고
|
||||||
|
- `HomeRecommendationQueryService.findLiveRecommendations(...)`: 현재 진행 중인 라이브 도메인 조회 진입점으로 확장 재사용
|
||||||
|
- `HomeRecommendationQueryPort.HomeLiveRecommendationRecord`: `title`, `price`, `beginDateTime`을 추가해 신규 API 응답 조립에 재사용
|
||||||
|
- `DefaultHomeRecommendationQueryRepository.findLiveRecommendations(...)`: 진행 중 라이브 조건, 정렬, 차단 필터, 성인 라이브 필터 재사용
|
||||||
|
- `HomeFollowingLive`/`DefaultHomeFollowingQueryRepository.findOnAirLives(...)`: `title` 포함 라이브 응답 모델링과 CDN URL 변환 패턴 참고
|
||||||
|
- `LiveRoom`: `title`, `price`, `beginDateTime`, `channelName`, `isAdult`, `isActive` 필드 사용
|
||||||
|
- `MemberContentPreferenceService.canViewAdultContent(member)`: 성인 라이브 노출 가능 여부 판단
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Open Questions
|
||||||
|
- 없음. 현재 PRD는 인증 회원만 조회 가능, page size 20 고정, 기존 추천 라이브 조건 재사용을 기본 가정으로 작성한다.
|
||||||
@@ -213,6 +213,7 @@ CREATE TABLE user_creator_chat_message (
|
|||||||
- `GET /api/v2/chat/rooms?filter=ALL&limit=30`
|
- `GET /api/v2/chat/rooms?filter=ALL&limit=30`
|
||||||
- `filter`: `ALL`, `AI`, `DM`
|
- `filter`: `ALL`, `AI`, `DM`
|
||||||
- 최신순 30개씩 cursor 기반으로 조회한다.
|
- 최신순 30개씩 cursor 기반으로 조회한다.
|
||||||
|
- 비로그인 요청은 200 OK와 빈 목록 페이지를 반환한다.
|
||||||
- response data: `{ "rooms", "hasMore", "nextCursor" }`
|
- response data: `{ "rooms", "hasMore", "nextCursor" }`
|
||||||
- room item: `{ "roomId", "chatType", "targetName", "targetImageUrl", "lastMessage", "lastMessageAt" }`
|
- room item: `{ "roomId", "chatType", "targetName", "targetImageUrl", "lastMessage", "lastMessageAt" }`
|
||||||
|
|
||||||
@@ -271,6 +272,18 @@ CREATE TABLE user_creator_chat_message (
|
|||||||
|
|
||||||
## 채팅 리스트 API 응답 예시
|
## 채팅 리스트 API 응답 예시
|
||||||
|
|
||||||
|
비로그인 요청:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"rooms": [],
|
||||||
|
"hasMore": false,
|
||||||
|
"nextCursor": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
로그인 요청:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"rooms": [
|
"rooms": [
|
||||||
@@ -362,3 +375,21 @@ CREATE TABLE user_creator_chat_message (
|
|||||||
- 무엇을: 채팅 리스트 cursor 조건, UTC ISO 시간 변환, 빈 이미지 경로 기본 이미지 처리를 보완했다.
|
- 무엇을: 채팅 리스트 cursor 조건, UTC ISO 시간 변환, 빈 이미지 경로 기본 이미지 처리를 보완했다.
|
||||||
- 왜: 같은 `lastMessageAt`을 가진 방이 여러 개일 때 cursor 이후 항목이 누락되지 않아야 하고, 문서 기준 UTC 시간과 기본 이미지 정책을 정확히 지켜야 하기 때문이다.
|
- 왜: 같은 `lastMessageAt`을 가진 방이 여러 개일 때 cursor 이후 항목이 누락되지 않아야 하고, 문서 기준 UTC 시간과 기본 이미지 정책을 정확히 지켜야 하기 때문이다.
|
||||||
- 어떻게: cursor를 `lastMessageAt`, `chatType`, `roomId` tuple 기준으로 적용하고 repository seek 조건도 같은 정렬 기준에 맞췄다. `lastMessageAt`은 `ZoneOffset.UTC` 기준 ISO 문자열로 변환하고, 빈 이미지 경로도 기본 이미지로 대체했다. 동일 timestamp cursor 테스트를 추가한 뒤 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.chat.ChatRoomListServiceTest' --rerun-tasks`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest'`, `./gradlew ktlintCheck`, `./gradlew build` 실행 결과 `BUILD SUCCESSFUL`을 확인했고, 재리뷰에서 남은 Critical/Important 결함 없음 승인을 받았다.
|
- 어떻게: cursor를 `lastMessageAt`, `chatType`, `roomId` tuple 기준으로 적용하고 repository seek 조건도 같은 정렬 기준에 맞췄다. `lastMessageAt`은 `ZoneOffset.UTC` 기준 ISO 문자열로 변환하고, 빈 이미지 경로도 기본 이미지로 대체했다. 동일 timestamp cursor 테스트를 추가한 뒤 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.chat.ChatRoomListServiceTest' --rerun-tasks`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest'`, `./gradlew ktlintCheck`, `./gradlew build` 실행 결과 `BUILD SUCCESSFUL`을 확인했고, 재리뷰에서 남은 Critical/Important 결함 없음 승인을 받았다.
|
||||||
|
|
||||||
|
### 14차 채팅 리스트 비로그인 응답 정책 반영
|
||||||
|
- [x] **Task 14.1: 비로그인 채팅 리스트 빈 목록 반환**
|
||||||
|
- 수정 파일: `src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/controller/ChatRoomListController.kt`
|
||||||
|
- 테스트 파일: `src/test/kotlin/kr/co/vividnext/sodalive/v2/chat/ChatRoomListControllerTest.kt`
|
||||||
|
- 문서 파일: `docs/prd/20260513_유저크리에이터채팅방개편_prd.md`, `docs/plan-task/20260513_유저크리에이터채팅방개편.md`
|
||||||
|
- RED: `ChatRoomListController.getRooms(member = null, ...)`가 예외 없이 `rooms = []`, `hasMore = false`, `nextCursor = null`을 반환하고 `ChatRoomListService`를 호출하지 않는 테스트를 먼저 작성한다.
|
||||||
|
- RED 확인 명령: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.chat.ChatRoomListControllerTest`
|
||||||
|
- GREEN: `member == null`이면 `ChatRoomListPageResponse(emptyList(), false, null)`를 `ApiResponse.ok`로 감싸 반환하고, 로그인 사용자는 기존처럼 service에 위임한다.
|
||||||
|
- GREEN 확인 명령: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.chat.ChatRoomListControllerTest --tests kr.co.vividnext.sodalive.v2.chat.ChatRoomListServiceTest`
|
||||||
|
- REFACTOR/회귀 확인 명령: `./gradlew --no-daemon ktlintCheck`
|
||||||
|
- 검증 기록:
|
||||||
|
- RED: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.chat.ChatRoomListControllerTest` 실행 결과 비로그인 테스트가 `SodaException`으로 실패해 기존 예외 동작을 재현했다.
|
||||||
|
- GREEN: `ChatRoomListController.getRooms`의 `member == null` 분기에서 빈 `ChatRoomListPageResponse`를 반환하도록 최소 수정했다.
|
||||||
|
- GREEN 확인: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.chat.ChatRoomListControllerTest --tests kr.co.vividnext.sodalive.v2.chat.ChatRoomListServiceTest` 실행 결과 `BUILD SUCCESSFUL in 5m`을 확인했다.
|
||||||
|
- 회귀 확인: `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL in 33s`를 확인했다.
|
||||||
|
- 문서 확인: PRD와 plan-task 문서에서 미완성 표식을 검색한 결과 매칭이 없었다.
|
||||||
|
- 문서 명령 확인: 최초 `./gradlew --no-daemon tasks --all`은 Gradle wrapper lock 파일 샌드박스 접근 오류로 실패했고, 승인 실행한 동일 명령은 `BUILD SUCCESSFUL in 6s`로 통과했다.
|
||||||
|
|||||||
@@ -124,6 +124,7 @@
|
|||||||
|
|
||||||
#### Requirements
|
#### Requirements
|
||||||
- 인증된 회원이 참여 중인 채팅방만 조회한다.
|
- 인증된 회원이 참여 중인 채팅방만 조회한다.
|
||||||
|
- 비로그인 사용자가 호출하면 예외를 발생시키지 않고 빈 목록을 내려준다.
|
||||||
- 필터는 `ALL`, `AI`, `DM` 3가지를 지원한다.
|
- 필터는 `ALL`, `AI`, `DM` 3가지를 지원한다.
|
||||||
- `AI`는 기존 AI 캐릭터 채팅방을 의미한다.
|
- `AI`는 기존 AI 캐릭터 채팅방을 의미한다.
|
||||||
- `DM`은 유저-크리에이터 채팅방을 의미하며, API 문서와 클라이언트 표시 용어에서 User-Creator 채팅 대신 DM으로 명명한다.
|
- `DM`은 유저-크리에이터 채팅방을 의미하며, API 문서와 클라이언트 표시 용어에서 User-Creator 채팅 대신 DM으로 명명한다.
|
||||||
@@ -135,6 +136,7 @@
|
|||||||
- 마지막 메시지가 없는 방은 채팅 리스트에 노출하지 않는다.
|
- 마지막 메시지가 없는 방은 채팅 리스트에 노출하지 않는다.
|
||||||
|
|
||||||
#### Edge Cases
|
#### Edge Cases
|
||||||
|
- 비로그인 요청은 `rooms = []`, `hasMore = false`, `nextCursor = null`로 응답한다.
|
||||||
- 상대방 회원 또는 AI 캐릭터의 프로필 이미지가 없으면 기본 이미지를 사용한다.
|
- 상대방 회원 또는 AI 캐릭터의 프로필 이미지가 없으면 기본 이미지를 사용한다.
|
||||||
- 마지막 메시지가 음성 메시지이면 본문 요약 대신 `[음성 메시지]`를 내려준다.
|
- 마지막 메시지가 음성 메시지이면 본문 요약 대신 `[음성 메시지]`를 내려준다.
|
||||||
- 마지막 메시지가 비활성화되었거나 표시할 수 없는 상태라면 해당 메시지를 제외하고 다음 최신 표시 가능 메시지를 기준으로 요약한다.
|
- 마지막 메시지가 비활성화되었거나 표시할 수 없는 상태라면 해당 메시지를 제외하고 다음 최신 표시 가능 메시지를 기준으로 요약한다.
|
||||||
@@ -209,6 +211,7 @@
|
|||||||
- 채팅 리스트에서 마지막 메시지가 없는 방은 노출하지 않는다.
|
- 채팅 리스트에서 마지막 메시지가 없는 방은 노출하지 않는다.
|
||||||
- 음성 메시지의 마지막 대화 요약 문구는 `[음성 메시지]`를 사용한다.
|
- 음성 메시지의 마지막 대화 요약 문구는 `[음성 메시지]`를 사용한다.
|
||||||
- 채팅 리스트 API URL prefix는 `/api/v2/chat/rooms`를 사용한다.
|
- 채팅 리스트 API URL prefix는 `/api/v2/chat/rooms`를 사용한다.
|
||||||
|
- 채팅 리스트 API는 비로그인 요청에도 200 OK를 반환하며, 빈 목록 페이지를 내려준다.
|
||||||
- 채팅 리스트 DTO 필드명은 `roomId`, `chatType`, `targetName`, `targetImageUrl`, `lastMessage`, `lastMessageAt`을 사용한다.
|
- 채팅 리스트 DTO 필드명은 `roomId`, `chatType`, `targetName`, `targetImageUrl`, `lastMessage`, `lastMessageAt`을 사용한다.
|
||||||
- `targetName`은 AI 채팅이면 캐릭터명, DM이면 나를 제외한 참여 회원 닉네임이다.
|
- `targetName`은 AI 채팅이면 캐릭터명, DM이면 나를 제외한 참여 회원 닉네임이다.
|
||||||
- `targetImageUrl`은 AI 채팅이면 캐릭터 대표 이미지, DM이면 나를 제외한 참여 회원 프로필 이미지이며, 이미지가 없으면 기본 이미지 URL을 내려준다.
|
- `targetImageUrl`은 AI 채팅이면 캐릭터 대표 이미지, DM이면 나를 제외한 참여 회원 프로필 이미지이며, 이미지가 없으면 기본 이미지 URL을 내려준다.
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ class SecurityConfig(
|
|||||||
.antMatchers(HttpMethod.GET, "/api/v2/audio/rankings").permitAll()
|
.antMatchers(HttpMethod.GET, "/api/v2/audio/rankings").permitAll()
|
||||||
.antMatchers(HttpMethod.GET, "/api/v2/home/rankings/creators").permitAll()
|
.antMatchers(HttpMethod.GET, "/api/v2/home/rankings/creators").permitAll()
|
||||||
.antMatchers(HttpMethod.GET, "/api/v2/home/following").permitAll()
|
.antMatchers(HttpMethod.GET, "/api/v2/home/following").permitAll()
|
||||||
|
.antMatchers(HttpMethod.GET, "/api/v2/home/on-air-lives").authenticated()
|
||||||
// 페이지네이션 하위 경로(/lives, /debut-creators 등)는 인증 필수
|
// 페이지네이션 하위 경로(/lives, /debut-creators 등)는 인증 필수
|
||||||
.antMatchers(HttpMethod.GET, "/api/v2/home/recommendations/**").authenticated()
|
.antMatchers(HttpMethod.GET, "/api/v2/home/recommendations/**").authenticated()
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.home.live.adapter.`in`.web
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.home.live.application.HomeOnAirLiveFacade
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v2/home/on-air-lives")
|
||||||
|
class HomeOnAirLiveController(
|
||||||
|
private val homeOnAirLiveFacade: HomeOnAirLiveFacade
|
||||||
|
) {
|
||||||
|
@GetMapping
|
||||||
|
fun getOnAirLives(
|
||||||
|
@RequestParam(defaultValue = "0") page: Int,
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
|
) = run {
|
||||||
|
ApiResponse.ok(homeOnAirLiveFacade.getOnAirLives(requireMember(member), page))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireMember(member: Member?): Member {
|
||||||
|
return member ?: throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.home.live.application
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLivePageResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLiveResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeLiveRecommendationRecord
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class HomeOnAirLiveFacade(
|
||||||
|
private val queryService: HomeRecommendationQueryService,
|
||||||
|
private val memberContentPreferenceService: MemberContentPreferenceService,
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
|
private val cloudFrontHost: String
|
||||||
|
) {
|
||||||
|
fun getOnAirLives(member: Member, page: Int): HomeOnAirLivePageResponse {
|
||||||
|
val normalizedPage = page.coerceIn(0, MAX_PAGE)
|
||||||
|
val fetched = queryService.findLiveRecommendations(
|
||||||
|
offset = normalizedPage * PAGE_SIZE,
|
||||||
|
limit = PAGE_SIZE + 1,
|
||||||
|
memberId = member.id,
|
||||||
|
includeAdultLives = memberContentPreferenceService.canViewAdultContent(member)
|
||||||
|
)
|
||||||
|
val items = fetched.take(PAGE_SIZE).map { it.toResponse() }
|
||||||
|
|
||||||
|
return HomeOnAirLivePageResponse(
|
||||||
|
items = items,
|
||||||
|
page = normalizedPage,
|
||||||
|
size = PAGE_SIZE,
|
||||||
|
hasNext = fetched.size > PAGE_SIZE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun HomeLiveRecommendationRecord.toResponse() = HomeOnAirLiveResponse(
|
||||||
|
roomId = liveRoomId,
|
||||||
|
creatorNickname = creatorNickname,
|
||||||
|
creatorProfileImage = profileImageUrl(creatorProfileImage),
|
||||||
|
title = title,
|
||||||
|
price = price,
|
||||||
|
beginDateTimeUtc = beginDateTime.toUtcIso()
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun profileImageUrl(path: String?): String {
|
||||||
|
return imageUrl(path) ?: "$cloudFrontHost/profile/default-profile.png"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun imageUrl(path: String?): String? {
|
||||||
|
return if (path.isNullOrBlank()) null else "$cloudFrontHost/$path"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LocalDateTime.toUtcIso(): String {
|
||||||
|
return atOffset(ZoneOffset.UTC).toInstant().toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PAGE_SIZE = 20
|
||||||
|
private const val MAX_PAGE = 10_000
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.home.live.dto
|
||||||
|
|
||||||
|
data class HomeOnAirLivePageResponse(
|
||||||
|
val items: List<HomeOnAirLiveResponse>,
|
||||||
|
val page: Int,
|
||||||
|
val size: Int,
|
||||||
|
val hasNext: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class HomeOnAirLiveResponse(
|
||||||
|
val roomId: Long,
|
||||||
|
val creatorNickname: String,
|
||||||
|
val creatorProfileImage: String,
|
||||||
|
val title: String,
|
||||||
|
val price: Int,
|
||||||
|
val beginDateTimeUtc: String
|
||||||
|
)
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.chat.controller
|
package kr.co.vividnext.sodalive.v2.chat.controller
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListPageResponse
|
||||||
import kr.co.vividnext.sodalive.v2.chat.service.ChatRoomListService
|
import kr.co.vividnext.sodalive.v2.chat.service.ChatRoomListService
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
@@ -22,7 +22,7 @@ class ChatRoomListController(
|
|||||||
@RequestParam(required = false) cursor: String?,
|
@RequestParam(required = false) cursor: String?,
|
||||||
@RequestParam(defaultValue = "30") limit: Int
|
@RequestParam(defaultValue = "30") limit: Int
|
||||||
) = run {
|
) = run {
|
||||||
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
|
if (member == null) return@run ApiResponse.ok(ChatRoomListPageResponse(emptyList(), false, null))
|
||||||
ApiResponse.ok(service.getRooms(member, filter, cursor, limit))
|
ApiResponse.ok(service.getRooms(member, filter, cursor, limit))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,10 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
HomeLiveRecommendationRecord::class.java,
|
HomeLiveRecommendationRecord::class.java,
|
||||||
liveRoom.id,
|
liveRoom.id,
|
||||||
member.nickname,
|
member.nickname,
|
||||||
member.profileImage
|
member.profileImage,
|
||||||
|
liveRoom.title,
|
||||||
|
liveRoom.price,
|
||||||
|
liveRoom.beginDateTime
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.from(liveRoom)
|
.from(liveRoom)
|
||||||
|
|||||||
@@ -80,7 +80,10 @@ interface HomeRecommendationQueryPort {
|
|||||||
data class HomeLiveRecommendationRecord(
|
data class HomeLiveRecommendationRecord(
|
||||||
val liveRoomId: Long,
|
val liveRoomId: Long,
|
||||||
val creatorNickname: String,
|
val creatorNickname: String,
|
||||||
val creatorProfileImage: String?
|
val creatorProfileImage: String?,
|
||||||
|
val title: String,
|
||||||
|
val price: Int,
|
||||||
|
val beginDateTime: LocalDateTime
|
||||||
)
|
)
|
||||||
|
|
||||||
data class HomeBannerRecommendationRecord(
|
data class HomeBannerRecommendationRecord(
|
||||||
|
|||||||
@@ -406,11 +406,12 @@ class AudioContentServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
@DisplayName("업로드 완료 시 예약 공개 콘텐츠는 생성 시점에 최근 소식을 발행하지 않는다")
|
@DisplayName("업로드 완료 시 예약 공개 콘텐츠는 생성 시점에 최근 소식을 발행하지 않는다")
|
||||||
fun shouldNotPublishNewsWhenUploadCompleteKeepsScheduledContentInactive() {
|
fun shouldNotPublishNewsWhenUploadCompleteKeepsScheduledContentInactive() {
|
||||||
|
val now = LocalDateTime.now()
|
||||||
val creator = createMember(id = 2200L, nickname = "scheduled-creator")
|
val creator = createMember(id = 2200L, nickname = "scheduled-creator")
|
||||||
val audioContent = createAudioContent(creator = creator)
|
val audioContent = createAudioContent(creator = creator)
|
||||||
audioContent.isActive = false
|
audioContent.isActive = false
|
||||||
audioContent.createdAt = LocalDateTime.of(2026, 6, 25, 9, 0)
|
audioContent.createdAt = now
|
||||||
audioContent.releaseDate = LocalDateTime.of(2026, 6, 26, 9, 0)
|
audioContent.releaseDate = now.plusYears(1)
|
||||||
Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent))
|
Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent))
|
||||||
|
|
||||||
service.uploadComplete(
|
service.uploadComplete(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
|||||||
import kr.co.vividnext.sodalive.v2.api.common.dto.RecommendationBannerResponse
|
import kr.co.vividnext.sodalive.v2.api.common.dto.RecommendationBannerResponse
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
class HomeRecommendationResponseTest {
|
class HomeRecommendationResponseTest {
|
||||||
@@ -114,4 +115,23 @@ class HomeRecommendationResponseTest {
|
|||||||
assertEquals(true, json["popularCommunityPosts"][1]["imageUrl"].isNull)
|
assertEquals(true, json["popularCommunityPosts"][1]["imageUrl"].isNull)
|
||||||
assertEquals(true, json["popularCommunityPosts"][1]["audioUrl"].isNull)
|
assertEquals(true, json["popularCommunityPosts"][1]["audioUrl"].isNull)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("기존 홈 라이브 추천 item 응답 스키마에는 신규 현재 진행 중 라이브 필드를 포함하지 않는다")
|
||||||
|
fun shouldKeepHomeLiveItemSchemaWithoutTitlePriceAndBeginDateTimeUtc() {
|
||||||
|
val item = HomeLiveItem(
|
||||||
|
roomId = 1L,
|
||||||
|
creatorNickname = "creator",
|
||||||
|
creatorProfileImage = "https://cdn.test/profile.png"
|
||||||
|
)
|
||||||
|
|
||||||
|
val json = objectMapper.readTree(objectMapper.writeValueAsString(item))
|
||||||
|
|
||||||
|
assertEquals(1L, json["roomId"].asLong())
|
||||||
|
assertEquals("creator", json["creatorNickname"].asText())
|
||||||
|
assertEquals("https://cdn.test/profile.png", json["creatorProfileImage"].asText())
|
||||||
|
assertFalse(json.has("title"))
|
||||||
|
assertFalse(json.has("price"))
|
||||||
|
assertFalse(json.has("beginDateTimeUtc"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.home.live.adapter.`in`.web
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.CountryContext
|
||||||
|
import kr.co.vividnext.sodalive.configs.SecurityConfig
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
|
import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler
|
||||||
|
import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint
|
||||||
|
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.home.live.application.HomeOnAirLiveFacade
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLivePageResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLiveResponse
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
|
||||||
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
||||||
|
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
|
||||||
|
|
||||||
|
@WebMvcTest(HomeOnAirLiveController::class)
|
||||||
|
@Import(SecurityConfig::class, JwtAuthenticationEntryPoint::class, JwtAccessDeniedHandler::class)
|
||||||
|
class HomeOnAirLiveControllerTest @Autowired constructor(
|
||||||
|
private val mockMvc: MockMvc
|
||||||
|
) {
|
||||||
|
@MockBean
|
||||||
|
private lateinit var facade: HomeOnAirLiveFacade
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var countryContext: CountryContext
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var langContext: LangContext
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var sodaMessageSource: SodaMessageSource
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var tokenProvider: TokenProvider
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("현재 진행 중인 라이브 조회는 비회원 요청을 거부한다")
|
||||||
|
fun shouldRejectAnonymousRequest() {
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/home/on-air-lives")
|
||||||
|
.with(anonymous())
|
||||||
|
)
|
||||||
|
.andExpect(status().isUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("현재 진행 중인 라이브 조회는 인증 회원과 page를 facade에 전달하고 성공 응답을 반환한다")
|
||||||
|
fun shouldPassAuthenticatedMemberAndPageToFacade() {
|
||||||
|
val member = createMember(100L)
|
||||||
|
Mockito.doReturn(createResponse()).`when`(facade).getOnAirLives(eqValue(member), eqValue(2))
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/home/on-air-lives")
|
||||||
|
.param("page", "2")
|
||||||
|
.with(user(MemberAdapter(member)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.size").value(20))
|
||||||
|
.andExpect(jsonPath("$.data.items[0].title").value("paid live"))
|
||||||
|
|
||||||
|
Mockito.verify(facade).getOnAirLives(eqValue(member), eqValue(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createResponse() = HomeOnAirLivePageResponse(
|
||||||
|
items = listOf(
|
||||||
|
HomeOnAirLiveResponse(
|
||||||
|
roomId = 1L,
|
||||||
|
creatorNickname = "creator",
|
||||||
|
creatorProfileImage = "https://cdn.test/profile.png",
|
||||||
|
title = "paid live",
|
||||||
|
price = 30,
|
||||||
|
beginDateTimeUtc = "2026-06-26T12:30:00Z"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
page = 2,
|
||||||
|
size = 20,
|
||||||
|
hasNext = false
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun createMember(id: Long): Member {
|
||||||
|
return Member(
|
||||||
|
email = "viewer$id@test.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = "viewer$id",
|
||||||
|
role = MemberRole.USER
|
||||||
|
).apply { this.id = id }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> eqValue(value: T): T {
|
||||||
|
return Mockito.eq(value) ?: value
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.home.live.adapter.`in`.web
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.content.ContentType
|
||||||
|
import kr.co.vividnext.sodalive.live.room.LiveRoom
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference
|
||||||
|
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.cache.type=none",
|
||||||
|
"spring.datasource.url=jdbc:h2:mem:home-on-air-live-e2e;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
|
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
|
||||||
|
class HomeOnAirLiveEndToEndTest @Autowired constructor(
|
||||||
|
private val mockMvc: MockMvc,
|
||||||
|
private val entityManager: EntityManager,
|
||||||
|
private val transactionTemplate: TransactionTemplate
|
||||||
|
) {
|
||||||
|
@Test
|
||||||
|
@DisplayName("현재 진행 중인 라이브 조회 API는 인증 회원에게 최신순 라이브와 상세 필드를 반환한다")
|
||||||
|
fun shouldReturnAuthenticatedOnAirLivesWithTitlePriceAndBeginDateTimeUtc() {
|
||||||
|
val fixture = createOnAirLivesFixture()
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/home/on-air-lives")
|
||||||
|
.param("page", "0")
|
||||||
|
.with(user(MemberAdapter(fixture.viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.items.length()").value(2))
|
||||||
|
.andExpect(jsonPath("$.data.items[0].roomId").value(fixture.newestLiveId))
|
||||||
|
.andExpect(jsonPath("$.data.items[0].creatorNickname").value("on-air-e2e-creator"))
|
||||||
|
.andExpect(jsonPath("$.data.items[0].creatorProfileImage").value("https://cdn.test/on-air-e2e-creator.png"))
|
||||||
|
.andExpect(jsonPath("$.data.items[0].title").value("newest on air live"))
|
||||||
|
.andExpect(jsonPath("$.data.items[0].price").value(30))
|
||||||
|
.andExpect(jsonPath("$.data.items[0].beginDateTimeUtc").value("2026-06-26T12:30:00Z"))
|
||||||
|
.andExpect(jsonPath("$.data.items[1].roomId").value(fixture.oldestLiveId))
|
||||||
|
.andExpect(jsonPath("$.data.page").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.size").value(20))
|
||||||
|
.andExpect(jsonPath("$.data.hasNext").value(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("현재 진행 중인 라이브 조회 API는 성인 콘텐츠를 볼 수 없는 회원에게 성인 라이브를 제외한다")
|
||||||
|
fun shouldExcludeAdultLiveWhenViewerCannotViewAdultContent() {
|
||||||
|
val fixture = createAdultFilterFixture()
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/home/on-air-lives")
|
||||||
|
.param("page", "0")
|
||||||
|
.with(user(MemberAdapter(fixture.viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.data.items.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.items[0].roomId").value(fixture.visibleLiveId))
|
||||||
|
.andExpect(jsonPath("$.data.items[?(@.roomId == ${fixture.adultLiveId})]").isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createOnAirLivesFixture(): OnAirLivesFixture {
|
||||||
|
return transactionTemplate.execute {
|
||||||
|
val viewer = saveMember("on-air-e2e-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("on-air-e2e-creator", MemberRole.CREATOR)
|
||||||
|
savePreference(viewer, isAdultContentVisible = true)
|
||||||
|
val newest = saveLiveRoom(
|
||||||
|
creator = creator,
|
||||||
|
title = "newest on air live",
|
||||||
|
price = 30,
|
||||||
|
beginDateTime = LocalDateTime.of(2026, 6, 26, 12, 30),
|
||||||
|
channelName = "newest-on-air-channel",
|
||||||
|
isAdult = false
|
||||||
|
)
|
||||||
|
val oldest = saveLiveRoom(
|
||||||
|
creator = creator,
|
||||||
|
title = "oldest on air live",
|
||||||
|
price = 10,
|
||||||
|
beginDateTime = LocalDateTime.of(2026, 6, 26, 12, 0),
|
||||||
|
channelName = "oldest-on-air-channel",
|
||||||
|
isAdult = false
|
||||||
|
)
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
OnAirLivesFixture(
|
||||||
|
viewer = viewer,
|
||||||
|
newestLiveId = newest.id!!,
|
||||||
|
oldestLiveId = oldest.id!!
|
||||||
|
)
|
||||||
|
}!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createAdultFilterFixture(): AdultFilterFixture {
|
||||||
|
return transactionTemplate.execute {
|
||||||
|
val viewer = saveMember("on-air-adult-filter-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("on-air-adult-filter-creator", MemberRole.CREATOR)
|
||||||
|
savePreference(viewer, isAdultContentVisible = false)
|
||||||
|
val adult = saveLiveRoom(
|
||||||
|
creator = creator,
|
||||||
|
title = "adult on air live",
|
||||||
|
price = 30,
|
||||||
|
beginDateTime = LocalDateTime.of(2026, 6, 26, 12, 30),
|
||||||
|
channelName = "adult-on-air-channel",
|
||||||
|
isAdult = true
|
||||||
|
)
|
||||||
|
val visible = saveLiveRoom(
|
||||||
|
creator = creator,
|
||||||
|
title = "visible on air live",
|
||||||
|
price = 10,
|
||||||
|
beginDateTime = LocalDateTime.of(2026, 6, 26, 12, 0),
|
||||||
|
channelName = "visible-on-air-channel",
|
||||||
|
isAdult = false
|
||||||
|
)
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
AdultFilterFixture(
|
||||||
|
viewer = viewer,
|
||||||
|
visibleLiveId = visible.id!!,
|
||||||
|
adultLiveId = adult.id!!
|
||||||
|
)
|
||||||
|
}!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveMember(nickname: String, role: MemberRole): Member {
|
||||||
|
val member = Member(
|
||||||
|
email = "$nickname@test.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = nickname,
|
||||||
|
profileImage = "$nickname.png",
|
||||||
|
role = role
|
||||||
|
)
|
||||||
|
entityManager.persist(member)
|
||||||
|
return member
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun savePreference(member: Member, isAdultContentVisible: Boolean): MemberContentPreference {
|
||||||
|
val preference = MemberContentPreference(
|
||||||
|
isAdultContentVisible = isAdultContentVisible,
|
||||||
|
contentType = ContentType.ALL
|
||||||
|
)
|
||||||
|
preference.member = member
|
||||||
|
entityManager.persist(preference)
|
||||||
|
return preference
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveLiveRoom(
|
||||||
|
creator: Member,
|
||||||
|
title: String,
|
||||||
|
price: Int,
|
||||||
|
beginDateTime: LocalDateTime,
|
||||||
|
channelName: String,
|
||||||
|
isAdult: Boolean
|
||||||
|
): LiveRoom {
|
||||||
|
val liveRoom = LiveRoom(
|
||||||
|
title = title,
|
||||||
|
notice = "notice",
|
||||||
|
beginDateTime = beginDateTime,
|
||||||
|
numberOfPeople = 0,
|
||||||
|
isAdult = isAdult,
|
||||||
|
price = price
|
||||||
|
)
|
||||||
|
liveRoom.member = creator
|
||||||
|
liveRoom.channelName = channelName
|
||||||
|
liveRoom.isActive = true
|
||||||
|
entityManager.persist(liveRoom)
|
||||||
|
return liveRoom
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class OnAirLivesFixture(
|
||||||
|
val viewer: Member,
|
||||||
|
val newestLiveId: Long,
|
||||||
|
val oldestLiveId: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class AdultFilterFixture(
|
||||||
|
val viewer: Member,
|
||||||
|
val visibleLiveId: Long,
|
||||||
|
val adultLiveId: Long
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.home.live.application
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeLiveRecommendationRecord
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
class HomeOnAirLiveFacadeTest {
|
||||||
|
private val queryService = Mockito.mock(HomeRecommendationQueryService::class.java)
|
||||||
|
private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
|
||||||
|
private val facade = HomeOnAirLiveFacade(queryService, preferenceService, "https://cdn.test")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("현재 진행 중인 라이브 facade는 20개 고정 page와 hasNext를 조립한다")
|
||||||
|
fun shouldReturnFixedSizePageAndHasNext() {
|
||||||
|
val member = createMember(100L)
|
||||||
|
Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member)
|
||||||
|
Mockito.doReturn((1L..21L).map { record(it) }).`when`(queryService).findLiveRecommendations(
|
||||||
|
eqValue(0),
|
||||||
|
eqValue(21),
|
||||||
|
eqValue(member.id),
|
||||||
|
eqValue(true)
|
||||||
|
)
|
||||||
|
|
||||||
|
val response = facade.getOnAirLives(member, page = 0)
|
||||||
|
|
||||||
|
assertEquals(0, response.page)
|
||||||
|
assertEquals(20, response.size)
|
||||||
|
assertEquals(true, response.hasNext)
|
||||||
|
assertEquals(20, response.items.size)
|
||||||
|
Mockito.verify(queryService).findLiveRecommendations(eqValue(0), eqValue(21), eqValue(member.id), eqValue(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("현재 진행 중인 라이브 facade는 프로필 이미지가 없으면 기본 이미지를 사용한다")
|
||||||
|
fun shouldUseDefaultProfileImageWhenCreatorProfileImageIsBlank() {
|
||||||
|
val member = createMember(100L)
|
||||||
|
Mockito.doReturn(false).`when`(preferenceService).canViewAdultContent(member)
|
||||||
|
Mockito.doReturn(listOf(record(1L, creatorProfileImage = null))).`when`(queryService).findLiveRecommendations(
|
||||||
|
eqValue(0),
|
||||||
|
eqValue(21),
|
||||||
|
eqValue(member.id),
|
||||||
|
eqValue(false)
|
||||||
|
)
|
||||||
|
|
||||||
|
val response = facade.getOnAirLives(member, page = 0)
|
||||||
|
|
||||||
|
assertEquals("https://cdn.test/profile/default-profile.png", response.items.single().creatorProfileImage)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("현재 진행 중인 라이브 facade는 시작 시간을 UTC ISO 문자열로 변환한다")
|
||||||
|
fun shouldMapBeginDateTimeToUtcIsoString() {
|
||||||
|
val member = createMember(100L)
|
||||||
|
Mockito.doReturn(false).`when`(preferenceService).canViewAdultContent(member)
|
||||||
|
Mockito.doReturn(listOf(record(1L, beginDateTime = LocalDateTime.of(2026, 6, 26, 12, 30)))).`when`(queryService)
|
||||||
|
.findLiveRecommendations(eqValue(0), eqValue(21), eqValue(member.id), eqValue(false))
|
||||||
|
|
||||||
|
val response = facade.getOnAirLives(member, page = 0)
|
||||||
|
|
||||||
|
assertEquals("2026-06-26T12:30:00Z", response.items.single().beginDateTimeUtc)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun record(
|
||||||
|
id: Long,
|
||||||
|
creatorProfileImage: String? = "profile.png",
|
||||||
|
beginDateTime: LocalDateTime = LocalDateTime.of(2026, 6, 26, 12, 30)
|
||||||
|
) = HomeLiveRecommendationRecord(
|
||||||
|
liveRoomId = id,
|
||||||
|
creatorNickname = "creator-$id",
|
||||||
|
creatorProfileImage = creatorProfileImage,
|
||||||
|
title = "live-$id",
|
||||||
|
price = id.toInt(),
|
||||||
|
beginDateTime = beginDateTime
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun createMember(id: Long): Member {
|
||||||
|
return Member(
|
||||||
|
email = "viewer$id@test.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = "viewer$id",
|
||||||
|
role = MemberRole.USER
|
||||||
|
).apply { this.id = id }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> eqValue(value: T): T {
|
||||||
|
return Mockito.eq(value) ?: value
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.home.live.dto
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class HomeOnAirLiveResponseTest {
|
||||||
|
private val objectMapper = jacksonObjectMapper()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("현재 진행 중인 라이브 page 응답은 공개 API 필드를 직렬화한다")
|
||||||
|
fun shouldSerializeOnAirLivePageResponse() {
|
||||||
|
val response = HomeOnAirLivePageResponse(
|
||||||
|
items = listOf(
|
||||||
|
HomeOnAirLiveResponse(
|
||||||
|
roomId = 1L,
|
||||||
|
creatorNickname = "creator",
|
||||||
|
creatorProfileImage = "https://cdn.test/profile.png",
|
||||||
|
title = "paid live",
|
||||||
|
price = 30,
|
||||||
|
beginDateTimeUtc = "2026-06-26T12:30:00Z"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
page = 0,
|
||||||
|
size = 20,
|
||||||
|
hasNext = true
|
||||||
|
)
|
||||||
|
|
||||||
|
val json = objectMapper.readTree(objectMapper.writeValueAsString(response))
|
||||||
|
|
||||||
|
assertEquals(1L, json["items"][0]["roomId"].asLong())
|
||||||
|
assertEquals("creator", json["items"][0]["creatorNickname"].asText())
|
||||||
|
assertEquals("https://cdn.test/profile.png", json["items"][0]["creatorProfileImage"].asText())
|
||||||
|
assertEquals("paid live", json["items"][0]["title"].asText())
|
||||||
|
assertEquals(30, json["items"][0]["price"].asInt())
|
||||||
|
assertEquals("2026-06-26T12:30:00Z", json["items"][0]["beginDateTimeUtc"].asText())
|
||||||
|
assertEquals(0, json["page"].asInt())
|
||||||
|
assertEquals(20, json["size"].asInt())
|
||||||
|
assertEquals(true, json["hasNext"].asBoolean())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.chat
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.v2.chat.controller.ChatRoomListController
|
||||||
|
import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListItemResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListPageResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.chat.service.ChatRoomListService
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.Mockito
|
||||||
|
|
||||||
|
class ChatRoomListControllerTest {
|
||||||
|
private lateinit var service: ChatRoomListService
|
||||||
|
private lateinit var controller: ChatRoomListController
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setUp() {
|
||||||
|
service = Mockito.mock(ChatRoomListService::class.java)
|
||||||
|
controller = ChatRoomListController(service)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("채팅 리스트 조회는 비로그인 사용자에게 빈 목록을 반환한다")
|
||||||
|
fun shouldReturnEmptyRoomsForAnonymousUser() {
|
||||||
|
val response = controller.getRooms(member = null, filter = "ALL", cursor = null, limit = 30)
|
||||||
|
|
||||||
|
assertTrue(response.success)
|
||||||
|
assertEquals(emptyList<ChatRoomListItemResponse>(), response.data?.rooms)
|
||||||
|
assertFalse(response.data?.hasMore ?: true)
|
||||||
|
assertNull(response.data?.nextCursor)
|
||||||
|
Mockito.verifyNoInteractions(service)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("채팅 리스트 조회는 로그인 사용자의 요청을 서비스에 위임한다")
|
||||||
|
fun shouldDelegateAuthenticatedRequestToService() {
|
||||||
|
val member = Member(password = "pw", nickname = "user").apply { id = 1L }
|
||||||
|
val serviceResponse = ChatRoomListPageResponse(
|
||||||
|
rooms = emptyList(),
|
||||||
|
hasMore = false,
|
||||||
|
nextCursor = null
|
||||||
|
)
|
||||||
|
Mockito.`when`(service.getRooms(member, "DM", "cursor", 10)).thenReturn(serviceResponse)
|
||||||
|
|
||||||
|
val response = controller.getRooms(member = member, filter = "DM", cursor = "cursor", limit = 10)
|
||||||
|
|
||||||
|
assertTrue(response.success)
|
||||||
|
assertEquals(serviceResponse, response.data)
|
||||||
|
Mockito.verify(service).getRooms(member, "DM", "cursor", 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -106,6 +106,66 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
assertEquals(listOf(oldest.id), page1.map { it.liveRoomId })
|
assertEquals(listOf(oldest.id), page1.map { it.liveRoomId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("라이브 추천 조회는 진행 중인 라이브의 제목, 가격, 시작 시간을 함께 반환한다")
|
||||||
|
fun shouldReturnLiveTitlePriceAndBeginDateTimeForOnAirLiveQuery() {
|
||||||
|
val beginDateTime = LocalDateTime.of(2026, 6, 26, 12, 30)
|
||||||
|
val viewer = saveMember("on-air-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("on-air-creator", MemberRole.CREATOR)
|
||||||
|
val live = saveLiveRoom(
|
||||||
|
creator = creator,
|
||||||
|
beginDateTime = beginDateTime,
|
||||||
|
channelName = "channel",
|
||||||
|
title = "paid live",
|
||||||
|
price = 30
|
||||||
|
)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val lives = repository.findLiveRecommendations(
|
||||||
|
offset = 0,
|
||||||
|
limit = 1,
|
||||||
|
memberId = viewer.id,
|
||||||
|
includeAdultLives = true
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf(live.id), lives.map { it.liveRoomId })
|
||||||
|
assertEquals("paid live", lives.single().title)
|
||||||
|
assertEquals(30, lives.single().price)
|
||||||
|
assertEquals(beginDateTime, lives.single().beginDateTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("진행 중 라이브 조회 정책은 활성 방송자, 채널명, 활성 라이브, 성인 노출, 차단 관계를 적용한다")
|
||||||
|
fun shouldApplyOnAirLiveVisibilityPolicy() {
|
||||||
|
val baseAt = LocalDateTime.of(2026, 6, 26, 12, 0)
|
||||||
|
val viewer = saveMember("policy-viewer", MemberRole.USER)
|
||||||
|
val visibleCreator = saveMember("policy-visible", MemberRole.CREATOR)
|
||||||
|
val inactiveCreator = saveMember("policy-inactive", MemberRole.CREATOR, isActive = false)
|
||||||
|
val viewerBlockedCreator = saveMember("policy-viewer-blocked", MemberRole.CREATOR)
|
||||||
|
val creatorBlockedViewer = saveMember("policy-creator-blocked", MemberRole.CREATOR)
|
||||||
|
val olderVisibleLive = saveLiveRoom(visibleCreator, baseAt, channelName = "older")
|
||||||
|
val newerVisibleLive = saveLiveRoom(visibleCreator, baseAt.plusMinutes(1), channelName = "newer")
|
||||||
|
saveLiveRoom(inactiveCreator, baseAt.plusMinutes(6), channelName = "inactive-creator")
|
||||||
|
saveLiveRoom(visibleCreator, baseAt.plusMinutes(5), channelName = "inactive-live", isActive = false)
|
||||||
|
saveLiveRoom(visibleCreator, baseAt.plusMinutes(5), channelName = null)
|
||||||
|
saveLiveRoom(visibleCreator, baseAt.plusMinutes(4), channelName = "")
|
||||||
|
saveLiveRoom(visibleCreator, baseAt.plusMinutes(3), channelName = "adult", isAdult = true)
|
||||||
|
saveLiveRoom(viewerBlockedCreator, baseAt.plusMinutes(2), channelName = "viewer-blocked")
|
||||||
|
saveLiveRoom(creatorBlockedViewer, baseAt.plusMinutes(2), channelName = "creator-blocked")
|
||||||
|
saveBlock(viewer, viewerBlockedCreator)
|
||||||
|
saveBlock(creatorBlockedViewer, viewer)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val lives = repository.findLiveRecommendations(
|
||||||
|
offset = 0,
|
||||||
|
limit = 10,
|
||||||
|
memberId = viewer.id,
|
||||||
|
includeAdultLives = false
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf(newerVisibleLive.id, olderVisibleLive.id), lives.map { it.liveRoomId })
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("라이브 추천은 회원과 크리에이터의 양방향 차단 관계를 제외한다")
|
@DisplayName("라이브 추천은 회원과 크리에이터의 양방향 차단 관계를 제외한다")
|
||||||
fun shouldExcludeBidirectionalBlockedCreatorsFromLiveRecommendations() {
|
fun shouldExcludeBidirectionalBlockedCreatorsFromLiveRecommendations() {
|
||||||
@@ -2082,17 +2142,22 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
creator: Member,
|
creator: Member,
|
||||||
beginDateTime: LocalDateTime,
|
beginDateTime: LocalDateTime,
|
||||||
channelName: String?,
|
channelName: String?,
|
||||||
isAdult: Boolean = false
|
isAdult: Boolean = false,
|
||||||
|
title: String = "live-${creator.nickname}-$beginDateTime",
|
||||||
|
price: Int = 0,
|
||||||
|
isActive: Boolean = true
|
||||||
): LiveRoom {
|
): LiveRoom {
|
||||||
val room = LiveRoom(
|
val room = LiveRoom(
|
||||||
title = "live-${creator.nickname}-$beginDateTime",
|
title = title,
|
||||||
notice = "notice",
|
notice = "notice",
|
||||||
beginDateTime = beginDateTime,
|
beginDateTime = beginDateTime,
|
||||||
numberOfPeople = 0,
|
numberOfPeople = 0,
|
||||||
isAdult = isAdult
|
isAdult = isAdult,
|
||||||
|
price = price
|
||||||
)
|
)
|
||||||
room.member = creator
|
room.member = creator
|
||||||
room.channelName = channelName
|
room.channelName = channelName
|
||||||
|
room.isActive = isActive
|
||||||
entityManager.persist(room)
|
entityManager.persist(room)
|
||||||
return room
|
return room
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,23 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
assertEquals(port.liveRecommendations, recommendations)
|
assertEquals(port.liveRecommendations, recommendations)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("라이브 추천 조회는 paging과 성인 노출 여부를 조회 포트에 그대로 위임한다")
|
||||||
|
fun shouldDelegateLiveRecommendationQueryWithPagingAndAdultFlag() {
|
||||||
|
val recommendations = service.findLiveRecommendations(
|
||||||
|
offset = 40,
|
||||||
|
limit = 21,
|
||||||
|
memberId = 100L,
|
||||||
|
includeAdultLives = true
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(40, port.liveOffset)
|
||||||
|
assertEquals(21, port.liveLimit)
|
||||||
|
assertEquals(100L, port.liveMemberId)
|
||||||
|
assertEquals(true, port.liveIncludeAdultLives)
|
||||||
|
assertEquals(port.liveRecommendations, recommendations)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("홈 배너 추천은 기본 20개를 활성 배너 조회 포트에 위임한다")
|
@DisplayName("홈 배너 추천은 기본 20개를 활성 배너 조회 포트에 위임한다")
|
||||||
fun shouldFindHomeBannersWithDefaultLimit() {
|
fun shouldFindHomeBannersWithDefaultLimit() {
|
||||||
@@ -628,7 +645,10 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
HomeLiveRecommendationRecord(
|
HomeLiveRecommendationRecord(
|
||||||
liveRoomId = 1L,
|
liveRoomId = 1L,
|
||||||
creatorNickname = "creator",
|
creatorNickname = "creator",
|
||||||
creatorProfileImage = "profile.png"
|
creatorProfileImage = "profile.png",
|
||||||
|
title = "live",
|
||||||
|
price = 10,
|
||||||
|
beginDateTime = LocalDateTime.of(2026, 6, 26, 12, 30)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
val banners = listOf(
|
val banners = listOf(
|
||||||
|
|||||||
Reference in New Issue
Block a user