diff --git a/docs/20260402_시리즈배너언어별조회적용.md b/docs/20260402_시리즈배너언어별조회적용.md new file mode 100644 index 00000000..fe218b3c --- /dev/null +++ b/docs/20260402_시리즈배너언어별조회적용.md @@ -0,0 +1,10 @@ +- [x] 시리즈 배너 등록·조회 경로와 언어 처리 기준을 확인한다. +- [x] 배너 등록 시 언어를 저장하고 관리자 목록에서 시리즈 제목에 `(언어)` 표기를 추가한다. +- [x] 사용자 시리즈 메인 조회에서 요청 언어와 일치하는 배너만 반환하도록 수정하고 검증 결과를 기록한다. + +## 검증 기록 + +### 1차 구현 +- 무엇을: 시리즈 배너 등록 요청에 `lang`을 추가하고, 관리자 목록에서는 `seriesTitle (언어)` 형태로 응답하며, 사용자 시리즈 메인에서는 `LangContext`와 일치하는 언어 배너만 조회하도록 수정했다. +- 왜: 관리자 화면에서는 같은 시리즈명의 다국어 배너를 구분할 수 있어야 하고, 사용자 화면에서는 요청 언어와 맞는 배너만 노출되어야 하기 때문이다. +- 어떻게: Kotlin LSP가 없어 정적 진단은 Gradle 컴파일로 대체했고, `./gradlew test --tests "kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerServiceTest" --tests "kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerTest" --tests "kr.co.vividnext.sodalive.content.series.main.SeriesMainControllerTest"`를 실행해 등록 언어 저장, 관리자 목록 언어 표기, 사용자 언어별 배너 조회를 검증했다. 결과는 `BUILD SUCCESSFUL`이다. diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerController.kt index 6f8c3d4b..7ec2edd5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerController.kt @@ -56,7 +56,9 @@ class AdminContentSeriesBannerController( val banners = bannerService.getActiveBanners(pageable) val response = SeriesBannerListPageResponse( totalCount = banners.totalElements, - content = banners.content.map { SeriesBannerResponse.from(it, imageHost) } + content = banners.content.map { + SeriesBannerResponse.from(it, imageHost, appendLanguageToSeriesTitle = true) + } ) ApiResponse.ok(response) } @@ -82,7 +84,7 @@ class AdminContentSeriesBannerController( val objectMapper = ObjectMapper() val request = objectMapper.readValue(requestString, SeriesBannerRegisterRequest::class.java) - val banner = bannerService.registerBanner(seriesId = request.seriesId, imagePath = "") + val banner = bannerService.registerBanner(seriesId = request.seriesId, imagePath = "", lang = request.lang) val imagePath = saveImage(banner.id!!, image) val updatedBanner = bannerService.updateBanner(banner.id!!, imagePath) val response = SeriesBannerResponse.from(updatedBanner, imageHost) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/dto/SeriesBannerDtos.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/dto/SeriesBannerDtos.kt index d054ca91..a8537476 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/dto/SeriesBannerDtos.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/dto/SeriesBannerDtos.kt @@ -2,10 +2,12 @@ package kr.co.vividnext.sodalive.admin.content.series.banner.dto import com.fasterxml.jackson.annotation.JsonProperty import kr.co.vividnext.sodalive.content.series.main.banner.SeriesBanner +import kr.co.vividnext.sodalive.i18n.Lang // 시리즈 배너 등록 요청 DTO data class SeriesBannerRegisterRequest( - @JsonProperty("seriesId") val seriesId: Long + @JsonProperty("seriesId") val seriesId: Long, + @JsonProperty("lang") val lang: Lang? = null ) // 시리즈 배너 수정 요청 DTO @@ -22,14 +24,30 @@ data class SeriesBannerResponse( val seriesTitle: String ) { companion object { - fun from(banner: SeriesBanner, imageHost: String): SeriesBannerResponse { + fun from( + banner: SeriesBanner, + imageHost: String, + appendLanguageToSeriesTitle: Boolean = false + ): SeriesBannerResponse { return SeriesBannerResponse( id = banner.id!!, imagePath = "$imageHost/${banner.imagePath}", seriesId = banner.series.id!!, - seriesTitle = banner.series.title + seriesTitle = if (appendLanguageToSeriesTitle) { + "${banner.series.title} (${getLanguageLabel(banner.lang)})" + } else { + banner.series.title + } ) } + + private fun getLanguageLabel(lang: Lang): String { + return when (lang) { + Lang.KO -> "한국어" + Lang.EN -> "영어" + Lang.JA -> "일본어" + } + } } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/SeriesMainController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/SeriesMainController.kt index 41c6fde9..0d21dc79 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/SeriesMainController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/SeriesMainController.kt @@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.content.series.ContentSeriesService import kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerService import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import org.springframework.beans.factory.annotation.Value @@ -21,6 +22,7 @@ import org.springframework.web.bind.annotation.RestController class SeriesMainController( private val contentSeriesService: ContentSeriesService, private val bannerService: ContentSeriesBannerService, + private val langContext: LangContext, private val memberContentPreferenceService: MemberContentPreferenceService, @Value("\${cloud.aws.cloud-front.host}") @@ -33,7 +35,7 @@ class SeriesMainController( if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") val preference = resolvePreference(member) - val banners = bannerService.getActiveBanners(PageRequest.of(0, 10)) + val banners = bannerService.getDisplayBanners(PageRequest.of(0, 10), langContext.lang) .content .map { SeriesBannerResponse.from(it, imageHost) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerService.kt index 8ea977b8..fbce2efa 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerService.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.content.series.main.banner import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.Lang import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service @@ -16,22 +17,28 @@ class ContentSeriesBannerService( return bannerRepository.findByIsActiveTrueOrderBySortOrderAsc(pageable) } + fun getDisplayBanners(pageable: Pageable, lang: Lang): Page { + return bannerRepository.findByIsActiveTrueAndLangOrderBySortOrderAsc(lang, pageable) + } + fun getBannerById(bannerId: Long): SeriesBanner { return bannerRepository.findById(bannerId) .orElseThrow { SodaException(messageKey = "series.banner.error.not_found") } } @Transactional - fun registerBanner(seriesId: Long, imagePath: String): SeriesBanner { + fun registerBanner(seriesId: Long, imagePath: String, lang: Lang? = null): SeriesBanner { val series = seriesRepository.findByIdAndActiveTrue(seriesId) ?: throw SodaException(messageKey = "series.banner.error.series_not_found") val finalSortOrder = (bannerRepository.findMaxSortOrder() ?: 0) + 1 + val finalLang = lang ?: Lang.KO val banner = SeriesBanner( imagePath = imagePath, series = series, - sortOrder = finalSortOrder + sortOrder = finalSortOrder, + lang = finalLang ) return bannerRepository.save(banner) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/SeriesBanner.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/SeriesBanner.kt index 2b4cc810..d460cee3 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/SeriesBanner.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/SeriesBanner.kt @@ -2,7 +2,11 @@ package kr.co.vividnext.sodalive.content.series.main.banner import kr.co.vividnext.sodalive.common.BaseEntity import kr.co.vividnext.sodalive.creator.admin.content.series.Series +import kr.co.vividnext.sodalive.i18n.Lang +import javax.persistence.Column import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated import javax.persistence.FetchType import javax.persistence.JoinColumn import javax.persistence.ManyToOne @@ -25,6 +29,10 @@ class SeriesBanner( // 정렬 순서 (낮을수록 먼저 표시) var sortOrder: Int = 0, + @Column(nullable = false) + @Enumerated(EnumType.STRING) + var lang: Lang = Lang.KO, + // 활성화 여부 (소프트 삭제용) var isActive: Boolean = true ) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/SeriesBannerRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/SeriesBannerRepository.kt index c2c2052f..b730bab9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/SeriesBannerRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/SeriesBannerRepository.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.content.series.main.banner +import kr.co.vividnext.sodalive.i18n.Lang import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository @@ -10,6 +11,8 @@ import org.springframework.stereotype.Repository interface SeriesBannerRepository : JpaRepository { fun findByIsActiveTrueOrderBySortOrderAsc(pageable: Pageable): Page + fun findByIsActiveTrueAndLangOrderBySortOrderAsc(lang: Lang, pageable: Pageable): Page + @Query("SELECT MAX(b.sortOrder) FROM SeriesBanner b WHERE b.isActive = true") fun findMaxSortOrder(): Int? } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerControllerTest.kt new file mode 100644 index 00000000..dc46b303 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerControllerTest.kt @@ -0,0 +1,108 @@ +package kr.co.vividnext.sodalive.admin.content.series.banner + +import com.amazonaws.services.s3.AmazonS3Client +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerService +import kr.co.vividnext.sodalive.content.series.main.banner.SeriesBanner +import kr.co.vividnext.sodalive.creator.admin.content.series.Series +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import org.springframework.mock.web.MockMultipartFile +import java.net.URL + +class AdminContentSeriesBannerControllerTest { + private val bannerService = Mockito.mock(ContentSeriesBannerService::class.java) + private val amazonS3Client = Mockito.mock(AmazonS3Client::class.java) + private val s3Uploader = S3Uploader(amazonS3Client) + private val controller = AdminContentSeriesBannerController( + bannerService = bannerService, + s3Uploader = s3Uploader, + langContext = LangContext(), + messageSource = SodaMessageSource(), + s3Bucket = "test-bucket", + imageHost = "https://cdn.test" + ) + + @Test + fun shouldRegisterJapaneseBannerThroughAdminApi() { + val image = MockMultipartFile("image", "banner.png", "image/png", "image".toByteArray()) + val registeredBanner = createBanner(id = 10L, lang = Lang.JA, imagePath = "") + val updatedBanner = createBanner(id = 10L, lang = Lang.JA, imagePath = "") + + Mockito.`when`(amazonS3Client.getUrl(Mockito.eq("test-bucket"), Mockito.anyString())) + .thenAnswer { URL("https://cdn.test/${it.arguments[1]}") } + + Mockito.`when`( + bannerService.registerBanner( + seriesId = 1L, + imagePath = "", + lang = Lang.JA + ) + ).thenReturn(registeredBanner) + Mockito.doAnswer { + updatedBanner.apply { + imagePath = it.arguments[1] as String + } + }.`when`(bannerService).updateBanner(Mockito.eq(10L), Mockito.anyString(), Mockito.isNull()) + + val response = controller.registerBanner( + image = image, + requestString = "{\"seriesId\":1,\"lang\":\"ja\"}" + ) + + assertTrue(response.success) + assertEquals(10L, response.data?.id) + assertTrue(response.data?.imagePath?.startsWith("https://cdn.test/series_banner/10/") == true) + Mockito.verify(bannerService).registerBanner(1L, "", Lang.JA) + } + + @Test + fun shouldDeserializeIso639LanguageCodeToLangEnum() { + val request = ObjectMapper().readValue( + "{\"seriesId\":1,\"lang\":\"en\"}", + kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerRegisterRequest::class.java + ) + + assertEquals(Lang.EN, request.lang) + } + + @Test + fun shouldAppendBannerLanguageToSeriesTitleInBannerList() { + val pageable = PageRequest.of(0, 20) + val japaneseBanner = createBanner(id = 12L, lang = Lang.JA, imagePath = "banner/jp.png") + + Mockito.`when`(bannerService.getActiveBanners(pageable)) + .thenReturn(PageImpl(listOf(japaneseBanner), pageable, 1)) + + val response = controller.getBannerList(page = 0, size = 20) + + assertTrue(response.success) + assertEquals("series-12 (일본어)", response.data?.content?.first()?.seriesTitle) + } + + private fun createBanner(id: Long, lang: Lang, imagePath: String): SeriesBanner { + val series = Series( + title = "series-$id", + introduction = "introduction-$id", + languageCode = "ko" + ) + series.id = id + + return SeriesBanner( + imagePath = imagePath, + series = series, + sortOrder = 1, + lang = lang + ).also { + it.id = id + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/content/series/main/SeriesMainControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/content/series/main/SeriesMainControllerTest.kt new file mode 100644 index 00000000..a560232b --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/content/series/main/SeriesMainControllerTest.kt @@ -0,0 +1,105 @@ +package kr.co.vividnext.sodalive.content.series.main + +import kr.co.vividnext.sodalive.content.ContentType +import kr.co.vividnext.sodalive.content.series.ContentSeriesService +import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse +import kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerService +import kr.co.vividnext.sodalive.content.series.main.banner.SeriesBanner +import kr.co.vividnext.sodalive.creator.admin.content.series.Series +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService +import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest + +class SeriesMainControllerTest { + private val contentSeriesService = Mockito.mock(ContentSeriesService::class.java) + private val bannerService = Mockito.mock(ContentSeriesBannerService::class.java) + private val langContext = LangContext().apply { setLang(Lang.JA) } + private val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java) + private val controller = SeriesMainController( + contentSeriesService = contentSeriesService, + bannerService = bannerService, + langContext = langContext, + memberContentPreferenceService = memberContentPreferenceService, + imageHost = "https://cdn.test" + ) + + @Test + fun shouldFetchOnlyRequestedLanguageBanners() { + val member = createMember(id = 1L) + val preference = ViewerContentPreference( + countryCode = "KR", + isAdultContentVisible = true, + contentType = ContentType.ALL, + isAdult = true + ) + val pageable = PageRequest.of(0, 10) + val japaneseBanner = SeriesBanner( + imagePath = "banner/jp.png", + series = createSeries(id = 10L), + sortOrder = 1, + lang = Lang.JA + ).also { + it.id = 100L + } + + Mockito.`when`(memberContentPreferenceService.resolveForQuery(member)).thenReturn(preference) + Mockito.`when`(bannerService.getDisplayBanners(pageable, Lang.JA)) + .thenReturn(PageImpl(listOf(japaneseBanner), pageable, 1)) + Mockito.`when`( + contentSeriesService.getSeriesList( + null, + false, + true, + true, + true, + ContentType.ALL, + member, + 0, + 20 + ) + ).thenReturn(GetSeriesListResponse(totalCount = 0, items = emptyList())) + Mockito.`when`( + contentSeriesService.getRecommendSeriesList( + true, + ContentType.ALL, + member + ) + ).thenReturn(emptyList()) + + val response = controller.fetchData(member) + + assertTrue(response.success) + assertEquals(1, response.data?.banners?.size) + assertEquals("series-10", response.data?.banners?.first()?.seriesTitle) + Mockito.verify(bannerService).getDisplayBanners(pageable, Lang.JA) + Mockito.verify(bannerService, Mockito.never()).getActiveBanners(pageable) + } + + private fun createMember(id: Long): Member { + return Member( + email = "member-$id@test.com", + password = "password", + nickname = "member-$id" + ).also { + it.id = id + } + } + + private fun createSeries(id: Long): Series { + return Series( + title = "series-$id", + introduction = "introduction-$id", + languageCode = "ja" + ).also { + it.id = id + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerServiceTest.kt new file mode 100644 index 00000000..ce77f408 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerServiceTest.kt @@ -0,0 +1,100 @@ +package kr.co.vividnext.sodalive.content.series.main.banner + +import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository +import kr.co.vividnext.sodalive.creator.admin.content.series.Series +import kr.co.vividnext.sodalive.i18n.Lang +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest + +class ContentSeriesBannerServiceTest { + private lateinit var bannerRepository: SeriesBannerRepository + private lateinit var seriesRepository: AdminContentSeriesRepository + private lateinit var service: ContentSeriesBannerService + + @BeforeEach + fun setUp() { + bannerRepository = Mockito.mock(SeriesBannerRepository::class.java) + seriesRepository = Mockito.mock(AdminContentSeriesRepository::class.java) + service = ContentSeriesBannerService( + bannerRepository = bannerRepository, + seriesRepository = seriesRepository + ) + } + + @Test + @DisplayName("일본어 배너 등록 요청은 JA 언어값으로 저장한다") + fun shouldRegisterJapaneseBanner() { + val series = createSeries(id = 1L) + + Mockito.`when`(seriesRepository.findByIdAndActiveTrue(1L)).thenReturn(series) + Mockito.`when`(bannerRepository.findMaxSortOrder()).thenReturn(2) + Mockito.`when`(bannerRepository.save(Mockito.any(SeriesBanner::class.java))).thenAnswer { it.arguments[0] } + + val banner = service.registerBanner( + seriesId = 1L, + imagePath = "banner/jp.png", + lang = Lang.JA + ) + + assertEquals(Lang.JA, banner.lang) + assertEquals(3, banner.sortOrder) + assertEquals("banner/jp.png", banner.imagePath) + } + + @Test + @DisplayName("언어가 없는 배너 등록 요청은 KO로 저장한다") + fun shouldRegisterKoreanBannerWhenLangIsMissing() { + val series = createSeries(id = 2L) + + Mockito.`when`(seriesRepository.findByIdAndActiveTrue(2L)).thenReturn(series) + Mockito.`when`(bannerRepository.findMaxSortOrder()).thenReturn(null) + Mockito.`when`(bannerRepository.save(Mockito.any(SeriesBanner::class.java))).thenAnswer { it.arguments[0] } + + val banner = service.registerBanner( + seriesId = 2L, + imagePath = "banner/default.png", + lang = null + ) + + assertEquals(Lang.KO, banner.lang) + assertEquals(1, banner.sortOrder) + } + + @Test + @DisplayName("일본어 사용자는 일본어 배너만 조회한다") + fun shouldReturnJapaneseBannersForJapaneseUser() { + val pageable = PageRequest.of(0, 10) + val japaneseBanner = SeriesBanner( + imagePath = "banner/jp.png", + series = createSeries(id = 3L), + sortOrder = 1, + lang = Lang.JA + ) + val expectedPage = PageImpl(listOf(japaneseBanner), pageable, 1) + + Mockito.`when`(bannerRepository.findByIsActiveTrueAndLangOrderBySortOrderAsc(Lang.JA, pageable)) + .thenReturn(expectedPage) + + val actual = service.getDisplayBanners(pageable, Lang.JA) + + assertEquals(expectedPage, actual) + Mockito.verify(bannerRepository).findByIsActiveTrueAndLangOrderBySortOrderAsc(Lang.JA, pageable) + Mockito.verify(bannerRepository, Mockito.never()) + .findByIsActiveTrueOrderBySortOrderAsc(pageable) + } + + private fun createSeries(id: Long): Series { + return Series( + title = "series-$id", + introduction = "introduction-$id", + languageCode = "ko" + ).also { + it.id = id + } + } +}