Compare commits

..

5 Commits

13 changed files with 449 additions and 13 deletions

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
HELP.md
.gradle
.envrc
.omx/
build/
!**/src/main/**/build/
!**/src/test/**/build/

View File

@@ -0,0 +1,10 @@
# `.omx/` Git 제외 처리
- [x] `.gitignore``.omx/`를 추가한다.
- [x] `git status``git check-ignore`로 제외가 정상 동작하는지 확인한다.
## 검증 기록
- 무엇을: `.gitignore``.omx/`를 추가하고 `.omx/` 하위 파일들이 무시되는지 확인했다.
- 왜: `.omx/`는 런타임 상태, 로그, 메트릭 파일이라 버전 관리 대상이 아니기 때문이다.
- 어떻게: `git check-ignore -v .omx/tmux-hook.json .omx/state/hud-state.json .omx/logs/turns-2026-04-06.jsonl`로 무시 규칙을 확인했고, `git status --short``.omx/`가 더 이상 추적 대상이 아니고 문서만 남는지 확인했다.

View File

@@ -0,0 +1,19 @@
SET @schema_name := DATABASE();
SET @settlement_ratio_column_exists := (
SELECT COUNT(1)
FROM information_schema.columns
WHERE table_schema = @schema_name
AND table_name = 'content'
AND column_name = 'settlement_ratio'
);
SET @add_settlement_ratio_column_sql := IF(
@settlement_ratio_column_exists = 0,
'ALTER TABLE content ADD COLUMN settlement_ratio INT NULL COMMENT ''콘텐츠별 정산 요율(%)'' AFTER price',
'SELECT ''content.settlement_ratio already exists'' AS message'
);
PREPARE add_settlement_ratio_column_stmt FROM @add_settlement_ratio_column_sql;
EXECUTE add_settlement_ratio_column_stmt;
DEALLOCATE PREPARE add_settlement_ratio_column_stmt;

View File

