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 35ffe8d..6067566 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 @@ -1,2 +1,94 @@ package com.oliver.kidio.backend.data.database +import io.ktor.server.config.* +import kotlinx.coroutines.Dispatchers +import org.jetbrains.exposed.sql.* +import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction +import org.jetbrains.exposed.sql.transactions.transaction + +object TasksTable : Table("tasks") { + val id = varchar("id", 50) + val question = text("question") + val options = text("options") // Kommagetrennte Liste der Antwortmöglichkeiten + val correctAnswer = varchar("correct_answer", 255) + val rewardMinutes = integer("reward_minutes") + + override val primaryKey = PrimaryKey(id) +} + +object ScreentimeTable : Table("user_screentime") { + val userId = varchar("user_id", 50) + val remainingSeconds = integer("remaining_seconds") + val parentPin = varchar("parent_pin", 10) + + override val primaryKey = PrimaryKey(userId) +} + +object DatabaseFactory { + fun init(config: ApplicationConfig) { + val driverClassName = config.propertyOrNull("storage.driverClassName")?.getString() ?: "org.postgresql.Driver" + + val host = System.getenv("DB_HOST") + ?: config.propertyOrNull("storage.host")?.getString() + ?: "localhost" + + val port = System.getenv("DB_PORT") + ?: config.propertyOrNull("storage.port")?.getString() + ?: "5432" + + val dbName = System.getenv("DB_NAME") + ?: config.propertyOrNull("storage.database")?.getString() + ?: "kidio_db" + + val username = System.getenv("DB_USER") + ?: config.propertyOrNull("storage.username")?.getString() + ?: "postgres" + + val password = System.getenv("DB_PASSWORD") + ?: config.propertyOrNull("storage.password")?.getString() + ?: "postgres" + + val jdbcURL = "jdbc:postgresql://$host:$port/$dbName" + + val database = Database.connect( + url = jdbcURL, + driver = driverClassName, + user = username, + password = password + ) + + transaction(database) { + SchemaUtils.create(TasksTable, ScreentimeTable) + + // Standard aufgaben, sollte es keine mehr geben + if (TasksTable.selectAll().empty()) { + TasksTable.insert { + it[id] = "t1" + it[question] = "Was ist 12 + 15?" + it[options] = "25,27,30,22" + it[correctAnswer] = "27" + it[rewardMinutes] = 10 + } + TasksTable.insert { + it[id] = "t2" + it[question] = "Was ist 8 x 7?" + it[options] = "54,56,64,48" + it[correctAnswer] = "56" + it[rewardMinutes] = 15 + } + } + + // Seed a default user screentime if empty + if (ScreentimeTable.selectAll().empty()) { + ScreentimeTable.insert { + it[userId] = "default_user" + it[remainingSeconds] = 1800 + it[parentPin] = "1234" + } + } + } + } + + suspend fun dbQuery(block: suspend () -> T): T = + newSuspendedTransaction(Dispatchers.IO) { block() } +} 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 new file mode 100644 index 0000000..c5b9fe2 --- /dev/null +++ b/src/main/kotlin/com/oliver/kidio/backend/domain/repository/KidioRepository.kt @@ -0,0 +1,78 @@ +package com.oliver.kidio.backend.domain.repository + +import com.oliver.kidio.backend.Task +import com.oliver.kidio.backend.data.database.DatabaseFactory +import com.oliver.kidio.backend.data.database.ScreentimeTable +import com.oliver.kidio.backend.data.database.TasksTable +import org.jetbrains.exposed.sql.* + +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) +} + +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 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 = DatabaseFactory.dbQuery { + ScreentimeTable.selectAll() + .where { ScreentimeTable.userId eq userId } + .map { it[ScreentimeTable.remainingSeconds] } + .singleOrNull() ?: 1800 + } + + override suspend fun updateScreentime(userId: String, seconds: Int): Unit = DatabaseFactory.dbQuery { + val rowsUpdated = ScreentimeTable.update({ ScreentimeTable.userId eq userId }) { + it[remainingSeconds] = seconds + } + if (rowsUpdated == 0) { + ScreentimeTable.insert { + it[ScreentimeTable.userId] = userId + it[remainingSeconds] = seconds + it[parentPin] = "1234" + } + } + } +} + +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 + ) + + override suspend fun getAllTasks(): List = tasks + + override suspend fun getTaskAnswerAndReward(taskId: String): Pair? = answers[taskId] + + override suspend fun getScreentime(userId: String): Int = screentimes[userId] ?: 1800 + + override suspend fun updateScreentime(userId: String, seconds: Int) { + screentimes[userId] = seconds + } +} 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 ea8bc6e..23da00e 100644 --- a/src/main/kotlin/com/oliver/kidio/backend/plugins/Routing.kt +++ b/src/main/kotlin/com/oliver/kidio/backend/plugins/Routing.kt @@ -1,28 +1,13 @@ -package com.oliver.kidio.backend +package com.oliver.kidio.backend.plugins +import com.oliver.kidio.backend.* +import com.oliver.kidio.backend.domain.repository.KidioRepository import io.ktor.server.application.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* -// Dummy-Speicher für die Test-Daten -private var mockRemainingSeconds = 1800 -private val mockTasks = mutableListOf( - Task( - id = "t1", - question = "Was ist 12 + 15?", - options = listOf("25", "27", "30", "22"), - rewardMinutes = 10 - ), - Task( - id = "t2", - question = "Was ist 8 x 7?", - options = listOf("54", "56", "64", "48"), - rewardMinutes = 15 - ) -) - -fun Application.configureRouting() { +fun Application.configureRouting(repository: KidioRepository) { routing { // Health-Check @@ -32,22 +17,35 @@ fun Application.configureRouting() { // 1. Alle verfügbaren Aufgaben abrufen get("/api/v1/tasks") { - call.respond(mockTasks) + val tasks = repository.getAllTasks() + call.respond(tasks) } // 2. Antwort auf eine Aufgabe überprüfen post("/api/v1/tasks/verify") { val request = call.receive() - val isCorrect = when (request.taskId) { - "t1" -> request.selectedAnswer == "27" - "t2" -> request.selectedAnswer == "56" - else -> false + 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 reward = 10 - mockRemainingSeconds += (reward * 60) + val currentScreentime = repository.getScreentime("default_user") + val newSeconds = currentScreentime + (reward * 60) + repository.updateScreentime("default_user", newSeconds) + call.respond( TaskVerifyResponse( isCorrect = true, @@ -70,15 +68,18 @@ fun Application.configureRouting() { post("/api/v1/screentime/sync") { val request = call.receive() - mockRemainingSeconds -= request.usedSecondsSinceLastSync - if (mockRemainingSeconds < 0) mockRemainingSeconds = 0 + val currentSeconds = repository.getScreentime(request.userId) + var newSeconds = currentSeconds - request.usedSecondsSinceLastSync + if (newSeconds < 0) newSeconds = 0 + + repository.updateScreentime(request.userId, newSeconds) call.respond( SyncResponse( - remainingSeconds = mockRemainingSeconds, - isBlocked = mockRemainingSeconds <= 0 + remainingSeconds = newSeconds, + isBlocked = newSeconds <= 0 ) ) } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/main.kt b/src/main/kotlin/main.kt index 4b050e2..5246a3c 100644 --- a/src/main/kotlin/main.kt +++ b/src/main/kotlin/main.kt @@ -1,14 +1,19 @@ -import com.oliver.kidio.backend.configureRouting +import com.oliver.kidio.backend.plugins.configureRouting +import com.oliver.kidio.backend.data.database.DatabaseFactory +import com.oliver.kidio.backend.domain.repository.ExposedKidioRepository import io.ktor.serialization.kotlinx.json.* import io.ktor.server.application.* import io.ktor.server.plugins.contentnegotiation.* fun Application.module() { + // Datenbank initialisieren + DatabaseFactory.init(environment.config) + // JSON aktivieren install(ContentNegotiation) { json() } - // Ruft die Funktion aus Routing.kt auf - configureRouting() + // Ruft die Funktion aus Routing.kt auf mit dem echten ExposedRepository + configureRouting(ExposedKidioRepository()) } \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 271c560..25c97a3 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -4,3 +4,11 @@ ktor: application: modules: - MainKt.module + +storage: + driverClassName: "org.postgresql.Driver" + host: "82.165.11.162" + port: "5432" + database: "kidio_db" + username: "postgres" + password: "Julian__98" diff --git a/src/test/kotlin/ServerTest.kt b/src/test/kotlin/ServerTest.kt index 3f57720..4771973 100644 --- a/src/test/kotlin/ServerTest.kt +++ b/src/test/kotlin/ServerTest.kt @@ -1,18 +1,98 @@ package com.oliver.kidio.backend -import io.ktor.client.request.get -import io.ktor.http.HttpStatusCode -import io.ktor.server.testing.testApplication +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.testing.* +import com.oliver.kidio.backend.plugins.configureRouting +import com.oliver.kidio.backend.domain.repository.InMemoryKidioRepository +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation +import io.ktor.serialization.kotlinx.json.* +import kotlinx.serialization.json.* import kotlin.test.* class ServerTest { @Test - fun `test root endpoint`() = testApplication { - // loads default configuration - configure() - // verify server root returns 200 - assertEquals(HttpStatusCode.OK, client.get("/").status) + fun `test health endpoint`() = testApplication { + application { + this.install(ServerContentNegotiation) { + json() + } + configureRouting(InMemoryKidioRepository()) + } + + val response = client.get("/api/v1/health") + assertEquals(HttpStatusCode.OK, response.status) + assertTrue(response.bodyAsText().contains("Kidio Backend läuft!")) } + @Test + fun `test tasks endpoint and verify`() = testApplication { + application { + this.install(ServerContentNegotiation) { + json() + } + configureRouting(InMemoryKidioRepository()) + } + + // 1. Get Tasks + val tasksResponse = client.get("/api/v1/tasks") + 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.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.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) + } + + @Test + fun `test screentime sync`() = testApplication { + application { + this.install(ServerContentNegotiation) { + json() + } + configureRouting(InMemoryKidioRepository()) + } + + // Sync first time (user default_user has 1800s seeded) + val syncResponse1 = client.post("/api/v1/screentime/sync") { + 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) + + // Sync and block + val syncResponse2 = client.post("/api/v1/screentime/sync") { + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody("""{"userId":"default_user","usedSecondsSinceLastSync":2000}""") + } + assertEquals(HttpStatusCode.OK, syncResponse2.status) + val syncJson2 = Json.decodeFromString(syncResponse2.bodyAsText()) + assertEquals(0, syncJson2.remainingSeconds) + assertTrue(syncJson2.isBlocked) + } }