From ceb1ccdab813aaed1f8d0dc62de6845607df6037 Mon Sep 17 00:00:00 2001 From: Stefan Mogeritsch Date: Mon, 23 Mar 2026 15:20:16 +0100 Subject: [PATCH] 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 --- .../clubs-infrastructure/build.gradle.kts | 2 +- .../clubs/clubs-service/build.gradle.kts | 5 +- .../service/config/DatabaseConfiguration.kt | 5 +- .../mocode/clubs/service/dev/ZnsClubSeeder.kt | 5 +- .../horses/horses-api/build.gradle.kts | 4 +- .../horses/horses-common/build.gradle.kts | 2 +- .../at/mocode/horses/domain/model/DomPferd.kt | 12 +- .../horses-infrastructure/build.gradle.kts | 4 +- .../HorseRepositoryImpl.kt.disabled | 336 +++++++++++++++++ .../persistence/ZnsClubTable.kt | 14 + .../persistence/ZnsPersonTable.kt | 23 ++ .../horses/horses-service/build.gradle.kts | 10 +- .../ApplicationConfiguration.kt.disabled | 60 +++ .../service/config/DatabaseConfiguration.kt | 5 +- .../config/DatabaseConfiguration.kt.disabled | 108 ++++++ .../horses/service/dev/ZnsDataSeeder.kt | 277 ++++++++++++++ .../HorseServiceIntegrationTest.kt | 350 ------------------ .../integration/TransactionContextTest.kt | 171 --------- .../integration/TransactionalContextTest.kt | 187 ---------- .../src/test/resources/logback-test.xml | 10 - .../officials-infrastructure/build.gradle.kts | 2 +- .../officials-service/build.gradle.kts | 5 +- .../service/config/DatabaseConfiguration.kt | 5 +- .../service/dev/ZnsOfficialSeeder.kt | 5 +- .../persons-infrastructure/build.gradle.kts | 2 +- .../persons/persons-service/build.gradle.kts | 5 +- .../service/config/DatabaseConfiguration.kt | 5 +- .../persons/service/dev/ZnsPersonSeeder.kt | 5 +- gradle/libs.versions.toml | 1 + settings.gradle.kts | 62 +--- 30 files changed, 890 insertions(+), 797 deletions(-) create mode 100644 backend/services/horses/horses-infrastructure/src/main/kotlin/at/mocode/horses/infrastructure/persistence/HorseRepositoryImpl.kt.disabled create mode 100644 backend/services/horses/horses-infrastructure/src/main/kotlin/at/mocode/horses/infrastructure/persistence/ZnsClubTable.kt create mode 100644 backend/services/horses/horses-infrastructure/src/main/kotlin/at/mocode/horses/infrastructure/persistence/ZnsPersonTable.kt create mode 100644 backend/services/horses/horses-service/src/main/kotlin/at/mocode/horses/service/config/ApplicationConfiguration.kt.disabled create mode 100644 backend/services/horses/horses-service/src/main/kotlin/at/mocode/horses/service/config/DatabaseConfiguration.kt.disabled create mode 100644 backend/services/horses/horses-service/src/main/kotlin/at/mocode/horses/service/dev/ZnsDataSeeder.kt delete mode 100644 backend/services/horses/horses-service/src/test/kotlin/at/mocode/horses/service/integration/HorseServiceIntegrationTest.kt delete mode 100644 backend/services/horses/horses-service/src/test/kotlin/at/mocode/horses/service/integration/TransactionContextTest.kt delete mode 100644 backend/services/horses/horses-service/src/test/kotlin/at/mocode/horses/service/integration/TransactionalContextTest.kt delete mode 100644 backend/services/horses/horses-service/src/test/resources/logback-test.xml diff --git a/backend/services/clubs/clubs-infrastructure/build.gradle.kts b/backend/services/clubs/clubs-infrastructure/build.gradle.kts index da59f968..0956a0b8 100644 --- a/backend/services/clubs/clubs-infrastructure/build.gradle.kts +++ b/backend/services/clubs/clubs-infrastructure/build.gradle.kts @@ -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) diff --git a/backend/services/clubs/clubs-service/build.gradle.kts b/backend/services/clubs/clubs-service/build.gradle.kts index 95652878..6c38b8de 100644 --- a/backend/services/clubs/clubs-service/build.gradle.kts +++ b/backend/services/clubs/clubs-service/build.gradle.kts @@ -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) diff --git a/backend/services/clubs/clubs-service/src/main/kotlin/at/mocode/clubs/service/config/DatabaseConfiguration.kt b/backend/services/clubs/clubs-service/src/main/kotlin/at/mocode/clubs/service/config/DatabaseConfiguration.kt index 7bbf9e68..6ea7ae75 100644 --- a/backend/services/clubs/clubs-service/src/main/kotlin/at/mocode/clubs/service/config/DatabaseConfiguration.kt +++ b/backend/services/clubs/clubs-service/src/main/kotlin/at/mocode/clubs/service/config/DatabaseConfiguration.kt @@ -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") } } diff --git a/backend/services/clubs/clubs-service/src/main/kotlin/at/mocode/clubs/service/dev/ZnsClubSeeder.kt b/backend/services/clubs/clubs-service/src/main/kotlin/at/mocode/clubs/service/dev/ZnsClubSeeder.kt index 274b2278..d16efe01 100644 --- a/backend/services/clubs/clubs-service/src/main/kotlin/at/mocode/clubs/service/dev/ZnsClubSeeder.kt +++ b/backend/services/clubs/clubs-service/src/main/kotlin/at/mocode/clubs/service/dev/ZnsClubSeeder.kt @@ -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.") diff --git a/backend/services/horses/horses-api/build.gradle.kts b/backend/services/horses/horses-api/build.gradle.kts index 80c7935a..7ceb013b 100644 --- a/backend/services/horses/horses-api/build.gradle.kts +++ b/backend/services/horses/horses-api/build.gradle.kts @@ -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) diff --git a/backend/services/horses/horses-common/build.gradle.kts b/backend/services/horses/horses-common/build.gradle.kts index 1d1ce417..ead40886 100644 --- a/backend/services/horses/horses-common/build.gradle.kts +++ b/backend/services/horses/horses-common/build.gradle.kts @@ -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) diff --git a/backend/services/horses/horses-domain/src/main/kotlin/at/mocode/horses/domain/model/DomPferd.kt b/backend/services/horses/horses-domain/src/main/kotlin/at/mocode/horses/domain/model/DomPferd.kt index 9e43c288..e9d1dbfe 100644 --- a/backend/services/horses/horses-domain/src/main/kotlin/at/mocode/horses/domain/model/DomPferd.kt +++ b/backend/services/horses/horses-domain/src/main/kotlin/at/mocode/horses/domain/model/DomPferd.kt @@ -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()) diff --git a/backend/services/horses/horses-infrastructure/build.gradle.kts b/backend/services/horses/horses-infrastructure/build.gradle.kts index a85f4335..1c0aa997 100644 --- a/backend/services/horses/horses-infrastructure/build.gradle.kts +++ b/backend/services/horses/horses-infrastructure/build.gradle.kts @@ -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) diff --git a/backend/services/horses/horses-infrastructure/src/main/kotlin/at/mocode/horses/infrastructure/persistence/HorseRepositoryImpl.kt.disabled b/backend/services/horses/horses-infrastructure/src/main/kotlin/at/mocode/horses/infrastructure/persistence/HorseRepositoryImpl.kt.disabled new file mode 100644 index 00000000..a49d8514 --- /dev/null +++ b/backend/services/horses/horses-infrastructure/src/main/kotlin/at/mocode/horses/infrastructure/persistence/HorseRepositoryImpl.kt.disabled @@ -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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 + } +} diff --git a/backend/services/horses/horses-infrastructure/src/main/kotlin/at/mocode/horses/infrastructure/persistence/ZnsClubTable.kt b/backend/services/horses/horses-infrastructure/src/main/kotlin/at/mocode/horses/infrastructure/persistence/ZnsClubTable.kt new file mode 100644 index 00000000..c10e3744 --- /dev/null +++ b/backend/services/horses/horses-infrastructure/src/main/kotlin/at/mocode/horses/infrastructure/persistence/ZnsClubTable.kt @@ -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") +} diff --git a/backend/services/horses/horses-infrastructure/src/main/kotlin/at/mocode/horses/infrastructure/persistence/ZnsPersonTable.kt b/backend/services/horses/horses-infrastructure/src/main/kotlin/at/mocode/horses/infrastructure/persistence/ZnsPersonTable.kt new file mode 100644 index 00000000..83d234d9 --- /dev/null +++ b/backend/services/horses/horses-infrastructure/src/main/kotlin/at/mocode/horses/infrastructure/persistence/ZnsPersonTable.kt @@ -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") +} diff --git a/backend/services/horses/horses-service/build.gradle.kts b/backend/services/horses/horses-service/build.gradle.kts index 8f34b92c..b5b6c301 100644 --- a/backend/services/horses/horses-service/build.gradle.kts +++ b/backend/services/horses/horses-service/build.gradle.kts @@ -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 diff --git a/backend/services/horses/horses-service/src/main/kotlin/at/mocode/horses/service/config/ApplicationConfiguration.kt.disabled b/backend/services/horses/horses-service/src/main/kotlin/at/mocode/horses/service/config/ApplicationConfiguration.kt.disabled new file mode 100644 index 00000000..4605095f --- /dev/null +++ b/backend/services/horses/horses-service/src/main/kotlin/at/mocode/horses/service/config/ApplicationConfiguration.kt.disabled @@ -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) + } +} diff --git a/backend/services/horses/horses-service/src/main/kotlin/at/mocode/horses/service/config/DatabaseConfiguration.kt b/backend/services/horses/horses-service/src/main/kotlin/at/mocode/horses/service/config/DatabaseConfiguration.kt index 6fba1505..9894862a 100644 --- a/backend/services/horses/horses-service/src/main/kotlin/at/mocode/horses/service/config/DatabaseConfiguration.kt +++ b/backend/services/horses/horses-service/src/main/kotlin/at/mocode/horses/service/config/DatabaseConfiguration.kt @@ -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") } } diff --git a/backend/services/horses/horses-service/src/main/kotlin/at/mocode/horses/service/config/DatabaseConfiguration.kt.disabled b/backend/services/horses/horses-service/src/main/kotlin/at/mocode/horses/service/config/DatabaseConfiguration.kt.disabled new file mode 100644 index 00000000..971a9a50 --- /dev/null +++ b/backend/services/horses/horses-service/src/main/kotlin/at/mocode/horses/service/config/DatabaseConfiguration.kt.disabled @@ -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) + } + } +} diff --git a/backend/services/horses/horses-service/src/main/kotlin/at/mocode/horses/service/dev/ZnsDataSeeder.kt b/backend/services/horses/horses-service/src/main/kotlin/at/mocode/horses/service/dev/ZnsDataSeeder.kt new file mode 100644 index 00000000..8d83eee0 --- /dev/null +++ b/backend/services/horses/horses-service/src/main/kotlin/at/mocode/horses/service/dev/ZnsDataSeeder.kt @@ -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 + } + } + +} diff --git a/backend/services/horses/horses-service/src/test/kotlin/at/mocode/horses/service/integration/HorseServiceIntegrationTest.kt b/backend/services/horses/horses-service/src/test/kotlin/at/mocode/horses/service/integration/HorseServiceIntegrationTest.kt deleted file mode 100644 index 94b562ec..00000000 --- a/backend/services/horses/horses-service/src/test/kotlin/at/mocode/horses/service/integration/HorseServiceIntegrationTest.kt +++ /dev/null @@ -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") - } -} diff --git a/backend/services/horses/horses-service/src/test/kotlin/at/mocode/horses/service/integration/TransactionContextTest.kt b/backend/services/horses/horses-service/src/test/kotlin/at/mocode/horses/service/integration/TransactionContextTest.kt deleted file mode 100644 index ea8cdb9c..00000000 --- a/backend/services/horses/horses-service/src/test/kotlin/at/mocode/horses/service/integration/TransactionContextTest.kt +++ /dev/null @@ -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") - } -} diff --git a/backend/services/horses/horses-service/src/test/kotlin/at/mocode/horses/service/integration/TransactionalContextTest.kt b/backend/services/horses/horses-service/src/test/kotlin/at/mocode/horses/service/integration/TransactionalContextTest.kt deleted file mode 100644 index 72e5e7cc..00000000 --- a/backend/services/horses/horses-service/src/test/kotlin/at/mocode/horses/service/integration/TransactionalContextTest.kt +++ /dev/null @@ -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") - } -} diff --git a/backend/services/horses/horses-service/src/test/resources/logback-test.xml b/backend/services/horses/horses-service/src/test/resources/logback-test.xml deleted file mode 100644 index 379e9ea6..00000000 --- a/backend/services/horses/horses-service/src/test/resources/logback-test.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - - - - - - diff --git a/backend/services/officials/officials-infrastructure/build.gradle.kts b/backend/services/officials/officials-infrastructure/build.gradle.kts index 71f96b46..a91c977a 100644 --- a/backend/services/officials/officials-infrastructure/build.gradle.kts +++ b/backend/services/officials/officials-infrastructure/build.gradle.kts @@ -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) diff --git a/backend/services/officials/officials-service/build.gradle.kts b/backend/services/officials/officials-service/build.gradle.kts index a209ef93..ffe6187a 100644 --- a/backend/services/officials/officials-service/build.gradle.kts +++ b/backend/services/officials/officials-service/build.gradle.kts @@ -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) diff --git a/backend/services/officials/officials-service/src/main/kotlin/at/mocode/officials/service/config/DatabaseConfiguration.kt b/backend/services/officials/officials-service/src/main/kotlin/at/mocode/officials/service/config/DatabaseConfiguration.kt index eece616c..158d6d8f 100644 --- a/backend/services/officials/officials-service/src/main/kotlin/at/mocode/officials/service/config/DatabaseConfiguration.kt +++ b/backend/services/officials/officials-service/src/main/kotlin/at/mocode/officials/service/config/DatabaseConfiguration.kt @@ -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") } } diff --git a/backend/services/officials/officials-service/src/main/kotlin/at/mocode/officials/service/dev/ZnsOfficialSeeder.kt b/backend/services/officials/officials-service/src/main/kotlin/at/mocode/officials/service/dev/ZnsOfficialSeeder.kt index a4785221..56594e53 100644 --- a/backend/services/officials/officials-service/src/main/kotlin/at/mocode/officials/service/dev/ZnsOfficialSeeder.kt +++ b/backend/services/officials/officials-service/src/main/kotlin/at/mocode/officials/service/dev/ZnsOfficialSeeder.kt @@ -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.") diff --git a/backend/services/persons/persons-infrastructure/build.gradle.kts b/backend/services/persons/persons-infrastructure/build.gradle.kts index b8fe39c9..3306c3bf 100644 --- a/backend/services/persons/persons-infrastructure/build.gradle.kts +++ b/backend/services/persons/persons-infrastructure/build.gradle.kts @@ -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) diff --git a/backend/services/persons/persons-service/build.gradle.kts b/backend/services/persons/persons-service/build.gradle.kts index 43b2216f..6d3271c8 100644 --- a/backend/services/persons/persons-service/build.gradle.kts +++ b/backend/services/persons/persons-service/build.gradle.kts @@ -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) diff --git a/backend/services/persons/persons-service/src/main/kotlin/at/mocode/persons/service/config/DatabaseConfiguration.kt b/backend/services/persons/persons-service/src/main/kotlin/at/mocode/persons/service/config/DatabaseConfiguration.kt index 866d1700..62d6ae30 100644 --- a/backend/services/persons/persons-service/src/main/kotlin/at/mocode/persons/service/config/DatabaseConfiguration.kt +++ b/backend/services/persons/persons-service/src/main/kotlin/at/mocode/persons/service/config/DatabaseConfiguration.kt @@ -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") } } diff --git a/backend/services/persons/persons-service/src/main/kotlin/at/mocode/persons/service/dev/ZnsPersonSeeder.kt b/backend/services/persons/persons-service/src/main/kotlin/at/mocode/persons/service/dev/ZnsPersonSeeder.kt index ad42705c..d0852e58 100644 --- a/backend/services/persons/persons-service/src/main/kotlin/at/mocode/persons/service/dev/ZnsPersonSeeder.kt +++ b/backend/services/persons/persons-service/src/main/kotlin/at/mocode/persons/service/dev/ZnsPersonSeeder.kt @@ -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.") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2ddc6945..40e54094 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 0cb10899..824077db 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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")