feat: integrate PostgreSQL database with Exposed and implement Repository pattern
This commit is contained in:
parent
27ff371d4e
commit
1ae05f0c36
|
|
@ -1,2 +1,94 @@
|
||||||
package com.oliver.kidio.backend.data.database
|
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 <T> dbQuery(block: suspend () -> T): T =
|
||||||
|
newSuspendedTransaction(Dispatchers.IO) { block() }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<Task>
|
||||||
|
suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>?
|
||||||
|
suspend fun getScreentime(userId: String): Int
|
||||||
|
suspend fun updateScreentime(userId: String, seconds: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 = 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<Task> = tasks
|
||||||
|
|
||||||
|
override suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>? = answers[taskId]
|
||||||
|
|
||||||
|
override suspend fun getScreentime(userId: String): Int = screentimes[userId] ?: 1800
|
||||||
|
|
||||||
|
override suspend fun updateScreentime(userId: String, seconds: Int) {
|
||||||
|
screentimes[userId] = seconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.application.*
|
||||||
import io.ktor.server.request.*
|
import io.ktor.server.request.*
|
||||||
import io.ktor.server.response.*
|
import io.ktor.server.response.*
|
||||||
import io.ktor.server.routing.*
|
import io.ktor.server.routing.*
|
||||||
|
|
||||||
// Dummy-Speicher für die Test-Daten
|
fun Application.configureRouting(repository: KidioRepository) {
|
||||||
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() {
|
|
||||||
routing {
|
routing {
|
||||||
|
|
||||||
// Health-Check
|
// Health-Check
|
||||||
|
|
@ -32,22 +17,35 @@ fun Application.configureRouting() {
|
||||||
|
|
||||||
// 1. Alle verfügbaren Aufgaben abrufen
|
// 1. Alle verfügbaren Aufgaben abrufen
|
||||||
get("/api/v1/tasks") {
|
get("/api/v1/tasks") {
|
||||||
call.respond(mockTasks)
|
val tasks = repository.getAllTasks()
|
||||||
|
call.respond(tasks)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Antwort auf eine Aufgabe überprüfen
|
// 2. Antwort auf eine Aufgabe überprüfen
|
||||||
post("/api/v1/tasks/verify") {
|
post("/api/v1/tasks/verify") {
|
||||||
val request = call.receive<TaskVerifyRequest>()
|
val request = call.receive<TaskVerifyRequest>()
|
||||||
|
|
||||||
val isCorrect = when (request.taskId) {
|
val taskInfo = repository.getTaskAnswerAndReward(request.taskId)
|
||||||
"t1" -> request.selectedAnswer == "27"
|
|
||||||
"t2" -> request.selectedAnswer == "56"
|
if (taskInfo == null) {
|
||||||
else -> false
|
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) {
|
if (isCorrect) {
|
||||||
val reward = 10
|
val currentScreentime = repository.getScreentime("default_user")
|
||||||
mockRemainingSeconds += (reward * 60)
|
val newSeconds = currentScreentime + (reward * 60)
|
||||||
|
repository.updateScreentime("default_user", newSeconds)
|
||||||
|
|
||||||
call.respond(
|
call.respond(
|
||||||
TaskVerifyResponse(
|
TaskVerifyResponse(
|
||||||
isCorrect = true,
|
isCorrect = true,
|
||||||
|
|
@ -70,13 +68,16 @@ fun Application.configureRouting() {
|
||||||
post("/api/v1/screentime/sync") {
|
post("/api/v1/screentime/sync") {
|
||||||
val request = call.receive<SyncRequest>()
|
val request = call.receive<SyncRequest>()
|
||||||
|
|
||||||
mockRemainingSeconds -= request.usedSecondsSinceLastSync
|
val currentSeconds = repository.getScreentime(request.userId)
|
||||||
if (mockRemainingSeconds < 0) mockRemainingSeconds = 0
|
var newSeconds = currentSeconds - request.usedSecondsSinceLastSync
|
||||||
|
if (newSeconds < 0) newSeconds = 0
|
||||||
|
|
||||||
|
repository.updateScreentime(request.userId, newSeconds)
|
||||||
|
|
||||||
call.respond(
|
call.respond(
|
||||||
SyncResponse(
|
SyncResponse(
|
||||||
remainingSeconds = mockRemainingSeconds,
|
remainingSeconds = newSeconds,
|
||||||
isBlocked = mockRemainingSeconds <= 0
|
isBlocked = newSeconds <= 0
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.serialization.kotlinx.json.*
|
||||||
import io.ktor.server.application.*
|
import io.ktor.server.application.*
|
||||||
import io.ktor.server.plugins.contentnegotiation.*
|
import io.ktor.server.plugins.contentnegotiation.*
|
||||||
|
|
||||||
fun Application.module() {
|
fun Application.module() {
|
||||||
|
// Datenbank initialisieren
|
||||||
|
DatabaseFactory.init(environment.config)
|
||||||
|
|
||||||
// JSON aktivieren
|
// JSON aktivieren
|
||||||
install(ContentNegotiation) {
|
install(ContentNegotiation) {
|
||||||
json()
|
json()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ruft die Funktion aus Routing.kt auf
|
// Ruft die Funktion aus Routing.kt auf mit dem echten ExposedRepository
|
||||||
configureRouting()
|
configureRouting(ExposedKidioRepository())
|
||||||
}
|
}
|
||||||
|
|
@ -4,3 +4,11 @@ ktor:
|
||||||
application:
|
application:
|
||||||
modules:
|
modules:
|
||||||
- MainKt.module
|
- MainKt.module
|
||||||
|
|
||||||
|
storage:
|
||||||
|
driverClassName: "org.postgresql.Driver"
|
||||||
|
host: "82.165.11.162"
|
||||||
|
port: "5432"
|
||||||
|
database: "kidio_db"
|
||||||
|
username: "postgres"
|
||||||
|
password: "Julian__98"
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,98 @@
|
||||||
package com.oliver.kidio.backend
|
package com.oliver.kidio.backend
|
||||||
|
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.*
|
||||||
import io.ktor.http.HttpStatusCode
|
import io.ktor.client.statement.*
|
||||||
import io.ktor.server.testing.testApplication
|
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.*
|
import kotlin.test.*
|
||||||
|
|
||||||
class ServerTest {
|
class ServerTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `test root endpoint`() = testApplication {
|
fun `test health endpoint`() = testApplication {
|
||||||
// loads default configuration
|
application {
|
||||||
configure()
|
this.install(ServerContentNegotiation) {
|
||||||
// verify server root returns 200
|
json()
|
||||||
assertEquals(HttpStatusCode.OK, client.get("/").status)
|
}
|
||||||
|
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<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.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.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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<SyncResponse>(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<SyncResponse>(syncResponse2.bodyAsText())
|
||||||
|
assertEquals(0, syncJson2.remainingSeconds)
|
||||||
|
assertTrue(syncJson2.isBlocked)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue