feat/adding-h5p-tasks #10
13
README.md
13
README.md
|
|
@ -517,3 +517,16 @@ MIT License
|
|||
```
|
||||
|
||||
Eine entsprechende `LICENSE`-Datei sollte sich im Root-Verzeichnis des Repositorys befinden.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Kidio Ökosystem
|
||||
Für Kinder (Der spielerische Teil - "Kid"):
|
||||
|
||||
* Kidio Learn: Interaktive Lernspiele (Mathe, Sprachen, Logik), die sich dem Alter und Lernfortschritt des Kindes anpassen.
|
||||
|
||||
* Kidio Create: Tools und Plattformen, um eigene Ideen, Kunstwerke oder kleine Projekte zu entwickeln und zu teilen.
|
||||
|
||||
* Kidio Media: Ein sicherer Bereich mit kindgerechten Videos, Hörspielen und Büchern (ähnlich wie YouTube Kids).
|
||||

|
||||
|
|
@ -7,13 +7,24 @@ import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransacti
|
|||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
|
||||
object TasksTable : Table("tasks") {
|
||||
val id = varchar("id", 50)
|
||||
val id = varchar("id", 64)
|
||||
val externalSource = varchar("external_source", 32).default("local")
|
||||
val sourceUrl = text("source_url").nullable()
|
||||
val license = varchar("license", 100).default("CC BY-SA 4.0")
|
||||
val subject = varchar("subject", 64).default("Mathematik")
|
||||
val minAge = integer("min_age").default(6)
|
||||
val maxAge = integer("max_age").default(12)
|
||||
val taskType = varchar("task_type", 32).default("MULTIPLE_CHOICE")
|
||||
val question = text("question")
|
||||
val options = text("options")
|
||||
val correctAnswer = varchar("correct_answer", 255)
|
||||
val rewardMinutes = integer("reward_minutes")
|
||||
val rewardMinutes = integer("reward_minutes").default(5)
|
||||
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
|
||||
init {
|
||||
index("idx_tasks_filter", false, subject, minAge, maxAge)
|
||||
}
|
||||
}
|
||||
|
||||
object ScreentimeTable : Table("user_screentime") {
|
||||
|
|
@ -27,9 +38,9 @@ object UserTable : Table("users") {
|
|||
val id = varchar("id", 36)
|
||||
val email = varchar("email", 255).uniqueIndex()
|
||||
val passwordHash = varchar("password_hash", 255)
|
||||
val familyName = varchar("family_name", 100)
|
||||
val childAge = integer("child_age")
|
||||
val parentPin = varchar("parent_pin", 4)
|
||||
val familyName = varchar("family_name", 100).default("Familie")
|
||||
val childAge = integer("child_age").default(8)
|
||||
val parentPin = varchar("parent_pin", 4).default("1234")
|
||||
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
}
|
||||
|
|
@ -55,11 +66,42 @@ object DatabaseFactory {
|
|||
)
|
||||
|
||||
transaction(database) {
|
||||
SchemaUtils.create(TasksTable, ScreentimeTable, UserTable)
|
||||
// Fill existing NULL values if columns exist before SchemaUtils alters constraints
|
||||
exec(
|
||||
"""
|
||||
DO ${'$'}${'$'}
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name='users') THEN
|
||||
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='family_name') THEN
|
||||
UPDATE users SET family_name = 'Familie' WHERE family_name IS NULL;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='child_age') THEN
|
||||
UPDATE users SET child_age = 8 WHERE child_age IS NULL;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='parent_pin') THEN
|
||||
UPDATE users SET parent_pin = '1234' WHERE parent_pin IS NULL;
|
||||
END IF;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name='tasks') THEN
|
||||
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='tasks' AND column_name='subject') THEN
|
||||
UPDATE tasks SET subject = 'Mathematik' WHERE subject IS NULL;
|
||||
END IF;
|
||||
END IF;
|
||||
END ${'$'}${'$'};
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
SchemaUtils.createMissingTablesAndColumns(TasksTable, ScreentimeTable, UserTable)
|
||||
|
||||
if (TasksTable.selectAll().empty()) {
|
||||
TasksTable.insert {
|
||||
it[id] = "t1"
|
||||
it[externalSource] = "local"
|
||||
it[license] = "CC BY-SA 4.0"
|
||||
it[subject] = "Mathematik"
|
||||
it[minAge] = 6
|
||||
it[maxAge] = 10
|
||||
it[taskType] = "MULTIPLE_CHOICE"
|
||||
it[question] = "Was ist 12 + 15?"
|
||||
it[options] = "25,27,30,22"
|
||||
it[correctAnswer] = "27"
|
||||
|
|
@ -67,6 +109,12 @@ object DatabaseFactory {
|
|||
}
|
||||
TasksTable.insert {
|
||||
it[id] = "t2"
|
||||
it[externalSource] = "local"
|
||||
it[license] = "CC BY-SA 4.0"
|
||||
it[subject] = "Mathematik"
|
||||
it[minAge] = 7
|
||||
it[maxAge] = 12
|
||||
it[taskType] = "MULTIPLE_CHOICE"
|
||||
it[question] = "Was ist 8 x 7?"
|
||||
it[options] = "54,56,64,48"
|
||||
it[correctAnswer] = "56"
|
||||
|
|
|
|||
|
|
@ -6,9 +6,29 @@ import kotlinx.serialization.Serializable
|
|||
@Serializable
|
||||
data class Task(
|
||||
val id: String,
|
||||
val subject: String = "Mathematik",
|
||||
val minAge: Int = 6,
|
||||
val maxAge: Int = 12,
|
||||
val question: String,
|
||||
val options: List<String>,
|
||||
val rewardMinutes: Int,
|
||||
val license: String? = null,
|
||||
)
|
||||
|
||||
// Interne Datenklasse für importierte Aufgaben (Ingestion)
|
||||
data class ImportedTask(
|
||||
val id: String,
|
||||
val externalSource: String = "zum.de",
|
||||
val sourceUrl: String? = null,
|
||||
val license: String = "CC BY-SA 4.0",
|
||||
val subject: String,
|
||||
val minAge: Int,
|
||||
val maxAge: Int,
|
||||
val taskType: String = "MULTIPLE_CHOICE",
|
||||
val question: String,
|
||||
val options: List<String>,
|
||||
val correctAnswer: String,
|
||||
val rewardMinutes: Int = 5,
|
||||
)
|
||||
|
||||
// Anfrage des Kindes, wenn es eine Aufgabe löst
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.oliver.kidio.backend.domain.repository
|
||||
|
||||
import com.oliver.kidio.backend.ImportedTask
|
||||
import com.oliver.kidio.backend.Task
|
||||
import com.oliver.kidio.backend.User
|
||||
import com.oliver.kidio.backend.data.database.DatabaseFactory
|
||||
|
|
@ -8,10 +9,22 @@ import com.oliver.kidio.backend.data.database.ScreentimeTable
|
|||
import com.oliver.kidio.backend.data.database.TasksTable
|
||||
import com.oliver.kidio.backend.data.database.UserTable
|
||||
import org.jetbrains.exposed.sql.*
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.greaterEq
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.lessEq
|
||||
import org.jetbrains.exposed.sql.lowerCase
|
||||
import org.mindrot.jbcrypt.BCrypt
|
||||
|
||||
interface KidioRepository {
|
||||
suspend fun getAllTasks(): List<Task>
|
||||
suspend fun getAllTasks(): List<Task> = getTasks(limit = 100)
|
||||
|
||||
suspend fun getTasks(
|
||||
subject: String? = null,
|
||||
age: Int? = null,
|
||||
limit: Int = 10,
|
||||
): List<Task>
|
||||
|
||||
suspend fun upsertTask(task: ImportedTask): Boolean
|
||||
|
||||
suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>?
|
||||
|
||||
|
|
@ -33,18 +46,76 @@ interface KidioRepository {
|
|||
}
|
||||
|
||||
class ExposedKidioRepository : KidioRepository {
|
||||
override suspend fun getAllTasks(): List<Task> =
|
||||
override suspend fun getTasks(
|
||||
subject: String?,
|
||||
age: Int?,
|
||||
limit: Int,
|
||||
): List<Task> =
|
||||
DatabaseFactory.dbQuery {
|
||||
TasksTable.selectAll().map {
|
||||
var query = TasksTable.selectAll()
|
||||
if (!subject.isNullOrBlank()) {
|
||||
query = query.where { TasksTable.subject.lowerCase() eq subject.trim().lowercase() }
|
||||
}
|
||||
if (age != null) {
|
||||
query = query.andWhere { (TasksTable.minAge lessEq age) and (TasksTable.maxAge greaterEq age) }
|
||||
}
|
||||
val safeLimit = if (limit <= 0) 10 else limit
|
||||
query.limit(safeLimit).map {
|
||||
Task(
|
||||
id = it[TasksTable.id],
|
||||
subject = it[TasksTable.subject],
|
||||
minAge = it[TasksTable.minAge],
|
||||
maxAge = it[TasksTable.maxAge],
|
||||
question = it[TasksTable.question],
|
||||
options = it[TasksTable.options].split(",").map { opt -> opt.trim() },
|
||||
rewardMinutes = it[TasksTable.rewardMinutes],
|
||||
license = it[TasksTable.license],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getAllTasks(): List<Task> = getTasks(limit = 100)
|
||||
|
||||
override suspend fun upsertTask(task: ImportedTask): Boolean =
|
||||
DatabaseFactory.dbQuery {
|
||||
try {
|
||||
val updated =
|
||||
TasksTable.update({ TasksTable.id eq task.id }) {
|
||||
it[externalSource] = task.externalSource
|
||||
it[sourceUrl] = task.sourceUrl
|
||||
it[license] = task.license
|
||||
it[subject] = task.subject
|
||||
it[minAge] = task.minAge
|
||||
it[maxAge] = task.maxAge
|
||||
it[taskType] = task.taskType
|
||||
it[question] = task.question
|
||||
it[options] = task.options.joinToString(",")
|
||||
it[correctAnswer] = task.correctAnswer
|
||||
it[rewardMinutes] = task.rewardMinutes
|
||||
}
|
||||
if (updated == 0) {
|
||||
TasksTable.insert {
|
||||
it[id] = task.id
|
||||
it[externalSource] = task.externalSource
|
||||
it[sourceUrl] = task.sourceUrl
|
||||
it[license] = task.license
|
||||
it[subject] = task.subject
|
||||
it[minAge] = task.minAge
|
||||
it[maxAge] = task.maxAge
|
||||
it[taskType] = task.taskType
|
||||
it[question] = task.question
|
||||
it[options] = task.options.joinToString(",")
|
||||
it[correctAnswer] = task.correctAnswer
|
||||
it[rewardMinutes] = task.rewardMinutes
|
||||
}
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
println("FEHLER beim Upsert von Task ${task.id}: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun registerUser(user: User): Boolean =
|
||||
DatabaseFactory.dbQuery {
|
||||
try {
|
||||
|
|
@ -164,13 +235,28 @@ class ExposedKidioRepository : KidioRepository {
|
|||
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),
|
||||
ImportedTask(
|
||||
id = "t1",
|
||||
subject = "Mathematik",
|
||||
minAge = 6,
|
||||
maxAge = 10,
|
||||
question = "Was ist 12 + 15?",
|
||||
options = listOf("25", "27", "30", "22"),
|
||||
correctAnswer = "27",
|
||||
rewardMinutes = 10,
|
||||
license = "CC BY-SA 4.0",
|
||||
),
|
||||
ImportedTask(
|
||||
id = "t2",
|
||||
subject = "Mathematik",
|
||||
minAge = 7,
|
||||
maxAge = 12,
|
||||
question = "Was ist 8 x 7?",
|
||||
options = listOf("54", "56", "64", "48"),
|
||||
correctAnswer = "56",
|
||||
rewardMinutes = 15,
|
||||
license = "CC BY-SA 4.0",
|
||||
),
|
||||
)
|
||||
private val screentimes =
|
||||
mutableMapOf(
|
||||
|
|
@ -178,9 +264,42 @@ class InMemoryKidioRepository : KidioRepository {
|
|||
)
|
||||
private val users = mutableListOf<User>()
|
||||
|
||||
override suspend fun getAllTasks(): List<Task> = tasks
|
||||
override suspend fun getAllTasks(): List<Task> = getTasks(limit = 100)
|
||||
|
||||
override suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>? = answers[taskId]
|
||||
override suspend fun getTasks(
|
||||
subject: String?,
|
||||
|
DrunkenPinguin marked this conversation as resolved
|
||||
age: Int?,
|
||||
limit: Int,
|
||||
): List<Task> {
|
||||
val safeLimit = if (limit <= 0) 10 else limit
|
||||
return tasks.filter { task ->
|
||||
val matchSubject = subject.isNullOrBlank() || task.subject.equals(subject.trim(), ignoreCase = true)
|
||||
val matchAge = age == null || (task.minAge <= age && task.maxAge >= age)
|
||||
matchSubject && matchAge
|
||||
}.take(safeLimit).map {
|
||||
Task(
|
||||
id = it.id,
|
||||
subject = it.subject,
|
||||
minAge = it.minAge,
|
||||
maxAge = it.maxAge,
|
||||
question = it.question,
|
||||
options = it.options,
|
||||
rewardMinutes = it.rewardMinutes,
|
||||
license = it.license,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun upsertTask(task: ImportedTask): Boolean {
|
||||
tasks.removeIf { it.id == task.id }
|
||||
tasks.add(task)
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>? {
|
||||
val task = tasks.find { it.id == taskId } ?: return null
|
||||
return task.correctAnswer to task.rewardMinutes
|
||||
}
|
||||
|
||||
override suspend fun getScreentime(userId: String): Int = screentimes[userId] ?: 1800
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,114 @@
|
|||
package com.oliver.kidio.backend.ingestion
|
||||
|
||||
import com.oliver.kidio.backend.ImportedTask
|
||||
import com.oliver.kidio.backend.data.database.TasksTable
|
||||
import com.oliver.kidio.backend.domain.repository.ExposedKidioRepository
|
||||
import com.oliver.kidio.backend.domain.repository.KidioRepository
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.jetbrains.exposed.sql.Database
|
||||
import org.jetbrains.exposed.sql.SchemaUtils
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import java.io.ByteArrayInputStream
|
||||
|
||||
object H5PIngestionCli {
|
||||
/**
|
||||
* Executes batch ingestion pipeline and upserts tasks into the target repository.
|
||||
*/
|
||||
fun ingestBatch(
|
||||
repository: KidioRepository,
|
||||
client: ZumApiClient = ZumApiClient(),
|
||||
subject: String? = null,
|
||||
limit: Int = 50,
|
||||
): Int =
|
||||
runBlocking {
|
||||
var importedCount = 0
|
||||
val items = client.getCuratedOerItems(subject = subject, limit = limit)
|
||||
|
||||
for (item in items) {
|
||||
var parsed: ParsedH5PTask? = null
|
||||
|
||||
// 1. Try downloading H5P archive if reachable
|
||||
val h5pBytes = client.downloadH5P(item.downloadUrl)
|
||||
if (h5pBytes != null) {
|
||||
parsed = H5PParser.parseH5PZip(ByteArrayInputStream(h5pBytes), item.id, item.downloadUrl)
|
||||
}
|
||||
|
||||
// 2. Fallback to raw content.json if remote download is not available
|
||||
if (parsed == null && item.rawJsonContent != null) {
|
||||
parsed =
|
||||
H5PParser.parseContentJson(
|
||||
contentJson = item.rawJsonContent,
|
||||
taskId = item.id,
|
||||
title = item.title,
|
||||
license = item.license,
|
||||
sourceUrl = item.downloadUrl,
|
||||
)
|
||||
}
|
||||
|
||||
if (parsed != null) {
|
||||
val (minAge, maxAge) = ZumApiClient.mapGradeToAgeRange(item.grade)
|
||||
val task =
|
||||
ImportedTask(
|
||||
id = parsed.id,
|
||||
externalSource = "zum.de",
|
||||
sourceUrl = parsed.sourceUrl ?: item.downloadUrl,
|
||||
license = parsed.license,
|
||||
subject = ZumApiClient.mapSubject(item.subject),
|
||||
minAge = minAge,
|
||||
maxAge = maxAge,
|
||||
taskType = if (parsed.options.size == 2 && parsed.options.contains("Wahr")) "TRUE_FALSE" else "MULTIPLE_CHOICE",
|
||||
question = parsed.question,
|
||||
options = parsed.options,
|
||||
correctAnswer = parsed.correctAnswer,
|
||||
rewardMinutes = 5,
|
||||
)
|
||||
|
||||
val success = repository.upsertTask(task)
|
||||
if (success) {
|
||||
importedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println("Ingestion completed: $importedCount tasks successfully imported/updated.")
|
||||
importedCount
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) {
|
||||
println("=== Kidio H5P/ZUM Ingestion Tool ===")
|
||||
val host = System.getenv("DB_HOST") ?: "localhost"
|
||||
val port = System.getenv("DB_PORT") ?: "5432"
|
||||
val dbName = System.getenv("DB_NAME") ?: "kidio_db"
|
||||
val user = System.getenv("DB_USER") ?: "kidio_admin"
|
||||
val password = System.getenv("DB_PASSWORD") ?: "learning"
|
||||
|
||||
var subject: String? = null
|
||||
var limit = 50
|
||||
|
||||
for (arg in args) {
|
||||
when {
|
||||
arg.startsWith("--subject=") -> subject = arg.substringAfter("--subject=")
|
||||
arg.startsWith("--limit=") -> limit = arg.substringAfter("--limit=").toIntOrNull() ?: 50
|
||||
}
|
||||
}
|
||||
|
||||
val jdbcUrl = "jdbc:postgresql://$host:$port/$dbName"
|
||||
println("Connecting to database: $jdbcUrl")
|
||||
|
||||
val database =
|
||||
Database.connect(
|
||||
url = jdbcUrl,
|
||||
driver = "org.postgresql.Driver",
|
||||
user = user,
|
||||
password = password,
|
||||
)
|
||||
|
||||
transaction(database) {
|
||||
SchemaUtils.createMissingTablesAndColumns(TasksTable)
|
||||
}
|
||||
|
||||
val repository = ExposedKidioRepository()
|
||||
ingestBatch(repository = repository, subject = subject, limit = limit)
|
||||
}
|
||||
}
|
||||
210
src/main/kotlin/com/oliver/kidio/backend/ingestion/H5PParser.kt
Normal file
210
src/main/kotlin/com/oliver/kidio/backend/ingestion/H5PParser.kt
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
package com.oliver.kidio.backend.ingestion
|
||||
|
||||
import kotlinx.serialization.json.*
|
||||
import java.io.InputStream
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
data class ParsedH5PTask(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val library: String,
|
||||
val question: String,
|
||||
val options: List<String>,
|
||||
val correctAnswer: String,
|
||||
val license: String = "CC BY-SA 4.0",
|
||||
val sourceUrl: String? = null,
|
||||
)
|
||||
|
||||
object H5PParser {
|
||||
private val json =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips HTML tags and decodes common HTML entities to provide clean text.
|
||||
*/
|
||||
fun stripHtml(html: String): String {
|
||||
var text = html.replace(Regex("(?i)<(br|p|div|li|tr|h[1-6])[^>]*>"), " ")
|
||||
text = text.replace(Regex("<[^>]*>"), "")
|
||||
text =
|
||||
text
|
||||
.replace(" ", " ")
|
||||
.replace("&", "&")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("×", "×")
|
||||
.replace("„", "„")
|
||||
.replace("“", "“")
|
||||
.replace("ä", "ä")
|
||||
.replace("Ä", "Ä")
|
||||
.replace("ö", "ö")
|
||||
.replace("Ö", "Ö")
|
||||
.replace("ü", "ü")
|
||||
.replace("Ü", "Ü")
|
||||
.replace("ß", "ß")
|
||||
text = text.replace(Regex("\\s+([?.!,:;])"), "$1")
|
||||
return text.replace(Regex("\\s+"), " ").trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an .h5p ZIP archive stream.
|
||||
*/
|
||||
fun parseH5PZip(
|
||||
inputStream: InputStream,
|
||||
taskId: String,
|
||||
sourceUrl: String? = null,
|
||||
): ParsedH5PTask? {
|
||||
val zip = ZipInputStream(inputStream)
|
||||
var h5pJsonString: String? = null
|
||||
var contentJsonString: String? = null
|
||||
|
||||
var entry = zip.nextEntry
|
||||
while (entry != null) {
|
||||
val name = entry.name
|
||||
if (name == "h5p.json" || name.endsWith("/h5p.json")) {
|
||||
h5pJsonString = zip.readBytes().toString(Charsets.UTF_8)
|
||||
} else if (name == "content/content.json" || name == "content.json" || name.endsWith("/content.json")) {
|
||||
contentJsonString = zip.readBytes().toString(Charsets.UTF_8)
|
||||
}
|
||||
zip.closeEntry()
|
||||
entry = zip.nextEntry
|
||||
}
|
||||
|
||||
if (contentJsonString == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
var library = "H5P.MultiChoice"
|
||||
var title = "H5P Task"
|
||||
var license = "CC BY-SA 4.0"
|
||||
|
||||
if (h5pJsonString != null) {
|
||||
try {
|
||||
val h5pTree = json.parseToJsonElement(h5pJsonString).jsonObject
|
||||
library = h5pTree["mainLibrary"]?.jsonPrimitive?.contentOrNull ?: library
|
||||
title = h5pTree["title"]?.jsonPrimitive?.contentOrNull ?: title
|
||||
license = h5pTree["license"]?.jsonPrimitive?.contentOrNull ?: license
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
return parseContentJson(
|
||||
contentJson = contentJsonString,
|
||||
library = library,
|
||||
taskId = taskId,
|
||||
title = title,
|
||||
license = license,
|
||||
sourceUrl = sourceUrl,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses content.json representation for supported H5P libraries (MultiChoice, TrueFalse).
|
||||
*/
|
||||
fun parseContentJson(
|
||||
contentJson: String,
|
||||
library: String = "H5P.MultiChoice",
|
||||
taskId: String = "task-1",
|
||||
title: String = "H5P Task",
|
||||
license: String = "CC BY-SA 4.0",
|
||||
sourceUrl: String? = null,
|
||||
): ParsedH5PTask? {
|
||||
val element =
|
||||
try {
|
||||
json.parseToJsonElement(contentJson).jsonObject
|
||||
} catch (e: Exception) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (library.contains("TrueFalse", ignoreCase = true) || (element.containsKey("correct") && !element.containsKey("answers"))) {
|
||||
return parseTrueFalse(element, taskId, title, library, license, sourceUrl)
|
||||
}
|
||||
|
||||
return parseMultiChoice(element, taskId, title, library, license, sourceUrl)
|
||||
}
|
||||
|
||||
private fun parseMultiChoice(
|
||||
element: JsonObject,
|
||||
taskId: String,
|
||||
title: String,
|
||||
library: String,
|
||||
license: String,
|
||||
sourceUrl: String?,
|
||||
): ParsedH5PTask? {
|
||||
val questionRaw = element["question"]?.jsonPrimitive?.contentOrNull ?: return null
|
||||
val questionClean = stripHtml(questionRaw)
|
||||
if (questionClean.isBlank()) return null
|
||||
|
||||
val answersArray = element["answers"]?.jsonArray ?: return null
|
||||
val options = mutableListOf<String>()
|
||||
var correctAnswer: String? = null
|
||||
|
||||
for (item in answersArray) {
|
||||
val itemObj = item.jsonObject
|
||||
val textRaw = itemObj["text"]?.jsonPrimitive?.contentOrNull ?: continue
|
||||
val textClean = stripHtml(textRaw)
|
||||
if (textClean.isNotBlank()) {
|
||||
options.add(textClean)
|
||||
val isCorrect = itemObj["correct"]?.jsonPrimitive?.booleanOrNull ?: false
|
||||
if (isCorrect && correctAnswer == null) {
|
||||
correctAnswer = textClean
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.isEmpty() || correctAnswer == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return ParsedH5PTask(
|
||||
id = taskId,
|
||||
title = title,
|
||||
library = library,
|
||||
question = questionClean,
|
||||
options = options,
|
||||
correctAnswer = correctAnswer,
|
||||
license = license,
|
||||
sourceUrl = sourceUrl,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseTrueFalse(
|
||||
element: JsonObject,
|
||||
taskId: String,
|
||||
title: String,
|
||||
library: String,
|
||||
license: String,
|
||||
sourceUrl: String?,
|
||||
): ParsedH5PTask? {
|
||||
val questionRaw = element["question"]?.jsonPrimitive?.contentOrNull ?: return null
|
||||
val questionClean = stripHtml(questionRaw)
|
||||
if (questionClean.isBlank()) return null
|
||||
|
||||
val correctPrimitive = element["correct"]?.jsonPrimitive
|
||||
val isTrueCorrect =
|
||||
when {
|
||||
correctPrimitive?.booleanOrNull != null -> correctPrimitive.boolean
|
||||
correctPrimitive?.contentOrNull != null -> correctPrimitive.content.equals("true", ignoreCase = true)
|
||||
else -> true
|
||||
}
|
||||
|
||||
val options = listOf("Wahr", "Falsch")
|
||||
val correctAnswer = if (isTrueCorrect) "Wahr" else "Falsch"
|
||||
|
||||
return ParsedH5PTask(
|
||||
id = taskId,
|
||||
title = title,
|
||||
library = library,
|
||||
question = questionClean,
|
||||
options = options,
|
||||
correctAnswer = correctAnswer,
|
||||
license = license,
|
||||
sourceUrl = sourceUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
package com.oliver.kidio.backend.ingestion
|
||||
|
||||
import java.net.URI
|
||||
import java.net.http.HttpClient
|
||||
import java.net.http.HttpRequest
|
||||
import java.net.http.HttpResponse
|
||||
import java.time.Duration
|
||||
|
||||
data class ZumOerItem(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val subject: String,
|
||||
val grade: Int? = null,
|
||||
val downloadUrl: String,
|
||||
val license: String = "CC BY-SA 4.0",
|
||||
val rawJsonContent: String? = null,
|
||||
)
|
||||
|
||||
class ZumApiClient(
|
||||
private val httpClient: HttpClient =
|
||||
HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.build(),
|
||||
) {
|
||||
companion object {
|
||||
fun mapSubject(raw: String): String {
|
||||
val lower = raw.trim().lowercase()
|
||||
return when {
|
||||
lower.contains("mathe") -> "Mathematik"
|
||||
lower.contains("deutsch") || lower.contains("german") -> "Deutsch"
|
||||
lower.contains("sach") || lower.contains("hsu") || lower.contains("natur") -> "Sachunterricht"
|
||||
lower.contains("englisch") || lower.contains("english") -> "Englisch"
|
||||
lower.contains("bio") -> "Biologie"
|
||||
lower.contains("geo") || lower.contains("erdkunde") -> "Geographie"
|
||||
lower.contains("gesch") -> "Geschichte"
|
||||
else -> raw.trim().replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
|
||||
}
|
||||
}
|
||||
|
||||
fun mapGradeToAgeRange(grade: Int?): Pair<Int, Int> {
|
||||
return when (grade) {
|
||||
1 -> 6 to 7
|
||||
2 -> 7 to 8
|
||||
3 -> 8 to 9
|
||||
4 -> 9 to 10
|
||||
5 -> 10 to 11
|
||||
6 -> 11 to 12
|
||||
7 -> 12 to 13
|
||||
8 -> 13 to 14
|
||||
else -> 6 to 12
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads an .h5p package file from a remote URL.
|
||||
*/
|
||||
fun downloadH5P(downloadUrl: String): ByteArray? {
|
||||
return try {
|
||||
val request =
|
||||
HttpRequest.newBuilder()
|
||||
.uri(URI.create(downloadUrl))
|
||||
.timeout(Duration.ofSeconds(15))
|
||||
.header("User-Agent", "Kidio-OER-Ingest/1.0 (Educational Non-Profit)")
|
||||
.GET()
|
||||
.build()
|
||||
|
||||
val response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray())
|
||||
if (response.statusCode() == 200) {
|
||||
response.body()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("WARNUNG: Download fehlgeschlagen für $downloadUrl: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns curated ZUM/OER sample metadata items, filterable by subject.
|
||||
*/
|
||||
fun getCuratedOerItems(
|
||||
subject: String? = null,
|
||||
limit: Int = 20,
|
||||
): List<ZumOerItem> {
|
||||
val allItems =
|
||||
listOf(
|
||||
ZumOerItem(
|
||||
id = "zum-math-001",
|
||||
title = "Grundrechenarten: Multiplikation 3. Klasse",
|
||||
subject = "Mathematik",
|
||||
grade = 3,
|
||||
downloadUrl = "https://apps.zum.de/api/h5p/math-001.h5p",
|
||||
license = "CC BY-SA 4.0",
|
||||
rawJsonContent =
|
||||
"""
|
||||
{
|
||||
"question": "<p>Was ist das Ergebnis von <strong>6 × 8</strong>?</p>",
|
||||
"answers": [
|
||||
{"text": "42", "correct": false},
|
||||
{"text": "48", "correct": true},
|
||||
{"text": "54", "correct": false},
|
||||
{"text": "56", "correct": false}
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
),
|
||||
ZumOerItem(
|
||||
id = "zum-math-002",
|
||||
title = "Zahlenraum bis 100: Addition",
|
||||
subject = "Mathematik",
|
||||
grade = 2,
|
||||
downloadUrl = "https://apps.zum.de/api/h5p/math-002.h5p",
|
||||
license = "CC BY-SA 4.0",
|
||||
rawJsonContent =
|
||||
"""
|
||||
{
|
||||
"question": "<p>Berechne: 34 + 28</p>",
|
||||
"answers": [
|
||||
{"text": "52", "correct": false},
|
||||
{"text": "62", "correct": true},
|
||||
{"text": "64", "correct": false}
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
),
|
||||
ZumOerItem(
|
||||
id = "zum-math-003",
|
||||
title = "Geometrie: Formen erkennen",
|
||||
subject = "Mathematik",
|
||||
grade = 4,
|
||||
downloadUrl = "https://apps.zum.de/api/h5p/math-003.h5p",
|
||||
license = "CC BY-SA 4.0",
|
||||
rawJsonContent =
|
||||
"""
|
||||
{
|
||||
"question": "<p>Ein Quadrat hat vier gleich lange Seiten und vier rechte Winkel.</p>",
|
||||
"correct": true
|
||||
}
|
||||
""".trimIndent(),
|
||||
),
|
||||
ZumOerItem(
|
||||
id = "zum-de-001",
|
||||
title = "Wortarten: Nomen erkennen",
|
||||
subject = "Deutsch",
|
||||
grade = 2,
|
||||
downloadUrl = "https://apps.zum.de/api/h5p/de-001.h5p",
|
||||
license = "CC BY-SA 4.0",
|
||||
rawJsonContent =
|
||||
"""
|
||||
{
|
||||
"question": "<p>Welches der folgenden Wörter ist ein <strong>Nomen (Namenwort)</strong>?</p>",
|
||||
"answers": [
|
||||
{"text": "schnell", "correct": false},
|
||||
{"text": "laufen", "correct": false},
|
||||
{"text": "Schule", "correct": true},
|
||||
{"text": "schön", "correct": false}
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
),
|
||||
ZumOerItem(
|
||||
id = "zum-de-002",
|
||||
title = "Rechtschreibung: Doppel-s oder ss",
|
||||
subject = "Deutsch",
|
||||
grade = 4,
|
||||
downloadUrl = "https://apps.zum.de/api/h5p/de-002.h5p",
|
||||
license = "CC BY-SA 4.0",
|
||||
rawJsonContent =
|
||||
"""
|
||||
{
|
||||
"question": "<p>Wie wird das Wort richtig geschrieben? „Der Flu... ist tief.“</p>",
|
||||
"answers": [
|
||||
{"text": "Flus", "correct": false},
|
||||
{"text": "Fluss", "correct": true},
|
||||
{"text": "Fluß", "correct": false}
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
),
|
||||
ZumOerItem(
|
||||
id = "zum-sach-001",
|
||||
title = "Natur & Tiere: Winterschlaf",
|
||||
subject = "Sachunterricht",
|
||||
grade = 3,
|
||||
downloadUrl = "https://apps.zum.de/api/h5p/sach-001.h5p",
|
||||
license = "CC BY-SA 4.0",
|
||||
rawJsonContent =
|
||||
"""
|
||||
{
|
||||
"question": "<p>Welches dieser Tiere hält einen echten <strong>Winterschlaf</strong>?</p>",
|
||||
"answers": [
|
||||
{"text": "Igel", "correct": true},
|
||||
{"text": "Eichhörnchen", "correct": false},
|
||||
{"text": "Amsel", "correct": false},
|
||||
{"text": "Fuchs", "correct": false}
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
),
|
||||
ZumOerItem(
|
||||
id = "zum-sach-002",
|
||||
title = "Planetensystem: Sonnennähe",
|
||||
subject = "Sachunterricht",
|
||||
grade = 4,
|
||||
downloadUrl = "https://apps.zum.de/api/h5p/sach-002.h5p",
|
||||
license = "CC BY-SA 4.0",
|
||||
rawJsonContent =
|
||||
"""
|
||||
{
|
||||
"question": "<p>Welcher Planet ist der Sonne am nächsten?</p>",
|
||||
"answers": [
|
||||
{"text": "Venus", "correct": false},
|
||||
{"text": "Mars", "correct": false},
|
||||
{"text": "Merkur", "correct": true},
|
||||
{"text": "Jupiter", "correct": false}
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
),
|
||||
ZumOerItem(
|
||||
id = "zum-eng-001",
|
||||
title = "English Vocabulary: Colors & Animals",
|
||||
subject = "Englisch",
|
||||
grade = 4,
|
||||
downloadUrl = "https://apps.zum.de/api/h5p/eng-001.h5p",
|
||||
license = "CC BY-SA 4.0",
|
||||
rawJsonContent =
|
||||
"""
|
||||
{
|
||||
"question": "<p>What is the English word for „Hund“?</p>",
|
||||
"answers": [
|
||||
{"text": "Cat", "correct": false},
|
||||
{"text": "Dog", "correct": true},
|
||||
{"text": "Bird", "correct": false},
|
||||
{"text": "Rabbit", "correct": false}
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
),
|
||||
)
|
||||
|
||||
val normalizedSubject = subject?.let { mapSubject(it) }
|
||||
val filtered =
|
||||
if (normalizedSubject.isNullOrBlank()) {
|
||||
allItems
|
||||
} else {
|
||||
allItems.filter { it.subject.equals(normalizedSubject, ignoreCase = true) }
|
||||
}
|
||||
|
||||
return filtered.take(limit)
|
||||
}
|
||||
}
|
||||
|
|
@ -91,9 +91,13 @@ fun Application.configureRouting(
|
|||
}
|
||||
|
||||
authenticate("auth-jwt") {
|
||||
// 1. Alle verfügbaren Aufgaben abrufen
|
||||
// 1. Alle verfügbaren Aufgaben abrufen (mit optionaler Filterung nach Fach und Alter)
|
||||
get("/api/v1/tasks") {
|
||||
val tasks = repository.getAllTasks()
|
||||
val subject = call.request.queryParameters["subject"]
|
||||
val age = call.request.queryParameters["age"]?.toIntOrNull()
|
||||
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 10
|
||||
|
||||
val tasks = repository.getTasks(subject = subject, age = age, limit = limit)
|
||||
call.respond(tasks)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ ktor:
|
|||
|
||||
storage:
|
||||
driverClassName: "org.postgresql.Driver"
|
||||
host: "82.165.11.162"
|
||||
host: "localhost"
|
||||
port: "5432"
|
||||
database: "kidio_db"
|
||||
username: "kidio_admin"
|
||||
|
|
|
|||
137
src/test/kotlin/H5PParserTest.kt
Normal file
137
src/test/kotlin/H5PParserTest.kt
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
package com.oliver.kidio.backend
|
||||
|
||||
import com.oliver.kidio.backend.domain.repository.InMemoryKidioRepository
|
||||
import com.oliver.kidio.backend.ingestion.H5PIngestionCli
|
||||
import com.oliver.kidio.backend.ingestion.H5PParser
|
||||
import com.oliver.kidio.backend.ingestion.ZumApiClient
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class H5PParserTest {
|
||||
@Test
|
||||
fun `test HTML stripping and entity decoding`() {
|
||||
val html = "<p>Was ist <strong>12 × 4</strong>? „Test“ äöü</p>"
|
||||
val clean = H5PParser.stripHtml(html)
|
||||
assertTrue(clean.contains("Was ist 12"))
|
||||
assertTrue(clean.contains("äöü"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test parse MultiChoice content json`() {
|
||||
val json =
|
||||
"""
|
||||
{
|
||||
"question": "<p>Was ist die Hauptstadt von <strong>Frankreich</strong>?</p>",
|
||||
"answers": [
|
||||
{"text": "<div>Berlin</div>", "correct": false},
|
||||
{"text": "<div>Paris</div>", "correct": true},
|
||||
{"text": "<div>Rom</div>", "correct": false}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val parsed = H5PParser.parseContentJson(json, library = "H5P.MultiChoice", taskId = "test-1")
|
||||
assertNotNull(parsed)
|
||||
assertEquals("test-1", parsed.id)
|
||||
assertEquals("Was ist die Hauptstadt von Frankreich?", parsed.question)
|
||||
assertEquals(3, parsed.options.size)
|
||||
assertEquals("Paris", parsed.correctAnswer)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test parse TrueFalse content json`() {
|
||||
val json =
|
||||
"""
|
||||
{
|
||||
"question": "<p>Die Erde ist eine Scheibe.</p>",
|
||||
"correct": false
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val parsed = H5PParser.parseContentJson(json, library = "H5P.TrueFalse", taskId = "tf-1")
|
||||
assertNotNull(parsed)
|
||||
assertEquals("tf-1", parsed.id)
|
||||
assertEquals("Die Erde ist eine Scheibe.", parsed.question)
|
||||
assertEquals(listOf("Wahr", "Falsch"), parsed.options)
|
||||
assertEquals("Falsch", parsed.correctAnswer)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test parse ZIP stream with h5p metadata and content`() {
|
||||
val baos = ByteArrayOutputStream()
|
||||
ZipOutputStream(baos).use { zip ->
|
||||
// Add h5p.json
|
||||
val h5pEntry = ZipEntry("h5p.json")
|
||||
zip.putNextEntry(h5pEntry)
|
||||
zip.write(
|
||||
"""
|
||||
{
|
||||
"title": "Mathematik Quiz",
|
||||
"mainLibrary": "H5P.MultiChoice",
|
||||
"license": "CC BY-SA 4.0"
|
||||
}
|
||||
""".trimIndent().toByteArray(Charsets.UTF_8),
|
||||
)
|
||||
zip.closeEntry()
|
||||
|
||||
// Add content/content.json
|
||||
val contentEntry = ZipEntry("content/content.json")
|
||||
zip.putNextEntry(contentEntry)
|
||||
zip.write(
|
||||
"""
|
||||
{
|
||||
"question": "<p>Wie viele Seiten hat ein Dreieck?</p>",
|
||||
"answers": [
|
||||
{"text": "3", "correct": true},
|
||||
{"text": "4", "correct": false},
|
||||
{"text": "5", "correct": false}
|
||||
]
|
||||
}
|
||||
""".trimIndent().toByteArray(Charsets.UTF_8),
|
||||
)
|
||||
zip.closeEntry()
|
||||
}
|
||||
|
||||
val parsed = H5PParser.parseH5PZip(ByteArrayInputStream(baos.toByteArray()), "zip-task-1")
|
||||
assertNotNull(parsed)
|
||||
assertEquals("zip-task-1", parsed.id)
|
||||
assertEquals("Mathematik Quiz", parsed.title)
|
||||
assertEquals("Wie viele Seiten hat ein Dreieck?", parsed.question)
|
||||
assertEquals("3", parsed.correctAnswer)
|
||||
assertEquals(listOf("3", "4", "5"), parsed.options)
|
||||
assertEquals("CC BY-SA 4.0", parsed.license)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test taxonomy and grade mapping`() {
|
||||
assertEquals("Mathematik", ZumApiClient.mapSubject("mathe"))
|
||||
assertEquals("Deutsch", ZumApiClient.mapSubject("german language"))
|
||||
assertEquals("Sachunterricht", ZumApiClient.mapSubject("Sachkunde"))
|
||||
assertEquals("Englisch", ZumApiClient.mapSubject("english"))
|
||||
|
||||
assertEquals(6 to 7, ZumApiClient.mapGradeToAgeRange(1))
|
||||
assertEquals(8 to 9, ZumApiClient.mapGradeToAgeRange(3))
|
||||
assertEquals(6 to 12, ZumApiClient.mapGradeToAgeRange(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test ingestion CLI batch process with in-memory repository`() =
|
||||
runBlocking {
|
||||
val repository = InMemoryKidioRepository()
|
||||
val importedCount = H5PIngestionCli.ingestBatch(repository = repository, limit = 10)
|
||||
|
||||
assertTrue(importedCount > 0)
|
||||
val mathTasks = repository.getTasks(subject = "Mathematik", age = 8)
|
||||
assertTrue(mathTasks.isNotEmpty())
|
||||
|
||||
val deTasks = repository.getTasks(subject = "Deutsch")
|
||||
assertTrue(deTasks.isNotEmpty())
|
||||
}
|
||||
}
|
||||
|
|
@ -166,7 +166,29 @@ class ServerTest {
|
|||
assertEquals("t1", tasksJson[0].id)
|
||||
assertEquals("Was ist 12 + 15?", tasksJson[0].question)
|
||||
|
||||
// 2. Verify Task correct answer
|
||||
// 2. Filter nach Fach & Alter
|
||||
val filteredResponse =
|
||||
client.get("/api/v1/tasks?subject=Mathematik&age=8") {
|
||||
header(HttpHeaders.Authorization, "Bearer $token")
|
||||
}
|
||||
assertEquals(HttpStatusCode.OK, filteredResponse.status)
|
||||
val filteredJson = Json.decodeFromString<List<Task>>(filteredResponse.bodyAsText())
|
||||
assertEquals(2, filteredJson.size)
|
||||
|
||||
// 3. Filter mit Alter außerhalb des Bereichs
|
||||
val age15Response =
|
||||
client.get("/api/v1/tasks?subject=Mathematik&age=15") {
|
||||
header(HttpHeaders.Authorization, "Bearer $token")
|
||||
}
|
||||
assertEquals(HttpStatusCode.OK, age15Response.status)
|
||||
val age15Json = Json.decodeFromString<List<Task>>(age15Response.bodyAsText())
|
||||
assertEquals(0, age15Json.size)
|
||||
|
||||
// 4. Antwort enthält NIEMALS correctAnswer
|
||||
assertFalse(tasksResponse.bodyAsText().contains("correctAnswer"))
|
||||
assertFalse(tasksResponse.bodyAsText().contains("correct_answer"))
|
||||
|
||||
// 5. Verify Task correct answer
|
||||
val verifyResponse =
|
||||
client.post("/api/v1/tasks/verify") {
|
||||
header(HttpHeaders.Authorization, "Bearer $token")
|
||||
|
|
@ -178,7 +200,7 @@ class ServerTest {
|
|||
assertTrue(verifyJson.isCorrect)
|
||||
assertEquals(10, verifyJson.earnedMinutes)
|
||||
|
||||
// 3. Verify Task incorrect answer
|
||||
// 6. Verify Task incorrect answer
|
||||
val verifyWrongResponse =
|
||||
client.post("/api/v1/tasks/verify") {
|
||||
header(HttpHeaders.Authorization, "Bearer $token")
|
||||
|
|
|
|||
Loading…
Reference in a new issue
Eigene Aufgaben rausnehmen oder in eine JSON Datei packen