fixing web-app
This commit is contained in:
+26
@@ -0,0 +1,26 @@
|
||||
package at.mocode.horses.service
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.runApplication
|
||||
import org.springframework.context.annotation.ComponentScan
|
||||
|
||||
/**
|
||||
* Main application class for the Horses Service.
|
||||
*
|
||||
* This service provides APIs for managing horses and their data.
|
||||
*/
|
||||
@SpringBootApplication
|
||||
@ComponentScan(basePackages = [
|
||||
"at.mocode.horses.service",
|
||||
"at.mocode.horses.api",
|
||||
"at.mocode.horses.infrastructure",
|
||||
"at.mocode.infrastructure.messaging"
|
||||
])
|
||||
class HorsesServiceApplication
|
||||
|
||||
/**
|
||||
* Main entry point for the Horses Service application.
|
||||
*/
|
||||
fun main(args: Array<String>) {
|
||||
runApplication<HorsesServiceApplication>(*args)
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package at.mocode.horses.service.config
|
||||
|
||||
import at.mocode.horses.application.usecase.CreateHorseUseCase
|
||||
import at.mocode.horses.application.usecase.TransactionalCreateHorseUseCase
|
||||
import at.mocode.horses.application.usecase.UpdateHorseUseCase
|
||||
import at.mocode.horses.application.usecase.DeleteHorseUseCase
|
||||
import at.mocode.horses.application.usecase.GetHorseUseCase
|
||||
import at.mocode.horses.domain.repository.HorseRepository
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
|
||||
/**
|
||||
* Application configuration for the Horses Service.
|
||||
*
|
||||
* This configuration wires the use cases as Spring beans.
|
||||
*/
|
||||
@Configuration
|
||||
class ApplicationConfiguration {
|
||||
|
||||
/**
|
||||
* Creates the CreateHorseUseCase as a Spring bean.
|
||||
*/
|
||||
@Bean
|
||||
fun createHorseUseCase(horseRepository: HorseRepository): CreateHorseUseCase {
|
||||
return CreateHorseUseCase(horseRepository)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the TransactionalCreateHorseUseCase as a Spring bean.
|
||||
* This version ensures all database operations run within a single transaction.
|
||||
*/
|
||||
@Bean
|
||||
fun transactionalCreateHorseUseCase(horseRepository: HorseRepository): TransactionalCreateHorseUseCase {
|
||||
return TransactionalCreateHorseUseCase(horseRepository)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the UpdateHorseUseCase as a Spring bean.
|
||||
*/
|
||||
@Bean
|
||||
fun updateHorseUseCase(horseRepository: HorseRepository): UpdateHorseUseCase {
|
||||
return UpdateHorseUseCase(horseRepository)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the DeleteHorseUseCase as a Spring bean.
|
||||
*/
|
||||
@Bean
|
||||
fun deleteHorseUseCase(horseRepository: HorseRepository): DeleteHorseUseCase {
|
||||
return DeleteHorseUseCase(horseRepository)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the GetHorseUseCase as a Spring bean.
|
||||
*/
|
||||
@Bean
|
||||
fun getHorseUseCase(horseRepository: HorseRepository): GetHorseUseCase {
|
||||
return GetHorseUseCase(horseRepository)
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package at.mocode.horses.service.config
|
||||
|
||||
import at.mocode.core.utils.database.DatabaseConfig
|
||||
import at.mocode.core.utils.database.DatabaseFactory
|
||||
import at.mocode.horses.infrastructure.persistence.HorseTable
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.context.annotation.Profile
|
||||
import org.springframework.stereotype.Component
|
||||
import jakarta.annotation.PostConstruct
|
||||
import jakarta.annotation.PreDestroy
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.jetbrains.exposed.sql.SchemaUtils
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
|
||||
/**
|
||||
* Database configuration for the Horses Service.
|
||||
*
|
||||
* This configuration ensures that Database.connect() is called properly
|
||||
* before any Exposed operations are performed.
|
||||
*/
|
||||
@Configuration
|
||||
@Profile("!test")
|
||||
class HorsesDatabaseConfiguration {
|
||||
|
||||
private val log = LoggerFactory.getLogger(HorsesDatabaseConfiguration::class.java)
|
||||
|
||||
@PostConstruct
|
||||
fun initializeDatabase() {
|
||||
log.info("Initializing database schema for Horses Service...")
|
||||
|
||||
try {
|
||||
// Database connection is already initialized by the gateway
|
||||
// Only initialize the schema for this service
|
||||
transaction {
|
||||
SchemaUtils.createMissingTablesAndColumns(HorseTable)
|
||||
log.info("Horse database schema initialized successfully")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log.error("Failed to initialize database schema", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
fun closeDatabase() {
|
||||
log.info("Closing database connection for Horses Service...")
|
||||
try {
|
||||
DatabaseFactory.close()
|
||||
log.info("Database connection closed successfully")
|
||||
} catch (e: Exception) {
|
||||
log.error("Error closing database connection", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-specific database configuration.
|
||||
*/
|
||||
@Configuration
|
||||
@Profile("test")
|
||||
class HorsesTestDatabaseConfiguration {
|
||||
|
||||
private val log = LoggerFactory.getLogger(HorsesTestDatabaseConfiguration::class.java)
|
||||
|
||||
@PostConstruct
|
||||
fun initializeTestDatabase() {
|
||||
log.info("Initializing test database connection for Horses Service...")
|
||||
|
||||
try {
|
||||
// Use H2 in-memory database for tests
|
||||
val testConfig = DatabaseConfig(
|
||||
jdbcUrl = "jdbc:h2:mem:horses_test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE",
|
||||
username = "sa",
|
||||
password = "",
|
||||
driverClassName = "org.h2.Driver",
|
||||
maxPoolSize = 5,
|
||||
minPoolSize = 1,
|
||||
autoMigrate = true
|
||||
)
|
||||
|
||||
DatabaseFactory.init(testConfig)
|
||||
log.info("Test database connection initialized successfully")
|
||||
|
||||
// Initialize database schema for tests
|
||||
transaction {
|
||||
SchemaUtils.createMissingTablesAndColumns(HorseTable)
|
||||
log.info("Test horse database schema initialized successfully")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log.error("Failed to initialize test database connection", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
fun closeTestDatabase() {
|
||||
log.info("Closing test database connection for Horses Service...")
|
||||
try {
|
||||
DatabaseFactory.close()
|
||||
log.info("Test database connection closed successfully")
|
||||
} catch (e: Exception) {
|
||||
log.error("Error closing test database connection", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
package at.mocode.horses.service.integration
|
||||
|
||||
import at.mocode.horses.domain.model.DomPferd
|
||||
import at.mocode.horses.domain.repository.HorseRepository
|
||||
import at.mocode.infrastructure.messaging.client.EventPublisher
|
||||
import at.mocode.core.domain.model.PferdeGeschlechtE
|
||||
import io.mockk.mockk
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.test.context.ActiveProfiles
|
||||
import org.springframework.test.context.ContextConfiguration
|
||||
import org.springframework.test.context.TestPropertySource
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Integration tests for the Horses Service.
|
||||
*
|
||||
* These tests verify the complete functionality including:
|
||||
* - Repository operations
|
||||
* - Database persistence
|
||||
* - Domain model validation
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@ActiveProfiles("test")
|
||||
@TestPropertySource(properties = [
|
||||
"spring.datasource.url=jdbc:h2:mem:testdb",
|
||||
"spring.kafka.bootstrap-servers=localhost:9092"
|
||||
])
|
||||
@ContextConfiguration(classes = [HorseServiceIntegrationTest.TestConfig::class])
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class HorseServiceIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private lateinit var horseRepository: HorseRepository
|
||||
|
||||
@Configuration
|
||||
class TestConfig {
|
||||
@Bean
|
||||
fun eventPublisher(): EventPublisher = mockk(relaxed = true)
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() = runBlocking {
|
||||
// Clean up database before each test
|
||||
println("[DEBUG_LOG] Setting up horse test - cleaning database")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should create horse successfully`() = runBlocking {
|
||||
println("[DEBUG_LOG] Testing horse creation")
|
||||
|
||||
// Given
|
||||
val horse = DomPferd(
|
||||
pferdeName = "Thunder",
|
||||
geschlecht = PferdeGeschlechtE.WALLACH,
|
||||
geburtsdatum = LocalDate(2020, 5, 15),
|
||||
rasse = "Warmblut",
|
||||
farbe = "Braun",
|
||||
lebensnummer = "AT123456789",
|
||||
chipNummer = "123456789012345",
|
||||
stockmass = 165,
|
||||
istAktiv = true
|
||||
)
|
||||
|
||||
// When
|
||||
val savedHorse = horseRepository.save(horse)
|
||||
|
||||
// Then
|
||||
assertNotNull(savedHorse)
|
||||
assertEquals("Thunder", savedHorse.pferdeName)
|
||||
assertEquals(PferdeGeschlechtE.WALLACH, savedHorse.geschlecht)
|
||||
assertEquals("AT123456789", savedHorse.lebensnummer)
|
||||
assertEquals("123456789012345", savedHorse.chipNummer)
|
||||
assertEquals("Warmblut", savedHorse.rasse)
|
||||
assertTrue(savedHorse.istAktiv)
|
||||
|
||||
println("[DEBUG_LOG] Horse created successfully with ID: ${savedHorse.pferdId}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should find horse by lebensnummer`() = runBlocking {
|
||||
println("[DEBUG_LOG] Testing find horse by lebensnummer")
|
||||
|
||||
// Given
|
||||
val horse = DomPferd(
|
||||
pferdeName = "Lightning",
|
||||
geschlecht = PferdeGeschlechtE.STUTE,
|
||||
geburtsdatum = LocalDate(2019, 3, 10),
|
||||
rasse = "Vollblut",
|
||||
farbe = "Schimmel",
|
||||
lebensnummer = "AT987654321",
|
||||
chipNummer = "987654321098765",
|
||||
stockmass = 160,
|
||||
istAktiv = true
|
||||
)
|
||||
horseRepository.save(horse)
|
||||
|
||||
// When
|
||||
val foundHorse = horseRepository.findByLebensnummer("AT987654321")
|
||||
|
||||
// Then
|
||||
assertNotNull(foundHorse)
|
||||
assertEquals("Lightning", foundHorse.pferdeName)
|
||||
assertEquals("AT987654321", foundHorse.lebensnummer)
|
||||
assertEquals(PferdeGeschlechtE.STUTE, foundHorse.geschlecht)
|
||||
assertEquals("Vollblut", foundHorse.rasse)
|
||||
|
||||
println("[DEBUG_LOG] Horse found by lebensnummer: ${foundHorse.pferdId}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should find horse by chip number`() = runBlocking {
|
||||
println("[DEBUG_LOG] Testing find horse by chip number")
|
||||
|
||||
// Given
|
||||
val horse = DomPferd(
|
||||
pferdeName = "Storm",
|
||||
geschlecht = PferdeGeschlechtE.HENGST,
|
||||
geburtsdatum = LocalDate(2021, 8, 20),
|
||||
rasse = "Haflinger",
|
||||
farbe = "Fuchs",
|
||||
lebensnummer = "AT555666777",
|
||||
chipNummer = "555666777888999",
|
||||
stockmass = 150,
|
||||
istAktiv = true
|
||||
)
|
||||
horseRepository.save(horse)
|
||||
|
||||
// When
|
||||
val foundHorse = horseRepository.findByChipNummer("555666777888999")
|
||||
|
||||
// Then
|
||||
assertNotNull(foundHorse)
|
||||
assertEquals("Storm", foundHorse.pferdeName)
|
||||
assertEquals("555666777888999", foundHorse.chipNummer)
|
||||
assertEquals(PferdeGeschlechtE.HENGST, foundHorse.geschlecht)
|
||||
assertEquals("Haflinger", foundHorse.rasse)
|
||||
|
||||
println("[DEBUG_LOG] Horse found by chip number: ${foundHorse.pferdId}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should find horses by gender`() = runBlocking {
|
||||
println("[DEBUG_LOG] Testing find horses by gender")
|
||||
|
||||
// Given
|
||||
val stallion = DomPferd(
|
||||
pferdeName = "Stallion Horse",
|
||||
geschlecht = PferdeGeschlechtE.HENGST,
|
||||
geburtsdatum = LocalDate(2018, 4, 12),
|
||||
rasse = "Warmblut",
|
||||
farbe = "Braun",
|
||||
lebensnummer = "AT111222333",
|
||||
chipNummer = "111222333444555",
|
||||
stockmass = 170,
|
||||
istAktiv = true
|
||||
)
|
||||
|
||||
val mare = DomPferd(
|
||||
pferdeName = "Mare Horse",
|
||||
geschlecht = PferdeGeschlechtE.STUTE,
|
||||
geburtsdatum = LocalDate(2017, 6, 8),
|
||||
rasse = "Vollblut",
|
||||
farbe = "Rappe",
|
||||
lebensnummer = "AT444555666",
|
||||
chipNummer = "444555666777888",
|
||||
stockmass = 165,
|
||||
istAktiv = true
|
||||
)
|
||||
|
||||
horseRepository.save(stallion)
|
||||
horseRepository.save(mare)
|
||||
|
||||
// When
|
||||
val stallions = horseRepository.findByGeschlecht(PferdeGeschlechtE.HENGST, true, 10)
|
||||
|
||||
// Then
|
||||
assertTrue(stallions.isNotEmpty(), "Should find at least one stallion")
|
||||
assertTrue(stallions.any { it.pferdeName == "Stallion Horse" }, "Should contain the stallion horse")
|
||||
assertTrue(stallions.all { it.geschlecht == PferdeGeschlechtE.HENGST }, "All returned horses should be stallions")
|
||||
|
||||
println("[DEBUG_LOG] Found ${stallions.size} stallions")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should find horses by breed`() = runBlocking {
|
||||
println("[DEBUG_LOG] Testing find horses by breed")
|
||||
|
||||
// Given
|
||||
val warmblutHorse = DomPferd(
|
||||
pferdeName = "Warmblut Horse",
|
||||
geschlecht = PferdeGeschlechtE.WALLACH,
|
||||
geburtsdatum = LocalDate(2019, 9, 15),
|
||||
rasse = "Warmblut",
|
||||
farbe = "Braun",
|
||||
lebensnummer = "AT333444555",
|
||||
chipNummer = "333444555666777",
|
||||
stockmass = 168,
|
||||
istAktiv = true
|
||||
)
|
||||
horseRepository.save(warmblutHorse)
|
||||
|
||||
// When
|
||||
val warmblutHorses = horseRepository.findByRasse("Warmblut", true, 10)
|
||||
|
||||
// Then
|
||||
assertTrue(warmblutHorses.isNotEmpty(), "Should find at least one Warmblut horse")
|
||||
assertTrue(warmblutHorses.any { it.pferdeName == "Warmblut Horse" }, "Should contain the Warmblut horse")
|
||||
assertTrue(warmblutHorses.all { it.rasse == "Warmblut" }, "All returned horses should be Warmblut")
|
||||
|
||||
println("[DEBUG_LOG] Found ${warmblutHorses.size} Warmblut horses")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should find OEPS registered horses`() = runBlocking {
|
||||
println("[DEBUG_LOG] Testing find OEPS registered horses")
|
||||
|
||||
// Given
|
||||
val oepsHorse = DomPferd(
|
||||
pferdeName = "OEPS Horse",
|
||||
geschlecht = PferdeGeschlechtE.WALLACH,
|
||||
geburtsdatum = LocalDate(2018, 7, 22),
|
||||
rasse = "Warmblut",
|
||||
farbe = "Braun",
|
||||
lebensnummer = "AT777888999",
|
||||
chipNummer = "777888999000111",
|
||||
oepsNummer = "OEPS123456",
|
||||
stockmass = 170,
|
||||
istAktiv = true
|
||||
)
|
||||
|
||||
val nonOepsHorse = DomPferd(
|
||||
pferdeName = "Non-OEPS Horse",
|
||||
geschlecht = PferdeGeschlechtE.STUTE,
|
||||
geburtsdatum = LocalDate(2017, 11, 5),
|
||||
rasse = "Vollblut",
|
||||
farbe = "Rappe",
|
||||
lebensnummer = "AT000111222",
|
||||
chipNummer = "000111222333444",
|
||||
stockmass = 165,
|
||||
istAktiv = true
|
||||
)
|
||||
|
||||
horseRepository.save(oepsHorse)
|
||||
horseRepository.save(nonOepsHorse)
|
||||
|
||||
// When
|
||||
val oepsHorses = horseRepository.findOepsRegistered(true)
|
||||
|
||||
// Then
|
||||
assertTrue(oepsHorses.isNotEmpty(), "Should find at least one OEPS registered horse")
|
||||
assertTrue(oepsHorses.any { it.pferdeName == "OEPS Horse" }, "Should contain the OEPS registered horse")
|
||||
assertTrue(oepsHorses.all { !it.oepsNummer.isNullOrBlank() }, "All returned horses should have OEPS numbers")
|
||||
|
||||
println("[DEBUG_LOG] Found ${oepsHorses.size} OEPS registered horses")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should find FEI registered horses`() = runBlocking {
|
||||
println("[DEBUG_LOG] Testing find FEI registered horses")
|
||||
|
||||
// Given
|
||||
val feiHorse = DomPferd(
|
||||
pferdeName = "FEI Horse",
|
||||
geschlecht = PferdeGeschlechtE.HENGST,
|
||||
geburtsdatum = LocalDate(2016, 2, 14),
|
||||
rasse = "Warmblut",
|
||||
farbe = "Schimmel",
|
||||
lebensnummer = "AT999000111",
|
||||
chipNummer = "999000111222333",
|
||||
feiNummer = "FEI789012",
|
||||
stockmass = 175,
|
||||
istAktiv = true
|
||||
)
|
||||
horseRepository.save(feiHorse)
|
||||
|
||||
// When
|
||||
val feiHorses = horseRepository.findFeiRegistered(true)
|
||||
|
||||
// Then
|
||||
assertTrue(feiHorses.isNotEmpty(), "Should find at least one FEI registered horse")
|
||||
assertTrue(feiHorses.any { it.pferdeName == "FEI Horse" }, "Should contain the FEI registered horse")
|
||||
assertTrue(feiHorses.all { !it.feiNummer.isNullOrBlank() }, "All returned horses should have FEI numbers")
|
||||
|
||||
println("[DEBUG_LOG] Found ${feiHorses.size} FEI registered horses")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should validate duplicate lebensnummer`() = runBlocking {
|
||||
println("[DEBUG_LOG] Testing duplicate lebensnummer validation")
|
||||
|
||||
// Given
|
||||
val horse = DomPferd(
|
||||
pferdeName = "First Horse",
|
||||
geschlecht = PferdeGeschlechtE.WALLACH,
|
||||
geburtsdatum = LocalDate(2019, 1, 1),
|
||||
rasse = "Warmblut",
|
||||
farbe = "Braun",
|
||||
lebensnummer = "AT123123123",
|
||||
chipNummer = "123123123456789",
|
||||
stockmass = 165,
|
||||
istAktiv = true
|
||||
)
|
||||
horseRepository.save(horse)
|
||||
|
||||
// When
|
||||
val exists = horseRepository.existsByLebensnummer("AT123123123")
|
||||
|
||||
// Then
|
||||
assertTrue(exists, "Should detect existing lebensnummer")
|
||||
|
||||
println("[DEBUG_LOG] Duplicate lebensnummer validation passed")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should validate duplicate chip number`() = runBlocking {
|
||||
println("[DEBUG_LOG] Testing duplicate chip number validation")
|
||||
|
||||
// Given
|
||||
val horse = DomPferd(
|
||||
pferdeName = "Chip Test Horse",
|
||||
geschlecht = PferdeGeschlechtE.STUTE,
|
||||
geburtsdatum = LocalDate(2020, 12, 25),
|
||||
rasse = "Haflinger",
|
||||
farbe = "Fuchs",
|
||||
lebensnummer = "AT456456456",
|
||||
chipNummer = "456456456789012",
|
||||
stockmass = 148,
|
||||
istAktiv = true
|
||||
)
|
||||
horseRepository.save(horse)
|
||||
|
||||
// When
|
||||
val exists = horseRepository.existsByChipNummer("456456456789012")
|
||||
|
||||
// Then
|
||||
assertTrue(exists, "Should detect existing chip number")
|
||||
|
||||
println("[DEBUG_LOG] Duplicate chip number validation passed")
|
||||
}
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
package at.mocode.horses.service.integration
|
||||
|
||||
import at.mocode.horses.domain.model.DomPferd
|
||||
import at.mocode.horses.domain.repository.HorseRepository
|
||||
import at.mocode.core.domain.model.PferdeGeschlechtE
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.datetime.LocalDate
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.test.context.ActiveProfiles
|
||||
import org.springframework.test.context.TestPropertySource
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
/**
|
||||
* Integration tests to demonstrate and verify transaction context issues with coroutines.
|
||||
*
|
||||
* This test class reproduces the race condition that can occur when multiple
|
||||
* coroutines perform database operations without proper transaction boundaries.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@ActiveProfiles("test")
|
||||
@TestPropertySource(properties = [
|
||||
"spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE",
|
||||
"spring.jpa.hibernate.ddl-auto=create-drop"
|
||||
])
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class TransactionContextTest {
|
||||
|
||||
@Autowired
|
||||
private lateinit var horseRepository: HorseRepository
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
runBlocking {
|
||||
// Clean up any existing test data
|
||||
// Note: This is a simplified cleanup - in a real scenario you'd have proper cleanup
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should demonstrate race condition without transaction boundaries`(): Unit = runBlocking {
|
||||
println("[DEBUG_LOG] Starting race condition test")
|
||||
|
||||
val lebensnummer = "TEST-RACE-001"
|
||||
val chipNummer = "CHIP-RACE-001"
|
||||
|
||||
// Create two horses with the same identifiers
|
||||
val horse1 = DomPferd(
|
||||
pferdeName = "Race Horse 1",
|
||||
geschlecht = PferdeGeschlechtE.WALLACH,
|
||||
geburtsdatum = LocalDate(2020, 1, 1),
|
||||
lebensnummer = lebensnummer,
|
||||
chipNummer = chipNummer,
|
||||
istAktiv = true
|
||||
)
|
||||
|
||||
val horse2 = DomPferd(
|
||||
pferdeName = "Race Horse 2",
|
||||
geschlecht = PferdeGeschlechtE.STUTE,
|
||||
geburtsdatum = LocalDate(2020, 1, 2),
|
||||
lebensnummer = lebensnummer, // Same lebensnummer - should cause conflict
|
||||
chipNummer = chipNummer, // Same chipNummer - should cause conflict
|
||||
istAktiv = true
|
||||
)
|
||||
|
||||
println("[DEBUG_LOG] Created horses with duplicate identifiers")
|
||||
|
||||
// Simulate the use case logic: check uniqueness then save
|
||||
// This mimics what CreateHorseUseCase.execute() does without transactions
|
||||
suspend fun createHorseWithChecks(horse: DomPferd): Boolean {
|
||||
return try {
|
||||
// Check uniqueness constraints (like in checkUniquenessConstraints)
|
||||
val existsByLebensnummer = horse.lebensnummer?.let {
|
||||
horseRepository.existsByLebensnummer(it)
|
||||
} ?: false
|
||||
|
||||
val existsByChipNummer = horse.chipNummer?.let {
|
||||
horseRepository.existsByChipNummer(it)
|
||||
} ?: false
|
||||
|
||||
println("[DEBUG_LOG] ${horse.pferdeName}: existsByLebensnummer=$existsByLebensnummer, existsByChipNummer=$existsByChipNummer")
|
||||
|
||||
if (existsByLebensnummer || existsByChipNummer) {
|
||||
println("[DEBUG_LOG] ${horse.pferdeName}: Uniqueness check failed")
|
||||
false
|
||||
} else {
|
||||
// Save the horse (like in the use case)
|
||||
horseRepository.save(horse)
|
||||
println("[DEBUG_LOG] ${horse.pferdeName}: Saved successfully")
|
||||
true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("[DEBUG_LOG] ${horse.pferdeName}: Exception during creation: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// Launch two concurrent coroutines to create horses
|
||||
val results = listOf(
|
||||
async {
|
||||
println("[DEBUG_LOG] Starting creation 1")
|
||||
createHorseWithChecks(horse1)
|
||||
},
|
||||
async {
|
||||
println("[DEBUG_LOG] Starting creation 2")
|
||||
createHorseWithChecks(horse2)
|
||||
}
|
||||
).awaitAll()
|
||||
|
||||
println("[DEBUG_LOG] Both operations completed")
|
||||
println("[DEBUG_LOG] Result 1 success: ${results[0]}")
|
||||
println("[DEBUG_LOG] Result 2 success: ${results[1]}")
|
||||
|
||||
// In a properly transactional system, exactly one should succeed
|
||||
val successCount = results.count { it }
|
||||
val failureCount = results.count { !it }
|
||||
|
||||
println("[DEBUG_LOG] Success count: $successCount, Failure count: $failureCount")
|
||||
|
||||
// Check what actually got saved in the database
|
||||
val savedByLebensnummer = horseRepository.findByLebensnummer(lebensnummer)
|
||||
val savedByChipNummer = horseRepository.findByChipNummer(chipNummer)
|
||||
|
||||
println("[DEBUG_LOG] Found by lebensnummer: ${savedByLebensnummer?.pferdeName}")
|
||||
println("[DEBUG_LOG] Found by chipNummer: ${savedByChipNummer?.pferdeName}")
|
||||
|
||||
// This test demonstrates the issue - without transactions, both operations might succeed
|
||||
// due to race conditions, or the behavior might be unpredictable
|
||||
// The fix should ensure exactly one succeeds and one fails with a proper error
|
||||
assertTrue(successCount >= 1, "At least one operation should succeed")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should demonstrate transaction context propagation issue`(): Unit = runBlocking {
|
||||
println("[DEBUG_LOG] Starting transaction context propagation test")
|
||||
|
||||
// This test will show that without @Transactional, each repository call
|
||||
// runs in its own transaction context, which can lead to inconsistencies
|
||||
|
||||
val horse = DomPferd(
|
||||
pferdeName = "Transaction Test Horse",
|
||||
geschlecht = PferdeGeschlechtE.HENGST,
|
||||
lebensnummer = "TRANS-TEST-001",
|
||||
istAktiv = true
|
||||
)
|
||||
|
||||
println("[DEBUG_LOG] Creating horse with repository operations")
|
||||
|
||||
// Simulate multiple repository operations that should be atomic
|
||||
val existsCheck = horseRepository.existsByLebensnummer("TRANS-TEST-001")
|
||||
println("[DEBUG_LOG] Exists check result: $existsCheck")
|
||||
|
||||
if (!existsCheck) {
|
||||
val savedHorse = horseRepository.save(horse)
|
||||
println("[DEBUG_LOG] Horse saved successfully: ${savedHorse.pferdeName}")
|
||||
assertNotNull(savedHorse)
|
||||
assertEquals("Transaction Test Horse", savedHorse.pferdeName)
|
||||
}
|
||||
|
||||
// The issue is that without @Transactional, if an exception occurs after
|
||||
// the uniqueness checks but before/during save, the database state
|
||||
// might be inconsistent
|
||||
val finalCheck = horseRepository.findByLebensnummer("TRANS-TEST-001")
|
||||
assertNotNull(finalCheck, "Horse should be saved in database")
|
||||
}
|
||||
}
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
package at.mocode.horses.service.integration
|
||||
|
||||
import at.mocode.horses.application.usecase.TransactionalCreateHorseUseCase
|
||||
import at.mocode.horses.domain.repository.HorseRepository
|
||||
import at.mocode.core.domain.model.PferdeGeschlechtE
|
||||
import com.benasher44.uuid.uuid4
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.datetime.LocalDate
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.test.context.ActiveProfiles
|
||||
import org.springframework.test.context.TestPropertySource
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
/**
|
||||
* Integration tests to verify that transaction context issues with coroutines are resolved.
|
||||
*
|
||||
* This test class verifies that the transactional use cases properly handle
|
||||
* concurrent operations and maintain data consistency.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@ActiveProfiles("test")
|
||||
@TestPropertySource(properties = [
|
||||
"spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE",
|
||||
"spring.jpa.hibernate.ddl-auto=create-drop"
|
||||
])
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class TransactionalContextTest {
|
||||
|
||||
@Autowired
|
||||
private lateinit var horseRepository: HorseRepository
|
||||
|
||||
@Autowired
|
||||
private lateinit var transactionalCreateHorseUseCase: TransactionalCreateHorseUseCase
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
runBlocking {
|
||||
// Clean up any existing test data
|
||||
// Note: This is a simplified cleanup - in a real scenario you'd have proper cleanup
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should handle race condition properly with transaction boundaries`(): Unit = runBlocking {
|
||||
println("[DEBUG_LOG] Starting transactional race condition test")
|
||||
|
||||
val lebensnummer = "TRANS-RACE-001"
|
||||
val chipNummer = "TRANS-CHIP-001"
|
||||
|
||||
// Create two identical horse creation requests
|
||||
val ownerId = uuid4()
|
||||
val request1 = TransactionalCreateHorseUseCase.CreateHorseRequest(
|
||||
pferdeName = "Transactional Race Horse 1",
|
||||
geschlecht = PferdeGeschlechtE.WALLACH,
|
||||
geburtsdatum = LocalDate(2020, 1, 1),
|
||||
lebensnummer = lebensnummer,
|
||||
chipNummer = chipNummer,
|
||||
besitzerId = ownerId
|
||||
)
|
||||
|
||||
val request2 = TransactionalCreateHorseUseCase.CreateHorseRequest(
|
||||
pferdeName = "Transactional Race Horse 2",
|
||||
geschlecht = PferdeGeschlechtE.STUTE,
|
||||
geburtsdatum = LocalDate(2020, 1, 2),
|
||||
lebensnummer = lebensnummer, // Same lebensnummer - should cause conflict
|
||||
chipNummer = chipNummer, // Same chipNummer - should cause conflict
|
||||
besitzerId = ownerId
|
||||
)
|
||||
|
||||
println("[DEBUG_LOG] Created requests with duplicate identifiers")
|
||||
|
||||
// Launch two concurrent coroutines to create horses using transactional use case
|
||||
val results = listOf(
|
||||
async {
|
||||
println("[DEBUG_LOG] Starting transactional creation 1")
|
||||
transactionalCreateHorseUseCase.execute(request1)
|
||||
},
|
||||
async {
|
||||
println("[DEBUG_LOG] Starting transactional creation 2")
|
||||
transactionalCreateHorseUseCase.execute(request2)
|
||||
}
|
||||
).awaitAll()
|
||||
|
||||
println("[DEBUG_LOG] Both transactional operations completed")
|
||||
println("[DEBUG_LOG] Result 1 success: ${results[0].success}")
|
||||
println("[DEBUG_LOG] Result 2 success: ${results[1].success}")
|
||||
|
||||
// With proper transaction boundaries, exactly one should succeed
|
||||
val successCount = results.count { it.success }
|
||||
val failureCount = results.count { !it.success }
|
||||
|
||||
println("[DEBUG_LOG] Success count: $successCount, Failure count: $failureCount")
|
||||
|
||||
// Verify that exactly one operation succeeded and one failed
|
||||
assertEquals(1, successCount, "Exactly one operation should succeed with proper transactions")
|
||||
assertEquals(1, failureCount, "Exactly one operation should fail with proper transactions")
|
||||
|
||||
// Check what actually got saved in the database
|
||||
val savedByLebensnummer = horseRepository.findByLebensnummer(lebensnummer)
|
||||
val savedByChipNummer = horseRepository.findByChipNummer(chipNummer)
|
||||
|
||||
println("[DEBUG_LOG] Found by lebensnummer: ${savedByLebensnummer?.pferdeName}")
|
||||
println("[DEBUG_LOG] Found by chipNummer: ${savedByChipNummer?.pferdeName}")
|
||||
|
||||
// Verify that exactly one horse was saved
|
||||
assertNotNull(savedByLebensnummer, "One horse should be saved with the lebensnummer")
|
||||
assertNotNull(savedByChipNummer, "One horse should be saved with the chipNummer")
|
||||
assertEquals(savedByLebensnummer?.pferdId, savedByChipNummer?.pferdId, "Both queries should return the same horse")
|
||||
|
||||
// Verify that the failed operation returned proper error
|
||||
val failedResult = results.find { !it.success }
|
||||
assertNotNull(failedResult, "There should be one failed result")
|
||||
assertEquals("UNIQUENESS_ERROR", failedResult?.error?.code, "Failed operation should return uniqueness error")
|
||||
|
||||
println("[DEBUG_LOG] Transactional test completed successfully - race condition properly handled")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should maintain transaction consistency on validation failure`(): Unit = runBlocking {
|
||||
println("[DEBUG_LOG] Starting transaction consistency test")
|
||||
|
||||
// Create a request with invalid data that will fail validation
|
||||
val request = TransactionalCreateHorseUseCase.CreateHorseRequest(
|
||||
pferdeName = "", // Empty name should fail validation
|
||||
geschlecht = PferdeGeschlechtE.HENGST,
|
||||
lebensnummer = "VALIDATION-TEST-001",
|
||||
stockmass = 300, // Invalid height should fail validation
|
||||
besitzerId = uuid4() // Add owner to pass basic validation
|
||||
)
|
||||
|
||||
println("[DEBUG_LOG] Executing transactional create with invalid data")
|
||||
val result = transactionalCreateHorseUseCase.execute(request)
|
||||
|
||||
println("[DEBUG_LOG] Creation result: success=${result.success}")
|
||||
|
||||
// Verify that the operation failed due to validation
|
||||
assertTrue(!result.success, "Operation should fail due to validation errors")
|
||||
assertEquals("VALIDATION_ERROR", result.error?.code, "Should return validation error")
|
||||
|
||||
// Verify that no horse was saved in the database
|
||||
val savedHorse = horseRepository.findByLebensnummer("VALIDATION-TEST-001")
|
||||
assertTrue(savedHorse == null, "No horse should be saved when validation fails")
|
||||
|
||||
println("[DEBUG_LOG] Transaction consistency test completed - no data saved on validation failure")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should successfully create horse with valid data in transaction`(): Unit = runBlocking {
|
||||
println("[DEBUG_LOG] Starting successful transactional creation test")
|
||||
|
||||
val request = TransactionalCreateHorseUseCase.CreateHorseRequest(
|
||||
pferdeName = "Successful Transaction Horse",
|
||||
geschlecht = PferdeGeschlechtE.STUTE,
|
||||
geburtsdatum = LocalDate(2021, 6, 15),
|
||||
lebensnummer = "SUCCESS-TEST-001",
|
||||
chipNummer = "SUCCESS-CHIP-001",
|
||||
rasse = "Warmblut",
|
||||
stockmass = 165,
|
||||
besitzerId = uuid4() // Add required owner
|
||||
)
|
||||
|
||||
println("[DEBUG_LOG] Executing transactional create with valid data")
|
||||
val result = transactionalCreateHorseUseCase.execute(request)
|
||||
|
||||
println("[DEBUG_LOG] Creation result: success=${result.success}")
|
||||
|
||||
// Verify that the operation succeeded
|
||||
assertTrue(result.success, "Operation should succeed with valid data")
|
||||
assertNotNull(result.data, "Result should contain the created horse")
|
||||
assertEquals("Successful Transaction Horse", result.data?.pferdeName, "Horse name should match")
|
||||
|
||||
// Verify that the horse was saved in the database
|
||||
val savedHorse = horseRepository.findByLebensnummer("SUCCESS-TEST-001")
|
||||
assertNotNull(savedHorse, "Horse should be saved in database")
|
||||
assertEquals("Successful Transaction Horse", savedHorse.pferdeName, "Saved horse name should match")
|
||||
assertEquals("SUCCESS-CHIP-001", savedHorse.chipNummer, "Saved horse chip number should match")
|
||||
|
||||
println("[DEBUG_LOG] Successful transactional creation test completed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<configuration>
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE" />
|
||||
</root>
|
||||
</configuration>
|
||||
Reference in New Issue
Block a user