feat(horses-service): remove integration tests and obsolete configurations

- Deleted outdated integration test classes (`HorseServiceIntegrationTest`, `TransactionalContextTest`, and others) and test resources (`logback-test.xml`).
- Removed obsolete Gradle dependencies related to these tests and revised project module references.
- Simplified `DomPferd` domain model with minor refactorings for serialization and validation.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
Stefan Mogeritsch 2026-03-23 15:20:16 +01:00
parent c53daa926a
commit ceb1ccdab8
30 changed files with 890 additions and 797 deletions

View File

@ -6,7 +6,7 @@ plugins {
dependencies {
implementation(projects.platform.platformDependencies)
implementation(projects.clubs.clubsDomain)
implementation(projects.backend.services.clubs.clubsDomain)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)

View File

@ -13,8 +13,8 @@ dependencies {
implementation(projects.platform.platformDependencies)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
implementation(projects.clubs.clubsDomain)
implementation(projects.clubs.clubsInfrastructure)
implementation(projects.backend.services.clubs.clubsDomain)
implementation(projects.backend.services.clubs.clubsInfrastructure)
implementation(libs.spring.boot.starter.web)
implementation(libs.spring.boot.starter.validation)
@ -23,6 +23,7 @@ dependencies {
implementation(libs.exposed.core)
implementation(libs.exposed.dao)
implementation(libs.exposed.jdbc)
implementation(libs.exposed.migration.jdbc)
implementation(libs.exposed.kotlin.datetime)
implementation(libs.hikari.cp)
runtimeOnly(libs.postgresql.driver)

View File

@ -3,8 +3,8 @@ package at.mocode.clubs.service.config
import at.mocode.clubs.infrastructure.persistence.ZnsClubTable
import jakarta.annotation.PostConstruct
import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Configuration
@ -24,7 +24,8 @@ class ClubsDatabaseConfiguration(
log.info("Initialisiere Datenbank-Schema für Clubs-Service...")
Database.connect(jdbcUrl, user = username, password = password)
transaction {
SchemaUtils.createMissingTablesAndColumns(ZnsClubTable)
val statements = MigrationUtils.statementsRequiredForDatabaseMigration(ZnsClubTable)
statements.forEach { exec(it) }
log.info("Datenbank-Schema erfolgreich initialisiert")
}
}

View File

@ -1,9 +1,9 @@
package at.mocode.clubs.service.dev
import at.mocode.clubs.infrastructure.persistence.ZnsClubTable
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.batchInsert
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.CommandLineRunner
@ -36,7 +36,8 @@ class ZnsClubSeeder(
}
log.info("Starte ZNS-Vereine-Import aus: {}", dir.absolutePath)
transaction {
SchemaUtils.createMissingTablesAndColumns(ZnsClubTable)
val statements = MigrationUtils.statementsRequiredForDatabaseMigration(ZnsClubTable)
statements.forEach { exec(it) }
}
seedClubs(dir)
log.info("ZNS-Vereine-Import abgeschlossen.")

View File

@ -18,8 +18,8 @@ springBoot {
dependencies {
// Interne Module
implementation(projects.platform.platformDependencies)
implementation(projects.horses.horsesDomain)
implementation(projects.horses.horsesApplication)
implementation(projects.backend.services.horses.horsesDomain)
implementation(projects.backend.services.horses.horsesApplication)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)

View File

@ -3,7 +3,7 @@ plugins {
}
dependencies {
implementation(projects.horses.horsesDomain)
implementation(projects.backend.services.horses.horsesDomain)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
testImplementation(projects.platform.platformTesting)

View File

@ -6,6 +6,7 @@ import at.mocode.core.domain.model.PferdeGeschlechtE
import at.mocode.core.domain.serialization.KotlinInstantSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import kotlinx.datetime.LocalDate
import kotlinx.datetime.number
import kotlinx.datetime.todayIn
import kotlinx.serialization.Serializable
import kotlin.time.Clock
@ -22,7 +23,7 @@ import kotlin.uuid.Uuid
* @property pferdId Unique internal identifier for this horse (UUID).
* @property pferdeName Name of the horse.
* @property geschlecht Gender of the horse (Hengst, Stute, Wallach).
* @property geburtsdatum Birth date of the horse.
* @property geburtsdatum Birthdate of the horse.
* @property rasse Breed of the horse.
* @property farbe Color/coat of the horse.
* @property besitzerId ID of the current owner (Person from member-management context).
@ -132,9 +133,10 @@ data class DomPferd(
val today = kotlin.time.Clock.System.todayIn(kotlinx.datetime.TimeZone.currentSystemDefault())
var age = today.year - birthDate.year
// Check if birthday has occurred this year
if (today.monthNumber < birthDate.monthNumber ||
(today.monthNumber == birthDate.monthNumber && today.dayOfMonth < birthDate.dayOfMonth)) {
// Check if a birthday has occurred this year
if (today.month.number < birthDate.month.number ||
(today.month.number == birthDate.month.number && today.day < birthDate.day)
) {
age--
}
@ -164,7 +166,7 @@ data class DomPferd(
}
/**
* Creates a copy of this horse with updated timestamp.
* Creates a copy of this horse with an updated timestamp.
*/
fun withUpdatedTimestamp(): DomPferd {
return this.copy(updatedAt = kotlin.time.Clock.System.now())

View File

@ -7,9 +7,9 @@ plugins {
dependencies {
// Interne Module
implementation(projects.platform.platformDependencies)
implementation(projects.horses.horsesDomain)
implementation(projects.backend.services.horses.horsesDomain)
// horses-common: ON HOLD
// implementation(projects.horses.horsesCommon)
// implementation(projects.backend.services.horses.horsesCommon)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)

View File

@ -0,0 +1,336 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.horses.infrastructure.persistence
import at.mocode.core.domain.model.PferdeGeschlechtE
import at.mocode.horses.domain.model.DomPferd
import at.mocode.horses.domain.repository.HorseRepository
import at.mocode.core.utils.database.DatabaseFactory
import kotlin.uuid.Uuid
import kotlin.time.Clock
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.statements.UpdateBuilder
import org.springframework.stereotype.Repository
/**
* PostgreSQL implementation of the HorseRepository using Exposed ORM.
*
* This implementation provides database operations for horse entities,
* mapping between the domain model (DomPferd) and the database table (HorseTable).
*/
@Repository
class HorseRepositoryImpl : HorseRepository {
override suspend fun findById(id: Uuid): DomPferd? = DatabaseFactory.dbQuery {
HorseTable.selectAll().where { HorseTable.id eq id }
.map { rowToDomPferd(it) }
.singleOrNull()
}
override suspend fun findByLebensnummer(lebensnummer: String): DomPferd? = DatabaseFactory.dbQuery {
HorseTable.selectAll().where { HorseTable.lebensnummer eq lebensnummer }
.map { rowToDomPferd(it) }
.singleOrNull()
}
override suspend fun findByChipNummer(chipNummer: String): DomPferd? = DatabaseFactory.dbQuery {
HorseTable.selectAll().where { HorseTable.chipNummer eq chipNummer }
.map { rowToDomPferd(it) }
.singleOrNull()
}
override suspend fun findByPassNummer(passNummer: String): DomPferd? = DatabaseFactory.dbQuery {
HorseTable.selectAll().where { HorseTable.passNummer eq passNummer }
.map { rowToDomPferd(it) }
.singleOrNull()
}
override suspend fun findByOepsNummer(oepsNummer: String): DomPferd? = DatabaseFactory.dbQuery {
HorseTable.selectAll().where { HorseTable.oepsNummer eq oepsNummer }
.map { rowToDomPferd(it) }
.singleOrNull()
}
override suspend fun findByFeiNummer(feiNummer: String): DomPferd? = DatabaseFactory.dbQuery {
HorseTable.selectAll().where { HorseTable.feiNummer eq feiNummer }
.map { rowToDomPferd(it) }
.singleOrNull()
}
override suspend fun findByName(searchTerm: String, limit: Int): List<DomPferd> = DatabaseFactory.dbQuery {
HorseTable.selectAll().where { HorseTable.pferdeName like "%$searchTerm%" }
.orderBy(HorseTable.pferdeName to SortOrder.ASC)
.limit(limit)
.map { rowToDomPferd(it) }
}
override suspend fun findByOwnerId(ownerId: Uuid, activeOnly: Boolean): List<DomPferd> = DatabaseFactory.dbQuery {
val query = HorseTable.selectAll().where { HorseTable.besitzerId eq ownerId }
if (activeOnly) {
query.andWhere { HorseTable.istAktiv eq true }
} else {
query
}.orderBy(HorseTable.pferdeName to SortOrder.ASC)
.map { rowToDomPferd(it) }
}
override suspend fun findByResponsiblePersonId(responsiblePersonId: Uuid, activeOnly: Boolean): List<DomPferd> = DatabaseFactory.dbQuery {
val query = HorseTable.selectAll().where { HorseTable.verantwortlichePersonId eq responsiblePersonId }
if (activeOnly) {
query.andWhere { HorseTable.istAktiv eq true }
} else {
query
}.orderBy(HorseTable.pferdeName to SortOrder.ASC)
.map { rowToDomPferd(it) }
}
override suspend fun findByGeschlecht(geschlecht: PferdeGeschlechtE, activeOnly: Boolean, limit: Int): List<DomPferd> = DatabaseFactory.dbQuery {
val query = HorseTable.selectAll().where { HorseTable.geschlecht eq geschlecht }
if (activeOnly) {
query.andWhere { HorseTable.istAktiv eq true }
} else {
query
}.orderBy(HorseTable.pferdeName to SortOrder.ASC)
.limit(limit)
.map { rowToDomPferd(it) }
}
override suspend fun findByRasse(rasse: String, activeOnly: Boolean, limit: Int): List<DomPferd> = DatabaseFactory.dbQuery {
val query = HorseTable.selectAll().where { HorseTable.rasse eq rasse }
if (activeOnly) {
query.andWhere { HorseTable.istAktiv eq true }
} else {
query
}.orderBy(HorseTable.pferdeName to SortOrder.ASC)
.limit(limit)
.map { rowToDomPferd(it) }
}
override suspend fun findByBirthYear(birthYear: Int, activeOnly: Boolean): List<DomPferd> = DatabaseFactory.dbQuery {
val query = HorseTable.selectAll().where {
HorseTable.geburtsdatum.isNotNull() and
(CustomFunction(
"EXTRACT",
IntegerColumnType(),
stringLiteral("YEAR FROM "),
HorseTable.geburtsdatum
) eq birthYear)
}
if (activeOnly) {
query.andWhere { HorseTable.istAktiv eq true }
} else {
query
}.orderBy(HorseTable.pferdeName to SortOrder.ASC)
.map { rowToDomPferd(it) }
}
override suspend fun findByBirthYearRange(fromYear: Int, toYear: Int, activeOnly: Boolean): List<DomPferd> = DatabaseFactory.dbQuery {
val query = HorseTable.selectAll().where {
HorseTable.geburtsdatum.isNotNull() and
(CustomFunction(
"EXTRACT",
IntegerColumnType(),
stringLiteral("YEAR FROM "),
HorseTable.geburtsdatum
) greaterEq fromYear) and
(CustomFunction(
"EXTRACT",
IntegerColumnType(),
stringLiteral("YEAR FROM "),
HorseTable.geburtsdatum
) lessEq toYear)
}
if (activeOnly) {
query.andWhere { HorseTable.istAktiv eq true }
} else {
query
}.orderBy(HorseTable.geburtsdatum, SortOrder.DESC)
.map { rowToDomPferd(it) }
}
override suspend fun findAllActive(limit: Int): List<DomPferd> = DatabaseFactory.dbQuery {
HorseTable.selectAll().where { HorseTable.istAktiv eq true }
.orderBy(HorseTable.pferdeName to SortOrder.ASC)
.limit(limit)
.map { rowToDomPferd(it) }
}
override suspend fun findOepsRegistered(activeOnly: Boolean): List<DomPferd> = DatabaseFactory.dbQuery {
val query = HorseTable.selectAll().where { HorseTable.oepsNummer.isNotNull() }
if (activeOnly) {
query.andWhere { HorseTable.istAktiv eq true }
} else {
query
}.orderBy(HorseTable.pferdeName to SortOrder.ASC)
.map { rowToDomPferd(it) }
}
override suspend fun findFeiRegistered(activeOnly: Boolean): List<DomPferd> = DatabaseFactory.dbQuery {
val query = HorseTable.selectAll().where { HorseTable.feiNummer.isNotNull() }
if (activeOnly) {
query.andWhere { HorseTable.istAktiv eq true }
} else {
query
}.orderBy(HorseTable.pferdeName to SortOrder.ASC)
.map { rowToDomPferd(it) }
}
override suspend fun save(horse: DomPferd): DomPferd = DatabaseFactory.dbQuery {
val now = Clock.System.now()
val existingHorse = findById(horse.pferdId)
if (existingHorse != null) {
// Update existing horse
val updatedHorse = horse.copy(updatedAt = now)
HorseTable.update({ HorseTable.id eq horse.pferdId }) {
domPferdToStatement(it, updatedHorse)
}
updatedHorse
} else {
// Insert a new horse
HorseTable.insert {
it[id] = horse.pferdId
domPferdToStatement(it, horse.copy(updatedAt = now))
}
horse.copy(updatedAt = now)
}
}
override suspend fun delete(id: Uuid): Boolean = DatabaseFactory.dbQuery {
val deletedRows = HorseTable.deleteWhere { HorseTable.id eq id }
deletedRows > 0
}
override suspend fun existsByLebensnummer(lebensnummer: String): Boolean = DatabaseFactory.dbQuery {
HorseTable.selectAll().where { HorseTable.lebensnummer eq lebensnummer }
.count() > 0
}
override suspend fun existsByChipNummer(chipNummer: String): Boolean = DatabaseFactory.dbQuery {
HorseTable.selectAll().where { HorseTable.chipNummer eq chipNummer }
.count() > 0
}
override suspend fun existsByPassNummer(passNummer: String): Boolean = DatabaseFactory.dbQuery {
HorseTable.selectAll().where { HorseTable.passNummer eq passNummer }
.count() > 0
}
override suspend fun existsByOepsNummer(oepsNummer: String): Boolean = DatabaseFactory.dbQuery {
HorseTable.selectAll().where { HorseTable.oepsNummer eq oepsNummer }
.count() > 0
}
override suspend fun existsByFeiNummer(feiNummer: String): Boolean = DatabaseFactory.dbQuery {
HorseTable.selectAll().where { HorseTable.feiNummer eq feiNummer }
.count() > 0
}
override suspend fun countActive(): Long = DatabaseFactory.dbQuery {
HorseTable.selectAll().where { HorseTable.istAktiv eq true }
.count()
}
override suspend fun countByOwnerId(ownerId: Uuid, activeOnly: Boolean): Long = DatabaseFactory.dbQuery {
val query = HorseTable.selectAll().where { HorseTable.besitzerId eq ownerId }
if (activeOnly) {
query.andWhere { HorseTable.istAktiv eq true }
} else {
query
}.count()
}
override suspend fun countOepsRegistered(activeOnly: Boolean): Long = DatabaseFactory.dbQuery {
val query = HorseTable.selectAll().where {
HorseTable.oepsNummer.isNotNull() and (HorseTable.oepsNummer neq "")
}
if (activeOnly) {
query.andWhere { HorseTable.istAktiv eq true }
} else {
query
}.count()
}
override suspend fun countFeiRegistered(activeOnly: Boolean): Long = DatabaseFactory.dbQuery {
val query = HorseTable.selectAll().where {
HorseTable.feiNummer.isNotNull() and (HorseTable.feiNummer neq "")
}
if (activeOnly) {
query.andWhere { HorseTable.istAktiv eq true }
} else {
query
}.count()
}
/**
* Maps a database row to a DomPferd domain object.
*/
private fun rowToDomPferd(row: ResultRow): DomPferd {
return DomPferd(
pferdId = row[HorseTable.id].value,
pferdeName = row[HorseTable.pferdeName],
geschlecht = row[HorseTable.geschlecht],
geburtsdatum = row[HorseTable.geburtsdatum],
rasse = row[HorseTable.rasse],
farbe = row[HorseTable.farbe],
besitzerId = row[HorseTable.besitzerId],
verantwortlichePersonId = row[HorseTable.verantwortlichePersonId],
zuechterName = row[HorseTable.zuechterName],
zuchtbuchNummer = row[HorseTable.zuchtbuchNummer],
lebensnummer = row[HorseTable.lebensnummer],
chipNummer = row[HorseTable.chipNummer],
passNummer = row[HorseTable.passNummer],
oepsNummer = row[HorseTable.oepsNummer],
feiNummer = row[HorseTable.feiNummer],
vaterName = row[HorseTable.vaterName],
mutterName = row[HorseTable.mutterName],
mutterVaterName = row[HorseTable.mutterVaterName],
stockmass = row[HorseTable.stockmass],
istAktiv = row[HorseTable.istAktiv],
bemerkungen = row[HorseTable.bemerkungen],
datenQuelle = row[HorseTable.datenQuelle],
createdAt = row[HorseTable.createdAt],
updatedAt = row[HorseTable.updatedAt]
)
}
/**
* Maps a DomPferd domain object to database statement values.
*/
private fun domPferdToStatement(statement: UpdateBuilder<*>, horse: DomPferd) {
statement[HorseTable.pferdeName] = horse.pferdeName
statement[HorseTable.geschlecht] = horse.geschlecht
statement[HorseTable.geburtsdatum] = horse.geburtsdatum
statement[HorseTable.rasse] = horse.rasse
statement[HorseTable.farbe] = horse.farbe
statement[HorseTable.besitzerId] = horse.besitzerId
statement[HorseTable.verantwortlichePersonId] = horse.verantwortlichePersonId
statement[HorseTable.zuechterName] = horse.zuechterName
statement[HorseTable.zuchtbuchNummer] = horse.zuchtbuchNummer
statement[HorseTable.lebensnummer] = horse.lebensnummer
statement[HorseTable.chipNummer] = horse.chipNummer
statement[HorseTable.passNummer] = horse.passNummer
statement[HorseTable.oepsNummer] = horse.oepsNummer
statement[HorseTable.feiNummer] = horse.feiNummer
statement[HorseTable.vaterName] = horse.vaterName
statement[HorseTable.mutterName] = horse.mutterName
statement[HorseTable.mutterVaterName] = horse.mutterVaterName
statement[HorseTable.stockmass] = horse.stockmass
statement[HorseTable.istAktiv] = horse.istAktiv
statement[HorseTable.bemerkungen] = horse.bemerkungen
statement[HorseTable.datenQuelle] = horse.datenQuelle
statement[HorseTable.createdAt] = horse.createdAt
statement[HorseTable.updatedAt] = horse.updatedAt
}
}

View File

@ -0,0 +1,14 @@
package at.mocode.horses.infrastructure.persistence
import org.jetbrains.exposed.v1.core.dao.id.java.UUIDTable
import org.jetbrains.exposed.v1.datetime.timestamp
/**
* Tabelle für importierte Vereine aus dem ZNS-Datenbestand (VEREIN01.dat).
* Wird ausschließlich als Dev-Seed-Daten verwendet.
*/
object ZnsClubTable : UUIDTable("zns_clubs") {
val vereinsNummer = varchar("vereins_nummer", 4).uniqueIndex()
val name = varchar("name", 50)
val createdAt = timestamp("created_at")
}

View File

@ -0,0 +1,23 @@
package at.mocode.horses.infrastructure.persistence
import org.jetbrains.exposed.v1.core.dao.id.java.UUIDTable
import org.jetbrains.exposed.v1.datetime.date
import org.jetbrains.exposed.v1.datetime.timestamp
/**
* Tabelle für importierte Personen/Reiter aus dem ZNS-Datenbestand (LIZENZ01.dat).
* Wird ausschließlich als Dev-Seed-Daten verwendet.
*/
object ZnsPersonTable : UUIDTable("zns_persons") {
val lizenzNummer = varchar("lizenz_nummer", 10).uniqueIndex()
val nachname = varchar("nachname", 50)
val vorname = varchar("vorname", 25)
val vereinsNummer = varchar("vereins_nummer", 4).nullable()
val vereinsName = varchar("vereins_name", 50).nullable()
val nation = varchar("nation", 3).nullable()
val lizenzKlasse = varchar("lizenz_klasse", 6).nullable()
val mitgliedsNummer = varchar("mitglieds_nummer", 13).nullable()
val geschlecht = varchar("geschlecht", 1).nullable()
val geburtsdatum = date("geburtsdatum").nullable()
val createdAt = timestamp("created_at")
}

View File

@ -14,10 +14,10 @@ dependencies {
implementation(projects.platform.platformDependencies)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
implementation(projects.horses.horsesDomain)
implementation(projects.backend.services.horses.horsesDomain)
// horses-common: ON HOLD veraltete API-Referenzen
// implementation(projects.horses.horsesCommon)
implementation(projects.horses.horsesInfrastructure)
// implementation(projects.backend.services.horses.horsesCommon)
implementation(projects.backend.services.horses.horsesInfrastructure)
// Spring Boot Starters
implementation(libs.spring.boot.starter.web)
@ -28,9 +28,11 @@ dependencies {
implementation(libs.exposed.core)
implementation(libs.exposed.dao)
implementation(libs.exposed.jdbc)
implementation(libs.exposed.migration.jdbc)
implementation(libs.exposed.kotlin.datetime)
implementation(libs.hikari.cp)
runtimeOnly(libs.postgresql.driver)
testImplementation(project(":backend:infrastructure:messaging:messaging-client"))
runtimeOnly(libs.postgresql.driver)
testRuntimeOnly(libs.h2.driver)
// Testing

View File

@ -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)
}
}

View File

@ -5,8 +5,8 @@ import at.mocode.horses.infrastructure.persistence.ZnsClubTable
import at.mocode.horses.infrastructure.persistence.ZnsPersonTable
import jakarta.annotation.PostConstruct
import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Configuration
@ -30,7 +30,8 @@ class HorsesDatabaseConfiguration(
log.info("Initialisiere Datenbank-Schema für Horses-Service...")
Database.connect(jdbcUrl, user = username, password = password)
transaction {
SchemaUtils.createMissingTablesAndColumns(HorseTable, ZnsPersonTable, ZnsClubTable)
val statements = MigrationUtils.statementsRequiredForDatabaseMigration(HorseTable, ZnsPersonTable, ZnsClubTable)
statements.forEach { exec(it) }
log.info("Datenbank-Schema erfolgreich initialisiert")
}
}

View File

@ -0,0 +1,108 @@
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 at.mocode.horses.infrastructure.persistence.ZnsPersonTable
import at.mocode.horses.infrastructure.persistence.ZnsClubTable
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.v1.core.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.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)
}
}
}

View File

@ -0,0 +1,277 @@
package at.mocode.horses.service.dev
import at.mocode.core.domain.model.DatenQuelleE
import at.mocode.core.domain.model.PferdeGeschlechtE
import at.mocode.horses.infrastructure.persistence.HorseTable
import at.mocode.horses.infrastructure.persistence.ZnsClubTable
import at.mocode.horses.infrastructure.persistence.ZnsPersonTable
import org.jetbrains.exposed.v1.jdbc.batchInsert
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.CommandLineRunner
import org.springframework.context.annotation.Profile
import org.springframework.stereotype.Component
import java.io.File
import kotlin.time.Clock
/**
* Dev-only Seeder: Importiert ZNS-Rohdaten (Fixbreiten-Flat-Files) in die lokale Entwicklungs-DB.
*
* Aktivierung: Spring-Profil "dev" + Umgebungsvariable ZNS_DATA_DIR (Pfad zum ZNS-Verzeichnis).
* Beispiel: ZNS_DATA_DIR=/pfad/zu/ZNS ./gradlew :horses-service:bootRun --args='--spring.profiles.active=dev'
*
* ACHTUNG: Kein Produktions-Feature nur für lokale Entwicklung gedacht.
*/
@Component
@Profile("dev")
class ZnsDataSeeder(
@Value("\${zns.data.dir:#{null}}") private val znsDataDir: String?
) : CommandLineRunner {
private val log = LoggerFactory.getLogger(ZnsDataSeeder::class.java)
override fun run(vararg args: String) {
if (znsDataDir == null) {
log.warn("ZNS_DATA_DIR nicht gesetzt ZnsDataSeeder wird übersprungen.")
log.warn("Setze die Umgebungsvariable ZNS_DATA_DIR=/pfad/zu/ZNS um den Seeder zu aktivieren.")
return
}
val dir = File(znsDataDir)
if (!dir.exists() || !dir.isDirectory) {
log.error("ZNS_DATA_DIR '{}' existiert nicht oder ist kein Verzeichnis.", znsDataDir)
return
}
log.info("=== ZnsDataSeeder gestartet Quelle: {} ===", znsDataDir)
transaction {
MigrationUtils.statementsRequiredForDatabaseMigration(ZnsClubTable, ZnsPersonTable, HorseTable)
.forEach { exec(it) }
}
seedClubs(dir)
seedPersons(dir)
seedHorses(dir)
log.info("=== ZnsDataSeeder abgeschlossen ===")
}
// -------------------------------------------------------------------------
// VEREIN01.dat → zns_clubs
// Format: [0-3] VereinsNr, [4-53] Name (54 Zeichen pro Zeile)
// -------------------------------------------------------------------------
private fun seedClubs(dir: File) {
val file = File(dir, "VEREIN01.dat")
if (!file.exists()) {
log.warn("VEREIN01.dat nicht gefunden Vereine werden übersprungen."); return
}
data class ClubRow(val nr: String, val name: String)
val rows = file.readLines(Charsets.ISO_8859_1)
.filter { it.length >= 4 }
.map { line ->
ClubRow(
nr = line.substring(0, 4).trim(),
name = line.substring(4).trim().take(50)
)
}
.filter { it.nr.isNotBlank() }
val now = Clock.System.now()
transaction {
ZnsClubTable.batchInsert(rows, ignore = true) { row ->
this[ZnsClubTable.vereinsNummer] = row.nr
this[ZnsClubTable.name] = row.name
this[ZnsClubTable.createdAt] = now
}
}
log.info("Vereine importiert: {} Datensätze", rows.size)
}
// -------------------------------------------------------------------------
// LIZENZ01.dat → zns_persons
// Format (220 Zeichen):
// [0-5] LizenzNr
// [6-55] Nachname
// [56-80] Vorname
// [81-83] VereinsNr (2-stellig, rechtsbündig)
// [84-133] VereinsName
// [134-136] Nation
// [137-143] LizenzKlasse (z.B. "R1", "R2", "RD2")
// [144-156] MitgliedsNr
// [157-165] Telefon (nicht gespeichert)
// [166-169] Jahr
// [170] Geschlecht (M/W)
// [171-178] Geburtsdatum (YYYYMMDD)
// -------------------------------------------------------------------------
private fun seedPersons(dir: File) {
val file = File(dir, "LIZENZ01.dat")
if (!file.exists()) {
log.warn("LIZENZ01.dat nicht gefunden Personen werden übersprungen."); return
}
data class PersonRow(
val lizenzNr: String,
val nachname: String,
val vorname: String,
val vereinsNr: String?,
val vereinsName: String?,
val nation: String?,
val lizenzKlasse: String?,
val mitgliedsNr: String?,
val geschlecht: String?,
val geburtsdatum: java.time.LocalDate?
)
val rows = file.readLines(Charsets.ISO_8859_1)
.filter { it.length >= 6 }
.mapNotNull { line ->
val lizenzNr = line.substring(0, 6).trim()
if (lizenzNr.isBlank()) return@mapNotNull null
val nachname = line.safeSubstring(6, 56).trim()
val vorname = line.safeSubstring(56, 81).trim()
val vereinsNr = line.safeSubstring(81, 84).trim().takeIf { it.isNotBlank() }
val vereinsName = line.safeSubstring(84, 134).trim().takeIf { it.isNotBlank() }
val nation = line.safeSubstring(134, 137).trim().takeIf { it.isNotBlank() }
val lizenzKlasse = line.safeSubstring(137, 144).trim().takeIf { it.isNotBlank() }
val mitgliedsNr = line.safeSubstring(144, 157).trim().takeIf { it.isNotBlank() }
val geschlecht = line.safeSubstring(170, 171).trim().takeIf { it.isNotBlank() }
val gebDatStr = line.safeSubstring(171, 179).trim()
val gebDat = parseDate(gebDatStr)
PersonRow(
lizenzNr, nachname, vorname, vereinsNr, vereinsName,
nation, lizenzKlasse, mitgliedsNr, geschlecht, gebDat
)
}
val now = Clock.System.now()
transaction {
ZnsPersonTable.batchInsert(rows, ignore = true) { row ->
this[ZnsPersonTable.lizenzNummer] = row.lizenzNr
this[ZnsPersonTable.nachname] = row.nachname
this[ZnsPersonTable.vorname] = row.vorname
this[ZnsPersonTable.vereinsNummer] = row.vereinsNr
this[ZnsPersonTable.vereinsName] = row.vereinsName
this[ZnsPersonTable.nation] = row.nation
this[ZnsPersonTable.lizenzKlasse] = row.lizenzKlasse
this[ZnsPersonTable.mitgliedsNummer] = row.mitgliedsNr
this[ZnsPersonTable.geschlecht] = row.geschlecht
this[ZnsPersonTable.geburtsdatum] = row.geburtsdatum?.let {
kotlinx.datetime.LocalDate(it.year, it.monthValue, it.dayOfMonth)
}
this[ZnsPersonTable.createdAt] = now
}
}
log.info("Personen importiert: {} Datensätze", rows.size)
}
// -------------------------------------------------------------------------
// PFERDE01.dat → horses
// Format (211 Zeichen):
// [0-3] OepsNr (4 Zeichen)
// [4-33] Name (30 Zeichen)
// [34-42] ZnsNr / interne Nummer (9 Zeichen)
// [43] Geschlecht (S=Stute, W=Wallach, H=Hengst)
// [44-47] Geburtsjahr (4 Zeichen)
// [48-62] Farbe (15 Zeichen)
// [63-77] Rasse (15 Zeichen)
// [78-81] VereinsNr (4 Zeichen)
// [82-85] Jahr (4 Zeichen)
// [86-135] BesitzerName (50 Zeichen)
// [136-185] VaterName (50 Zeichen)
// [186-210] ChipNr (25 Zeichen)
// -------------------------------------------------------------------------
private fun seedHorses(dir: File) {
val file = File(dir, "PFERDE01.dat")
if (!file.exists()) {
log.warn("PFERDE01.dat nicht gefunden Pferde werden übersprungen."); return
}
data class HorseRow(
val oepsNr: String,
val name: String,
val geschlecht: PferdeGeschlechtE,
val geburtsjahr: Int?,
val farbe: String?,
val rasse: String?,
val besitzerName: String?,
val vaterName: String?,
val chipNr: String?
)
val rows = file.readLines(Charsets.ISO_8859_1)
.filter { it.length >= 5 }
.mapNotNull { line ->
val oepsNr = line.safeSubstring(0, 4).trim()
val name = line.safeSubstring(4, 34).trim()
if (name.isBlank()) return@mapNotNull null
val geschlechtChar = line.safeSubstring(43, 44).trim()
val geschlecht = when (geschlechtChar.uppercase()) {
"S" -> PferdeGeschlechtE.STUTE
"W" -> PferdeGeschlechtE.WALLACH
"H" -> PferdeGeschlechtE.HENGST
else -> PferdeGeschlechtE.UNBEKANNT
}
val gebjahr = line.safeSubstring(44, 48).trim().toIntOrNull()
val farbe = line.safeSubstring(48, 63).trim().takeIf { it.isNotBlank() }
val rasse = line.safeSubstring(63, 78).trim().takeIf { it.isNotBlank() }
val besitzer = line.safeSubstring(86, 136).trim().takeIf { it.isNotBlank() }
val vater = line.safeSubstring(136, 186).trim().takeIf { it.isNotBlank() }
val chip = line.safeSubstring(186, 211).trim().takeIf { it.isNotBlank() }
HorseRow(oepsNr, name, geschlecht, gebjahr, farbe, rasse, besitzer, vater, chip)
}
val now = Clock.System.now()
transaction {
HorseTable.batchInsert(rows, ignore = true) { row ->
this[HorseTable.pferdeName] = row.name
this[HorseTable.geschlecht] = row.geschlecht
this[HorseTable.geburtsdatum] = row.geburtsjahr?.let {
kotlinx.datetime.LocalDate(it, 1, 1)
}
this[HorseTable.farbe] = row.farbe
this[HorseTable.rasse] = row.rasse
this[HorseTable.oepsNummer] = row.oepsNr.takeIf { it.isNotBlank() }
this[HorseTable.chipNummer] = row.chipNr
this[HorseTable.vaterName] = row.vaterName
this[HorseTable.zuechterName] = row.besitzerName
this[HorseTable.datenQuelle] = DatenQuelleE.IMPORT_ZNS
this[HorseTable.istAktiv] = true
this[HorseTable.createdAt] = now
this[HorseTable.updatedAt] = now
}
}
log.info("Pferde importiert: {} Datensätze", rows.size)
}
// -------------------------------------------------------------------------
// Hilfsfunktionen
// -------------------------------------------------------------------------
private fun String.safeSubstring(start: Int, end: Int): String {
if (start >= this.length) return ""
return this.substring(start, minOf(end, this.length))
}
private fun parseDate(s: String): java.time.LocalDate? {
if (s.length < 8) return null
return try {
java.time.LocalDate.of(
s.substring(0, 4).toInt(),
s.substring(4, 6).toInt(),
s.substring(6, 8).toInt()
)
} catch (_: Exception) {
null
}
}
}

View File

@ -1,350 +0,0 @@
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")
}
}

View File

@ -1,171 +0,0 @@
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")
}
}

View File

@ -1,187 +0,0 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
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 kotlin.uuid.Uuid
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 = Uuid.random()
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 = Uuid.random() // 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 = Uuid.random() // 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")
}
}

