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