Test #462

Merged
klaus merged 13 commits from test into main 2026-10-30 03:15:43 +00:00
5 changed files with 546 additions and 0 deletions
Showing only changes of commit aa6863f4f9 - Show all commits
@@ -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 = '가로형 서브 배너';
@@ -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<SubBanner> = 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<SubBanner> {
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
}
}
@@ -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)
}
@@ -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<SubBanner>
fun findVisibleByPlacement(placement: SubBannerPlacement, now: LocalDateTime): List<SubBanner>
fun save(subBanner: SubBanner): SubBanner
fun deleteById(id: Long)
}
@@ -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)
)
}
}
}