Compare commits
7 commits
main
...
feature/au
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86dcc68825 | ||
|
|
c2c1e334c7 | ||
|
|
e0995dafbc | ||
|
|
dee5d95e7a | ||
|
|
8458d6a3f1 | ||
|
|
b1448884c6 | ||
|
|
cb03279c6d |
|
|
@ -1,4 +0,0 @@
|
|||
[*.{kt,kts}]
|
||||
# Erlaubt Wildcard-Imports für das gesamte Projekt:
|
||||
ij_kotlin_allow_trailing_comma = true
|
||||
ktlint_standard_no-wildcard-imports = disabled
|
||||
33
.github/workflows/ci.yml
vendored
33
.github/workflows/ci.yml
vendored
|
|
@ -1,33 +0,0 @@
|
|||
name: CI Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main", "feat/*" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
lint-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Code auschecken
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
|
||||
- name: Java 21 einrichten
|
||||
uses: https://github.com/actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
|
||||
- name: Gradle Rechte vergeben
|
||||
run: chmod +x gradlew
|
||||
|
||||
- name: 1. Code-Style (Linter) prüfen
|
||||
run: ./gradlew ktlintCheck --no-daemon
|
||||
|
||||
- name: 2. Tests ausführen
|
||||
run: ./gradlew test --no-daemon
|
||||
|
||||
- name: 3. Backend bauen
|
||||
run: ./gradlew build -x test --max-workers=2 --no-daemon -Dorg.gradle.jvmargs="-Xmx1024m"
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -11,4 +11,3 @@ build/
|
|||
*.iws
|
||||
*.ipr
|
||||
out/
|
||||
build/
|
||||
|
|
|
|||
10
.idea/.gitignore
vendored
Normal file
10
.idea/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Ignored default folder with query files
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
1
.idea/.name
Normal file
1
.idea/.name
Normal file
|
|
@ -0,0 +1 @@
|
|||
Kidio_backend
|
||||
10
.idea/codeStyles/Project.xml
Normal file
10
.idea/codeStyles/Project.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<JetCodeStyleSettings>
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</JetCodeStyleSettings>
|
||||
<codeStyleSettings language="kotlin">
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
5
.idea/codeStyles/codeStyleConfig.xml
Normal file
5
.idea/codeStyles/codeStyleConfig.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
6
.idea/compiler.xml
Normal file
6
.idea/compiler.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="21" />
|
||||
</component>
|
||||
</project>
|
||||
12
.idea/dataSources.xml
Normal file
12
.idea/dataSources.xml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="kidio_db@82.165.11.162" uuid="c8ad70de-d5fc-407a-9165-4f24328fa311">
|
||||
<driver-ref>postgresql</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||
<jdbc-url>jdbc:postgresql://82.165.11.162:5432/kidio_db</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
||||
16
.idea/gradle.xml
Normal file
16
.idea/gradle.xml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
7
.idea/kotlinc.xml
Normal file
7
.idea/kotlinc.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="externalSystemId" value="Gradle" />
|
||||
<option name="version" value="2.4.0" />
|
||||
</component>
|
||||
</project>
|
||||
7
.idea/misc.xml
Normal file
7
.idea/misc.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" project-jdk-name="homebrew-26" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
8
.idea/modules.xml
Normal file
8
.idea/modules.xml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/modules/Kidio_backend.iml" filepath="$PROJECT_DIR$/.idea/modules/Kidio_backend.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
8
.idea/modules/Kidio_backend.iml
Normal file
8
.idea/modules/Kidio_backend.iml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module version="4">
|
||||
<component name="AdditionalModuleElements">
|
||||
<content url="file://$MODULE_DIR$/../.." dumb="true">
|
||||
<excludeFolder url="file://$MODULE_DIR$/../dataSources" />
|
||||
</content>
|
||||
</component>
|
||||
</module>
|
||||
6
.idea/vcs.xml
Normal file
6
.idea/vcs.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
9
Kidio_backend.iml
Normal file
9
Kidio_backend.iml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
590
README.md
590
README.md
|
|
@ -1,585 +1,33 @@
|
|||
# Kidio Backend
|
||||
# Kidio_backend
|
||||
|
||||
Das **Kidio-Backend** ist eine gamifizierte Lösung zur Verwaltung von Bildschirmzeit für Kinder.
|
||||
This project was created using the [Ktor Project Generator](https://start.ktor.io).
|
||||
|
||||
Die Anwendung basiert auf dem **Ktor-Framework in Kotlin** und kombiniert **PostgreSQL** für die persistente Datenhaltung mit **Redis** als Caching-Layer für schnelle Zugriffszeiten.
|
||||
Here are some useful links to get you started:
|
||||
|
||||
Kinder können durch das Lösen von Lernaufgaben, beispielsweise Mathematikaufgaben, zusätzliche Bildschirmzeit erwerben. Gleichzeitig synchronisiert das System die verbrauchte Bildschirmzeit und sperrt das Gerät automatisch, sobald keine verfügbare Bildschirmzeit mehr vorhanden ist.
|
||||
|
||||
---
|
||||
* [Ktor Documentation](https://ktor.io/docs/home.html)
|
||||
* [Ktor GitHub page](https://github.com/ktorio/ktor)
|
||||
* [Ktor Slack chat](https://app.slack.com/client/T09229ZC6/C0A974TJ9). [Request an invite](https://surveys.jetbrains.com/s3/kotlin-slack-sign-up).
|
||||
|
||||
## Features
|
||||
|
||||
| Feature | Beschreibung |
|
||||
| :--------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **JWT-Authentifizierung** | Sichere API-Endpunkte für authentifizierte Benutzer über JSON Web Tokens (JWT). |
|
||||
| **Lernaufgaben (Tasks)** | Abrufen von interaktiven Lernaufgaben mit vordefinierten Antwortmöglichkeiten, Filterung nach Fach und Alter sowie Belohnungen in Minuten. |
|
||||
| **Antwort-Verifizierung** | Überprüfung der vom Kind ausgewählten Antworten. Bei korrekter Antwort wird die Belohnung direkt auf die verbleibende Bildschirmzeit aufgerechnet. |
|
||||
| **OER / H5P Ingestion Pipeline** | Automatisierter Live-Crawler und Batch-Import von freien Bildungsinhalten (ZUM.de / H5P) inklusive HTML-Bereinigung und Lizenzattributierung (CC-BY). |
|
||||
| **Bildschirmzeit-Synchronisation** | Laufender Datenabgleich der verbrauchten Bildschirmzeit. Sinkt die verbleibende Zeit auf oder unter null, wird der Status automatisch auf `isBlocked = true` gesetzt. |
|
||||
| **Zwei-Ebenen-Speicher** | PostgreSQL als persistente Datenbank, kombiniert mit Redis-Caching für schnelle Screentime-Abfragen. |
|
||||
| **Fallback-Mechanismus** | Ist Redis nicht erreichbar, fällt das System automatisch und transparent auf PostgreSQL zurück. |
|
||||
Here's a list of features included in this project:
|
||||
|
||||
---
|
||||
| Name | Description |
|
||||
|------|-------------|
|
||||
|
||||
## Technologiestack
|
||||
## Building & Running
|
||||
|
||||
* **Framework:** Ktor
|
||||
* **Programmiersprache:** Kotlin
|
||||
* **Datenbank-ORM:** JetBrains Exposed
|
||||
* **Persistenz:** PostgreSQL
|
||||
* **Caching:** Redis via Jedis
|
||||
* **Authentifizierung:** JWT via Java JWT (Auth0)
|
||||
* **Serialisierung:** Kotlinx Serialization
|
||||
* **Code Quality / Linter:** KtLint
|
||||
* **Testing:** Ktor Server Testing, JUnit
|
||||
* **CI/CD:** Forgejo Actions (Gitea / Act Runner)
|
||||
To build or run the project, use one of the following tasks:
|
||||
|
||||
---
|
||||
| Task | Description |
|
||||
|-------------------|-------------------|
|
||||
| `./gradlew test` | Run the tests |
|
||||
| `./gradlew build` | Build the project |
|
||||
| `./gradlew run` | Run the server |
|
||||
|
||||
## API-Endpunkte
|
||||
If the server starts successfully, you'll see the following output:
|
||||
|
||||
Alle API-Endpunkte befinden sich unter dem Präfix:
|
||||
|
||||
```text
|
||||
/api/v1
|
||||
```
|
||||
|
||||
### Öffentliche Endpunkte
|
||||
|
||||
#### 1. Health-Check
|
||||
|
||||
**Methode:** `GET`
|
||||
|
||||
**Pfad:**
|
||||
|
||||
```text
|
||||
/api/v1/health
|
||||
2024-12-04 14:32:45.584 [main] INFO Application - Application started in 0.303 seconds.
|
||||
2024-12-04 14:32:45.682 [main] INFO Application - Responding at http://0.0.0.0:8080
|
||||
```
|
||||
|
||||
**Beschreibung:**
|
||||
|
||||
Prüft, ob der Backend-Server ordnungsgemäß läuft.
|
||||
|
||||
**Antwort:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "OK",
|
||||
"message": "Kidio Backend läuft!"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 2. Benutzer-Registrierung
|
||||
|
||||
**Methode:** `POST`
|
||||
|
||||
**Pfad:**
|
||||
|
||||
```text
|
||||
/api/v1/auth/register
|
||||
```
|
||||
|
||||
**Request-Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "test@family.de",
|
||||
"passwordPlain": "strengGeheim1",
|
||||
"familyName": "Familie Schmidt",
|
||||
"childAge": 8,
|
||||
"parentPin": "4321"
|
||||
}
|
||||
```
|
||||
|
||||
**Antwort – `201 Created`:**
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsIn...",
|
||||
"userId": "6380776f-88d3-4e9f-a2be-0d9cacae447e",
|
||||
"message": "Registrierung erfolgreich"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 3. Benutzer-Login
|
||||
|
||||
**Methode:** `POST`
|
||||
|
||||
**Pfad:**
|
||||
|
||||
```text
|
||||
/api/v1/auth/login
|
||||
```
|
||||
|
||||
**Request-Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "test@family.de",
|
||||
"passwordPlain": "strengGeheim1"
|
||||
}
|
||||
```
|
||||
|
||||
**Antwort – `200 OK`:**
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsIn...",
|
||||
"userId": "6380776f-88d3-4e9f-a2be-0d9cacae447e",
|
||||
"familyName": "Familie Schmidt",
|
||||
"childAge": 8
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Geschützte Endpunkte
|
||||
|
||||
Die folgenden Endpunkte erfordern ein gültiges JWT im HTTP-Header:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <Token>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 4. Bildschirmzeit abrufen
|
||||
|
||||
**Methode:** `GET`
|
||||
|
||||
**Pfad:**
|
||||
|
||||
```text
|
||||
/api/v1/screentime
|
||||
```
|
||||
|
||||
**Beschreibung:**
|
||||
|
||||
Liefert die aktuell verbleibende Bildschirmzeit sowie den aktuellen Sperrstatus des authentifizierten Benutzers.
|
||||
|
||||
**Antwort – `200 OK`:**
|
||||
|
||||
```json
|
||||
{
|
||||
"remainingSeconds": 1800,
|
||||
"isBlocked": false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 5. Bildschirmzeit synchronisieren
|
||||
|
||||
**Methode:** `POST`
|
||||
|
||||
**Pfad:**
|
||||
|
||||
```text
|
||||
/api/v1/screentime/sync
|
||||
```
|
||||
|
||||
**Beschreibung:**
|
||||
|
||||
Zieht die seit dem letzten Abgleich vom Endgerät verbrauchten Sekunden von der verfügbaren Bildschirmzeit ab.
|
||||
|
||||
**Request-Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"usedSecondsSinceLastSync": 30
|
||||
}
|
||||
```
|
||||
|
||||
**Antwort – `200 OK`:**
|
||||
|
||||
```json
|
||||
{
|
||||
"remainingSeconds": 1770,
|
||||
"isBlocked": false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 6. Lernaufgaben abrufen (mit Fach- & Altersfilter)
|
||||
|
||||
**Methode:** `GET`
|
||||
|
||||
**Pfad:**
|
||||
|
||||
```text
|
||||
/api/v1/tasks
|
||||
```
|
||||
|
||||
**Optionale Query-Parameter:**
|
||||
|
||||
| Parameter | Typ | Standardwert | Beschreibung |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `subject` | String | `null` | Schulfach (z. B. `Mathematik`, `Deutsch`, `Sachunterricht`, `Englisch`, `Biologie`, `Geographie`, `Geschichte`). |
|
||||
| `age` | Integer | `null` | Zielalter des Kindes (filtert nach `minAge <= age AND maxAge >= age`). |
|
||||
| `limit` | Integer | `10` | Maximale Anzahl an zurückgegebenen Aufgaben. |
|
||||
|
||||
**Beispiel-Anfrage:**
|
||||
|
||||
```text
|
||||
GET /api/v1/tasks?subject=Mathematik&age=8&limit=5
|
||||
```
|
||||
|
||||
**Beschreibung:**
|
||||
|
||||
Liefert kuratierte oder aus OER/H5P importierte Lernaufgaben. Die korrekte Lösung (`correctAnswer`) wird zum Schutz vor Manipulationen serverseitig geheim gehalten und niemals an den Client übertragen.
|
||||
|
||||
**Antwort – `200 OK`:**
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "zum-math-001",
|
||||
"subject": "Mathematik",
|
||||
"minAge": 8,
|
||||
"maxAge": 9,
|
||||
"question": "Was ist das Ergebnis von 6 × 8?",
|
||||
"options": [
|
||||
"42",
|
||||
"48",
|
||||
"54",
|
||||
"56"
|
||||
],
|
||||
"rewardMinutes": 5,
|
||||
"license": "CC BY-SA 4.0"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 7. Antwort überprüfen und Belohnung verbuchen
|
||||
|
||||
**Methode:** `POST`
|
||||
|
||||
**Pfad:**
|
||||
|
||||
```text
|
||||
/api/v1/tasks/verify
|
||||
```
|
||||
|
||||
**Request-Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"taskId": "zum-math-001",
|
||||
"selectedAnswer": "48"
|
||||
}
|
||||
```
|
||||
|
||||
**Antwort – korrekte Lösung (`200 OK`):**
|
||||
|
||||
```json
|
||||
{
|
||||
"isCorrect": true,
|
||||
"earnedMinutes": 5,
|
||||
"message": "Super gemacht! Du hast 5 Minuten gewonnen."
|
||||
}
|
||||
```
|
||||
|
||||
**Antwort – falsche Lösung (`200 OK`):**
|
||||
|
||||
```json
|
||||
{
|
||||
"isCorrect": false,
|
||||
"earnedMinutes": 0,
|
||||
"message": "Schade, das war leider falsch. Versuche es nochmal!"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## H5P / ZUM.de Aufgaben-Import (Standalone CLI)
|
||||
|
||||
Das Backend verfügt über ein integriertes CLI-Tool zum automatisierten Importieren und Crawlen interaktiver Lernaufgaben von **ZUM.de / OER (H5P)**.
|
||||
|
||||
### Batch-Import ausführen
|
||||
|
||||
```bash
|
||||
# Alle Fächer importieren (z. B. 100 Aufgaben)
|
||||
./gradlew runIngestion --args="--limit=100"
|
||||
|
||||
# Gezielt ein Schulfach importieren (z. B. Mathematik)
|
||||
./gradlew runIngestion --args="--subject=Mathematik --limit=50"
|
||||
|
||||
# Deutsch für Grundschüler importieren
|
||||
./gradlew runIngestion --args="--subject=Deutsch --limit=30"
|
||||
```
|
||||
|
||||
### CLI-Optionen
|
||||
|
||||
| Argument | Standardwert | Beschreibung |
|
||||
| :--- | :--- | :--- |
|
||||
| `--subject=<Name>` | *(alle Fächer)* | Filtert Aufgaben nach Fach (z. B. `Mathematik`, `Deutsch`, `Englisch`, `Sachunterricht`). |
|
||||
| `--limit=<Zahl>` | `50` | Maximale Anzahl an Aufgaben, die im Durchlauf importiert/aktualisiert werden. |
|
||||
|
||||
### Funktionsweise der Ingestion-Pipeline
|
||||
1. **Live-Crawler & Discovery:** Durchsucht `apps.zum.de` nach interaktiven Inhalten (`H5P.MultiChoice`, `H5P.TrueFalse`, `H5P.QuestionSet`).
|
||||
2. **Textbereinigung:** Entfernt störende HTML-Tags (`<p>`, `<strong>`, ` `) für native mobile Darstellung.
|
||||
3. **Taxonomie- & Alters-Mapping:** Weist Altersgrenzen (`minAge`, `maxAge`) basierend auf Klassenstufen automatisch zu.
|
||||
4. **Idempotenter Upsert:** Speichert Fragen, Optionen, geheime Lösungen und CC-Lizenzen direkt relational in PostgreSQL (`TasksTable`). Duplikate werden vermieden.
|
||||
|
||||
---
|
||||
|
||||
## Projektkonfiguration und Umgebungsvariablen
|
||||
|
||||
Die Anwendung wird über folgende Konfigurationsdatei konfiguriert:
|
||||
|
||||
```text
|
||||
src/main/resources/application.yaml
|
||||
```
|
||||
|
||||
Für eine flexible Konfiguration in lokalen Entwicklungsumgebungen, Docker-Containern oder Deployments können folgende Umgebungsvariablen gesetzt werden:
|
||||
|
||||
| Variable | Beschreibung | Standardwert |
|
||||
| :------------ | :----------------------------------------- | :------------------------------- |
|
||||
| `DB_HOST` | Hostname der PostgreSQL-Datenbank | `localhost` |
|
||||
| `DB_PORT` | Port der PostgreSQL-Datenbank | `5432` |
|
||||
| `DB_NAME` | Name der PostgreSQL-Datenbank | `kidio_db` |
|
||||
| `DB_USER` | PostgreSQL-Benutzername | `kidio_admin` |
|
||||
| `DB_PASSWORD` | PostgreSQL-Passwort | `learning` |
|
||||
| `REDIS_HOST` | Hostname der Redis-Instanz | `localhost` |
|
||||
| `REDIS_PORT` | Port der Redis-Instanz | `6379` |
|
||||
| `JWT_SECRET` | Geheimer Schlüssel zur Signierung der JWTs | `super-geheimes-secret-fuer-dev` |
|
||||
|
||||
> **Hinweis:** Die angegebenen Standardwerte sind für die lokale Entwicklung gedacht. Für produktive Umgebungen sollten insbesondere `DB_PASSWORD` und `JWT_SECRET` über sichere Secrets bzw. Umgebungsvariablen gesetzt werden.
|
||||
|
||||
---
|
||||
|
||||
## Lokale Entwicklung
|
||||
|
||||
### Voraussetzungen
|
||||
|
||||
Für die lokale Entwicklung werden benötigt:
|
||||
|
||||
* JDK / Java
|
||||
* Kotlin bzw. Gradle Wrapper
|
||||
* PostgreSQL
|
||||
* Redis
|
||||
|
||||
Die konkreten Versionen richten sich nach der im Projekt konfigurierten Gradle- und Kotlin-Version.
|
||||
|
||||
---
|
||||
|
||||
## Bauen und Ausführen
|
||||
|
||||
Das Projekt verwendet den Gradle Wrapper, sodass Gradle nicht global installiert sein muss.
|
||||
|
||||
### Anwendung starten
|
||||
|
||||
```bash
|
||||
./gradlew run
|
||||
```
|
||||
|
||||
Der Ktor-Server ist anschließend standardmäßig unter folgender Adresse erreichbar:
|
||||
|
||||
```text
|
||||
http://localhost:8080
|
||||
```
|
||||
|
||||
### Projekt bauen
|
||||
|
||||
```bash
|
||||
./gradlew build
|
||||
```
|
||||
|
||||
### Tests ausführen
|
||||
|
||||
```bash
|
||||
./gradlew test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Quality
|
||||
|
||||
Für die Einhaltung der Kotlin-Coding-Standards wird **KtLint** verwendet.
|
||||
|
||||
### Code automatisch formatieren
|
||||
|
||||
```bash
|
||||
./gradlew ktlintFormat
|
||||
```
|
||||
|
||||
### Code Style prüfen
|
||||
|
||||
```bash
|
||||
./gradlew ktlintCheck
|
||||
```
|
||||
|
||||
Der KtLint-Check wird ebenfalls innerhalb der CI-Pipeline ausgeführt.
|
||||
|
||||
---
|
||||
|
||||
## Empfohlener Workflow vor Commits / Pull Requests
|
||||
|
||||
Vor einem Commit oder Pull Request sollte der folgende Workflow ausgeführt werden:
|
||||
|
||||
```bash
|
||||
# 1. Kotlin-Code automatisch formatieren
|
||||
./gradlew ktlintFormat
|
||||
|
||||
# 2. Code Style überprüfen
|
||||
./gradlew ktlintCheck
|
||||
|
||||
# 3. Tests ausführen
|
||||
./gradlew test
|
||||
|
||||
# 4. Projekt bauen
|
||||
./gradlew build
|
||||
|
||||
# 5. Server lokal starten
|
||||
./gradlew run
|
||||
```
|
||||
|
||||
Der Server ist anschließend unter:
|
||||
|
||||
```text
|
||||
http://localhost:8080
|
||||
```
|
||||
|
||||
erreichbar.
|
||||
|
||||
---
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
Das Repository verwendet **Forgejo Actions** für Continuous Integration.
|
||||
|
||||
Bei jedem Push oder Pull Request auf den `main`-Branch wird automatisch ein Workflow ausgeführt.
|
||||
|
||||
Die Pipeline führt unter anderem folgende Prüfungen durch:
|
||||
|
||||
1. **Code Style**
|
||||
|
||||
```bash
|
||||
./gradlew ktlintCheck
|
||||
```
|
||||
|
||||
2. **Automatisierte Tests**
|
||||
|
||||
```bash
|
||||
./gradlew test
|
||||
```
|
||||
|
||||
Dadurch wird sichergestellt, dass Änderungen den bestehenden Code-Style einhalten und die automatisierten Tests weiterhin erfolgreich ausgeführt werden können.
|
||||
|
||||
---
|
||||
|
||||
## Architektur
|
||||
|
||||
Das Backend besteht im Wesentlichen aus folgenden Komponenten:
|
||||
|
||||
```text
|
||||
┌───────────────────┐
|
||||
│ Client │
|
||||
│ Web / Mobile / │
|
||||
│ Device │
|
||||
└─────────┬─────────┘
|
||||
│
|
||||
│ HTTP / JSON
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ Ktor │
|
||||
│ REST API │
|
||||
└─────────┬─────────┘
|
||||
│
|
||||
┌────────────┴────────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ Redis │ │ PostgreSQL │
|
||||
│ Cache │ │ Persistence │
|
||||
└────────┬────────┘ └────────┬────────┘
|
||||
│ │
|
||||
└────────────┬────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ Exposed ORM │
|
||||
└───────────────────┘
|
||||
```
|
||||
|
||||
### Redis-Fallback
|
||||
|
||||
Redis wird für schnelle Zugriffe auf häufig benötigte Daten, insbesondere die verbleibende Bildschirmzeit, verwendet.
|
||||
|
||||
Sollte Redis nicht erreichbar sein, greift das Backend automatisch auf PostgreSQL zurück.
|
||||
|
||||
Dadurch bleibt die Anwendung auch bei einem Ausfall des Caches funktionsfähig.
|
||||
|
||||
---
|
||||
|
||||
## Bildschirmzeit-Logik
|
||||
|
||||
Die Bildschirmzeit wird intern in Sekunden verwaltet.
|
||||
|
||||
Beispielsweise:
|
||||
|
||||
```text
|
||||
Start:
|
||||
1800 Sekunden
|
||||
= 30 Minuten
|
||||
|
||||
Verbrauch:
|
||||
200 Sekunden
|
||||
|
||||
Verbleibend:
|
||||
1600 Sekunden
|
||||
= 26 Minuten 40 Sekunden
|
||||
```
|
||||
|
||||
Sobald die verbleibende Bildschirmzeit auf `0` oder darunter fällt, wird der Benutzer automatisch gesperrt:
|
||||
|
||||
```json
|
||||
{
|
||||
"remainingSeconds": 0,
|
||||
"isBlocked": true
|
||||
}
|
||||
```
|
||||
|
||||
Durch das Lösen einer Aufgabe kann zusätzliche Bildschirmzeit erworben werden.
|
||||
|
||||
Beispiel:
|
||||
|
||||
```text
|
||||
Verbleibend: 0 Sekunden
|
||||
Aufgaben-Belohnung: 10 Minuten
|
||||
↓
|
||||
Neue Bildschirmzeit: 600 Sekunden
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lizenz
|
||||
|
||||
Falls für das Projekt eine bestimmte Lizenz verwendet wird, sollte diese hier ergänzt werden, beispielsweise:
|
||||
|
||||
```text
|
||||
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).
|
||||

|
||||
|
|
@ -1,7 +1,6 @@
|
|||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(ktorLibs.plugins.ktor)
|
||||
id("org.jlleitschuh.gradle.ktlint") version "12.1.0"
|
||||
kotlin("plugin.serialization") version "2.0.0"
|
||||
}
|
||||
|
||||
|
|
@ -11,12 +10,6 @@ version = "1.0.0-SNAPSHOT"
|
|||
application {
|
||||
mainClass = "io.ktor.server.netty.EngineMain"
|
||||
}
|
||||
ktor {
|
||||
openApi {
|
||||
enabled = true // Aktiviert die OpenAPI-Code-Generierung
|
||||
codeInferenceEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
|
|
@ -26,16 +19,13 @@ dependencies {
|
|||
implementation(ktorLibs.server.config.yaml)
|
||||
implementation(ktorLibs.server.core)
|
||||
implementation(ktorLibs.server.netty)
|
||||
implementation("io.ktor:ktor-server-openapi")
|
||||
// JWT Authentication
|
||||
|
||||
// JWT Authentication (NEU)
|
||||
implementation("io.ktor:ktor-server-auth")
|
||||
implementation("io.ktor:ktor-server-auth-jwt")
|
||||
|
||||
implementation(libs.logback.classic)
|
||||
|
||||
// Bcrypt
|
||||
implementation("org.mindrot:jbcrypt:0.4")
|
||||
|
||||
// JSON-Unterstützung und Serialization
|
||||
implementation("io.ktor:ktor-server-content-negotiation-jvm")
|
||||
implementation("io.ktor:ktor-serialization-kotlinx-json-jvm")
|
||||
|
|
@ -45,18 +35,11 @@ dependencies {
|
|||
implementation("org.jetbrains.exposed:exposed-core:0.50.0")
|
||||
implementation("org.jetbrains.exposed:exposed-dao:0.50.0")
|
||||
implementation("org.jetbrains.exposed:exposed-jdbc:0.50.0")
|
||||
|
||||
testImplementation(kotlin("test"))
|
||||
testImplementation(ktorLibs.server.testHost)
|
||||
|
||||
// redis
|
||||
implementation("redis.clients:jedis:5.1.2")
|
||||
}
|
||||
|
||||
tasks.register<JavaExec>("runIngestion") {
|
||||
group = "application"
|
||||
description = "Runs the standalone H5P/ZUM task ingestion CLI tool"
|
||||
mainClass.set("com.oliver.kidio.backend.ingestion.H5PIngestionCli")
|
||||
classpath = sourceSets["main"].runtimeClasspath
|
||||
standardInput = System.`in`
|
||||
implementation("org.jetbrains.exposed:exposed-java-time:0.50.0")
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
# Swagger Codegen Ignore
|
||||
# Generated by swagger-codegen https://github.com/swagger-api/swagger-codegen
|
||||
|
||||
# Use this file to prevent files from being overwritten by the generator.
|
||||
# The patterns follow closely to .gitignore or .dockerignore.
|
||||
|
||||
# As an example, the C# client generator defines ApiClient.cs.
|
||||
# You can make changes and tell Swagger Codgen to ignore just this file by uncommenting the following line:
|
||||
#ApiClient.cs
|
||||
|
||||
# You can match any string of characters against a directory, file or extension with a single asterisk (*):
|
||||
#foo/*/qux
|
||||
# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux
|
||||
|
||||
# You can recursively match patterns against a directory, file or extension with a double asterisk (**):
|
||||
#foo/**/qux
|
||||
# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux
|
||||
|
||||
# You can also negate patterns with an exclamation (!).
|
||||
# For example, you can ignore all files in a docs folder with the file extension .md:
|
||||
#docs/*.md
|
||||
# Then explicitly reverse the ignore rule for a single file:
|
||||
#!docs/README.md
|
||||
|
|
@ -1 +0,0 @@
|
|||
3.0.81
|
||||
5455
docs/index.html
5455
docs/index.html
File diff suppressed because one or more lines are too long
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
|
|
@ -1,6 +1,6 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
|
|
|
|||
19
gradlew
vendored
19
gradlew
vendored
|
|
@ -114,8 +114,6 @@ case "$( uname )" in #(
|
|||
NONSTOP*) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ]; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
|
||||
|
|
@ -132,8 +130,7 @@ location of your Java installation."
|
|||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
if ! command -v java >/dev/null 2>&1; then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
|
|
@ -149,6 +146,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
|||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$(ulimit -H -n) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
;;
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) : ;; #(
|
||||
|
|
@ -157,6 +155,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
|||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
|
|
@ -175,12 +174,14 @@ if "$cygwin" || "$msys" ; then
|
|||
JAVACMD=$(cygpath --unix "$JAVACMD")
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
for arg; do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
/?*)
|
||||
t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ]
|
||||
;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
|
|
@ -198,7 +199,6 @@ if "$cygwin" || "$msys" ; then
|
|||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
|
|
@ -214,8 +214,7 @@ set -- \
|
|||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
if ! command -v xargs >/dev/null 2>&1; then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
|
|
|
|||
|
|
@ -19,3 +19,4 @@ dependencyResolutionManagement {
|
|||
}
|
||||
|
||||
rootProject.name = "Kidio_backend"
|
||||
|
||||
|
|
|
|||
BIN
src/.DS_Store
vendored
BIN
src/.DS_Store
vendored
Binary file not shown.
BIN
src/main/.DS_Store
vendored
BIN
src/main/.DS_Store
vendored
Binary file not shown.
|
|
@ -1,71 +1,77 @@
|
|||
package com.oliver.kidio.backend.data.database
|
||||
|
||||
import com.oliver.kidio.backend.data.tables.ScreentimeTable
|
||||
import com.oliver.kidio.backend.data.tables.TasksTable
|
||||
import com.oliver.kidio.backend.data.tables.UserTable
|
||||
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 UserTable : Table("users") {
|
||||
val id = varchar("id", 36)
|
||||
val username = varchar("username", 50).index(isUnique = true)
|
||||
val passwordHash = varchar("password_hash", 255)
|
||||
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
}
|
||||
|
||||
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 dbUsername = System.getenv("DB_USER") ?: config.propertyOrNull("storage.username")?.getString() ?: "postgres"
|
||||
val password = System.getenv("DB_PASSWORD") ?: config.propertyOrNull("storage.password")?.getString() ?: "postgres"
|
||||
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 dbUsername = 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(
|
||||
val database = Database.connect(
|
||||
url = jdbcURL,
|
||||
driver = driverClassName,
|
||||
user = dbUsername,
|
||||
password = password,
|
||||
password = password
|
||||
)
|
||||
|
||||
transaction(database) {
|
||||
// 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)
|
||||
SchemaUtils.create(TasksTable, ScreentimeTable, UserTable)
|
||||
|
||||
// Standard aufgaben, sollte es keine mehr geben
|
||||
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"
|
||||
|
|
@ -73,12 +79,6 @@ 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"
|
||||
|
|
@ -86,25 +86,27 @@ object DatabaseFactory {
|
|||
}
|
||||
}
|
||||
|
||||
// Seed a default user screentime if empty
|
||||
if (ScreentimeTable.selectAll().empty()) {
|
||||
ScreentimeTable.insert {
|
||||
it[userId] = "default_user"
|
||||
it[remainingSeconds] = 1800
|
||||
}
|
||||
}
|
||||
|
||||
if (UserTable.selectAll().empty()) {
|
||||
UserTable.insert {
|
||||
it[id] = "default_user"
|
||||
it[email] = "test@kidio.de"
|
||||
it[passwordHash] = "1234" // Dummy-Hash für den Start
|
||||
it[familyName] = "Familie Test"
|
||||
it[childAge] = 8
|
||||
it[parentPin] = "1234"
|
||||
}
|
||||
}
|
||||
|
||||
// Create a standard user if the Users table is empty.
|
||||
if (UserTable.selectAll().empty()) {
|
||||
UserTable.insert {
|
||||
// ID automatische generierung
|
||||
it[id] = "default_user"
|
||||
it[username] = "default_user"
|
||||
it[passwordHash] = "1234"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun <T> dbQuery(block: suspend () -> T): T = newSuspendedTransaction(Dispatchers.IO) { block() }
|
||||
suspend fun <T> dbQuery(block: suspend () -> T): T =
|
||||
newSuspendedTransaction(Dispatchers.IO) { block() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
package com.oliver.kidio.backend.data.database
|
||||
import io.ktor.server.config.ApplicationConfig
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.config.*
|
||||
import redis.clients.jedis.JedisPool
|
||||
import redis.clients.jedis.JedisPoolConfig
|
||||
|
||||
|
|
@ -8,20 +7,15 @@ object RedisFactory {
|
|||
private var jedisPool: JedisPool? = null
|
||||
|
||||
fun init(config: ApplicationConfig) {
|
||||
val host =
|
||||
System.getenv("REDIS_HOST")
|
||||
val host = System.getenv("REDIS_HOST")
|
||||
?: config.propertyOrNull("storage.redisHost")?.getString()
|
||||
?: "localhost"
|
||||
|
||||
val port =
|
||||
(
|
||||
System.getenv("REDIS_PORT")
|
||||
val port = (System.getenv("REDIS_PORT")
|
||||
?: config.propertyOrNull("storage.redisPort")?.getString()
|
||||
?: "6379"
|
||||
).toInt()
|
||||
?: "6379").toInt()
|
||||
|
||||
val poolConfig =
|
||||
JedisPoolConfig().apply {
|
||||
val poolConfig = JedisPoolConfig().apply {
|
||||
maxTotal = 32
|
||||
maxIdle = 8
|
||||
minIdle = 2
|
||||
|
|
@ -29,7 +23,6 @@ object RedisFactory {
|
|||
jedisPool = JedisPool(poolConfig, host, port)
|
||||
}
|
||||
|
||||
fun getResource() =
|
||||
jedisPool?.resource
|
||||
fun getResource() = jedisPool?.resource
|
||||
?: throw IllegalStateException("Redis Verbindungspool ist nicht initzialisiert")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
package com.oliver.kidio.backend.data.tables
|
||||
|
||||
import org.jetbrains.exposed.sql.Table
|
||||
|
||||
object ScreentimeTable : Table("user_screentime") {
|
||||
val userId = varchar("user_id", 50)
|
||||
val remainingSeconds = integer("remaining_seconds")
|
||||
|
||||
override val primaryKey = PrimaryKey(userId)
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
package com.oliver.kidio.backend.data.tables
|
||||
|
||||
import org.jetbrains.exposed.sql.Table
|
||||
|
||||
object TasksTable : Table("tasks") {
|
||||
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").default(5)
|
||||
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
|
||||
init {
|
||||
index("idx_tasks_filter", false, subject, minAge, maxAge)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
package com.oliver.kidio.backend.data.tables
|
||||
|
||||
import org.jetbrains.exposed.sql.Table
|
||||
|
||||
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).default("Familie")
|
||||
val childAge = integer("child_age").default(8)
|
||||
val parentPin = varchar("parent_pin", 4).default("1234")
|
||||
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
package com.oliver.kidio.backend.domain.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class RegisterRequest(
|
||||
val email: String,
|
||||
val passwordPlain: String,
|
||||
val familyName: String,
|
||||
val childAge: Int,
|
||||
val parentPin: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LoginRequest(
|
||||
val email: String,
|
||||
val passwordPlain: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AuthResponse(
|
||||
val token: String,
|
||||
val userId: String,
|
||||
val familyName: String,
|
||||
val childAge: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VerifyPinRequest(
|
||||
val pin: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VerifyPinResponse(
|
||||
val success: Boolean,
|
||||
val message: String,
|
||||
)
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
package com.oliver.kidio.backend.domain.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
// Eine Lernaufgabe (z. B. Mathe)
|
||||
@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
|
||||
@Serializable
|
||||
data class TaskVerifyRequest(
|
||||
val taskId: String,
|
||||
val selectedAnswer: String,
|
||||
)
|
||||
|
||||
// Antwort vom Server nach der Aufgaben-Prüfung
|
||||
@Serializable
|
||||
data class TaskVerifyResponse(
|
||||
val isCorrect: Boolean,
|
||||
val earnedMinutes: Int,
|
||||
val message: String,
|
||||
)
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
package com.oliver.kidio.backend.domain.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
// Interne Backend-Klasse (Domain Model).
|
||||
data class User(
|
||||
val id: String,
|
||||
val email: String,
|
||||
val passwordHash: String,
|
||||
val familyName: String,
|
||||
val childAge: Int,
|
||||
val parentPin: String,
|
||||
)
|
||||
|
||||
// Datenabgleich für die Bildschirmzeit (Sync)
|
||||
@Serializable
|
||||
data class SyncRequest(
|
||||
val userId: String,
|
||||
val usedSecondsSinceLastSync: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SyncResponse(
|
||||
val remainingSeconds: Int,
|
||||
val isBlocked: Boolean,
|
||||
)
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
package com.oliver.kidio.backend
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
// Eine Lernaufgabe (z. B. Mathe)
|
||||
@Serializable
|
||||
data class Task(
|
||||
val id: String,
|
||||
val question: String,
|
||||
val options: List<String>, // Antwortmöglichkeiten
|
||||
val rewardMinutes: Int // Wie viel Belohnung es gibt
|
||||
)
|
||||
|
||||
// Anfrage des Kindes, wenn es eine Aufgabe löst
|
||||
@Serializable
|
||||
data class TaskVerifyRequest(
|
||||
val taskId: String,
|
||||
val selectedAnswer: String
|
||||
)
|
||||
|
||||
// Antwort vom Server nach der Aufgaben-Prüfung
|
||||
@Serializable
|
||||
data class TaskVerifyResponse(
|
||||
val isCorrect: Boolean,
|
||||
val earnedMinutes: Int,
|
||||
val message: String
|
||||
)
|
||||
|
||||
// Datenabgleich für die Bildschirmzeit (Sync)
|
||||
@Serializable
|
||||
data class SyncRequest(
|
||||
val userId: String,
|
||||
val usedSecondsSinceLastSync: Int
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LoginRequest(
|
||||
val userId: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class User(
|
||||
val id: String,
|
||||
val username: String,
|
||||
val passwordHash: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SyncResponse(
|
||||
val remainingSeconds: Int,
|
||||
val isBlocked: Boolean
|
||||
)
|
||||
|
|
@ -1,242 +0,0 @@
|
|||
package com.oliver.kidio.backend.domain.repository
|
||||
|
||||
import com.oliver.kidio.backend.data.database.DatabaseFactory
|
||||
import com.oliver.kidio.backend.data.database.RedisFactory
|
||||
import com.oliver.kidio.backend.data.tables.ScreentimeTable
|
||||
import com.oliver.kidio.backend.data.tables.TasksTable
|
||||
import com.oliver.kidio.backend.data.tables.UserTable
|
||||
import com.oliver.kidio.backend.domain.models.ImportedTask
|
||||
import com.oliver.kidio.backend.domain.models.Task
|
||||
import com.oliver.kidio.backend.domain.models.User
|
||||
import org.jetbrains.exposed.sql.*
|
||||
import org.mindrot.jbcrypt.BCrypt
|
||||
|
||||
class ExposedKidioRepository : KidioRepository {
|
||||
override suspend fun getTasks(
|
||||
subject: String?,
|
||||
age: Int?,
|
||||
limit: Int,
|
||||
): List<Task> =
|
||||
DatabaseFactory.dbQuery {
|
||||
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 getUserById(userId: String): User? =
|
||||
DatabaseFactory.dbQuery {
|
||||
UserTable.selectAll()
|
||||
.where { UserTable.id eq userId }
|
||||
.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 getAllTasks(): List<Task> = getTasks(limit = 100)
|
||||
|
||||
override suspend fun getExistingTaskIds(externalSource: String): Set<String> =
|
||||
DatabaseFactory.dbQuery {
|
||||
TasksTable
|
||||
.select(TasksTable.id)
|
||||
.where { TasksTable.externalSource eq externalSource }
|
||||
.map { it[TasksTable.id] }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
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 {
|
||||
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"
|
||||
|
||||
try {
|
||||
RedisFactory.getResource().use { jedis ->
|
||||
val cachedSeconds = jedis.get(redisKey)
|
||||
if (cachedSeconds != null) {
|
||||
println("DEBUG: Bildschirmzeit für $userId aus REDIS geladen: $cachedSeconds Sek")
|
||||
return cachedSeconds.toInt()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
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
|
||||
}
|
||||
|
||||
try {
|
||||
RedisFactory.getResource().use { jedis ->
|
||||
jedis.setex(redisKey, 3600, dbSeconds.toString())
|
||||
println("DEBUG: Bildschirmzeit für $userId in REDIS gecacht.")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("WARNUNG: Konnte Wert nicht in Redis speichern: ${e.message}")
|
||||
}
|
||||
return dbSeconds
|
||||
}
|
||||
|
||||
override suspend fun updateScreentime(
|
||||
userId: String,
|
||||
seconds: Int,
|
||||
) {
|
||||
DatabaseFactory.dbQuery {
|
||||
val rowsUpdated =
|
||||
ScreentimeTable.update({ ScreentimeTable.userId eq userId }) {
|
||||
it[remainingSeconds] = seconds
|
||||
}
|
||||
if (rowsUpdated == 0) {
|
||||
ScreentimeTable.insert {
|
||||
it[ScreentimeTable.userId] = userId
|
||||
it[ScreentimeTable.remainingSeconds] = seconds
|
||||
}
|
||||
}
|
||||
}
|
||||
val redisKey = "screentime:$userId"
|
||||
try {
|
||||
RedisFactory.getResource().use { jedis ->
|
||||
jedis.setex(redisKey, 3600, seconds.toString())
|
||||
println("DEBUG: Bildschirmzeit für $userId in REDIS aktualisiert auf $seconds Sek")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("WARNUNG: Konnte Redis nicht aktualisieren: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
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 findById(userId: String): User? =
|
||||
DatabaseFactory.dbQuery {
|
||||
UserTable.selectAll()
|
||||
.where { UserTable.id eq userId }
|
||||
.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 {
|
||||
return BCrypt.checkpw(password, passwordHash)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
package com.oliver.kidio.backend.domain.repository
|
||||
|
||||
import com.oliver.kidio.backend.domain.models.ImportedTask
|
||||
import com.oliver.kidio.backend.domain.models.Task
|
||||
import com.oliver.kidio.backend.domain.models.User
|
||||
import org.mindrot.jbcrypt.BCrypt
|
||||
|
||||
class InMemoryKidioRepository : KidioRepository {
|
||||
private val tasks = mutableListOf<ImportedTask>()
|
||||
|
||||
private val screentimes =
|
||||
mutableMapOf(
|
||||
"default_user" to 1800,
|
||||
)
|
||||
private val users = mutableListOf<User>()
|
||||
|
||||
override suspend fun getAllTasks(): List<Task> = getTasks(limit = 100)
|
||||
|
||||
override suspend fun getTasks(
|
||||
subject: String?,
|
||||
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 getUserById(userId: String): User? {
|
||||
return users.find { it.id == userId }
|
||||
}
|
||||
|
||||
override suspend fun getExistingTaskIds(externalSource: String): Set<String> {
|
||||
return tasks
|
||||
.filter { it.externalSource == externalSource }
|
||||
.map { it.id }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
override suspend fun updateScreentime(
|
||||
userId: String,
|
||||
seconds: Int,
|
||||
) {
|
||||
screentimes[userId] = seconds
|
||||
}
|
||||
|
||||
override suspend fun findByEmail(email: String): User? {
|
||||
return users.find { it.email == email }
|
||||
}
|
||||
|
||||
override suspend fun findById(userId: String): User? {
|
||||
return users.find { it.id == userId }
|
||||
}
|
||||
|
||||
override suspend fun verifyPassword(
|
||||
password: String,
|
||||
passwordHash: String,
|
||||
): Boolean {
|
||||
return BCrypt.checkpw(password, passwordHash)
|
||||
}
|
||||
|
||||
override suspend fun registerUser(user: User): Boolean {
|
||||
users.add(user)
|
||||
screentimes[user.id] = 1800
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
@ -1,41 +1,137 @@
|
|||
package com.oliver.kidio.backend.domain.repository
|
||||
|
||||
import com.oliver.kidio.backend.domain.models.ImportedTask
|
||||
import com.oliver.kidio.backend.domain.models.Task
|
||||
import com.oliver.kidio.backend.domain.models.User
|
||||
import com.oliver.kidio.backend.Task
|
||||
import com.oliver.kidio.backend.User
|
||||
import com.oliver.kidio.backend.data.database.DatabaseFactory
|
||||
import com.oliver.kidio.backend.data.database.RedisFactory
|
||||
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.*
|
||||
|
||||
interface KidioRepository {
|
||||
suspend fun getAllTasks(): List<Task> = getTasks(limit = 100)
|
||||
|
||||
suspend fun getUserById(userId: String): User?
|
||||
|
||||
suspend fun findById(userId: String): User?
|
||||
|
||||
suspend fun getTasks(
|
||||
subject: String? = null,
|
||||
age: Int? = null,
|
||||
limit: Int = 10,
|
||||
): List<Task>
|
||||
|
||||
suspend fun getExistingTaskIds(externalSource: String = "zum.de"): Set<String>
|
||||
|
||||
suspend fun upsertTask(task: ImportedTask): Boolean
|
||||
|
||||
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 findByUsername(username: String): User?
|
||||
suspend fun verifyPassword(password: String, passwordHash: String): 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 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"
|
||||
|
||||
try {
|
||||
RedisFactory.getResource().use { jedis ->
|
||||
val cachedSeconds = jedis.get(redisKey)
|
||||
if (cachedSeconds != null) {
|
||||
println("DEBUG: Bildschirmzeit für $userId aus REDIS geladen: $cachedSeconds Sek")
|
||||
return cachedSeconds.toInt()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
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
|
||||
}
|
||||
try {
|
||||
RedisFactory.getResource().use { jedis ->
|
||||
jedis.setex(redisKey, 3600, dbSeconds.toString())
|
||||
println("DEBUG: Bildschirmzeit für $userId in REDIS gecacht.")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("WARNUNG: Konnte Wert nicht in Redis speichern: ${e.message}")
|
||||
}
|
||||
return dbSeconds
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun findByUsername(username: String): User? = DatabaseFactory.dbQuery {
|
||||
UserTable.selectAll()
|
||||
.where { UserTable.username eq username }
|
||||
.map {
|
||||
User(
|
||||
id = it[UserTable.id],
|
||||
username = it[UserTable.username],
|
||||
passwordHash = it[UserTable.passwordHash]
|
||||
)
|
||||
}
|
||||
.singleOrNull()
|
||||
}
|
||||
|
||||
override suspend fun verifyPassword(password: String, passwordHash: String): Boolean {
|
||||
return 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
|
||||
)
|
||||
|
||||
suspend fun findByEmail(email: String): User?
|
||||
private val users = mutableListOf(
|
||||
User(id = "1", username = "max", passwordHash = "geheim123")
|
||||
)
|
||||
|
||||
suspend fun verifyPassword(
|
||||
password: String,
|
||||
passwordHash: String,
|
||||
): Boolean
|
||||
override suspend fun getAllTasks(): List<Task> = tasks
|
||||
|
||||
suspend fun registerUser(user: User): Boolean
|
||||
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
|
||||
}
|
||||
|
||||
override suspend fun findByUsername(username: String): User? {
|
||||
return users.find { it.username == username }
|
||||
}
|
||||
|
||||
override suspend fun verifyPassword(password: String, passwordHash: String): Boolean {
|
||||
return password == passwordHash
|
||||
}
|
||||
}
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
package com.oliver.kidio.backend.ingestion
|
||||
|
||||
import com.oliver.kidio.backend.data.tables.TasksTable
|
||||
import com.oliver.kidio.backend.domain.models.ImportedTask
|
||||
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 {
|
||||
val existingIds = repository.getExistingTaskIds("zum.de")
|
||||
println("Bereits in DB vorhandene ZUM-Aufgaben: ${existingIds.size}")
|
||||
|
||||
val items =
|
||||
client.fetchLiveOerItems(
|
||||
subject = subject,
|
||||
limit = limit,
|
||||
existingIds = existingIds,
|
||||
)
|
||||
|
||||
var importedCount = 0
|
||||
for (item in items) {
|
||||
importedCount += processItem(item, client, repository)
|
||||
}
|
||||
|
||||
println("Ingestion completed: $importedCount tasks successfully imported/updated.")
|
||||
importedCount
|
||||
}
|
||||
|
||||
private suspend fun processItem(
|
||||
item: ZumOerItem,
|
||||
client: ZumApiClient,
|
||||
repository: KidioRepository,
|
||||
): Int {
|
||||
var importedCount = 0
|
||||
val parsedList = extractTasks(item, client)
|
||||
|
||||
for (parsed in parsedList) {
|
||||
val task = mapToImportedTask(item, parsed)
|
||||
if (repository.upsertTask(task)) {
|
||||
importedCount++
|
||||
logSuccess(task)
|
||||
}
|
||||
}
|
||||
return importedCount
|
||||
}
|
||||
|
||||
private suspend fun extractTasks(
|
||||
item: ZumOerItem,
|
||||
client: ZumApiClient,
|
||||
): List<ParsedH5PTask> {
|
||||
if (item.rawJsonContent != null) {
|
||||
val tasks =
|
||||
H5PParser.parseAllTasks(
|
||||
contentJson = item.rawJsonContent,
|
||||
library = item.library ?: "H5P.MultiChoice",
|
||||
taskId = item.id,
|
||||
title = item.title,
|
||||
license = item.license,
|
||||
sourceUrl = item.downloadUrl,
|
||||
)
|
||||
if (tasks.isNotEmpty()) return tasks
|
||||
}
|
||||
|
||||
if (item.downloadUrl.endsWith(".h5p", ignoreCase = true)) {
|
||||
val h5pBytes = client.downloadH5P(item.downloadUrl) ?: return emptyList()
|
||||
val single = H5PParser.parseH5PZip(ByteArrayInputStream(h5pBytes), item.id, item.downloadUrl)
|
||||
|
||||
if (single != null) {
|
||||
return listOf(single)
|
||||
}
|
||||
}
|
||||
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
private fun mapToImportedTask(
|
||||
item: ZumOerItem,
|
||||
parsed: ParsedH5PTask,
|
||||
): ImportedTask {
|
||||
val (minAge, maxAge) = ZumApiClient.mapGradeToAgeRange(item.grade)
|
||||
|
||||
val isTrueFalse = parsed.options.size == 2 && parsed.options.contains("Wahr")
|
||||
|
||||
return 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 (isTrueFalse) "TRUE_FALSE" else "MULTIPLE_CHOICE",
|
||||
question = parsed.question,
|
||||
options = parsed.options,
|
||||
correctAnswer = parsed.correctAnswer,
|
||||
rewardMinutes = 5,
|
||||
)
|
||||
}
|
||||
|
||||
private fun logSuccess(task: ImportedTask) {
|
||||
val shortQ = task.question.take(30)
|
||||
val ageRange = "${task.minAge}-${task.maxAge}"
|
||||
println(" -> [Importiert] ${task.id} (${task.subject}, $ageRange): \"$shortQ...\"")
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,266 +0,0 @@
|
|||
package com.oliver.kidio.backend.ingestion
|
||||
|
||||
import kotlinx.serialization.json.*
|
||||
import java.io.InputStream
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
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 = DEFAULT_LIBRARY
|
||||
var title = DEFAULT_TITLE
|
||||
var license = DEFAULT_LICENSE
|
||||
|
||||
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 (e: Exception) {
|
||||
println("WARNUNG: h5p.json konnte nicht gelesen werden. Nutze Fallback-Werte. Fehler: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
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 = DEFAULT_LIBRARY,
|
||||
taskId: String = "task-1",
|
||||
title: String = DEFAULT_TITLE,
|
||||
license: String = DEFAULT_LICENSE,
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses one or multiple tasks from content.json (supports MultiChoice, TrueFalse, and QuestionSet).
|
||||
*/
|
||||
fun parseAllTasks(
|
||||
contentJson: String,
|
||||
library: String = DEFAULT_LIBRARY,
|
||||
taskId: String = "task-1",
|
||||
title: String = DEFAULT_TITLE,
|
||||
license: String = DEFAULT_LICENSE,
|
||||
sourceUrl: String? = null,
|
||||
): List<ParsedH5PTask> {
|
||||
val element =
|
||||
try {
|
||||
json.parseToJsonElement(contentJson).jsonObject
|
||||
} catch (e: Exception) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
if (library.contains("QuestionSet", ignoreCase = true) && element.containsKey("questions")) {
|
||||
val questionsArray = element["questions"]?.jsonArray ?: return emptyList()
|
||||
|
||||
return questionsArray.mapIndexedNotNull { idx, qElement ->
|
||||
parseQuestionSetItem(qElement, idx, taskId, title, license, sourceUrl)
|
||||
}
|
||||
}
|
||||
|
||||
val single = parseContentJson(contentJson, library, taskId, title, license, sourceUrl)
|
||||
return if (single != null) listOf(single) else emptyList()
|
||||
}
|
||||
|
||||
private fun parseQuestionSetItem(
|
||||
qElement: JsonElement,
|
||||
idx: Int,
|
||||
taskId: String,
|
||||
title: String,
|
||||
license: String,
|
||||
sourceUrl: String?,
|
||||
): ParsedH5PTask? {
|
||||
val qObj = qElement.jsonObject
|
||||
val subLib = qObj["library"]?.jsonPrimitive?.contentOrNull ?: DEFAULT_LIBRARY
|
||||
val subParams = qObj["params"]?.jsonObject ?: return null
|
||||
|
||||
val subId = "$taskId-q${idx + 1}"
|
||||
val subTitle = "$title #${idx + 1}"
|
||||
|
||||
val isTrueFalse =
|
||||
subLib.contains("TrueFalse", ignoreCase = true) ||
|
||||
(subParams.containsKey("correct") && !subParams.containsKey("answers"))
|
||||
|
||||
val isMultiChoice =
|
||||
subLib.contains("MultiChoice", ignoreCase = true) ||
|
||||
subParams.containsKey("answers")
|
||||
|
||||
return when {
|
||||
isTrueFalse -> parseTrueFalse(subParams, subId, subTitle, subLib, license, sourceUrl)
|
||||
isMultiChoice -> parseMultiChoice(subParams, subId, subTitle, subLib, license, sourceUrl)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseMultiChoice(
|
||||
element: JsonObject,
|
||||
taskId: String,
|
||||
title: String,
|
||||
library: String,
|
||||
license: String,
|
||||
sourceUrl: String?,
|
||||
): ParsedH5PTask? {
|
||||
val questionRaw =
|
||||
element["question"]?.jsonPrimitive?.contentOrNull
|
||||
?: element["text"]?.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
|
||||
?: element["text"]?.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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
package com.oliver.kidio.backend.ingestion
|
||||
|
||||
const val DEFAULT_LIBRARY: String = "H5P.MultiChoice"
|
||||
const val DEFAULT_TITLE: String = "H5P Task"
|
||||
|
||||
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 = DEFAULT_LICENSE,
|
||||
val sourceUrl: String? = null,
|
||||
)
|
||||
|
|
@ -1,388 +0,0 @@
|
|||
package com.oliver.kidio.backend.ingestion
|
||||
|
||||
import kotlinx.serialization.json.*
|
||||
import java.net.URI
|
||||
import java.net.URLEncoder
|
||||
import java.net.http.HttpClient
|
||||
import java.net.http.HttpRequest
|
||||
import java.net.http.HttpResponse
|
||||
import java.time.Duration
|
||||
|
||||
class ZumApiClient(
|
||||
private val httpClient: HttpClient =
|
||||
HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.build(),
|
||||
) {
|
||||
private val json =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
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") || lower.contains("heimat") -> "Sachunterricht"
|
||||
lower.contains("englisch") || lower.contains("english") -> "Englisch"
|
||||
lower.contains("bio") -> "Biologie"
|
||||
lower.contains("geo") || lower.contains("erdkunde") -> "Geographie"
|
||||
lower.contains("gesch") -> "Geschichte"
|
||||
lower.contains("chemie") -> "Chemie"
|
||||
lower.contains("physik") -> "Physik"
|
||||
lower.contains("musik") -> "Musik"
|
||||
lower.contains("kunst") -> "Kunst"
|
||||
lower.contains("informatik") -> "Informatik"
|
||||
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
|
||||
9 -> 14 to 15
|
||||
10 -> 15 to 16
|
||||
11, 12, 13 -> 16 to 18
|
||||
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", "Mozilla/5.0 (compatible; KidioBot/1.0; +https://kidio.oliver.com)")
|
||||
.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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches live OER items from apps.zum.de via search and Drupal settings extraction.
|
||||
* Skips items whose IDs are already present in existingIds.
|
||||
* Falls back to curated items if remote catalog is unreachable or returns 0 matching interactive items.
|
||||
*/
|
||||
fun fetchLiveOerItems(
|
||||
subject: String? = null,
|
||||
limit: Int = 50,
|
||||
maxPages: Int = 15,
|
||||
existingIds: Set<String> = emptySet(),
|
||||
): List<ZumOerItem> {
|
||||
val results = mutableListOf<ZumOerItem>()
|
||||
val seenAppIds = mutableSetOf<String>()
|
||||
|
||||
println("[ZUM-Apps Live] Starte Live-Abfrage auf apps.zum.de (Fach: ${subject ?: "Alle Fächer"}, Limit: $limit)...")
|
||||
|
||||
for (page in 0 until maxPages) {
|
||||
if (results.size >= limit) break
|
||||
processPage(page, subject, limit, existingIds, seenAppIds, results)
|
||||
}
|
||||
|
||||
if (results.isEmpty()) {
|
||||
println("[ZUM-Apps Live] Keine neuen Live-Inhalte gefunden oder Seite nicht erreichbar. Nutze kuratierte OER-Fallback-Daten.")
|
||||
return getCuratedOerItems(subject = subject, limit = limit)
|
||||
}
|
||||
|
||||
println("[ZUM-Apps Live] ${results.size} neue interaktive Aufgaben erfolgreich von apps.zum.de geladen.")
|
||||
return results
|
||||
}
|
||||
|
||||
private fun processPage(
|
||||
page: Int,
|
||||
subject: String?,
|
||||
limit: Int,
|
||||
existingIds: Set<String>,
|
||||
seenAppIds: MutableSet<String>,
|
||||
results: MutableList<ZumOerItem>,
|
||||
) {
|
||||
val searchUrl = buildSearchUrl(subject, page)
|
||||
val html = fetchString(searchUrl)
|
||||
|
||||
if (html == null) {
|
||||
println("[ZUM-Apps Live] Warnung: Seite $page konnte nicht geladen werden ($searchUrl)")
|
||||
return
|
||||
}
|
||||
|
||||
val appIds = extractNewAppIds(html, existingIds, seenAppIds)
|
||||
|
||||
if (appIds.isEmpty()) {
|
||||
println("[ZUM-Apps Live] Seite $page enthält keine neuen Aufgaben. Blättere weiter...")
|
||||
return
|
||||
}
|
||||
|
||||
for (appId in appIds) {
|
||||
if (results.size >= limit) break
|
||||
seenAppIds.add(appId)
|
||||
|
||||
val item = fetchAppDetails(appId, subject)
|
||||
if (item != null) {
|
||||
results.add(item)
|
||||
logNewItem(appId, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildSearchUrl(
|
||||
subject: String?,
|
||||
page: Int,
|
||||
): String {
|
||||
return if (subject.isNullOrBlank()) {
|
||||
"https://apps.zum.de/apps?page=$page"
|
||||
} else {
|
||||
val encoded = URLEncoder.encode(subject, "UTF-8")
|
||||
"https://apps.zum.de/apps?keys=$encoded&page=$page"
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractNewAppIds(
|
||||
html: String,
|
||||
existingIds: Set<String>,
|
||||
seenAppIds: Set<String>,
|
||||
): List<String> {
|
||||
return Regex("""href=["']/apps/(\d+)["']""")
|
||||
.findAll(html)
|
||||
.map { it.groupValues[1] }
|
||||
.distinct()
|
||||
.filter { appId ->
|
||||
val fullId = "zum-$appId"
|
||||
!seenAppIds.contains(appId) && !existingIds.contains(fullId) && !existingIds.contains(appId)
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun logNewItem(
|
||||
appId: String,
|
||||
item: ZumOerItem,
|
||||
) {
|
||||
val lib = item.library ?: "H5P"
|
||||
val gr = item.grade ?: "k.A."
|
||||
println("[ZUM-Apps Live] Neue App #$appId: \"${item.title}\" ($lib) [${item.subject}, Kl. $gr]")
|
||||
}
|
||||
|
||||
private fun fetchAppDetails(
|
||||
appId: String,
|
||||
fallbackSubject: String?,
|
||||
): ZumOerItem? {
|
||||
val appUrl = "https://apps.zum.de/apps/$appId"
|
||||
val html = fetchString(appUrl) ?: return null
|
||||
|
||||
val settingsMatch = Regex("""data-drupal-selector="drupal-settings-json">([^<]+)<""").find(html) ?: return null
|
||||
val jsonStr = settingsMatch.groupValues[1]
|
||||
|
||||
val drupalSettings =
|
||||
try {
|
||||
json.parseToJsonElement(jsonStr).jsonObject
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
|
||||
val h5pContents =
|
||||
drupalSettings["h5p"]?.jsonObject
|
||||
?.get("H5PIntegration")?.jsonObject
|
||||
?.get("contents")?.jsonObject
|
||||
?: return null
|
||||
|
||||
val contentObj =
|
||||
h5pContents["cid-$appId"]?.jsonObject
|
||||
?: h5pContents.values.firstOrNull()?.jsonObject
|
||||
?: return null
|
||||
|
||||
val library = contentObj["library"]?.jsonPrimitive?.contentOrNull ?: ""
|
||||
if (!library.contains("MultiChoice", ignoreCase = true) &&
|
||||
!library.contains("TrueFalse", ignoreCase = true) &&
|
||||
!library.contains("QuestionSet", ignoreCase = true)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
val jsonContent = contentObj["jsonContent"]?.jsonPrimitive?.contentOrNull ?: return null
|
||||
val metadata = contentObj["metadata"]?.jsonObject
|
||||
val title =
|
||||
metadata?.get("title")?.jsonPrimitive?.contentOrNull
|
||||
?: Regex("""<title>(.*?)(?:\|.*)?</title>""").find(html)?.groupValues?.get(1)?.trim()
|
||||
?: "ZUM App #$appId"
|
||||
|
||||
val rawLicense = metadata?.get("license")?.jsonPrimitive?.contentOrNull
|
||||
val license =
|
||||
when {
|
||||
rawLicense == "U" || rawLicense == null || rawLicense.isBlank() -> DEFAULT_LICENSE
|
||||
rawLicense.contains("CC", ignoreCase = true) -> rawLicense
|
||||
else -> "CC BY-SA 4.0 ($rawLicense)"
|
||||
}
|
||||
|
||||
val exportUrl = contentObj["exportUrl"]?.jsonPrimitive?.contentOrNull ?: appUrl
|
||||
|
||||
// Breitgefächerter Abruf aller HTML-Linktexte (Kategorien, Schulstufen, Tags)
|
||||
val tags =
|
||||
Regex("""<a[^>]*>([^<]+)</a>""")
|
||||
.findAll(html)
|
||||
.map { it.groupValues[1].trim() }
|
||||
.filter { it.isNotBlank() && it.length < 60 }
|
||||
.toList()
|
||||
|
||||
val subject = inferSubject(title, tags, fallbackSubject)
|
||||
val grade = inferGrade(title, html, tags)
|
||||
|
||||
return ZumOerItem(
|
||||
id = "zum-$appId",
|
||||
title = title,
|
||||
subject = subject,
|
||||
grade = grade,
|
||||
downloadUrl = exportUrl,
|
||||
license = license,
|
||||
library = library,
|
||||
rawJsonContent = jsonContent,
|
||||
)
|
||||
}
|
||||
|
||||
private fun inferSubject(
|
||||
title: String,
|
||||
tags: List<String>,
|
||||
fallbackSubject: String?,
|
||||
): String {
|
||||
for (tag in tags) {
|
||||
val mapped = mapSubject(tag)
|
||||
if (mapped != tag ||
|
||||
tag.equals("Mathematik", ignoreCase = true) ||
|
||||
tag.equals("Deutsch", ignoreCase = true) ||
|
||||
tag.equals("Biologie", ignoreCase = true) ||
|
||||
tag.equals("Englisch", ignoreCase = true)
|
||||
) {
|
||||
return mapped
|
||||
}
|
||||
}
|
||||
if (!fallbackSubject.isNullOrBlank()) {
|
||||
return mapSubject(fallbackSubject)
|
||||
}
|
||||
return mapSubject(title)
|
||||
}
|
||||
|
||||
private fun inferGrade(
|
||||
title: String,
|
||||
html: String,
|
||||
tags: List<String>,
|
||||
): Int? {
|
||||
val primaryText = (tags + title).joinToString(" ")
|
||||
|
||||
// 1. Muster: "3. Klasse", "3. Schuljahr", "3. Kl."
|
||||
Regex("""\b(\d+)\.\s*(?:Klasse|Schuljahr|Kl)\b""", RegexOption.IGNORE_CASE)
|
||||
.find(primaryText)?.groupValues?.get(1)?.toIntOrNull()?.let { return it }
|
||||
|
||||
// 2. Muster: "Klasse 3", "Kl. 3", "Schuljahr 3"
|
||||
Regex("""\b(?:Klasse|Schuljahr|Kl\.)\s*(\d+)\b""", RegexOption.IGNORE_CASE)
|
||||
.find(primaryText)?.groupValues?.get(1)?.toIntOrNull()?.let { return it }
|
||||
|
||||
// 3. Muster: "Klasse 3-4", "Klasse 3/4", "3.-4. Klasse"
|
||||
Regex("""\b(?:Klasse|Kl\.)?\s*(\d+)\s*[-/]\s*(\d+)\b""", RegexOption.IGNORE_CASE)
|
||||
.find(primaryText)?.groupValues?.get(1)?.toIntOrNull()?.let { return it }
|
||||
|
||||
// 4. Fallback: Gesamten HTML-Text der Seite nach Klassenangaben absuchen
|
||||
Regex("""\b(\d+)\.\s*(?:Klasse|Schuljahr|Kl)\b""", RegexOption.IGNORE_CASE)
|
||||
.find(html)?.groupValues?.get(1)?.toIntOrNull()?.let { return it }
|
||||
|
||||
Regex("""\b(?:Klasse|Schuljahr|Kl\.)\s*(\d+)\b""", RegexOption.IGNORE_CASE)
|
||||
.find(html)?.groupValues?.get(1)?.toIntOrNull()?.let { return it }
|
||||
|
||||
// 5. Fallback: Schulstufen-Schlüsselwörter
|
||||
val combinedLower = (primaryText + " " + html).lowercase()
|
||||
if (combinedLower.contains("primarstufe") || combinedLower.contains("grundschule")) return 3
|
||||
val isSek1 =
|
||||
combinedLower.contains("sekundarstufe i") ||
|
||||
combinedLower.contains("sek i") ||
|
||||
combinedLower.contains("realschule") ||
|
||||
combinedLower.contains("hauptschule")
|
||||
if (isSek1) return 6
|
||||
|
||||
val isSek2 =
|
||||
combinedLower.contains("sekundarstufe ii") ||
|
||||
combinedLower.contains("sek ii") ||
|
||||
combinedLower.contains("gymnasium") ||
|
||||
combinedLower.contains("abitur")
|
||||
if (isSek2) return 10
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun fetchString(url: String): String? {
|
||||
return try {
|
||||
val request =
|
||||
HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.timeout(Duration.ofSeconds(12))
|
||||
.header("User-Agent", "Mozilla/5.0 (compatible; KidioBot/1.0; +https://kidio.oliver.com)")
|
||||
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
.header("Accept-Language", "de-DE,de;q=0.9,en;q=0.8")
|
||||
.GET()
|
||||
.build()
|
||||
|
||||
val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(Charsets.UTF_8))
|
||||
if (response.statusCode() == 200) {
|
||||
response.body()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns curated ZUM/OER sample metadata items from resources, filterable by subject.
|
||||
*/
|
||||
fun getCuratedOerItems(
|
||||
subject: String? = null,
|
||||
limit: Int = 20,
|
||||
): List<ZumOerItem> {
|
||||
val allItems: List<ZumOerItem> =
|
||||
try {
|
||||
val inputStream = this::class.java.getResourceAsStream("/fallback_oer_items.json")
|
||||
if (inputStream != null) {
|
||||
val jsonText = inputStream.bufferedReader().use { it.readText() }
|
||||
json.decodeFromString(jsonText)
|
||||
} else {
|
||||
println("WARNUNG: fallback_oer_items.json wurde nicht gefunden!")
|
||||
emptyList()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("WARNUNG: Fehler beim Laden der Fallback-Daten: ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
package com.oliver.kidio.backend.ingestion
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
const val DEFAULT_LICENSE: String = "CC BY-SA 4.0"
|
||||
|
||||
@Serializable
|
||||
data class ZumOerItem(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val subject: String,
|
||||
val grade: Int? = null,
|
||||
val downloadUrl: String,
|
||||
val license: String = DEFAULT_LICENSE,
|
||||
val library: String? = null,
|
||||
val rawJsonContent: String? = null,
|
||||
)
|
||||
|
|
@ -1,27 +1,97 @@
|
|||
package com.oliver.kidio.backend.plugins
|
||||
|
||||
import com.oliver.kidio.backend.*
|
||||
import com.oliver.kidio.backend.domain.repository.KidioRepository
|
||||
import com.oliver.kidio.backend.routing.*
|
||||
import com.oliver.kidio.backend.security.JwtService
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.plugins.openapi.*
|
||||
import io.ktor.server.auth.authenticate
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
|
||||
fun Application.configureRouting(
|
||||
repository: KidioRepository,
|
||||
jwtService: JwtService,
|
||||
) {
|
||||
fun Application.configureRouting(repository: KidioRepository, jwtService : JwtService) {
|
||||
routing {
|
||||
openAPI(path = "openapi")
|
||||
|
||||
// Health-Check
|
||||
get("/api/v1/health") {
|
||||
call.respond(mapOf("status" to "OK", "message" to "Kidio Backend läuft!"))
|
||||
}
|
||||
|
||||
authRoutes(repository, jwtService)
|
||||
taskRoutes(repository)
|
||||
screentimeRoutes(repository)
|
||||
parentRoutes(repository)
|
||||
post ("/api/v1/auth/login" ) {
|
||||
val loginRequest = call.receive<LoginRequest>()
|
||||
|
||||
val token = jwtService.generateToken(userId = loginRequest.userId)
|
||||
call.respond(mapOf("token" to token))
|
||||
}
|
||||
|
||||
authenticate("auth-jwt") {
|
||||
|
||||
// 1. Alle verfügbaren Aufgaben abrufen
|
||||
get("/api/v1/tasks") {
|
||||
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 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("default_user")
|
||||
val newSeconds = currentScreentime + (reward * 60)
|
||||
repository.updateScreentime("default_user", 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!"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Bildschirmzeit synchronisieren
|
||||
post("/api/v1/screentime/sync") {
|
||||
val request = call.receive<SyncRequest>()
|
||||
|
||||
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 = newSeconds,
|
||||
isBlocked = newSeconds <= 0
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import io.ktor.server.auth.jwt.*
|
|||
/**
|
||||
* Repräsentiert den authentifizierten Benutzer im Request-Kontext.
|
||||
*/
|
||||
data class UserPrincipal(val userId: String)
|
||||
data class UserPrincipal(val userId: String) : Principal
|
||||
|
||||
fun Application.configureSecurity(jwtService: JwtService) {
|
||||
install(Authentication) {
|
||||
|
|
|
|||
|
|
@ -1,93 +0,0 @@
|
|||
package com.oliver.kidio.backend.routing
|
||||
|
||||
import com.oliver.kidio.backend.domain.models.AuthResponse
|
||||
import com.oliver.kidio.backend.domain.models.LoginRequest
|
||||
import com.oliver.kidio.backend.domain.models.RegisterRequest
|
||||
import com.oliver.kidio.backend.domain.models.User
|
||||
import com.oliver.kidio.backend.domain.repository.KidioRepository
|
||||
import com.oliver.kidio.backend.security.JwtService
|
||||
import com.oliver.kidio.backend.security.isValidPassword
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import org.mindrot.jbcrypt.BCrypt
|
||||
import java.util.UUID
|
||||
|
||||
fun Route.authRoutes(
|
||||
repository: KidioRepository,
|
||||
jwtService: JwtService,
|
||||
) {
|
||||
post("/api/v1/auth/register") {
|
||||
val registerRequest = call.receive<RegisterRequest>()
|
||||
if (registerRequest.email.isBlank() || registerRequest.passwordPlain.isBlank()) {
|
||||
call.respond(HttpStatusCode.BadRequest, "Bitte füllen Sie alle Felder aus")
|
||||
return@post
|
||||
}
|
||||
if (!isValidPassword(registerRequest.passwordPlain)) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
"Das Passwort muss mindestens 8 Zeichen lang sein, " +
|
||||
"mindestens einen Großbuchstaben und ein Sonderzeichen enthalten.",
|
||||
)
|
||||
return@post
|
||||
}
|
||||
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")
|
||||
return@post
|
||||
}
|
||||
|
||||
val userId = UUID.randomUUID().toString()
|
||||
val passwordHash = BCrypt.hashpw(registerRequest.passwordPlain, BCrypt.gensalt())
|
||||
|
||||
repository.registerUser(
|
||||
User(
|
||||
userId,
|
||||
registerRequest.email,
|
||||
passwordHash,
|
||||
registerRequest.familyName,
|
||||
registerRequest.childAge,
|
||||
registerRequest.parentPin,
|
||||
),
|
||||
)
|
||||
|
||||
val token = jwtService.generateToken(userId)
|
||||
|
||||
call.respond(
|
||||
HttpStatusCode.Created,
|
||||
mapOf(
|
||||
"token" to token,
|
||||
"userId" to userId,
|
||||
"message" to "Registrierung erfolgreich",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
post("/api/v1/auth/login") {
|
||||
val loginRequest = call.receive<LoginRequest>()
|
||||
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)) {
|
||||
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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
package com.oliver.kidio.backend.routing
|
||||
|
||||
import com.oliver.kidio.backend.domain.models.VerifyPinRequest
|
||||
import com.oliver.kidio.backend.domain.models.VerifyPinResponse
|
||||
import com.oliver.kidio.backend.domain.repository.KidioRepository
|
||||
import com.oliver.kidio.backend.plugins.UserPrincipal
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.auth.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
|
||||
fun Route.parentRoutes(repository: KidioRepository) {
|
||||
authenticate("auth-jwt") {
|
||||
post("/api/v1/parent/verify-pin") {
|
||||
val request = call.receive<VerifyPinRequest>()
|
||||
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
|
||||
}
|
||||
|
||||
if (request.pin.length != 4 || !request.pin.all { it.isDigit() }) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
VerifyPinResponse(success = false, message = "Die PIN muss aus genau 4 Ziffern bestehen."),
|
||||
)
|
||||
return@post
|
||||
}
|
||||
|
||||
val user = repository.findById(userId)
|
||||
if (user == null) {
|
||||
call.respond(
|
||||
HttpStatusCode.NotFound,
|
||||
VerifyPinResponse(success = false, message = "Benutzer nicht gefunden."),
|
||||
)
|
||||
return@post
|
||||
}
|
||||
|
||||
if (user.parentPin == request.pin) {
|
||||
call.respond(
|
||||
HttpStatusCode.OK,
|
||||
VerifyPinResponse(success = true, message = "PIN korrekt. Zugriff gewährt."),
|
||||
)
|
||||
} else {
|
||||
call.respond(
|
||||
HttpStatusCode.Unauthorized,
|
||||
VerifyPinResponse(success = false, message = "Falsche PIN."),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
package com.oliver.kidio.backend.routing
|
||||
|
||||
import com.oliver.kidio.backend.domain.models.SyncRequest
|
||||
import com.oliver.kidio.backend.domain.models.SyncResponse
|
||||
import com.oliver.kidio.backend.domain.repository.KidioRepository
|
||||
import com.oliver.kidio.backend.plugins.UserPrincipal
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.auth.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
|
||||
fun Route.screentimeRoutes(repository: KidioRepository) {
|
||||
authenticate("auth-jwt") {
|
||||
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 remainingSeconds = repository.getScreentime(userid)
|
||||
call.respond(
|
||||
HttpStatusCode.OK,
|
||||
SyncResponse(
|
||||
remainingSeconds = remainingSeconds,
|
||||
isBlocked = remainingSeconds <= 0,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
post("/api/v1/screentime/sync") {
|
||||
val request = call.receive<SyncRequest>()
|
||||
|
||||
val principal = call.principal<UserPrincipal>()
|
||||
val userId = principal?.userId ?: request.userId
|
||||
|
||||
val currentSeconds = repository.getScreentime(userId)
|
||||
var newSeconds = currentSeconds - request.usedSecondsSinceLastSync
|
||||
if (newSeconds < 0) newSeconds = 0
|
||||
|
||||
repository.updateScreentime(userId, newSeconds)
|
||||
|
||||
call.respond(
|
||||
SyncResponse(
|
||||
remainingSeconds = newSeconds,
|
||||
isBlocked = newSeconds <= 0,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
package com.oliver.kidio.backend.routing
|
||||
|
||||
import com.oliver.kidio.backend.domain.models.TaskVerifyRequest
|
||||
import com.oliver.kidio.backend.domain.models.TaskVerifyResponse
|
||||
import com.oliver.kidio.backend.domain.repository.KidioRepository
|
||||
import com.oliver.kidio.backend.plugins.UserPrincipal
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.auth.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
|
||||
fun Route.taskRoutes(repository: KidioRepository) {
|
||||
authenticate("auth-jwt") {
|
||||
get("/api/v1/tasks") {
|
||||
val principal = call.principal<UserPrincipal>()
|
||||
val userId = principal?.userId
|
||||
|
||||
val subject = call.request.queryParameters["subject"]
|
||||
|
||||
val queryAge = call.request.queryParameters["age"]?.toIntOrNull()
|
||||
val userAge = userId?.let { repository.getUserById(it)?.childAge }
|
||||
val effectiveAge = queryAge ?: userAge
|
||||
|
||||
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 10
|
||||
|
||||
val tasks = repository.getTasks(subject = subject, age = effectiveAge, limit = limit)
|
||||
call.respond(tasks)
|
||||
}
|
||||
|
||||
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!",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,10 +9,10 @@ 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),
|
||||
private val expirationInMs: Long = 36_000_000 // 10 Stunden
|
||||
) {
|
||||
val verifier: JWTVerifier =
|
||||
JWT
|
||||
|
||||
val verifier: JWTVerifier = JWT
|
||||
.require(Algorithm.HMAC256(secret))
|
||||
.withAudience(audience)
|
||||
.withIssuer(issuer)
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
package com.oliver.kidio.backend.security
|
||||
|
||||
fun isValidPassword(password: String): Boolean {
|
||||
if (password.length < 8) return false
|
||||
|
||||
val hasUppercase = password.any { it.isUpperCase() }
|
||||
val hasEmoji =
|
||||
password.any { char ->
|
||||
val type = Character.getType(char).toByte()
|
||||
type == Character.SURROGATE ||
|
||||
type == Character.OTHER_SYMBOL ||
|
||||
char.code in 0x1F600..0x1F64F ||
|
||||
char.code in 0x1F300..0x1F5FF ||
|
||||
char.code in 0x1F680..0x1F6FF ||
|
||||
char.code in 0x2600..0x26FF
|
||||
}
|
||||
|
||||
val hasSpecialChar = password.any { !it.isLetterOrDigit() && !hasEmoji }
|
||||
|
||||
return hasUppercase && hasSpecialChar && !hasEmoji
|
||||
}
|
||||
|
|
@ -1,13 +1,12 @@
|
|||
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.json
|
||||
import io.ktor.server.application.Application
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.plugins.contentnegotiation.*
|
||||
|
||||
fun Application.module() {
|
||||
// Datenbank initialisieren
|
||||
|
|
@ -26,6 +25,6 @@ fun Application.module() {
|
|||
|
||||
configureRouting(
|
||||
repository = repository,
|
||||
jwtService = jwtService,
|
||||
jwtService = jwtService
|
||||
)
|
||||
}
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
ktor:
|
||||
deployment:
|
||||
port: 8081
|
||||
port: 8080
|
||||
application:
|
||||
modules:
|
||||
- MainKt.module
|
||||
|
||||
storage:
|
||||
driverClassName: "org.postgresql.Driver"
|
||||
host: "localhost"
|
||||
host: "82.165.11.162"
|
||||
port: "5432"
|
||||
database: "kidio_db"
|
||||
username: "kidio_admin"
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
[
|
||||
{
|
||||
"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} ] }"
|
||||
},
|
||||
{
|
||||
"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} ] }"
|
||||
},
|
||||
{
|
||||
"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 }"
|
||||
},
|
||||
{
|
||||
"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} ] }"
|
||||
},
|
||||
{
|
||||
"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} ] }"
|
||||
},
|
||||
{
|
||||
"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} ] }"
|
||||
},
|
||||
{
|
||||
"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} ] }"
|
||||
},
|
||||
{
|
||||
"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} ] }"
|
||||
}
|
||||
]
|
||||
115
src/test/kotlin/ServerTest.kt
Normal file
115
src/test/kotlin/ServerTest.kt
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
package com.oliver.kidio.backend
|
||||
|
||||
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 com.oliver.kidio.backend.plugins.configureSecurity
|
||||
import com.oliver.kidio.backend.security.JwtService
|
||||
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 health endpoint`() = testApplication {
|
||||
application {
|
||||
install(ServerContentNegotiation) {
|
||||
json()
|
||||
}
|
||||
val jwtService = JwtService()
|
||||
configureSecurity(jwtService)
|
||||
configureRouting(InMemoryKidioRepository(), jwtService)
|
||||
}
|
||||
|
||||
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 {
|
||||
val jwtService = JwtService()
|
||||
application {
|
||||
install(ServerContentNegotiation) {
|
||||
json()
|
||||
}
|
||||
configureSecurity(jwtService)
|
||||
configureRouting(InMemoryKidioRepository(), jwtService)
|
||||
}
|
||||
|
||||
val token = jwtService.generateToken("default_user")
|
||||
|
||||
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 (with Auth header)
|
||||
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 (with Auth header)
|
||||
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)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test screentime sync`() = testApplication {
|
||||
val jwtService = JwtService()
|
||||
application {
|
||||
install(ServerContentNegotiation) {
|
||||
json()
|
||||
}
|
||||
configureSecurity(jwtService) // <-- Added configureSecurity
|
||||
configureRouting(InMemoryKidioRepository(), jwtService)
|
||||
}
|
||||
|
||||
val token = jwtService.generateToken("default_user")
|
||||
|
||||
// Sync first time
|
||||
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)
|
||||
|
||||
// Sync and block
|
||||
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":2000}""")
|
||||
}
|
||||
assertEquals(HttpStatusCode.OK, syncResponse2.status)
|
||||
val syncJson2 = Json.decodeFromString<SyncResponse>(syncResponse2.bodyAsText())
|
||||
assertEquals(0, syncJson2.remainingSeconds)
|
||||
assertTrue(syncJson2.isBlocked)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
package com.oliver.kidio.backend.com.oliver.kidio.backend.ingestion
|
||||
|
||||
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 parse QuestionSet with multiple questions`() {
|
||||
val json =
|
||||
"""
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"library": "H5P.MultiChoice 1.16",
|
||||
"params": {
|
||||
"question": "<p>Wie viel ist 5 + 5?</p>",
|
||||
"answers": [
|
||||
{"text": "10", "correct": true},
|
||||
{"text": "11", "correct": false}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"library": "H5P.TrueFalse 1.8",
|
||||
"params": {
|
||||
"question": "<p>Wasser kocht bei 100 Grad Celsius.</p>",
|
||||
"correct": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val parsedList = H5PParser.parseAllTasks(json, library = "H5P.QuestionSet", taskId = "qs-1", title = "Mini Quiz")
|
||||
assertEquals(2, parsedList.size)
|
||||
|
||||
val q1 = parsedList[0]
|
||||
assertEquals("qs-1-q1", q1.id)
|
||||
assertEquals("Wie viel ist 5 + 5?", q1.question)
|
||||
assertEquals("10", q1.correctAnswer)
|
||||
|
||||
val q2 = parsedList[1]
|
||||
assertEquals("qs-1-q2", q2.id)
|
||||
assertEquals("Wasser kocht bei 100 Grad Celsius.", q2.question)
|
||||
assertEquals("Wahr", q2.correctAnswer)
|
||||
}
|
||||
|
||||
@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())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
package com.oliver.kidio.backend.routing
|
||||
|
||||
import com.oliver.kidio.backend.domain.models.AuthResponse
|
||||
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.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.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation
|
||||
|
||||
class AuthRoutesTest {
|
||||
@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"
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
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(
|
||||
"""
|
||||
{
|
||||
"email": "test@family.de",
|
||||
"passwordPlain": "StrengGeheim1!"
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
assertEquals(HttpStatusCode.OK, loginResponse.status)
|
||||
val loginJson = Json.decodeFromString<AuthResponse>(loginResponse.bodyAsText())
|
||||
assertNotNull(loginJson.token)
|
||||
assertEquals("Familie Schmidt", loginJson.familyName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test registration rejects invalid passwords`() =
|
||||
testApplication {
|
||||
val jwtService = JwtService()
|
||||
val repository = InMemoryKidioRepository()
|
||||
|
||||
application {
|
||||
install(ServerContentNegotiation) {
|
||||
json()
|
||||
}
|
||||
configureSecurity(jwtService)
|
||||
configureRouting(repository, jwtService)
|
||||
}
|
||||
|
||||
val invalidPasswords =
|
||||
listOf(
|
||||
"Kurz1!",
|
||||
"keingrossbuch1!",
|
||||
"KeinSonder12",
|
||||
"Passwort123!😊",
|
||||
)
|
||||
|
||||
for (password in invalidPasswords) {
|
||||
val response =
|
||||
client.post("/api/v1/auth/register") {
|
||||
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
|
||||
setBody(
|
||||
"""
|
||||
{
|
||||
"email": "test_${System.currentTimeMillis()}@family.de",
|
||||
"passwordPlain": "$password",
|
||||
"familyName": "Familie Schmidt",
|
||||
"childAge": 8,
|
||||
"parentPin": "4321"
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
assertEquals(
|
||||
HttpStatusCode.BadRequest,
|
||||
response.status,
|
||||
"Passwort '$password' hätte abgelehnt werden müssen!",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
package com.oliver.kidio.backend.routing
|
||||
|
||||
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.get
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
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 kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation
|
||||
|
||||
class HealthRouteTest {
|
||||
@Test
|
||||
fun `test health endpoint`() =
|
||||
testApplication {
|
||||
val jwtService = JwtService()
|
||||
val repository = InMemoryKidioRepository()
|
||||
|
||||
application {
|
||||
install(ServerContentNegotiation) {
|
||||
json()
|
||||
}
|
||||
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!"))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
package com.oliver.kidio.backend.routing
|
||||
|
||||
import com.oliver.kidio.backend.domain.models.SyncResponse
|
||||
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.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.assertTrue
|
||||
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation
|
||||
|
||||
class ScreentimeRoutesTest {
|
||||
@Test
|
||||
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 screentime sync deducts time repeatedly`() =
|
||||
testApplication {
|
||||
val jwtService = JwtService()
|
||||
val repository = InMemoryKidioRepository()
|
||||
|
||||
application {
|
||||
install(ServerContentNegotiation) {
|
||||
json()
|
||||
}
|
||||
configureSecurity(jwtService)
|
||||
configureRouting(repository, jwtService)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// 2. Erneuter Sync: Weitere 300 Sekunden abziehen (1600 -> 1300)
|
||||
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}""")
|
||||
}
|
||||
assertEquals(HttpStatusCode.OK, syncResponse3.status)
|
||||
val syncJson3 = Json.decodeFromString<SyncResponse>(syncResponse3.bodyAsText())
|
||||
assertEquals(0, syncJson3.remainingSeconds)
|
||||
assertTrue(syncJson3.isBlocked)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,388 +0,0 @@
|
|||
package com.oliver.kidio.backend.routing
|
||||
|
||||
import com.oliver.kidio.backend.domain.models.AuthResponse
|
||||
import com.oliver.kidio.backend.domain.models.ImportedTask
|
||||
import com.oliver.kidio.backend.domain.models.SyncResponse
|
||||
import com.oliver.kidio.backend.domain.models.Task
|
||||
import com.oliver.kidio.backend.domain.models.TaskVerifyResponse
|
||||
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.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.coroutines.runBlocking
|
||||
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
|
||||
|
||||
class ServerTest {
|
||||
@Test
|
||||
fun `test health endpoint`() =
|
||||
testApplication {
|
||||
val jwtService = JwtService()
|
||||
val repository = InMemoryKidioRepository()
|
||||
|
||||
runBlocking {
|
||||
repository.upsertTask(
|
||||
ImportedTask(
|
||||
id = "t1",
|
||||
externalSource = "manual",
|
||||
subject = "Mathematik",
|
||||
minAge = 6,
|
||||
maxAge = 10,
|
||||
taskType = "MULTIPLE_CHOICE",
|
||||
question = "Was ist 12 + 15?",
|
||||
options = listOf("25", "27", "30", "22"),
|
||||
correctAnswer = "27",
|
||||
rewardMinutes = 10,
|
||||
license = "CC BY-SA 4.0",
|
||||
sourceUrl = "local",
|
||||
),
|
||||
)
|
||||
repository.upsertTask(
|
||||
ImportedTask(
|
||||
id = "t2",
|
||||
externalSource = "manual",
|
||||
subject = "Mathematik",
|
||||
minAge = 7,
|
||||
maxAge = 12,
|
||||
taskType = "MULTIPLE_CHOICE",
|
||||
question = "Was ist 8 x 7?",
|
||||
options = listOf("54", "56", "64", "48"),
|
||||
correctAnswer = "56",
|
||||
rewardMinutes = 15,
|
||||
license = "CC BY-SA 4.0",
|
||||
sourceUrl = "local",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
application {
|
||||
install(ServerContentNegotiation) {
|
||||
json()
|
||||
}
|
||||
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!"))
|
||||
}
|
||||
|
||||
@Test
|
||||
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()
|
||||
}
|
||||
configureSecurity(jwtService)
|
||||
configureRouting(repository, jwtService)
|
||||
}
|
||||
|
||||
// Ohne Token muss 401 Unauthorized kommen
|
||||
val tasksResponse = client.get("/api/v1/tasks")
|
||||
assertEquals(HttpStatusCode.Unauthorized, tasksResponse.status)
|
||||
}
|
||||
|
||||
@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"))
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test registration rejects invalid passwords`() =
|
||||
testApplication {
|
||||
val jwtService = JwtService()
|
||||
val repository = InMemoryKidioRepository()
|
||||
|
||||
application {
|
||||
install(ServerContentNegotiation) {
|
||||
json()
|
||||
}
|
||||
configureSecurity(jwtService)
|
||||
configureRouting(repository, jwtService)
|
||||
}
|
||||
|
||||
val invalidPasswords =
|
||||
listOf(
|
||||
"Kurz1!",
|
||||
"keingrossbuch1!",
|
||||
"KeinSonder12",
|
||||
"Passwort123!😊",
|
||||
)
|
||||
|
||||
for (password in invalidPasswords) {
|
||||
val response =
|
||||
client.post("/api/v1/auth/register") {
|
||||
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
|
||||
setBody(
|
||||
"""{
|
||||
"email": "test_${System.currentTimeMillis()}@family.de",
|
||||
"passwordPlain": "$password",
|
||||
"familyName": "Familie Schmidt",
|
||||
"childAge": 8,
|
||||
"parentPin": "4321"
|
||||
}""",
|
||||
)
|
||||
}
|
||||
assertEquals(
|
||||
HttpStatusCode.BadRequest,
|
||||
response.status,
|
||||
"Passwort '$password' hätte abgelehnt werden müssen!",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test tasks endpoint and verify with jwt`() =
|
||||
testApplication {
|
||||
val jwtService = JwtService()
|
||||
val repository = InMemoryKidioRepository()
|
||||
|
||||
runBlocking {
|
||||
repository.upsertTask(
|
||||
ImportedTask(
|
||||
id = "t1",
|
||||
externalSource = "manual",
|
||||
subject = "Mathematik",
|
||||
minAge = 6,
|
||||
maxAge = 10,
|
||||
taskType = "MULTIPLE_CHOICE",
|
||||
question = "Was ist 12 + 15?",
|
||||
options = listOf("25", "27", "30", "22"),
|
||||
correctAnswer = "27",
|
||||
rewardMinutes = 10,
|
||||
license = "CC BY-SA 4.0",
|
||||
sourceUrl = "local",
|
||||
),
|
||||
)
|
||||
repository.upsertTask(
|
||||
ImportedTask(
|
||||
id = "t2",
|
||||
externalSource = "manual",
|
||||
subject = "Mathematik",
|
||||
minAge = 7,
|
||||
maxAge = 12,
|
||||
taskType = "MULTIPLE_CHOICE",
|
||||
question = "Was ist 8 x 7?",
|
||||
options = listOf("54", "56", "64", "48"),
|
||||
correctAnswer = "56",
|
||||
rewardMinutes = 15,
|
||||
license = "CC BY-SA 4.0",
|
||||
sourceUrl = "local",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
application {
|
||||
install(ServerContentNegotiation) {
|
||||
json()
|
||||
}
|
||||
configureSecurity(jwtService)
|
||||
configureRouting(repository, jwtService)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// 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")
|
||||
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)
|
||||
|
||||
// 6. 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)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test screentime sync deducts time repeatedly`() =
|
||||
testApplication {
|
||||
val jwtService = JwtService()
|
||||
val repository = InMemoryKidioRepository()
|
||||
|
||||
application {
|
||||
install(ServerContentNegotiation) {
|
||||
json()
|
||||
}
|
||||
configureSecurity(jwtService)
|
||||
configureRouting(repository, jwtService)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// 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}""")
|
||||
}
|
||||
assertEquals(HttpStatusCode.OK, syncResponse3.status)
|
||||
val syncJson3 = Json.decodeFromString<SyncResponse>(syncResponse3.bodyAsText())
|
||||
assertEquals(0, syncJson3.remainingSeconds)
|
||||
assertTrue(syncJson3.isBlocked)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
package com.oliver.kidio.backend.routing
|
||||
|
||||
import com.oliver.kidio.backend.domain.models.ImportedTask
|
||||
import com.oliver.kidio.backend.domain.models.Task
|
||||
import com.oliver.kidio.backend.domain.models.TaskVerifyResponse
|
||||
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.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.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation
|
||||
|
||||
class TaskRoutesTest {
|
||||
@Test
|
||||
fun `test protected endpoints reject unauthorized requests`() =
|
||||
testApplication {
|
||||
val jwtService = JwtService()
|
||||
val repository = InMemoryKidioRepository()
|
||||
|
||||
application {
|
||||
install(ServerContentNegotiation) {
|
||||
json()
|
||||
}
|
||||
configureSecurity(jwtService)
|
||||
configureRouting(repository, jwtService)
|
||||
}
|
||||
|
||||
// Ohne Token muss 401 Unauthorized kommen
|
||||
val tasksResponse = client.get("/api/v1/tasks")
|
||||
assertEquals(HttpStatusCode.Unauthorized, tasksResponse.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test tasks endpoint and verify with jwt`() =
|
||||
testApplication {
|
||||
val jwtService = JwtService()
|
||||
val repository = InMemoryKidioRepository()
|
||||
|
||||
runBlocking {
|
||||
repository.upsertTask(
|
||||
ImportedTask(
|
||||
id = "t1",
|
||||
externalSource = "manual",
|
||||
subject = "Mathematik",
|
||||
minAge = 6,
|
||||
maxAge = 10,
|
||||
taskType = "MULTIPLE_CHOICE",
|
||||
question = "Was ist 12 + 15?",
|
||||
options = listOf("25", "27", "30", "22"),
|
||||
correctAnswer = "27",
|
||||
rewardMinutes = 10,
|
||||
license = "CC BY-SA 4.0",
|
||||
sourceUrl = "local",
|
||||
),
|
||||
)
|
||||
repository.upsertTask(
|
||||
ImportedTask(
|
||||
id = "t2",
|
||||
externalSource = "manual",
|
||||
subject = "Mathematik",
|
||||
minAge = 7,
|
||||
maxAge = 12,
|
||||
taskType = "MULTIPLE_CHOICE",
|
||||
question = "Was ist 8 x 7?",
|
||||
options = listOf("54", "56", "64", "48"),
|
||||
correctAnswer = "56",
|
||||
rewardMinutes = 15,
|
||||
license = "CC BY-SA 4.0",
|
||||
sourceUrl = "local",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
application {
|
||||
install(ServerContentNegotiation) {
|
||||
json()
|
||||
}
|
||||
configureSecurity(jwtService)
|
||||
configureRouting(repository, jwtService)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// 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")
|
||||
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)
|
||||
|
||||
// 6. 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
package com.oliver.kidio.backend.security
|
||||
|
||||
import com.auth0.jwt.JWT
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
class JwtServiceTest {
|
||||
@Test
|
||||
fun `test generate token contains correct user id and claims`() {
|
||||
// Wir nutzen ein Dummy-Secret für den Test
|
||||
val service = JwtService(secret = "test-secret-für-unit-tests")
|
||||
val token = service.generateToken("user-123")
|
||||
|
||||
assertNotNull(token)
|
||||
|
||||
// Token decodieren und prüfen, ob unsere Daten korrekt verpackt wurden
|
||||
val decoded = JWT.decode(token)
|
||||
val userIdClaim = decoded.getClaim("userId").asString()
|
||||
|
||||
assertEquals("user-123", userIdClaim)
|
||||
assertEquals("kidio-users", decoded.audience.firstOrNull())
|
||||
assertEquals("https://kidio.oliver.com/", decoded.issuer)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
package com.oliver.kidio.backend.security
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class PasswordUtilsTest {
|
||||
@Test
|
||||
fun `test valid passwords`() {
|
||||
assertTrue(isValidPassword("StrengGeheim1!"))
|
||||
assertTrue(isValidPassword("Kidio2026_Secure"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test password too short`() {
|
||||
assertFalse(isValidPassword("Kurz1!"))
|
||||
assertFalse(isValidPassword("Geheim!"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test missing uppercase`() {
|
||||
assertFalse(isValidPassword("kleingeschrieben1!"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test missing special character`() {
|
||||
assertFalse(isValidPassword("KeinSonderzeichen123"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test password with emojis is rejected`() {
|
||||
assertFalse(isValidPassword("Passwort123!😊"))
|
||||
assertFalse(isValidPassword("🚀Geheim123!"))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue