feat(subbanner): 기간 조회와 소프트 삭제 저장소를 추가한다

This commit is contained in:
2026-10-29 23:20:38 +09:00
parent 5f6b8d4cf5
commit aa6863f4f9
5 changed files with 546 additions and 0 deletions
@@ -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)
)
}
}
}