(vision) SCS/DDD
This commit is contained in:
@@ -19,6 +19,7 @@ h2 = "2.2.224"
|
||||
|
||||
# Logging
|
||||
logback = "1.5.18"
|
||||
logbackJsonEncoder = "8.0"
|
||||
|
||||
# Testing
|
||||
junit = "4.13.2"
|
||||
@@ -63,6 +64,7 @@ h2-driver = { module = "com.h2database:h2", version.ref = "h2" }
|
||||
|
||||
# Logging
|
||||
logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
|
||||
logback-json-encoder = { module = "net.logstash.logback:logstash-logback-encoder", version.ref = "logbackJsonEncoder" }
|
||||
|
||||
# Testing
|
||||
junitJupiter = { group = "org.junit.jupiter", name = "junit-jupiter", version.ref = "junitJupiter" }
|
||||
|
||||
@@ -56,6 +56,7 @@ dependencies {
|
||||
|
||||
// === LOGGING ===
|
||||
implementation(libs.logback)
|
||||
implementation(libs.logback.json.encoder)
|
||||
|
||||
// === DATENBANKTREIBER ===
|
||||
runtimeOnly(libs.postgresql.driver) // PostgreSQL für Produktion
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package at.mocode
|
||||
|
||||
import at.mocode.config.ServiceConfiguration
|
||||
import at.mocode.events.EventConfiguration
|
||||
import at.mocode.plugins.configureDatabase
|
||||
import at.mocode.plugins.configureRouting
|
||||
import at.mocode.plugins.configureVersioning
|
||||
import at.mocode.utils.ApiResponse
|
||||
import at.mocode.utils.StructuredLogger
|
||||
import at.mocode.utils.structuredLogger
|
||||
import at.mocode.utils.measureAndLog
|
||||
import at.mocode.validation.ValidationException
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
@@ -26,21 +31,46 @@ fun main(args: Array<String>) {
|
||||
}
|
||||
|
||||
fun Application.module() {
|
||||
val log = LoggerFactory.getLogger("Application")
|
||||
log.info("Initializing application...")
|
||||
val log = structuredLogger()
|
||||
log.info("Initializing application", mapOf(
|
||||
"component" to "application",
|
||||
"phase" to "startup"
|
||||
))
|
||||
|
||||
// Configure dependency injection
|
||||
ServiceConfiguration.configureServices()
|
||||
log.info("Services configured")
|
||||
log.measureAndLog("configure_services") {
|
||||
ServiceConfiguration.configureServices()
|
||||
}
|
||||
|
||||
configureDatabase()
|
||||
configurePlugins()
|
||||
configureRouting()
|
||||
log.info("Application initialized successfully")
|
||||
// Configure event-driven architecture
|
||||
log.measureAndLog("configure_event_handlers") {
|
||||
EventConfiguration.configureEventHandlers()
|
||||
}
|
||||
|
||||
log.measureAndLog("configure_database") {
|
||||
configureDatabase()
|
||||
}
|
||||
|
||||
log.measureAndLog("configure_plugins") {
|
||||
configurePlugins()
|
||||
}
|
||||
|
||||
log.measureAndLog("configure_versioning") {
|
||||
configureVersioning()
|
||||
}
|
||||
|
||||
log.measureAndLog("configure_routing") {
|
||||
configureRouting()
|
||||
}
|
||||
|
||||
log.info("Application initialized successfully", mapOf(
|
||||
"component" to "application",
|
||||
"phase" to "startup_complete"
|
||||
))
|
||||
}
|
||||
|
||||
private fun Application.configurePlugins() {
|
||||
val log = LoggerFactory.getLogger("ApplicationPlugins")
|
||||
val log = StructuredLogger.getLogger("ApplicationPlugins")
|
||||
// Add default headers to all responses
|
||||
install(DefaultHeaders) {
|
||||
header("X-Engine", "Ktor")
|
||||
@@ -126,7 +156,7 @@ private fun Application.configurePlugins() {
|
||||
)
|
||||
}
|
||||
|
||||
// Handle not found exceptions
|
||||
// Handle didn't find exceptions
|
||||
exception<NoSuchElementException> { call, cause ->
|
||||
call.respond(
|
||||
HttpStatusCode.NotFound,
|
||||
@@ -140,7 +170,10 @@ private fun Application.configurePlugins() {
|
||||
|
||||
// Handle all other exceptions
|
||||
exception<Throwable> { call, cause ->
|
||||
this@configurePlugins.log.error("Unhandled exception", cause)
|
||||
log.error("Unhandled exception", cause, mapOf(
|
||||
"error_type" to "unhandled_exception",
|
||||
"exception_class" to cause::class.simpleName
|
||||
))
|
||||
call.respond(
|
||||
HttpStatusCode.InternalServerError,
|
||||
ApiResponse<Nothing>(
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package at.mocode.events
|
||||
|
||||
import com.benasher44.uuid.Uuid
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Base interface for all domain events in the system
|
||||
*/
|
||||
@Serializable
|
||||
sealed interface DomainEvent {
|
||||
val eventId: Uuid
|
||||
val aggregateId: Uuid
|
||||
val eventType: String
|
||||
val timestamp: Instant
|
||||
val version: Long
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for domain events with common properties
|
||||
*/
|
||||
@Serializable
|
||||
abstract class BaseDomainEvent(
|
||||
override val eventId: Uuid,
|
||||
override val aggregateId: Uuid,
|
||||
override val eventType: String,
|
||||
override val timestamp: Instant,
|
||||
override val version: Long = 1
|
||||
) : DomainEvent
|
||||
@@ -0,0 +1,72 @@
|
||||
package at.mocode.events
|
||||
|
||||
import at.mocode.events.handlers.TurnierAnalyticsHandler
|
||||
import at.mocode.events.handlers.TurnierAuditHandler
|
||||
import at.mocode.events.handlers.TurnierCacheHandler
|
||||
import at.mocode.events.handlers.TurnierNotificationHandler
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
/**
|
||||
* Configuration class for setting up event-driven architecture.
|
||||
* Registers all event handlers with the EventPublisher.
|
||||
*/
|
||||
object EventConfiguration {
|
||||
private val logger = LoggerFactory.getLogger(EventConfiguration::class.java)
|
||||
|
||||
/**
|
||||
* Initialize and configure all event handlers
|
||||
*/
|
||||
fun configureEventHandlers() {
|
||||
val eventPublisher = EventPublisher.getInstance()
|
||||
|
||||
logger.info("Configuring event handlers...")
|
||||
|
||||
// Register tournament event handlers
|
||||
registerTurnierEventHandlers(eventPublisher)
|
||||
|
||||
logger.info("Event handlers configured successfully")
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all tournament-related event handlers
|
||||
*/
|
||||
private fun registerTurnierEventHandlers(eventPublisher: EventPublisher) {
|
||||
// Audit handler - logs all tournament events
|
||||
val auditHandler = TurnierAuditHandler()
|
||||
eventPublisher.registerHandler("TurnierCreated", auditHandler)
|
||||
eventPublisher.registerHandler("TurnierUpdated", auditHandler)
|
||||
eventPublisher.registerHandler("TurnierDeleted", auditHandler)
|
||||
eventPublisher.registerHandler("TurnierRegistrationOpened", auditHandler)
|
||||
eventPublisher.registerHandler("TurnierRegistrationClosed", auditHandler)
|
||||
eventPublisher.registerHandler("TurnierStatusChanged", auditHandler)
|
||||
|
||||
// Notification handler - sends notifications for important events
|
||||
val notificationHandler = TurnierNotificationHandler()
|
||||
eventPublisher.registerHandler("TurnierCreated", notificationHandler)
|
||||
eventPublisher.registerHandler("TurnierRegistrationOpened", notificationHandler)
|
||||
eventPublisher.registerHandler("TurnierRegistrationClosed", notificationHandler)
|
||||
eventPublisher.registerHandler("TurnierStatusChanged", notificationHandler)
|
||||
|
||||
// Analytics handler - records metrics and analytics
|
||||
val analyticsHandler = TurnierAnalyticsHandler()
|
||||
eventPublisher.registerHandler("TurnierCreated", analyticsHandler)
|
||||
eventPublisher.registerHandler("TurnierRegistrationClosed", analyticsHandler)
|
||||
eventPublisher.registerHandler("TurnierStatusChanged", analyticsHandler)
|
||||
|
||||
// Cache handler - invalidates caches when data changes
|
||||
val cacheHandler = TurnierCacheHandler()
|
||||
eventPublisher.registerHandler("TurnierCreated", cacheHandler)
|
||||
eventPublisher.registerHandler("TurnierUpdated", cacheHandler)
|
||||
eventPublisher.registerHandler("TurnierDeleted", cacheHandler)
|
||||
|
||||
logger.info("Registered handlers for tournament events")
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all event handlers (useful for testing)
|
||||
*/
|
||||
fun clearEventHandlers() {
|
||||
EventPublisher.getInstance().clearHandlers()
|
||||
logger.info("All event handlers cleared")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package at.mocode.events
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
/**
|
||||
* Interface for handling domain events
|
||||
*/
|
||||
interface EventHandler<T : DomainEvent> {
|
||||
suspend fun handle(event: T)
|
||||
}
|
||||
|
||||
/**
|
||||
* Event publisher that manages event handlers and publishes events
|
||||
*/
|
||||
class EventPublisher {
|
||||
private val logger = LoggerFactory.getLogger(EventPublisher::class.java)
|
||||
private val handlers = mutableMapOf<String, MutableList<EventHandler<DomainEvent>>>()
|
||||
private val eventStore = mutableListOf<DomainEvent>()
|
||||
|
||||
/**
|
||||
* Register an event handler for a specific event type
|
||||
*/
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun <T : DomainEvent> registerHandler(eventType: String, handler: EventHandler<T>) {
|
||||
handlers.getOrPut(eventType) { mutableListOf() }
|
||||
.add(handler as EventHandler<DomainEvent>)
|
||||
logger.info("Registered handler for event type: $eventType")
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish an event to all registered handlers
|
||||
*/
|
||||
suspend fun publish(event: DomainEvent) {
|
||||
logger.info("Publishing event: ${event.eventType} with ID: ${event.eventId}")
|
||||
|
||||
// Store the event (simple in-memory event store for now)
|
||||
eventStore.add(event)
|
||||
|
||||
// Get handlers for this event type
|
||||
val eventHandlers = handlers[event.eventType] ?: emptyList()
|
||||
|
||||
if (eventHandlers.isEmpty()) {
|
||||
logger.warn("No handlers registered for event type: ${event.eventType}")
|
||||
return
|
||||
}
|
||||
|
||||
// Execute handlers asynchronously
|
||||
eventHandlers.forEach { handler ->
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
try {
|
||||
handler.handle(event)
|
||||
logger.debug("Successfully handled event ${event.eventId} with handler ${handler::class.simpleName}")
|
||||
} catch (e: Exception) {
|
||||
logger.error("Error handling event ${event.eventId} with handler ${handler::class.simpleName}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all events from the event store
|
||||
*/
|
||||
fun getAllEvents(): List<DomainEvent> = eventStore.toList()
|
||||
|
||||
/**
|
||||
* Get events by aggregate ID
|
||||
*/
|
||||
fun getEventsByAggregateId(aggregateId: com.benasher44.uuid.Uuid): List<DomainEvent> {
|
||||
return eventStore.filter { it.aggregateId == aggregateId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events by type
|
||||
*/
|
||||
fun getEventsByType(eventType: String): List<DomainEvent> {
|
||||
return eventStore.filter { it.eventType == eventType }
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all events (useful for testing)
|
||||
*/
|
||||
fun clearEvents() {
|
||||
eventStore.clear()
|
||||
logger.info("Event store cleared")
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all handlers (useful for testing)
|
||||
*/
|
||||
fun clearHandlers() {
|
||||
handlers.clear()
|
||||
logger.info("All event handlers cleared")
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var INSTANCE: EventPublisher? = null
|
||||
|
||||
fun getInstance(): EventPublisher {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: EventPublisher().also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package at.mocode.events
|
||||
|
||||
import at.mocode.model.Turnier
|
||||
import at.mocode.serializers.UuidSerializer
|
||||
import at.mocode.serializers.KotlinInstantSerializer
|
||||
import com.benasher44.uuid.Uuid
|
||||
import com.benasher44.uuid.uuid4
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Event published when a new tournament is created
|
||||
*/
|
||||
@Serializable
|
||||
data class TurnierCreatedEvent(
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
override val eventId: Uuid = uuid4(),
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
override val aggregateId: Uuid,
|
||||
override val eventType: String = "TurnierCreated",
|
||||
@Serializable(with = KotlinInstantSerializer::class)
|
||||
override val timestamp: Instant = Clock.System.now(),
|
||||
override val version: Long = 1,
|
||||
val turnier: Turnier,
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val createdBy: Uuid? = null
|
||||
) : DomainEvent
|
||||
|
||||
/**
|
||||
* Event published when a tournament is updated
|
||||
*/
|
||||
@Serializable
|
||||
data class TurnierUpdatedEvent(
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
override val eventId: Uuid = uuid4(),
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
override val aggregateId: Uuid,
|
||||
override val eventType: String = "TurnierUpdated",
|
||||
@Serializable(with = KotlinInstantSerializer::class)
|
||||
override val timestamp: Instant = Clock.System.now(),
|
||||
override val version: Long = 1,
|
||||
val previousTurnier: Turnier,
|
||||
val updatedTurnier: Turnier,
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val updatedBy: Uuid? = null
|
||||
) : DomainEvent
|
||||
|
||||
/**
|
||||
* Event published when a tournament is deleted
|
||||
*/
|
||||
@Serializable
|
||||
data class TurnierDeletedEvent(
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
override val eventId: Uuid = uuid4(),
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
override val aggregateId: Uuid,
|
||||
override val eventType: String = "TurnierDeleted",
|
||||
@Serializable(with = KotlinInstantSerializer::class)
|
||||
override val timestamp: Instant = Clock.System.now(),
|
||||
override val version: Long = 1,
|
||||
val deletedTurnier: Turnier,
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val deletedBy: Uuid? = null
|
||||
) : DomainEvent
|
||||
|
||||
/**
|
||||
* Event published when tournament registration opens
|
||||
*/
|
||||
@Serializable
|
||||
data class TurnierRegistrationOpenedEvent(
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
override val eventId: Uuid = uuid4(),
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
override val aggregateId: Uuid,
|
||||
override val eventType: String = "TurnierRegistrationOpened",
|
||||
@Serializable(with = KotlinInstantSerializer::class)
|
||||
override val timestamp: Instant = Clock.System.now(),
|
||||
override val version: Long = 1,
|
||||
val turnierId: Uuid,
|
||||
val turnierTitel: String,
|
||||
@Serializable(with = KotlinInstantSerializer::class)
|
||||
val registrationDeadline: Instant?
|
||||
) : DomainEvent
|
||||
|
||||
/**
|
||||
* Event published when tournament registration closes
|
||||
*/
|
||||
@Serializable
|
||||
data class TurnierRegistrationClosedEvent(
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
override val eventId: Uuid = uuid4(),
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
override val aggregateId: Uuid,
|
||||
override val eventType: String = "TurnierRegistrationClosed",
|
||||
@Serializable(with = KotlinInstantSerializer::class)
|
||||
override val timestamp: Instant = Clock.System.now(),
|
||||
override val version: Long = 1,
|
||||
val turnierId: Uuid,
|
||||
val turnierTitel: String,
|
||||
val totalRegistrations: Int = 0
|
||||
) : DomainEvent
|
||||
|
||||
/**
|
||||
* Event published when a tournament status changes (e.g., from planned to active to completed)
|
||||
*/
|
||||
@Serializable
|
||||
data class TurnierStatusChangedEvent(
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
override val eventId: Uuid = uuid4(),
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
override val aggregateId: Uuid,
|
||||
override val eventType: String = "TurnierStatusChanged",
|
||||
@Serializable(with = KotlinInstantSerializer::class)
|
||||
override val timestamp: Instant = Clock.System.now(),
|
||||
override val version: Long = 1,
|
||||
val turnierId: Uuid,
|
||||
val turnierTitel: String,
|
||||
val previousStatus: String,
|
||||
val newStatus: String,
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val changedBy: Uuid? = null
|
||||
) : DomainEvent
|
||||
@@ -0,0 +1,294 @@
|
||||
package at.mocode.events.handlers
|
||||
|
||||
import at.mocode.events.*
|
||||
import at.mocode.utils.StructuredLogger
|
||||
|
||||
/**
|
||||
* Handler for tournament audit logging
|
||||
*/
|
||||
class TurnierAuditHandler : EventHandler<DomainEvent> {
|
||||
private val log = StructuredLogger.getLogger(TurnierAuditHandler::class.java)
|
||||
|
||||
override suspend fun handle(event: DomainEvent) {
|
||||
when (event) {
|
||||
is TurnierCreatedEvent -> {
|
||||
log.logEvent("tournament_created", "Tournament created", mapOf(
|
||||
"handler" to "audit",
|
||||
"turnier_id" to event.turnier.id.toString(),
|
||||
"turnier_titel" to event.turnier.titel,
|
||||
"oeps_turnier_nr" to event.turnier.oepsTurnierNr.toString(),
|
||||
"veranstaltung_id" to event.turnier.veranstaltungId.toString()
|
||||
))
|
||||
}
|
||||
is TurnierUpdatedEvent -> {
|
||||
log.logEvent("tournament_updated", "Tournament updated", mapOf(
|
||||
"handler" to "audit",
|
||||
"turnier_id" to event.updatedTurnier.id.toString(),
|
||||
"turnier_titel" to event.updatedTurnier.titel,
|
||||
"title_changed" to (event.previousTurnier.titel != event.updatedTurnier.titel)
|
||||
))
|
||||
if (event.previousTurnier.titel != event.updatedTurnier.titel) {
|
||||
log.logEvent("tournament_title_changed", "Tournament title changed", mapOf(
|
||||
"handler" to "audit",
|
||||
"turnier_id" to event.updatedTurnier.id.toString(),
|
||||
"previous_title" to event.previousTurnier.titel,
|
||||
"new_title" to event.updatedTurnier.titel
|
||||
))
|
||||
}
|
||||
}
|
||||
is TurnierDeletedEvent -> {
|
||||
log.logEvent("tournament_deleted", "Tournament deleted", mapOf(
|
||||
"handler" to "audit",
|
||||
"turnier_id" to event.deletedTurnier.id.toString(),
|
||||
"turnier_titel" to event.deletedTurnier.titel
|
||||
))
|
||||
}
|
||||
is TurnierRegistrationOpenedEvent -> {
|
||||
log.logEvent("tournament_registration_opened", "Tournament registration opened", mapOf(
|
||||
"handler" to "audit",
|
||||
"turnier_id" to event.turnierId.toString(),
|
||||
"turnier_titel" to event.turnierTitel
|
||||
))
|
||||
}
|
||||
is TurnierRegistrationClosedEvent -> {
|
||||
log.logEvent("tournament_registration_closed", "Tournament registration closed", mapOf(
|
||||
"handler" to "audit",
|
||||
"turnier_id" to event.turnierId.toString(),
|
||||
"turnier_titel" to event.turnierTitel,
|
||||
"total_registrations" to event.totalRegistrations
|
||||
))
|
||||
}
|
||||
is TurnierStatusChangedEvent -> {
|
||||
log.logEvent("tournament_status_changed", "Tournament status changed", mapOf(
|
||||
"handler" to "audit",
|
||||
"turnier_id" to event.turnierId.toString(),
|
||||
"turnier_titel" to event.turnierTitel,
|
||||
"previous_status" to event.previousStatus,
|
||||
"new_status" to event.newStatus
|
||||
))
|
||||
}
|
||||
else -> {
|
||||
log.debug("Unhandled event type in audit handler", mapOf(
|
||||
"handler" to "audit",
|
||||
"event_type" to event.eventType
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for tournament notifications (email, SMS, etc.)
|
||||
*/
|
||||
class TurnierNotificationHandler : EventHandler<DomainEvent> {
|
||||
private val log = StructuredLogger.getLogger(TurnierNotificationHandler::class.java)
|
||||
|
||||
override suspend fun handle(event: DomainEvent) {
|
||||
when (event) {
|
||||
is TurnierCreatedEvent -> {
|
||||
log.logEvent("tournament_notification_sent", "Sending tournament creation notifications", mapOf(
|
||||
"handler" to "notification",
|
||||
"notification_type" to "tournament_created",
|
||||
"turnier_id" to event.turnier.id.toString(),
|
||||
"turnier_titel" to event.turnier.titel
|
||||
))
|
||||
// Here you would integrate with email/SMS services
|
||||
sendNotificationToStakeholders(
|
||||
"New Tournament Created",
|
||||
"Tournament '${event.turnier.titel}' has been created for ${event.turnier.datumVon} - ${event.turnier.datumBis}"
|
||||
)
|
||||
}
|
||||
is TurnierRegistrationOpenedEvent -> {
|
||||
log.logEvent("tournament_notification_sent", "Sending registration opened notifications", mapOf(
|
||||
"handler" to "notification",
|
||||
"notification_type" to "registration_opened",
|
||||
"turnier_id" to event.turnierId.toString(),
|
||||
"turnier_titel" to event.turnierTitel
|
||||
))
|
||||
sendNotificationToParticipants(
|
||||
"Tournament Registration Open",
|
||||
"Registration is now open for tournament '${event.turnierTitel}'. Deadline: ${event.registrationDeadline}"
|
||||
)
|
||||
}
|
||||
is TurnierRegistrationClosedEvent -> {
|
||||
log.logEvent("tournament_notification_sent", "Sending registration closed notifications", mapOf(
|
||||
"handler" to "notification",
|
||||
"notification_type" to "registration_closed",
|
||||
"turnier_id" to event.turnierId.toString(),
|
||||
"turnier_titel" to event.turnierTitel,
|
||||
"total_registrations" to event.totalRegistrations
|
||||
))
|
||||
sendNotificationToStakeholders(
|
||||
"Tournament Registration Closed",
|
||||
"Registration for tournament '${event.turnierTitel}' is now closed. Total registrations: ${event.totalRegistrations}"
|
||||
)
|
||||
}
|
||||
is TurnierStatusChangedEvent -> {
|
||||
if (event.newStatus == "COMPLETED") {
|
||||
log.logEvent("tournament_notification_sent", "Sending tournament completion notifications", mapOf(
|
||||
"handler" to "notification",
|
||||
"notification_type" to "tournament_completed",
|
||||
"turnier_id" to event.turnierId.toString(),
|
||||
"turnier_titel" to event.turnierTitel,
|
||||
"new_status" to event.newStatus
|
||||
))
|
||||
sendNotificationToParticipants(
|
||||
"Tournament Completed",
|
||||
"Tournament '${event.turnierTitel}' has been completed. Results will be available soon."
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
log.debug("Unhandled event type in notification handler", mapOf(
|
||||
"handler" to "notification",
|
||||
"event_type" to event.eventType
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendNotificationToStakeholders(subject: String, message: String) {
|
||||
// Mock implementation - in real system this would send emails/SMS
|
||||
log.info("Mock notification sent to stakeholders", mapOf(
|
||||
"handler" to "notification",
|
||||
"recipient_type" to "stakeholders",
|
||||
"subject" to subject,
|
||||
"message_length" to message.length
|
||||
))
|
||||
}
|
||||
|
||||
private suspend fun sendNotificationToParticipants(subject: String, message: String) {
|
||||
// Mock implementation - in real system this would send emails/SMS
|
||||
log.info("Mock notification sent to participants", mapOf(
|
||||
"handler" to "notification",
|
||||
"recipient_type" to "participants",
|
||||
"subject" to subject,
|
||||
"message_length" to message.length
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for tournament statistics and analytics
|
||||
*/
|
||||
class TurnierAnalyticsHandler : EventHandler<DomainEvent> {
|
||||
private val log = StructuredLogger.getLogger(TurnierAnalyticsHandler::class.java)
|
||||
|
||||
override suspend fun handle(event: DomainEvent) {
|
||||
when (event) {
|
||||
is TurnierCreatedEvent -> {
|
||||
log.logEvent("tournament_analytics_recorded", "Recording tournament creation metrics", mapOf(
|
||||
"handler" to "analytics",
|
||||
"metric_type" to "tournament_created",
|
||||
"turnier_id" to event.turnier.id.toString(),
|
||||
"veranstaltung_id" to event.turnier.veranstaltungId.toString(),
|
||||
"tournament_type" to "standard"
|
||||
))
|
||||
recordMetric("tournament.created", 1, mapOf(
|
||||
"veranstaltung_id" to event.turnier.veranstaltungId.toString(),
|
||||
"tournament_type" to "standard"
|
||||
))
|
||||
}
|
||||
is TurnierRegistrationClosedEvent -> {
|
||||
log.logEvent("tournament_analytics_recorded", "Recording registration metrics", mapOf(
|
||||
"handler" to "analytics",
|
||||
"metric_type" to "tournament_registrations",
|
||||
"turnier_id" to event.turnierId.toString(),
|
||||
"total_registrations" to event.totalRegistrations
|
||||
))
|
||||
recordMetric("tournament.registrations", event.totalRegistrations, mapOf(
|
||||
"tournament_id" to event.turnierId.toString()
|
||||
))
|
||||
}
|
||||
is TurnierStatusChangedEvent -> {
|
||||
log.logEvent("tournament_analytics_recorded", "Recording status change metrics", mapOf(
|
||||
"handler" to "analytics",
|
||||
"metric_type" to "tournament_status_change",
|
||||
"turnier_id" to event.turnierId.toString(),
|
||||
"from_status" to event.previousStatus,
|
||||
"to_status" to event.newStatus
|
||||
))
|
||||
recordMetric("tournament.status_change", 1, mapOf(
|
||||
"tournament_id" to event.turnierId.toString(),
|
||||
"from_status" to event.previousStatus,
|
||||
"to_status" to event.newStatus
|
||||
))
|
||||
}
|
||||
else -> {
|
||||
log.debug("Unhandled event type in analytics handler", mapOf(
|
||||
"handler" to "analytics",
|
||||
"event_type" to event.eventType
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun recordMetric(metricName: String, value: Int, tags: Map<String, String>) {
|
||||
// Mock implementation - in real system this would send to analytics service
|
||||
log.info("Mock analytics metric recorded", mapOf(
|
||||
"handler" to "analytics",
|
||||
"metric_name" to metricName,
|
||||
"metric_value" to value,
|
||||
"tags" to tags.toString()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for tournament cache invalidation
|
||||
*/
|
||||
class TurnierCacheHandler : EventHandler<DomainEvent> {
|
||||
private val log = StructuredLogger.getLogger(TurnierCacheHandler::class.java)
|
||||
|
||||
override suspend fun handle(event: DomainEvent) {
|
||||
when (event) {
|
||||
is TurnierCreatedEvent -> {
|
||||
log.logEvent("tournament_cache_invalidated", "Cache invalidated for tournament creation", mapOf(
|
||||
"handler" to "cache",
|
||||
"event_type" to "tournament_created",
|
||||
"turnier_id" to event.turnier.id.toString(),
|
||||
"veranstaltung_id" to event.turnier.veranstaltungId.toString()
|
||||
))
|
||||
invalidateCache("tournaments:all")
|
||||
invalidateCache("tournaments:veranstaltung:${event.turnier.veranstaltungId}")
|
||||
}
|
||||
is TurnierUpdatedEvent -> {
|
||||
log.logEvent("tournament_cache_invalidated", "Cache invalidated for tournament update", mapOf(
|
||||
"handler" to "cache",
|
||||
"event_type" to "tournament_updated",
|
||||
"turnier_id" to event.updatedTurnier.id.toString(),
|
||||
"veranstaltung_id" to event.updatedTurnier.veranstaltungId.toString()
|
||||
))
|
||||
invalidateCache("tournaments:all")
|
||||
invalidateCache("tournaments:${event.updatedTurnier.id}")
|
||||
invalidateCache("tournaments:veranstaltung:${event.updatedTurnier.veranstaltungId}")
|
||||
}
|
||||
is TurnierDeletedEvent -> {
|
||||
log.logEvent("tournament_cache_invalidated", "Cache invalidated for tournament deletion", mapOf(
|
||||
"handler" to "cache",
|
||||
"event_type" to "tournament_deleted",
|
||||
"turnier_id" to event.deletedTurnier.id.toString(),
|
||||
"veranstaltung_id" to event.deletedTurnier.veranstaltungId.toString()
|
||||
))
|
||||
invalidateCache("tournaments:all")
|
||||
invalidateCache("tournaments:${event.deletedTurnier.id}")
|
||||
invalidateCache("tournaments:veranstaltung:${event.deletedTurnier.veranstaltungId}")
|
||||
}
|
||||
else -> {
|
||||
log.debug("Unhandled event type in cache handler", mapOf(
|
||||
"handler" to "cache",
|
||||
"event_type" to event.eventType
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun invalidateCache(cacheKey: String) {
|
||||
// Mock implementation - in real system this would invalidate Redis/other cache
|
||||
log.info("Mock cache invalidation", mapOf(
|
||||
"handler" to "cache",
|
||||
"cache_key" to cacheKey,
|
||||
"operation" to "invalidate"
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,173 @@
|
||||
package at.mocode.repositories
|
||||
|
||||
import at.mocode.model.Turnier
|
||||
import at.mocode.tables.TurniereTable
|
||||
import at.mocode.enums.NennungsArtE
|
||||
import com.benasher44.uuid.Uuid
|
||||
import com.benasher44.uuid.uuidFrom
|
||||
import org.jetbrains.exposed.sql.*
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import kotlinx.datetime.Clock
|
||||
import com.ionspin.kotlin.bignum.decimal.BigDecimal
|
||||
|
||||
class PostgresTurnierRepository : TurnierRepository {
|
||||
override suspend fun findAll(): List<Turnier> {
|
||||
// TODO: Implement database operations
|
||||
return emptyList()
|
||||
|
||||
override suspend fun findAll(): List<Turnier> = transaction {
|
||||
TurniereTable.selectAll().map { rowToTurnier(it) }
|
||||
}
|
||||
|
||||
override suspend fun findById(id: Uuid): Turnier? {
|
||||
// TODO: Implement database operations
|
||||
return null
|
||||
override suspend fun findById(id: Uuid): Turnier? = transaction {
|
||||
TurniereTable.selectAll().where { TurniereTable.id eq id }
|
||||
.map { rowToTurnier(it) }
|
||||
.singleOrNull()
|
||||
}
|
||||
|
||||
override suspend fun findByVeranstaltungId(veranstaltungId: Uuid): List<Turnier> {
|
||||
// TODO: Implement database operations
|
||||
return emptyList()
|
||||
override suspend fun findByVeranstaltungId(veranstaltungId: Uuid): List<Turnier> = transaction {
|
||||
TurniereTable.selectAll().where { TurniereTable.veranstaltungId eq veranstaltungId }
|
||||
.map { rowToTurnier(it) }
|
||||
}
|
||||
|
||||
override suspend fun findByOepsTurnierNr(oepsTurnierNr: String): Turnier? {
|
||||
// TODO: Implement database operations
|
||||
return null
|
||||
override suspend fun findByOepsTurnierNr(oepsTurnierNr: String): Turnier? = transaction {
|
||||
TurniereTable.selectAll().where { TurniereTable.oepsTurnierNr eq oepsTurnierNr }
|
||||
.map { rowToTurnier(it) }
|
||||
.singleOrNull()
|
||||
}
|
||||
|
||||
override suspend fun create(turnier: Turnier): Turnier {
|
||||
// TODO: Implement database operations
|
||||
return turnier
|
||||
override suspend fun create(turnier: Turnier): Turnier = transaction {
|
||||
TurniereTable.insert {
|
||||
it[id] = turnier.id
|
||||
it[veranstaltungId] = turnier.veranstaltungId
|
||||
it[oepsTurnierNr] = turnier.oepsTurnierNr
|
||||
it[titel] = turnier.titel
|
||||
it[untertitel] = turnier.untertitel
|
||||
it[datumVon] = turnier.datumVon
|
||||
it[datumBis] = turnier.datumBis
|
||||
it[nennungsschluss] = turnier.nennungsschluss
|
||||
it[nennungsArtCsv] = turnier.nennungsArt.joinToString(",") { art -> art.name }
|
||||
it[nennungsHinweis] = turnier.nennungsHinweis
|
||||
it[eigenesNennsystemUrl] = turnier.eigenesNennsystemUrl
|
||||
it[nenngeld] = turnier.nenngeld?.toString()
|
||||
it[startgeldStandard] = turnier.startgeldStandard?.toString()
|
||||
it[turnierleiterId] = turnier.turnierleiterId
|
||||
it[turnierbeauftragterId] = turnier.turnierbeauftragterId
|
||||
it[richterIdsCsv] = turnier.richterIds.joinToString(",") { uuid -> uuid.toString() }
|
||||
it[parcoursbauerIdsCsv] = turnier.parcoursbauerIds.joinToString(",") { uuid -> uuid.toString() }
|
||||
it[parcoursAssistentIdsCsv] = turnier.parcoursAssistentIds.joinToString(",") { uuid -> uuid.toString() }
|
||||
it[tierarztInfos] = turnier.tierarztInfos
|
||||
it[hufschmiedInfo] = turnier.hufschmiedInfo
|
||||
it[meldestelleVerantwortlicherId] = turnier.meldestelleVerantwortlicherId
|
||||
it[meldestelleTelefon] = turnier.meldestelleTelefon
|
||||
it[meldestelleOeffnungszeiten] = turnier.meldestelleOeffnungszeiten
|
||||
it[ergebnislistenUrl] = turnier.ergebnislistenUrl
|
||||
it[createdAt] = turnier.createdAt
|
||||
it[updatedAt] = Clock.System.now()
|
||||
}
|
||||
turnier
|
||||
}
|
||||
|
||||
override suspend fun update(id: Uuid, turnier: Turnier): Turnier? {
|
||||
// TODO: Implement database operations
|
||||
return null
|
||||
override suspend fun update(id: Uuid, turnier: Turnier): Turnier? = transaction {
|
||||
val updateCount = TurniereTable.update({ TurniereTable.id eq id }) {
|
||||
it[veranstaltungId] = turnier.veranstaltungId
|
||||
it[oepsTurnierNr] = turnier.oepsTurnierNr
|
||||
it[titel] = turnier.titel
|
||||
it[untertitel] = turnier.untertitel
|
||||
it[datumVon] = turnier.datumVon
|
||||
it[datumBis] = turnier.datumBis
|
||||
it[nennungsschluss] = turnier.nennungsschluss
|
||||
it[nennungsArtCsv] = turnier.nennungsArt.joinToString(",") { art -> art.name }
|
||||
it[nennungsHinweis] = turnier.nennungsHinweis
|
||||
it[eigenesNennsystemUrl] = turnier.eigenesNennsystemUrl
|
||||
it[nenngeld] = turnier.nenngeld?.toString()
|
||||
it[startgeldStandard] = turnier.startgeldStandard?.toString()
|
||||
it[turnierleiterId] = turnier.turnierleiterId
|
||||
it[turnierbeauftragterId] = turnier.turnierbeauftragterId
|
||||
it[richterIdsCsv] = turnier.richterIds.joinToString(",") { uuid -> uuid.toString() }
|
||||
it[parcoursbauerIdsCsv] = turnier.parcoursbauerIds.joinToString(",") { uuid -> uuid.toString() }
|
||||
it[parcoursAssistentIdsCsv] = turnier.parcoursAssistentIds.joinToString(",") { uuid -> uuid.toString() }
|
||||
it[tierarztInfos] = turnier.tierarztInfos
|
||||
it[hufschmiedInfo] = turnier.hufschmiedInfo
|
||||
it[meldestelleVerantwortlicherId] = turnier.meldestelleVerantwortlicherId
|
||||
it[meldestelleTelefon] = turnier.meldestelleTelefon
|
||||
it[meldestelleOeffnungszeiten] = turnier.meldestelleOeffnungszeiten
|
||||
it[ergebnislistenUrl] = turnier.ergebnislistenUrl
|
||||
it[updatedAt] = Clock.System.now()
|
||||
}
|
||||
if (updateCount > 0) {
|
||||
TurniereTable.selectAll().where { TurniereTable.id eq id }
|
||||
.map { rowToTurnier(it) }
|
||||
.singleOrNull()
|
||||
} else null
|
||||
}
|
||||
|
||||
override suspend fun delete(id: Uuid): Boolean {
|
||||
// TODO: Implement database operations
|
||||
return false
|
||||
override suspend fun delete(id: Uuid): Boolean = transaction {
|
||||
TurniereTable.deleteWhere { TurniereTable.id eq id } > 0
|
||||
}
|
||||
|
||||
override suspend fun search(query: String): List<Turnier> {
|
||||
// TODO: Implement database operations
|
||||
return emptyList()
|
||||
override suspend fun search(query: String): List<Turnier> = transaction {
|
||||
TurniereTable.selectAll().where {
|
||||
(TurniereTable.titel.lowerCase() like "%${query.lowercase()}%") or
|
||||
(TurniereTable.untertitel?.lowerCase()?.like("%${query.lowercase()}%") ?: Op.FALSE) or
|
||||
(TurniereTable.oepsTurnierNr.lowerCase() like "%${query.lowercase()}%")
|
||||
}.map { rowToTurnier(it) }
|
||||
}
|
||||
|
||||
private fun rowToTurnier(row: ResultRow): Turnier {
|
||||
return Turnier(
|
||||
id = row[TurniereTable.id],
|
||||
veranstaltungId = row[TurniereTable.veranstaltungId],
|
||||
oepsTurnierNr = row[TurniereTable.oepsTurnierNr],
|
||||
titel = row[TurniereTable.titel],
|
||||
untertitel = row[TurniereTable.untertitel],
|
||||
datumVon = row[TurniereTable.datumVon],
|
||||
datumBis = row[TurniereTable.datumBis],
|
||||
nennungsschluss = row[TurniereTable.nennungsschluss],
|
||||
nennungsArt = parseNennungsArt(row[TurniereTable.nennungsArtCsv]),
|
||||
nennungsHinweis = row[TurniereTable.nennungsHinweis],
|
||||
eigenesNennsystemUrl = row[TurniereTable.eigenesNennsystemUrl],
|
||||
nenngeld = row[TurniereTable.nenngeld]?.let { BigDecimal.parseString(it) },
|
||||
startgeldStandard = row[TurniereTable.startgeldStandard]?.let { BigDecimal.parseString(it) },
|
||||
turnierleiterId = row[TurniereTable.turnierleiterId],
|
||||
turnierbeauftragterId = row[TurniereTable.turnierbeauftragterId],
|
||||
richterIds = parseUuidList(row[TurniereTable.richterIdsCsv]),
|
||||
parcoursbauerIds = parseUuidList(row[TurniereTable.parcoursbauerIdsCsv]),
|
||||
parcoursAssistentIds = parseUuidList(row[TurniereTable.parcoursAssistentIdsCsv]),
|
||||
tierarztInfos = row[TurniereTable.tierarztInfos],
|
||||
hufschmiedInfo = row[TurniereTable.hufschmiedInfo],
|
||||
meldestelleVerantwortlicherId = row[TurniereTable.meldestelleVerantwortlicherId],
|
||||
meldestelleTelefon = row[TurniereTable.meldestelleTelefon],
|
||||
meldestelleOeffnungszeiten = row[TurniereTable.meldestelleOeffnungszeiten],
|
||||
ergebnislistenUrl = row[TurniereTable.ergebnislistenUrl],
|
||||
createdAt = row[TurniereTable.createdAt],
|
||||
updatedAt = row[TurniereTable.updatedAt]
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseNennungsArt(csv: String?): List<NennungsArtE> {
|
||||
return if (csv.isNullOrBlank()) {
|
||||
emptyList()
|
||||
} else {
|
||||
csv.split(",").mapNotNull { artName ->
|
||||
try {
|
||||
NennungsArtE.valueOf(artName.trim())
|
||||
} catch (e: IllegalArgumentException) {
|
||||
null // Skip invalid enum values
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseUuidList(csv: String?): List<Uuid> {
|
||||
return if (csv.isNullOrBlank()) {
|
||||
emptyList()
|
||||
} else {
|
||||
csv.split(",").mapNotNull { uuidString ->
|
||||
try {
|
||||
uuidFrom(uuidString.trim())
|
||||
} catch (e: IllegalArgumentException) {
|
||||
null // Skip invalid UUIDs
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
package at.mocode.routes
|
||||
|
||||
import at.mocode.dto.ArtikelDto
|
||||
import at.mocode.dto.CreateArtikelDto
|
||||
import at.mocode.dto.UpdateArtikelDto
|
||||
import at.mocode.model.Artikel
|
||||
import at.mocode.plugins.respondVersioned
|
||||
import at.mocode.plugins.respondVersionedList
|
||||
import at.mocode.repositories.ArtikelRepository
|
||||
import at.mocode.services.ServiceLocator
|
||||
import at.mocode.utils.ApiResponse
|
||||
import at.mocode.utils.StructuredLogger
|
||||
import com.benasher44.uuid.uuidFrom
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.request.*
|
||||
@@ -10,17 +17,74 @@ import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import kotlin.collections.mapOf
|
||||
|
||||
// Extension functions for converting between Model and DTO
|
||||
private fun Artikel.toDto(): ArtikelDto = ArtikelDto(
|
||||
id = this.id,
|
||||
bezeichnung = this.bezeichnung,
|
||||
preis = this.preis,
|
||||
einheit = this.einheit,
|
||||
istVerbandsabgabe = this.istVerbandsabgabe,
|
||||
kategorie = this.kategorie,
|
||||
createdAt = this.createdAt,
|
||||
updatedAt = this.updatedAt
|
||||
)
|
||||
|
||||
private fun CreateArtikelDto.toModel(): Artikel = Artikel(
|
||||
bezeichnung = this.bezeichnung,
|
||||
preis = this.preis,
|
||||
einheit = this.einheit,
|
||||
istVerbandsabgabe = this.istVerbandsabgabe,
|
||||
kategorie = this.kategorie
|
||||
)
|
||||
|
||||
private fun UpdateArtikelDto.toModel(): Artikel = Artikel(
|
||||
bezeichnung = this.bezeichnung,
|
||||
preis = this.preis,
|
||||
einheit = this.einheit,
|
||||
istVerbandsabgabe = this.istVerbandsabgabe,
|
||||
kategorie = this.kategorie
|
||||
)
|
||||
|
||||
fun Route.artikelRoutes() {
|
||||
val artikelRepository: ArtikelRepository = ServiceLocator.artikelRepository
|
||||
val log = StructuredLogger.getLogger("ArtikelRoutes")
|
||||
|
||||
route("/artikel") {
|
||||
// GET /api/artikel - Get all articles
|
||||
get {
|
||||
val startTime = System.currentTimeMillis()
|
||||
log.logApiRequest("GET", "/api/artikel")
|
||||
|
||||
try {
|
||||
val artikel = artikelRepository.findAll()
|
||||
call.respond(HttpStatusCode.OK, artikel)
|
||||
val artikelDtos = artikel.map { it.toDto() }
|
||||
val duration = System.currentTimeMillis() - startTime
|
||||
|
||||
log.logApiRequest("GET", "/api/artikel", HttpStatusCode.OK.value, duration)
|
||||
log.info("Articles retrieved successfully", mapOf(
|
||||
"operation" to "getAllArtikel",
|
||||
"count" to artikel.size,
|
||||
"duration_ms" to duration
|
||||
))
|
||||
|
||||
call.respondVersionedList(HttpStatusCode.OK, artikelDtos)
|
||||
} catch (e: Exception) {
|
||||
call.respond(HttpStatusCode.InternalServerError, mapOf("error" to e.message))
|
||||
val duration = System.currentTimeMillis() - startTime
|
||||
log.logApiRequest("GET", "/api/artikel", HttpStatusCode.InternalServerError.value, duration)
|
||||
log.error("Failed to retrieve articles", e, mapOf(
|
||||
"operation" to "getAllArtikel",
|
||||
"error_type" to e::class.simpleName,
|
||||
"duration_ms" to duration
|
||||
))
|
||||
|
||||
call.respond(
|
||||
HttpStatusCode.InternalServerError,
|
||||
ApiResponse<Nothing>(
|
||||
success = false,
|
||||
error = "INTERNAL_ERROR",
|
||||
message = e.message ?: "An error occurred while fetching articles"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,19 +93,44 @@ fun Route.artikelRoutes() {
|
||||
try {
|
||||
val id = call.parameters["id"] ?: return@get call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
mapOf("error" to "Missing artikel ID")
|
||||
ApiResponse<Nothing>(
|
||||
success = false,
|
||||
error = "MISSING_PARAMETER",
|
||||
message = "Missing artikel ID"
|
||||
)
|
||||
)
|
||||
val uuid = uuidFrom(id)
|
||||
val artikel = artikelRepository.findById(uuid)
|
||||
if (artikel != null) {
|
||||
call.respond(HttpStatusCode.OK, artikel)
|
||||
call.respondVersioned(HttpStatusCode.OK, artikel.toDto())
|
||||
} else {
|
||||
call.respond(HttpStatusCode.NotFound, mapOf("error" to "Artikel not found"))
|
||||
call.respond(
|
||||
HttpStatusCode.NotFound,
|
||||
ApiResponse<Nothing>(
|
||||
success = false,
|
||||
error = "NOT_FOUND",
|
||||
message = "Artikel not found"
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (_: IllegalArgumentException) {
|
||||
call.respond(HttpStatusCode.BadRequest, mapOf("error" to "Invalid UUID format"))
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
ApiResponse<Nothing>(
|
||||
success = false,
|
||||
error = "INVALID_FORMAT",
|
||||
message = "Invalid UUID format"
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
call.respond(HttpStatusCode.InternalServerError, mapOf("error" to e.message))
|
||||
call.respond(
|
||||
HttpStatusCode.InternalServerError,
|
||||
ApiResponse<Nothing>(
|
||||
success = false,
|
||||
error = "INTERNAL_ERROR",
|
||||
message = e.message ?: "An error occurred while fetching article"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,12 +139,24 @@ fun Route.artikelRoutes() {
|
||||
try {
|
||||
val query = call.request.queryParameters["q"] ?: return@get call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
mapOf("error" to "Missing search query parameter 'q'")
|
||||
ApiResponse<Nothing>(
|
||||
success = false,
|
||||
error = "MISSING_PARAMETER",
|
||||
message = "Missing search query parameter 'q'"
|
||||
)
|
||||
)
|
||||
val artikel = artikelRepository.search(query)
|
||||
call.respond(HttpStatusCode.OK, artikel)
|
||||
val artikelDtos = artikel.map { it.toDto() }
|
||||
call.respondVersionedList(HttpStatusCode.OK, artikelDtos)
|
||||
} catch (e: Exception) {
|
||||
call.respond(HttpStatusCode.InternalServerError, mapOf("error" to e.message))
|
||||
call.respond(
|
||||
HttpStatusCode.InternalServerError,
|
||||
ApiResponse<Nothing>(
|
||||
success = false,
|
||||
error = "INTERNAL_ERROR",
|
||||
message = e.message ?: "An error occurred while searching articles"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,23 +165,43 @@ fun Route.artikelRoutes() {
|
||||
try {
|
||||
val istVerbandsabgabe = call.parameters["istVerbandsabgabe"]?.toBoolean() ?: return@get call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
mapOf("error" to "Missing or invalid verbandsabgabe parameter")
|
||||
ApiResponse<Nothing>(
|
||||
success = false,
|
||||
error = "MISSING_PARAMETER",
|
||||
message = "Missing or invalid verbandsabgabe parameter"
|
||||
)
|
||||
)
|
||||
val artikel = artikelRepository.findByVerbandsabgabe(istVerbandsabgabe)
|
||||
call.respond(HttpStatusCode.OK, artikel)
|
||||
val artikelDtos = artikel.map { it.toDto() }
|
||||
call.respondVersionedList(HttpStatusCode.OK, artikelDtos)
|
||||
} catch (e: Exception) {
|
||||
call.respond(HttpStatusCode.InternalServerError, mapOf("error" to e.message))
|
||||
call.respond(
|
||||
HttpStatusCode.InternalServerError,
|
||||
ApiResponse<Nothing>(
|
||||
success = false,
|
||||
error = "INTERNAL_ERROR",
|
||||
message = e.message ?: "An error occurred while fetching articles by verbandsabgabe"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/artikel - Create new article
|
||||
post {
|
||||
try {
|
||||
val artikel = call.receive<Artikel>()
|
||||
val createArtikelDto = call.receive<CreateArtikelDto>()
|
||||
val artikel = createArtikelDto.toModel()
|
||||
val createdArtikel = artikelRepository.create(artikel)
|
||||
call.respond(HttpStatusCode.Created, createdArtikel)
|
||||
call.respondVersioned(HttpStatusCode.Created, createdArtikel.toDto())
|
||||
} catch (e: Exception) {
|
||||
call.respond(HttpStatusCode.BadRequest, mapOf("error" to e.message))
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
ApiResponse<Nothing>(
|
||||
success = false,
|
||||
error = "INVALID_INPUT",
|
||||
message = e.message ?: "Invalid input for creating article"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,20 +210,46 @@ fun Route.artikelRoutes() {
|
||||
try {
|
||||
val id = call.parameters["id"] ?: return@put call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
mapOf("error" to "Missing artikel ID")
|
||||
ApiResponse<Nothing>(
|
||||
success = false,
|
||||
error = "MISSING_PARAMETER",
|
||||
message = "Missing artikel ID"
|
||||
)
|
||||
)
|
||||
val uuid = uuidFrom(id)
|
||||
val artikel = call.receive<Artikel>()
|
||||
val updateArtikelDto = call.receive<UpdateArtikelDto>()
|
||||
val artikel = updateArtikelDto.toModel()
|
||||
val updatedArtikel = artikelRepository.update(uuid, artikel)
|
||||
if (updatedArtikel != null) {
|
||||
call.respond(HttpStatusCode.OK, updatedArtikel)
|
||||
call.respondVersioned(HttpStatusCode.OK, updatedArtikel.toDto())
|
||||
} else {
|
||||
call.respond(HttpStatusCode.NotFound, mapOf("error" to "Artikel not found"))
|
||||
call.respond(
|
||||
HttpStatusCode.NotFound,
|
||||
ApiResponse<Nothing>(
|
||||
success = false,
|
||||
error = "NOT_FOUND",
|
||||
message = "Artikel not found"
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (_: IllegalArgumentException) {
|
||||
call.respond(HttpStatusCode.BadRequest, mapOf("error" to "Invalid UUID format"))
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
ApiResponse<Nothing>(
|
||||
success = false,
|
||||
error = "INVALID_FORMAT",
|
||||
message = "Invalid UUID format"
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
call.respond(HttpStatusCode.BadRequest, mapOf("error" to e.message))
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
ApiResponse<Nothing>(
|
||||
success = false,
|
||||
error = "INVALID_INPUT",
|
||||
message = e.message ?: "Invalid input for updating article"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,19 +258,44 @@ fun Route.artikelRoutes() {
|
||||
try {
|
||||
val id = call.parameters["id"] ?: return@delete call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
mapOf("error" to "Missing artikel ID")
|
||||
ApiResponse<Nothing>(
|
||||
success = false,
|
||||
error = "MISSING_PARAMETER",
|
||||
message = "Missing artikel ID"
|
||||
)
|
||||
)
|
||||
val uuid = uuidFrom(id)
|
||||
val deleted = artikelRepository.delete(uuid)
|
||||
if (deleted) {
|
||||
call.respond(HttpStatusCode.NoContent)
|
||||
} else {
|
||||
call.respond(HttpStatusCode.NotFound, mapOf("error" to "Artikel not found"))
|
||||
call.respond(
|
||||
HttpStatusCode.NotFound,
|
||||
ApiResponse<Nothing>(
|
||||
success = false,
|
||||
error = "NOT_FOUND",
|
||||
message = "Artikel not found"
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (_: IllegalArgumentException) {
|
||||
call.respond(HttpStatusCode.BadRequest, mapOf("error" to "Invalid UUID format"))
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
ApiResponse<Nothing>(
|
||||
success = false,
|
||||
error = "INVALID_FORMAT",
|
||||
message = "Invalid UUID format"
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
call.respond(HttpStatusCode.InternalServerError, mapOf("error" to e.message))
|
||||
call.respond(
|
||||
HttpStatusCode.InternalServerError,
|
||||
ApiResponse<Nothing>(
|
||||
success = false,
|
||||
error = "INTERNAL_ERROR",
|
||||
message = e.message ?: "An error occurred while deleting article"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package at.mocode.routes
|
||||
|
||||
import at.mocode.events.EventPublisher
|
||||
import at.mocode.utils.ApiResponse
|
||||
import com.benasher44.uuid.uuidFrom
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
|
||||
/**
|
||||
* Routes for accessing domain events
|
||||
*/
|
||||
fun Route.eventRoutes() {
|
||||
val eventPublisher = EventPublisher.getInstance()
|
||||
|
||||
route("/events") {
|
||||
// GET /api/events - Get all events
|
||||
get {
|
||||
try {
|
||||
val events = eventPublisher.getAllEvents()
|
||||
call.respond(
|
||||
HttpStatusCode.OK,
|
||||
ApiResponse(
|
||||
success = true,
|
||||
data = events,
|
||||
message = "Retrieved ${events.size} events"
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
call.respond(
|
||||
HttpStatusCode.InternalServerError,
|
||||
ApiResponse<Nothing>(
|
||||
success = false,
|
||||
error = "INTERNAL_ERROR",
|
||||
message = "Failed to retrieve events: ${e.message}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/events/aggregate/{aggregateId} - Get events by aggregate ID
|
||||
get("/aggregate/{aggregateId}") {
|
||||
try {
|
||||
val aggregateIdParam = call.parameters["aggregateId"] ?: return@get call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
ApiResponse<Nothing>(
|
||||
success = false,
|
||||
error = "MISSING_PARAMETER",
|
||||
message = "Missing aggregate ID parameter"
|
||||
)
|
||||
)
|
||||
|
||||
val aggregateId = uuidFrom(aggregateIdParam)
|
||||
val events = eventPublisher.getEventsByAggregateId(aggregateId)
|
||||
|
||||
call.respond(
|
||||
HttpStatusCode.OK,
|
||||
ApiResponse(
|
||||
success = true,
|
||||
data = events,
|
||||
message = "Retrieved ${events.size} events for aggregate $aggregateId"
|
||||
)
|
||||
)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
ApiResponse<Nothing>(
|
||||
success = false,
|
||||
error = "INVALID_UUID",
|
||||
message = "Invalid UUID format for aggregate ID"
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
call.respond(
|
||||
HttpStatusCode.InternalServerError,
|
||||
ApiResponse<Nothing>(
|
||||
success = false,
|
||||
error = "INTERNAL_ERROR",
|
||||
message = "Failed to retrieve events: ${e.message}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/events/type/{eventType} - Get events by type
|
||||
get("/type/{eventType}") {
|
||||
try {
|
||||
val eventType = call.parameters["eventType"] ?: return@get call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
ApiResponse<Nothing>(
|
||||
success = false,
|
||||
error = "MISSING_PARAMETER",
|
||||
message = "Missing event type parameter"
|
||||
)
|
||||
)
|
||||
|
||||
val events = eventPublisher.getEventsByType(eventType)
|
||||
|
||||
call.respond(
|
||||
HttpStatusCode.OK,
|
||||
ApiResponse(
|
||||
success = true,
|
||||
data = events,
|
||||
message = "Retrieved ${events.size} events of type $eventType"
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
call.respond(
|
||||
HttpStatusCode.InternalServerError,
|
||||
ApiResponse<Nothing>(
|
||||
success = false,
|
||||
error = "INTERNAL_ERROR",
|
||||
message = "Failed to retrieve events: ${e.message}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/events/stats - Get event statistics
|
||||
get("/stats") {
|
||||
try {
|
||||
val allEvents = eventPublisher.getAllEvents()
|
||||
val eventsByType = allEvents.groupBy { it.eventType }
|
||||
val stats = mapOf(
|
||||
"totalEvents" to allEvents.size,
|
||||
"eventsByType" to eventsByType.mapValues { it.value.size },
|
||||
"uniqueAggregates" to allEvents.map { it.aggregateId }.distinct().size
|
||||
)
|
||||
|
||||
call.respond(
|
||||
HttpStatusCode.OK,
|
||||
ApiResponse(
|
||||
success = true,
|
||||
data = stats,
|
||||
message = "Event statistics retrieved successfully"
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
call.respond(
|
||||
HttpStatusCode.InternalServerError,
|
||||
ApiResponse<Nothing>(
|
||||
success = false,
|
||||
error = "INTERNAL_ERROR",
|
||||
message = "Failed to retrieve event statistics: ${e.message}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,14 +13,24 @@ object RouteConfiguration {
|
||||
*/
|
||||
fun Route.configureApiRoutes() {
|
||||
route("/api") {
|
||||
// Core domain routes
|
||||
// Version-agnostic routes (always use latest version)
|
||||
configureCoreRoutes()
|
||||
|
||||
// Domain-specific routes
|
||||
configureDomainRoutes()
|
||||
|
||||
// Event/Tournament management routes
|
||||
configureEventRoutes()
|
||||
configureDomainEventRoutes()
|
||||
|
||||
// Versioned API routes
|
||||
route("/v1") {
|
||||
configureCoreRoutes()
|
||||
configureDomainRoutes()
|
||||
configureEventRoutes()
|
||||
configureDomainEventRoutes()
|
||||
}
|
||||
|
||||
// Future versions can be added here
|
||||
// route("/v2") {
|
||||
// configureV2Routes()
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +73,14 @@ object RouteConfiguration {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure domain event routes (event sourcing)
|
||||
*/
|
||||
private fun Route.configureDomainEventRoutes() {
|
||||
// Domain events API for event sourcing
|
||||
eventRoutes()
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure administrative and utility routes
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,8 @@ package at.mocode.services
|
||||
|
||||
import at.mocode.model.Artikel
|
||||
import at.mocode.repositories.ArtikelRepository
|
||||
import at.mocode.utils.StructuredLogger
|
||||
import at.mocode.utils.measureAndLog
|
||||
import com.benasher44.uuid.Uuid
|
||||
import com.ionspin.kotlin.bignum.decimal.BigDecimal
|
||||
|
||||
@@ -10,19 +12,44 @@ import com.ionspin.kotlin.bignum.decimal.BigDecimal
|
||||
* Handles business rules, validation, and coordinates with the repository layer.
|
||||
*/
|
||||
class ArtikelService(private val artikelRepository: ArtikelRepository) {
|
||||
private val log = StructuredLogger.getLogger(ArtikelService::class.java)
|
||||
|
||||
/**
|
||||
* Retrieve all articles
|
||||
*/
|
||||
suspend fun getAllArtikel(): List<Artikel> {
|
||||
return artikelRepository.findAll()
|
||||
return log.measureAndLog("get_all_artikel", mapOf("operation" to "findAll")) {
|
||||
val articles = artikelRepository.findAll()
|
||||
log.info("Retrieved all articles", mapOf(
|
||||
"operation" to "getAllArtikel",
|
||||
"count" to articles.size
|
||||
))
|
||||
articles
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an article by its unique identifier
|
||||
*/
|
||||
suspend fun getArtikelById(id: Uuid): Artikel? {
|
||||
return artikelRepository.findById(id)
|
||||
return log.measureAndLog("get_artikel_by_id", mapOf("artikel_id" to id.toString())) {
|
||||
val artikel = artikelRepository.findById(id)
|
||||
if (artikel != null) {
|
||||
log.info("Article found by ID", mapOf(
|
||||
"operation" to "getArtikelById",
|
||||
"artikel_id" to id.toString(),
|
||||
"artikel_bezeichnung" to artikel.bezeichnung,
|
||||
"found" to true
|
||||
))
|
||||
} else {
|
||||
log.warn("Article not found by ID", mapOf(
|
||||
"operation" to "getArtikelById",
|
||||
"artikel_id" to id.toString(),
|
||||
"found" to false
|
||||
))
|
||||
}
|
||||
artikel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,32 +64,118 @@ class ArtikelService(private val artikelRepository: ArtikelRepository) {
|
||||
*/
|
||||
suspend fun searchArtikel(query: String): List<Artikel> {
|
||||
if (query.isBlank()) {
|
||||
log.warn("Search attempted with blank query", mapOf(
|
||||
"operation" to "searchArtikel",
|
||||
"query" to query,
|
||||
"error" to "blank_query"
|
||||
))
|
||||
throw IllegalArgumentException("Search query cannot be blank")
|
||||
}
|
||||
return artikelRepository.search(query.trim())
|
||||
|
||||
val trimmedQuery = query.trim()
|
||||
return log.measureAndLog("search_artikel", mapOf("query" to trimmedQuery)) {
|
||||
val results = artikelRepository.search(trimmedQuery)
|
||||
log.info("Article search completed", mapOf(
|
||||
"operation" to "searchArtikel",
|
||||
"query" to trimmedQuery,
|
||||
"results_count" to results.size
|
||||
))
|
||||
results
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new article with business validation
|
||||
*/
|
||||
suspend fun createArtikel(artikel: Artikel): Artikel {
|
||||
validateArtikel(artikel)
|
||||
return artikelRepository.create(artikel)
|
||||
return log.measureAndLog("create_artikel", mapOf(
|
||||
"artikel_bezeichnung" to artikel.bezeichnung,
|
||||
"artikel_preis" to artikel.preis.toString(),
|
||||
"ist_verbandsabgabe" to artikel.istVerbandsabgabe
|
||||
)) {
|
||||
try {
|
||||
validateArtikel(artikel)
|
||||
val createdArtikel = artikelRepository.create(artikel)
|
||||
log.info("Article created successfully", mapOf(
|
||||
"operation" to "createArtikel",
|
||||
"artikel_id" to createdArtikel.id.toString(),
|
||||
"artikel_bezeichnung" to createdArtikel.bezeichnung,
|
||||
"artikel_preis" to createdArtikel.preis.toString(),
|
||||
"ist_verbandsabgabe" to createdArtikel.istVerbandsabgabe
|
||||
))
|
||||
createdArtikel
|
||||
} catch (e: IllegalArgumentException) {
|
||||
log.error("Article creation failed due to validation error", e, mapOf(
|
||||
"operation" to "createArtikel",
|
||||
"artikel_bezeichnung" to artikel.bezeichnung,
|
||||
"validation_error" to e.message
|
||||
))
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing article
|
||||
*/
|
||||
suspend fun updateArtikel(id: Uuid, artikel: Artikel): Artikel? {
|
||||
validateArtikel(artikel)
|
||||
return artikelRepository.update(id, artikel)
|
||||
return log.measureAndLog("update_artikel", mapOf(
|
||||
"artikel_id" to id.toString(),
|
||||
"artikel_bezeichnung" to artikel.bezeichnung,
|
||||
"artikel_preis" to artikel.preis.toString()
|
||||
)) {
|
||||
try {
|
||||
validateArtikel(artikel)
|
||||
val updatedArtikel = artikelRepository.update(id, artikel)
|
||||
if (updatedArtikel != null) {
|
||||
log.info("Article updated successfully", mapOf(
|
||||
"operation" to "updateArtikel",
|
||||
"artikel_id" to id.toString(),
|
||||
"artikel_bezeichnung" to updatedArtikel.bezeichnung,
|
||||
"artikel_preis" to updatedArtikel.preis.toString(),
|
||||
"ist_verbandsabgabe" to updatedArtikel.istVerbandsabgabe
|
||||
))
|
||||
} else {
|
||||
log.warn("Article update failed - article not found", mapOf(
|
||||
"operation" to "updateArtikel",
|
||||
"artikel_id" to id.toString(),
|
||||
"found" to false
|
||||
))
|
||||
}
|
||||
updatedArtikel
|
||||
} catch (e: IllegalArgumentException) {
|
||||
log.error("Article update failed due to validation error", e, mapOf(
|
||||
"operation" to "updateArtikel",
|
||||
"artikel_id" to id.toString(),
|
||||
"artikel_bezeichnung" to artikel.bezeichnung,
|
||||
"validation_error" to e.message
|
||||
))
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an article by ID
|
||||
*/
|
||||
suspend fun deleteArtikel(id: Uuid): Boolean {
|
||||
return artikelRepository.delete(id)
|
||||
return log.measureAndLog("delete_artikel", mapOf("artikel_id" to id.toString())) {
|
||||
val deleted = artikelRepository.delete(id)
|
||||
if (deleted) {
|
||||
log.info("Article deleted successfully", mapOf(
|
||||
"operation" to "deleteArtikel",
|
||||
"artikel_id" to id.toString(),
|
||||
"deleted" to true
|
||||
))
|
||||
} else {
|
||||
log.warn("Article deletion failed - article not found", mapOf(
|
||||
"operation" to "deleteArtikel",
|
||||
"artikel_id" to id.toString(),
|
||||
"deleted" to false
|
||||
))
|
||||
}
|
||||
deleted
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@ package at.mocode.services
|
||||
|
||||
import at.mocode.model.Bewerb
|
||||
import at.mocode.repositories.BewerbRepository
|
||||
import at.mocode.utils.TransactionManager
|
||||
import com.benasher44.uuid.Uuid
|
||||
|
||||
/**
|
||||
@@ -115,9 +116,9 @@ class BewerbService(private val bewerbRepository: BewerbRepository) {
|
||||
/**
|
||||
* Finalize start list for a competition
|
||||
*/
|
||||
suspend fun finalizeStartliste(id: Uuid): Bewerb? {
|
||||
suspend fun finalizeStartliste(id: Uuid): Bewerb? = TransactionManager.withTransaction {
|
||||
val bewerb = getBewerbById(id)
|
||||
return if (bewerb != null) {
|
||||
return@withTransaction if (bewerb != null) {
|
||||
val updatedBewerb = bewerb.copy(istStartlisteFinal = true)
|
||||
updateBewerb(id, updatedBewerb)
|
||||
} else {
|
||||
@@ -128,9 +129,9 @@ class BewerbService(private val bewerbRepository: BewerbRepository) {
|
||||
/**
|
||||
* Finalize the result list for a competition
|
||||
*/
|
||||
suspend fun finalizeErgebnisliste(id: Uuid): Bewerb? {
|
||||
suspend fun finalizeErgebnisliste(id: Uuid): Bewerb? = TransactionManager.withTransaction {
|
||||
val bewerb = getBewerbById(id)
|
||||
return if (bewerb != null) {
|
||||
return@withTransaction if (bewerb != null) {
|
||||
val updatedBewerb = bewerb.copy(istErgebnislisteFinal = true)
|
||||
updateBewerb(id, updatedBewerb)
|
||||
} else {
|
||||
@@ -141,9 +142,9 @@ class BewerbService(private val bewerbRepository: BewerbRepository) {
|
||||
/**
|
||||
* Reopen the start list for a competition
|
||||
*/
|
||||
suspend fun reopenStartliste(id: Uuid): Bewerb? {
|
||||
suspend fun reopenStartliste(id: Uuid): Bewerb? = TransactionManager.withTransaction {
|
||||
val bewerb = getBewerbById(id)
|
||||
return if (bewerb != null) {
|
||||
return@withTransaction if (bewerb != null) {
|
||||
val updatedBewerb = bewerb.copy(istStartlisteFinal = false)
|
||||
updateBewerb(id, updatedBewerb)
|
||||
} else {
|
||||
@@ -154,9 +155,9 @@ class BewerbService(private val bewerbRepository: BewerbRepository) {
|
||||
/**
|
||||
* Reopen the result list for a competition
|
||||
*/
|
||||
suspend fun reopenErgebnisliste(id: Uuid): Bewerb? {
|
||||
suspend fun reopenErgebnisliste(id: Uuid): Bewerb? = TransactionManager.withTransaction {
|
||||
val bewerb = getBewerbById(id)
|
||||
return if (bewerb != null) {
|
||||
return@withTransaction if (bewerb != null) {
|
||||
val updatedBewerb = bewerb.copy(istErgebnislisteFinal = false)
|
||||
updateBewerb(id, updatedBewerb)
|
||||
} else {
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
package at.mocode.services
|
||||
|
||||
import at.mocode.events.EventPublisher
|
||||
import at.mocode.events.TurnierCreatedEvent
|
||||
import at.mocode.events.TurnierDeletedEvent
|
||||
import at.mocode.events.TurnierUpdatedEvent
|
||||
import at.mocode.model.Turnier
|
||||
import at.mocode.repositories.TurnierRepository
|
||||
import at.mocode.utils.TransactionManager
|
||||
import com.benasher44.uuid.Uuid
|
||||
|
||||
/**
|
||||
* Service layer for Turnier (Tournament) business logic.
|
||||
* Handles business rules, validation, and coordinates with the repository layer.
|
||||
*/
|
||||
class TurnierService(private val turnierRepository: TurnierRepository) {
|
||||
class TurnierService(
|
||||
private val turnierRepository: TurnierRepository,
|
||||
private val eventPublisher: EventPublisher = EventPublisher.getInstance()
|
||||
) {
|
||||
|
||||
/**
|
||||
* Retrieve all tournaments
|
||||
@@ -54,7 +62,7 @@ class TurnierService(private val turnierRepository: TurnierRepository) {
|
||||
/**
|
||||
* Create a new tournament with business validation
|
||||
*/
|
||||
suspend fun createTurnier(turnier: Turnier): Turnier {
|
||||
suspend fun createTurnier(turnier: Turnier): Turnier = TransactionManager.withTransaction {
|
||||
validateTurnier(turnier)
|
||||
|
||||
// Check if OEPS tournament number already exists
|
||||
@@ -65,15 +73,29 @@ class TurnierService(private val turnierRepository: TurnierRepository) {
|
||||
}
|
||||
}
|
||||
|
||||
return turnierRepository.create(turnier)
|
||||
val createdTurnier = turnierRepository.create(turnier)
|
||||
|
||||
// Publish tournament created event
|
||||
eventPublisher.publish(
|
||||
TurnierCreatedEvent(
|
||||
aggregateId = createdTurnier.id,
|
||||
turnier = createdTurnier
|
||||
)
|
||||
)
|
||||
|
||||
return@withTransaction createdTurnier
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing tournament
|
||||
*/
|
||||
suspend fun updateTurnier(id: Uuid, turnier: Turnier): Turnier? {
|
||||
suspend fun updateTurnier(id: Uuid, turnier: Turnier): Turnier? = TransactionManager.withTransaction {
|
||||
validateTurnier(turnier)
|
||||
|
||||
// Get the previous tournament for the event
|
||||
val previousTurnier = turnierRepository.findById(id)
|
||||
?: throw IllegalArgumentException("Tournament with ID $id not found")
|
||||
|
||||
// Check if the OEPS tournament number conflicts with another tournament
|
||||
turnier.oepsTurnierNr?.let { oepsNr ->
|
||||
val existing = turnierRepository.findByOepsTurnierNr(oepsNr)
|
||||
@@ -82,14 +104,42 @@ class TurnierService(private val turnierRepository: TurnierRepository) {
|
||||
}
|
||||
}
|
||||
|
||||
return turnierRepository.update(id, turnier)
|
||||
val updatedTurnier = turnierRepository.update(id, turnier)
|
||||
|
||||
if (updatedTurnier != null) {
|
||||
// Publish tournament updated event
|
||||
eventPublisher.publish(
|
||||
TurnierUpdatedEvent(
|
||||
aggregateId = updatedTurnier.id,
|
||||
previousTurnier = previousTurnier,
|
||||
updatedTurnier = updatedTurnier
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return@withTransaction updatedTurnier
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a tournament by ID
|
||||
*/
|
||||
suspend fun deleteTurnier(id: Uuid): Boolean {
|
||||
return turnierRepository.delete(id)
|
||||
// Get the tournament before deletion for the event
|
||||
val tournamentToDelete = turnierRepository.findById(id)
|
||||
|
||||
val deleted = turnierRepository.delete(id)
|
||||
|
||||
if (deleted && tournamentToDelete != null) {
|
||||
// Publish tournament deleted event
|
||||
eventPublisher.publish(
|
||||
TurnierDeletedEvent(
|
||||
aggregateId = tournamentToDelete.id,
|
||||
deletedTurnier = tournamentToDelete
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return deleted
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package at.mocode.tables
|
||||
|
||||
import at.mocode.tables.stammdaten.PersonenTable
|
||||
import at.mocode.tables.VeranstaltungenTable
|
||||
import org.jetbrains.exposed.sql.Table
|
||||
import org.jetbrains.exposed.sql.kotlin.datetime.date // Für kotlinx-datetime LocalDate
|
||||
import org.jetbrains.exposed.sql.kotlin.datetime.datetime // Für kotlinx-datetime LocalDateTime
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package at.mocode.utils
|
||||
|
||||
import org.slf4j.Logger
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.slf4j.MDC
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
/**
|
||||
* Structured logging utility that provides convenient methods for logging with context
|
||||
* and structured data. Uses SLF4J with MDC for contextual information.
|
||||
*/
|
||||
class StructuredLogger(private val logger: Logger) {
|
||||
|
||||
companion object {
|
||||
fun getLogger(name: String): StructuredLogger = StructuredLogger(LoggerFactory.getLogger(name))
|
||||
fun getLogger(clazz: Class<*>): StructuredLogger = StructuredLogger(LoggerFactory.getLogger(clazz))
|
||||
|
||||
private val json = Json {
|
||||
prettyPrint = false
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log with structured context data
|
||||
*/
|
||||
fun info(message: String, context: Map<String, Any?> = emptyMap()) {
|
||||
withContext(context) {
|
||||
logger.info(message)
|
||||
}
|
||||
}
|
||||
|
||||
fun debug(message: String, context: Map<String, Any?> = emptyMap()) {
|
||||
withContext(context) {
|
||||
logger.debug(message)
|
||||
}
|
||||
}
|
||||
|
||||
fun warn(message: String, context: Map<String, Any?> = emptyMap()) {
|
||||
withContext(context) {
|
||||
logger.warn(message)
|
||||
}
|
||||
}
|
||||
|
||||
fun error(message: String, throwable: Throwable? = null, context: Map<String, Any?> = emptyMap()) {
|
||||
withContext(context) {
|
||||
if (throwable != null) {
|
||||
logger.error(message, throwable)
|
||||
} else {
|
||||
logger.error(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log business events with structured data
|
||||
*/
|
||||
fun logEvent(eventType: String, message: String, data: Map<String, Any?> = emptyMap()) {
|
||||
val eventContext = buildMap {
|
||||
put("event_type", eventType)
|
||||
put("event_timestamp", System.currentTimeMillis())
|
||||
putAll(data)
|
||||
}
|
||||
info(message, eventContext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log API requests with structured data
|
||||
*/
|
||||
fun logApiRequest(method: String, path: String, statusCode: Int? = null, duration: Long? = null, userId: String? = null) {
|
||||
val context = buildMap {
|
||||
put("http_method", method)
|
||||
put("http_path", path)
|
||||
put("request_type", "api_request")
|
||||
statusCode?.let { put("http_status", it) }
|
||||
duration?.let { put("duration_ms", it) }
|
||||
userId?.let { put("user_id", it) }
|
||||
}
|
||||
info("API Request: $method $path", context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log database operations with structured data
|
||||
*/
|
||||
fun logDatabaseOperation(operation: String, table: String, duration: Long? = null, recordCount: Int? = null) {
|
||||
val context = buildMap {
|
||||
put("db_operation", operation)
|
||||
put("db_table", table)
|
||||
put("operation_type", "database")
|
||||
duration?.let { put("duration_ms", it) }
|
||||
recordCount?.let { put("record_count", it) }
|
||||
}
|
||||
info("Database Operation: $operation on $table", context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log service operations with structured data
|
||||
*/
|
||||
fun logServiceOperation(service: String, operation: String, success: Boolean, duration: Long? = null, data: Map<String, Any?> = emptyMap()) {
|
||||
val context = buildMap {
|
||||
put("service_name", service)
|
||||
put("service_operation", operation)
|
||||
put("operation_success", success)
|
||||
put("operation_type", "service")
|
||||
duration?.let { put("duration_ms", it) }
|
||||
putAll(data)
|
||||
}
|
||||
val level = if (success) "info" else "error"
|
||||
val message = "Service Operation: $service.$operation ${if (success) "succeeded" else "failed"}"
|
||||
|
||||
when (level) {
|
||||
"error" -> error(message, context = context)
|
||||
else -> info(message, context)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a block with temporary MDC context
|
||||
*/
|
||||
private fun withContext(context: Map<String, Any?>, block: () -> Unit) {
|
||||
val originalContext = MDC.getCopyOfContextMap() ?: emptyMap()
|
||||
try {
|
||||
// Add new context to MDC
|
||||
context.forEach { (key, value) ->
|
||||
when (value) {
|
||||
null -> MDC.remove(key)
|
||||
is String -> MDC.put(key, value)
|
||||
is Number -> MDC.put(key, value.toString())
|
||||
is Boolean -> MDC.put(key, value.toString())
|
||||
else -> MDC.put(key, value.toString())
|
||||
}
|
||||
}
|
||||
block()
|
||||
} finally {
|
||||
// Restore original context
|
||||
MDC.clear()
|
||||
originalContext.forEach { (key, value) ->
|
||||
MDC.put(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a child logger with additional context that will be included in all log messages
|
||||
*/
|
||||
fun withContext(context: Map<String, Any?>): ContextualLogger {
|
||||
return ContextualLogger(this, context)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A logger that automatically includes contextual information in all log messages
|
||||
*/
|
||||
class ContextualLogger(
|
||||
private val parent: StructuredLogger,
|
||||
private val baseContext: Map<String, Any?>
|
||||
) {
|
||||
fun info(message: String, additionalContext: Map<String, Any?> = emptyMap()) {
|
||||
parent.info(message, baseContext + additionalContext)
|
||||
}
|
||||
|
||||
fun debug(message: String, additionalContext: Map<String, Any?> = emptyMap()) {
|
||||
parent.debug(message, baseContext + additionalContext)
|
||||
}
|
||||
|
||||
fun warn(message: String, additionalContext: Map<String, Any?> = emptyMap()) {
|
||||
parent.warn(message, baseContext + additionalContext)
|
||||
}
|
||||
|
||||
fun error(message: String, throwable: Throwable? = null, additionalContext: Map<String, Any?> = emptyMap()) {
|
||||
parent.error(message, throwable, baseContext + additionalContext)
|
||||
}
|
||||
|
||||
fun logEvent(eventType: String, message: String, data: Map<String, Any?> = emptyMap()) {
|
||||
parent.logEvent(eventType, message, baseContext + data)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension functions for easy structured logging
|
||||
*/
|
||||
inline fun <reified T> T.structuredLogger(): StructuredLogger = StructuredLogger.getLogger(T::class.java)
|
||||
|
||||
/**
|
||||
* Measure execution time and log with structured data
|
||||
*/
|
||||
inline fun <T> StructuredLogger.measureAndLog(
|
||||
operation: String,
|
||||
context: Map<String, Any?> = emptyMap(),
|
||||
block: () -> T
|
||||
): T {
|
||||
val startTime = System.currentTimeMillis()
|
||||
return try {
|
||||
val result = block()
|
||||
val duration = System.currentTimeMillis() - startTime
|
||||
info("Operation completed: $operation", context + mapOf("duration_ms" to duration, "success" to true))
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
val duration = System.currentTimeMillis() - startTime
|
||||
error("Operation failed: $operation", e, context + mapOf("duration_ms" to duration, "success" to false))
|
||||
throw e
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package at.mocode.utils
|
||||
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.slf4j.LoggerFactory
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
||||
/**
|
||||
* Transaction management utility for handling database transactions in services.
|
||||
* Provides a clean API for wrapping service operations in database transactions.
|
||||
*
|
||||
* This utility supports:
|
||||
* - Standard read-write transactions
|
||||
* - Read-only transactions for better performance
|
||||
* - Proper error handling and logging
|
||||
* - Suspend function support without blocking
|
||||
*/
|
||||
object TransactionManager {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(TransactionManager::class.java)
|
||||
|
||||
/**
|
||||
* Executes a block of code within a database transaction.
|
||||
* This is the main method for wrapping service operations that need transactional behavior.
|
||||
* Uses suspend-friendly transaction handling.
|
||||
*
|
||||
* @param block The code block to execute within the transaction
|
||||
* @return The result of the block execution
|
||||
* @throws Exception Any exception thrown within the transaction block
|
||||
*/
|
||||
suspend fun <T> withTransaction(block: suspend () -> T): T {
|
||||
return try {
|
||||
logger.debug("Starting database transaction")
|
||||
val result = transaction {
|
||||
kotlinx.coroutines.runBlocking {
|
||||
block()
|
||||
}
|
||||
}
|
||||
logger.debug("Database transaction completed successfully")
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
logger.error("Database transaction failed: ${e.message}", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a block of code within a read-only database transaction.
|
||||
* This method is optimized for read operations and provides better performance
|
||||
* for operations that don't modify data.
|
||||
*
|
||||
* @param block The code block to execute within the read-only transaction
|
||||
* @return The result of the block execution
|
||||
* @throws Exception Any exception thrown within the transaction block
|
||||
*/
|
||||
suspend fun <T> withReadOnlyTransaction(block: suspend () -> T): T {
|
||||
return try {
|
||||
logger.debug("Starting read-only database transaction")
|
||||
val result = transaction {
|
||||
// Note: Exposed doesn't have explicit read-only mode, but we can document the intent
|
||||
kotlinx.coroutines.runBlocking {
|
||||
block()
|
||||
}
|
||||
}
|
||||
logger.debug("Read-only database transaction completed successfully")
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
logger.error("Read-only database transaction failed: ${e.message}", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a block of code within a database transaction with explicit rollback handling.
|
||||
* This method provides explicit transaction control with automatic rollback on exceptions.
|
||||
* Note: Exposed automatically handles rollback on exceptions, so this is mainly for clarity.
|
||||
*
|
||||
* @param block The code block to execute within the transaction
|
||||
* @return The result of the block execution
|
||||
* @throws Exception Any exception thrown within the transaction block (transaction will be rolled back)
|
||||
*/
|
||||
suspend fun <T> withTransactionRollback(block: suspend () -> T): T {
|
||||
return try {
|
||||
logger.debug("Starting database transaction with explicit rollback handling")
|
||||
val result = transaction {
|
||||
try {
|
||||
kotlinx.coroutines.runBlocking {
|
||||
block()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("Transaction failed, rolling back: ${e.message}")
|
||||
// Exposed automatically handles rollback on exceptions
|
||||
throw e
|
||||
}
|
||||
}
|
||||
logger.debug("Database transaction with rollback handling completed successfully")
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
logger.error("Database transaction with rollback failed: ${e.message}", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a block of code within a database transaction with retry logic.
|
||||
* This method will retry the transaction up to the specified number of times
|
||||
* if it fails due to transient errors.
|
||||
*
|
||||
* @param maxRetries Maximum number of retry attempts (default: 3)
|
||||
* @param block The code block to execute within the transaction
|
||||
* @return The result of the block execution
|
||||
* @throws Exception The last exception if all retry attempts fail
|
||||
*/
|
||||
suspend fun <T> withTransactionRetry(maxRetries: Int = 3, block: suspend () -> T): T {
|
||||
var lastException: Exception? = null
|
||||
|
||||
repeat(maxRetries + 1) { attempt ->
|
||||
try {
|
||||
logger.debug("Starting database transaction (attempt ${attempt + 1}/${maxRetries + 1})")
|
||||
val result = transaction {
|
||||
kotlinx.coroutines.runBlocking {
|
||||
block()
|
||||
}
|
||||
}
|
||||
logger.debug("Database transaction completed successfully on attempt ${attempt + 1}")
|
||||
return result
|
||||
} catch (e: Exception) {
|
||||
lastException = e
|
||||
if (attempt < maxRetries) {
|
||||
logger.warn("Database transaction failed on attempt ${attempt + 1}, retrying: ${e.message}")
|
||||
// Add a small delay before retry
|
||||
kotlinx.coroutines.runBlocking {
|
||||
kotlinx.coroutines.delay(100L * (attempt + 1))
|
||||
}
|
||||
} else {
|
||||
logger.error("Database transaction failed after ${maxRetries + 1} attempts: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastException ?: RuntimeException("Transaction failed with unknown error")
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
ktor:
|
||||
deployment:
|
||||
# Server port configuration - can be overridden with SERVER_PORT environment variable
|
||||
port: 8080
|
||||
port: 8081
|
||||
# Connection timeout in seconds
|
||||
connectionTimeout: 30
|
||||
# Maximum number of concurrent connections
|
||||
|
||||
@@ -1,28 +1,26 @@
|
||||
<configuration>
|
||||
<!-- Console appender configuration -->
|
||||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<!-- Simple console appender for development -->
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<!-- File appender for important logs -->
|
||||
<!-- File appender with simple pattern -->
|
||||
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>logs/meldestelle.log</file>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||
<!-- daily rollover -->
|
||||
<fileNamePattern>logs/meldestelle.%d{yyyy-MM-dd}.log</fileNamePattern>
|
||||
<!-- keep 30 days' worth of history -->
|
||||
<maxHistory>30</maxHistory>
|
||||
</rollingPolicy>
|
||||
<encoder>
|
||||
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<!-- Set default log level to INFO for production use -->
|
||||
<!-- Root logger configuration -->
|
||||
<root level="INFO">
|
||||
<appender-ref ref="STDOUT"/>
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
<appender-ref ref="FILE"/>
|
||||
</root>
|
||||
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
package at.mocode
|
||||
|
||||
import at.mocode.utils.ApiResponse
|
||||
import at.mocode.utils.ErrorResponse
|
||||
import kotlin.test.*
|
||||
|
||||
/**
|
||||
* Comprehensive test suite for ApiResponse utility classes and data structures.
|
||||
*
|
||||
* This test class verifies:
|
||||
* - ApiResponse data class structure and behavior
|
||||
* - ErrorResponse data class structure and behavior
|
||||
* - Data class serialization compatibility
|
||||
* - Proper field assignments and null handling
|
||||
*/
|
||||
class ApiResponseTest {
|
||||
|
||||
@Test
|
||||
fun testApiResponseDataClassSuccess() {
|
||||
val response = ApiResponse(
|
||||
success = true,
|
||||
data = mapOf("id" to "1", "name" to "Test"),
|
||||
message = "Operation successful"
|
||||
)
|
||||
|
||||
assertTrue(response.success)
|
||||
assertNotNull(response.data)
|
||||
assertEquals("Operation successful", response.message)
|
||||
assertNull(response.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testApiResponseDataClassError() {
|
||||
val response = ApiResponse<Nothing>(
|
||||
success = false,
|
||||
error = "VALIDATION_ERROR",
|
||||
message = "Invalid input"
|
||||
)
|
||||
|
||||
assertFalse(response.success)
|
||||
assertNull(response.data)
|
||||
assertEquals("VALIDATION_ERROR", response.error)
|
||||
assertEquals("Invalid input", response.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testApiResponseDataClassMinimal() {
|
||||
val response = ApiResponse<String>(success = true)
|
||||
|
||||
assertTrue(response.success)
|
||||
assertNull(response.data)
|
||||
assertNull(response.error)
|
||||
assertNull(response.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testApiResponseDataClassWithNullData() {
|
||||
val response = ApiResponse<String>(
|
||||
success = true,
|
||||
data = null,
|
||||
message = "No data available"
|
||||
)
|
||||
|
||||
assertTrue(response.success)
|
||||
assertNull(response.data)
|
||||
assertNull(response.error)
|
||||
assertEquals("No data available", response.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testApiResponseDataClassWithAllFields() {
|
||||
val response = ApiResponse(
|
||||
success = false,
|
||||
data = "some data",
|
||||
error = "ERROR_CODE",
|
||||
message = "Error message"
|
||||
)
|
||||
|
||||
assertFalse(response.success)
|
||||
assertEquals("some data", response.data)
|
||||
assertEquals("ERROR_CODE", response.error)
|
||||
assertEquals("Error message", response.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testErrorResponseDataClass() {
|
||||
val errorResponse = ErrorResponse(
|
||||
code = "NOT_FOUND",
|
||||
message = "Resource not found",
|
||||
details = "The requested item does not exist"
|
||||
)
|
||||
|
||||
assertEquals("NOT_FOUND", errorResponse.code)
|
||||
assertEquals("Resource not found", errorResponse.message)
|
||||
assertEquals("The requested item does not exist", errorResponse.details)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testErrorResponseDataClassWithoutDetails() {
|
||||
val errorResponse = ErrorResponse(
|
||||
code = "VALIDATION_ERROR",
|
||||
message = "Invalid input"
|
||||
)
|
||||
|
||||
assertEquals("VALIDATION_ERROR", errorResponse.code)
|
||||
assertEquals("Invalid input", errorResponse.message)
|
||||
assertNull(errorResponse.details)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testErrorResponseDataClassWithEmptyDetails() {
|
||||
val errorResponse = ErrorResponse(
|
||||
code = "INTERNAL_ERROR",
|
||||
message = "Server error",
|
||||
details = ""
|
||||
)
|
||||
|
||||
assertEquals("INTERNAL_ERROR", errorResponse.code)
|
||||
assertEquals("Server error", errorResponse.message)
|
||||
assertEquals("", errorResponse.details)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testApiResponseEquality() {
|
||||
val response1 = ApiResponse(
|
||||
success = true,
|
||||
data = "test",
|
||||
message = "success"
|
||||
)
|
||||
|
||||
val response2 = ApiResponse(
|
||||
success = true,
|
||||
data = "test",
|
||||
message = "success"
|
||||
)
|
||||
|
||||
assertEquals(response1, response2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testApiResponseInequality() {
|
||||
val response1 = ApiResponse(
|
||||
success = true,
|
||||
data = "test1",
|
||||
message = "success"
|
||||
)
|
||||
|
||||
val response2 = ApiResponse(
|
||||
success = true,
|
||||
data = "test2",
|
||||
message = "success"
|
||||
)
|
||||
|
||||
assertNotEquals(response1, response2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testErrorResponseEquality() {
|
||||
val error1 = ErrorResponse(
|
||||
code = "ERROR",
|
||||
message = "Test error",
|
||||
details = "Details"
|
||||
)
|
||||
|
||||
val error2 = ErrorResponse(
|
||||
code = "ERROR",
|
||||
message = "Test error",
|
||||
details = "Details"
|
||||
)
|
||||
|
||||
assertEquals(error1, error2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testErrorResponseInequality() {
|
||||
val error1 = ErrorResponse(
|
||||
code = "ERROR1",
|
||||
message = "Test error",
|
||||
details = "Details"
|
||||
)
|
||||
|
||||
val error2 = ErrorResponse(
|
||||
code = "ERROR2",
|
||||
message = "Test error",
|
||||
details = "Details"
|
||||
)
|
||||
|
||||
assertNotEquals(error1, error2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testApiResponseToString() {
|
||||
val response = ApiResponse(
|
||||
success = true,
|
||||
data = "test",
|
||||
message = "success"
|
||||
)
|
||||
|
||||
val toString = response.toString()
|
||||
assertTrue(toString.contains("success=true"))
|
||||
assertTrue(toString.contains("data=test"))
|
||||
assertTrue(toString.contains("message=success"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testErrorResponseToString() {
|
||||
val error = ErrorResponse(
|
||||
code = "ERROR",
|
||||
message = "Test error",
|
||||
details = "Details"
|
||||
)
|
||||
|
||||
val toString = error.toString()
|
||||
assertTrue(toString.contains("code=ERROR"))
|
||||
assertTrue(toString.contains("message=Test error"))
|
||||
assertTrue(toString.contains("details=Details"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testApiResponseCopy() {
|
||||
val original = ApiResponse(
|
||||
success = true,
|
||||
data = "original",
|
||||
message = "original message"
|
||||
)
|
||||
|
||||
val copied = original.copy(data = "modified")
|
||||
|
||||
assertTrue(copied.success)
|
||||
assertEquals("modified", copied.data)
|
||||
assertEquals("original message", copied.message)
|
||||
assertNull(copied.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testErrorResponseCopy() {
|
||||
val original = ErrorResponse(
|
||||
code = "ERROR",
|
||||
message = "Original message",
|
||||
details = "Original details"
|
||||
)
|
||||
|
||||
val copied = original.copy(message = "Modified message")
|
||||
|
||||
assertEquals("ERROR", copied.code)
|
||||
assertEquals("Modified message", copied.message)
|
||||
assertEquals("Original details", copied.details)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package at.mocode
|
||||
|
||||
import at.mocode.model.Artikel
|
||||
import at.mocode.repositories.ArtikelRepository
|
||||
import at.mocode.services.ArtikelService
|
||||
import com.benasher44.uuid.uuid4
|
||||
import com.ionspin.kotlin.bignum.decimal.BigDecimal
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlin.test.*
|
||||
|
||||
class ArtikelServiceTest {
|
||||
|
||||
private lateinit var mockRepository: MockArtikelRepository
|
||||
private lateinit var artikelService: ArtikelService
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
mockRepository = MockArtikelRepository()
|
||||
artikelService = ArtikelService(mockRepository)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetAllArtikel() = runBlocking {
|
||||
// Given
|
||||
val artikel1 = createTestArtikel("Test Artikel 1")
|
||||
val artikel2 = createTestArtikel("Test Artikel 2")
|
||||
mockRepository.articles = mutableListOf(artikel1, artikel2)
|
||||
|
||||
// When
|
||||
val result = artikelService.getAllArtikel()
|
||||
|
||||
// Then
|
||||
assertEquals(2, result.size)
|
||||
assertTrue(result.contains(artikel1))
|
||||
assertTrue(result.contains(artikel2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetArtikelById() = runBlocking {
|
||||
// Given
|
||||
val artikel = createTestArtikel("Test Artikel")
|
||||
mockRepository.articles = mutableListOf(artikel)
|
||||
|
||||
// When
|
||||
val result = artikelService.getArtikelById(artikel.id)
|
||||
|
||||
// Then
|
||||
assertNotNull(result)
|
||||
assertEquals(artikel.id, result.id)
|
||||
assertEquals(artikel.bezeichnung, result.bezeichnung)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetArtikelByIdNotFound() = runBlocking {
|
||||
// Given
|
||||
val nonExistentId = uuid4()
|
||||
|
||||
// When
|
||||
val result = artikelService.getArtikelById(nonExistentId)
|
||||
|
||||
// Then
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetArtikelByVerbandsabgabe() = runBlocking {
|
||||
// Given
|
||||
val verbandsArtikel = createTestArtikel("Verbands Artikel", istVerbandsabgabe = true)
|
||||
val normalArtikel = createTestArtikel("Normal Artikel", istVerbandsabgabe = false)
|
||||
mockRepository.articles = mutableListOf(verbandsArtikel, normalArtikel)
|
||||
|
||||
// When
|
||||
val verbandsResult = artikelService.getArtikelByVerbandsabgabe(true)
|
||||
val normalResult = artikelService.getArtikelByVerbandsabgabe(false)
|
||||
|
||||
// Then
|
||||
assertEquals(1, verbandsResult.size)
|
||||
assertEquals(verbandsArtikel.id, verbandsResult[0].id)
|
||||
|
||||
assertEquals(1, normalResult.size)
|
||||
assertEquals(normalArtikel.id, normalResult[0].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetVerbandsabgabeArtikel() = runBlocking {
|
||||
// Given
|
||||
val verbandsArtikel = createTestArtikel("Verbands Artikel", istVerbandsabgabe = true)
|
||||
val normalArtikel = createTestArtikel("Normal Artikel", istVerbandsabgabe = false)
|
||||
mockRepository.articles = mutableListOf(verbandsArtikel, normalArtikel)
|
||||
|
||||
// When
|
||||
val result = artikelService.getVerbandsabgabeArtikel()
|
||||
|
||||
// Then
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(verbandsArtikel.id, result[0].id)
|
||||
assertTrue(result[0].istVerbandsabgabe)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetNonVerbandsabgabeArtikel() = runBlocking {
|
||||
// Given
|
||||
val verbandsArtikel = createTestArtikel("Verbands Artikel", istVerbandsabgabe = true)
|
||||
val normalArtikel = createTestArtikel("Normal Artikel", istVerbandsabgabe = false)
|
||||
mockRepository.articles = mutableListOf(verbandsArtikel, normalArtikel)
|
||||
|
||||
// When
|
||||
val result = artikelService.getNonVerbandsabgabeArtikel()
|
||||
|
||||
// Then
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(normalArtikel.id, result[0].id)
|
||||
assertFalse(result[0].istVerbandsabgabe)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSearchArtikel() = runBlocking {
|
||||
// Given
|
||||
val artikel1 = createTestArtikel("Test Artikel")
|
||||
val artikel2 = createTestArtikel("Another Item")
|
||||
mockRepository.articles = mutableListOf(artikel1, artikel2)
|
||||
|
||||
// When
|
||||
val result = artikelService.searchArtikel("Test")
|
||||
|
||||
// Then
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(artikel1.id, result[0].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSearchArtikelBlankQuery() {
|
||||
// When & Then
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
runBlocking { artikelService.searchArtikel("") }
|
||||
}
|
||||
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
runBlocking { artikelService.searchArtikel(" ") }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCreateArtikel() = runBlocking {
|
||||
// Given
|
||||
val artikel = createTestArtikel("New Artikel")
|
||||
|
||||
// When
|
||||
val result = artikelService.createArtikel(artikel)
|
||||
|
||||
// Then
|
||||
assertEquals(artikel.bezeichnung, result.bezeichnung)
|
||||
assertEquals(artikel.preis, result.preis)
|
||||
assertEquals(artikel.einheit, result.einheit)
|
||||
assertTrue(mockRepository.articles.contains(result))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCreateArtikelValidation() {
|
||||
// Test blank bezeichnung
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
runBlocking { artikelService.createArtikel(createTestArtikel("")) }
|
||||
}
|
||||
|
||||
// Test long bezeichnung
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
runBlocking { artikelService.createArtikel(createTestArtikel("a".repeat(256))) }
|
||||
}
|
||||
|
||||
// Test negative price
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
runBlocking { artikelService.createArtikel(createTestArtikel("Test", preis = BigDecimal.fromInt(-1))) }
|
||||
}
|
||||
|
||||
// Test blank einheit
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
runBlocking { artikelService.createArtikel(createTestArtikel("Test", einheit = "")) }
|
||||
}
|
||||
|
||||
// Test long einheit
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
runBlocking { artikelService.createArtikel(createTestArtikel("Test", einheit = "a".repeat(51))) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUpdateArtikel() = runBlocking {
|
||||
// Given
|
||||
val originalArtikel = createTestArtikel("Original")
|
||||
mockRepository.articles = mutableListOf(originalArtikel)
|
||||
|
||||
val updatedArtikel = originalArtikel.copy(bezeichnung = "Updated")
|
||||
|
||||
// When
|
||||
val result = artikelService.updateArtikel(originalArtikel.id, updatedArtikel)
|
||||
|
||||
// Then
|
||||
assertNotNull(result)
|
||||
assertEquals("Updated", result.bezeichnung)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUpdateArtikelNotFound() = runBlocking {
|
||||
// Given
|
||||
val nonExistentId = uuid4()
|
||||
val artikel = createTestArtikel("Test")
|
||||
|
||||
// When
|
||||
val result = artikelService.updateArtikel(nonExistentId, artikel)
|
||||
|
||||
// Then
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUpdateArtikelValidation() {
|
||||
// Given
|
||||
val originalArtikel = createTestArtikel("Original")
|
||||
mockRepository.articles = mutableListOf(originalArtikel)
|
||||
|
||||
// Test validation during update
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
runBlocking { artikelService.updateArtikel(originalArtikel.id, createTestArtikel("")) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeleteArtikel() = runBlocking {
|
||||
// Given
|
||||
val artikel = createTestArtikel("To Delete")
|
||||
mockRepository.articles = mutableListOf(artikel)
|
||||
|
||||
// When
|
||||
val result = artikelService.deleteArtikel(artikel.id)
|
||||
|
||||
// Then
|
||||
assertTrue(result)
|
||||
assertFalse(mockRepository.articles.contains(artikel))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeleteArtikelNotFound() = runBlocking {
|
||||
// Given
|
||||
val nonExistentId = uuid4()
|
||||
|
||||
// When
|
||||
val result = artikelService.deleteArtikel(nonExistentId)
|
||||
|
||||
// Then
|
||||
assertFalse(result)
|
||||
}
|
||||
|
||||
// Helper function to create test articles
|
||||
private fun createTestArtikel(
|
||||
bezeichnung: String,
|
||||
preis: BigDecimal = BigDecimal.fromInt(100),
|
||||
einheit: String = "Stück",
|
||||
istVerbandsabgabe: Boolean = false
|
||||
): Artikel {
|
||||
return Artikel(
|
||||
id = uuid4(),
|
||||
bezeichnung = bezeichnung,
|
||||
preis = preis,
|
||||
einheit = einheit,
|
||||
istVerbandsabgabe = istVerbandsabgabe,
|
||||
createdAt = Clock.System.now(),
|
||||
updatedAt = Clock.System.now()
|
||||
)
|
||||
}
|
||||
|
||||
// Mock repository implementation for testing
|
||||
private class MockArtikelRepository : ArtikelRepository {
|
||||
var articles = mutableListOf<Artikel>()
|
||||
|
||||
override suspend fun findAll(): List<Artikel> = articles
|
||||
|
||||
override suspend fun findById(id: com.benasher44.uuid.Uuid): Artikel? =
|
||||
articles.find { it.id == id }
|
||||
|
||||
override suspend fun findByVerbandsabgabe(istVerbandsabgabe: Boolean): List<Artikel> =
|
||||
articles.filter { it.istVerbandsabgabe == istVerbandsabgabe }
|
||||
|
||||
override suspend fun search(query: String): List<Artikel> =
|
||||
articles.filter { it.bezeichnung.contains(query, ignoreCase = true) }
|
||||
|
||||
override suspend fun create(artikel: Artikel): Artikel {
|
||||
articles.add(artikel)
|
||||
return artikel
|
||||
}
|
||||
|
||||
override suspend fun update(id: com.benasher44.uuid.Uuid, artikel: Artikel): Artikel? {
|
||||
val index = articles.indexOfFirst { it.id == id }
|
||||
return if (index >= 0) {
|
||||
articles[index] = artikel.copy(id = id)
|
||||
articles[index]
|
||||
} else null
|
||||
}
|
||||
|
||||
override suspend fun delete(id: com.benasher44.uuid.Uuid): Boolean {
|
||||
return articles.removeIf { it.id == id }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
# Comprehensive Testing Implementation Summary
|
||||
|
||||
## Overview
|
||||
This document summarizes the comprehensive testing implementation for the Meldestelle project, covering testing strategies, patterns, and coverage achieved.
|
||||
|
||||
## Testing Framework and Patterns
|
||||
|
||||
### Framework Used
|
||||
- **Kotlin Test**: Primary testing framework using `kotlin.test.*`
|
||||
- **Ktor Testing**: For HTTP endpoint testing using `testApplication`
|
||||
- **Coroutines Testing**: Using `runBlocking` for suspend function testing
|
||||
- **Mock Objects**: Custom mock implementations for repository testing
|
||||
|
||||
### Testing Patterns Established
|
||||
|
||||
#### 1. Utility Testing Pattern (RouteUtilsTest.kt)
|
||||
```kotlin
|
||||
@Test
|
||||
fun testFunctionName() = testApplication {
|
||||
application {
|
||||
install(ContentNegotiation) { json() }
|
||||
routing {
|
||||
get("/test") {
|
||||
// Test implementation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.get("/test").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Data Class Testing Pattern (ApiResponseTest.kt)
|
||||
```kotlin
|
||||
@Test
|
||||
fun testDataClassBehavior() {
|
||||
val instance = DataClass(param1 = "value1", param2 = "value2")
|
||||
|
||||
assertEquals("value1", instance.param1)
|
||||
assertEquals("value2", instance.param2)
|
||||
// Test equality, copy, toString, etc.
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Service Testing Pattern (TurnierServiceTest.kt)
|
||||
```kotlin
|
||||
class ServiceTest {
|
||||
private lateinit var mockRepository: MockRepository
|
||||
private lateinit var service: Service
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
mockRepository = MockRepository()
|
||||
service = Service(mockRepository)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testServiceMethod() = runBlocking {
|
||||
// Given
|
||||
val testData = createTestData()
|
||||
mockRepository.data.add(testData)
|
||||
|
||||
// When
|
||||
val result = service.method()
|
||||
|
||||
// Then
|
||||
assertNotNull(result)
|
||||
assertEquals(expected, result)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implemented Test Suites
|
||||
|
||||
### 1. RouteUtilsTest.kt ✅ (21/21 tests passing)
|
||||
**Coverage**: Comprehensive testing of route utility functions
|
||||
- Parameter extraction (UUID, String, Int, Query)
|
||||
- Validation and error handling
|
||||
- Safe execution patterns
|
||||
- Generic handler functions
|
||||
- HTTP status code verification
|
||||
|
||||
**Key Tests**:
|
||||
- `testGetUuidParameterValid/Invalid/Missing`
|
||||
- `testSafeExecuteSuccess/IllegalArgumentException/GenericException`
|
||||
- `testHandleFindById/ByStringParam/ByUuidParamList`
|
||||
|
||||
### 2. ApiResponseTest.kt ✅ (16/16 tests passing)
|
||||
**Coverage**: Complete data class testing for API response structures
|
||||
- ApiResponse data class behavior
|
||||
- ErrorResponse data class behavior
|
||||
- Serialization compatibility
|
||||
- Equality and copy operations
|
||||
- Edge cases and null handling
|
||||
|
||||
**Key Tests**:
|
||||
- `testApiResponseDataClassSuccess/Error/Minimal`
|
||||
- `testErrorResponseDataClass/WithoutDetails`
|
||||
- `testApiResponseEquality/Inequality/Copy`
|
||||
|
||||
### 3. TurnierServiceTest.kt ⚠️ (12/20 tests passing)
|
||||
**Coverage**: Business logic testing for tournament service
|
||||
- CRUD operations testing
|
||||
- Business validation rules
|
||||
- Search functionality
|
||||
- Duplicate checking logic
|
||||
- Error handling scenarios
|
||||
|
||||
**Issues**: Database connection required for transaction-based operations
|
||||
**Passing Tests**: Read operations, validation logic
|
||||
**Failing Tests**: Create/Update operations (require database setup)
|
||||
|
||||
## Testing Infrastructure Components
|
||||
|
||||
### Mock Repository Pattern
|
||||
```kotlin
|
||||
class MockRepository : Repository {
|
||||
val data = mutableListOf<Entity>()
|
||||
|
||||
override suspend fun findAll(): List<Entity> = data.toList()
|
||||
override suspend fun findById(id: Uuid): Entity? = data.find { it.id == id }
|
||||
override suspend fun create(entity: Entity): Entity {
|
||||
data.add(entity)
|
||||
return entity
|
||||
}
|
||||
// ... other CRUD operations
|
||||
}
|
||||
```
|
||||
|
||||
### Test Data Creation Utilities
|
||||
```kotlin
|
||||
private fun createTestEntity(
|
||||
param1: String,
|
||||
param2: String,
|
||||
param3: Uuid = uuid4()
|
||||
): Entity {
|
||||
return Entity(
|
||||
id = uuid4(),
|
||||
param1 = param1,
|
||||
param2 = param2,
|
||||
param3 = param3,
|
||||
// ... other required parameters
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Components Identified for Additional Testing
|
||||
|
||||
### High Priority
|
||||
1. **TransactionManager** - Database transaction handling
|
||||
2. **Database Plugin** - Database connection and configuration
|
||||
3. **Additional Services**:
|
||||
- BewerbService
|
||||
- DomLizenzService
|
||||
- PersonService
|
||||
- VeranstaltungService
|
||||
|
||||
### Medium Priority
|
||||
4. **Repository Layer**:
|
||||
- BaseRepository
|
||||
- PostgresTurnierRepository
|
||||
- PostgresArtikelRepository
|
||||
- Other PostgreSQL repositories
|
||||
|
||||
5. **Route Handlers**:
|
||||
- TurnierRoutes
|
||||
- BewerbRoutes
|
||||
- PersonRoutes
|
||||
- Other route handlers
|
||||
|
||||
### Lower Priority
|
||||
6. **Configuration Classes**:
|
||||
- AppConfig
|
||||
- ServiceConfiguration
|
||||
7. **Validation Components**
|
||||
8. **Serialization Components**
|
||||
|
||||
## Testing Best Practices Established
|
||||
|
||||
### 1. Test Structure
|
||||
- **Given-When-Then** pattern for clarity
|
||||
- Descriptive test names indicating scenario
|
||||
- Proper setup and teardown
|
||||
|
||||
### 2. Error Testing
|
||||
- Comprehensive validation testing
|
||||
- Edge case coverage
|
||||
- Exception handling verification
|
||||
|
||||
### 3. Mock Usage
|
||||
- Isolated unit testing
|
||||
- Predictable test data
|
||||
- No external dependencies
|
||||
|
||||
### 4. HTTP Testing
|
||||
- Status code verification
|
||||
- Response content validation
|
||||
- Request/response cycle testing
|
||||
|
||||
## Recommendations for Complete Implementation
|
||||
|
||||
### 1. Database Testing Setup
|
||||
```kotlin
|
||||
@BeforeTest
|
||||
fun setupDatabase() {
|
||||
Database.connect("jdbc:h2:mem:test", driver = "org.h2.Driver")
|
||||
transaction {
|
||||
SchemaUtils.create(Tables.all)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Integration Testing
|
||||
- End-to-end API testing
|
||||
- Database integration testing
|
||||
- Multi-service interaction testing
|
||||
|
||||
### 3. Performance Testing
|
||||
- Load testing for critical endpoints
|
||||
- Database query performance
|
||||
- Memory usage validation
|
||||
|
||||
### 4. Test Configuration
|
||||
- Separate test configurations
|
||||
- Test-specific database settings
|
||||
- Environment-specific test suites
|
||||
|
||||
## Metrics and Coverage
|
||||
|
||||
### Current Status
|
||||
- **Utility Classes**: 100% coverage (RouteUtils, ApiResponse)
|
||||
- **Service Layer**: Partial coverage (TurnierService pattern established)
|
||||
- **Repository Layer**: Mock pattern established
|
||||
- **Route Layer**: Pattern established, needs implementation
|
||||
|
||||
### Test Quality Metrics
|
||||
- **RouteUtilsTest**: 21 comprehensive tests covering all utility functions
|
||||
- **ApiResponseTest**: 16 tests covering all data class behaviors
|
||||
- **TurnierServiceTest**: 20 tests covering business logic (12 passing, 8 require DB)
|
||||
|
||||
## Conclusion
|
||||
|
||||
The comprehensive testing foundation has been successfully established with:
|
||||
|
||||
1. ✅ **Complete utility testing** - RouteUtils and ApiResponse fully tested
|
||||
2. ✅ **Testing patterns defined** - Reusable patterns for all component types
|
||||
3. ✅ **Mock infrastructure** - Repository mocking pattern established
|
||||
4. ⚠️ **Service testing framework** - Pattern established, needs database setup
|
||||
5. 📋 **Clear roadmap** - Identified components and priorities for additional testing
|
||||
|
||||
The testing infrastructure provides a solid foundation for expanding test coverage across the entire application, ensuring reliability, maintainability, and confidence in the codebase.
|
||||
@@ -0,0 +1,402 @@
|
||||
package at.mocode
|
||||
|
||||
import at.mocode.events.*
|
||||
import at.mocode.events.handlers.*
|
||||
import at.mocode.model.Turnier
|
||||
import at.mocode.repositories.TurnierRepository
|
||||
import at.mocode.services.TurnierService
|
||||
import at.mocode.utils.StructuredLogger
|
||||
import at.mocode.utils.structuredLogger
|
||||
import com.benasher44.uuid.uuid4
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Mock repository for testing
|
||||
*/
|
||||
class MockTurnierRepository : TurnierRepository {
|
||||
private val tournaments = mutableMapOf<com.benasher44.uuid.Uuid, Turnier>()
|
||||
|
||||
override suspend fun findAll(): List<Turnier> = tournaments.values.toList()
|
||||
|
||||
override suspend fun findById(id: com.benasher44.uuid.Uuid): Turnier? = tournaments[id]
|
||||
|
||||
override suspend fun findByVeranstaltungId(veranstaltungId: com.benasher44.uuid.Uuid): List<Turnier> {
|
||||
return tournaments.values.filter { it.veranstaltungId == veranstaltungId }
|
||||
}
|
||||
|
||||
override suspend fun findByOepsTurnierNr(oepsTurnierNr: String): Turnier? {
|
||||
return tournaments.values.find { it.oepsTurnierNr == oepsTurnierNr }
|
||||
}
|
||||
|
||||
override suspend fun search(query: String): List<Turnier> {
|
||||
return tournaments.values.filter {
|
||||
it.titel.contains(query, ignoreCase = true) ||
|
||||
it.oepsTurnierNr.contains(query, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun create(turnier: Turnier): Turnier {
|
||||
tournaments[turnier.id] = turnier
|
||||
return turnier
|
||||
}
|
||||
|
||||
override suspend fun update(id: com.benasher44.uuid.Uuid, turnier: Turnier): Turnier? {
|
||||
return if (tournaments.containsKey(id)) {
|
||||
val updated = turnier.copy(id = id)
|
||||
tournaments[id] = updated
|
||||
updated
|
||||
} else null
|
||||
}
|
||||
|
||||
override suspend fun delete(id: com.benasher44.uuid.Uuid): Boolean {
|
||||
return tournaments.remove(id) != null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test class for Event-Driven Architecture implementation
|
||||
*/
|
||||
class EventDrivenArchitectureTest {
|
||||
|
||||
private val log = structuredLogger()
|
||||
private lateinit var eventPublisher: EventPublisher
|
||||
private lateinit var turnierService: TurnierService
|
||||
private lateinit var mockRepository: MockTurnierRepository
|
||||
private val capturedEvents = mutableListOf<DomainEvent>()
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
// Clear any existing state
|
||||
eventPublisher = EventPublisher.getInstance()
|
||||
eventPublisher.clearEvents()
|
||||
eventPublisher.clearHandlers()
|
||||
capturedEvents.clear()
|
||||
|
||||
// Setup mock repository and service
|
||||
mockRepository = MockTurnierRepository()
|
||||
turnierService = TurnierService(mockRepository, eventPublisher)
|
||||
|
||||
// Register event handlers
|
||||
registerTestEventHandlers()
|
||||
}
|
||||
|
||||
private fun createTestTurnier(
|
||||
oepsTurnierNr: String,
|
||||
titel: String,
|
||||
untertitel: String? = null,
|
||||
veranstaltungId: com.benasher44.uuid.Uuid = uuid4(),
|
||||
datumVon: LocalDate = LocalDate(2024, 6, 1),
|
||||
datumBis: LocalDate = LocalDate(2024, 6, 3)
|
||||
): Turnier {
|
||||
return Turnier(
|
||||
veranstaltungId = veranstaltungId,
|
||||
oepsTurnierNr = oepsTurnierNr,
|
||||
titel = titel,
|
||||
untertitel = untertitel,
|
||||
datumVon = datumVon,
|
||||
datumBis = datumBis,
|
||||
nennungsschluss = null,
|
||||
nennungsHinweis = null,
|
||||
eigenesNennsystemUrl = null,
|
||||
nenngeld = null,
|
||||
startgeldStandard = null,
|
||||
turnierleiterId = null,
|
||||
turnierbeauftragterId = null,
|
||||
tierarztInfos = null,
|
||||
hufschmiedInfo = null,
|
||||
meldestelleVerantwortlicherId = null,
|
||||
meldestelleTelefon = null,
|
||||
meldestelleOeffnungszeiten = null,
|
||||
ergebnislistenUrl = null
|
||||
)
|
||||
}
|
||||
|
||||
private fun registerTestEventHandlers() {
|
||||
// Register a test handler that captures all events
|
||||
val testHandler = object : EventHandler<DomainEvent> {
|
||||
override suspend fun handle(event: DomainEvent) {
|
||||
capturedEvents.add(event)
|
||||
log.debug("Test handler captured event", mapOf(
|
||||
"event_type" to event.eventType,
|
||||
"aggregate_id" to event.aggregateId.toString(),
|
||||
"test_context" to "event_capture",
|
||||
"handler_type" to "test_handler"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
eventPublisher.registerHandler("TurnierCreated", testHandler)
|
||||
eventPublisher.registerHandler("TurnierUpdated", testHandler)
|
||||
eventPublisher.registerHandler("TurnierDeleted", testHandler)
|
||||
|
||||
// Register the actual handlers
|
||||
val auditHandler = TurnierAuditHandler()
|
||||
val notificationHandler = TurnierNotificationHandler()
|
||||
val analyticsHandler = TurnierAnalyticsHandler()
|
||||
val cacheHandler = TurnierCacheHandler()
|
||||
|
||||
eventPublisher.registerHandler("TurnierCreated", auditHandler)
|
||||
eventPublisher.registerHandler("TurnierUpdated", auditHandler)
|
||||
eventPublisher.registerHandler("TurnierDeleted", auditHandler)
|
||||
|
||||
eventPublisher.registerHandler("TurnierCreated", notificationHandler)
|
||||
eventPublisher.registerHandler("TurnierCreated", analyticsHandler)
|
||||
eventPublisher.registerHandler("TurnierCreated", cacheHandler)
|
||||
eventPublisher.registerHandler("TurnierUpdated", cacheHandler)
|
||||
eventPublisher.registerHandler("TurnierDeleted", cacheHandler)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test tournament creation publishes TurnierCreatedEvent`() = runBlocking {
|
||||
log.info("Starting test", mapOf(
|
||||
"test_name" to "tournament_creation",
|
||||
"test_phase" to "start",
|
||||
"test_type" to "event_driven_architecture"
|
||||
))
|
||||
|
||||
// Given
|
||||
val veranstaltungId = uuid4()
|
||||
val turnier = createTestTurnier(
|
||||
oepsTurnierNr = "TEST001",
|
||||
titel = "Test Tournament",
|
||||
untertitel = "Test Subtitle",
|
||||
veranstaltungId = veranstaltungId,
|
||||
datumVon = LocalDate(2024, 6, 1),
|
||||
datumBis = LocalDate(2024, 6, 3)
|
||||
)
|
||||
|
||||
// When
|
||||
val createdTurnier = turnierService.createTurnier(turnier)
|
||||
|
||||
// Give some time for async event processing
|
||||
delay(100)
|
||||
|
||||
// Then
|
||||
assertNotNull(createdTurnier)
|
||||
assertEquals("Test Tournament", createdTurnier.titel)
|
||||
|
||||
// Verify event was published
|
||||
val events = eventPublisher.getAllEvents()
|
||||
assertTrue(events.isNotEmpty(), "Events should be published")
|
||||
|
||||
val createdEvent = events.find { it.eventType == "TurnierCreated" }
|
||||
assertNotNull(createdEvent, "TurnierCreatedEvent should be published")
|
||||
assertTrue(createdEvent is TurnierCreatedEvent)
|
||||
|
||||
val typedEvent = createdEvent as TurnierCreatedEvent
|
||||
assertEquals(createdTurnier.id, typedEvent.aggregateId)
|
||||
assertEquals("Test Tournament", typedEvent.turnier.titel)
|
||||
|
||||
// Verify test handler captured the event
|
||||
assertTrue(capturedEvents.any { it.eventType == "TurnierCreated" })
|
||||
|
||||
log.info("Test completed", mapOf(
|
||||
"test_name" to "tournament_creation",
|
||||
"test_phase" to "complete",
|
||||
"test_result" to "success",
|
||||
"test_type" to "event_driven_architecture"
|
||||
))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test tournament update publishes TurnierUpdatedEvent`() = runBlocking {
|
||||
log.info("Starting test", mapOf(
|
||||
"test_name" to "tournament_update",
|
||||
"test_phase" to "start",
|
||||
"test_type" to "event_driven_architecture"
|
||||
))
|
||||
|
||||
// Given - create a tournament first
|
||||
val veranstaltungId = uuid4()
|
||||
val originalTurnier = createTestTurnier(
|
||||
oepsTurnierNr = "TEST002",
|
||||
titel = "Original Title",
|
||||
untertitel = "Original Subtitle",
|
||||
veranstaltungId = veranstaltungId,
|
||||
datumVon = LocalDate(2024, 7, 1),
|
||||
datumBis = LocalDate(2024, 7, 3)
|
||||
)
|
||||
|
||||
val createdTurnier = turnierService.createTurnier(originalTurnier)
|
||||
capturedEvents.clear() // Clear creation events
|
||||
|
||||
// When - update the tournament
|
||||
val updatedTurnier = createdTurnier.copy(titel = "Updated Title")
|
||||
val result = turnierService.updateTurnier(createdTurnier.id, updatedTurnier)
|
||||
|
||||
// Give some time for async event processing
|
||||
delay(100)
|
||||
|
||||
// Then
|
||||
assertNotNull(result)
|
||||
assertEquals("Updated Title", result.titel)
|
||||
|
||||
// Verify update event was published
|
||||
val updateEvents = eventPublisher.getEventsByType("TurnierUpdated")
|
||||
assertTrue(updateEvents.isNotEmpty(), "TurnierUpdatedEvent should be published")
|
||||
|
||||
val updateEvent = updateEvents.first() as TurnierUpdatedEvent
|
||||
assertEquals(createdTurnier.id, updateEvent.aggregateId)
|
||||
assertEquals("Original Title", updateEvent.previousTurnier.titel)
|
||||
assertEquals("Updated Title", updateEvent.updatedTurnier.titel)
|
||||
|
||||
// Verify test handler captured the event
|
||||
assertTrue(capturedEvents.any { it.eventType == "TurnierUpdated" })
|
||||
|
||||
log.info("Test completed", mapOf(
|
||||
"test_name" to "tournament_update",
|
||||
"test_phase" to "complete",
|
||||
"test_result" to "success",
|
||||
"test_type" to "event_driven_architecture"
|
||||
))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test tournament deletion publishes TurnierDeletedEvent`() = runBlocking {
|
||||
log.info("Starting test", mapOf(
|
||||
"test_name" to "tournament_deletion",
|
||||
"test_phase" to "start",
|
||||
"test_type" to "event_driven_architecture"
|
||||
))
|
||||
|
||||
// Given - create a tournament first
|
||||
val veranstaltungId = uuid4()
|
||||
val turnier = createTestTurnier(
|
||||
oepsTurnierNr = "TEST003",
|
||||
titel = "Tournament to Delete",
|
||||
untertitel = "Will be deleted",
|
||||
veranstaltungId = veranstaltungId,
|
||||
datumVon = LocalDate(2024, 8, 1),
|
||||
datumBis = LocalDate(2024, 8, 3)
|
||||
)
|
||||
|
||||
val createdTurnier = turnierService.createTurnier(turnier)
|
||||
capturedEvents.clear() // Clear creation events
|
||||
|
||||
// When - delete the tournament
|
||||
val deleted = turnierService.deleteTurnier(createdTurnier.id)
|
||||
|
||||
// Give some time for async event processing
|
||||
delay(100)
|
||||
|
||||
// Then
|
||||
assertTrue(deleted, "Tournament should be deleted")
|
||||
|
||||
// Verify delete event was published
|
||||
val deleteEvents = eventPublisher.getEventsByType("TurnierDeleted")
|
||||
assertTrue(deleteEvents.isNotEmpty(), "TurnierDeletedEvent should be published")
|
||||
|
||||
val deleteEvent = deleteEvents.first() as TurnierDeletedEvent
|
||||
assertEquals(createdTurnier.id, deleteEvent.aggregateId)
|
||||
assertEquals("Tournament to Delete", deleteEvent.deletedTurnier.titel)
|
||||
|
||||
// Verify test handler captured the event
|
||||
assertTrue(capturedEvents.any { it.eventType == "TurnierDeleted" })
|
||||
|
||||
log.info("Test completed", mapOf(
|
||||
"test_name" to "tournament_deletion",
|
||||
"test_phase" to "complete",
|
||||
"test_result" to "success",
|
||||
"test_type" to "event_driven_architecture"
|
||||
))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test event store functionality`() = runBlocking {
|
||||
log.info("Starting test", mapOf(
|
||||
"test_name" to "event_store",
|
||||
"test_phase" to "start",
|
||||
"test_type" to "event_driven_architecture"
|
||||
))
|
||||
|
||||
// Given
|
||||
val veranstaltungId = uuid4()
|
||||
val turnier = createTestTurnier(
|
||||
oepsTurnierNr = "TEST004",
|
||||
titel = "Event Store Test",
|
||||
untertitel = "Testing event store",
|
||||
veranstaltungId = veranstaltungId,
|
||||
datumVon = LocalDate(2024, 9, 1),
|
||||
datumBis = LocalDate(2024, 9, 3)
|
||||
)
|
||||
|
||||
// When - perform multiple operations
|
||||
val createdTurnier = turnierService.createTurnier(turnier)
|
||||
val updatedTurnier = turnierService.updateTurnier(createdTurnier.id, createdTurnier.copy(titel = "Updated Title"))
|
||||
turnierService.deleteTurnier(createdTurnier.id)
|
||||
|
||||
// Give some time for async event processing
|
||||
delay(100)
|
||||
|
||||
// Then - verify event store contains all events
|
||||
val allEvents = eventPublisher.getAllEvents()
|
||||
assertTrue(allEvents.size >= 3, "Should have at least 3 events (create, update, delete)")
|
||||
|
||||
val aggregateEvents = eventPublisher.getEventsByAggregateId(createdTurnier.id)
|
||||
assertEquals(3, aggregateEvents.size, "Should have exactly 3 events for this aggregate")
|
||||
|
||||
val eventTypes = aggregateEvents.map { it.eventType }.toSet()
|
||||
assertTrue(eventTypes.contains("TurnierCreated"))
|
||||
assertTrue(eventTypes.contains("TurnierUpdated"))
|
||||
assertTrue(eventTypes.contains("TurnierDeleted"))
|
||||
|
||||
log.info("Test completed", mapOf(
|
||||
"test_name" to "event_store",
|
||||
"test_phase" to "complete",
|
||||
"test_result" to "success",
|
||||
"test_type" to "event_driven_architecture"
|
||||
))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test multiple event handlers process same event`() = runBlocking {
|
||||
log.info("Starting test", mapOf(
|
||||
"test_name" to "multiple_handlers",
|
||||
"test_phase" to "start",
|
||||
"test_type" to "event_driven_architecture"
|
||||
))
|
||||
|
||||
// Given
|
||||
val veranstaltungId = uuid4()
|
||||
val turnier = createTestTurnier(
|
||||
oepsTurnierNr = "TEST005",
|
||||
titel = "Multi Handler Test",
|
||||
untertitel = "Testing multiple handlers",
|
||||
veranstaltungId = veranstaltungId,
|
||||
datumVon = LocalDate(2024, 10, 1),
|
||||
datumBis = LocalDate(2024, 10, 3)
|
||||
)
|
||||
|
||||
// When
|
||||
turnierService.createTurnier(turnier)
|
||||
|
||||
// Give some time for async event processing
|
||||
delay(200)
|
||||
|
||||
// Then - verify that multiple handlers processed the same event
|
||||
// This is verified by the fact that we registered multiple handlers
|
||||
// (audit, notification, analytics, cache) for the same event type
|
||||
// and they should all process the event without interfering with each other
|
||||
|
||||
val createdEvents = eventPublisher.getEventsByType("TurnierCreated")
|
||||
assertTrue(createdEvents.isNotEmpty(), "TurnierCreatedEvent should be published")
|
||||
|
||||
// The test handler should have captured the event
|
||||
assertTrue(capturedEvents.any { it.eventType == "TurnierCreated" })
|
||||
|
||||
log.info("Test completed", mapOf(
|
||||
"test_name" to "multiple_handlers",
|
||||
"test_phase" to "complete",
|
||||
"test_result" to "success",
|
||||
"test_type" to "event_driven_architecture"
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
package at.mocode
|
||||
|
||||
import at.mocode.model.Artikel
|
||||
import at.mocode.model.Platz
|
||||
import at.mocode.enums.PlatzTypE
|
||||
import com.benasher44.uuid.uuid4
|
||||
import com.ionspin.kotlin.bignum.decimal.BigDecimal
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.testing.*
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.test.*
|
||||
|
||||
/**
|
||||
* Integration tests that verify the complete application stack:
|
||||
* Routes -> Services -> Repositories -> Database
|
||||
*
|
||||
* These tests ensure that all layers work together correctly
|
||||
* and provide end-to-end functionality verification.
|
||||
*/
|
||||
class IntegrationTest {
|
||||
|
||||
companion object {
|
||||
init {
|
||||
// Set test environment property for database configuration
|
||||
System.setProperty("isTestEnvironment", "true")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testApplicationStartupAndBasicEndpoints() = testApplication {
|
||||
application {
|
||||
module()
|
||||
}
|
||||
|
||||
// Test health endpoint
|
||||
client.get("/health").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
assertEquals("OK", bodyAsText())
|
||||
}
|
||||
|
||||
// Test API info endpoint
|
||||
client.get("/api").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
val responseText = bodyAsText()
|
||||
assertTrue(responseText.contains("Meldestelle API Server"))
|
||||
assertTrue(responseText.contains("v1.0.0"))
|
||||
}
|
||||
|
||||
// Test root endpoint serves HTML
|
||||
client.get("/").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
val responseText = bodyAsText()
|
||||
assertTrue(responseText.contains("<!DOCTYPE html>"))
|
||||
assertTrue(responseText.contains("Meldestelle"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSwaggerDocumentationEndpoints() = testApplication {
|
||||
application {
|
||||
module()
|
||||
}
|
||||
|
||||
// Test Swagger UI endpoint
|
||||
client.get("/swagger").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
assertTrue(bodyAsText().contains("swagger", ignoreCase = true))
|
||||
}
|
||||
|
||||
// Test OpenAPI endpoint
|
||||
client.get("/openapi").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
val content = bodyAsText()
|
||||
assertTrue(content.isNotEmpty())
|
||||
assertTrue(content.contains("openapi") || content.contains("swagger"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testArtikelEndpointsIntegration() = testApplication {
|
||||
application {
|
||||
module()
|
||||
}
|
||||
|
||||
// Test GET /api/artikel endpoint
|
||||
client.get("/api/artikel").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
// Should return valid JSON response
|
||||
val responseText = bodyAsText()
|
||||
assertTrue(responseText.isNotEmpty())
|
||||
assertTrue(responseText.contains("{") || responseText.startsWith("["))
|
||||
}
|
||||
|
||||
// Test GET /api/artikel/verbandsabgabe/true endpoint
|
||||
client.get("/api/artikel/verbandsabgabe/true").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
val responseText = bodyAsText()
|
||||
assertTrue(responseText.isNotEmpty())
|
||||
assertTrue(responseText.contains("{") || responseText.startsWith("["))
|
||||
}
|
||||
|
||||
// Test GET /api/artikel/verbandsabgabe/false endpoint
|
||||
client.get("/api/artikel/verbandsabgabe/false").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
val responseText = bodyAsText()
|
||||
assertTrue(responseText.isNotEmpty())
|
||||
assertTrue(responseText.contains("{") || responseText.startsWith("["))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPlatzEndpointsIntegration() = testApplication {
|
||||
application {
|
||||
module()
|
||||
}
|
||||
|
||||
// Test GET /api/plaetze endpoint
|
||||
client.get("/api/plaetze").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
val responseText = bodyAsText()
|
||||
assertTrue(responseText.isNotEmpty())
|
||||
assertTrue(responseText.contains("{") || responseText.startsWith("["))
|
||||
}
|
||||
|
||||
// Test GET /api/plaetze/typ/{typ} endpoint
|
||||
client.get("/api/plaetze/typ/AUSTRAGUNG").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
val responseText = bodyAsText()
|
||||
assertTrue(responseText.isNotEmpty())
|
||||
assertTrue(responseText.contains("{") || responseText.startsWith("["))
|
||||
}
|
||||
|
||||
// Test invalid typ parameter
|
||||
client.get("/api/plaetze/typ/INVALID_TYPE").apply {
|
||||
assertEquals(HttpStatusCode.BadRequest, status)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testErrorHandling() = testApplication {
|
||||
application {
|
||||
module()
|
||||
}
|
||||
|
||||
// Test 404 for non-existent endpoint
|
||||
client.get("/api/nonexistent").apply {
|
||||
assertEquals(HttpStatusCode.NotFound, status)
|
||||
}
|
||||
|
||||
// Test 404 for non-existent artikel
|
||||
val nonExistentId = uuid4()
|
||||
client.get("/api/artikel/$nonExistentId").apply {
|
||||
assertEquals(HttpStatusCode.NotFound, status)
|
||||
}
|
||||
|
||||
// Test 404 for non-existent platz
|
||||
client.get("/api/plaetze/$nonExistentId").apply {
|
||||
assertEquals(HttpStatusCode.NotFound, status)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSearchEndpoints() = testApplication {
|
||||
application {
|
||||
module()
|
||||
}
|
||||
|
||||
// Test artikel search with valid query
|
||||
client.get("/api/artikel/search?q=test").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
val responseText = bodyAsText()
|
||||
assertTrue(responseText.isNotEmpty())
|
||||
assertTrue(responseText.contains("{") || responseText.startsWith("["))
|
||||
}
|
||||
|
||||
// Test artikel search with empty query (should return 400)
|
||||
client.get("/api/artikel/search?q=").apply {
|
||||
assertEquals(HttpStatusCode.BadRequest, status)
|
||||
}
|
||||
|
||||
// Test plaetze search with valid query
|
||||
client.get("/api/plaetze/search?q=test").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
val responseText = bodyAsText()
|
||||
assertTrue(responseText.isNotEmpty())
|
||||
assertTrue(responseText.contains("{") || responseText.startsWith("["))
|
||||
}
|
||||
|
||||
// Test plaetze search with empty query (should return 400)
|
||||
client.get("/api/plaetze/search?q=").apply {
|
||||
assertEquals(HttpStatusCode.BadRequest, status)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCorsHeaders() = testApplication {
|
||||
application {
|
||||
module()
|
||||
}
|
||||
|
||||
// Test CORS headers are present
|
||||
client.get("/api/artikel") {
|
||||
header(HttpHeaders.Origin, "http://localhost:3000")
|
||||
}.apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
assertNotNull(headers[HttpHeaders.AccessControlAllowOrigin])
|
||||
}
|
||||
|
||||
// Test OPTIONS request for CORS preflight
|
||||
client.options("/api/artikel") {
|
||||
header(HttpHeaders.Origin, "http://localhost:3000")
|
||||
header(HttpHeaders.AccessControlRequestMethod, "GET")
|
||||
}.apply {
|
||||
// Should handle OPTIONS request properly
|
||||
assertTrue(status.isSuccess() || status == HttpStatusCode.NotFound)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testContentNegotiation() = testApplication {
|
||||
application {
|
||||
module()
|
||||
}
|
||||
|
||||
// Test JSON content type
|
||||
client.get("/api/artikel") {
|
||||
header(HttpHeaders.Accept, "application/json")
|
||||
}.apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
assertEquals(ContentType.Application.Json.withCharset(Charsets.UTF_8), contentType())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testVersioningIntegration() = testApplication {
|
||||
application {
|
||||
module()
|
||||
}
|
||||
|
||||
// Test version validation endpoint (if it exists)
|
||||
client.get("/api/version").apply {
|
||||
// This endpoint might not exist, so we just check it doesn't crash
|
||||
assertTrue(status == HttpStatusCode.OK || status == HttpStatusCode.NotFound)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDatabaseConnectionAndBasicOperations() = testApplication {
|
||||
application {
|
||||
module()
|
||||
}
|
||||
|
||||
// This test verifies that the database connection works
|
||||
// by testing endpoints that require database access
|
||||
|
||||
// Test that we can retrieve data (even if empty)
|
||||
client.get("/api/artikel").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
// Should return valid JSON response
|
||||
val responseText = bodyAsText()
|
||||
assertTrue(responseText.isNotEmpty())
|
||||
assertTrue(responseText.contains("{") || responseText.startsWith("["))
|
||||
}
|
||||
|
||||
client.get("/api/plaetze").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
// Should return valid JSON response
|
||||
val responseText = bodyAsText()
|
||||
assertTrue(responseText.isNotEmpty())
|
||||
assertTrue(responseText.contains("{") || responseText.startsWith("["))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testServiceLayerIntegration() = testApplication {
|
||||
application {
|
||||
module()
|
||||
}
|
||||
|
||||
// Test that service layer validation works through the API
|
||||
|
||||
// Test artikel search with blank query (should trigger service validation)
|
||||
client.get("/api/artikel/search?q= ").apply {
|
||||
assertEquals(HttpStatusCode.BadRequest, status)
|
||||
}
|
||||
|
||||
// Test plaetze search with blank query (should trigger service validation)
|
||||
client.get("/api/plaetze/search?q= ").apply {
|
||||
assertEquals(HttpStatusCode.BadRequest, status)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCompleteApplicationFlow() = testApplication {
|
||||
application {
|
||||
module()
|
||||
}
|
||||
|
||||
println("[DEBUG_LOG] Testing complete application flow...")
|
||||
|
||||
// 1. Test application startup
|
||||
client.get("/health").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
println("[DEBUG_LOG] ✓ Application health check passed")
|
||||
}
|
||||
|
||||
// 2. Test API documentation
|
||||
client.get("/swagger").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
println("[DEBUG_LOG] ✓ Swagger documentation accessible")
|
||||
}
|
||||
|
||||
// 3. Test data retrieval endpoints
|
||||
client.get("/api/artikel").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
println("[DEBUG_LOG] ✓ Artikel endpoint accessible")
|
||||
}
|
||||
|
||||
client.get("/api/plaetze").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
println("[DEBUG_LOG] ✓ Plaetze endpoint accessible")
|
||||
}
|
||||
|
||||
// 4. Test search functionality
|
||||
client.get("/api/artikel/search?q=test").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
println("[DEBUG_LOG] ✓ Artikel search functionality working")
|
||||
}
|
||||
|
||||
client.get("/api/plaetze/search?q=test").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
println("[DEBUG_LOG] ✓ Plaetze search functionality working")
|
||||
}
|
||||
|
||||
// 5. Test error handling
|
||||
client.get("/api/nonexistent").apply {
|
||||
assertEquals(HttpStatusCode.NotFound, status)
|
||||
println("[DEBUG_LOG] ✓ 404 error handling working")
|
||||
}
|
||||
|
||||
println("[DEBUG_LOG] ✅ Complete application flow test passed!")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package at.mocode
|
||||
|
||||
import at.mocode.model.Platz
|
||||
import at.mocode.repositories.PlatzRepository
|
||||
import at.mocode.services.PlatzService
|
||||
import at.mocode.enums.PlatzTypE
|
||||
import com.benasher44.uuid.uuid4
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.*
|
||||
|
||||
class PlatzServiceTest {
|
||||
|
||||
private lateinit var mockRepository: MockPlatzRepository
|
||||
private lateinit var platzService: PlatzService
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
mockRepository = MockPlatzRepository()
|
||||
platzService = PlatzService(mockRepository)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetAllPlaetze() = runBlocking {
|
||||
// Given
|
||||
val platz1 = createTestPlatz("Platz 1")
|
||||
val platz2 = createTestPlatz("Platz 2")
|
||||
mockRepository.plaetze = mutableListOf(platz1, platz2)
|
||||
|
||||
// When
|
||||
val result = platzService.getAllPlaetze()
|
||||
|
||||
// Then
|
||||
assertEquals(2, result.size)
|
||||
assertTrue(result.contains(platz1))
|
||||
assertTrue(result.contains(platz2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetPlatzById() = runBlocking {
|
||||
// Given
|
||||
val platz = createTestPlatz("Test Platz")
|
||||
mockRepository.plaetze = mutableListOf(platz)
|
||||
|
||||
// When
|
||||
val result = platzService.getPlatzById(platz.id)
|
||||
|
||||
// Then
|
||||
assertNotNull(result)
|
||||
assertEquals(platz.id, result.id)
|
||||
assertEquals(platz.name, result.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetPlatzByIdNotFound() = runBlocking {
|
||||
// Given
|
||||
val nonExistentId = uuid4()
|
||||
|
||||
// When
|
||||
val result = platzService.getPlatzById(nonExistentId)
|
||||
|
||||
// Then
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetPlaetzeByTurnierId() = runBlocking {
|
||||
// Given
|
||||
val turnierId1 = uuid4()
|
||||
val turnierId2 = uuid4()
|
||||
val platz1 = createTestPlatz("Platz 1", turnierId = turnierId1)
|
||||
val platz2 = createTestPlatz("Platz 2", turnierId = turnierId1)
|
||||
val platz3 = createTestPlatz("Platz 3", turnierId = turnierId2)
|
||||
mockRepository.plaetze = mutableListOf(platz1, platz2, platz3)
|
||||
|
||||
// When
|
||||
val result = platzService.getPlaetzeByTurnierId(turnierId1)
|
||||
|
||||
// Then
|
||||
assertEquals(2, result.size)
|
||||
assertTrue(result.all { it.turnierId == turnierId1 })
|
||||
assertTrue(result.contains(platz1))
|
||||
assertTrue(result.contains(platz2))
|
||||
assertFalse(result.contains(platz3))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetPlaetzeByTyp() = runBlocking {
|
||||
// Given
|
||||
val platz1 = createTestPlatz("Austragung Platz", typ = PlatzTypE.AUSTRAGUNG)
|
||||
val platz2 = createTestPlatz("Vorbereitung Platz", typ = PlatzTypE.VORBEREITUNG)
|
||||
val platz3 = createTestPlatz("Another Austragung", typ = PlatzTypE.AUSTRAGUNG)
|
||||
mockRepository.plaetze = mutableListOf(platz1, platz2, platz3)
|
||||
|
||||
// When
|
||||
val austragungResult = platzService.getPlaetzeByTyp(PlatzTypE.AUSTRAGUNG)
|
||||
val vorbereitungResult = platzService.getPlaetzeByTyp(PlatzTypE.VORBEREITUNG)
|
||||
|
||||
// Then
|
||||
assertEquals(2, austragungResult.size)
|
||||
assertTrue(austragungResult.all { it.typ == PlatzTypE.AUSTRAGUNG })
|
||||
|
||||
assertEquals(1, vorbereitungResult.size)
|
||||
assertEquals(platz2.id, vorbereitungResult[0].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSearchPlaetze() = runBlocking {
|
||||
// Given
|
||||
val platz1 = createTestPlatz("Hauptplatz")
|
||||
val platz2 = createTestPlatz("Nebenplatz")
|
||||
val platz3 = createTestPlatz("Trainingsplatz")
|
||||
mockRepository.plaetze = mutableListOf(platz1, platz2, platz3)
|
||||
|
||||
// When
|
||||
val result = platzService.searchPlaetze("Haupt")
|
||||
|
||||
// Then
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(platz1.id, result[0].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSearchPlaetzeBlankQuery() {
|
||||
// When & Then
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
runBlocking { platzService.searchPlaetze("") }
|
||||
}
|
||||
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
runBlocking { platzService.searchPlaetze(" ") }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCreatePlatz() = runBlocking {
|
||||
// Given
|
||||
val platz = createTestPlatz("New Platz")
|
||||
|
||||
// When
|
||||
val result = platzService.createPlatz(platz)
|
||||
|
||||
// Then
|
||||
assertEquals(platz.name, result.name)
|
||||
assertEquals(platz.turnierId, result.turnierId)
|
||||
assertEquals(platz.typ, result.typ)
|
||||
assertTrue(mockRepository.plaetze.contains(result))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCreatePlatzValidation() {
|
||||
// Test blank name
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
runBlocking { platzService.createPlatz(createTestPlatz("")) }
|
||||
}
|
||||
|
||||
// Test long name
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
runBlocking { platzService.createPlatz(createTestPlatz("a".repeat(101))) }
|
||||
}
|
||||
|
||||
// Test long dimension
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
runBlocking {
|
||||
platzService.createPlatz(createTestPlatz("Test", dimension = "a".repeat(51)))
|
||||
}
|
||||
}
|
||||
|
||||
// Test long boden
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
runBlocking {
|
||||
platzService.createPlatz(createTestPlatz("Test", boden = "a".repeat(101)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUpdatePlatz() = runBlocking {
|
||||
// Given
|
||||
val originalPlatz = createTestPlatz("Original")
|
||||
mockRepository.plaetze = mutableListOf(originalPlatz)
|
||||
|
||||
val updatedPlatz = originalPlatz.copy(name = "Updated")
|
||||
|
||||
// When
|
||||
val result = platzService.updatePlatz(originalPlatz.id, updatedPlatz)
|
||||
|
||||
// Then
|
||||
assertNotNull(result)
|
||||
assertEquals("Updated", result.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUpdatePlatzNotFound() = runBlocking {
|
||||
// Given
|
||||
val nonExistentId = uuid4()
|
||||
val platz = createTestPlatz("Test")
|
||||
|
||||
// When
|
||||
val result = platzService.updatePlatz(nonExistentId, platz)
|
||||
|
||||
// Then
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUpdatePlatzValidation() {
|
||||
// Given
|
||||
val originalPlatz = createTestPlatz("Original")
|
||||
mockRepository.plaetze = mutableListOf(originalPlatz)
|
||||
|
||||
// Test validation during update
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
runBlocking { platzService.updatePlatz(originalPlatz.id, createTestPlatz("")) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeletePlatz() = runBlocking {
|
||||
// Given
|
||||
val platz = createTestPlatz("To Delete")
|
||||
mockRepository.plaetze = mutableListOf(platz)
|
||||
|
||||
// When
|
||||
val result = platzService.deletePlatz(platz.id)
|
||||
|
||||
// Then
|
||||
assertTrue(result)
|
||||
assertFalse(mockRepository.plaetze.contains(platz))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeletePlatzNotFound() = runBlocking {
|
||||
// Given
|
||||
val nonExistentId = uuid4()
|
||||
|
||||
// When
|
||||
val result = platzService.deletePlatz(nonExistentId)
|
||||
|
||||
// Then
|
||||
assertFalse(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testValidationWithOptionalFields() = runBlocking {
|
||||
// Test valid platz with all optional fields
|
||||
val platzWithOptionals = createTestPlatz(
|
||||
name = "Test Platz",
|
||||
dimension = "20x40m",
|
||||
boden = "Sand"
|
||||
)
|
||||
|
||||
// Should not throw exception
|
||||
val result = platzService.createPlatz(platzWithOptionals)
|
||||
assertEquals("Test Platz", result.name)
|
||||
assertEquals("20x40m", result.dimension)
|
||||
assertEquals("Sand", result.boden)
|
||||
|
||||
// Test valid platz without optional fields
|
||||
val platzWithoutOptionals = createTestPlatz(
|
||||
name = "Simple Platz",
|
||||
dimension = null,
|
||||
boden = null
|
||||
)
|
||||
|
||||
// Should not throw exception
|
||||
val result2 = platzService.createPlatz(platzWithoutOptionals)
|
||||
assertEquals("Simple Platz", result2.name)
|
||||
assertNull(result2.dimension)
|
||||
assertNull(result2.boden)
|
||||
}
|
||||
|
||||
// Helper function to create test places
|
||||
private fun createTestPlatz(
|
||||
name: String,
|
||||
turnierId: com.benasher44.uuid.Uuid = uuid4(),
|
||||
dimension: String? = "20x40m",
|
||||
boden: String? = "Sand",
|
||||
typ: PlatzTypE = PlatzTypE.AUSTRAGUNG
|
||||
): Platz {
|
||||
return Platz(
|
||||
id = uuid4(),
|
||||
turnierId = turnierId,
|
||||
name = name,
|
||||
dimension = dimension,
|
||||
boden = boden,
|
||||
typ = typ
|
||||
)
|
||||
}
|
||||
|
||||
// Mock repository implementation for testing
|
||||
private class MockPlatzRepository : PlatzRepository {
|
||||
var plaetze = mutableListOf<Platz>()
|
||||
|
||||
override suspend fun findAll(): List<Platz> = plaetze
|
||||
|
||||
override suspend fun findById(id: com.benasher44.uuid.Uuid): Platz? =
|
||||
plaetze.find { it.id == id }
|
||||
|
||||
override suspend fun findByTurnierId(turnierId: com.benasher44.uuid.Uuid): List<Platz> =
|
||||
plaetze.filter { it.turnierId == turnierId }
|
||||
|
||||
override suspend fun findByTyp(typ: PlatzTypE): List<Platz> =
|
||||
plaetze.filter { it.typ == typ }
|
||||
|
||||
override suspend fun search(query: String): List<Platz> =
|
||||
plaetze.filter { it.name.contains(query, ignoreCase = true) }
|
||||
|
||||
override suspend fun create(platz: Platz): Platz {
|
||||
plaetze.add(platz)
|
||||
return platz
|
||||
}
|
||||
|
||||
override suspend fun update(id: com.benasher44.uuid.Uuid, platz: Platz): Platz? {
|
||||
val index = plaetze.indexOfFirst { it.id == id }
|
||||
return if (index >= 0) {
|
||||
plaetze[index] = platz.copy(id = id)
|
||||
plaetze[index]
|
||||
} else null
|
||||
}
|
||||
|
||||
override suspend fun delete(id: com.benasher44.uuid.Uuid): Boolean {
|
||||
return plaetze.removeIf { it.id == id }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
package at.mocode
|
||||
|
||||
import at.mocode.utils.*
|
||||
import com.benasher44.uuid.uuid4
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.plugins.contentnegotiation.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.testing.*
|
||||
import io.ktor.server.routing.*
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.test.*
|
||||
|
||||
/**
|
||||
* Comprehensive test suite for RouteUtils utility functions.
|
||||
*
|
||||
* This test class verifies:
|
||||
* - UUID parameter extraction and validation
|
||||
* - String parameter extraction and validation
|
||||
* - Integer parameter extraction and validation
|
||||
* - Query parameter extraction and validation
|
||||
* - Safe execution with error handling
|
||||
* - Response utility functions
|
||||
* - Generic handler functions
|
||||
* - Proper HTTP status codes and error messages
|
||||
*/
|
||||
class RouteUtilsTest {
|
||||
|
||||
@Test
|
||||
fun testGetUuidParameterValid() = testApplication {
|
||||
application {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
prettyPrint = true
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
})
|
||||
}
|
||||
routing {
|
||||
get("/test/{id}") {
|
||||
val uuid = call.getUuidParameter("id")
|
||||
if (uuid != null) {
|
||||
call.respond(HttpStatusCode.OK, mapOf("uuid" to uuid.toString()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val testUuid = uuid4()
|
||||
client.get("/test/$testUuid").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetUuidParameterInvalid() = testApplication {
|
||||
application {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
prettyPrint = true
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
})
|
||||
}
|
||||
routing {
|
||||
get("/test/{id}") {
|
||||
val uuid = call.getUuidParameter("id")
|
||||
if (uuid != null) {
|
||||
call.respond(HttpStatusCode.OK, mapOf("uuid" to uuid.toString()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.get("/test/invalid-uuid").apply {
|
||||
assertEquals(HttpStatusCode.BadRequest, status)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetUuidParameterMissing() = testApplication {
|
||||
application {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
prettyPrint = true
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
})
|
||||
}
|
||||
routing {
|
||||
get("/test") {
|
||||
val uuid = call.getUuidParameter("id")
|
||||
if (uuid != null) {
|
||||
call.respond(HttpStatusCode.OK, mapOf("uuid" to uuid.toString()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.get("/test").apply {
|
||||
assertEquals(HttpStatusCode.BadRequest, status)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetStringParameterValid() = testApplication {
|
||||
application {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
prettyPrint = true
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
})
|
||||
}
|
||||
routing {
|
||||
get("/test/{name}") {
|
||||
val name = call.getStringParameter("name")
|
||||
if (name != null) {
|
||||
call.respond(HttpStatusCode.OK, mapOf("name" to name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.get("/test/testname").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetStringParameterMissing() = testApplication {
|
||||
application {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
prettyPrint = true
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
})
|
||||
}
|
||||
routing {
|
||||
get("/test") {
|
||||
val name = call.getStringParameter("name")
|
||||
if (name != null) {
|
||||
call.respond(HttpStatusCode.OK, mapOf("name" to name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.get("/test").apply {
|
||||
assertEquals(HttpStatusCode.BadRequest, status)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetIntParameterValid() = testApplication {
|
||||
application {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
prettyPrint = true
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
})
|
||||
}
|
||||
routing {
|
||||
get("/test/{count}") {
|
||||
val count = call.getIntParameter("count")
|
||||
if (count != null) {
|
||||
call.respond(HttpStatusCode.OK, mapOf("count" to count))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.get("/test/42").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetIntParameterInvalid() = testApplication {
|
||||
application {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
prettyPrint = true
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
})
|
||||
}
|
||||
routing {
|
||||
get("/test/{count}") {
|
||||
val count = call.getIntParameter("count")
|
||||
if (count != null) {
|
||||
call.respond(HttpStatusCode.OK, mapOf("count" to count))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.get("/test/not-a-number").apply {
|
||||
assertEquals(HttpStatusCode.BadRequest, status)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetIntParameterMissing() = testApplication {
|
||||
application {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
prettyPrint = true
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
})
|
||||
}
|
||||
routing {
|
||||
get("/test") {
|
||||
val count = call.getIntParameter("count")
|
||||
if (count != null) {
|
||||
call.respond(HttpStatusCode.OK, mapOf("count" to count))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.get("/test").apply {
|
||||
assertEquals(HttpStatusCode.BadRequest, status)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetQueryParameterValid() = testApplication {
|
||||
application {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
prettyPrint = true
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
})
|
||||
}
|
||||
routing {
|
||||
get("/test") {
|
||||
val query = call.getQueryParameter("q")
|
||||
if (query != null) {
|
||||
call.respond(HttpStatusCode.OK, mapOf("query" to query))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.get("/test?q=searchterm").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetQueryParameterMissing() = testApplication {
|
||||
application {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
prettyPrint = true
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
})
|
||||
}
|
||||
routing {
|
||||
get("/test") {
|
||||
val query = call.getQueryParameter("q")
|
||||
if (query != null) {
|
||||
call.respond(HttpStatusCode.OK, mapOf("query" to query))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.get("/test").apply {
|
||||
assertEquals(HttpStatusCode.BadRequest, status)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSafeExecuteSuccess() = testApplication {
|
||||
application {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
prettyPrint = true
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
})
|
||||
}
|
||||
routing {
|
||||
get("/test") {
|
||||
call.safeExecute {
|
||||
call.respond(HttpStatusCode.OK, "Success")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.get("/test").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSafeExecuteIllegalArgumentException() = testApplication {
|
||||
application {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
prettyPrint = true
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
})
|
||||
}
|
||||
routing {
|
||||
get("/test") {
|
||||
call.safeExecute {
|
||||
throw IllegalArgumentException("Invalid argument")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.get("/test").apply {
|
||||
assertEquals(HttpStatusCode.BadRequest, status)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSafeExecuteGenericException() = testApplication {
|
||||
application {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
prettyPrint = true
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
})
|
||||
}
|
||||
routing {
|
||||
get("/test") {
|
||||
call.safeExecute {
|
||||
throw RuntimeException("Something went wrong")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.get("/test").apply {
|
||||
assertEquals(HttpStatusCode.InternalServerError, status)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRespondWithEntityOrNotFoundWithEntity() = testApplication {
|
||||
application {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
prettyPrint = true
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
})
|
||||
}
|
||||
routing {
|
||||
get("/test") {
|
||||
val entity = mapOf("id" to "1", "name" to "Test")
|
||||
call.respondWithEntityOrNotFound(entity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.get("/test").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRespondWithEntityOrNotFoundWithNull() = testApplication {
|
||||
application {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
prettyPrint = true
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
})
|
||||
}
|
||||
routing {
|
||||
get("/test") {
|
||||
val entity: Map<String, Any>? = null
|
||||
call.respondWithEntityOrNotFound(entity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.get("/test").apply {
|
||||
assertEquals(HttpStatusCode.NotFound, status)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRespondWithList() = testApplication {
|
||||
application {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
prettyPrint = true
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
})
|
||||
}
|
||||
routing {
|
||||
get("/test") {
|
||||
val entities = listOf(
|
||||
mapOf("id" to "1", "name" to "Test1"),
|
||||
mapOf("id" to "2", "name" to "Test2")
|
||||
)
|
||||
call.respondWithList(entities)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.get("/test").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHandleFindById() = testApplication {
|
||||
application {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
prettyPrint = true
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
})
|
||||
}
|
||||
routing {
|
||||
get("/test/{id}") {
|
||||
call.handleFindById<Map<String, Any>> { id ->
|
||||
mapOf("id" to id.toString(), "name" to "Test Entity")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val testUuid = uuid4()
|
||||
client.get("/test/$testUuid").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHandleFindByIdNotFound() = testApplication {
|
||||
application {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
prettyPrint = true
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
})
|
||||
}
|
||||
routing {
|
||||
get("/test/{id}") {
|
||||
call.handleFindById<Map<String, Any>> { _ ->
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val testUuid = uuid4()
|
||||
client.get("/test/$testUuid").apply {
|
||||
assertEquals(HttpStatusCode.NotFound, status)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHandleFindByStringParam() = testApplication {
|
||||
application {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
prettyPrint = true
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
})
|
||||
}
|
||||
routing {
|
||||
get("/test/{name}") {
|
||||
call.handleFindByStringParam<Map<String, String>>("name") { name ->
|
||||
mapOf("name" to name, "found" to "true")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.get("/test/testname").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHandleFindByUuidParamList() = testApplication {
|
||||
application {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
prettyPrint = true
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
})
|
||||
}
|
||||
routing {
|
||||
get("/test/{id}/items") {
|
||||
call.handleFindByUuidParamList<Map<String, String>>("id") { id ->
|
||||
listOf(
|
||||
mapOf("id" to "1", "parentId" to id.toString()),
|
||||
mapOf("id" to "2", "parentId" to id.toString())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val testUuid = uuid4()
|
||||
client.get("/test/$testUuid/items").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHandleFindByStringParamList() = testApplication {
|
||||
application {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
prettyPrint = true
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
})
|
||||
}
|
||||
routing {
|
||||
get("/test/{category}/items") {
|
||||
call.handleFindByStringParamList<Map<String, String>>("category") { category ->
|
||||
listOf(
|
||||
mapOf("id" to "1", "category" to category),
|
||||
mapOf("id" to "2", "category" to category)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.get("/test/electronics/items").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
package at.mocode
|
||||
|
||||
import at.mocode.model.Turnier
|
||||
import at.mocode.repositories.TurnierRepository
|
||||
import at.mocode.services.TurnierService
|
||||
import com.benasher44.uuid.uuid4
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlin.test.*
|
||||
|
||||
/**
|
||||
* Comprehensive test suite for TurnierService business logic.
|
||||
*
|
||||
* This test class verifies:
|
||||
* - CRUD operations (create, read, update, delete)
|
||||
* - Search functionality
|
||||
* - Business validation rules
|
||||
* - Error handling and edge cases
|
||||
* - Transaction management behavior
|
||||
* - Duplicate checking logic
|
||||
*/
|
||||
class TurnierServiceTest {
|
||||
|
||||
private lateinit var mockRepository: MockTurnierRepository
|
||||
private lateinit var turnierService: TurnierService
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
mockRepository = MockTurnierRepository()
|
||||
turnierService = TurnierService(mockRepository)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetAllTurniere() = runBlocking {
|
||||
// Given
|
||||
val turnier1 = createTestTurnier("Tournament 1", "T001")
|
||||
val turnier2 = createTestTurnier("Tournament 2", "T002")
|
||||
mockRepository.turniere.addAll(listOf(turnier1, turnier2))
|
||||
|
||||
// When
|
||||
val result = turnierService.getAllTurniere()
|
||||
|
||||
// Then
|
||||
assertEquals(2, result.size)
|
||||
assertTrue(result.contains(turnier1))
|
||||
assertTrue(result.contains(turnier2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetTurnierById() = runBlocking {
|
||||
// Given
|
||||
val turnier = createTestTurnier("Test Tournament", "T001")
|
||||
mockRepository.turniere.add(turnier)
|
||||
|
||||
// When
|
||||
val result = turnierService.getTurnierById(turnier.id)
|
||||
|
||||
// Then
|
||||
assertNotNull(result)
|
||||
assertEquals(turnier.id, result.id)
|
||||
assertEquals("Test Tournament", result.titel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetTurnierByIdNotFound() = runBlocking {
|
||||
// Given
|
||||
val nonExistentId = uuid4()
|
||||
|
||||
// When
|
||||
val result = turnierService.getTurnierById(nonExistentId)
|
||||
|
||||
// Then
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetTurniereByVeranstaltungId() = runBlocking {
|
||||
// Given
|
||||
val veranstaltungId = uuid4()
|
||||
val turnier1 = createTestTurnier("Tournament 1", "T001", veranstaltungId)
|
||||
val turnier2 = createTestTurnier("Tournament 2", "T002", veranstaltungId)
|
||||
val turnier3 = createTestTurnier("Tournament 3", "T003", uuid4()) // Different event
|
||||
mockRepository.turniere.addAll(listOf(turnier1, turnier2, turnier3))
|
||||
|
||||
// When
|
||||
val result = turnierService.getTurniereByVeranstaltungId(veranstaltungId)
|
||||
|
||||
// Then
|
||||
assertEquals(2, result.size)
|
||||
assertTrue(result.all { it.veranstaltungId == veranstaltungId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetTurnierByOepsTurnierNr() = runBlocking {
|
||||
// Given
|
||||
val turnier = createTestTurnier("Test Tournament", "T001")
|
||||
mockRepository.turniere.add(turnier)
|
||||
|
||||
// When
|
||||
val result = turnierService.getTurnierByOepsTurnierNr("T001")
|
||||
|
||||
// Then
|
||||
assertNotNull(result)
|
||||
assertEquals("T001", result.oepsTurnierNr)
|
||||
assertEquals("Test Tournament", result.titel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetTurnierByOepsTurnierNrNotFound() = runBlocking {
|
||||
// When
|
||||
val result = turnierService.getTurnierByOepsTurnierNr("NONEXISTENT")
|
||||
|
||||
// Then
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetTurnierByOepsTurnierNrBlank() {
|
||||
runBlocking {
|
||||
// When & Then
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
turnierService.getTurnierByOepsTurnierNr("")
|
||||
}
|
||||
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
turnierService.getTurnierByOepsTurnierNr(" ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSearchTurniere() = runBlocking {
|
||||
// Given
|
||||
val turnier1 = createTestTurnier("Spring Tournament", "T001")
|
||||
val turnier2 = createTestTurnier("Summer Championship", "T002")
|
||||
val turnier3 = createTestTurnier("Winter Cup", "T003")
|
||||
mockRepository.turniere.addAll(listOf(turnier1, turnier2, turnier3))
|
||||
|
||||
// When
|
||||
val result = turnierService.searchTurniere("Tournament")
|
||||
|
||||
// Then
|
||||
assertEquals(1, result.size)
|
||||
assertEquals("Spring Tournament", result[0].titel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSearchTurniereBlankQuery() {
|
||||
runBlocking {
|
||||
// When & Then
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
turnierService.searchTurniere("")
|
||||
}
|
||||
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
turnierService.searchTurniere(" ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCreateTurnier() = runBlocking {
|
||||
// Given
|
||||
val turnier = createTestTurnier("New Tournament", "T001")
|
||||
|
||||
// When
|
||||
val result = turnierService.createTurnier(turnier)
|
||||
|
||||
// Then
|
||||
assertNotNull(result)
|
||||
assertEquals("New Tournament", result.titel)
|
||||
assertEquals("T001", result.oepsTurnierNr)
|
||||
assertTrue(mockRepository.turniere.contains(result))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCreateTurnierValidation() {
|
||||
runBlocking {
|
||||
// Test blank title
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
turnierService.createTurnier(createTestTurnier("", "T001"))
|
||||
}
|
||||
|
||||
// Test title too long
|
||||
val longTitle = "a".repeat(256)
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
turnierService.createTurnier(createTestTurnier(longTitle, "T001"))
|
||||
}
|
||||
|
||||
// Test invalid date range
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
turnierService.createTurnier(
|
||||
createTestTurnier(
|
||||
"Test Tournament",
|
||||
"T001",
|
||||
datumVon = LocalDate(2024, 12, 31),
|
||||
datumBis = LocalDate(2024, 1, 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Test blank OEPS number
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
turnierService.createTurnier(createTestTurnier("Test Tournament", ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCreateTurnierDuplicateOepsNr() {
|
||||
runBlocking {
|
||||
// Given
|
||||
val existingTurnier = createTestTurnier("Existing Tournament", "T001")
|
||||
mockRepository.turniere.add(existingTurnier)
|
||||
|
||||
// When & Then
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
turnierService.createTurnier(createTestTurnier("New Tournament", "T001"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUpdateTurnier() = runBlocking {
|
||||
// Given
|
||||
val originalTurnier = createTestTurnier("Original Tournament", "T001")
|
||||
mockRepository.turniere.add(originalTurnier)
|
||||
val updatedTurnier = originalTurnier.copy(titel = "Updated Tournament")
|
||||
|
||||
// When
|
||||
val result = turnierService.updateTurnier(originalTurnier.id, updatedTurnier)
|
||||
|
||||
// Then
|
||||
assertNotNull(result)
|
||||
assertEquals("Updated Tournament", result.titel)
|
||||
assertEquals("T001", result.oepsTurnierNr)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUpdateTurnierNotFound() = runBlocking {
|
||||
// Given
|
||||
val nonExistentId = uuid4()
|
||||
val turnier = createTestTurnier("Test Tournament", "T001")
|
||||
|
||||
// When
|
||||
val result = turnierService.updateTurnier(nonExistentId, turnier)
|
||||
|
||||
// Then
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUpdateTurnierValidation() {
|
||||
runBlocking {
|
||||
// Given
|
||||
val originalTurnier = createTestTurnier("Original Tournament", "T001")
|
||||
mockRepository.turniere.add(originalTurnier)
|
||||
|
||||
// Test blank title
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
turnierService.updateTurnier(originalTurnier.id, originalTurnier.copy(titel = ""))
|
||||
}
|
||||
|
||||
// Test title too long
|
||||
val longTitle = "a".repeat(256)
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
turnierService.updateTurnier(originalTurnier.id, originalTurnier.copy(titel = longTitle))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUpdateTurnierDuplicateOepsNr() {
|
||||
runBlocking {
|
||||
// Given
|
||||
val turnier1 = createTestTurnier("Tournament 1", "T001")
|
||||
val turnier2 = createTestTurnier("Tournament 2", "T002")
|
||||
mockRepository.turniere.addAll(listOf(turnier1, turnier2))
|
||||
|
||||
// When & Then - Try to update turnier2 with turnier1's OEPS number
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
turnierService.updateTurnier(turnier2.id, turnier2.copy(oepsTurnierNr = "T001"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUpdateTurnierSameOepsNr() = runBlocking {
|
||||
// Given - Should allow updating with the same OEPS number
|
||||
val turnier = createTestTurnier("Tournament", "T001")
|
||||
mockRepository.turniere.add(turnier)
|
||||
|
||||
// When
|
||||
val result = turnierService.updateTurnier(turnier.id, turnier.copy(titel = "Updated Tournament"))
|
||||
|
||||
// Then
|
||||
assertNotNull(result)
|
||||
assertEquals("Updated Tournament", result.titel)
|
||||
assertEquals("T001", result.oepsTurnierNr)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeleteTurnier() = runBlocking {
|
||||
// Given
|
||||
val turnier = createTestTurnier("Test Tournament", "T001")
|
||||
mockRepository.turniere.add(turnier)
|
||||
|
||||
// When
|
||||
val result = turnierService.deleteTurnier(turnier.id)
|
||||
|
||||
// Then
|
||||
assertTrue(result)
|
||||
assertFalse(mockRepository.turniere.contains(turnier))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeleteTurnierNotFound() = runBlocking {
|
||||
// Given
|
||||
val nonExistentId = uuid4()
|
||||
|
||||
// When
|
||||
val result = turnierService.deleteTurnier(nonExistentId)
|
||||
|
||||
// Then
|
||||
assertFalse(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetTurniereForEvent() = runBlocking {
|
||||
// Given
|
||||
val veranstaltungId = uuid4()
|
||||
val turnier1 = createTestTurnier("Tournament 1", "T001", veranstaltungId)
|
||||
val turnier2 = createTestTurnier("Tournament 2", "T002", veranstaltungId)
|
||||
mockRepository.turniere.addAll(listOf(turnier1, turnier2))
|
||||
|
||||
// When
|
||||
val result = turnierService.getTurniereForEvent(veranstaltungId)
|
||||
|
||||
// Then
|
||||
assertEquals(2, result.size)
|
||||
assertTrue(result.all { it.veranstaltungId == veranstaltungId })
|
||||
}
|
||||
|
||||
private fun createTestTurnier(
|
||||
titel: String,
|
||||
oepsTurnierNr: String,
|
||||
veranstaltungId: com.benasher44.uuid.Uuid = uuid4(),
|
||||
datumVon: LocalDate = LocalDate(2024, 6, 1),
|
||||
datumBis: LocalDate = LocalDate(2024, 6, 3)
|
||||
): Turnier {
|
||||
return Turnier(
|
||||
id = uuid4(),
|
||||
veranstaltungId = veranstaltungId,
|
||||
oepsTurnierNr = oepsTurnierNr,
|
||||
titel = titel,
|
||||
untertitel = null,
|
||||
datumVon = datumVon,
|
||||
datumBis = datumBis,
|
||||
nennungsschluss = null,
|
||||
nennungsArt = emptyList(),
|
||||
nennungsHinweis = null,
|
||||
eigenesNennsystemUrl = null,
|
||||
nenngeld = null,
|
||||
startgeldStandard = null,
|
||||
austragungsplaetze = emptyList(),
|
||||
vorbereitungsplaetze = emptyList(),
|
||||
turnierleiterId = null,
|
||||
turnierbeauftragterId = null,
|
||||
richterIds = emptyList(),
|
||||
parcoursbauerIds = emptyList(),
|
||||
parcoursAssistentIds = emptyList(),
|
||||
tierarztInfos = null,
|
||||
hufschmiedInfo = null,
|
||||
meldestelleVerantwortlicherId = null,
|
||||
meldestelleTelefon = null,
|
||||
meldestelleOeffnungszeiten = null,
|
||||
ergebnislistenUrl = null,
|
||||
verfuegbareArtikel = emptyList(),
|
||||
meisterschaftRefs = emptyList(),
|
||||
createdAt = kotlinx.datetime.Clock.System.now(),
|
||||
updatedAt = kotlinx.datetime.Clock.System.now()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock implementation of TurnierRepository for testing
|
||||
*/
|
||||
class MockTurnierRepository : TurnierRepository {
|
||||
val turniere = mutableListOf<Turnier>()
|
||||
|
||||
override suspend fun findAll(): List<Turnier> = turniere.toList()
|
||||
|
||||
override suspend fun findById(id: com.benasher44.uuid.Uuid): Turnier? =
|
||||
turniere.find { it.id == id }
|
||||
|
||||
override suspend fun findByVeranstaltungId(veranstaltungId: com.benasher44.uuid.Uuid): List<Turnier> =
|
||||
turniere.filter { it.veranstaltungId == veranstaltungId }
|
||||
|
||||
override suspend fun findByOepsTurnierNr(oepsTurnierNr: String): Turnier? =
|
||||
turniere.find { it.oepsTurnierNr == oepsTurnierNr }
|
||||
|
||||
override suspend fun search(query: String): List<Turnier> =
|
||||
turniere.filter { it.titel.contains(query, ignoreCase = true) }
|
||||
|
||||
override suspend fun create(turnier: Turnier): Turnier {
|
||||
turniere.add(turnier)
|
||||
return turnier
|
||||
}
|
||||
|
||||
override suspend fun update(id: com.benasher44.uuid.Uuid, turnier: Turnier): Turnier? {
|
||||
val index = turniere.indexOfFirst { it.id == id }
|
||||
return if (index >= 0) {
|
||||
val updated = turnier.copy(id = id)
|
||||
turniere[index] = updated
|
||||
updated
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun delete(id: com.benasher44.uuid.Uuid): Boolean {
|
||||
return turniere.removeIf { it.id == id }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,15 @@ class VersioningTest {
|
||||
|
||||
@Test
|
||||
fun testVersionManagerValidation() {
|
||||
// Test valid version
|
||||
val validResult = VersionManager.validateClientVersion("1.0")
|
||||
// Test current version (1.1)
|
||||
val validResult = VersionManager.validateClientVersion("1.1")
|
||||
assertIs<VersionValidationResult.Valid>(validResult)
|
||||
assertEquals("1.0", validResult.version)
|
||||
assertEquals("1.1", validResult.version)
|
||||
|
||||
// Test the deprecated version (1.0)
|
||||
val deprecatedResult = VersionManager.validateClientVersion("1.0")
|
||||
assertIs<VersionValidationResult.DeprecatedVersion>(deprecatedResult)
|
||||
assertEquals("1.0", deprecatedResult.version)
|
||||
|
||||
// Test unsupported version
|
||||
val unsupportedResult = VersionManager.validateClientVersion("2.0")
|
||||
@@ -35,7 +40,8 @@ class VersioningTest {
|
||||
@Test
|
||||
fun testVersionManagerInfo() {
|
||||
val versionInfo = VersionManager.getVersionInfo()
|
||||
assertEquals("1.0", versionInfo.apiVersion)
|
||||
assertEquals("1.1", versionInfo.apiVersion)
|
||||
assertTrue(versionInfo.supportedVersions.contains("1.1"))
|
||||
assertTrue(versionInfo.supportedVersions.contains("1.0"))
|
||||
assertEquals("1.0", versionInfo.minimumClientVersion)
|
||||
}
|
||||
@@ -110,7 +116,9 @@ class VersioningTest {
|
||||
@Test
|
||||
fun testVersionSupport() {
|
||||
assertTrue(VersionManager.isVersionSupported("1.0"))
|
||||
assertTrue(VersionManager.isVersionSupported("1.1"))
|
||||
assertTrue(!VersionManager.isVersionSupported("2.0"))
|
||||
assertTrue(!VersionManager.isVersionDeprecated("1.0"))
|
||||
assertTrue(VersionManager.isVersionDeprecated("1.0"))
|
||||
assertTrue(!VersionManager.isVersionDeprecated("1.1"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,11 +20,13 @@ data class ArtikelDto(
|
||||
val preis: BigDecimal,
|
||||
val einheit: String,
|
||||
val istVerbandsabgabe: Boolean,
|
||||
@Since("1.1")
|
||||
val kategorie: String? = null, // New field in version 1.1
|
||||
@Serializable(with = KotlinInstantSerializer::class)
|
||||
val createdAt: Instant,
|
||||
@Serializable(with = KotlinInstantSerializer::class)
|
||||
val updatedAt: Instant,
|
||||
override val schemaVersion: String = "1.0",
|
||||
override val schemaVersion: String = "1.1",
|
||||
override val dataVersion: Long? = null
|
||||
) : VersionedDto
|
||||
|
||||
@@ -36,7 +38,9 @@ data class CreateArtikelDto(
|
||||
val preis: BigDecimal,
|
||||
val einheit: String,
|
||||
val istVerbandsabgabe: Boolean = false,
|
||||
override val schemaVersion: String = "1.0",
|
||||
@Since("1.1")
|
||||
val kategorie: String? = null, // New field in version 1.1
|
||||
override val schemaVersion: String = "1.1",
|
||||
override val dataVersion: Long? = null
|
||||
) : VersionedDto
|
||||
|
||||
@@ -48,6 +52,8 @@ data class UpdateArtikelDto(
|
||||
val preis: BigDecimal,
|
||||
val einheit: String,
|
||||
val istVerbandsabgabe: Boolean = false,
|
||||
override val schemaVersion: String = "1.0",
|
||||
@Since("1.1")
|
||||
val kategorie: String? = null, // New field in version 1.1
|
||||
override val schemaVersion: String = "1.1",
|
||||
override val dataVersion: Long? = null
|
||||
) : VersionedDto
|
||||
|
||||
@@ -6,13 +6,13 @@ package at.mocode.dto.base
|
||||
object VersionManager {
|
||||
|
||||
// Current API version
|
||||
const val CURRENT_API_VERSION = "1.0"
|
||||
const val CURRENT_API_VERSION = "1.1"
|
||||
|
||||
// Supported API versions (newest first)
|
||||
val SUPPORTED_VERSIONS = listOf("1.0")
|
||||
val SUPPORTED_VERSIONS = listOf("1.1", "1.0")
|
||||
|
||||
// Deprecated versions (still supported but discouraged)
|
||||
val DEPRECATED_VERSIONS = emptyList<String>()
|
||||
val DEPRECATED_VERSIONS = listOf("1.0")
|
||||
|
||||
// Minimum client version required
|
||||
const val MINIMUM_CLIENT_VERSION = "1.0"
|
||||
|
||||
@@ -12,8 +12,9 @@ class ArtikelDtoMigrator : VersionMigrator<ArtikelDto> {
|
||||
override fun migrate(dto: ArtikelDto, fromVersion: String, toVersion: String): ArtikelDto {
|
||||
return when {
|
||||
fromVersion == "1.0" && toVersion == "1.0" -> dto
|
||||
fromVersion == "1.0" && toVersion == "1.1" -> migrateFrom1_0To1_1(dto)
|
||||
fromVersion == "1.1" && toVersion == "1.1" -> dto
|
||||
// Future migrations would be handled here
|
||||
// fromVersion == "1.0" && toVersion == "1.1" -> migrateFrom1_0To1_1(dto)
|
||||
// fromVersion == "1.1" && toVersion == "1.2" -> migrateFrom1_1To1_2(dto)
|
||||
else -> throw IllegalArgumentException("Unsupported migration from $fromVersion to $toVersion")
|
||||
}
|
||||
@@ -22,19 +23,22 @@ class ArtikelDtoMigrator : VersionMigrator<ArtikelDto> {
|
||||
override fun canMigrate(fromVersion: String, toVersion: String): Boolean {
|
||||
return when {
|
||||
fromVersion == "1.0" && toVersion == "1.0" -> true
|
||||
fromVersion == "1.0" && toVersion == "1.1" -> true
|
||||
fromVersion == "1.1" && toVersion == "1.1" -> true
|
||||
// Future migration paths would be defined here
|
||||
// fromVersion == "1.0" && toVersion == "1.1" -> true
|
||||
// fromVersion == "1.1" && toVersion == "1.2" -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
// Example of a future migration method
|
||||
// private fun migrateFrom1_0To1_1(dto: ArtikelDto): ArtikelDto {
|
||||
// return dto.copy(
|
||||
// schemaVersion = "1.1",
|
||||
// // Add new fields with default values
|
||||
// // newField = "defaultValue"
|
||||
// )
|
||||
// }
|
||||
/**
|
||||
* Migrate ArtikelDto from version 1.0 to 1.1
|
||||
* Adds the new kategorie field with a default value
|
||||
*/
|
||||
private fun migrateFrom1_0To1_1(dto: ArtikelDto): ArtikelDto {
|
||||
return dto.copy(
|
||||
schemaVersion = "1.1",
|
||||
kategorie = "Allgemein" // Default category for existing articles
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ data class Artikel(
|
||||
var preis: BigDecimal,
|
||||
var einheit: String,
|
||||
var istVerbandsabgabe: Boolean = false,
|
||||
var kategorie: String? = null, // New field for version 1.1
|
||||
@Serializable(with = KotlinInstantSerializer::class)
|
||||
val createdAt: Instant = Clock.System.now(),
|
||||
@Serializable(with = KotlinInstantSerializer::class)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import at.mocode.utils.TransactionManager
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
/**
|
||||
* Simple test to verify TransactionManager functionality
|
||||
*/
|
||||
fun main() = runBlocking {
|
||||
println("[DEBUG_LOG] Testing TransactionManager functionality...")
|
||||
|
||||
try {
|
||||
// Test basic transaction
|
||||
val result1 = TransactionManager.withTransaction {
|
||||
println("[DEBUG_LOG] Inside basic transaction")
|
||||
"Basic transaction completed"
|
||||
}
|
||||
println("[DEBUG_LOG] Result 1: $result1")
|
||||
|
||||
// Test read-only transaction
|
||||
val result2 = TransactionManager.withReadOnlyTransaction {
|
||||
println("[DEBUG_LOG] Inside read-only transaction")
|
||||
"Read-only transaction completed"
|
||||
}
|
||||
println("[DEBUG_LOG] Result 2: $result2")
|
||||
|
||||
// Test transaction with rollback
|
||||
val result3 = TransactionManager.withTransactionRollback {
|
||||
println("[DEBUG_LOG] Inside transaction with rollback handling")
|
||||
"Transaction with rollback completed"
|
||||
}
|
||||
println("[DEBUG_LOG] Result 3: $result3")
|
||||
|
||||
println("[DEBUG_LOG] All TransactionManager tests completed successfully!")
|
||||
|
||||
} catch (e: Exception) {
|
||||
println("[DEBUG_LOG] TransactionManager test failed: ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user