chore(ci): Align GH Workflows with Docker SSoT, new paths; minimal SSoT guard; staticAnalysis (#23)

* chore(MP-21): snapshot pre-refactor state (Epic 1)

* chore(MP-22): scaffold new repo structure, relocate Docker Compose, move frontend/backend modules, update Makefile; add docs mapping and env template

* MP-22 Epic 2: Erfolgreich umgesetzt und verifiziert

* MP-23 Epic 3: Gradle/Build Governance zentralisieren

* MP-23 Epic 3: Gradle/Build Governance zentralisieren

* chore(devops)!: Docker-SSoT (.env) konsolidiert, Compose-Mounts ergänzt, Makefile entfernt

- ENV Single Source of Truth
  - docker/.env.example neu (inkl. REDIS_PASSWORD, Ports, Build-Overrides)
  - config/.env(.example) als DEPRECATED markiert (Verweis auf docker/.env[.example])

- Docker Compose vereinheitlicht (docker/docker-compose.yaml)
  - Postgres: zentralen postgresql.conf mounten (../config/postgres/postgresql.conf)
    und Start mit -c config_file=/etc/postgresql/postgresql.conf
  - Redis: zentralen redis.conf mounten (../config/redis/redis.conf)
    und Start via "redis-server … ${REDIS_PASSWORD:+--requirepass $REDIS_PASSWORD}"
  - Web-Nginx: ../config/nginx/nginx.prod.conf → /etc/nginx/nginx.conf (ro)
  - Monitoring: Prometheus/Grafana nutzen ../config/monitoring/* als SSoT

- Frontend/DI/Network (MP-23 Grundlage)
  - :frontend:core:network Modul mit Koin `apiClient` (Ktor + JSON/Retry/Timeout/Logging)
  - Plattform-Basis-URL-Auflösung (JVM: ENV API_BASE_URL; JS: globalThis.API_BASE_URL / Same-Origin)
  - Web index.html setzt API_BASE_URL (Query `?apiBaseUrl=…` > Same-Origin > Fallback)

- Build/Gradle & Module-Refs
  - settings.gradle.kts: neue Frontend-/Backend-Pfade bereits inkludiert
  - Features/Shell: Abhängigkeiten auf :frontend:shared / :frontend:core:* angepasst
  - Ping-API-Refs auf :backend:services:ping:ping-api vereinheitlicht

- Dockerfiles angepasst
  - backend/infrastructure/gateway/Dockerfile → Tasks/Pfade auf :backend:gateway
  - backend/services/ping/Dockerfile → Tasks/Pfade auf :backend:services:ping:ping-service

- Static Analysis / Guards
  - config/detekt/detekt.yml hinzugefügt
  - Leichter Arch-Guard (Frontend) gegen manuelle Authorization-Header vorbereitet

- Doku
  - docs/ARCHITECTURE.md (Struktur, Mapping, Next Steps) ergänzt
  - docs/adr/README.md angelegt

BREAKING CHANGES:
- Makefile komplett entfernt (bitte direkt `docker compose` verwenden)
- ENV-Quelle ist jetzt docker/.env (statt config/.env oder Root)
- Compose-Datei unter docker/docker-compose.yaml (nicht mehr compose.yaml im Repo-Root)

Verifikation (lokal):
- ENV anlegen: `cp docker/.env.example docker/.env` (Werte anpassen)
- Compose prüfen: `docker compose --env-file docker/.env -f docker/docker-compose.yaml config`
- Infrastruktur: `docker compose --env-file docker/.env -f docker/docker-compose.yaml -p meldestelle up -d postgres redis keycloak web-app`
- Services bauen: `docker compose --env-file docker/.env -f docker/docker-compose.yaml -p meldestelle build api-gateway ping-service --no-cache --progress=plain`

Refs: MP-22 (Epic 2), MP-23 (Epic 3)

* chore(devops)!: Docker-SSoT (.env) konsolidiert, Compose-Mounts ergänzt, Makefile entfernt

- ENV Single Source of Truth
  - docker/.env.example neu (inkl. REDIS_PASSWORD, Ports, Build-Overrides)
  - config/.env(.example) als DEPRECATED markiert (Verweis auf docker/.env[.example])

- Docker Compose vereinheitlicht (docker/docker-compose.yaml)
  - Postgres: zentralen postgresql.conf mounten (../config/postgres/postgresql.conf)
    und Start mit -c config_file=/etc/postgresql/postgresql.conf
  - Redis: zentralen redis.conf mounten (../config/redis/redis.conf)
    und Start via "redis-server … ${REDIS_PASSWORD:+--requirepass $REDIS_PASSWORD}"
  - Web-Nginx: ../config/nginx/nginx.prod.conf → /etc/nginx/nginx.conf (ro)
  - Monitoring: Prometheus/Grafana nutzen ../config/monitoring/* als SSoT

- Frontend/DI/Network (MP-23 Grundlage)
  - :frontend:core:network Modul mit Koin `apiClient` (Ktor + JSON/Retry/Timeout/Logging)
  - Plattform-Basis-URL-Auflösung (JVM: ENV API_BASE_URL; JS: globalThis.API_BASE_URL / Same-Origin)
  - Web index.html setzt API_BASE_URL (Query `?apiBaseUrl=…` > Same-Origin > Fallback)

- Build/Gradle & Module-Refs
  - settings.gradle.kts: neue Frontend-/Backend-Pfade bereits inkludiert
  - Features/Shell: Abhängigkeiten auf :frontend:shared / :frontend:core:* angepasst
  - Ping-API-Refs auf :backend:services:ping:ping-api vereinheitlicht

- Dockerfiles angepasst
  - backend/infrastructure/gateway/Dockerfile → Tasks/Pfade auf :backend:gateway
  - backend/services/ping/Dockerfile → Tasks/Pfade auf :backend:services:ping:ping-service

- Static Analysis / Guards
  - config/detekt/detekt.yml hinzugefügt
  - Leichter Arch-Guard (Frontend) gegen manuelle Authorization-Header vorbereitet

- Doku
  - docs/ARCHITECTURE.md (Struktur, Mapping, Next Steps) ergänzt
  - docs/adr/README.md angelegt

BREAKING CHANGES:
- Makefile komplett entfernt (bitte direkt `docker compose` verwenden)
- ENV-Quelle ist jetzt docker/.env (statt config/.env oder Root)
- Compose-Datei unter docker/docker-compose.yaml (nicht mehr compose.yaml im Repo-Root)

Verifikation (lokal):
- ENV anlegen: `cp docker/.env.example docker/.env` (Werte anpassen)
- Compose prüfen: `docker compose --env-file docker/.env -f docker/docker-compose.yaml config`
- Infrastruktur: `docker compose --env-file docker/.env -f docker/docker-compose.yaml -p meldestelle up -d postgres redis keycloak web-app`
- Services bauen: `docker compose --env-file docker/.env -f docker/docker-compose.yaml -p meldestelle build api-gateway ping-service --no-cache --progress=plain`

Refs: MP-22 (Epic 2), MP-23 (Epic 3)

* chore(devops)!: Docker-SSoT (.env) konsolidiert, Compose-Mounts ergänzt, Makefile entfernt

- ENV Single Source of Truth
  - docker/.env.example neu (inkl. REDIS_PASSWORD, Ports, Build-Overrides)
  - config/.env(.example) als DEPRECATED markiert (Verweis auf docker/.env[.example])

- Docker Compose vereinheitlicht (docker/docker-compose.yaml)
  - Postgres: zentralen postgresql.conf mounten (../config/postgres/postgresql.conf)
    und Start mit -c config_file=/etc/postgresql/postgresql.conf
  - Redis: zentralen redis.conf mounten (../config/redis/redis.conf)
    und Start via "redis-server … ${REDIS_PASSWORD:+--requirepass $REDIS_PASSWORD}"
  - Web-Nginx: ../config/nginx/nginx.prod.conf → /etc/nginx/nginx.conf (ro)
  - Monitoring: Prometheus/Grafana nutzen ../config/monitoring/* als SSoT

- Frontend/DI/Network (MP-23 Grundlage)
  - :frontend:core:network Modul mit Koin `apiClient` (Ktor + JSON/Retry/Timeout/Logging)
  - Plattform-Basis-URL-Auflösung (JVM: ENV API_BASE_URL; JS: globalThis.API_BASE_URL / Same-Origin)
  - Web index.html setzt API_BASE_URL (Query `?apiBaseUrl=…` > Same-Origin > Fallback)

- Build/Gradle & Module-Refs
  - settings.gradle.kts: neue Frontend-/Backend-Pfade bereits inkludiert
  - Features/Shell: Abhängigkeiten auf :frontend:shared / :frontend:core:* angepasst
  - Ping-API-Refs auf :backend:services:ping:ping-api vereinheitlicht

- Dockerfiles angepasst
  - backend/infrastructure/gateway/Dockerfile → Tasks/Pfade auf :backend:gateway
  - backend/services/ping/Dockerfile → Tasks/Pfade auf :backend:services:ping:ping-service

- Static Analysis / Guards
  - config/detekt/detekt.yml hinzugefügt
  - Leichter Arch-Guard (Frontend) gegen manuelle Authorization-Header vorbereitet

- Doku
  - docs/ARCHITECTURE.md (Struktur, Mapping, Next Steps) ergänzt
  - docs/adr/README.md angelegt

BREAKING CHANGES:
- Makefile komplett entfernt (bitte direkt `docker compose` verwenden)
- ENV-Quelle ist jetzt docker/.env (statt config/.env oder Root)
- Compose-Datei unter docker/docker-compose.yaml (nicht mehr compose.yaml im Repo-Root)

Verifikation (lokal):
- ENV anlegen: `cp docker/.env.example docker/.env` (Werte anpassen)
- Compose prüfen: `docker compose --env-file docker/.env -f docker/docker-compose.yaml config`
- Infrastruktur: `docker compose --env-file docker/.env -f docker/docker-compose.yaml -p meldestelle up -d postgres redis keycloak web-app`
- Services bauen: `docker compose --env-file docker/.env -f docker/docker-compose.yaml -p meldestelle build api-gateway ping-service --no-cache --progress=plain`

Refs: MP-22 (Epic 2), MP-23 (Epic 3)

* chore(ci): Workflows an Docker-SSoT & neue Struktur angepasst, minimaler SSoT-Guard

- ssot-guard.yml: Option B (minimal) → `docker compose -f docker/docker-compose.yaml config` als Lint
- integration-tests.yml: `./gradlew staticAnalysis` vor Integrationstests
- docs-kdoc-sync.yml: Dokka-Task Fallback (dokkaGfmAll || dokkaGfm), YouTrack-Sync nur wenn Script vorhanden
- deploy-proxmox.yml: Compose-Pfade auf docker/docker-compose.yaml + `--env-file docker/.env`; Build/Test Schritte vereinheitlicht
- ci-main.yml: SSoT-Skripte per `if: hashFiles(...)` guarded, Compose-Lint Fallback; OpenAPI‑Pfad → backend/gateway; ADR‑Pfade → docs/adr/**; `staticAnalysis` in Build integriert
- youtrack-sync.yml: unverändert (funktional)

Refs: MP-22, MP-23

* chore(ci): Workflows an Docker-SSoT & neue Struktur angepasst, minimaler SSoT-Guard

- ssot-guard.yml: Option B (minimal) → `docker compose -f docker/docker-compose.yaml config` als Lint
- integration-tests.yml: `./gradlew staticAnalysis` vor Integrationstests
- docs-kdoc-sync.yml: Dokka-Task Fallback (dokkaGfmAll || dokkaGfm), YouTrack-Sync nur wenn Script vorhanden
- deploy-proxmox.yml: Compose-Pfade auf docker/docker-compose.yaml + `--env-file docker/.env`; Build/Test Schritte vereinheitlicht
- ci-main.yml: SSoT-Skripte per `if: hashFiles(...)` guarded, Compose-Lint Fallback; OpenAPI‑Pfad → backend/gateway; ADR‑Pfade → docs/adr/**; `staticAnalysis` in Build integriert
- youtrack-sync.yml: unverändert (funktional)

Refs: MP-22, MP-23

* fix(ci): create .env from example before validating compose config

* fix(ci): update ssot-guard filename (.yaml) and sync workflow state

* fixing

* fix(webpack): correct sql.js fallback configuration for webpack 5
This commit is contained in:
StefanMo
2025-12-03 12:03:40 +01:00
committed by GitHub
parent 034892e890
commit 95fe3e0573
365 changed files with 2283 additions and 15142 deletions
+167
View File
@@ -0,0 +1,167 @@
# syntax=docker/dockerfile:1.7
# ===================================================================
# Dockerfile for Horses Service
# Based on Spring Boot Service Template with Horses-specific configuration
# ===================================================================
# === CENTRALIZED BUILD ARGUMENTS ===
# Values sourced from docker/versions.toml and docker/build-args/
# Global arguments (docker/build-args/global.env)
ARG GRADLE_VERSION
ARG JAVA_VERSION
ARG BUILD_DATE
ARG VERSION
# Service-specific arguments (docker/build-args/services.env)
# Note: Keine Runtime-Profile/Ports als Build-ARGs
ARG SERVICE_PATH=horses/horses-service
ARG SERVICE_NAME=horses-service
# ===================================================================
# Build Stage
# ===================================================================
FROM gradle:${GRADLE_VERSION}-jdk${JAVA_VERSION}-alpine AS builder
# Re-declare build arguments for diesem Stage (nur Build-Zeit)
ARG SERVICE_PATH=horses/horses-service
ARG SERVICE_NAME=horses-service
LABEL stage=builder
LABEL maintainer="Meldestelle Development Team"
WORKDIR /workspace
# Gradle optimizations
ENV GRADLE_OPTS="-Dorg.gradle.caching=true \
-Dorg.gradle.daemon=false \
-Dorg.gradle.parallel=true \
-Dorg.gradle.configureondemand=true \
-Xmx2g"
# Copy build files in optimal order for caching
COPY gradlew gradlew.bat gradle.properties settings.gradle.kts ./
COPY gradle/ gradle/
# Make gradlew executable (required on Linux/Unix systems)
RUN chmod +x gradlew
COPY platform/ platform/
COPY core/ core/
COPY build.gradle.kts ./
# Copy horses service modules in dependency order
COPY horses/horses-domain/ horses/horses-domain/
COPY horses/horses-api/ horses/horses-api/
COPY horses/horses-application/ horses/horses-application/
COPY horses/horses-infrastructure/ horses/horses-infrastructure/
COPY horses/horses-service/ horses/horses-service/
# Build horses service (ohne Runtime-Profile bei Build)
RUN echo "Building Horses Service..." && \
./gradlew :horses:horses-service:dependencies --no-daemon --info && \
./gradlew :horses:horses-service:bootJar --no-daemon --info
# Extract JAR layers for optimized Docker layer caching
WORKDIR /builder
RUN cp /workspace/horses/horses-service/build/libs/*.jar app.jar && \
java -Djarmode=layertools -jar app.jar extract
# ===================================================================
# Runtime Stage
# ===================================================================
FROM eclipse-temurin:${JAVA_VERSION}-jre-alpine AS runtime
# Metadata
LABEL service="horses-service" \
version="1.0.0" \
description="Horses Management Service for Austrian Equestrian Federation" \
maintainer="Meldestelle Development Team" \
java.version="${JAVA_VERSION}"
# Build arguments
ARG APP_USER=horsesuser
ARG APP_GROUP=horsesgroup
ARG APP_UID=1005
ARG APP_GID=1005
WORKDIR /app
# System setup
RUN apk update && \
apk upgrade && \
apk add --no-cache curl jq tzdata && \
rm -rf /var/cache/apk/*
# Non-root user creation
RUN addgroup -g ${APP_GID} -S ${APP_GROUP} && \
adduser -u ${APP_UID} -S ${APP_USER} -G ${APP_GROUP} -h /app -s /bin/sh
# Directory setup
RUN mkdir -p /app/logs /app/tmp && \
chown -R ${APP_USER}:${APP_GROUP} /app
# Re-declare build arguments for runtime stage
ARG SERVICE_PATH=horses/horses-service
ARG SERVICE_NAME=horses-service
# Copy Spring Boot layers in optimal order for Docker layer caching
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/dependencies/ ./
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/spring-boot-loader/ ./
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/snapshot-dependencies/ ./
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/application/ ./
USER ${APP_USER}
# Expose application port and debug port
EXPOSE 8084 5005
# Health check
HEALTHCHECK --interval=15s --timeout=3s --start-period=40s --retries=3 \
CMD curl -fsS --max-time 2 http://localhost:8084/actuator/health/readiness || exit 1
# JVM configuration optimized for horses service
ENV JAVA_OPTS="-XX:MaxRAMPercentage=80.0 \
-XX:+UseG1GC \
-XX:+UseStringDeduplication \
-XX:+UseContainerSupport \
-XX:G1HeapRegionSize=16m \
-XX:+OptimizeStringConcat \
-XX:+UseCompressedOops \
-Djava.security.egd=file:/dev/./urandom \
-Djava.awt.headless=true \
-Dfile.encoding=UTF-8 \
-Duser.timezone=Europe/Vienna \
-Dmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus"
# Spring Boot configuration (Profile nur zur Laufzeit via Compose/Env)
ENV SPRING_OUTPUT_ANSI_ENABLED=ALWAYS \
SERVER_PORT=8084 \
LOGGING_LEVEL_ROOT=INFO \
LOGGING_LEVEL_AT_MOCODE_HORSES=DEBUG
# Startup command with debug support
ENTRYPOINT ["sh", "-c", "\
echo 'Starting Horses Service on port 8084...'; \
if [ \"${DEBUG:-false}\" = \"true\" ]; then \
echo 'Debug mode enabled on port 5005'; \
exec java $JAVA_OPTS -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 org.springframework.boot.loader.launch.JarLauncher; \
else \
exec java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher; \
fi"]
# ===================================================================
# Documentation
# ===================================================================
# Build commands:
# docker build -t meldestelle/horses-service:latest -f dockerfiles/services/horses-service/Dockerfile .
# docker run -p 8085:8085 --name horses-service meldestelle/horses-service:latest
#
# Key features:
# - Multi-stage build with JAR layer extraction for optimal caching
# - Non-root user execution for security (UID/GID 1005)
# - Optimized JVM settings for containers
# - Comprehensive health checks with horses-specific endpoint
# - Debug support on port 5005
# - Vienna timezone configuration for Austrian operations
# ===================================================================
@@ -0,0 +1,44 @@
plugins {
// KORREKTUR: Alle Plugins werden jetzt konsistent über den Version Catalog geladen.
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.spring)
alias(libs.plugins.kotlin.serialization)
// Das Ktor-Plugin wird hier nicht benötigt, da Ktor als Bibliothek in Spring Boot läuft.
// Das 'application'-Plugin wird vom Spring Boot Plugin bereitgestellt.
alias(libs.plugins.spring.boot)
alias(libs.plugins.spring.dependencyManagement)
}
// Der springBoot-Block konfiguriert die Anwendung, wenn sie als JAR-Datei ausgeführt wird.
springBoot {
mainClass.set("at.mocode.horses.api.ApplicationKt")
}
dependencies {
// Interne Module
implementation(projects.platform.platformDependencies)
implementation(projects.horses.horsesDomain)
implementation(projects.horses.horsesApplication)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
// KORREKTUR: Alle externen Abhängigkeiten werden jetzt über den Version Catalog bezogen.
// Spring dependencies
implementation(libs.spring.web)
// Ktor Server (als embedded Server in Spring)
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.netty)
implementation(libs.ktor.server.contentNegotiation)
implementation(libs.ktor.server.serialization.kotlinx.json)
implementation(libs.ktor.server.statusPages)
implementation(libs.ktor.server.auth)
implementation(libs.ktor.server.authJwt)
// Testing
testImplementation(projects.platform.platformTesting)
testImplementation(libs.ktor.server.tests)
testImplementation(libs.spring.boot.starter.test)
}
@@ -0,0 +1,438 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.horses.api.rest
import at.mocode.core.domain.model.ApiResponse
import at.mocode.core.domain.model.PferdeGeschlechtE
import at.mocode.horses.application.usecase.CreateHorseUseCase
import at.mocode.horses.application.usecase.DeleteHorseUseCase
import at.mocode.horses.application.usecase.GetHorseUseCase
import at.mocode.horses.application.usecase.UpdateHorseUseCase
import at.mocode.horses.domain.repository.HorseRepository
import at.mocode.core.utils.validation.ApiValidationUtils
import kotlin.uuid.Uuid
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable
/**
* REST API controller for horse registry operations.
*
* This controller provides HTTP endpoints for all horse-related operations
* following REST conventions and proper HTTP status codes.
*/
class HorseController(
private val horseRepository: HorseRepository
) {
private val getHorseUseCase = GetHorseUseCase(horseRepository)
private val createHorseUseCase = CreateHorseUseCase(horseRepository)
private val updateHorseUseCase = UpdateHorseUseCase(horseRepository)
private val deleteHorseUseCase = DeleteHorseUseCase(horseRepository)
/**
* Configures the horse-related routes.
*/
fun configureRoutes(routing: Routing) {
routing.route("/api/horses") {
// GET /api/horses - Get all horses with optional filtering
get {
try {
// Validate query parameters
val validationErrors = ApiValidationUtils.validateQueryParameters(
limit = call.request.queryParameters["limit"],
search = call.request.queryParameters["search"]
)
if (!ApiValidationUtils.isValid(validationErrors)) {
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<Any>(ApiValidationUtils.createErrorMessage(validationErrors))
)
return@get
}
val activeOnly = call.request.queryParameters["activeOnly"]?.toBoolean() ?: true
val limit = call.request.queryParameters["limit"]?.toInt() ?: 100
val ownerId = call.request.queryParameters["ownerId"]?.let {
ApiValidationUtils.validateUuidString(it) ?: return@get call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<Any>("Invalid ownerId format")
)
}
val geschlecht = call.request.queryParameters["geschlecht"]?.let {
try {
PferdeGeschlechtE.valueOf(it)
} catch (_: IllegalArgumentException) {
return@get call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<Any>("Invalid geschlecht value. Valid values: ${PferdeGeschlechtE.entries.joinToString(", ")}")
)
}
}
val rasse = call.request.queryParameters["rasse"]
val searchTerm = call.request.queryParameters["search"]
val horses = when {
searchTerm != null -> getHorseUseCase.searchByName(searchTerm, limit)
ownerId != null -> getHorseUseCase.getByOwnerId(ownerId, activeOnly)
geschlecht != null -> getHorseUseCase.getByGeschlecht(geschlecht, activeOnly, limit)
rasse != null -> getHorseUseCase.getByRasse(rasse, activeOnly, limit)
else -> getHorseUseCase.getAllActive(limit)
}
call.respond(HttpStatusCode.OK, ApiResponse.success(horses))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to retrieve horses: ${e.message}"))
}
}
// GET /api/horses/{id} - Get horse by ID
get("/{id}") {
try {
val horseId = Uuid.parse(call.parameters["id"]!!)
val horse = getHorseUseCase.getById(horseId)
if (horse != null) {
call.respond(HttpStatusCode.OK, ApiResponse.success(horse))
} else {
call.respond(HttpStatusCode.NotFound, ApiResponse.error<Any>("Horse not found"))
}
} catch (_: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Any>("Invalid horse ID format"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to retrieve horse: ${e.message}"))
}
}
// GET /api/horses/search/lebensnummer/{nummer} - Find by life number
get("/search/lebensnummer/{nummer}") {
try {
val lebensnummer = call.parameters["nummer"]!!
val horse = getHorseUseCase.getByLebensnummer(lebensnummer)
if (horse != null) {
call.respond(HttpStatusCode.OK, ApiResponse.success(horse))
} else {
call.respond(HttpStatusCode.NotFound, ApiResponse.error<Any>("Horse with life number '$lebensnummer' not found"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to search horse: ${e.message}"))
}
}
// GET /api/horses/search/chip/{nummer} - Find by chip number
get("/search/chip/{nummer}") {
try {
val chipNummer = call.parameters["nummer"]!!
val horse = getHorseUseCase.getByChipNummer(chipNummer)
if (horse != null) {
call.respond(HttpStatusCode.OK, ApiResponse.success(horse))
} else {
call.respond(HttpStatusCode.NotFound, ApiResponse.error<Any>("Horse with chip number '$chipNummer' not found"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to search horse: ${e.message}"))
}
}
// GET /api/horses/search/passport/{nummer} - Find by passport number
get("/search/passport/{nummer}") {
try {
val passNummer = call.parameters["nummer"]!!
val horse = getHorseUseCase.getByPassNummer(passNummer)
if (horse != null) {
call.respond(HttpStatusCode.OK, ApiResponse.success(horse))
} else {
call.respond(HttpStatusCode.NotFound, ApiResponse.error<Any>("Horse with passport number '$passNummer' not found"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to search horse: ${e.message}"))
}
}
// GET /api/horses/search/oeps/{nummer} - Find by OEPS number
get("/search/oeps/{nummer}") {
try {
val oepsNummer = call.parameters["nummer"]!!
val horse = getHorseUseCase.getByOepsNummer(oepsNummer)
if (horse != null) {
call.respond(HttpStatusCode.OK, ApiResponse.success(horse))
} else {
call.respond(HttpStatusCode.NotFound, ApiResponse.error<Any>("Horse with OEPS number '$oepsNummer' not found"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to search horse: ${e.message}"))
}
}
// GET /api/horses/search/fei/{nummer} - Find by FEI number
get("/search/fei/{nummer}") {
try {
val feiNummer = call.parameters["nummer"]!!
val horse = getHorseUseCase.getByFeiNummer(feiNummer)
if (horse != null) {
call.respond(HttpStatusCode.OK, ApiResponse.success(horse))
} else {
call.respond(HttpStatusCode.NotFound, ApiResponse.error<Any>("Horse with FEI number '$feiNummer' not found"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to search horse: ${e.message}"))
}
}
// GET /api/horses/oeps-registered - Get OEPS registered horses
get("/oeps-registered") {
try {
val activeOnly = call.request.queryParameters["activeOnly"]?.toBoolean() ?: true
val horses = getHorseUseCase.getOepsRegistered(activeOnly)
call.respond(HttpStatusCode.OK, ApiResponse.success(horses))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to retrieve OEPS horses: ${e.message}"))
}
}
// GET /api/horses/fei-registered - Get FEI registered horses
get("/fei-registered") {
try {
val activeOnly = call.request.queryParameters["activeOnly"]?.toBoolean() ?: true
val horses = getHorseUseCase.getFeiRegistered(activeOnly)
call.respond(HttpStatusCode.OK, ApiResponse.success(horses))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to retrieve FEI horses: ${e.message}"))
}
}
// GET /api/horses/stats - Get horse statistics
get("/stats") {
try {
val activeCount = getHorseUseCase.countActive()
val oepsCount = getHorseUseCase.countOepsRegistered(true)
val feiCount = getHorseUseCase.countFeiRegistered(true)
val stats = HorseStats(
totalActive = activeCount,
oepsRegistered = oepsCount,
feiRegistered = feiCount
)
call.respond(HttpStatusCode.OK, ApiResponse.success(stats))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to retrieve statistics: ${e.message}"))
}
}
// POST /api/horses - Create new horse
post {
try {
val createRequest = call.receive<CreateHorseUseCase.CreateHorseRequest>()
// Validate input using shared validation utilities
val validationErrors = ApiValidationUtils.validateHorseRequest(
pferdeName = createRequest.pferdeName,
lebensnummer = createRequest.lebensnummer,
chipNummer = createRequest.chipNummer,
oepsNummer = createRequest.oepsNummer,
feiNummer = createRequest.feiNummer
)
if (!ApiValidationUtils.isValid(validationErrors)) {
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<Any>(ApiValidationUtils.createErrorMessage(validationErrors))
)
return@post
}
val response = createHorseUseCase.execute(createRequest)
if (response.success) {
call.respond(HttpStatusCode.Created, ApiResponse.success(response.data!!))
} else {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Any>("Validation failed"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to create horse: ${e.message}"))
}
}
// PUT /api/horses/{id} - Update horse
put("/{id}") {
try {
val horseId = Uuid.parse(call.parameters["id"]!!)
val updateData = call.receive<UpdateHorseRequest>()
// Validate input using shared validation utilities
val validationErrors = ApiValidationUtils.validateHorseRequest(
pferdeName = updateData.pferdeName,
lebensnummer = updateData.lebensnummer,
chipNummer = updateData.chipNummer,
oepsNummer = updateData.oepsNummer,
feiNummer = updateData.feiNummer
)
if (!ApiValidationUtils.isValid(validationErrors)) {
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<Any>(ApiValidationUtils.createErrorMessage(validationErrors))
)
return@put
}
val updateRequest = UpdateHorseUseCase.UpdateHorseRequest(
pferdId = horseId,
pferdeName = updateData.pferdeName,
geschlecht = updateData.geschlecht,
geburtsdatum = updateData.geburtsdatum,
rasse = updateData.rasse,
farbe = updateData.farbe,
besitzerId = updateData.besitzerId,
verantwortlichePersonId = updateData.verantwortlichePersonId,
zuechterName = updateData.zuechterName,
zuchtbuchNummer = updateData.zuchtbuchNummer,
lebensnummer = updateData.lebensnummer,
chipNummer = updateData.chipNummer,
passNummer = updateData.passNummer,
oepsNummer = updateData.oepsNummer,
feiNummer = updateData.feiNummer,
vaterName = updateData.vaterName,
mutterName = updateData.mutterName,
mutterVaterName = updateData.mutterVaterName,
stockmass = updateData.stockmass,
istAktiv = updateData.istAktiv,
bemerkungen = updateData.bemerkungen,
datenQuelle = updateData.datenQuelle
)
val response = updateHorseUseCase.execute(updateRequest)
if (response.success && response.horse != null) {
call.respond(HttpStatusCode.OK, ApiResponse.success(response.horse))
} else {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Any>("Update failed: ${response.errors.joinToString(", ")}"))
}
} catch (_: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Any>("Invalid horse ID format"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to update horse: ${e.message}"))
}
}
// DELETE /api/horses/{id} - Delete horse
delete("/{id}") {
try {
val horseId = Uuid.parse(call.parameters["id"]!!)
val forceDelete = call.request.queryParameters["force"]?.toBoolean() ?: false
val deleteRequest = DeleteHorseUseCase.DeleteHorseRequest(horseId, forceDelete)
val response = deleteHorseUseCase.execute(deleteRequest)
if (response.success) {
val message = if (response.warnings.isNotEmpty()) {
"Horse deleted successfully. Warnings: ${response.warnings.joinToString(", ")}"
} else {
"Horse deleted successfully"
}
call.respond(HttpStatusCode.OK, ApiResponse.success(message))
} else {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Any>("Delete failed: ${response.errors.joinToString(", ")}"))
}
} catch (_: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Any>("Invalid horse ID format"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to delete horse: ${e.message}"))
}
}
// POST /api/horses/{id}/soft-delete - Soft delete horse (mark as inactive)
post("/{id}/soft-delete") {
try {
val horseId = Uuid.parse(call.parameters["id"]!!)
val response = deleteHorseUseCase.softDelete(horseId)
if (response.success) {
val message = if (response.warnings.isNotEmpty()) {
"Horse marked as inactive. Warnings: ${response.warnings.joinToString(", ")}"
} else {
"Horse marked as inactive"
}
call.respond(HttpStatusCode.OK, ApiResponse.success(message))
} else {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Any>("Soft delete failed: ${response.errors.joinToString(", ")}"))
}
} catch (_: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Any>("Invalid horse ID format"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to soft delete horse: ${e.message}"))
}
}
// POST /api/horses/batch-delete - Batch delete multiple horses
post("/batch-delete") {
try {
val batchRequest = call.receive<BatchDeleteRequest>()
val response = deleteHorseUseCase.batchDelete(batchRequest.horseIds, batchRequest.forceDelete)
val statusCode = if (response.overallSuccess) HttpStatusCode.OK else HttpStatusCode.PartialContent
call.respond(statusCode, ApiResponse.success(response))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to batch delete horses: ${e.message}"))
}
}
}
}
/**
* DTO for updating horse data via API.
*/
@Serializable
data class UpdateHorseRequest(
val pferdeName: String,
val geschlecht: PferdeGeschlechtE,
val geburtsdatum: kotlinx.datetime.LocalDate? = null,
val rasse: String? = null,
val farbe: String? = null,
@Contextual val besitzerId: Uuid? = null,
@Contextual val verantwortlichePersonId: Uuid? = null,
val zuechterName: String? = null,
val zuchtbuchNummer: String? = null,
val lebensnummer: String? = null,
val chipNummer: String? = null,
val passNummer: String? = null,
val oepsNummer: String? = null,
val feiNummer: String? = null,
val vaterName: String? = null,
val mutterName: String? = null,
val mutterVaterName: String? = null,
val stockmass: Int? = null,
val istAktiv: Boolean = true,
val bemerkungen: String? = null,
val datenQuelle: at.mocode.core.domain.model.DatenQuelleE = at.mocode.core.domain.model.DatenQuelleE.MANUELL
)
/**
* DTO for batch delete request.
*/
@Serializable
data class BatchDeleteRequest(
val horseIds: List<@Contextual Uuid>,
val forceDelete: Boolean = false
)
/**
* DTO for horse statistics.
*/
@Serializable
data class HorseStats(
val totalActive: Long,
val oepsRegistered: Long,
val feiRegistered: Long
)
}
@@ -0,0 +1,10 @@
plugins {
kotlin("jvm")
}
dependencies {
implementation(projects.horses.horsesDomain)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
testImplementation(projects.platform.platformTesting)
}
@@ -0,0 +1,207 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.horses.application.usecase
import at.mocode.horses.domain.model.DomPferd
import at.mocode.horses.domain.repository.HorseRepository
import at.mocode.core.domain.model.PferdeGeschlechtE
import at.mocode.core.domain.model.DatenQuelleE
import at.mocode.core.domain.model.ApiResponse
import at.mocode.core.domain.model.ErrorDto
import at.mocode.core.domain.model.ValidationResult
import at.mocode.core.domain.model.ValidationError
import kotlin.uuid.Uuid
import kotlinx.datetime.LocalDate
import kotlinx.datetime.todayIn
/**
* Use case for creating a new horse in the registry.
*
* This use case handles the business logic for horse registration including
* validation, uniqueness checks, and persistence.
*/
class CreateHorseUseCase(
private val horseRepository: HorseRepository
) {
/**
* Request data for creating a new horse.
*/
data class CreateHorseRequest(
val pferdeName: String,
val geschlecht: PferdeGeschlechtE,
val geburtsdatum: LocalDate? = null,
val rasse: String? = null,
val farbe: String? = null,
val besitzerId: Uuid? = null,
val verantwortlichePersonId: Uuid? = null,
val zuechterName: String? = null,
val zuchtbuchNummer: String? = null,
val lebensnummer: String? = null,
val chipNummer: String? = null,
val passNummer: String? = null,
val oepsNummer: String? = null,
val feiNummer: String? = null,
val vaterName: String? = null,
val mutterName: String? = null,
val mutterVaterName: String? = null,
val stockmass: Int? = null,
val bemerkungen: String? = null,
val datenQuelle: DatenQuelleE = DatenQuelleE.MANUELL
)
/**
* Executes the horse creation use case.
*
* @param request The horse creation request data
* @return ApiResponse with the created horse or validation errors
*/
suspend fun execute(request: CreateHorseRequest): ApiResponse<DomPferd> {
// Create domain object
val horse = DomPferd(
pferdeName = request.pferdeName,
geschlecht = request.geschlecht,
geburtsdatum = request.geburtsdatum,
rasse = request.rasse,
farbe = request.farbe,
besitzerId = request.besitzerId,
verantwortlichePersonId = request.verantwortlichePersonId,
zuechterName = request.zuechterName,
zuchtbuchNummer = request.zuchtbuchNummer,
lebensnummer = request.lebensnummer,
chipNummer = request.chipNummer,
passNummer = request.passNummer,
oepsNummer = request.oepsNummer,
feiNummer = request.feiNummer,
vaterName = request.vaterName,
mutterName = request.mutterName,
mutterVaterName = request.mutterVaterName,
stockmass = request.stockmass,
bemerkungen = request.bemerkungen,
datenQuelle = request.datenQuelle
)
// Validate the horse
val validationResult = validateHorse(horse)
if (!validationResult.isValid()) {
val errors = (validationResult as ValidationResult.Invalid).errors
return ApiResponse(
success = false,
data = null,
error = ErrorDto(
code = "VALIDATION_ERROR",
message = "Horse validation failed",
details = errors.associate { it.field to it.message }
)
)
}
// Check for uniqueness constraints
val uniquenessResult = checkUniquenessConstraints(horse)
if (!uniquenessResult.isValid()) {
val errors = (uniquenessResult as ValidationResult.Invalid).errors
return ApiResponse(
success = false,
data = null,
error = ErrorDto(
code = "UNIQUENESS_ERROR",
message = "Horse uniqueness validation failed",
details = errors.associate { it.field to it.message }
)
)
}
// Save the horse
val savedHorse = horseRepository.save(horse)
return ApiResponse(
success = true,
data = savedHorse,
message = "Horse created successfully"
)
}
/**
* Validates the horse data according to business rules.
*/
private fun validateHorse(horse: DomPferd): ValidationResult {
val errors = mutableListOf<ValidationError>()
// Use domain validation
val domainErrors = horse.validateForRegistration()
domainErrors.forEach { errorMessage ->
errors.add(ValidationError("horse", errorMessage, "DOMAIN_VALIDATION"))
}
// Additional business validations
horse.stockmass?.let { height ->
if (height < 50 || height > 220) {
errors.add(ValidationError("stockmass", "Horse height must be between 50 and 220 cm", "INVALID_RANGE"))
}
}
horse.geburtsdatum?.let { birthDate ->
val currentYear = kotlinx.datetime.Clock.System.todayIn(kotlinx.datetime.TimeZone.currentSystemDefault()).year
if (birthDate.year > currentYear) {
errors.add(ValidationError("geburtsdatum", "Birth date cannot be in the future", "FUTURE_DATE"))
}
if (birthDate.year < (currentYear - 50)) {
errors.add(ValidationError("geburtsdatum", "Birth date cannot be more than 50 years ago", "TOO_OLD"))
}
}
return if (errors.isEmpty()) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(errors)
}
}
/**
* Checks uniqueness constraints for identification numbers.
*/
private suspend fun checkUniquenessConstraints(horse: DomPferd): ValidationResult {
val errors = mutableListOf<ValidationError>()
// Check lebensnummer uniqueness
horse.lebensnummer?.let { lebensnummer ->
if (lebensnummer.isNotBlank() && horseRepository.existsByLebensnummer(lebensnummer)) {
errors.add(ValidationError("lebensnummer", "A horse with this life number already exists", "DUPLICATE"))
}
}
// Check chip number uniqueness
horse.chipNummer?.let { chipNummer ->
if (chipNummer.isNotBlank() && horseRepository.existsByChipNummer(chipNummer)) {
errors.add(ValidationError("chipNummer", "A horse with this chip number already exists", "DUPLICATE"))
}
}
// Check passport number uniqueness
horse.passNummer?.let { passNummer ->
if (passNummer.isNotBlank() && horseRepository.existsByPassNummer(passNummer)) {
errors.add(ValidationError("passNummer", "A horse with this passport number already exists", "DUPLICATE"))
}
}
// Check OEPS number uniqueness
horse.oepsNummer?.let { oepsNummer ->
if (oepsNummer.isNotBlank() && horseRepository.existsByOepsNummer(oepsNummer)) {
errors.add(ValidationError("oepsNummer", "A horse with this OEPS number already exists", "DUPLICATE"))
}
}
// Check FEI number uniqueness
horse.feiNummer?.let { feiNummer ->
if (feiNummer.isNotBlank() && horseRepository.existsByFeiNummer(feiNummer)) {
errors.add(ValidationError("feiNummer", "A horse with this FEI number already exists", "DUPLICATE"))
}
}
return if (errors.isEmpty()) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(errors)
}
}
}
@@ -0,0 +1,215 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.horses.application.usecase
import at.mocode.horses.domain.repository.HorseRepository
import kotlin.uuid.Uuid
/**
* Use case for deleting a horse from the registry.
*
* This use case handles the business logic for horse deletion including
* existence checks and business rule validation.
*/
class DeleteHorseUseCase(
private val horseRepository: HorseRepository
) {
/**
* Request data for deleting a horse.
*/
data class DeleteHorseRequest(
val pferdId: Uuid,
val forceDelete: Boolean = false
)
/**
* Response data for horse deletion.
*/
data class DeleteHorseResponse(
val success: Boolean,
val errors: List<String> = emptyList(),
val warnings: List<String> = emptyList()
)
/**
* Executes the horse deletion use case.
*
* @param request The horse deletion request data
* @return DeleteHorseResponse indicating success or failure with errors
*/
suspend fun execute(request: DeleteHorseRequest): DeleteHorseResponse {
// Check if horse exists
val existingHorse = horseRepository.findById(request.pferdId)
?: return DeleteHorseResponse(
success = false,
errors = listOf("Horse not found")
)
// Check business rules for deletion
val businessRuleErrors = checkBusinessRules(request, existingHorse.pferdeName)
if (businessRuleErrors.isNotEmpty() && !request.forceDelete) {
return DeleteHorseResponse(
success = false,
errors = businessRuleErrors
)
}
// Generate warnings for important information
val warnings = generateWarnings(existingHorse.pferdeName, existingHorse.oepsNummer, existingHorse.feiNummer)
// Perform the deletion
val deleted = horseRepository.delete(request.pferdId)
return if (deleted) {
DeleteHorseResponse(
success = true,
warnings = warnings
)
} else {
DeleteHorseResponse(
success = false,
errors = listOf("Failed to delete horse from database")
)
}
}
/**
* Soft delete alternative - marks horse as inactive instead of deleting.
*/
suspend fun softDelete(pferdId: Uuid): DeleteHorseResponse {
val existingHorse = horseRepository.findById(pferdId)
?: return DeleteHorseResponse(
success = false,
errors = listOf("Horse not found")
)
if (!existingHorse.istAktiv) {
return DeleteHorseResponse(
success = false,
errors = listOf("Horse is already inactive")
)
}
// Mark as inactive
val inactiveHorse = existingHorse.copy(istAktiv = false).withUpdatedTimestamp()
horseRepository.save(inactiveHorse)
return DeleteHorseResponse(
success = true,
warnings = listOf("Horse marked as inactive instead of deleted")
)
}
/**
* Checks business rules that might prevent deletion.
*/
private suspend fun checkBusinessRules(request: DeleteHorseRequest, horseName: String): List<String> {
val errors = mutableListOf<String>()
// In a real system, you would check for:
// - Active competitions/entries
// - Historical records that should be preserved
// - Breeding records
// - License dependencies
// For now, we'll implement basic checks
// Example: Check if horse has OEPS or FEI registration
val horse = horseRepository.findById(request.pferdId)
if (horse != null) {
if (horse.isOepsRegistered() && !request.forceDelete) {
errors.add("Cannot delete OEPS registered horse without force delete flag")
}
if (horse.isFeiRegistered() && !request.forceDelete) {
errors.add("Cannot delete FEI registered horse without force delete flag")
}
// Check if horse has breeding information (might be important for pedigree)
if ((horse.vaterName != null || horse.mutterName != null) && !request.forceDelete) {
errors.add("Horse has pedigree information that might be referenced by other horses")
}
}
return errors
}
/**
* Generates warnings about the deletion.
*/
private fun generateWarnings(horseName: String, oepsNummer: String?, feiNummer: String?): List<String> {
val warnings = mutableListOf<String>()
warnings.add("Horse '$horseName' will be permanently deleted")
if (!oepsNummer.isNullOrBlank()) {
warnings.add("OEPS registration number '$oepsNummer' will be lost")
}
if (!feiNummer.isNullOrBlank()) {
warnings.add("FEI registration number '$feiNummer' will be lost")
}
warnings.add("This action cannot be undone")
return warnings
}
/**
* Batch delete multiple horses.
*/
suspend fun batchDelete(horseIds: List<Uuid>, forceDelete: Boolean = false): BatchDeleteResponse {
val results = mutableListOf<DeleteResult>()
var successCount = 0
var errorCount = 0
for (horseId in horseIds) {
val request = DeleteHorseRequest(horseId, forceDelete)
val response = execute(request)
results.add(
DeleteResult(
horseId = horseId,
success = response.success,
errors = response.errors,
warnings = response.warnings
)
)
if (response.success) {
successCount++
} else {
errorCount++
}
}
return BatchDeleteResponse(
results = results,
successCount = successCount,
errorCount = errorCount,
totalCount = horseIds.size
)
}
/**
* Result for individual horse deletion in batch operation.
*/
data class DeleteResult(
val horseId: Uuid,
val success: Boolean,
val errors: List<String> = emptyList(),
val warnings: List<String> = emptyList()
)
/**
* Response for batch delete operation.
*/
data class BatchDeleteResponse(
val results: List<DeleteResult>,
val successCount: Int,
val errorCount: Int,
val totalCount: Int
) {
val overallSuccess: Boolean = errorCount == 0
}
}
@@ -0,0 +1,304 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.horses.application.usecase
import at.mocode.horses.domain.model.DomPferd
import at.mocode.horses.domain.repository.HorseRepository
import at.mocode.core.domain.model.PferdeGeschlechtE
import kotlin.uuid.Uuid
import kotlinx.datetime.todayIn
/**
* Use case for retrieving horse information.
*
* This use case encapsulates the business logic for fetching horse data
* and provides a clean interface for the application layer.
*/
class GetHorseUseCase(
private val horseRepository: HorseRepository
) {
/**
* Retrieves a horse by its unique ID.
*
* @param horseId The unique identifier of the horse
* @return The horse if found, null otherwise
*/
suspend fun getById(horseId: Uuid): DomPferd? {
return horseRepository.findById(horseId)
}
/**
* Retrieves a horse by its life number.
*
* @param lebensnummer The life number to search for
* @return The horse if found, null otherwise
*/
suspend fun getByLebensnummer(lebensnummer: String): DomPferd? {
require(lebensnummer.isNotBlank()) { "Life number cannot be blank" }
return horseRepository.findByLebensnummer(lebensnummer.trim())
}
/**
* Retrieves a horse by its chip number.
*
* @param chipNummer The chip number to search for
* @return The horse if found, null otherwise
*/
suspend fun getByChipNummer(chipNummer: String): DomPferd? {
require(chipNummer.isNotBlank()) { "Chip number cannot be blank" }
return horseRepository.findByChipNummer(chipNummer.trim())
}
/**
* Retrieves a horse by its passport number.
*
* @param passNummer The passport number to search for
* @return The horse if found, null otherwise
*/
suspend fun getByPassNummer(passNummer: String): DomPferd? {
require(passNummer.isNotBlank()) { "Passport number cannot be blank" }
return horseRepository.findByPassNummer(passNummer.trim())
}
/**
* Retrieves a horse by its OEPS number.
*
* @param oepsNummer The OEPS number to search for
* @return The horse if found, null otherwise
*/
suspend fun getByOepsNummer(oepsNummer: String): DomPferd? {
require(oepsNummer.isNotBlank()) { "OEPS number cannot be blank" }
return horseRepository.findByOepsNummer(oepsNummer.trim())
}
/**
* Retrieves a horse by its FEI number.
*
* @param feiNummer The FEI number to search for
* @return The horse if found, null otherwise
*/
suspend fun getByFeiNummer(feiNummer: String): DomPferd? {
require(feiNummer.isNotBlank()) { "FEI number cannot be blank" }
return horseRepository.findByFeiNummer(feiNummer.trim())
}
/**
* Searches for horses by name (partial match).
*
* @param searchTerm The search term to match against horse names
* @param limit Maximum number of results to return (default: 50)
* @return List of matching horses
*/
suspend fun searchByName(searchTerm: String, limit: Int = 50): List<DomPferd> {
require(searchTerm.isNotBlank()) { "Search term cannot be blank" }
require(limit > 0) { "Limit must be positive" }
return horseRepository.findByName(searchTerm.trim(), limit)
}
/**
* Retrieves all horses owned by a specific person.
*
* @param ownerId The ID of the owner
* @param activeOnly Whether to return only active horses (default: true)
* @return List of horses owned by the person
*/
suspend fun getByOwnerId(ownerId: Uuid, activeOnly: Boolean = true): List<DomPferd> {
return horseRepository.findByOwnerId(ownerId, activeOnly)
}
/**
* Retrieves all horses for which a person is responsible.
*
* @param responsiblePersonId The ID of the responsible person
* @param activeOnly Whether to return only active horses (default: true)
* @return List of horses for which the person is responsible
*/
suspend fun getByResponsiblePersonId(responsiblePersonId: Uuid, activeOnly: Boolean = true): List<DomPferd> {
return horseRepository.findByResponsiblePersonId(responsiblePersonId, activeOnly)
}
/**
* Retrieves horses by gender.
*
* @param geschlecht The gender to filter by
* @param activeOnly Whether to return only active horses (default: true)
* @param limit Maximum number of results to return (default: 100)
* @return List of horses with the specified gender
*/
suspend fun getByGeschlecht(geschlecht: PferdeGeschlechtE, activeOnly: Boolean = true, limit: Int = 100): List<DomPferd> {
require(limit > 0) { "Limit must be positive" }
return horseRepository.findByGeschlecht(geschlecht, activeOnly, limit)
}
/**
* Retrieves horses by breed.
*
* @param rasse The breed to filter by
* @param activeOnly Whether to return only active horses (default: true)
* @param limit Maximum number of results to return (default: 100)
* @return List of horses of the specified breed
*/
suspend fun getByRasse(rasse: String, activeOnly: Boolean = true, limit: Int = 100): List<DomPferd> {
require(rasse.isNotBlank()) { "Breed cannot be blank" }
require(limit > 0) { "Limit must be positive" }
return horseRepository.findByRasse(rasse.trim(), activeOnly, limit)
}
/**
* Retrieves horses by birth year.
*
* @param birthYear The birth year to filter by
* @param activeOnly Whether to return only active horses (default: true)
* @return List of horses born in the specified year
*/
suspend fun getByBirthYear(birthYear: Int, activeOnly: Boolean = true): List<DomPferd> {
require(birthYear > 1900) { "Birth year must be after 1900" }
require(birthYear <= kotlinx.datetime.Clock.System.todayIn(kotlinx.datetime.TimeZone.currentSystemDefault()).year) {
"Birth year cannot be in the future"
}
return horseRepository.findByBirthYear(birthYear, activeOnly)
}
/**
* Retrieves horses by birth year range.
*
* @param fromYear The start year (inclusive)
* @param toYear The end year (inclusive)
* @param activeOnly Whether to return only active horses (default: true)
* @return List of horses born within the specified year range
*/
suspend fun getByBirthYearRange(fromYear: Int, toYear: Int, activeOnly: Boolean = true): List<DomPferd> {
require(fromYear > 1900) { "From year must be after 1900" }
require(toYear >= fromYear) { "To year must be greater than or equal to from year" }
val currentYear = kotlinx.datetime.Clock.System.todayIn(kotlinx.datetime.TimeZone.currentSystemDefault()).year
require(toYear <= currentYear) { "To year cannot be in the future" }
return horseRepository.findByBirthYearRange(fromYear, toYear, activeOnly)
}
/**
* Retrieves all active horses.
*
* @param limit Maximum number of results to return (default: 1000)
* @return List of active horses
*/
suspend fun getAllActive(limit: Int = 1000): List<DomPferd> {
require(limit > 0) { "Limit must be positive" }
return horseRepository.findAllActive(limit)
}
/**
* Retrieves horses with OEPS registration.
*
* @param activeOnly Whether to return only active horses (default: true)
* @return List of OEPS registered horses
*/
suspend fun getOepsRegistered(activeOnly: Boolean = true): List<DomPferd> {
return horseRepository.findOepsRegistered(activeOnly)
}
/**
* Retrieves horses with FEI registration.
*
* @param activeOnly Whether to return only active horses (default: true)
* @return List of FEI registered horses
*/
suspend fun getFeiRegistered(activeOnly: Boolean = true): List<DomPferd> {
return horseRepository.findFeiRegistered(activeOnly)
}
/**
* Checks if a horse with the given life number exists.
*
* @param lebensnummer The life number to check
* @return true if a horse with this life number exists, false otherwise
*/
suspend fun existsByLebensnummer(lebensnummer: String): Boolean {
require(lebensnummer.isNotBlank()) { "Life number cannot be blank" }
return horseRepository.existsByLebensnummer(lebensnummer.trim())
}
/**
* Checks if a horse with the given chip number exists.
*
* @param chipNummer The chip number to check
* @return true if a horse with this chip number exists, false otherwise
*/
suspend fun existsByChipNummer(chipNummer: String): Boolean {
require(chipNummer.isNotBlank()) { "Chip number cannot be blank" }
return horseRepository.existsByChipNummer(chipNummer.trim())
}
/**
* Checks if a horse with the given passport number exists.
*
* @param passNummer The passport number to check
* @return true if a horse with this passport number exists, false otherwise
*/
suspend fun existsByPassNummer(passNummer: String): Boolean {
require(passNummer.isNotBlank()) { "Passport number cannot be blank" }
return horseRepository.existsByPassNummer(passNummer.trim())
}
/**
* Checks if a horse with the given OEPS number exists.
*
* @param oepsNummer The OEPS number to check
* @return true if a horse with this OEPS number exists, false otherwise
*/
suspend fun existsByOepsNummer(oepsNummer: String): Boolean {
require(oepsNummer.isNotBlank()) { "OEPS number cannot be blank" }
return horseRepository.existsByOepsNummer(oepsNummer.trim())
}
/**
* Checks if a horse with the given FEI number exists.
*
* @param feiNummer The FEI number to check
* @return true if a horse with this FEI number exists, false otherwise
*/
suspend fun existsByFeiNummer(feiNummer: String): Boolean {
require(feiNummer.isNotBlank()) { "FEI number cannot be blank" }
return horseRepository.existsByFeiNummer(feiNummer.trim())
}
/**
* Counts the total number of active horses.
*
* @return The total count of active horses
*/
suspend fun countActive(): Long {
return horseRepository.countActive()
}
/**
* Counts horses by owner.
*
* @param ownerId The ID of the owner
* @param activeOnly Whether to count only active horses (default: true)
* @return The count of horses owned by the person
*/
suspend fun countByOwnerId(ownerId: Uuid, activeOnly: Boolean = true): Long {
return horseRepository.countByOwnerId(ownerId, activeOnly)
}
/**
* Counts horses with OEPS registration.
*
* @param activeOnly Whether to count only active horses (default: true)
* @return The count of OEPS registered horses
*/
suspend fun countOepsRegistered(activeOnly: Boolean = true): Long {
return horseRepository.countOepsRegistered(activeOnly)
}
/**
* Counts horses with FEI registration.
*
* @param activeOnly Whether to count only active horses (default: true)
* @return The count of FEI registered horses
*/
suspend fun countFeiRegistered(activeOnly: Boolean = true): Long {
return horseRepository.countFeiRegistered(activeOnly)
}
}
@@ -0,0 +1,256 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.horses.application.usecase
import at.mocode.horses.domain.model.DomPferd
import at.mocode.horses.domain.repository.HorseRepository
import at.mocode.core.domain.model.PferdeGeschlechtE
import at.mocode.core.domain.model.DatenQuelleE
import at.mocode.core.domain.model.ApiResponse
import at.mocode.core.domain.model.ErrorDto
import at.mocode.core.domain.model.ValidationResult
import at.mocode.core.domain.model.ValidationError
import at.mocode.core.utils.database.DatabaseFactory
import kotlin.uuid.Uuid
import kotlinx.datetime.LocalDate
import kotlinx.datetime.todayIn
/**
* Transactional version of CreateHorseUseCase that ensures all database operations
* run within a single transaction to maintain data consistency.
*
* This use case handles the business logic for horse registration including
* validation, uniqueness checks, and persistence - all within a single transaction.
*/
class TransactionalCreateHorseUseCase(
private val horseRepository: HorseRepository
) {
/**
* Request data for creating a new horse.
*/
data class CreateHorseRequest(
val pferdeName: String,
val geschlecht: PferdeGeschlechtE,
val geburtsdatum: LocalDate? = null,
val rasse: String? = null,
val farbe: String? = null,
val besitzerId: Uuid? = null,
val verantwortlichePersonId: Uuid? = null,
val zuechterName: String? = null,
val zuchtbuchNummer: String? = null,
val lebensnummer: String? = null,
val chipNummer: String? = null,
val passNummer: String? = null,
val oepsNummer: String? = null,
val feiNummer: String? = null,
val vaterName: String? = null,
val mutterName: String? = null,
val mutterVaterName: String? = null,
val stockmass: Int? = null,
val bemerkungen: String? = null,
val datenQuelle: DatenQuelleE = DatenQuelleE.MANUELL
)
/**
* Executes the horse creation use case within a single transaction.
*
* @param request The horse creation request data
* @return ApiResponse with the created horse or validation errors
*/
suspend fun execute(request: CreateHorseRequest): ApiResponse<DomPferd> {
println("[DEBUG_LOG] TransactionalCreateHorseUseCase.execute() called for horse: ${request.pferdeName}")
// Wrap the entire use case logic in a single transaction
return DatabaseFactory.dbQuery {
println("[DEBUG_LOG] Inside transaction for horse: ${request.pferdeName}")
// Create domain object
val horse = DomPferd(
pferdeName = request.pferdeName,
geschlecht = request.geschlecht,
geburtsdatum = request.geburtsdatum,
rasse = request.rasse,
farbe = request.farbe,
besitzerId = request.besitzerId,
verantwortlichePersonId = request.verantwortlichePersonId,
zuechterName = request.zuechterName,
zuchtbuchNummer = request.zuchtbuchNummer,
lebensnummer = request.lebensnummer,
chipNummer = request.chipNummer,
passNummer = request.passNummer,
oepsNummer = request.oepsNummer,
feiNummer = request.feiNummer,
vaterName = request.vaterName,
mutterName = request.mutterName,
mutterVaterName = request.mutterVaterName,
stockmass = request.stockmass,
bemerkungen = request.bemerkungen,
datenQuelle = request.datenQuelle
)
// Validate the horse
println("[DEBUG_LOG] Starting validation for horse: ${horse.pferdeName}")
val validationResult = validateHorse(horse)
if (!validationResult.isValid()) {
val errors = (validationResult as ValidationResult.Invalid).errors
println("[DEBUG_LOG] Validation failed for horse: ${horse.pferdeName}, errors: ${errors.map { "${it.field}: ${it.message}" }}")
return@dbQuery ApiResponse(
success = false,
data = null,
error = ErrorDto(
code = "VALIDATION_ERROR",
message = "Horse validation failed",
details = errors.associate { it.field to it.message }
)
)
}
println("[DEBUG_LOG] Validation passed for horse: ${horse.pferdeName}")
// Check for uniqueness constraints - all within the same transaction
println("[DEBUG_LOG] Starting uniqueness check for horse: ${horse.pferdeName}")
val uniquenessResult = checkUniquenessConstraints(horse)
if (!uniquenessResult.isValid()) {
val errors = (uniquenessResult as ValidationResult.Invalid).errors
println("[DEBUG_LOG] Uniqueness check failed for horse: ${horse.pferdeName}, errors: ${errors.map { "${it.field}: ${it.message}" }}")
return@dbQuery ApiResponse(
success = false,
data = null,
error = ErrorDto(
code = "UNIQUENESS_ERROR",
message = "Horse uniqueness validation failed",
details = errors.associate { it.field to it.message }
)
)
}
println("[DEBUG_LOG] Uniqueness check passed for horse: ${horse.pferdeName}")
// Save the horse - still within the same transaction
println("[DEBUG_LOG] Saving horse: ${horse.pferdeName}")
try {
val savedHorse = horseRepository.save(horse)
println("[DEBUG_LOG] Horse saved successfully: ${savedHorse.pferdeName} with ID: ${savedHorse.pferdId}")
ApiResponse(
success = true,
data = savedHorse,
message = "Horse created successfully"
)
} catch (e: Exception) {
println("[DEBUG_LOG] Database constraint violation for horse: ${horse.pferdeName}, error: ${e.message}")
// Handle database constraint violations (duplicate keys)
if (e.message?.contains("unique", ignoreCase = true) == true ||
e.message?.contains("duplicate", ignoreCase = true) == true) {
// Determine which field caused the constraint violation
val constraintField = when {
e.message?.contains("lebensnummer", ignoreCase = true) == true -> "lebensnummer"
e.message?.contains("chip_nummer", ignoreCase = true) == true -> "chipNummer"
e.message?.contains("pass_nummer", ignoreCase = true) == true -> "passNummer"
e.message?.contains("oeps_nummer", ignoreCase = true) == true -> "oepsNummer"
e.message?.contains("fei_nummer", ignoreCase = true) == true -> "feiNummer"
else -> "identification"
}
ApiResponse(
success = false,
data = null,
error = ErrorDto(
code = "UNIQUENESS_ERROR",
message = "Horse uniqueness validation failed due to database constraint",
details = mapOf(constraintField to "A horse with this ${constraintField} already exists")
)
)
} else {
// Re-throw other exceptions
throw e
}
}
}
}
/**
* Validates the horse data according to business rules.
*/
private fun validateHorse(horse: DomPferd): ValidationResult {
val errors = mutableListOf<ValidationError>()
// Use domain validation
val domainErrors = horse.validateForRegistration()
domainErrors.forEach { errorMessage ->
errors.add(ValidationError("horse", errorMessage, "DOMAIN_VALIDATION"))
}
// Additional business validations
horse.stockmass?.let { height ->
if (height < 50 || height > 220) {
errors.add(ValidationError("stockmass", "Horse height must be between 50 and 220 cm", "INVALID_RANGE"))
}
}
horse.geburtsdatum?.let { birthDate ->
val currentYear = kotlinx.datetime.Clock.System.todayIn(kotlinx.datetime.TimeZone.currentSystemDefault()).year
if (birthDate.year > currentYear) {
errors.add(ValidationError("geburtsdatum", "Birth date cannot be in the future", "FUTURE_DATE"))
}
if (birthDate.year < (currentYear - 50)) {
errors.add(ValidationError("geburtsdatum", "Birth date cannot be more than 50 years ago", "TOO_OLD"))
}
}
return if (errors.isEmpty()) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(errors)
}
}
/**
* Checks uniqueness constraints for identification numbers.
* Note: This method is called within a transaction, so all repository calls
* will use the same transaction context.
*/
private suspend fun checkUniquenessConstraints(horse: DomPferd): ValidationResult {
val errors = mutableListOf<ValidationError>()
// Check lebensnummer uniqueness
horse.lebensnummer?.let { lebensnummer ->
if (lebensnummer.isNotBlank() && horseRepository.existsByLebensnummer(lebensnummer)) {
errors.add(ValidationError("lebensnummer", "A horse with this life number already exists", "DUPLICATE"))
}
}
// Check chip number uniqueness
horse.chipNummer?.let { chipNummer ->
if (chipNummer.isNotBlank() && horseRepository.existsByChipNummer(chipNummer)) {
errors.add(ValidationError("chipNummer", "A horse with this chip number already exists", "DUPLICATE"))
}
}
// Check passport number uniqueness
horse.passNummer?.let { passNummer ->
if (passNummer.isNotBlank() && horseRepository.existsByPassNummer(passNummer)) {
errors.add(ValidationError("passNummer", "A horse with this passport number already exists", "DUPLICATE"))
}
}
// Check OEPS number uniqueness
horse.oepsNummer?.let { oepsNummer ->
if (oepsNummer.isNotBlank() && horseRepository.existsByOepsNummer(oepsNummer)) {
errors.add(ValidationError("oepsNummer", "A horse with this OEPS number already exists", "DUPLICATE"))
}
}
// Check FEI number uniqueness
horse.feiNummer?.let { feiNummer ->
if (feiNummer.isNotBlank() && horseRepository.existsByFeiNummer(feiNummer)) {
errors.add(ValidationError("feiNummer", "A horse with this FEI number already exists", "DUPLICATE"))
}
}
return if (errors.isEmpty()) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(errors)
}
}
}
@@ -0,0 +1,213 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.horses.application.usecase
import at.mocode.horses.domain.model.DomPferd
import at.mocode.horses.domain.repository.HorseRepository
import at.mocode.core.domain.model.PferdeGeschlechtE
import at.mocode.core.domain.model.DatenQuelleE
import kotlin.uuid.Uuid
import kotlinx.datetime.LocalDate
import kotlinx.datetime.todayIn
/**
* Use case for updating an existing horse in the registry.
*
* This use case handles the business logic for horse updates including
* validation, uniqueness checks, and persistence.
*/
class UpdateHorseUseCase(
private val horseRepository: HorseRepository
) {
/**
* Request data for updating a horse.
*/
data class UpdateHorseRequest(
val pferdId: Uuid,
val pferdeName: String,
val geschlecht: PferdeGeschlechtE,
val geburtsdatum: LocalDate? = null,
val rasse: String? = null,
val farbe: String? = null,
val besitzerId: Uuid? = null,
val verantwortlichePersonId: Uuid? = null,
val zuechterName: String? = null,
val zuchtbuchNummer: String? = null,
val lebensnummer: String? = null,
val chipNummer: String? = null,
val passNummer: String? = null,
val oepsNummer: String? = null,
val feiNummer: String? = null,
val vaterName: String? = null,
val mutterName: String? = null,
val mutterVaterName: String? = null,
val stockmass: Int? = null,
val istAktiv: Boolean = true,
val bemerkungen: String? = null,
val datenQuelle: DatenQuelleE = DatenQuelleE.MANUELL
)
/**
* Response data for horse update.
*/
data class UpdateHorseResponse(
val horse: DomPferd?,
val success: Boolean,
val errors: List<String> = emptyList()
)
/**
* Executes the horse update use case.
*
* @param request The horse update request data
* @return UpdateHorseResponse with the updated horse or validation errors
*/
suspend fun execute(request: UpdateHorseRequest): UpdateHorseResponse {
// Check if horse exists
val existingHorse = horseRepository.findById(request.pferdId)
?: return UpdateHorseResponse(
horse = null,
success = false,
errors = listOf("Horse not found")
)
// Create updated domain object
val updatedHorse = existingHorse.copy(
pferdeName = request.pferdeName,
geschlecht = request.geschlecht,
geburtsdatum = request.geburtsdatum,
rasse = request.rasse,
farbe = request.farbe,
besitzerId = request.besitzerId,
verantwortlichePersonId = request.verantwortlichePersonId,
zuechterName = request.zuechterName,
zuchtbuchNummer = request.zuchtbuchNummer,
lebensnummer = request.lebensnummer,
chipNummer = request.chipNummer,
passNummer = request.passNummer,
oepsNummer = request.oepsNummer,
feiNummer = request.feiNummer,
vaterName = request.vaterName,
mutterName = request.mutterName,
mutterVaterName = request.mutterVaterName,
stockmass = request.stockmass,
istAktiv = request.istAktiv,
bemerkungen = request.bemerkungen,
datenQuelle = request.datenQuelle
)
// Validate the updated horse
val validationErrors = validateHorse(updatedHorse)
if (validationErrors.isNotEmpty()) {
return UpdateHorseResponse(
horse = updatedHorse,
success = false,
errors = validationErrors
)
}
// Check for uniqueness constraints (excluding current horse)
val uniquenessErrors = checkUniquenessConstraints(updatedHorse, existingHorse)
if (uniquenessErrors.isNotEmpty()) {
return UpdateHorseResponse(
horse = updatedHorse,
success = false,
errors = uniquenessErrors
)
}
// Save the updated horse
val savedHorse = horseRepository.save(updatedHorse)
return UpdateHorseResponse(
horse = savedHorse,
success = true
)
}
/**
* Validates the horse data according to business rules.
*/
private fun validateHorse(horse: DomPferd): List<String> {
val errors = mutableListOf<String>()
// Basic validation
if (horse.pferdeName.isBlank()) {
errors.add("Horse name is required")
}
// Height validation
horse.stockmass?.let { height ->
if (height < 50 || height > 220) {
errors.add("Horse height must be between 50 and 220 cm")
}
}
// Birth date validation
horse.geburtsdatum?.let { birthDate ->
val currentYear = kotlinx.datetime.Clock.System.todayIn(kotlinx.datetime.TimeZone.currentSystemDefault()).year
if (birthDate.year > currentYear) {
errors.add("Birth date cannot be in the future")
}
if (birthDate.year < (currentYear - 50)) {
errors.add("Birth date cannot be more than 50 years ago")
}
}
return errors
}
/**
* Checks uniqueness constraints for identification numbers, excluding the current horse.
*/
private suspend fun checkUniquenessConstraints(updatedHorse: DomPferd, existingHorse: DomPferd): List<String> {
val errors = mutableListOf<String>()
// Check lebensnummer uniqueness (if changed)
updatedHorse.lebensnummer?.let { lebensnummer ->
if (lebensnummer.isNotBlank() &&
lebensnummer != existingHorse.lebensnummer &&
horseRepository.existsByLebensnummer(lebensnummer)) {
errors.add("A horse with this life number already exists")
}
}
// Check chip number uniqueness (if changed)
updatedHorse.chipNummer?.let { chipNummer ->
if (chipNummer.isNotBlank() &&
chipNummer != existingHorse.chipNummer &&
horseRepository.existsByChipNummer(chipNummer)) {
errors.add("A horse with this chip number already exists")
}
}
// Check passport number uniqueness (if changed)
updatedHorse.passNummer?.let { passNummer ->
if (passNummer.isNotBlank() &&
passNummer != existingHorse.passNummer &&
horseRepository.existsByPassNummer(passNummer)) {
errors.add("A horse with this passport number already exists")
}
}
// Check OEPS number uniqueness (if changed)
updatedHorse.oepsNummer?.let { oepsNummer ->
if (oepsNummer.isNotBlank() &&
oepsNummer != existingHorse.oepsNummer &&
horseRepository.existsByOepsNummer(oepsNummer)) {
errors.add("A horse with this OEPS number already exists")
}
}
// Check FEI number uniqueness (if changed)
updatedHorse.feiNummer?.let { feiNummer ->
if (feiNummer.isNotBlank() &&
feiNummer != existingHorse.feiNummer &&
horseRepository.existsByFeiNummer(feiNummer)) {
errors.add("A horse with this FEI number already exists")
}
}
return errors
}
}
@@ -0,0 +1,9 @@
plugins {
kotlin("jvm")
}
dependencies {
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
testImplementation(projects.platform.platformTesting)
}
@@ -0,0 +1,172 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.horses.domain.model
import at.mocode.core.domain.model.PferdeGeschlechtE
import at.mocode.core.domain.model.DatenQuelleE
import at.mocode.core.domain.serialization.KotlinInstantSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import kotlin.uuid.Uuid
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate
import kotlinx.datetime.todayIn
import kotlinx.serialization.Serializable
/**
* Domain model representing a horse in the registry system.
*
* This entity contains all essential information about a horse including
* identification, ownership, breeding information, and administrative data.
* It serves as the core aggregate root for the horse-registry bounded context.
*
* @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 rasse Breed of the horse.
* @property farbe Color/coat of the horse.
* @property besitzerId ID of the current owner (Person from member-management context).
* @property verantwortlichePersonId ID of the responsible person (trainer, rider, etc.).
* @property zuechterName Name of the breeder.
* @property zuchtbuchNummer Studbook number if registered.
* @property lebensnummer Life number (unique identification number).
* @property chipNummer Microchip number for identification.
* @property passNummer Passport number.
* @property oepsNummer OEPS (Austrian Equestrian Federation) number.
* @property feiNummer FEI (International Equestrian Federation) number.
* @property vaterName Name of the sire (father).
* @property mutterName Name of the dam (mother).
* @property mutterVaterName Name of the maternal grandsire.
* @property stockmass Height of the horse in cm.
* @property istAktiv Whether the horse is currently active in the system.
* @property bemerkungen Additional notes or comments.
* @property datenQuelle Source of the data (manual entry, import, etc.).
* @property createdAt Timestamp when this record was created.
* @property updatedAt Timestamp when this record was last updated.
*/
@Serializable
data class DomPferd(
@Serializable(with = UuidSerializer::class)
val pferdId: Uuid = Uuid.random(),
// Basic Information
var pferdeName: String,
var geschlecht: PferdeGeschlechtE,
var geburtsdatum: LocalDate? = null,
var rasse: String? = null,
var farbe: String? = null,
// Ownership and Responsibility
@Serializable(with = UuidSerializer::class)
var besitzerId: Uuid? = null,
@Serializable(with = UuidSerializer::class)
var verantwortlichePersonId: Uuid? = null,
// Breeding Information
var zuechterName: String? = null,
var zuchtbuchNummer: String? = null,
// Identification Numbers
var lebensnummer: String? = null,
var chipNummer: String? = null,
var passNummer: String? = null,
var oepsNummer: String? = null,
var feiNummer: String? = null,
// Pedigree Information
var vaterName: String? = null,
var mutterName: String? = null,
var mutterVaterName: String? = null,
// Physical Characteristics
var stockmass: Int? = null, // Height in cm
// Status and Administrative
var istAktiv: Boolean = true,
var bemerkungen: String? = null,
var datenQuelle: DatenQuelleE = DatenQuelleE.MANUELL,
// Audit Fields
@Serializable(with = KotlinInstantSerializer::class)
val createdAt: Instant = Clock.System.now(),
@Serializable(with = KotlinInstantSerializer::class)
var updatedAt: Instant = Clock.System.now()
) {
/**
* Returns the display name for the horse, combining name and birth year if available.
*/
fun getDisplayName(): String {
return geburtsdatum?.let { birthDate ->
"$pferdeName (${birthDate.year})"
} ?: pferdeName
}
/**
* Checks if the horse has complete identification information.
*/
fun hasCompleteIdentification(): Boolean {
return !lebensnummer.isNullOrBlank() ||
!chipNummer.isNullOrBlank() ||
!passNummer.isNullOrBlank()
}
/**
* Checks if the horse is registered with OEPS.
*/
fun isOepsRegistered(): Boolean {
return !oepsNummer.isNullOrBlank()
}
/**
* Checks if the horse is registered with FEI.
*/
fun isFeiRegistered(): Boolean {
return !feiNummer.isNullOrBlank()
}
/**
* Returns the age of the horse in years, or null if birth date is unknown.
*/
fun getAge(): Int? {
return geburtsdatum?.let { birthDate ->
val today = kotlinx.datetime.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)) {
age--
}
age
}
}
/**
* Validates that required fields are present for horse registration.
*/
fun validateForRegistration(): List<String> {
val errors = mutableListOf<String>()
if (pferdeName.isBlank()) {
errors.add("Horse name is required")
}
if (!hasCompleteIdentification()) {
errors.add("At least one identification number (life number, chip number, or passport number) is required")
}
if (besitzerId == null) {
errors.add("Owner is required")
}
return errors
}
/**
* Creates a copy of this horse with updated timestamp.
*/
fun withUpdatedTimestamp(): DomPferd {
return this.copy(updatedAt = Clock.System.now())
}
}
@@ -0,0 +1,243 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.horses.domain.repository
import at.mocode.horses.domain.model.DomPferd
import at.mocode.core.domain.model.PferdeGeschlechtE
import kotlin.uuid.Uuid
/**
* Repository interface for DomPferd (Horse) domain operations.
*
* This interface defines the contract for horse data access operations
* without depending on specific implementation details (database, etc.).
* Following the hexagonal architecture pattern, this interface belongs
* to the domain layer and will be implemented in the infrastructure layer.
*/
interface HorseRepository {
/**
* Finds a horse by its unique ID.
*
* @param id The unique identifier of the horse
* @return The horse if found, null otherwise
*/
suspend fun findById(id: Uuid): DomPferd?
/**
* Finds a horse by its life number (Lebensnummer).
*
* @param lebensnummer The life number to search for
* @return The horse if found, null otherwise
*/
suspend fun findByLebensnummer(lebensnummer: String): DomPferd?
/**
* Finds a horse by its chip number.
*
* @param chipNummer The chip number to search for
* @return The horse if found, null otherwise
*/
suspend fun findByChipNummer(chipNummer: String): DomPferd?
/**
* Finds a horse by its passport number.
*
* @param passNummer The passport number to search for
* @return The horse if found, null otherwise
*/
suspend fun findByPassNummer(passNummer: String): DomPferd?
/**
* Finds a horse by its OEPS number.
*
* @param oepsNummer The OEPS number to search for
* @return The horse if found, null otherwise
*/
suspend fun findByOepsNummer(oepsNummer: String): DomPferd?
/**
* Finds a horse by its FEI number.
*
* @param feiNummer The FEI number to search for
* @return The horse if found, null otherwise
*/
suspend fun findByFeiNummer(feiNummer: String): DomPferd?
/**
* Finds horses by name (partial match).
*
* @param searchTerm The search term to match against horse names
* @param limit Maximum number of results to return
* @return List of matching horses
*/
suspend fun findByName(searchTerm: String, limit: Int = 50): List<DomPferd>
/**
* Finds all horses owned by a specific person.
*
* @param ownerId The ID of the owner (from member-management context)
* @param activeOnly Whether to return only active horses
* @return List of horses owned by the person
*/
suspend fun findByOwnerId(ownerId: Uuid, activeOnly: Boolean = true): List<DomPferd>
/**
* Finds all horses for which a person is responsible.
*
* @param responsiblePersonId The ID of the responsible person
* @param activeOnly Whether to return only active horses
* @return List of horses for which the person is responsible
*/
suspend fun findByResponsiblePersonId(responsiblePersonId: Uuid, activeOnly: Boolean = true): List<DomPferd>
/**
* Finds horses by gender.
*
* @param geschlecht The gender to filter by
* @param activeOnly Whether to return only active horses
* @param limit Maximum number of results to return
* @return List of horses with the specified gender
*/
suspend fun findByGeschlecht(geschlecht: PferdeGeschlechtE, activeOnly: Boolean = true, limit: Int = 100): List<DomPferd>
/**
* Finds horses by breed.
*
* @param rasse The breed to filter by
* @param activeOnly Whether to return only active horses
* @param limit Maximum number of results to return
* @return List of horses of the specified breed
*/
suspend fun findByRasse(rasse: String, activeOnly: Boolean = true, limit: Int = 100): List<DomPferd>
/**
* Finds horses by birth year.
*
* @param birthYear The birth year to filter by
* @param activeOnly Whether to return only active horses
* @return List of horses born in the specified year
*/
suspend fun findByBirthYear(birthYear: Int, activeOnly: Boolean = true): List<DomPferd>
/**
* Finds horses by birth year range.
*
* @param fromYear The start year (inclusive)
* @param toYear The end year (inclusive)
* @param activeOnly Whether to return only active horses
* @return List of horses born within the specified year range
*/
suspend fun findByBirthYearRange(fromYear: Int, toYear: Int, activeOnly: Boolean = true): List<DomPferd>
/**
* Finds all active horses.
*
* @param limit Maximum number of results to return
* @return List of active horses
*/
suspend fun findAllActive(limit: Int = 1000): List<DomPferd>
/**
* Finds horses with OEPS registration.
*
* @param activeOnly Whether to return only active horses
* @return List of OEPS registered horses
*/
suspend fun findOepsRegistered(activeOnly: Boolean = true): List<DomPferd>
/**
* Finds horses with FEI registration.
*
* @param activeOnly Whether to return only active horses
* @return List of FEI registered horses
*/
suspend fun findFeiRegistered(activeOnly: Boolean = true): List<DomPferd>
/**
* Saves a horse (create or update).
*
* @param horse The horse to save
* @return The saved horse with updated timestamps
*/
suspend fun save(horse: DomPferd): DomPferd
/**
* Deletes a horse by ID.
*
* @param id The unique identifier of the horse to delete
* @return true if the horse was deleted, false if not found
*/
suspend fun delete(id: Uuid): Boolean
/**
* Checks if a horse with the given life number exists.
*
* @param lebensnummer The life number to check
* @return true if a horse with this life number exists, false otherwise
*/
suspend fun existsByLebensnummer(lebensnummer: String): Boolean
/**
* Checks if a horse with the given chip number exists.
*
* @param chipNummer The chip number to check
* @return true if a horse with this chip number exists, false otherwise
*/
suspend fun existsByChipNummer(chipNummer: String): Boolean
/**
* Checks if a horse with the given passport number exists.
*
* @param passNummer The passport number to check
* @return true if a horse with this passport number exists, false otherwise
*/
suspend fun existsByPassNummer(passNummer: String): Boolean
/**
* Checks if a horse with the given OEPS number exists.
*
* @param oepsNummer The OEPS number to check
* @return true if a horse with this OEPS number exists, false otherwise
*/
suspend fun existsByOepsNummer(oepsNummer: String): Boolean
/**
* Checks if a horse with the given FEI number exists.
*
* @param feiNummer The FEI number to check
* @return true if a horse with this FEI number exists, false otherwise
*/
suspend fun existsByFeiNummer(feiNummer: String): Boolean
/**
* Counts the total number of active horses.
*
* @return The total count of active horses
*/
suspend fun countActive(): Long
/**
* Counts horses by owner.
*
* @param ownerId The ID of the owner
* @param activeOnly Whether to count only active horses
* @return The count of horses owned by the person
*/
suspend fun countByOwnerId(ownerId: Uuid, activeOnly: Boolean = true): Long
/**
* Counts horses with OEPS registration.
*
* @param activeOnly Whether to count only active horses
* @return The count of OEPS registered horses
*/
suspend fun countOepsRegistered(activeOnly: Boolean = true): Long
/**
* Counts horses with FEI registration.
*
* @param activeOnly Whether to count only active horses
* @return The count of FEI registered horses
*/
suspend fun countFeiRegistered(activeOnly: Boolean = true): Long
}
@@ -0,0 +1,30 @@
plugins {
// KORREKTUR: Alle Plugins werden jetzt konsistent über den Version Catalog geladen.
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.spring)
// Das JPA-Plugin wird jetzt ebenfalls zentral verwaltet.
alias(libs.plugins.kotlin.jpa)
}
dependencies {
// Interne Module
implementation(projects.platform.platformDependencies)
implementation(projects.horses.horsesDomain)
implementation(projects.horses.horsesApplication)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
implementation(projects.infrastructure.cache.cacheApi)
implementation(projects.infrastructure.eventStore.eventStoreApi)
implementation(projects.infrastructure.messaging.messagingClient)
// KORREKTUR: Alle externen Abhängigkeiten werden jetzt über den Version Catalog bezogen.
// Spring Data JPA
implementation(libs.spring.boot.starter.data.jpa)
// Datenbank-Treiber
runtimeOnly(libs.postgresql.driver)
// Testing
testImplementation(projects.platform.platformTesting)
}
@@ -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 kotlinx.datetime.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
}
}
@@ -0,0 +1,69 @@
package at.mocode.horses.infrastructure.persistence
import at.mocode.core.domain.model.PferdeGeschlechtE
import at.mocode.core.domain.model.DatenQuelleE
import org.jetbrains.exposed.dao.id.UUIDTable
import org.jetbrains.exposed.sql.kotlin.datetime.date
import org.jetbrains.exposed.sql.kotlin.datetime.timestamp
/**
* Database table definition for horses in the horse-registry context.
*
* This table stores all horse information including identification,
* ownership, breeding data, and administrative information.
*/
object HorseTable : UUIDTable("horses") {
// Basic Information
val pferdeName = varchar("pferde_name", 255)
val geschlecht = enumerationByName<PferdeGeschlechtE>("geschlecht", 20)
val geburtsdatum = date("geburtsdatum").nullable()
val rasse = varchar("rasse", 100).nullable()
val farbe = varchar("farbe", 100).nullable()
// Ownership and Responsibility
val besitzerId = uuid("besitzer_id").nullable()
val verantwortlichePersonId = uuid("verantwortliche_person_id").nullable()
// Breeding Information
val zuechterName = varchar("zuechter_name", 255).nullable()
val zuchtbuchNummer = varchar("zuchtbuch_nummer", 100).nullable()
// Identification Numbers
val lebensnummer = varchar("lebensnummer", 50).nullable()
val chipNummer = varchar("chip_nummer", 50).nullable()
val passNummer = varchar("pass_nummer", 50).nullable()
val oepsNummer = varchar("oeps_nummer", 50).nullable()
val feiNummer = varchar("fei_nummer", 50).nullable()
// Pedigree Information
val vaterName = varchar("vater_name", 255).nullable()
val mutterName = varchar("mutter_name", 255).nullable()
val mutterVaterName = varchar("mutter_vater_name", 255).nullable()
// Physical Characteristics
val stockmass = integer("stockmass").nullable()
// Status and Administrative
val istAktiv = bool("ist_aktiv").default(true)
val bemerkungen = text("bemerkungen").nullable()
val datenQuelle = enumerationByName<DatenQuelleE>("daten_quelle", 20).default(DatenQuelleE.MANUELL)
// Audit Fields
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
init {
// Indexes for performance
index(false, pferdeName)
index(false, besitzerId)
index(false, istAktiv)
// Unique constraints for identification numbers
// These ensure database-level uniqueness even under concurrent access
uniqueIndex(lebensnummer)
uniqueIndex(chipNummer)
uniqueIndex(passNummer)
uniqueIndex(oepsNummer)
uniqueIndex(feiNummer)
}
}
@@ -0,0 +1,54 @@
plugins {
// KORREKTUR: Alle Plugins werden jetzt konsistent über den Version Catalog geladen.
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.spring)
alias(libs.plugins.spring.boot)
alias(libs.plugins.spring.dependencyManagement)
}
// Der springBoot-Block konfiguriert die Anwendung, wenn sie als JAR-Datei ausgeführt wird.
springBoot {
mainClass.set("at.mocode.horses.service.HorsesServiceApplicationKt")
}
dependencies {
// Interne Module
implementation(projects.platform.platformDependencies)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
implementation(projects.horses.horsesDomain)
implementation(projects.horses.horsesApplication)
implementation(projects.horses.horsesInfrastructure)
implementation(projects.horses.horsesApi)
// Infrastruktur-Clients
implementation(projects.infrastructure.cache.redisCache)
implementation(projects.infrastructure.messaging.messagingClient)
implementation(projects.infrastructure.monitoring.monitoringClient)
// KORREKTUR: Alle externen Abhängigkeiten werden jetzt über den Version Catalog bezogen.
// Spring Boot Starters
implementation(libs.spring.boot.starter.web)
implementation(libs.spring.boot.starter.validation)
implementation(libs.spring.boot.starter.actuator)
// Datenbank-Abhängigkeiten
implementation(libs.exposed.core)
implementation(libs.exposed.dao)
implementation(libs.exposed.jdbc)
implementation(libs.exposed.kotlin.datetime)
implementation(libs.hikari.cp)
runtimeOnly(libs.postgresql.driver)
testRuntimeOnly(libs.h2.driver)
// Testing
testImplementation(projects.platform.platformTesting)
testImplementation(libs.spring.boot.starter.test)
testImplementation(libs.logback.classic) // SLF4J provider for tests
}
tasks.test {
useJUnitPlatform()
}
@@ -0,0 +1,26 @@
package at.mocode.horses.service
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.context.annotation.ComponentScan
/**
* Main application class for the Horses Service.
*
* This service provides APIs for managing horses and their data.
*/
@SpringBootApplication
@ComponentScan(basePackages = [
"at.mocode.horses.service",
"at.mocode.horses.api",
"at.mocode.horses.infrastructure",
"at.mocode.infrastructure.messaging"
])
class HorsesServiceApplication
/**
* Main entry point for the Horses Service application.
*/
fun main(args: Array<String>) {
runApplication<HorsesServiceApplication>(*args)
}
@@ -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)
}
}
@@ -0,0 +1,106 @@
package at.mocode.horses.service.config
import at.mocode.core.utils.database.DatabaseConfig
import at.mocode.core.utils.database.DatabaseFactory
import at.mocode.horses.infrastructure.persistence.HorseTable
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Profile
import org.springframework.stereotype.Component
import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import org.slf4j.LoggerFactory
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction
/**
* Database configuration for the Horses Service.
*
* This configuration ensures that Database.connect() is called properly
* before any Exposed operations are performed.
*/
@Configuration
@Profile("!test")
class HorsesDatabaseConfiguration {
private val log = LoggerFactory.getLogger(HorsesDatabaseConfiguration::class.java)
@PostConstruct
fun initializeDatabase() {
log.info("Initializing database schema for Horses Service...")
try {
// Database connection is already initialized by the gateway
// Only initialize the schema for this service
transaction {
SchemaUtils.createMissingTablesAndColumns(HorseTable)
log.info("Horse database schema initialized successfully")
}
} catch (e: Exception) {
log.error("Failed to initialize database schema", e)
throw e
}
}
@PreDestroy
fun closeDatabase() {
log.info("Closing database connection for Horses Service...")
try {
DatabaseFactory.close()
log.info("Database connection closed successfully")
} catch (e: Exception) {
log.error("Error closing database connection", e)
}
}
}
/**
* Test-specific database configuration.
*/
@Configuration
@Profile("test")
class HorsesTestDatabaseConfiguration {
private val log = LoggerFactory.getLogger(HorsesTestDatabaseConfiguration::class.java)
@PostConstruct
fun initializeTestDatabase() {
log.info("Initializing test database connection for Horses Service...")
try {
// Use H2 in-memory database for tests
val testConfig = DatabaseConfig(
jdbcUrl = "jdbc:h2:mem:horses_test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE",
username = "sa",
password = "",
driverClassName = "org.h2.Driver",
maxPoolSize = 5,
minPoolSize = 1,
autoMigrate = true
)
DatabaseFactory.init(testConfig)
log.info("Test database connection initialized successfully")
// Initialize database schema for tests
transaction {
SchemaUtils.createMissingTablesAndColumns(HorseTable)
log.info("Test horse database schema initialized successfully")
}
} catch (e: Exception) {
log.error("Failed to initialize test database connection", e)
throw e
}
}
@PreDestroy
fun closeTestDatabase() {
log.info("Closing test database connection for Horses Service...")
try {
DatabaseFactory.close()
log.info("Test database connection closed successfully")
} catch (e: Exception) {
log.error("Error closing test database connection", e)
}
}
}
@@ -0,0 +1,350 @@
package at.mocode.horses.service.integration
import at.mocode.horses.domain.model.DomPferd
import at.mocode.horses.domain.repository.HorseRepository
import at.mocode.infrastructure.messaging.client.EventPublisher
import at.mocode.core.domain.model.PferdeGeschlechtE
import io.mockk.mockk
import kotlinx.datetime.LocalDate
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.TestInstance
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.context.TestPropertySource
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* Integration tests for the Horses Service.
*
* These tests verify the complete functionality including:
* - Repository operations
* - Database persistence
* - Domain model validation
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@TestPropertySource(properties = [
"spring.datasource.url=jdbc:h2:mem:testdb",
"spring.kafka.bootstrap-servers=localhost:9092"
])
@ContextConfiguration(classes = [HorseServiceIntegrationTest.TestConfig::class])
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class HorseServiceIntegrationTest {
@Autowired
private lateinit var horseRepository: HorseRepository
@Configuration
class TestConfig {
@Bean
fun eventPublisher(): EventPublisher = mockk(relaxed = true)
}
@BeforeEach
fun setUp() = runBlocking {
// Clean up database before each test
println("[DEBUG_LOG] Setting up horse test - cleaning database")
}
@Test
fun `should create horse successfully`() = runBlocking {
println("[DEBUG_LOG] Testing horse creation")
// Given
val horse = DomPferd(
pferdeName = "Thunder",
geschlecht = PferdeGeschlechtE.WALLACH,
geburtsdatum = LocalDate(2020, 5, 15),
rasse = "Warmblut",
farbe = "Braun",
lebensnummer = "AT123456789",
chipNummer = "123456789012345",
stockmass = 165,
istAktiv = true
)
// When
val savedHorse = horseRepository.save(horse)
// Then
assertNotNull(savedHorse)
assertEquals("Thunder", savedHorse.pferdeName)
assertEquals(PferdeGeschlechtE.WALLACH, savedHorse.geschlecht)
assertEquals("AT123456789", savedHorse.lebensnummer)
assertEquals("123456789012345", savedHorse.chipNummer)
assertEquals("Warmblut", savedHorse.rasse)
assertTrue(savedHorse.istAktiv)
println("[DEBUG_LOG] Horse created successfully with ID: ${savedHorse.pferdId}")
}
@Test
fun `should find horse by lebensnummer`() = runBlocking {
println("[DEBUG_LOG] Testing find horse by lebensnummer")
// Given
val horse = DomPferd(
pferdeName = "Lightning",
geschlecht = PferdeGeschlechtE.STUTE,
geburtsdatum = LocalDate(2019, 3, 10),
rasse = "Vollblut",
farbe = "Schimmel",
lebensnummer = "AT987654321",
chipNummer = "987654321098765",
stockmass = 160,
istAktiv = true
)
horseRepository.save(horse)
// When
val foundHorse = horseRepository.findByLebensnummer("AT987654321")
// Then
assertNotNull(foundHorse)
assertEquals("Lightning", foundHorse.pferdeName)
assertEquals("AT987654321", foundHorse.lebensnummer)
assertEquals(PferdeGeschlechtE.STUTE, foundHorse.geschlecht)
assertEquals("Vollblut", foundHorse.rasse)
println("[DEBUG_LOG] Horse found by lebensnummer: ${foundHorse.pferdId}")
}
@Test
fun `should find horse by chip number`() = runBlocking {
println("[DEBUG_LOG] Testing find horse by chip number")
// Given
val horse = DomPferd(
pferdeName = "Storm",
geschlecht = PferdeGeschlechtE.HENGST,
geburtsdatum = LocalDate(2021, 8, 20),
rasse = "Haflinger",
farbe = "Fuchs",
lebensnummer = "AT555666777",
chipNummer = "555666777888999",
stockmass = 150,
istAktiv = true
)
horseRepository.save(horse)
// When
val foundHorse = horseRepository.findByChipNummer("555666777888999")
// Then
assertNotNull(foundHorse)
assertEquals("Storm", foundHorse.pferdeName)
assertEquals("555666777888999", foundHorse.chipNummer)
assertEquals(PferdeGeschlechtE.HENGST, foundHorse.geschlecht)
assertEquals("Haflinger", foundHorse.rasse)
println("[DEBUG_LOG] Horse found by chip number: ${foundHorse.pferdId}")
}
@Test
fun `should find horses by gender`() = runBlocking {
println("[DEBUG_LOG] Testing find horses by gender")
// Given
val stallion = DomPferd(
pferdeName = "Stallion Horse",
geschlecht = PferdeGeschlechtE.HENGST,
geburtsdatum = LocalDate(2018, 4, 12),
rasse = "Warmblut",
farbe = "Braun",
lebensnummer = "AT111222333",
chipNummer = "111222333444555",
stockmass = 170,
istAktiv = true
)
val mare = DomPferd(
pferdeName = "Mare Horse",
geschlecht = PferdeGeschlechtE.STUTE,
geburtsdatum = LocalDate(2017, 6, 8),
rasse = "Vollblut",
farbe = "Rappe",
lebensnummer = "AT444555666",
chipNummer = "444555666777888",
stockmass = 165,
istAktiv = true
)
horseRepository.save(stallion)
horseRepository.save(mare)
// When
val stallions = horseRepository.findByGeschlecht(PferdeGeschlechtE.HENGST, true, 10)
// Then
assertTrue(stallions.isNotEmpty(), "Should find at least one stallion")
assertTrue(stallions.any { it.pferdeName == "Stallion Horse" }, "Should contain the stallion horse")
assertTrue(stallions.all { it.geschlecht == PferdeGeschlechtE.HENGST }, "All returned horses should be stallions")
println("[DEBUG_LOG] Found ${stallions.size} stallions")
}
@Test
fun `should find horses by breed`() = runBlocking {
println("[DEBUG_LOG] Testing find horses by breed")
// Given
val warmblutHorse = DomPferd(
pferdeName = "Warmblut Horse",
geschlecht = PferdeGeschlechtE.WALLACH,
geburtsdatum = LocalDate(2019, 9, 15),
rasse = "Warmblut",
farbe = "Braun",
lebensnummer = "AT333444555",
chipNummer = "333444555666777",
stockmass = 168,
istAktiv = true
)
horseRepository.save(warmblutHorse)
// When
val warmblutHorses = horseRepository.findByRasse("Warmblut", true, 10)
// Then
assertTrue(warmblutHorses.isNotEmpty(), "Should find at least one Warmblut horse")
assertTrue(warmblutHorses.any { it.pferdeName == "Warmblut Horse" }, "Should contain the Warmblut horse")
assertTrue(warmblutHorses.all { it.rasse == "Warmblut" }, "All returned horses should be Warmblut")
println("[DEBUG_LOG] Found ${warmblutHorses.size} Warmblut horses")
}
@Test
fun `should find OEPS registered horses`() = runBlocking {
println("[DEBUG_LOG] Testing find OEPS registered horses")
// Given
val oepsHorse = DomPferd(
pferdeName = "OEPS Horse",
geschlecht = PferdeGeschlechtE.WALLACH,
geburtsdatum = LocalDate(2018, 7, 22),
rasse = "Warmblut",
farbe = "Braun",
lebensnummer = "AT777888999",
chipNummer = "777888999000111",
oepsNummer = "OEPS123456",
stockmass = 170,
istAktiv = true
)
val nonOepsHorse = DomPferd(
pferdeName = "Non-OEPS Horse",
geschlecht = PferdeGeschlechtE.STUTE,
geburtsdatum = LocalDate(2017, 11, 5),
rasse = "Vollblut",
farbe = "Rappe",
lebensnummer = "AT000111222",
chipNummer = "000111222333444",
stockmass = 165,
istAktiv = true
)
horseRepository.save(oepsHorse)
horseRepository.save(nonOepsHorse)
// When
val oepsHorses = horseRepository.findOepsRegistered(true)
// Then
assertTrue(oepsHorses.isNotEmpty(), "Should find at least one OEPS registered horse")
assertTrue(oepsHorses.any { it.pferdeName == "OEPS Horse" }, "Should contain the OEPS registered horse")
assertTrue(oepsHorses.all { !it.oepsNummer.isNullOrBlank() }, "All returned horses should have OEPS numbers")
println("[DEBUG_LOG] Found ${oepsHorses.size} OEPS registered horses")
}
@Test
fun `should find FEI registered horses`() = runBlocking {
println("[DEBUG_LOG] Testing find FEI registered horses")
// Given
val feiHorse = DomPferd(
pferdeName = "FEI Horse",
geschlecht = PferdeGeschlechtE.HENGST,
geburtsdatum = LocalDate(2016, 2, 14),
rasse = "Warmblut",
farbe = "Schimmel",
lebensnummer = "AT999000111",
chipNummer = "999000111222333",
feiNummer = "FEI789012",
stockmass = 175,
istAktiv = true
)
horseRepository.save(feiHorse)
// When
val feiHorses = horseRepository.findFeiRegistered(true)
// Then
assertTrue(feiHorses.isNotEmpty(), "Should find at least one FEI registered horse")
assertTrue(feiHorses.any { it.pferdeName == "FEI Horse" }, "Should contain the FEI registered horse")
assertTrue(feiHorses.all { !it.feiNummer.isNullOrBlank() }, "All returned horses should have FEI numbers")
println("[DEBUG_LOG] Found ${feiHorses.size} FEI registered horses")
}
@Test
fun `should validate duplicate lebensnummer`() = runBlocking {
println("[DEBUG_LOG] Testing duplicate lebensnummer validation")
// Given
val horse = DomPferd(
pferdeName = "First Horse",
geschlecht = PferdeGeschlechtE.WALLACH,
geburtsdatum = LocalDate(2019, 1, 1),
rasse = "Warmblut",
farbe = "Braun",
lebensnummer = "AT123123123",
chipNummer = "123123123456789",
stockmass = 165,
istAktiv = true
)
horseRepository.save(horse)
// When
val exists = horseRepository.existsByLebensnummer("AT123123123")
// Then
assertTrue(exists, "Should detect existing lebensnummer")
println("[DEBUG_LOG] Duplicate lebensnummer validation passed")
}
@Test
fun `should validate duplicate chip number`() = runBlocking {
println("[DEBUG_LOG] Testing duplicate chip number validation")
// Given
val horse = DomPferd(
pferdeName = "Chip Test Horse",
geschlecht = PferdeGeschlechtE.STUTE,
geburtsdatum = LocalDate(2020, 12, 25),
rasse = "Haflinger",
farbe = "Fuchs",
lebensnummer = "AT456456456",
chipNummer = "456456456789012",
stockmass = 148,
istAktiv = true
)
horseRepository.save(horse)
// When
val exists = horseRepository.existsByChipNummer("456456456789012")
// Then
assertTrue(exists, "Should detect existing chip number")
println("[DEBUG_LOG] Duplicate chip number validation passed")
}
}
@@ -0,0 +1,171 @@
package at.mocode.horses.service.integration
import at.mocode.horses.domain.model.DomPferd
import at.mocode.horses.domain.repository.HorseRepository
import at.mocode.core.domain.model.PferdeGeschlechtE
import kotlinx.coroutines.*
import kotlinx.datetime.LocalDate
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.TestInstance
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.TestPropertySource
import org.springframework.beans.factory.annotation.Autowired
import kotlin.test.assertTrue
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
/**
* Integration tests to demonstrate and verify transaction context issues with coroutines.
*
* This test class reproduces the race condition that can occur when multiple
* coroutines perform database operations without proper transaction boundaries.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@TestPropertySource(properties = [
"spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE",
"spring.jpa.hibernate.ddl-auto=create-drop"
])
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class TransactionContextTest {
@Autowired
private lateinit var horseRepository: HorseRepository
@BeforeEach
fun setUp() {
runBlocking {
// Clean up any existing test data
// Note: This is a simplified cleanup - in a real scenario you'd have proper cleanup
}
}
@Test
fun `should demonstrate race condition without transaction boundaries`(): Unit = runBlocking {
println("[DEBUG_LOG] Starting race condition test")
val lebensnummer = "TEST-RACE-001"
val chipNummer = "CHIP-RACE-001"
// Create two horses with the same identifiers
val horse1 = DomPferd(
pferdeName = "Race Horse 1",
geschlecht = PferdeGeschlechtE.WALLACH,
geburtsdatum = LocalDate(2020, 1, 1),
lebensnummer = lebensnummer,
chipNummer = chipNummer,
istAktiv = true
)
val horse2 = DomPferd(
pferdeName = "Race Horse 2",
geschlecht = PferdeGeschlechtE.STUTE,
geburtsdatum = LocalDate(2020, 1, 2),
lebensnummer = lebensnummer, // Same lebensnummer - should cause conflict
chipNummer = chipNummer, // Same chipNummer - should cause conflict
istAktiv = true
)
println("[DEBUG_LOG] Created horses with duplicate identifiers")
// Simulate the use case logic: check uniqueness then save
// This mimics what CreateHorseUseCase.execute() does without transactions
suspend fun createHorseWithChecks(horse: DomPferd): Boolean {
return try {
// Check uniqueness constraints (like in checkUniquenessConstraints)
val existsByLebensnummer = horse.lebensnummer?.let {
horseRepository.existsByLebensnummer(it)
} ?: false
val existsByChipNummer = horse.chipNummer?.let {
horseRepository.existsByChipNummer(it)
} ?: false
println("[DEBUG_LOG] ${horse.pferdeName}: existsByLebensnummer=$existsByLebensnummer, existsByChipNummer=$existsByChipNummer")
if (existsByLebensnummer || existsByChipNummer) {
println("[DEBUG_LOG] ${horse.pferdeName}: Uniqueness check failed")
false
} else {
// Save the horse (like in the use case)
horseRepository.save(horse)
println("[DEBUG_LOG] ${horse.pferdeName}: Saved successfully")
true
}
} catch (e: Exception) {
println("[DEBUG_LOG] ${horse.pferdeName}: Exception during creation: ${e.message}")
false
}
}
// Launch two concurrent coroutines to create horses
val results = listOf(
async {
println("[DEBUG_LOG] Starting creation 1")
createHorseWithChecks(horse1)
},
async {
println("[DEBUG_LOG] Starting creation 2")
createHorseWithChecks(horse2)
}
).awaitAll()
println("[DEBUG_LOG] Both operations completed")
println("[DEBUG_LOG] Result 1 success: ${results[0]}")
println("[DEBUG_LOG] Result 2 success: ${results[1]}")
// In a properly transactional system, exactly one should succeed
val successCount = results.count { it }
val failureCount = results.count { !it }
println("[DEBUG_LOG] Success count: $successCount, Failure count: $failureCount")
// Check what actually got saved in the database
val savedByLebensnummer = horseRepository.findByLebensnummer(lebensnummer)
val savedByChipNummer = horseRepository.findByChipNummer(chipNummer)
println("[DEBUG_LOG] Found by lebensnummer: ${savedByLebensnummer?.pferdeName}")
println("[DEBUG_LOG] Found by chipNummer: ${savedByChipNummer?.pferdeName}")
// This test demonstrates the issue - without transactions, both operations might succeed
// due to race conditions, or the behavior might be unpredictable
// The fix should ensure exactly one succeeds and one fails with a proper error
assertTrue(successCount >= 1, "At least one operation should succeed")
}
@Test
fun `should demonstrate transaction context propagation issue`(): Unit = runBlocking {
println("[DEBUG_LOG] Starting transaction context propagation test")
// This test will show that without @Transactional, each repository call
// runs in its own transaction context, which can lead to inconsistencies
val horse = DomPferd(
pferdeName = "Transaction Test Horse",
geschlecht = PferdeGeschlechtE.HENGST,
lebensnummer = "TRANS-TEST-001",
istAktiv = true
)
println("[DEBUG_LOG] Creating horse with repository operations")
// Simulate multiple repository operations that should be atomic
val existsCheck = horseRepository.existsByLebensnummer("TRANS-TEST-001")
println("[DEBUG_LOG] Exists check result: $existsCheck")
if (!existsCheck) {
val savedHorse = horseRepository.save(horse)
println("[DEBUG_LOG] Horse saved successfully: ${savedHorse.pferdeName}")
assertNotNull(savedHorse)
assertEquals("Transaction Test Horse", savedHorse.pferdeName)
}
// The issue is that without @Transactional, if an exception occurs after
// the uniqueness checks but before/during save, the database state
// might be inconsistent
val finalCheck = horseRepository.findByLebensnummer("TRANS-TEST-001")
assertNotNull(finalCheck, "Horse should be saved in database")
}
}
@@ -0,0 +1,187 @@
@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")
}
}
@@ -0,0 +1,10 @@
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE" />
</root>
</configuration>