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
|
||||
|
||||
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.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<TaskVerifyRequest>()
|
||||
|
||||
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<SyncRequest>()
|
||||
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<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