@@ -0,0 +1,85 @@
- [x] 변수명 확정: 엔티티 내부 추가 변수는 `AudioContent.settlementRatio: Int?`로 사용한다.
- 이유: `AudioContent`는 이미 콘텐츠 도메인 엔티티이므로 `contentSettlementRatio`는 중복 표현에 가깝다.
- 근거: 이 저장소의 엔티티 필드는 `AudioContent.price`, `LiveRoom.price`, `CreatorCommunity.price`처럼 엔티티 스코프 안에서는 도메인 접두어를 반복하지 않는다.
- 예외 기준: `CreatorSettlementRatio.contentSettlementRatio`처럼 하나의 엔티티 안에서 `live/content/community` 여러 정산 대상을 함께 구분해야 할 때만 `content` 접두어가 필요하다.
- DTO/API 정책: 해당 값은 관리자에서만 사용하므로 관리자 요청/응답 DTO와 API 필드명도 예외 없이 `settlementRatio`로 통일한다.
- nullable 정책: 기존 데이터와 크리에이터 정산 요율 미등록 케이스를 안전하게 수용하기 위해 초기 도입 시 `Int?`로 두고, 계산 시 `콘텐츠별 요율 -> 크리에이터 기본 요율 -> 70% 기본값` 순서로 fallback 하도록 설계한다.
- [x] `AudioContent` 엔티티에 콘텐츠별 정산 요율 필드를 추가한다.
- 대상 파일: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt`
- 작업 내용: `price` 인접 위치에 `settlementRatio: Int?` 필드를 추가하고, 기존 생성자 호출부가 모두 컴파일되도록 생성 경로를 함께 정리한다.
- [x] 관리자 콘텐츠 목록 조회 응답에 콘텐츠별 정산 요율을 노출한다.
- 대상 파일: `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentRepository.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/GetAdminContentListResponse.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentService.kt`
- 작업 내용:
- `QGetAdminContentListItem(...)` QueryProjection에 `audioContent.settlementRatio`를 추가한다.
- `GetAdminContentListItem``settlementRatio: Int?`를 추가하고, 관리자 목록 응답 필드명도 동일하게 `settlementRatio`로 맞춘다.
- `AdminContentController.getAudioContentList` 응답에 정산 요율이 함께 내려가도록 조회 체인을 맞춘다.
- [x] 관리자 콘텐츠 수정 API에서 콘텐츠별 정산 요율을 수정할 수 있게 한다.
- 대상 파일: `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/UpdateAdminContentRequest.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentService.kt`
- 작업 내용:
- `UpdateAdminContentRequest``settlementRatio: Int?`를 추가하고, 관리자 수정 요청 필드명도 동일하게 `settlementRatio`로 맞춘다.
- `AdminContentService.updateAudioContent`에서 요청값이 들어오면 `audioContent.settlementRatio`를 갱신한다.
- 숫자 범위 정책은 `0~100`으로 검증한다.
- 개별 콘텐츠 정산 요율 삭제는 `isSettlementRatioDeleted: true` 플래그로만 처리하고, `settlementRatio`와 동시 전달 시 invalid request 로 처리한다.
- [x] 실제 콘텐츠 정산 계산이 크리에이터 기본 요율이 아니라 콘텐츠별 요율을 우선 사용하도록 변경한다.
- 대상 파일: `src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/CreatorAdminCalculateQueryRepository.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateContentQueryData.kt`
- 작업 내용:
- 콘텐츠 판매/누적 판매 집계 쿼리에서 `creatorSettlementRatio.contentSettlementRatio` 직접 사용 부분을 점검한다.
- 정산 대상 비율은 `audioContent.settlementRatio`를 우선 사용하고, 값이 없을 때만 `creatorSettlementRatio.contentSettlementRatio`를 fallback 하도록 쿼리 또는 계산 DTO를 조정한다.
- 현재 `GetCalculateContentQueryData`의 70% 기본값 정책은 마지막 fallback 으로 유지한다.
- [x] 크리에이터 관리자 정산 조회도 동일 기준을 사용하도록 맞춘다.
- 대상 파일: `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/CreatorAdminCalculateQueryRepository.kt`
- 작업 내용: 관리자 정산 조회와 동일하게 콘텐츠별 정산 요율 우선 정책을 반영해 관리자/크리에이터 화면 간 계산 기준이 달라지지 않게 한다.
- [x] 기존 데이터 처리 정책을 정리한다.
- 대상 범위: 운영 DB 스키마/기존 콘텐츠 데이터
- 작업 내용:
- 신규 컬럼 추가 시 nullable 로 도입하고, DDL은 `docs/20260407_audio_content_settlement_ratio_ddl.sql` 기준으로 관리한다.
- 콘텐츠 등록 시 크리에이터 기본 정산 요율을 복사하지 않고, `NULL` 상태에서도 계산이 가능하도록 fallback 순서를 유지한다.
- 운영 정책상 기존 콘텐츠에도 즉시 고정값이 필요하면 별도 SQL 또는 배치 백필 계획을 추가로 작성한다.
- [x] 영향 DTO/Q 클래스/컴파일 산출물을 재생성하고 검증한다.
- 작업 내용:
- QueryDSL projection 변경 후 Q 클래스 재생성이 필요한지 확인하고 빌드로 반영한다.
- 엔티티/관리자 DTO/API/QueryProjection 필드명이 모두 `settlementRatio`로 일치하는지 확인해 매핑 누락 가능성을 제거한다.
- [x] 검증을 단계별로 수행한다.
- 작업 내용:
- `AdminContentController.getAudioContentList`에서 정산 요율 조회 포함 여부를 확인한다.
- `AdminContentController.modifyAudioContent`에서 정산 요율 수정 반영 여부를 확인한다.
- `AdminContentController.modifyAudioContent`에서 `isSettlementRatioDeleted = true` 요청 시 개별 콘텐츠 정산 요율이 삭제되는지 확인한다.
- 콘텐츠 등록 시 `settlementRatio`가 nullable 상태로 유지되어도 계산 fallback 이 정상 동작하는지 확인한다.
- 콘텐츠 정산 조회(`AdminCalculateQueryRepository`, `CreatorAdminCalculateQueryRepository`)가 콘텐츠별 요율을 우선 적용하는지 확인한다.
- 실행 검증은 최소 `./gradlew build`, 필요 시 `./gradlew test`까지 수행한다.
## 1차 구현 검증 기록
- 무엇을: `AudioContent.settlementRatio` nullable 컬럼 추가, 관리자 목록/수정 API 반영, 콘텐츠/누적 정산 쿼리의 콘텐츠별 요율 우선 fallback 반영, 생성 시 기본 요율 복사 제거.
- 왜: 기존 콘텐츠와 미설정 콘텐츠를 `NULL`로 유지하면서도 관리자에서 개별 요율을 조회/수정하고 정산 시 올바른 fallback 순서를 적용하기 위해.
- 어떻게:
- `./gradlew build` → 성공
- `./gradlew test``build` 과정에 포함되어 성공
- 관리자/정산 API의 실서버 수동 호출 검증 → 이 로컬 작업 세션에서는 애플리케이션 실행 및 인증 가능한 테스트 데이터가 없어 미실행
## 2차 수정 검증 기록
- 무엇을: `@SpringBootTest` 없이 콘텐츠 정산 계산 DTO의 명시 비율 적용과 `null -> 70% fallback`을 검증하는 순수 단위 테스트를 추가했다.
- 왜: 정산 쿼리 변경만으로는 계산 결과가 코드상에서 충분히 고정되지 않아, 문서에 적힌 계산 규칙을 재현 가능한 테스트로 보장하기 위해.
- 어떻게:
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.ContentSettlementCalculationTest` → 성공
- 검증 대상: `GetCalculateContentQueryData`, `GetCumulativeSalesByContentQueryData`
- 검증 시나리오: `settlementRatio = 80` 적용, `settlementRatio = null` 시 70% fallback 적용
## 3차 수정 검증 기록
- 무엇을: 관리자 콘텐츠 수정 API의 개별 콘텐츠 정산 요율 삭제 방식을 명시적 null 대신 `isSettlementRatioDeleted` 플래그로 전환한다.
- 왜: 부분 업데이트 요청에서 필드 생략과 null 삭제 의미가 섞이지 않도록 API 계약을 명확히 하기 위해.
- 어떻게:
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.content.AdminContentServiceTest` → 성공
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.ContentSettlementCalculationTest` → 성공
- `./gradlew build` → 성공
- 검증 시나리오: 유효한 요율 설정, 삭제 플래그 삭제, 값/삭제 플래그 동시 전달 충돌, `null` 키/삭제 플래그 동시 전달 충돌, 범위 초과 거부, 정산 fallback 회귀 확인

View File

@@ -86,6 +86,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
val pointGroup = CaseBuilder()
.`when`(order.point.loe(0)).then(0)
.otherwise(1)
val contentSettlementRatio = audioContent.settlementRatio.coalesce(creatorSettlementRatio.contentSettlementRatio)
return queryFactory
.select(audioContent.id)
@@ -108,7 +109,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
orderFormattedDate,
order.can,
pointGroup,
creatorSettlementRatio.contentSettlementRatio
contentSettlementRatio
)
.fetch()
.size
@@ -124,6 +125,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
val pointGroup = CaseBuilder()
.`when`(order.point.loe(0)).then(0)
.otherwise(1)
val contentSettlementRatio = audioContent.settlementRatio.coalesce(creatorSettlementRatio.contentSettlementRatio)
return queryFactory
.select(
@@ -137,7 +139,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
order.id.count(),
order.can.sum(),
order.point.sum(),
creatorSettlementRatio.contentSettlementRatio
contentSettlementRatio
)
)
.from(order)
@@ -159,7 +161,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
orderFormattedDate,
order.can,
pointGroup,
creatorSettlementRatio.contentSettlementRatio
contentSettlementRatio
)
.orderBy(member.id.desc(), orderFormattedDate.desc(), audioContent.id.asc())
.offset(offset)
@@ -182,13 +184,23 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
}
fun getCumulativeSalesByContentTotalCount(): Int {
val pointGroup = CaseBuilder()
.`when`(order.point.loe(0)).then(0)
.otherwise(1)
val contentSettlementRatio = audioContent.settlementRatio.coalesce(creatorSettlementRatio.contentSettlementRatio)
return queryFactory
.select(audioContent.id)
.from(order)
.innerJoin(order.audioContent, audioContent)
.innerJoin(audioContent.member, member)
.leftJoin(creatorSettlementRatio)
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where(order.isActive.isTrue)
.groupBy(member.id, audioContent.id, order.can)
.groupBy(member.id, audioContent.id, order.type, order.can, pointGroup, contentSettlementRatio)
.fetch()
.size
}
@@ -197,6 +209,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
val pointGroup = CaseBuilder()
.`when`(order.point.loe(0)).then(0)
.otherwise(1)
val contentSettlementRatio = audioContent.settlementRatio.coalesce(creatorSettlementRatio.contentSettlementRatio)
return queryFactory
.select(
@@ -209,7 +222,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
order.id.count(),
order.can.sum(),
order.point.sum(),
creatorSettlementRatio.contentSettlementRatio
contentSettlementRatio
)
)
.from(order)
@@ -227,7 +240,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
order.type,
order.can,
pointGroup,
creatorSettlementRatio.contentSettlementRatio
contentSettlementRatio
)
.offset(offset)
.limit(limit)

