From aa6863f4f98ef9a0720e0f1a82983afb25c2db11 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 29 Oct 2026 23:20:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(subbanner):=20=EA=B8=B0=EA=B0=84=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EC=99=80=20=EC=86=8C=ED=94=84=ED=8A=B8=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=A0=80=EC=9E=A5=EC=86=8C=EB=A5=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../create-sub-banner.sql | 15 + .../persistence/DefaultSubBannerRepository.kt | 73 ++++ .../out/persistence/SubBannerEntity.kt | 45 ++ .../subbanner/port/out/SubBannerRepository.kt | 13 + .../persistence/SubBannerPersistenceTest.kt | 400 ++++++++++++++++++ 5 files changed, 546 insertions(+) create mode 100644 docs/20261029_가로형서브배너/create-sub-banner.sql create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/DefaultSubBannerRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerEntity.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/port/out/SubBannerRepository.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerPersistenceTest.kt diff --git a/docs/20261029_가로형서브배너/create-sub-banner.sql b/docs/20261029_가로형서브배너/create-sub-banner.sql new file mode 100644 index 00000000..6b2247dd --- /dev/null +++ b/docs/20261029_가로형서브배너/create-sub-banner.sql @@ -0,0 +1,15 @@ +CREATE TABLE sub_banner ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '서브 배너 ID', + placement VARCHAR(32) NOT NULL COMMENT '노출 위치: HOME_RECOMMENDATION, CONTENT_RECOMMENDATION', + image_path LONGTEXT NOT NULL COMMENT '배너 이미지 S3 object key', + web_url LONGTEXT NULL COMMENT '웹 이동 URL', + android_deep_link LONGTEXT NULL COMMENT 'Android 이동 딥링크', + ios_deep_link LONGTEXT NULL COMMENT 'iOS 이동 딥링크', + start_date TIMESTAMP NULL DEFAULT NULL COMMENT '노출 시작 시각 (UTC 초 단위, NULL이면 시작 제한 없음)', + end_date TIMESTAMP NULL DEFAULT NULL COMMENT '노출 종료 시각 (UTC 초 단위, NULL이면 종료 제한 없음)', + is_active BOOLEAN NOT NULL DEFAULT TRUE COMMENT '활성 여부 (false이면 삭제)', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각', + PRIMARY KEY (id), + INDEX idx_sub_banner_placement_id (placement, id) +) COMMENT = '가로형 서브 배너'; diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/DefaultSubBannerRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/DefaultSubBannerRepository.kt new file mode 100644 index 00000000..75dc42f6 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/DefaultSubBannerRepository.kt @@ -0,0 +1,73 @@ +package kr.co.vividnext.sodalive.v2.subbanner.adapter.out.persistence + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.v2.subbanner.adapter.out.persistence.QSubBannerEntity.subBannerEntity +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.springframework.stereotype.Repository +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit +import javax.persistence.EntityManager + +@Repository +class DefaultSubBannerRepository( + private val entityManager: EntityManager, + private val queryFactory: JPAQueryFactory +) : SubBannerRepository { + override fun findById(id: Long): SubBanner? = entityManager.find(SubBannerEntity::class.java, id) + ?.takeIf { it.isActive }?.toDomain() + + override fun findAllByPlacement(placement: SubBannerPlacement): List = queryFactory + .selectFrom(subBannerEntity) + .where(subBannerEntity.placement.eq(placement), subBannerEntity.isActive.isTrue) + .orderBy(subBannerEntity.id.asc()) + .fetch() + .map { it.toDomain() } + + override fun findVisibleByPlacement(placement: SubBannerPlacement, now: LocalDateTime): List { + val visibleAt = now.truncatedTo(ChronoUnit.SECONDS) + return queryFactory + .selectFrom(subBannerEntity) + .where( + subBannerEntity.placement.eq(placement), + subBannerEntity.isActive.isTrue, + subBannerEntity.startDate.isNull.or(subBannerEntity.startDate.loe(visibleAt)), + subBannerEntity.endDate.isNull.or(subBannerEntity.endDate.goe(visibleAt)) + ) + .orderBy(subBannerEntity.id.asc()) + .fetch() + .map { it.toDomain() } + } + + override fun save(subBanner: SubBanner): SubBanner { + val startDate = subBanner.startDate?.truncatedTo(ChronoUnit.SECONDS) + val endDate = subBanner.endDate?.truncatedTo(ChronoUnit.SECONDS) + val entity = if (subBanner.id == null) { + SubBannerEntity( + subBanner.placement, + subBanner.imagePath, + subBanner.webUrl, + subBanner.androidDeepLink, + subBanner.iosDeepLink, + startDate, + endDate + ).also { entityManager.persist(it) } + } else { + entityManager.find(SubBannerEntity::class.java, subBanner.id).apply { + placement = subBanner.placement + imagePath = subBanner.imagePath + webUrl = subBanner.webUrl + androidDeepLink = subBanner.androidDeepLink + iosDeepLink = subBanner.iosDeepLink + this.startDate = startDate + this.endDate = endDate + } + } + return entity.toDomain() + } + + override fun deleteById(id: Long) { + entityManager.find(SubBannerEntity::class.java, id).isActive = false + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerEntity.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerEntity.kt new file mode 100644 index 00000000..fbb32014 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerEntity.kt @@ -0,0 +1,45 @@ +package kr.co.vividnext.sodalive.v2.subbanner.adapter.out.persistence + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement +import org.hibernate.annotations.DynamicUpdate +import java.time.LocalDateTime +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated +import javax.persistence.Index +import javax.persistence.Table + +@Entity +@DynamicUpdate +@Table(name = "sub_banner", indexes = [Index(name = "idx_sub_banner_placement_id", columnList = "placement, id")]) +class SubBannerEntity( + @Enumerated(EnumType.STRING) + @Column(name = "placement", nullable = false, length = 32) + var placement: SubBannerPlacement, + + @Column(name = "image_path", nullable = false, columnDefinition = "LONGTEXT") + var imagePath: String, + + @Column(name = "web_url", columnDefinition = "LONGTEXT") + var webUrl: String?, + + @Column(name = "android_deep_link", columnDefinition = "LONGTEXT") + var androidDeepLink: String?, + + @Column(name = "ios_deep_link", columnDefinition = "LONGTEXT") + var iosDeepLink: String?, + + @Column(name = "start_date", columnDefinition = "TIMESTAMP") + var startDate: LocalDateTime?, + + @Column(name = "end_date", columnDefinition = "TIMESTAMP") + var endDate: LocalDateTime?, + + @Column(name = "is_active", nullable = false) + var isActive: Boolean = true +) : BaseEntity() { + fun toDomain(): SubBanner = SubBanner(id, placement, imagePath, webUrl, androidDeepLink, iosDeepLink, startDate, endDate) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/port/out/SubBannerRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/port/out/SubBannerRepository.kt new file mode 100644 index 00000000..7325b1ac --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/port/out/SubBannerRepository.kt @@ -0,0 +1,13 @@ +package kr.co.vividnext.sodalive.v2.subbanner.port.out + +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement +import java.time.LocalDateTime + +interface SubBannerRepository { + fun findById(id: Long): SubBanner? + fun findAllByPlacement(placement: SubBannerPlacement): List + fun findVisibleByPlacement(placement: SubBannerPlacement, now: LocalDateTime): List + fun save(subBanner: SubBanner): SubBanner + fun deleteById(id: Long) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerPersistenceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerPersistenceTest.kt new file mode 100644 index 00000000..d351194e --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerPersistenceTest.kt @@ -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(), repository.findAllByPlacement(placement)) + assertEquals(emptyList(), 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(), 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(), repository.findAllByPlacement(saved.placement)) + assertEquals(emptyList(), 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(), readRepository.findAllByPlacement(saved.placement)) + assertEquals(emptyList(), 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 { + 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) + ) + } + } +}