diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..b6ebc1c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,4 @@ +[*.{kt,kts}] +# Erlaubt Wildcard-Imports für das gesamte Projekt: +ij_kotlin_allow_trailing_comma = true +disabled_rules = no-wildcard-imports \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 46b4549..9868e1e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -45,4 +45,4 @@ dependencies { // redis implementation("redis.clients:jedis:5.1.2") -} \ No newline at end of file +} diff --git a/src/main/kotlin/main.kt b/src/main/kotlin/Main.kt similarity index 76% rename from src/main/kotlin/main.kt rename to src/main/kotlin/Main.kt index dd67d89..2f97bc1 100644 --- a/src/main/kotlin/main.kt +++ b/src/main/kotlin/Main.kt @@ -1,12 +1,13 @@ -import com.oliver.kidio.backend.plugins.configureRouting import com.oliver.kidio.backend.data.database.DatabaseFactory import com.oliver.kidio.backend.data.database.RedisFactory import com.oliver.kidio.backend.domain.repository.ExposedKidioRepository +import com.oliver.kidio.backend.plugins.configureRouting import com.oliver.kidio.backend.plugins.configureSecurity import com.oliver.kidio.backend.security.JwtService -import io.ktor.serialization.kotlinx.json.* -import io.ktor.server.application.* -import io.ktor.server.plugins.contentnegotiation.* +import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.application.Application +import io.ktor.server.application.install +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation fun Application.module() { // Datenbank initialisieren @@ -25,6 +26,6 @@ fun Application.module() { configureRouting( repository = repository, - jwtService = jwtService + jwtService = jwtService, ) } diff --git a/src/main/kotlin/com/oliver/kidio/backend/data/database/DatabaseFactory.kt b/src/main/kotlin/com/oliver/kidio/backend/data/database/DatabaseFactory.kt index df3362f..b1aa9c0 100644 --- a/src/main/kotlin/com/oliver/kidio/backend/data/database/DatabaseFactory.kt +++ b/src/main/kotlin/com/oliver/kidio/backend/data/database/DatabaseFactory.kt @@ -22,6 +22,7 @@ object ScreentimeTable : Table("user_screentime") { override val primaryKey = PrimaryKey(userId) } + object UserTable : Table("users") { val id = varchar("id", 36) val email = varchar("email", 255).uniqueIndex() @@ -30,7 +31,6 @@ object UserTable : Table("users") { val childAge = integer("child_age") val parentPin = varchar("parent_pin", 4) - override val primaryKey = PrimaryKey(id) } @@ -46,12 +46,13 @@ object DatabaseFactory { val jdbcURL = "jdbc:postgresql://$host:$port/$dbName" - val database = Database.connect( - url = jdbcURL, - driver = driverClassName, - user = dbUsername, - password = password - ) + val database = + Database.connect( + url = jdbcURL, + driver = driverClassName, + user = dbUsername, + password = password, + ) transaction(database) { SchemaUtils.create(TasksTable, ScreentimeTable, UserTable) @@ -79,7 +80,7 @@ object DatabaseFactory { it[remainingSeconds] = 1800 } } - + if (UserTable.selectAll().empty()) { UserTable.insert { it[id] = "default_user" @@ -93,6 +94,5 @@ object DatabaseFactory { } } - suspend fun dbQuery(block: suspend () -> T): T = - newSuspendedTransaction(Dispatchers.IO) { block() } -} \ No newline at end of file + suspend fun dbQuery(block: suspend () -> T): T = newSuspendedTransaction(Dispatchers.IO) { block() } +} diff --git a/src/main/kotlin/com/oliver/kidio/backend/data/database/RedisFactory.kt b/src/main/kotlin/com/oliver/kidio/backend/data/database/RedisFactory.kt index b32d0a1..efad7e5 100644 --- a/src/main/kotlin/com/oliver/kidio/backend/data/database/RedisFactory.kt +++ b/src/main/kotlin/com/oliver/kidio/backend/data/database/RedisFactory.kt @@ -1,5 +1,6 @@ package com.oliver.kidio.backend.data.database -import io.ktor.server.config.* +import io.ktor.server.config.ApplicationConfig +import io.ktor.server.routing.get import redis.clients.jedis.JedisPool import redis.clients.jedis.JedisPoolConfig @@ -7,22 +8,28 @@ object RedisFactory { private var jedisPool: JedisPool? = null fun init(config: ApplicationConfig) { - val host = System.getenv("REDIS_HOST") - ?: config.propertyOrNull("storage.redisHost")?.getString() - ?: "localhost" + val host = + System.getenv("REDIS_HOST") + ?: config.propertyOrNull("storage.redisHost")?.getString() + ?: "localhost" - val port = (System.getenv("REDIS_PORT") - ?: config.propertyOrNull("storage.redisPort")?.getString() - ?: "6379").toInt() + val port = + ( + System.getenv("REDIS_PORT") + ?: config.propertyOrNull("storage.redisPort")?.getString() + ?: "6379" + ).toInt() - val poolConfig = JedisPoolConfig().apply { - maxTotal = 32 - maxIdle = 8 - minIdle = 2 - } + val poolConfig = + JedisPoolConfig().apply { + maxTotal = 32 + maxIdle = 8 + minIdle = 2 + } jedisPool = JedisPool(poolConfig, host, port) } - fun getResource() = jedisPool?.resource - ?: throw IllegalStateException("Redis Verbindungspool ist nicht initzialisiert") + fun getResource() = + jedisPool?.resource + ?: throw IllegalStateException("Redis Verbindungspool ist nicht initzialisiert") } diff --git a/src/main/kotlin/com/oliver/kidio/backend/domain/models/models.kt b/src/main/kotlin/com/oliver/kidio/backend/domain/models/Models.kt similarity index 70% rename from src/main/kotlin/com/oliver/kidio/backend/domain/models/models.kt rename to src/main/kotlin/com/oliver/kidio/backend/domain/models/Models.kt index 79bccbf..d4b56df 100644 --- a/src/main/kotlin/com/oliver/kidio/backend/domain/models/models.kt +++ b/src/main/kotlin/com/oliver/kidio/backend/domain/models/Models.kt @@ -8,14 +8,14 @@ data class Task( val id: String, val question: String, val options: List, - val rewardMinutes: Int // Wie viel Belohnung es gibt + val rewardMinutes: Int, ) // Anfrage des Kindes, wenn es eine Aufgabe löst @Serializable data class TaskVerifyRequest( val taskId: String, - val selectedAnswer: String + val selectedAnswer: String, ) // Antwort vom Server nach der Aufgaben-Prüfung @@ -23,20 +23,20 @@ data class TaskVerifyRequest( data class TaskVerifyResponse( val isCorrect: Boolean, val earnedMinutes: Int, - val message: String + val message: String, ) // Datenabgleich für die Bildschirmzeit (Sync) @Serializable data class SyncRequest( val userId: String, - val usedSecondsSinceLastSync: Int + val usedSecondsSinceLastSync: Int, ) @Serializable data class SyncResponse( val remainingSeconds: Int, - val isBlocked: Boolean + val isBlocked: Boolean, ) // --- NEU: AUTHENTIFIZIERUNG --- @@ -47,21 +47,21 @@ data class RegisterRequest( val passwordPlain: String, val familyName: String, val childAge: Int, - val parentPin: String + val parentPin: String, ) @Serializable data class LoginRequest( val email: String, - val passwordPlain: String + val passwordPlain: String, ) @Serializable data class AuthResponse( - val token: String, // Der digitale "Ausweis" (JWT) - val userId: String, // Wichtig für die App, um Aufgaben zuzuordnen - val familyName: String, // Für "Hallo Familie Müller!" - val childAge: Int + val token: String, + val userId: String, + val familyName: String, + val childAge: Int, ) // Interne Backend-Klasse (Domain Model). @@ -71,5 +71,5 @@ data class User( val passwordHash: String, val familyName: String, val childAge: Int, - val parentPin: String -) \ No newline at end of file + val parentPin: String, +) diff --git a/src/main/kotlin/com/oliver/kidio/backend/domain/repository/KidioRepository.kt b/src/main/kotlin/com/oliver/kidio/backend/domain/repository/KidioRepository.kt index a15c87e..177e0e9 100644 --- a/src/main/kotlin/com/oliver/kidio/backend/domain/repository/KidioRepository.kt +++ b/src/main/kotlin/com/oliver/kidio/backend/domain/repository/KidioRepository.kt @@ -12,53 +12,68 @@ import org.mindrot.jbcrypt.BCrypt interface KidioRepository { suspend fun getAllTasks(): List + suspend fun getTaskAnswerAndReward(taskId: String): Pair? + suspend fun getScreentime(userId: String): Int - suspend fun updateScreentime(userId: String, seconds: Int) + + suspend fun updateScreentime( + userId: String, + seconds: Int, + ) + suspend fun findByEmail(email: String): User? - suspend fun verifyPassword(password: String, passwordHash: String): Boolean + + suspend fun verifyPassword( + password: String, + passwordHash: String, + ): Boolean + suspend fun registerUser(user: User): Boolean } class ExposedKidioRepository : KidioRepository { - override suspend fun getAllTasks(): List = DatabaseFactory.dbQuery { - TasksTable.selectAll().map { - Task( - id = it[TasksTable.id], - question = it[TasksTable.question], - options = it[TasksTable.options].split(",").map { opt -> opt.trim() }, - rewardMinutes = it[TasksTable.rewardMinutes] - ) - } - } - - override suspend fun registerUser(user: User): Boolean = DatabaseFactory.dbQuery { - try { - UserTable.insert { - it[id] = user.id - it[email] = user.email - it[passwordHash] = user.passwordHash - it[familyName] = user.familyName - it[childAge] = user.childAge - it[parentPin] = user.parentPin + override suspend fun getAllTasks(): List = + DatabaseFactory.dbQuery { + TasksTable.selectAll().map { + Task( + id = it[TasksTable.id], + question = it[TasksTable.question], + options = it[TasksTable.options].split(",").map { opt -> opt.trim() }, + rewardMinutes = it[TasksTable.rewardMinutes], + ) } - ScreentimeTable.insert { - it[userId] = user.id - it[remainingSeconds] = 1800 // Startguthaben: 30 Minuten - } - true - } catch (e: Exception) { - println("FEHLER bei Registrierung: ${e.message}") - false } - } - override suspend fun getTaskAnswerAndReward(taskId: String): Pair? = DatabaseFactory.dbQuery { - TasksTable.selectAll() - .where { TasksTable.id eq taskId } - .map { it[TasksTable.correctAnswer] to it[TasksTable.rewardMinutes] } - .singleOrNull() - } + override suspend fun registerUser(user: User): Boolean = + DatabaseFactory.dbQuery { + try { + UserTable.insert { + it[id] = user.id + it[email] = user.email + it[passwordHash] = user.passwordHash + it[familyName] = user.familyName + it[childAge] = user.childAge + it[parentPin] = user.parentPin + } + ScreentimeTable.insert { + it[userId] = user.id + it[remainingSeconds] = 1800 // Startguthaben: 30 Minuten + } + true + } catch (e: Exception) { + println("FEHLER bei Registrierung: ${e.message}") + false + } + } + + override suspend fun getTaskAnswerAndReward(taskId: String): Pair? = + DatabaseFactory.dbQuery { + TasksTable.selectAll() + .where { TasksTable.id eq taskId } + .map { it[TasksTable.correctAnswer] to it[TasksTable.rewardMinutes] } + .singleOrNull() + } override suspend fun getScreentime(userId: String): Int { val redisKey = "screentime:$userId" @@ -75,12 +90,13 @@ class ExposedKidioRepository : KidioRepository { println("WARNUNG: Konnte nicht aus Redis lesen (Postgres-Fallback genutzt): ${e.message}") } - val dbSeconds = DatabaseFactory.dbQuery { - ScreentimeTable.selectAll() - .where { ScreentimeTable.userId eq userId } - .map { it[ScreentimeTable.remainingSeconds] } - .singleOrNull() ?: 1800 - } + val dbSeconds = + DatabaseFactory.dbQuery { + ScreentimeTable.selectAll() + .where { ScreentimeTable.userId eq userId } + .map { it[ScreentimeTable.remainingSeconds] } + .singleOrNull() ?: 1800 + } try { RedisFactory.getResource().use { jedis -> @@ -93,11 +109,15 @@ class ExposedKidioRepository : KidioRepository { return dbSeconds } - override suspend fun updateScreentime(userId: String, seconds: Int) { + override suspend fun updateScreentime( + userId: String, + seconds: Int, + ) { DatabaseFactory.dbQuery { - val rowsUpdated = ScreentimeTable.update({ ScreentimeTable.userId eq userId }) { - it[remainingSeconds] = seconds - } + val rowsUpdated = + ScreentimeTable.update({ ScreentimeTable.userId eq userId }) { + it[remainingSeconds] = seconds + } if (rowsUpdated == 0) { ScreentimeTable.insert { it[ScreentimeTable.userId] = userId @@ -116,39 +136,46 @@ class ExposedKidioRepository : KidioRepository { } } - override suspend fun findByEmail(email: String): User? = DatabaseFactory.dbQuery { - UserTable.selectAll() - .where { UserTable.email eq email } - .map { - User( - id = it[UserTable.id], - email = it[UserTable.email], - passwordHash = it[UserTable.passwordHash], - familyName = it[UserTable.familyName], - childAge = it[UserTable.childAge], - parentPin = it[UserTable.parentPin] - ) - } - .singleOrNull() - } + override suspend fun findByEmail(email: String): User? = + DatabaseFactory.dbQuery { + UserTable.selectAll() + .where { UserTable.email eq email } + .map { + User( + id = it[UserTable.id], + email = it[UserTable.email], + passwordHash = it[UserTable.passwordHash], + familyName = it[UserTable.familyName], + childAge = it[UserTable.childAge], + parentPin = it[UserTable.parentPin], + ) + } + .singleOrNull() + } - override suspend fun verifyPassword(password: String, passwordHash: String): Boolean { + override suspend fun verifyPassword( + password: String, + passwordHash: String, + ): Boolean { return BCrypt.checkpw(password, passwordHash) } } class InMemoryKidioRepository : KidioRepository { - private val tasks = mutableListOf( - Task("t1", "Was ist 12 + 15?", listOf("25", "27", "30", "22"), 10), - Task("t2", "Was ist 8 x 7?", listOf("54", "56", "64", "48"), 15) - ) - private val answers = mutableMapOf( - "t1" to ("27" to 10), - "t2" to ("56" to 15) - ) - private val screentimes = mutableMapOf( - "default_user" to 1800 - ) + private val tasks = + mutableListOf( + Task("t1", "Was ist 12 + 15?", listOf("25", "27", "30", "22"), 10), + Task("t2", "Was ist 8 x 7?", listOf("54", "56", "64", "48"), 15), + ) + private val answers = + mutableMapOf( + "t1" to ("27" to 10), + "t2" to ("56" to 15), + ) + private val screentimes = + mutableMapOf( + "default_user" to 1800, + ) private val users = mutableListOf() override suspend fun getAllTasks(): List = tasks @@ -157,7 +184,10 @@ class InMemoryKidioRepository : KidioRepository { override suspend fun getScreentime(userId: String): Int = screentimes[userId] ?: 1800 - override suspend fun updateScreentime(userId: String, seconds: Int) { + override suspend fun updateScreentime( + userId: String, + seconds: Int, + ) { screentimes[userId] = seconds } @@ -165,7 +195,10 @@ class InMemoryKidioRepository : KidioRepository { return users.find { it.email == email } } - override suspend fun verifyPassword(password: String, passwordHash: String): Boolean { + override suspend fun verifyPassword( + password: String, + passwordHash: String, + ): Boolean { return BCrypt.checkpw(password, passwordHash) } @@ -174,4 +207,4 @@ class InMemoryKidioRepository : KidioRepository { screentimes[user.id] = 1800 return true } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/oliver/kidio/backend/plugins/Routing.kt b/src/main/kotlin/com/oliver/kidio/backend/plugins/Routing.kt index 3fbec59..c82939d 100644 --- a/src/main/kotlin/com/oliver/kidio/backend/plugins/Routing.kt +++ b/src/main/kotlin/com/oliver/kidio/backend/plugins/Routing.kt @@ -13,7 +13,10 @@ import io.ktor.server.routing.* import org.mindrot.jbcrypt.BCrypt import java.util.UUID -fun Application.configureRouting(repository: KidioRepository, jwtService : JwtService) { +fun Application.configureRouting( + repository: KidioRepository, + jwtService: JwtService, +) { routing { post("/api/v1/auth/register") { val registerRequest = call.receive() @@ -24,7 +27,7 @@ fun Application.configureRouting(repository: KidioRepository, jwtService : JwtSe if (registerRequest.parentPin.length != 4) { call.respond(HttpStatusCode.BadRequest, "Die Eltern-PIN muss genau 4 Ziffern lang sein") return@post - } + } val existingUser = repository.findByEmail(registerRequest.email) if (existingUser != null) { call.respond(HttpStatusCode.Conflict, "Ein Benutzer mit dieser E-Mail-Adresse existiert bereits") @@ -35,8 +38,17 @@ fun Application.configureRouting(repository: KidioRepository, jwtService : JwtSe val userId = UUID.randomUUID().toString() val passwordHash = BCrypt.hashpw(registerRequest.passwordPlain, BCrypt.gensalt()) - //datenbank insert hinzufügen - repository.registerUser(User(userId, registerRequest.email, passwordHash, registerRequest.familyName, registerRequest.childAge, registerRequest.parentPin)) + // datenbank insert hinzufügen + repository.registerUser( + User( + userId, + registerRequest.email, + passwordHash, + registerRequest.familyName, + registerRequest.childAge, + registerRequest.parentPin, + ), + ) val token = jwtService.generateToken(userId) @@ -45,8 +57,8 @@ fun Application.configureRouting(repository: KidioRepository, jwtService : JwtSe mapOf( "token" to token, "userId" to userId, - "message" to "Registrierung erfolgreich" - ) + "message" to "Registrierung erfolgreich", + ), ) } // Health-Check @@ -54,33 +66,31 @@ fun Application.configureRouting(repository: KidioRepository, jwtService : JwtSe call.respond(mapOf("status" to "OK", "message" to "Kidio Backend läuft!")) } - post ("/api/v1/auth/login" ) { + post("/api/v1/auth/login") { val loginRequest = call.receive() - if(loginRequest.email.isBlank() || loginRequest.passwordPlain.isBlank()){ + if (loginRequest.email.isBlank() || loginRequest.passwordPlain.isBlank()) { call.respond(HttpStatusCode.BadRequest, "Bitte füllen Sie alle Felder aus") return@post } val getUser = repository.findByEmail(loginRequest.email) - if (getUser == null ||!repository.verifyPassword(loginRequest.passwordPlain, getUser.passwordHash)){ + if (getUser == null || !repository.verifyPassword(loginRequest.passwordPlain, getUser.passwordHash)) { call.respond(HttpStatusCode.Unauthorized, "Ungültige Anmeldedaten") return@post } val token = jwtService.generateToken(getUser.id) - call.respond(HttpStatusCode.OK, AuthResponse( - token, - userId = getUser.id, - familyName = getUser.familyName, - childAge = getUser.childAge - )) + call.respond( + HttpStatusCode.OK, + AuthResponse( + token, + userId = getUser.id, + familyName = getUser.familyName, + childAge = getUser.childAge, + ), + ) return@post - - - - } authenticate("auth-jwt") { - // 1. Alle verfügbaren Aufgaben abrufen get("/api/v1/tasks") { val tasks = repository.getAllTasks() @@ -88,67 +98,67 @@ fun Application.configureRouting(repository: KidioRepository, jwtService : JwtSe } // 2. Antwort auf eine Aufgabe überprüfen - post("/api/v1/tasks/verify") { - val request = call.receive() - val principal = call.principal() - val userId = principal?.userId - if (userId == null){ - call.respond(HttpStatusCode.Unauthorized, mapOf("error" to "Ungültiges oder abgelaufenes Token")) + post("/api/v1/tasks/verify") { + val request = call.receive() + val principal = call.principal() + val userId = principal?.userId + if (userId == null) { + call.respond(HttpStatusCode.Unauthorized, mapOf("error" to "Ungültiges oder abgelaufenes Token")) return@post - } - val taskInfo = repository.getTaskAnswerAndReward(request.taskId) - if (taskInfo == null) { - call.respond( - TaskVerifyResponse( - isCorrect = false, - earnedMinutes = 0, - message = "Aufgabe nicht gefunden!" - ) - ) - return@post - } - - val (correctAnswer, reward) = taskInfo - val isCorrect = request.selectedAnswer.trim() == correctAnswer.trim() - - if (isCorrect) { - val currentScreentime = repository.getScreentime(userId) - val newSeconds = currentScreentime + (reward * 60) - repository.updateScreentime(userId, newSeconds) - - call.respond( - TaskVerifyResponse( - isCorrect = true, - earnedMinutes = reward, - message = "Super gemacht! Du hast $reward Minuten gewonnen." - ) - ) - } else { - call.respond( - TaskVerifyResponse( - isCorrect = false, - earnedMinutes = 0, - message = "Schade, das war leider falsch. Versuche es nochmal!" - ) - ) - } } + val taskInfo = repository.getTaskAnswerAndReward(request.taskId) + if (taskInfo == null) { + call.respond( + TaskVerifyResponse( + isCorrect = false, + earnedMinutes = 0, + message = "Aufgabe nicht gefunden!", + ), + ) + return@post + } + + val (correctAnswer, reward) = taskInfo + val isCorrect = request.selectedAnswer.trim() == correctAnswer.trim() + + if (isCorrect) { + val currentScreentime = repository.getScreentime(userId) + val newSeconds = currentScreentime + (reward * 60) + repository.updateScreentime(userId, newSeconds) + + call.respond( + TaskVerifyResponse( + isCorrect = true, + earnedMinutes = reward, + message = "Super gemacht! Du hast $reward Minuten gewonnen.", + ), + ) + } else { + call.respond( + TaskVerifyResponse( + isCorrect = false, + earnedMinutes = 0, + message = "Schade, das war leider falsch. Versuche es nochmal!", + ), + ) + } + } get("/api/v1/screentime") { val principal = call.principal() - val userid = principal?.userId?: run { - call.respond(HttpStatusCode.Unauthorized, mapOf("error" to "Ungültiger Token")) - return@get - } + val userid = + principal?.userId + ?: run { + call.respond(HttpStatusCode.Unauthorized, mapOf("error" to "Ungültiger Token")) + return@get + } val remainingSeconds = repository.getScreentime(userid) call.respond( HttpStatusCode.OK, SyncResponse( remainingSeconds = remainingSeconds, - isBlocked = remainingSeconds <= 0 - ) + isBlocked = remainingSeconds <= 0, + ), ) - - } // 3. Bildschirmzeit synchronisieren @@ -168,11 +178,10 @@ fun Application.configureRouting(repository: KidioRepository, jwtService : JwtSe call.respond( SyncResponse( remainingSeconds = newSeconds, - isBlocked = newSeconds <= 0 - ) + isBlocked = newSeconds <= 0, + ), ) } } } } - diff --git a/src/main/kotlin/com/oliver/kidio/backend/security/JwtService.kt b/src/main/kotlin/com/oliver/kidio/backend/security/JwtService.kt index e5faf17..6d0cc09 100644 --- a/src/main/kotlin/com/oliver/kidio/backend/security/JwtService.kt +++ b/src/main/kotlin/com/oliver/kidio/backend/security/JwtService.kt @@ -9,14 +9,14 @@ class JwtService( private val secret: String = System.getenv("JWT_SECRET") ?: "super-geheimes-secret-fuer-dev", private val issuer: String = "https://kidio.oliver.com/", private val audience: String = "kidio-users", - private val expirationInMs: Long = java.util.concurrent.TimeUnit.DAYS.toMillis(30) // 30 Tage + private val expirationInMs: Long = java.util.concurrent.TimeUnit.DAYS.toMillis(30), ) { - - val verifier: JWTVerifier = JWT - .require(Algorithm.HMAC256(secret)) - .withAudience(audience) - .withIssuer(issuer) - .build() + val verifier: JWTVerifier = + JWT + .require(Algorithm.HMAC256(secret)) + .withAudience(audience) + .withIssuer(issuer) + .build() /** * Generiert ein JWT-Token für einen bestimmten Benutzer. diff --git a/src/test/kotlin/ServerTest.kt b/src/test/kotlin/ServerTest.kt index fe23153..af98e24 100644 --- a/src/test/kotlin/ServerTest.kt +++ b/src/test/kotlin/ServerTest.kt @@ -4,220 +4,243 @@ import com.oliver.kidio.backend.domain.repository.InMemoryKidioRepository import com.oliver.kidio.backend.plugins.configureRouting import com.oliver.kidio.backend.plugins.configureSecurity import com.oliver.kidio.backend.security.JwtService -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.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.application.install +import io.ktor.server.testing.testApplication +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation -import io.ktor.server.testing.* -import kotlinx.serialization.json.* -import kotlin.test.* class ServerTest { - @Test - fun `test health endpoint`() = testApplication { - val jwtService = JwtService() - val repository = InMemoryKidioRepository() + fun `test health endpoint`() = + testApplication { + val jwtService = JwtService() + val repository = InMemoryKidioRepository() - application { - install(ServerContentNegotiation) { - json() + application { + install(ServerContentNegotiation) { + json() + } + configureSecurity(jwtService) + configureRouting(repository, jwtService) } - configureSecurity(jwtService) - configureRouting(repository, jwtService) - } - val response = client.get("/api/v1/health") - assertEquals(HttpStatusCode.OK, response.status) - assertTrue(response.bodyAsText().contains("Kidio Backend läuft!")) - } + val response = client.get("/api/v1/health") + assertEquals(HttpStatusCode.OK, response.status) + assertTrue(response.bodyAsText().contains("Kidio Backend läuft!")) + } @Test - fun `test get screentime returns current status`() = testApplication { - val jwtService = JwtService() - val repository = InMemoryKidioRepository() + fun `test get screentime returns current status`() = + testApplication { + val jwtService = JwtService() + val repository = InMemoryKidioRepository() - application { - install(ServerContentNegotiation) { json() } - configureSecurity(jwtService) - configureRouting(repository, jwtService) - } - - val token = jwtService.generateToken("default_user") - - // Ohne Token -> 401 Unauthorized - val unauthResponse = client.get("/api/v1/screentime") - assertEquals(HttpStatusCode.Unauthorized, unauthResponse.status) - - // Mit Token -> 200 OK - val response = client.get("/api/v1/screentime") { - header(HttpHeaders.Authorization, "Bearer $token") - } - assertEquals(HttpStatusCode.OK, response.status) - val json = Json.decodeFromString(response.bodyAsText()) - assertEquals(1800, json.remainingSeconds) - assertFalse(json.isBlocked) - } - - @Test - fun `test protected endpoints reject unauthorized requests`() = testApplication { - val jwtService = JwtService() - val repository = InMemoryKidioRepository() - - application { - install(ServerContentNegotiation) { - json() + application { + install(ServerContentNegotiation) { json() } + configureSecurity(jwtService) + configureRouting(repository, jwtService) } - configureSecurity(jwtService) - configureRouting(repository, jwtService) - } - // Ohne Token muss 401 Unauthorized kommen - val tasksResponse = client.get("/api/v1/tasks") - assertEquals(HttpStatusCode.Unauthorized, tasksResponse.status) - } + val token = jwtService.generateToken("default_user") + + // Ohne Token -> 401 Unauthorized + val unauthResponse = client.get("/api/v1/screentime") + assertEquals(HttpStatusCode.Unauthorized, unauthResponse.status) + + // Mit Token -> 200 OK + val response = + client.get("/api/v1/screentime") { + header(HttpHeaders.Authorization, "Bearer $token") + } + assertEquals(HttpStatusCode.OK, response.status) + val json = Json.decodeFromString(response.bodyAsText()) + assertEquals(1800, json.remainingSeconds) + assertFalse(json.isBlocked) + } @Test - fun `test registration and login flow`() = testApplication { - val jwtService = JwtService() - val repository = InMemoryKidioRepository() + fun `test protected endpoints reject unauthorized requests`() = + testApplication { + val jwtService = JwtService() + val repository = InMemoryKidioRepository() - application { - install(ServerContentNegotiation) { - json() + application { + install(ServerContentNegotiation) { + json() + } + configureSecurity(jwtService) + configureRouting(repository, jwtService) } - configureSecurity(jwtService) - configureRouting(repository, jwtService) + + // Ohne Token muss 401 Unauthorized kommen + val tasksResponse = client.get("/api/v1/tasks") + assertEquals(HttpStatusCode.Unauthorized, tasksResponse.status) } - // 1. Registrierung - val registerResponse = client.post("/api/v1/auth/register") { - header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) - setBody( - """{ + @Test + fun `test registration and login flow`() = + testApplication { + val jwtService = JwtService() + val repository = InMemoryKidioRepository() + + application { + install(ServerContentNegotiation) { + json() + } + configureSecurity(jwtService) + configureRouting(repository, jwtService) + } + + // 1. Registrierung + val registerResponse = + client.post("/api/v1/auth/register") { + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody( + """{ "email": "test@family.de", "passwordPlain": "strengGeheim1", "familyName": "Familie Schmidt", "childAge": 8, "parentPin": "4321" - }""" - ) - } - assertEquals(HttpStatusCode.Created, registerResponse.status) - assertTrue(registerResponse.bodyAsText().contains("token")) + }""", + ) + } + assertEquals(HttpStatusCode.Created, registerResponse.status) + assertTrue(registerResponse.bodyAsText().contains("token")) - // 2. Login mit denselben Daten - val loginResponse = client.post("/api/v1/auth/login") { - header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) - setBody( - """{ + // 2. Login mit denselben Daten + val loginResponse = + client.post("/api/v1/auth/login") { + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody( + """{ "email": "test@family.de", "passwordPlain": "strengGeheim1" - }""" - ) + }""", + ) + } + assertEquals(HttpStatusCode.OK, loginResponse.status) + val loginJson = Json.decodeFromString(loginResponse.bodyAsText()) + assertNotNull(loginJson.token) + assertEquals("Familie Schmidt", loginJson.familyName) } - assertEquals(HttpStatusCode.OK, loginResponse.status) - val loginJson = Json.decodeFromString(loginResponse.bodyAsText()) - assertNotNull(loginJson.token) - assertEquals("Familie Schmidt", loginJson.familyName) - } @Test - fun `test tasks endpoint and verify with jwt`() = testApplication { - val jwtService = JwtService() - val repository = InMemoryKidioRepository() + fun `test tasks endpoint and verify with jwt`() = + testApplication { + val jwtService = JwtService() + val repository = InMemoryKidioRepository() - application { - install(ServerContentNegotiation) { - json() + application { + install(ServerContentNegotiation) { + json() + } + configureSecurity(jwtService) + configureRouting(repository, jwtService) } - configureSecurity(jwtService) - configureRouting(repository, jwtService) - } - val token = jwtService.generateToken("default_user") + val token = jwtService.generateToken("default_user") - // 1. Get Tasks mit Token - val tasksResponse = client.get("/api/v1/tasks") { - header(HttpHeaders.Authorization, "Bearer $token") - } - assertEquals(HttpStatusCode.OK, tasksResponse.status) - val tasksJson = Json.decodeFromString>(tasksResponse.bodyAsText()) - assertEquals(2, tasksJson.size) - assertEquals("t1", tasksJson[0].id) - assertEquals("Was ist 12 + 15?", tasksJson[0].question) + // 1. Get Tasks mit Token + val tasksResponse = + client.get("/api/v1/tasks") { + header(HttpHeaders.Authorization, "Bearer $token") + } + assertEquals(HttpStatusCode.OK, tasksResponse.status) + val tasksJson = Json.decodeFromString>(tasksResponse.bodyAsText()) + assertEquals(2, tasksJson.size) + assertEquals("t1", tasksJson[0].id) + assertEquals("Was ist 12 + 15?", tasksJson[0].question) - // 2. Verify Task correct answer - val verifyResponse = client.post("/api/v1/tasks/verify") { - header(HttpHeaders.Authorization, "Bearer $token") - header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) - setBody("""{"taskId":"t1","selectedAnswer":"27"}""") - } - assertEquals(HttpStatusCode.OK, verifyResponse.status) - val verifyJson = Json.decodeFromString(verifyResponse.bodyAsText()) - assertTrue(verifyJson.isCorrect) - assertEquals(10, verifyJson.earnedMinutes) + // 2. Verify Task correct answer + val verifyResponse = + client.post("/api/v1/tasks/verify") { + header(HttpHeaders.Authorization, "Bearer $token") + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody("""{"taskId":"t1","selectedAnswer":"27"}""") + } + assertEquals(HttpStatusCode.OK, verifyResponse.status) + val verifyJson = Json.decodeFromString(verifyResponse.bodyAsText()) + assertTrue(verifyJson.isCorrect) + assertEquals(10, verifyJson.earnedMinutes) - // 3. Verify Task incorrect answer - val verifyWrongResponse = client.post("/api/v1/tasks/verify") { - header(HttpHeaders.Authorization, "Bearer $token") - header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) - setBody("""{"taskId":"t1","selectedAnswer":"99"}""") + // 3. Verify Task incorrect answer + val verifyWrongResponse = + client.post("/api/v1/tasks/verify") { + header(HttpHeaders.Authorization, "Bearer $token") + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody("""{"taskId":"t1","selectedAnswer":"99"}""") + } + assertEquals(HttpStatusCode.OK, verifyWrongResponse.status) + val verifyWrongJson = Json.decodeFromString(verifyWrongResponse.bodyAsText()) + assertFalse(verifyWrongJson.isCorrect) + assertEquals(0, verifyWrongJson.earnedMinutes) } - assertEquals(HttpStatusCode.OK, verifyWrongResponse.status) - val verifyWrongJson = Json.decodeFromString(verifyWrongResponse.bodyAsText()) - assertFalse(verifyWrongJson.isCorrect) - assertEquals(0, verifyWrongJson.earnedMinutes) - } @Test - fun `test screentime sync deducts time repeatedly`() = testApplication { - val jwtService = JwtService() - val repository = InMemoryKidioRepository() + fun `test screentime sync deducts time repeatedly`() = + testApplication { + val jwtService = JwtService() + val repository = InMemoryKidioRepository() - application { - install(ServerContentNegotiation) { - json() + application { + install(ServerContentNegotiation) { + json() + } + configureSecurity(jwtService) + configureRouting(repository, jwtService) } - configureSecurity(jwtService) - configureRouting(repository, jwtService) - } - val token = jwtService.generateToken("default_user") + val token = jwtService.generateToken("default_user") - // 1. Sync: 200 Sekunden abziehen (1800 -> 1600) - val syncResponse1 = client.post("/api/v1/screentime/sync") { - header(HttpHeaders.Authorization, "Bearer $token") - header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) - setBody("""{"userId":"default_user","usedSecondsSinceLastSync":200}""") - } - assertEquals(HttpStatusCode.OK, syncResponse1.status) - val syncJson1 = Json.decodeFromString(syncResponse1.bodyAsText()) - assertEquals(1600, syncJson1.remainingSeconds) - assertFalse(syncJson1.isBlocked) + // 1. Sync: 200 Sekunden abziehen (1800 -> 1600) + val syncResponse1 = + client.post("/api/v1/screentime/sync") { + header(HttpHeaders.Authorization, "Bearer $token") + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody("""{"userId":"default_user","usedSecondsSinceLastSync":200}""") + } + assertEquals(HttpStatusCode.OK, syncResponse1.status) + val syncJson1 = Json.decodeFromString(syncResponse1.bodyAsText()) + assertEquals(1600, syncJson1.remainingSeconds) + assertFalse(syncJson1.isBlocked) - // 2. Erneuter Sync: Weitere 300 Sekunden abziehen (1600 -> 1300) - darf nicht auf 1800 zurückspringen! - val syncResponse2 = client.post("/api/v1/screentime/sync") { - header(HttpHeaders.Authorization, "Bearer $token") - header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) - setBody("""{"userId":"default_user","usedSecondsSinceLastSync":300}""") - } - assertEquals(HttpStatusCode.OK, syncResponse2.status) - val syncJson2 = Json.decodeFromString(syncResponse2.bodyAsText()) - assertEquals(1300, syncJson2.remainingSeconds) - assertFalse(syncJson2.isBlocked) + // 2. Erneuter Sync: Weitere 300 Sekunden abziehen (1600 -> 1300) - darf nicht auf 1800 zurückspringen! + val syncResponse2 = + client.post("/api/v1/screentime/sync") { + header(HttpHeaders.Authorization, "Bearer $token") + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody("""{"userId":"default_user","usedSecondsSinceLastSync":300}""") + } + assertEquals(HttpStatusCode.OK, syncResponse2.status) + val syncJson2 = Json.decodeFromString(syncResponse2.bodyAsText()) + assertEquals(1300, syncJson2.remainingSeconds) + assertFalse(syncJson2.isBlocked) - // 3. Sync bis zur Sperre (weitere 1500 Sekunden abziehen -> 0) - val syncResponse3 = client.post("/api/v1/screentime/sync") { - header(HttpHeaders.Authorization, "Bearer $token") - header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) - setBody("""{"userId":"default_user","usedSecondsSinceLastSync":1500}""") + // 3. Sync bis zur Sperre (weitere 1500 Sekunden abziehen -> 0) + val syncResponse3 = + client.post("/api/v1/screentime/sync") { + header(HttpHeaders.Authorization, "Bearer $token") + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody("""{"userId":"default_user","usedSecondsSinceLastSync":1500}""") + } + assertEquals(HttpStatusCode.OK, syncResponse3.status) + val syncJson3 = Json.decodeFromString(syncResponse3.bodyAsText()) + assertEquals(0, syncJson3.remainingSeconds) + assertTrue(syncJson3.isBlocked) } - assertEquals(HttpStatusCode.OK, syncResponse3.status) - val syncJson3 = Json.decodeFromString(syncResponse3.bodyAsText()) - assertEquals(0, syncJson3.remainingSeconds) - assertTrue(syncJson3.isBlocked) - } }