View File

@ -1,10 +0,0 @@
<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>

View File

@ -6,7 +6,7 @@ plugins {
dependencies {
implementation(projects.platform.platformDependencies)
implementation(projects.officials.officialsDomain)
implementation(projects.backend.services.officials.officialsDomain)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)

View File

@ -13,8 +13,8 @@ dependencies {
implementation(projects.platform.platformDependencies)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
implementation(projects.officials.officialsDomain)
implementation(projects.officials.officialsInfrastructure)
implementation(projects.backend.services.officials.officialsDomain)
implementation(projects.backend.services.officials.officialsInfrastructure)
implementation(libs.spring.boot.starter.web)
implementation(libs.spring.boot.starter.validation)
@ -23,6 +23,7 @@ dependencies {
implementation(libs.exposed.core)
implementation(libs.exposed.dao)
implementation(libs.exposed.jdbc)
implementation(libs.exposed.migration.jdbc)
implementation(libs.exposed.kotlin.datetime)
implementation(libs.hikari.cp)
runtimeOnly(libs.postgresql.driver)

View File

@ -3,8 +3,8 @@ package at.mocode.officials.service.config
import at.mocode.officials.infrastructure.persistence.ZnsOfficialTable
import jakarta.annotation.PostConstruct
import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Configuration
@ -24,7 +24,8 @@ class OfficialsDatabaseConfiguration(
log.info("Initialisiere Datenbank-Schema für Officials-Service...")
Database.connect(jdbcUrl, user = username, password = password)
transaction {
SchemaUtils.createMissingTablesAndColumns(ZnsOfficialTable)
val statements = MigrationUtils.statementsRequiredForDatabaseMigration(ZnsOfficialTable)
statements.forEach { exec(it) }
log.info("Datenbank-Schema erfolgreich initialisiert")
}
}

View File

@ -1,9 +1,9 @@
package at.mocode.officials.service.dev
import at.mocode.officials.infrastructure.persistence.ZnsOfficialTable
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.batchInsert
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.CommandLineRunner
@ -36,7 +36,8 @@ class ZnsOfficialSeeder(
}
log.info("Starte ZNS-Richter-Import aus: {}", dir.absolutePath)
transaction {
SchemaUtils.createMissingTablesAndColumns(ZnsOfficialTable)
val statements = MigrationUtils.statementsRequiredForDatabaseMigration(ZnsOfficialTable)
statements.forEach { exec(it) }
}
seedOfficials(dir)
log.info("ZNS-Richter-Import abgeschlossen.")

View File

@ -6,7 +6,7 @@ plugins {
dependencies {
implementation(projects.platform.platformDependencies)
implementation(projects.persons.personsDomain)
implementation(projects.backend.services.persons.personsDomain)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)

View File

@ -13,8 +13,8 @@ dependencies {
implementation(projects.platform.platformDependencies)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
implementation(projects.persons.personsDomain)
implementation(projects.persons.personsInfrastructure)
implementation(projects.backend.services.persons.personsDomain)
implementation(projects.backend.services.persons.personsInfrastructure)
implementation(libs.spring.boot.starter.web)
implementation(libs.spring.boot.starter.validation)
@ -23,6 +23,7 @@ dependencies {
implementation(libs.exposed.core)
implementation(libs.exposed.dao)
implementation(libs.exposed.jdbc)
implementation(libs.exposed.migration.jdbc)
implementation(libs.exposed.kotlin.datetime)
implementation(libs.hikari.cp)
runtimeOnly(libs.postgresql.driver)

View File

@ -3,8 +3,8 @@ package at.mocode.persons.service.config
import at.mocode.persons.infrastructure.persistence.ZnsPersonTable
import jakarta.annotation.PostConstruct
import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Configuration
@ -24,7 +24,8 @@ class PersonsDatabaseConfiguration(
log.info("Initialisiere Datenbank-Schema für Persons-Service...")
Database.connect(jdbcUrl, user = username, password = password)
transaction {
SchemaUtils.createMissingTablesAndColumns(ZnsPersonTable)
val statements = MigrationUtils.statementsRequiredForDatabaseMigration(ZnsPersonTable)
statements.forEach { exec(it) }
log.info("Datenbank-Schema erfolgreich initialisiert")
}
}

View File

@ -1,9 +1,9 @@
package at.mocode.persons.service.dev
import at.mocode.persons.infrastructure.persistence.ZnsPersonTable
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.batchInsert
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.CommandLineRunner
@ -36,7 +36,8 @@ class ZnsPersonSeeder(
}
log.info("Starte ZNS-Personen-Import aus: {}", dir.absolutePath)
transaction {
SchemaUtils.createMissingTablesAndColumns(ZnsPersonTable)
val statements = MigrationUtils.statementsRequiredForDatabaseMigration(ZnsPersonTable)
statements.forEach { exec(it) }
}
seedPersons(dir)
log.info("ZNS-Personen-Import abgeschlossen.")

View File

@ -218,6 +218,7 @@ exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "e
exposed-dao = { module = "org.jetbrains.exposed:exposed-dao", version.ref = "exposed" }
exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "exposed" }
exposed-kotlin-datetime = { module = "org.jetbrains.exposed:exposed-kotlin-datetime", version.ref = "exposed" }
exposed-migration-jdbc = { module = "org.jetbrains.exposed:exposed-migration-jdbc", version.ref = "exposed" }
exposed-java-time = { module = "org.jetbrains.exposed:exposed-java-time", version.ref = "exposed" }
exposed-json = { module = "org.jetbrains.exposed:exposed-json", version.ref = "exposed" }
exposed-money = { module = "org.jetbrains.exposed:exposed-money", version.ref = "exposed" }

View File

@ -73,51 +73,29 @@ include(":backend:services:entries:entries-api")
// Code liegt im Branch: feature/entries-service
// include(":backend:services:entries:entries-service")
// --- HORSES (Pferde-Verwaltung) ---
// Namespace ':horses:*' damit projects.horses.* Accessors funktionieren
include(":horses")
project(":horses").projectDir = file("backend/services/horses")
include(":horses:horses-domain")
// horses-common: ON HOLD veraltete API-Referenzen
// include(":horses:horses-common")
include(":horses:horses-infrastructure")
// horses-api: ON HOLD Ktor-basiert, wird separat aktiviert
// include(":horses:horses-api")
include(":horses:horses-service")
project(":horses:horses-domain").projectDir = file("backend/services/horses/horses-domain")
// project(":horses:horses-common").projectDir = file("backend/services/horses/horses-common")
project(":horses:horses-infrastructure").projectDir = file("backend/services/horses/horses-infrastructure")
project(":horses:horses-service").projectDir = file("backend/services/horses/horses-service")
// --- PERSONS (Personen/Reiter) ---
include(":persons")
project(":persons").projectDir = file("backend/services/persons")
include(":persons:persons-domain")
include(":persons:persons-infrastructure")
include(":persons:persons-service")
project(":persons:persons-domain").projectDir = file("backend/services/persons/persons-domain")
project(":persons:persons-infrastructure").projectDir = file("backend/services/persons/persons-infrastructure")
project(":persons:persons-service").projectDir = file("backend/services/persons/persons-service")
// --- CLUBS (Vereine) ---
include(":clubs")
project(":clubs").projectDir = file("backend/services/clubs")
include(":clubs:clubs-domain")
include(":clubs:clubs-infrastructure")
include(":clubs:clubs-service")
project(":clubs:clubs-domain").projectDir = file("backend/services/clubs/clubs-domain")
project(":clubs:clubs-infrastructure").projectDir = file("backend/services/clubs/clubs-infrastructure")
project(":clubs:clubs-service").projectDir = file("backend/services/clubs/clubs-service")
include(":backend:services:clubs:clubs-domain")
include(":backend:services:clubs:clubs-infrastructure")
include(":backend:services:clubs:clubs-service")
// --- HORSES (Pferde-Verwaltung) ---
include(":backend:services:horses:horses-domain")
// horses-common: ON HOLD veraltete API-Referenzen
// include(":backend:services:horses:horses-common")
include(":backend:services:horses:horses-infrastructure")
// horses-api: ON HOLD Ktor-basiert, wird separat aktiviert
// include(":backend:services:horses:horses-api")
include(":backend:services:horses:horses-service")
// --- OFFICIALS (Richter) ---
include(":officials")
project(":officials").projectDir = file("backend/services/officials")
include(":officials:officials-domain")
include(":officials:officials-infrastructure")
include(":officials:officials-service")
project(":officials:officials-domain").projectDir = file("backend/services/officials/officials-domain")
project(":officials:officials-infrastructure").projectDir = file("backend/services/officials/officials-infrastructure")
project(":officials:officials-service").projectDir = file("backend/services/officials/officials-service")
include(":backend:services:officials:officials-domain")
include(":backend:services:officials:officials-infrastructure")
include(":backend:services:officials:officials-service")
// --- PERSONS (Personen/Reiter) ---
include(":backend:services:persons:persons-domain")
include(":backend:services:persons:persons-infrastructure")
include(":backend:services:persons:persons-service")
// --- PING (Ping Service) ---
include(":backend:services:ping:ping-service")