style: fix missing assertion imports in ServerTest
Some checks failed
CI Pipeline / lint-and-test (pull_request) Failing after 43s

This commit is contained in:
Oliver Wallisch 2026-09-11 22:38:56 +02:00
parent 809d559562
commit 0d8fdec26a
10 changed files with 450 additions and 373 deletions

4
.editorconfig Normal file
View file

@ -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

View file

@ -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,
)
}

View file

@ -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)
@ -93,6 +94,5 @@ object DatabaseFactory {
}
}
suspend fun <T> dbQuery(block: suspend () -> T): T =
newSuspendedTransaction(Dispatchers.IO) { block() }
suspend fun <T> dbQuery(block: suspend () -> T): T = newSuspendedTransaction(Dispatchers.IO) { block() }
}

View file

@ -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")
}

View file

@ -8,14 +8,14 @@ data class Task(
val id: String,
val question: String,
val options: List<String>,
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
val parentPin: String,
)

View file

@ -12,53 +12,68 @@ import org.mindrot.jbcrypt.BCrypt
interface KidioRepository {
suspend fun getAllTasks(): List<Task>
suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>?
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<Task> = 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<Task> =
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<String, Int>? = 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<String, Int>? =
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<User>()
override suspend fun getAllTasks(): List<Task> = 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)
}

View file

@ -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<RegisterRequest>()
@ -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<LoginRequest>()
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<TaskVerifyRequest>()
val principal = call.principal<UserPrincipal>()
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<TaskVerifyRequest>()
val principal = call.principal<UserPrincipal>()
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<UserPrincipal>()
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,
),
)
}
}
}
}

View file

@ -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.

View file

@ -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<SyncResponse>(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<SyncResponse>(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<AuthResponse>(loginResponse.bodyAsText())
assertNotNull(loginJson.token)
assertEquals("Familie Schmidt", loginJson.familyName)
}
assertEquals(HttpStatusCode.OK, loginResponse.status)
val loginJson = Json.decodeFromString<AuthResponse>(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<List<Task>>(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<List<Task>>(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<TaskVerifyResponse>(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<TaskVerifyResponse>(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<TaskVerifyResponse>(verifyWrongResponse.bodyAsText())
assertFalse(verifyWrongJson.isCorrect)
assertEquals(0, verifyWrongJson.earnedMinutes)
}
assertEquals(HttpStatusCode.OK, verifyWrongResponse.status)
val verifyWrongJson = Json.decodeFromString<TaskVerifyResponse>(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<SyncResponse>(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<SyncResponse>(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<SyncResponse>(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<SyncResponse>(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<SyncResponse>(syncResponse3.bodyAsText())
assertEquals(0, syncJson3.remainingSeconds)
assertTrue(syncJson3.isBlocked)
}
assertEquals(HttpStatusCode.OK, syncResponse3.status)
val syncJson3 = Json.decodeFromString<SyncResponse>(syncResponse3.bodyAsText())
assertEquals(0, syncJson3.remainingSeconds)
assertTrue(syncJson3.isBlocked)
}
}