View File

@@ -110,6 +110,7 @@ class AdminAudioContentQueryRepositoryImpl(
audioContentTheme.theme,
audioContentTheme.id,
audioContent.price,
audioContent.settlementRatio,
audioContent.limited,
audioContent.remaining,
audioContent.isAdult,

View File

@@ -93,7 +93,8 @@ class AdminContentService(
@Transactional
fun updateAudioContent(coverImage: MultipartFile?, requestString: String) {
val request = objectMapper.readValue(requestString, UpdateAdminContentRequest::class.java)
val requestNode = objectMapper.readTree(requestString)
val request = objectMapper.treeToValue(requestNode, UpdateAdminContentRequest::class.java)
val audioContent = repository.findByIdOrNull(id = request.id)
?: throw SodaException(messageKey = "admin.content.not_found")
@@ -145,6 +146,18 @@ class AdminContentService(
val theme = themeRepository.findByIdAndActive(id = request.themeId)
audioContent.theme = theme
}
if (request.isSettlementRatioDeleted == true) {
if (requestNode.has("settlementRatio")) {
throw SodaException(messageKey = "common.error.invalid_request")
}
audioContent.settlementRatio = null
} else if (request.settlementRatio != null) {
if (request.settlementRatio !in 0..100) {
throw SodaException(messageKey = "common.error.invalid_request")
}
audioContent.settlementRatio = request.settlementRatio
}
}
fun getContentMainTabList(): List<GetContentMainTabItem> {

View File

@@ -18,6 +18,7 @@ data class GetAdminContentListItem @QueryProjection constructor(
val theme: String,
val themeId: Long,
val price: Int,
val settlementRatio: Int?,
val totalContentCount: Int?,
val remainingContentCount: Int?,
val isAdult: Boolean,

View File

@@ -7,6 +7,8 @@ data class UpdateAdminContentRequest(
val detail: String?,
val curationId: Long?,
val themeId: Long?,
val settlementRatio: Int?,
val isSettlementRatioDeleted: Boolean?,
val isAdult: Boolean?,
val isActive: Boolean?,
val isCommentAvailable: Boolean?

View File

@@ -35,6 +35,7 @@ data class AudioContent(
var languageCode: String?,
var playCount: Long = 0,
var price: Int = 0,
var settlementRatio: Int? = null,
var releaseDate: LocalDateTime? = null,
val limited: Int? = null,
var remaining: Int? = null,

View File

@@ -74,18 +74,28 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
memberId: Long
): Int {
val orderFormattedDate = getFormattedDate(order.createdAt)
val pointGroup = CaseBuilder()
.`when`(order.point.loe(0)).then(0)
.otherwise(1)
val contentSettlementRatio = audioContent.settlementRatio.coalesce(creatorSettlementRatio.contentSettlementRatio)
return queryFactory
.select(audioContent.id)
.from(order)
.innerJoin(order.audioContent, audioContent)
.innerJoin(audioContent.member, member)
.leftJoin(creatorSettlementRatio)
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where(
order.createdAt.goe(startDate)
.and(order.createdAt.loe(endDate))
.and(order.isActive.isTrue)
.and(order.creator.id.eq(memberId))
)
.groupBy(audioContent.id, order.type, orderFormattedDate, order.can)
.groupBy(audioContent.id, order.type, orderFormattedDate, order.can, pointGroup, contentSettlementRatio)
.orderBy(member.id.desc(), orderFormattedDate.desc(), audioContent.id.asc())
.fetch()
.size
@@ -102,6 +112,7 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
val pointGroup = CaseBuilder()
.`when`(order.point.loe(0)).then(0)
.otherwise(1)
val contentSettlementRatio = audioContent.settlementRatio.coalesce(creatorSettlementRatio.contentSettlementRatio)
return queryFactory
.select(
@@ -115,7 +126,7 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
order.id.count(),
order.can.sum(),
order.point.sum(),
creatorSettlementRatio.contentSettlementRatio
contentSettlementRatio
)
)
.from(order)
@@ -138,7 +149,7 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
orderFormattedDate,
order.can,
pointGroup,
creatorSettlementRatio.contentSettlementRatio
contentSettlementRatio
)
.offset(offset)
.limit(limit)
@@ -161,16 +172,26 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
}
fun getCumulativeSalesByContentTotalCount(memberId: Long): Int {
val pointGroup = CaseBuilder()
.`when`(order.point.loe(0)).then(0)
.otherwise(1)
val contentSettlementRatio = audioContent.settlementRatio.coalesce(creatorSettlementRatio.contentSettlementRatio)
return queryFactory
.select(audioContent.id)
.from(order)
.innerJoin(order.audioContent, audioContent)
.innerJoin(audioContent.member, member)
.leftJoin(creatorSettlementRatio)
.on(
member.id.eq(creatorSettlementRatio.member.id)
.and(creatorSettlementRatio.deletedAt.isNull)
)
.where(
audioContent.member.id.eq(memberId)
.and(order.isActive.isTrue)
)
.groupBy(member.id, audioContent.id, order.can)
.groupBy(member.id, audioContent.id, order.type, order.can, pointGroup, contentSettlementRatio)
.fetch()
.size
}
@@ -183,6 +204,7 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
val pointGroup = CaseBuilder()
.`when`(order.point.loe(0)).then(0)
.otherwise(1)
val contentSettlementRatio = audioContent.settlementRatio.coalesce(creatorSettlementRatio.contentSettlementRatio)
return queryFactory
.select(
@@ -195,7 +217,7 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
order.id.count(),
order.can.sum(),
order.point.sum(),
creatorSettlementRatio.contentSettlementRatio
contentSettlementRatio
)
)
.from(order)
@@ -216,7 +238,7 @@ class CreatorAdminCalculateQueryRepository(private val queryFactory: JPAQueryFac
order.type,
order.can,
pointGroup,
creatorSettlementRatio.contentSettlementRatio
contentSettlementRatio
)
.offset(offset)
.limit(limit)

View File

@@ -0,0 +1,102 @@
package kr.co.vividnext.sodalive.admin.calculate
import kr.co.vividnext.sodalive.content.order.OrderType
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
class ContentSettlementCalculationTest {
@Test
@DisplayName("콘텐츠 정산 응답은 콘텐츠별 정산 요율을 우선 적용한다")
fun shouldApplyExplicitSettlementRatioForCalculateContentResponse() {
val result = GetCalculateContentQueryData(
nickname = "creator",
title = "content",
registrationDate = "2026-04-07",
saleDate = "2026-04-07",
orderType = OrderType.KEEP,
orderPrice = 100,
numberOfPeople = 2,
totalCan = 100,
totalPoint = 0,
settlementRatio = 80
).toGetCalculateContentResponse()
assertEquals("소장", result.orderType)
assertEquals(10_000, result.totalKrw)
assertEquals(660, result.paymentFee)
assertEquals(7_472, result.settlementAmount)
assertEquals(247, result.tax)
assertEquals(7_225, result.depositAmount)
}
@Test
@DisplayName("콘텐츠 정산 응답은 정산 요율이 없으면 70퍼센트 기본값으로 계산한다")
fun shouldFallbackToDefaultSettlementRatioForCalculateContentResponse() {
val result = GetCalculateContentQueryData(
nickname = "creator",
title = "content",
registrationDate = "2026-04-07",
saleDate = "2026-04-07",
orderType = OrderType.RENTAL,
orderPrice = 70,
numberOfPeople = 1,
totalCan = 100,
totalPoint = 0,
settlementRatio = null
).toGetCalculateContentResponse()
assertEquals("대여", result.orderType)
assertEquals(10_000, result.totalKrw)
assertEquals(660, result.paymentFee)
assertEquals(6_538, result.settlementAmount)
assertEquals(216, result.tax)
assertEquals(6_322, result.depositAmount)
}
@Test
@DisplayName("누적 콘텐츠 정산 응답은 콘텐츠별 정산 요율을 우선 적용한다")
fun shouldApplyExplicitSettlementRatioForCumulativeSalesResponse() {
val result = GetCumulativeSalesByContentQueryData(
nickname = "creator",
title = "content",
registrationDate = "2026-04-07",
orderType = OrderType.KEEP,
orderPrice = 100,
numberOfPeople = 2,
totalCan = 100,
totalPoint = 0,
settlementRatio = 80
).toCumulativeSalesByContentItem()
assertEquals("소장", result.orderType)
assertEquals(10_000, result.totalKrw)
assertEquals(660, result.paymentFee)
assertEquals(7_472, result.settlementAmount)
assertEquals(247, result.tax)
assertEquals(7_225, result.depositAmount)
}
@Test
@DisplayName("누적 콘텐츠 정산 응답은 정산 요율이 없으면 70퍼센트 기본값으로 계산한다")
fun shouldFallbackToDefaultSettlementRatioForCumulativeSalesResponse() {
val result = GetCumulativeSalesByContentQueryData(
nickname = "creator",
title = "content",
registrationDate = "2026-04-07",
orderType = OrderType.RENTAL,
orderPrice = 70,
numberOfPeople = 1,
totalCan = 100,
totalPoint = 0,
settlementRatio = null
).toCumulativeSalesByContentItem()
assertEquals("대여", result.orderType)
assertEquals(10_000, result.totalKrw)
assertEquals(660, result.paymentFee)
assertEquals(6_538, result.settlementAmount)
assertEquals(216, result.tax)
assertEquals(6_322, result.depositAmount)
}
}

View File

@@ -0,0 +1,166 @@
package kr.co.vividnext.sodalive.admin.content
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kr.co.vividnext.sodalive.admin.content.curation.AdminContentCurationRepository
import kr.co.vividnext.sodalive.admin.content.tab.AdminContentMainTabRepository
import kr.co.vividnext.sodalive.admin.content.theme.AdminContentThemeRepository
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.AudioContent
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import java.util.Optional
class AdminContentServiceTest {
private lateinit var repository: AdminContentRepository
private lateinit var themeRepository: AdminContentThemeRepository
private lateinit var audioContentCloudFront: AudioContentCloudFront
private lateinit var curationRepository: AdminContentCurationRepository
private lateinit var contentMainTabRepository: AdminContentMainTabRepository
private lateinit var s3Uploader: S3Uploader
private lateinit var service: AdminContentService
@BeforeEach
fun setup() {
repository = Mockito.mock(AdminContentRepository::class.java)
themeRepository = Mockito.mock(AdminContentThemeRepository::class.java)
audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java)
curationRepository = Mockito.mock(AdminContentCurationRepository::class.java)
contentMainTabRepository = Mockito.mock(AdminContentMainTabRepository::class.java)
s3Uploader = Mockito.mock(S3Uploader::class.java)
service = AdminContentService(
repository = repository,
themeRepository = themeRepository,
audioContentCloudFront = audioContentCloudFront,
curationRepository = curationRepository,
contentMainTabRepository = contentMainTabRepository,
objectMapper = jacksonObjectMapper(),
s3Uploader = s3Uploader,
bucket = "test-bucket"
)
}
@Test
@DisplayName("관리자 콘텐츠 수정은 유효한 개별 정산 요율을 저장한다")
fun shouldUpdateSettlementRatioWhenValidValueIsProvided() {
val audioContent = createAudioContent(settlementRatio = 70)
Mockito.`when`(repository.findById(1L)).thenReturn(Optional.of(audioContent))
service.updateAudioContent(
coverImage = null,
requestString = """
{"id":1,"isDefaultCoverImage":false,"settlementRatio":80}
""".trimIndent()
)
assertEquals(80, audioContent.settlementRatio)
}
@Test
@DisplayName("관리자 콘텐츠 수정은 삭제 플래그로 개별 정산 요율을 삭제한다")
fun shouldDeleteSettlementRatioWhenDeleteFlagIsTrue() {
val audioContent = createAudioContent(settlementRatio = 70)
Mockito.`when`(repository.findById(1L)).thenReturn(Optional.of(audioContent))
service.updateAudioContent(
coverImage = null,
requestString = """
{"id":1,"isDefaultCoverImage":false,"isSettlementRatioDeleted":true}
""".trimIndent()
)
assertNull(audioContent.settlementRatio)
}
@Test
@DisplayName("관리자 콘텐츠 수정은 삭제 플래그와 정산 요율을 함께 보내면 예외를 던진다")
fun shouldThrowWhenDeleteFlagAndSettlementRatioAreProvidedTogether() {
val audioContent = createAudioContent(settlementRatio = 70)
Mockito.`when`(repository.findById(1L)).thenReturn(Optional.of(audioContent))
val exception = assertThrows(SodaException::class.java) {
service.updateAudioContent(
coverImage = null,
requestString = """
{"id":1,"isDefaultCoverImage":false,"settlementRatio":80,"isSettlementRatioDeleted":true}
""".trimIndent()
)
}
assertEquals("common.error.invalid_request", exception.messageKey)
assertEquals(70, audioContent.settlementRatio)
}
@Test
@DisplayName("관리자 콘텐츠 수정은 삭제 플래그와 null 정산 요율 키를 함께 보내도 예외를 던진다")
fun shouldThrowWhenDeleteFlagAndNullSettlementRatioKeyAreProvidedTogether() {
val audioContent = createAudioContent(settlementRatio = 70)
Mockito.`when`(repository.findById(1L)).thenReturn(Optional.of(audioContent))
val exception = assertThrows(SodaException::class.java) {
service.updateAudioContent(
coverImage = null,
requestString = """
{"id":1,"isDefaultCoverImage":false,"settlementRatio":null,"isSettlementRatioDeleted":true}
""".trimIndent()
)
}
assertEquals("common.error.invalid_request", exception.messageKey)
assertEquals(70, audioContent.settlementRatio)
}
@Test
@DisplayName("관리자 콘텐츠 수정은 범위를 벗어난 정산 요율이면 예외를 던진다")
fun shouldThrowWhenSettlementRatioIsOutOfRange() {
val audioContent = createAudioContent(settlementRatio = 70)
Mockito.`when`(repository.findById(1L)).thenReturn(Optional.of(audioContent))
val exception = assertThrows(SodaException::class.java) {
service.updateAudioContent(
coverImage = null,
requestString = """
{"id":1,"isDefaultCoverImage":false,"settlementRatio":101}
""".trimIndent()
)
}
assertEquals("common.error.invalid_request", exception.messageKey)
assertEquals(70, audioContent.settlementRatio)
}
@Test
@DisplayName("관리자 콘텐츠 수정은 삭제 플래그와 정산 요율이 없으면 기존 값을 유지한다")
fun shouldKeepSettlementRatioWhenNoSettlementRatioFieldsAreProvided() {
val audioContent = createAudioContent(settlementRatio = 70)
Mockito.`when`(repository.findById(1L)).thenReturn(Optional.of(audioContent))
service.updateAudioContent(
coverImage = null,
requestString = """
{"id":1,"isDefaultCoverImage":false}
""".trimIndent()
)
assertEquals(70, audioContent.settlementRatio)
}
private fun createAudioContent(settlementRatio: Int?): AudioContent {
return AudioContent(
title = "title",
detail = "detail",
languageCode = "ko",
price = 100,
settlementRatio = settlementRatio
).apply {
id = 1L
}
}
}