test #449

Merged
klaus merged 29 commits from test into main 2026-09-10 07:58:32 +00:00
4 changed files with 319 additions and 23 deletions
Showing only changes of commit dba70483ad - Show all commits
@@ -11,6 +11,7 @@ import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
import kr.co.vividnext.sodalive.content.main.banner.QAudioContentBanner.audioContentBanner
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
import kr.co.vividnext.sodalive.event.EventItem
@@ -38,6 +39,7 @@ import javax.persistence.EntityManager
class DefaultAudioRecommendationQueryRepository(
private val queryFactory: JPAQueryFactory,
private val entityManager: EntityManager,
private val contentTranslationRepository: ContentTranslationRepository,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) : AudioRecommendationQueryRepository {
@@ -115,32 +117,35 @@ class DefaultAudioRecommendationQueryRepository(
limit: Int,
memberId: Long?,
canViewAdultContent: Boolean,
now: LocalDateTime
now: LocalDateTime,
locale: String
): List<AudioCard> {
val rows = audioRows(memberId, canViewAdultContent, now) {
orderBy(audioContent.releaseDate.desc(), audioContent.id.desc()).limit(limit.toLong())
}
return rows.toAudioCards(now, canViewAdultContent)
return rows.toAudioCards(now, canViewAdultContent, locale)
}
override fun findFreeAudios(
limit: Int,
memberId: Long?,
canViewAdultContent: Boolean,
now: LocalDateTime
now: LocalDateTime,
locale: String
): List<AudioCard> {
val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')")
val rows = audioRows(memberId, canViewAdultContent, now, audioContent.price.eq(0)) {
orderBy(randomTieBreaker.asc()).limit(limit.toLong())
}
return rows.toAudioCards(now, canViewAdultContent)
return rows.toAudioCards(now, canViewAdultContent, locale)
}
override fun findPointAudios(
limit: Int,
memberId: Long?,
canViewAdultContent: Boolean,
now: LocalDateTime
now: LocalDateTime,
locale: String
): List<AudioCard> {
val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')")
val rows = audioRows(
@@ -151,25 +156,27 @@ class DefaultAudioRecommendationQueryRepository(
) {
orderBy(randomTieBreaker.asc()).limit(limit.toLong())
}
return rows.toAudioCards(now, canViewAdultContent)
return rows.toAudioCards(now, canViewAdultContent, locale)
}
override fun findAudioCardsByIds(
contentIds: List<Long>,
memberId: Long?,
canViewAdultContent: Boolean,
now: LocalDateTime
now: LocalDateTime,
locale: String
): List<AudioCard> {
if (contentIds.isEmpty()) return emptyList()
val orderById = contentIds.withIndex().associate { it.value to it.index }
val rows = audioRows(memberId, canViewAdultContent, now, audioContent.id.`in`(contentIds)) { this }
return rows.toAudioCards(now, canViewAdultContent).sortedBy { orderById[it.audioContentId] ?: Int.MAX_VALUE }
return rows.toAudioCards(now, canViewAdultContent, locale).sortedBy { orderById[it.audioContentId] ?: Int.MAX_VALUE }
}
override fun findCommentedAudiosByIds(
contentIds: List<Long>,
memberId: Long?,
canViewAdultContent: Boolean
canViewAdultContent: Boolean,
locale: String
): List<CommentedAudio> {
if (contentIds.isEmpty()) return emptyList()
val contentOrder = contentIds.withIndex().associate { it.value to it.index }
@@ -235,7 +242,7 @@ class DefaultAudioRecommendationQueryRepository(
)
)
""".trimIndent()
return entityManager.createNativeQuery(sql)
val commentedAudios = entityManager.createNativeQuery(sql)
.setParameter("contentIds", contentIds)
.setParameter("memberId", memberId)
.setParameter("canViewAdultContent", canViewAdultContent)
@@ -251,6 +258,11 @@ class DefaultAudioRecommendationQueryRepository(
?: "$cloudFrontHost/profile/default-profile.png"
)
}
val translatedTitleByContentId = translatedTitles(commentedAudios.map { it.audioContentId }, locale)
return commentedAudios
.map { audio ->
translatedTitleByContentId[audio.audioContentId]?.let { audio.copy(title = it) } ?: audio
}
.sortedBy { contentOrder[it.audioContentId] ?: Int.MAX_VALUE }
}
@@ -455,18 +467,23 @@ class DefaultAudioRecommendationQueryRepository(
.fetch()
}
private fun List<Tuple>.toAudioCards(now: LocalDateTime, canViewAdultContent: Boolean): List<AudioCard> {
private fun List<Tuple>.toAudioCards(
now: LocalDateTime,
canViewAdultContent: Boolean,
locale: String
): List<AudioCard> {
if (isEmpty()) return emptyList()
val contentIds = map { it.get(audioContent.id)!! }
val creatorIds = map { it.get(audioContent.member.id)!! }.distinct()
val firstContentIdByCreatorId = firstAudioContentIds(creatorIds, now, canViewAdultContent)
val isOriginalSeriesByContentId = originalSeriesFlags(contentIds)
val translatedTitleByContentId = translatedTitles(contentIds, locale)
return map { row ->
val contentId = row.get(audioContent.id)!!
val creatorId = row.get(audioContent.member.id)!!
AudioCard(
audioContentId = contentId,
title = row.get(audioContent.title)!!,
title = translatedTitleByContentId[contentId] ?: row.get(audioContent.title)!!,
duration = row.get(audioContent.duration),
imageUrl = row.get(audioContent.coverImage).toCdnUrl(cloudFrontHost),
price = row.get(audioContent.price)!!,
@@ -479,6 +496,17 @@ class DefaultAudioRecommendationQueryRepository(
}
}
private fun translatedTitles(contentIds: List<Long>, locale: String): Map<Long, String> {
if (contentIds.isEmpty()) return emptyMap()
return contentTranslationRepository.findByContentIdInAndLocale(contentIds, locale)
.mapNotNull { translation ->
translation.renderedPayload.title
.takeIf { it.isNotBlank() }
?.let { translation.contentId to it }
}
.toMap()
}
private fun firstAudioContentIds(
creatorIds: List<Long>,
now: LocalDateTime,
@@ -2,6 +2,8 @@ package kr.co.vividnext.sodalive.v2.api.content.overview.adapter.`in`.web
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.content.translation.ContentTranslation
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole
@@ -93,6 +95,44 @@ class ContentOverviewEndToEndTest @Autowired constructor(
.andExpect(jsonPath("$.data.hasNext").value(false))
}
@Test
@DisplayName("콘텐츠 전체보기 New & Hot은 같은 요청에서 en-ja-en 제목 번역을 격리한다")
fun shouldIsolateRequestedLocaleTitlesForNewAndHotAudioOverview() {
val fixture = createNewAndHotFixture("content-overview-new-hot")
saveContentTranslation(fixture.audioContentId, "en", "content-overview-new-hot-en-translated")
saveContentTranslation(fixture.audioContentId, "ja", "content-overview-new-hot-ja-translated")
expectOverviewTitle(fixture, "NEW_AND_HOT_AUDIO", "en", "content-overview-new-hot-en-translated")
expectOverviewTitle(fixture, "NEW_AND_HOT_AUDIO", "ja", "content-overview-new-hot-ja-translated")
expectOverviewTitle(fixture, "NEW_AND_HOT_AUDIO", "en", "content-overview-new-hot-en-translated")
}
@Test
@DisplayName("콘텐츠 전체보기 첫 오디오는 같은 요청에서 en-ja-en 제목 번역을 격리한다")
fun shouldIsolateRequestedLocaleTitlesForFirstAudioContentOverview() {
val fixture = createFirstAudioFixture("content-overview-first")
saveContentTranslation(fixture.audioContentId, "en", "content-overview-first-en-translated")
saveContentTranslation(fixture.audioContentId, "ja", "content-overview-first-ja-translated")
expectOverviewTitle(fixture, "FIRST_AUDIO_CONTENT", "en", "content-overview-first-en-translated")
expectOverviewTitle(fixture, "FIRST_AUDIO_CONTENT", "ja", "content-overview-first-ja-translated")
expectOverviewTitle(fixture, "FIRST_AUDIO_CONTENT", "en", "content-overview-first-en-translated")
}
private fun expectOverviewTitle(fixture: Fixture, type: String, locale: String, title: String) {
mockMvc.perform(
get("/api/v2/contents")
.param("type", type)
.param("page", "0")
.param("size", "20")
.header("Accept-Language", locale)
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.items[0].contentId").value(fixture.audioContentId))
.andExpect(jsonPath("$.data.items[0].title").value(title))
}
@Test
@DisplayName("콘텐츠 전체보기 API는 유효하지 않은 type을 New & Hot으로 대체한다")
fun shouldFallbackInvalidTypeToNewAndHotAudio() {
@@ -197,6 +237,24 @@ class ContentOverviewEndToEndTest @Autowired constructor(
)
}
private fun saveContentTranslation(contentId: Long, locale: String, title: String) {
transactionTemplate.execute {
val translation = ContentTranslation(
contentId = contentId,
locale = locale,
renderedPayload = ContentTranslationPayload(title = title, detail = "", tags = "")
)
entityManager.persist(translation)
entityManager.flush()
val payload = "{\"title\":\"$title\",\"detail\":\"\",\"tags\":\"\"}"
entityManager.createNativeQuery(
"update content_translation set rendered_payload = '$payload' format json where id = :id"
)
.setParameter("id", translation.id)
.executeUpdate()
}
}
private data class Fixture(
val viewer: Member,
val audioContentId: Long
@@ -4,6 +4,8 @@ import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.comment.AudioContentComment
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.content.translation.ContentTranslation
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
import kr.co.vividnext.sodalive.member.Member
@@ -54,9 +56,11 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
.andExpect(jsonPath("$.data.latestAudios[0].isOriginalSeries").value(true))
.andExpect(jsonPath("$.data.recommendedAudios").isArray)
.andExpect(jsonPath("$.data.recommendedAudios[0].audioContentId").value(fixture.audioContentId))
.andExpect(jsonPath("$.data.pointAudios.length()").value(1))
.andExpect(jsonPath("$.data.pointAudios[0].audioContentId").value(fixture.paidPointAudioContentId))
.andExpect(jsonPath("$.data.pointAudios[0].price").value(100))
.andExpect(
jsonPath(
"$.data.pointAudios[?(@.audioContentId == ${fixture.paidPointAudioContentId} && @.price == 100)]"
).isNotEmpty
)
.andExpect(jsonPath("$.data.mostCommentedAudios[0].latestComment").value("latest e2e comment"))
.andExpect(
jsonPath("$.data.mostCommentedAudios[0].latestCommentWriterProfileImageUrl")
@@ -64,6 +68,51 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
)
}
@Test
@DisplayName("오디오 추천 API는 같은 요청에서 en-ja-en 제목 번역을 격리한다")
fun shouldIsolateRequestedLocaleTitlesForAllRecommendationAudioCards() {
val fixture = createFixture()
transactionTemplate.execute {
saveContentTranslation(fixture.audioContentId, "en", "audio-recommendation-e2e-en")
saveContentTranslation(fixture.audioContentId, "ja", "audio-recommendation-e2e-ja")
saveContentTranslation(fixture.paidPointAudioContentId, "en", "paid-point-en")
saveContentTranslation(fixture.paidPointAudioContentId, "ja", "paid-point-ja")
entityManager.flush()
}
expectRecommendationTitles(fixture, "en", "audio-recommendation-e2e-en", "paid-point-en")
expectRecommendationTitles(fixture, "ja", "audio-recommendation-e2e-ja", "paid-point-ja")
expectRecommendationTitles(fixture, "en", "audio-recommendation-e2e-en", "paid-point-en")
}
private fun expectRecommendationTitles(
fixture: Fixture,
locale: String,
audioTitle: String,
pointAudioTitle: String
) {
mockMvc.perform(get("/api/v2/audio/recommendations").header("Accept-Language", locale))
.andExpect(status().isOk)
.andExpect(
jsonPath(translatedTitlePath("latestAudios", fixture.audioContentId, audioTitle)).isNotEmpty
)
.andExpect(
jsonPath(translatedTitlePath("newAndHotAudios", fixture.audioContentId, audioTitle)).isNotEmpty
)
.andExpect(
jsonPath(translatedTitlePath("freeAudios", fixture.audioContentId, audioTitle)).isNotEmpty
)
.andExpect(
jsonPath(translatedTitlePath("pointAudios", fixture.paidPointAudioContentId, pointAudioTitle)).isNotEmpty
)
.andExpect(
jsonPath(translatedTitlePath("mostCommentedAudios", fixture.audioContentId, audioTitle)).isNotEmpty
)
.andExpect(
jsonPath(translatedTitlePath("recommendedAudios", fixture.audioContentId, audioTitle)).isNotEmpty
)
}
private fun createFixture(): Fixture {
return transactionTemplate.execute {
val now = LocalDateTime.now().minusHours(1)
@@ -183,6 +232,26 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
)
}
private fun saveContentTranslation(contentId: Long, locale: String, title: String) {
val translation = ContentTranslation(
contentId = contentId,
locale = locale,
renderedPayload = ContentTranslationPayload(title = title, detail = "", tags = "")
)
entityManager.persist(translation)
entityManager.flush()
val payload = "{\"title\":\"$title\",\"detail\":\"\",\"tags\":\"\"}"
entityManager.createNativeQuery(
"update content_translation set rendered_payload = '$payload' format json where id = :id"
)
.setParameter("id", translation.id)
.executeUpdate()
}
private fun translatedTitlePath(collection: String, contentId: Long, title: String): String {
return "$.data.$collection[?(@.audioContentId == $contentId && @.title == '$title')]"
}
private data class Fixture(
val seriesId: Long,
val audioContentId: Long,
@@ -10,6 +10,9 @@ import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.content.translation.ContentTranslation
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
import kr.co.vividnext.sodalive.i18n.Lang
@@ -38,9 +41,15 @@ import javax.persistence.EntityManager
@Import(QueryDslConfig::class)
class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
private val entityManager: EntityManager,
queryFactory: JPAQueryFactory
queryFactory: JPAQueryFactory,
contentTranslationRepository: ContentTranslationRepository
) {
private val repository = DefaultAudioRecommendationQueryRepository(queryFactory, entityManager, "https://cdn.test")
private val repository = DefaultAudioRecommendationQueryRepository(
queryFactory,
entityManager,
contentTranslationRepository,
"https://cdn.test"
)
@Test
@DisplayName("배너는 콘텐츠 추천 탭과 성인 조회 가능 여부를 적용한다")
@@ -210,9 +219,27 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
}
flushAndClear()
val latestAudios = repository.findLatestAudios(12, viewer.id, canViewAdultContent = false, now = now)
val freeAudios = repository.findFreeAudios(10, viewer.id, canViewAdultContent = false, now = now)
val pointAudios = repository.findPointAudios(10, viewer.id, canViewAdultContent = false, now = now)
val latestAudios = repository.findLatestAudios(
12,
viewer.id,
canViewAdultContent = false,
now = now,
locale = Lang.KO.code
)
val freeAudios = repository.findFreeAudios(
10,
viewer.id,
canViewAdultContent = false,
now = now,
locale = Lang.KO.code
)
val pointAudios = repository.findPointAudios(
10,
viewer.id,
canViewAdultContent = false,
now = now,
locale = Lang.KO.code
)
assertEquals(12, latestAudios.size)
assertEquals(listOf(latest.id, paidPoint.id), latestAudios.take(2).map { it.audioContentId })
@@ -297,7 +324,8 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
val commented = repository.findCommentedAudiosByIds(
contentIds = listOf(first.id!!, second.id!!),
memberId = viewer.id,
canViewAdultContent = false
canViewAdultContent = false,
locale = Lang.KO.code
)
assertEquals(listOf(first.id), snapshots.map { it.targetId })
@@ -308,6 +336,101 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
assertEquals("https://cdn.test/commented.png", commented.first().imageUrl)
}
@Test
@DisplayName("오디오 카드 공통 QueryDSL 경로는 빈 문자열, 공백, 누락, 다른 언어 번역을 원문으로 대체한다")
fun shouldTranslateAudioCardsByRequestedLocaleWithFallbackAndInputOrder() {
val now = LocalDateTime.of(2026, 6, 23, 12, 0)
val creator = saveMember("translated-card-creator", MemberRole.CREATOR)
val theme = saveTheme()
val translated = saveAudio(creator, theme, "translated-source", now.minusDays(1))
val empty = saveAudio(creator, theme, "empty-source", now.minusDays(2))
val whitespace = saveAudio(creator, theme, "whitespace-source", now.minusDays(3))
val otherLocaleOnly = saveAudio(creator, theme, "other-locale-source", now.minusDays(4))
val missing = saveAudio(creator, theme, "missing-source", now.minusDays(5))
saveContentTranslation(translated, "en", "translated-en")
saveContentTranslation(translated, "ja", "translated-ja")
saveContentTranslation(empty, "en", "")
saveContentTranslation(whitespace, "en", " ")
saveContentTranslation(otherLocaleOnly, "ja", "other-locale-ja")
flushAndClear()
val contentIds = listOf(missing.id!!, empty.id!!, whitespace.id!!, otherLocaleOnly.id!!, translated.id!!)
val englishCards = repository.findAudioCardsByIds(contentIds, null, false, now, Lang.EN.code)
val japaneseCards = repository.findAudioCardsByIds(contentIds, null, false, now, Lang.JA.code)
assertEquals(contentIds, englishCards.map { it.audioContentId })
assertEquals(
listOf("missing-source", "empty-source", "whitespace-source", "other-locale-source", "translated-en"),
englishCards.map { it.title }
)
assertEquals(contentIds, japaneseCards.map { it.audioContentId })
assertEquals(
listOf("missing-source", "empty-source", "whitespace-source", "other-locale-ja", "translated-ja"),
japaneseCards.map { it.title }
)
}
@Test
@DisplayName("최근 댓글 많은 오디오 native query는 locale별 제목만 바꾸고 fallback과 댓글 필드를 보존한다")
fun shouldTranslateCommentedAudiosByRequestedLocaleWithFallbackAndPreservedFields() {
val now = LocalDateTime.now().minusHours(1)
val creator = saveMember("translated-commented-creator", MemberRole.CREATOR)
val writer = saveMember("translated-commented-writer", MemberRole.USER)
val theme = saveTheme()
val translated = saveAudio(creator, theme, "commented-translated-source", now.minusMinutes(1))
val empty = saveAudio(creator, theme, "commented-empty-source", now.minusMinutes(2))
val whitespace = saveAudio(creator, theme, "commented-whitespace-source", now.minusMinutes(3))
val otherLocaleOnly = saveAudio(creator, theme, "commented-other-locale-source", now.minusMinutes(4))
val missing = saveAudio(creator, theme, "commented-missing-source", now.minusMinutes(5))
saveComment(translated, writer, "translated-comment", now)
saveComment(empty, writer, "empty-comment", now)
saveComment(whitespace, writer, "whitespace-comment", now)
saveComment(otherLocaleOnly, writer, "other-locale-comment", now)
saveComment(missing, writer, "missing-comment", now)
saveContentTranslation(translated, "en", "commented-translated-en")
saveContentTranslation(translated, "ja", "commented-translated-ja")
saveContentTranslation(empty, "en", "")
saveContentTranslation(whitespace, "en", " ")
saveContentTranslation(otherLocaleOnly, "ja", "commented-other-locale-ja")
flushAndClear()
val contentIds = listOf(missing.id!!, empty.id!!, whitespace.id!!, otherLocaleOnly.id!!, translated.id!!)
val englishAudios = repository.findCommentedAudiosByIds(contentIds, null, false, Lang.EN.code)
val japaneseAudios = repository.findCommentedAudiosByIds(contentIds, null, false, Lang.JA.code)
assertEquals(contentIds, englishAudios.map { it.audioContentId })
assertEquals(contentIds, japaneseAudios.map { it.audioContentId })
assertEquals(
listOf(
"commented-missing-source",
"commented-empty-source",
"commented-whitespace-source",
"commented-other-locale-source",
"commented-translated-en"
),
englishAudios.map { it.title }
)
assertEquals(
listOf(
"commented-missing-source",
"commented-empty-source",
"commented-whitespace-source",
"commented-other-locale-ja",
"commented-translated-ja"
),
japaneseAudios.map { it.title }
)
assertEquals(
listOf("missing-comment", "empty-comment", "whitespace-comment", "other-locale-comment", "translated-comment"),
englishAudios.map { it.latestComment }
)
assertEquals(englishAudios.map { it.latestComment }, japaneseAudios.map { it.latestComment })
assertEquals(
englishAudios.map { it.latestCommentWriterProfileImageUrl },
japaneseAudios.map { it.latestCommentWriterProfileImageUrl }
)
}
@Test
@DisplayName("최근 댓글 많은 오디오 스냅샷은 같은 크리에이터 후보 중 최고 점수 1개만 반환한다")
fun shouldKeepOnlyHighestScoredMostCommentedSnapshotPerCreator() {
@@ -356,7 +479,8 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
val commented = repository.findCommentedAudiosByIds(
contentIds = listOf(audio.id!!),
memberId = viewer.id,
canViewAdultContent = false
canViewAdultContent = false,
locale = Lang.KO.code
)
assertEquals(1, commented.size)
@@ -381,7 +505,8 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
val commented = repository.findCommentedAudiosByIds(
contentIds = listOf(audio.id!!),
memberId = viewer.id,
canViewAdultContent = false
canViewAdultContent = false,
locale = Lang.KO.code
)
assertEquals(1, commented.size)
@@ -507,6 +632,22 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
return audio
}
private fun saveContentTranslation(content: AudioContent, locale: String, title: String) {
val translation = ContentTranslation(
contentId = content.id!!,
locale = locale,
renderedPayload = ContentTranslationPayload(title = title, detail = "", tags = "")
)
entityManager.persist(translation)
entityManager.flush()
val payload = "{\"title\":\"$title\",\"detail\":\"\",\"tags\":\"\"}"
entityManager.createNativeQuery(
"update content_translation set rendered_payload = '$payload' format json where id = :id"
)
.setParameter("id", translation.id)
.executeUpdate()
}
private fun saveView(audio: AudioContent, viewedAt: LocalDateTime) {
entityManager.persist(
CreatorContentViewHistory(memberId = 1L, contentId = audio.id!!, genreId = 1L, viewedAt = viewedAt)