feat(subbanner): 기간 조회와 소프트 삭제 저장소를 추가한다
This commit is contained in:
+400
@@ -0,0 +1,400 @@
|
||||
package kr.co.vividnext.sodalive.v2.subbanner.adapter.out.persistence
|
||||
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner
|
||||
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement
|
||||
import kr.co.vividnext.sodalive.v2.subbanner.port.out.SubBannerRepository
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertSame
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
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.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.Arguments
|
||||
import org.junit.jupiter.params.provider.EnumSource
|
||||
import org.junit.jupiter.params.provider.MethodSource
|
||||
import org.junit.jupiter.params.provider.ValueSource
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||
import org.springframework.context.annotation.Import
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.transaction.annotation.Propagation
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.sql.DriverManager
|
||||
import java.sql.SQLException
|
||||
import java.time.LocalDateTime
|
||||
import java.util.stream.Stream
|
||||
import javax.persistence.EntityManager
|
||||
import javax.persistence.EntityManagerFactory
|
||||
|
||||
@DataJpaTest(properties = ["spring.cache.type=none"])
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import(QueryDslConfig::class, DefaultSubBannerRepository::class)
|
||||
class SubBannerPersistenceTest @Autowired constructor(
|
||||
private val entityManager: EntityManager,
|
||||
private val repository: SubBannerRepository,
|
||||
private val entityManagerFactory: EntityManagerFactory,
|
||||
private val jdbcTemplate: JdbcTemplate
|
||||
) {
|
||||
@BeforeEach
|
||||
fun setSecondPrecisionColumns() {
|
||||
jdbcTemplate.execute("alter table sub_banner alter column start_date timestamp(0)")
|
||||
jdbcTemplate.execute("alter table sub_banner alter column end_date timestamp(0)")
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(ints = [0, 123456789, 999999999])
|
||||
@DisplayName("nullable UTC 기간은 저장 결과와 DB 재조회 모두 초 정밀도를 정확히 보존한다")
|
||||
fun shouldRoundTripNullableDatesAtSecondPrecision(nano: Int) {
|
||||
val start = LocalDateTime.of(2026, 10, 29, 23, 59, 0)
|
||||
val end = LocalDateTime.of(2026, 10, 29, 23, 59, 59)
|
||||
val midnight = LocalDateTime.of(2026, 10, 30, 0, 0, 0)
|
||||
for ((startDate, endDate) in listOf(null to null, start to null, null to end, start to end, midnight to midnight)) {
|
||||
val input = banner().copy(startDate = startDate?.withNano(nano), endDate = endDate?.withNano(nano))
|
||||
val saved = repository.save(input)
|
||||
flushAndClear()
|
||||
|
||||
val stored = entityManager.find(SubBannerEntity::class.java, saved.id)
|
||||
assertNotNull(stored)
|
||||
assertEquals(startDate, stored.startDate)
|
||||
assertEquals(endDate, stored.endDate)
|
||||
val expected = input.copy(id = saved.id, startDate = startDate, endDate = endDate)
|
||||
assertEquals(expected, repository.findById(requireNotNull(saved.id)))
|
||||
assertEquals(expected, saved)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("이미지 key와 긴 원문 URI 및 7개 링크 조합을 DB 왕복에서도 자르거나 변형하지 않는다")
|
||||
fun shouldRoundTripLongImageKeysAndSevenLinkCombinations() {
|
||||
val suffix = "a".repeat(300)
|
||||
for (mask in 1..7) {
|
||||
val input = banner().copy(
|
||||
imagePath = "sub-banner/$suffix.png",
|
||||
webUrl = if (mask and 1 != 0) "https://example.com/$suffix?next=%2Fhome#detail" else null,
|
||||
androidDeepLink = if (mask and 2 != 0) "sodalive://content/$suffix?next=%2Fhome" else null,
|
||||
iosDeepLink = if (mask and 4 != 0) "sodalive-ios://content/$suffix#detail" else null
|
||||
)
|
||||
val saved = repository.save(input)
|
||||
flushAndClear()
|
||||
|
||||
assertNotNull(saved.id)
|
||||
assertEquals(input.copy(id = saved.id), repository.findById(requireNotNull(saved.id)))
|
||||
assertEquals(
|
||||
"HOME_RECOMMENDATION",
|
||||
entityManager.createNativeQuery("select placement from sub_banner where id = :id")
|
||||
.setParameter("id", saved.id).singleResult
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(SubBannerPlacement::class)
|
||||
@DisplayName("관리자 목록은 다른 placement를 제외하고 모든 기간의 20개 초과 행을 id 오름차순으로 반환한다")
|
||||
fun shouldReturnAllPlacementRowsInIdOrderWithoutLimit(placement: SubBannerPlacement) {
|
||||
val now = LocalDateTime.of(2026, 10, 29, 23, 59, 59)
|
||||
assertEquals(emptyList<SubBanner>(), repository.findAllByPlacement(placement))
|
||||
assertEquals(emptyList<SubBanner>(), repository.findVisibleByPlacement(placement, now))
|
||||
val otherPlacement = SubBannerPlacement.values().first { it != placement }
|
||||
val saved = (1..25).map { index ->
|
||||
repository.save(banner(otherPlacement))
|
||||
repository.save(banner(placement).copy(imagePath = "sub-banner/$index.png"))
|
||||
}
|
||||
val future = repository.save(banner(placement).copy(startDate = now.plusSeconds(1)))
|
||||
val expired = repository.save(banner(placement).copy(endDate = now.minusSeconds(1)))
|
||||
flushAndClear()
|
||||
|
||||
assertEquals(saved + future + expired, repository.findAllByPlacement(placement))
|
||||
assertEquals(saved, repository.findVisibleByPlacement(placement, now))
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0}")
|
||||
@MethodSource("visibilityCases")
|
||||
@DisplayName("공개 목록은 nullable 기간의 양끝과 소수초 now 및 날짜 경계를 초 단위로 판정한다")
|
||||
fun shouldApplyInclusiveNullablePeriodAtSecondPrecision(
|
||||
name: String,
|
||||
now: LocalDateTime,
|
||||
startDate: LocalDateTime?,
|
||||
endDate: LocalDateTime?,
|
||||
visible: Boolean
|
||||
) {
|
||||
val candidate = repository.save(banner().copy(startDate = startDate, endDate = endDate))
|
||||
repository.save(banner(SubBannerPlacement.CONTENT_RECOMMENDATION))
|
||||
flushAndClear()
|
||||
|
||||
assertEquals(
|
||||
if (visible) listOf(candidate) else emptyList(),
|
||||
repository.findVisibleByPlacement(SubBannerPlacement.HOME_RECOMMENDATION, now),
|
||||
name
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("수정은 같은 id와 생성 시각을 보존하며 모든 필드 교체와 nullable 기간 해제를 저장한다")
|
||||
fun shouldUpdateSameRowAndClearNullableFields() {
|
||||
val saved = repository.save(banner().copy(startDate = LocalDateTime.of(2026, 10, 29, 23, 59, 0)))
|
||||
flushAndClear()
|
||||
val id = requireNotNull(saved.id)
|
||||
val createdAt = entityManager.find(SubBannerEntity::class.java, id).createdAt
|
||||
assertNotNull(createdAt)
|
||||
val replacement = saved.copy(
|
||||
placement = SubBannerPlacement.CONTENT_RECOMMENDATION,
|
||||
imagePath = "sub-banner/replacement.png",
|
||||
webUrl = null,
|
||||
androidDeepLink = "sodalive://replacement",
|
||||
iosDeepLink = "sodalive-ios://replacement",
|
||||
startDate = null,
|
||||
endDate = LocalDateTime.of(2026, 10, 30, 0, 0, 59, 999999999)
|
||||
)
|
||||
val updated = repository.save(replacement)
|
||||
flushAndClear()
|
||||
|
||||
val expected = replacement.copy(endDate = LocalDateTime.of(2026, 10, 30, 0, 0, 59))
|
||||
assertEquals(expected, updated)
|
||||
assertEquals(expected, repository.findById(id))
|
||||
assertEquals(emptyList<SubBanner>(), repository.findAllByPlacement(SubBannerPlacement.HOME_RECOMMENDATION))
|
||||
assertEquals(listOf(expected), repository.findAllByPlacement(SubBannerPlacement.CONTENT_RECOMMENDATION))
|
||||
val stored = entityManager.find(SubBannerEntity::class.java, id)
|
||||
assertEquals(createdAt, stored.createdAt)
|
||||
assertNotNull(stored.updatedAt)
|
||||
|
||||
val cleared = expected.copy(
|
||||
webUrl = "https://example.com/replacement",
|
||||
androidDeepLink = null,
|
||||
iosDeepLink = null,
|
||||
endDate = null
|
||||
)
|
||||
assertEquals(cleared, repository.save(cleared))
|
||||
flushAndClear()
|
||||
assertEquals(cleared, repository.findById(id))
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(SubBannerPlacement::class)
|
||||
@DisplayName("소프트 삭제는 모든 기간의 행과 생성 정보를 보존하고 모든 조회에서 제외한다")
|
||||
fun shouldRetainSoftDeletedRowsAndExcludeThemFromEveryRead(placement: SubBannerPlacement) {
|
||||
assertNull(repository.findById(Long.MAX_VALUE))
|
||||
val now = LocalDateTime.of(2026, 10, 29, 23, 59, 59)
|
||||
val periods = listOf(null to null, now.minusMinutes(1) to now, now.plusSeconds(1) to null, null to now.minusSeconds(1))
|
||||
val deleted = periods.map { (start, end) ->
|
||||
repository.save(
|
||||
banner(placement).copy(
|
||||
androidDeepLink = "sodalive://content/123?from=banner",
|
||||
iosDeepLink = "sodalive-ios://content/123#detail",
|
||||
startDate = start,
|
||||
endDate = end
|
||||
)
|
||||
)
|
||||
}
|
||||
val active = periods.map { (start, end) -> repository.save(banner(placement).copy(startDate = start, endDate = end)) }
|
||||
val other = repository.save(banner(SubBannerPlacement.values().first { it != placement }))
|
||||
flushAndClear()
|
||||
val createdAt = deleted.associate { it.id to entityManager.find(SubBannerEntity::class.java, it.id).createdAt }
|
||||
|
||||
deleted.forEach { repository.deleteById(requireNotNull(it.id)) }
|
||||
flushAndClear()
|
||||
|
||||
deleted.forEach { original ->
|
||||
val stored = entityManager.find(SubBannerEntity::class.java, original.id)
|
||||
assertNotNull(stored, "Soft delete must retain the original row")
|
||||
assertEquals(original, stored.toDomain())
|
||||
assertEquals(createdAt[original.id], stored.createdAt)
|
||||
assertNotNull(stored.updatedAt)
|
||||
assertEquals(
|
||||
false,
|
||||
entityManager.createNativeQuery("select is_active from sub_banner where id = :id")
|
||||
.setParameter("id", original.id).singleResult
|
||||
)
|
||||
assertNull(repository.findById(requireNotNull(original.id)))
|
||||
}
|
||||
active.forEach { assertEquals(it, repository.findById(requireNotNull(it.id))) }
|
||||
assertEquals(active, repository.findAllByPlacement(placement))
|
||||
assertEquals(active.take(2), repository.findVisibleByPlacement(placement, now))
|
||||
assertEquals(other, repository.findById(requireNotNull(other.id)))
|
||||
assertEquals(listOf(other), repository.findAllByPlacement(other.placement))
|
||||
assertEquals(listOf(other), repository.findVisibleByPlacement(other.placement, now))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("삭제 전 도메인 스냅샷 저장도 비활성 행을 다시 활성화하지 않는다")
|
||||
fun shouldNotReactivateDeletedRowWhenSavingStaleDomain() {
|
||||
val saved = repository.save(banner())
|
||||
flushAndClear()
|
||||
val id = requireNotNull(saved.id)
|
||||
repository.deleteById(id)
|
||||
flushAndClear()
|
||||
assertNotNull(entityManager.find(SubBannerEntity::class.java, id), "Soft delete must retain the original row")
|
||||
|
||||
repository.save(saved.copy(webUrl = "https://example.com/changed"))
|
||||
flushAndClear()
|
||||
|
||||
assertEquals(
|
||||
false,
|
||||
entityManager.createNativeQuery("select is_active from sub_banner where id = :id")
|
||||
.setParameter("id", id).singleResult
|
||||
)
|
||||
assertNull(repository.findById(id))
|
||||
assertEquals(emptyList<SubBanner>(), repository.findAllByPlacement(saved.placement))
|
||||
assertEquals(emptyList<SubBanner>(), repository.findVisibleByPlacement(saved.placement, LocalDateTime.now()))
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
@DisplayName("삭제 전 활성 managed entity를 유지한 수정 트랜잭션도 먼저 커밋된 소프트 삭제를 되돌리지 않는다")
|
||||
fun shouldNotReactivateDeletedRowWhenStaleManagedEntityCommits() {
|
||||
val now = LocalDateTime.of(2026, 10, 29, 23, 59, 0)
|
||||
val updateContext = entityManagerFactory.createEntityManager()
|
||||
val deleteContext = entityManagerFactory.createEntityManager()
|
||||
val readContext = entityManagerFactory.createEntityManager()
|
||||
var seededId: Long? = null
|
||||
try {
|
||||
val deleteRepository = DefaultSubBannerRepository(deleteContext, JPAQueryFactory(deleteContext))
|
||||
deleteContext.transaction.begin()
|
||||
val saved = deleteRepository.save(banner().copy(startDate = now, endDate = now.plusSeconds(59)))
|
||||
val id = requireNotNull(saved.id)
|
||||
seededId = id
|
||||
deleteContext.transaction.commit()
|
||||
|
||||
updateContext.transaction.begin()
|
||||
val updateRepository = DefaultSubBannerRepository(updateContext, JPAQueryFactory(updateContext))
|
||||
val existing = requireNotNull(updateRepository.findById(id))
|
||||
val staleEntity = updateContext.find(SubBannerEntity::class.java, id)
|
||||
val createdAt = staleEntity.createdAt
|
||||
assertTrue(staleEntity.isActive)
|
||||
|
||||
deleteContext.transaction.begin()
|
||||
deleteRepository.deleteById(id)
|
||||
println("CONCURRENCY EVIDENCE delete commit")
|
||||
deleteContext.transaction.commit()
|
||||
|
||||
assertTrue(staleEntity.isActive, "The update context must retain its stale active entity")
|
||||
assertSame(staleEntity, updateContext.find(SubBannerEntity::class.java, id))
|
||||
val replacement = existing.copy(webUrl = "https://example.com/concurrent-update")
|
||||
assertEquals(replacement, updateRepository.save(replacement))
|
||||
println("CONCURRENCY EVIDENCE stale payload commit")
|
||||
updateContext.transaction.commit()
|
||||
|
||||
readContext.transaction.begin()
|
||||
val stored = readContext.find(SubBannerEntity::class.java, id)
|
||||
assertNotNull(stored, "Concurrent update must preserve the soft-deleted row")
|
||||
val active = readContext.createNativeQuery("select is_active from sub_banner where id = :id")
|
||||
.setParameter("id", id).singleResult
|
||||
println(
|
||||
"CONCURRENCY DB EVIDENCE id=$id is_active=$active " +
|
||||
"payload=${stored.toDomain()} created_at=${stored.createdAt}"
|
||||
)
|
||||
assertEquals(false, active, "Stale managed payload update must not reactivate a committed soft delete")
|
||||
assertEquals(replacement, stored.toDomain())
|
||||
assertEquals(createdAt, stored.createdAt)
|
||||
val readRepository = DefaultSubBannerRepository(readContext, JPAQueryFactory(readContext))
|
||||
assertNull(readRepository.findById(id))
|
||||
assertEquals(emptyList<SubBanner>(), readRepository.findAllByPlacement(saved.placement))
|
||||
assertEquals(emptyList<SubBanner>(), readRepository.findVisibleByPlacement(saved.placement, now))
|
||||
readContext.transaction.commit()
|
||||
} finally {
|
||||
listOf(updateContext, deleteContext, readContext).forEach { context ->
|
||||
if (context.transaction.isActive) context.transaction.rollback()
|
||||
context.close()
|
||||
}
|
||||
seededId?.let { id ->
|
||||
val removed = jdbcTemplate.update("delete from sub_banner where id = ?", id)
|
||||
println("CONCURRENCY EVIDENCE fixture cleanup rows=$removed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ORM 생성 기본값은 true이며 활성 컬럼은 null을 허용하지 않는다")
|
||||
fun shouldCreateActiveRowsWithNonNullableFlag() {
|
||||
val saved = repository.save(banner())
|
||||
flushAndClear()
|
||||
|
||||
val columns = entityManager.createNativeQuery(
|
||||
"select count(*) from INFORMATION_SCHEMA.COLUMNS " +
|
||||
"where TABLE_NAME = 'sub_banner' and COLUMN_NAME = 'is_active' and IS_NULLABLE = 'NO'"
|
||||
).singleResult as Number
|
||||
assertEquals(1, columns.toInt(), "ORM schema must contain a non-nullable is_active column")
|
||||
assertEquals(
|
||||
true,
|
||||
entityManager.createNativeQuery("select is_active from sub_banner where id = :id")
|
||||
.setParameter("id", saved.id).singleResult
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("작성한 CREATE DDL은 실제 실행 시 활성 기본값 true와 NOT NULL을 보장한다")
|
||||
fun shouldApplyActiveDefaultAndNotNullFromAuthoredCreateDdl() {
|
||||
DriverManager.getConnection(
|
||||
"jdbc:h2:mem:sub-banner-soft-delete-ddl;MODE=MySQL;DATABASE_TO_UPPER=false"
|
||||
).use { connection ->
|
||||
connection.createStatement().use { statement ->
|
||||
statement.execute(Files.readString(Path.of("docs/20261029_가로형서브배너/create-sub-banner.sql")))
|
||||
connection.metaData.getColumns(null, null, "sub_banner", "is_active").use { columns ->
|
||||
assertTrue(columns.next(), "CREATE DDL must contain is_active")
|
||||
assertEquals("NO", columns.getString("IS_NULLABLE"))
|
||||
}
|
||||
statement.executeUpdate(
|
||||
"insert into sub_banner (placement, image_path, web_url) " +
|
||||
"values ('HOME_RECOMMENDATION', 'sub-banner/image.png', 'https://example.com/banner')"
|
||||
)
|
||||
statement.executeQuery("select is_active from sub_banner").use { rows ->
|
||||
assertTrue(rows.next())
|
||||
assertTrue(rows.getBoolean("is_active"))
|
||||
}
|
||||
assertThrows(SQLException::class.java) {
|
||||
statement.executeUpdate("update sub_banner set is_active = null")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun banner(placement: SubBannerPlacement = SubBannerPlacement.HOME_RECOMMENDATION): SubBanner = SubBanner(
|
||||
placement = placement,
|
||||
imagePath = "sub-banner/image.png",
|
||||
webUrl = "https://example.com/banner",
|
||||
androidDeepLink = null,
|
||||
iosDeepLink = null,
|
||||
startDate = null,
|
||||
endDate = null
|
||||
)
|
||||
|
||||
private fun flushAndClear() {
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun visibilityCases(): Stream<Arguments> {
|
||||
val now = LocalDateTime.of(2026, 10, 29, 23, 59, 59)
|
||||
val before = LocalDateTime.of(2026, 10, 29, 23, 59, 58)
|
||||
val midnight = LocalDateTime.of(2026, 10, 30, 0, 0, 0)
|
||||
return Stream.of(
|
||||
Arguments.of("unbounded", now, null, null, true),
|
||||
Arguments.of("start-before", now, before, null, true),
|
||||
Arguments.of("start-exact", now, now, null, true),
|
||||
Arguments.of("start-after", now, midnight, null, false),
|
||||
Arguments.of("end-before", now, null, before, false),
|
||||
Arguments.of("end-exact", now, null, now, true),
|
||||
Arguments.of("end-after", now, null, midnight, true),
|
||||
Arguments.of("inside", now, before, midnight, true),
|
||||
Arguments.of("before-range", now, midnight, midnight.plusSeconds(1), false),
|
||||
Arguments.of("after-range", now, before.minusSeconds(1), before, false),
|
||||
Arguments.of("both-exact", now, now, now, true),
|
||||
Arguments.of("end-second-123456789", now.withNano(123456789), before, now, true),
|
||||
Arguments.of("end-second-999999999", now.withNano(999999999), before, now, true),
|
||||
Arguments.of("next-second-not-started", now.withNano(999999999), midnight, null, false),
|
||||
Arguments.of("midnight-start-exact", midnight, midnight, null, true),
|
||||
Arguments.of("midnight-expired", midnight, null, now, false)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user