fix: Merge-Konflikte in Routing und ServerTest gelöst
Some checks failed
CI Pipeline / lint-and-test (pull_request) Failing after 1m14s
Some checks failed
CI Pipeline / lint-and-test (pull_request) Failing after 1m14s
This commit is contained in:
commit
a8f30d7ef2
4
.editorconfig
Normal file
4
.editorconfig
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
[*.{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
Normal file
33
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
name: CI Pipeline
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main" ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ "main" ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-and-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Code auschecken
|
||||||
|
uses: https://github.com/actions/checkout@v4 # <-- Vollständige URL oder github/ Prefix
|
||||||
|
|
||||||
|
- name: Java 21 einrichten
|
||||||
|
uses: https://github.com/actions/setup-java@v4 # <-- Vollständige URL oder github/ Prefix
|
||||||
|
with:
|
||||||
|
distribution: 'temurin'
|
||||||
|
java-version: '21'
|
||||||
|
|
||||||
|
- name: Gradle Rechte vergeben
|
||||||
|
run: chmod +x gradlew
|
||||||
|
|
||||||
|
- name: 1. Code-Style (Linter) prüfen
|
||||||
|
run: ./gradlew ktlintCheck
|
||||||
|
|
||||||
|
- name: 2. Tests ausführen
|
||||||
|
run: ./gradlew test
|
||||||
|
|
||||||
|
- name: 3. Backend bauen
|
||||||
|
run: ./gradlew build -x test --max-workers=2 -Dorg.gradle.jvmargs="-Xmx1024m"
|
||||||
598
README.md
598
README.md
|
|
@ -1,171 +1,519 @@
|
||||||
# Kidio Backend
|
# Kidio Backend
|
||||||
|
|
||||||
Das Kidio-Backend ist eine gamifizierte Lösung zur Verwaltung von Bildschirmzeit für Kinder. Die Anwendung basiert auf dem **Ktor**-Framework in Kotlin und kombiniert **PostgreSQL** für die Persistenz mit **Redis** als Caching-Layer für schnelle Zugriffszeiten.
|
Das **Kidio-Backend** ist eine gamifizierte Lösung zur Verwaltung von Bildschirmzeit für Kinder.
|
||||||
|
|
||||||
Kinder können durch das Lösen von Lernaufgaben (z. B. Mathe-Aufgaben) zusätzliche Bildschirmzeit erwerben, während das System ihre verbrauchte Bildschirmzeit synchronisiert und bei Ablauf sperrt.
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
Hier ist eine Übersicht der implementierten Kernfunktionen des Kidio-Backends:
|
| Feature | Beschreibung |
|
||||||
|
| :--------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Name | 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 und Belohnungen in Minuten. |
|
||||||
| **JWT Authentifizierung** | Sichere API-Endpunkte für authentifizierte Benutzer über JSON Web Tokens (JWT). |
|
| **Antwort-Verifizierung** | Überprüfung der vom Kind ausgewählten Antworten. Bei korrekter Antwort wird die Belohnung direkt auf die verbleibende Bildschirmzeit aufgerechnet. |
|
||||||
| **Lernaufgaben (Tasks)** | Abrufen von interaktiven Lernaufgaben mit vordefinierten Antwortmöglichkeiten und Belohnungen (z. B. Minuten). |
|
| **Bildschirmzeit-Synchronisation** | Laufender Datenabgleich der verbrauchten Bildschirmzeit. Sinkt die verbleibende Zeit auf oder unter null, wird der Status automatisch auf `isBlocked = true` gesetzt. |
|
||||||
| **Antwort-Verifizierung** | Überprüfung der vom Kind ausgewählten Antworten. Bei korrekter Antwort wird die Belohnung direkt auf die verbleibende Bildschirmzeit aufgerechnet. |
|
| **Zwei-Ebenen-Speicher** | PostgreSQL als persistente Datenbank, kombiniert mit Redis-Caching für schnelle Screentime-Abfragen. |
|
||||||
| **Bildschirmzeit-Synchronisation** | Laufender Datenabgleich der genutzten Bildschirmzeit. Sinkt die Zeit auf oder unter Null, wird der Status automatisch auf `isBlocked = true` gesetzt. |
|
| **Fallback-Mechanismus** | Ist Redis nicht erreichbar, fällt das System automatisch und transparent auf PostgreSQL zurück. |
|
||||||
| **Zwei-Ebenen-Speicher** | Datenhaltung in **PostgreSQL** (via JetBrains Exposed ORM) kombiniert mit **Redis-Caching** (via Jedis) für optimale Performance bei der Screentime-Abfrage. |
|
|
||||||
| **Fallback-Mechanismus** | Ist die Redis-Instanz nicht erreichbar, fällt das System automatisch und transparent auf PostgreSQL zurück. |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Technologiestack
|
## Technologiestack
|
||||||
|
|
||||||
- **Framework**: [Ktor](https://ktor.io/) (Kotlin-basiertes asynchrones Web-Framework)
|
* **Framework:** Ktor
|
||||||
- **Datenbank-ORM**: [JetBrains Exposed](https://github.com/JetBrains/Exposed)
|
* **Programmiersprache:** Kotlin
|
||||||
- **Persistenz**: PostgreSQL
|
* **Datenbank-ORM:** JetBrains Exposed
|
||||||
- **Caching**: Redis (via Jedis Client)
|
* **Persistenz:** PostgreSQL
|
||||||
- **Sicherheit & Auth**: JWT (Java JWT von auth0)
|
* **Caching:** Redis via Jedis
|
||||||
- **Serialization**: Kotlinx Serialization (JSON)
|
* **Authentifizierung:** JWT via Java JWT (Auth0)
|
||||||
- **Testing**: Ktor Server Testing, JUnit
|
* **Serialisierung:** Kotlinx Serialization
|
||||||
|
* **Code Quality / Linter:** KtLint
|
||||||
|
* **Testing:** Ktor Server Testing, JUnit
|
||||||
|
* **CI/CD:** Forgejo Actions (Gitea / Act Runner)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API-Endpunkte
|
## API-Endpunkte
|
||||||
|
|
||||||
Alle Endpunkte befinden sich unter dem Präfix `/api/v1`.
|
Alle API-Endpunkte befinden sich unter dem Präfix:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/api/v1
|
||||||
|
```
|
||||||
|
|
||||||
### Öffentliche Endpunkte
|
### Öffentliche Endpunkte
|
||||||
|
|
||||||
#### 1. Health-Check
|
#### 1. Health-Check
|
||||||
- **Methode**: `GET`
|
|
||||||
- **Pfad**: `/api/v1/health`
|
|
||||||
- **Beschreibung**: Prüft, ob der Backend-Server ordnungsgemäß läuft.
|
|
||||||
- **Antwort**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "OK",
|
|
||||||
"message": "Kidio Backend läuft!"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. Benutzer-Login (Auth-Token abholen)
|
**Methode:** `GET`
|
||||||
- **Methode**: `POST`
|
|
||||||
- **Pfad**: `/api/v1/auth/login`
|
|
||||||
- **Request-Body**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"userId": "default_user"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- **Antwort**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"token": "eyJhbGciOiJIUzI1NiIsIn..."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Geschützte Endpunkte (Erfordern JWT im `Authorization: Bearer <Token>` Header)
|
**Pfad:**
|
||||||
|
|
||||||
#### 3. Alle verfügbaren Aufgaben abrufen
|
```text
|
||||||
- **Methode**: `GET`
|
/api/v1/health
|
||||||
- **Pfad**: `/api/v1/tasks`
|
```
|
||||||
- **Beschreibung**: Liefert eine Liste aller verfügbaren Lernaufgaben.
|
|
||||||
- **Antwort**:
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"id": "t1",
|
|
||||||
"question": "Was ist 12 + 15?",
|
|
||||||
"options": ["25", "27", "30", "22"],
|
|
||||||
"rewardMinutes": 10
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. Antwort überprüfen & Belohnung verbuchen
|
**Beschreibung:**
|
||||||
- **Methode**: `POST`
|
|
||||||
- **Pfad**: `/api/v1/tasks/verify`
|
Prüft, ob der Backend-Server ordnungsgemäß läuft.
|
||||||
- **Request-Body**:
|
|
||||||
```json
|
**Antwort:**
|
||||||
{
|
|
||||||
"taskId": "t1",
|
```json
|
||||||
"selectedAnswer": "27"
|
{
|
||||||
}
|
"status": "OK",
|
||||||
```
|
"message": "Kidio Backend läuft!"
|
||||||
- **Antwort (Erfolg)**:
|
}
|
||||||
```json
|
```
|
||||||
{
|
|
||||||
"isCorrect": true,
|
---
|
||||||
"earnedMinutes": 10,
|
|
||||||
"message": "Super gemacht! Du hast 10 Minuten gewonnen."
|
#### 2. Benutzer-Registrierung
|
||||||
}
|
|
||||||
```
|
**Methode:** `POST`
|
||||||
- **Antwort (Fehler)**:
|
|
||||||
```json
|
**Pfad:**
|
||||||
{
|
|
||||||
"isCorrect": false,
|
```text
|
||||||
"earnedMinutes": 0,
|
/api/v1/auth/register
|
||||||
"message": "Schade, das war leider falsch. Versuche es nochmal!"
|
```
|
||||||
}
|
|
||||||
```
|
**Request-Body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": "test@family.de",
|
||||||
|
"passwordPlain": "strengGeheim1",
|
||||||
|
"familyName": "Familie Schmidt",
|
||||||
|
"childAge": 8,
|
||||||
|
"parentPin": "4321"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Antwort – `201 Created`:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "eyJhbGciOiJIUzI1NiIsIn...",
|
||||||
|
"familyName": "Familie Schmidt"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 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...",
|
||||||
|
"familyName": "Familie Schmidt"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
**Antwort:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"remainingSeconds": 1800,
|
||||||
|
"isBlocked": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
#### 5. Bildschirmzeit synchronisieren
|
#### 5. Bildschirmzeit synchronisieren
|
||||||
- **Methode**: `POST`
|
|
||||||
- **Pfad**: `/api/v1/screentime/sync`
|
**Methode:** `POST`
|
||||||
- **Beschreibung**: Zieht die seit dem letzten Abgleich verbrauchten Sekunden von der verfügbaren Bildschirmzeit ab.
|
|
||||||
- **Request-Body**:
|
**Pfad:**
|
||||||
```json
|
|
||||||
|
```text
|
||||||
|
/api/v1/screentime/sync
|
||||||
|
```
|
||||||
|
|
||||||
|
**Beschreibung:**
|
||||||
|
|
||||||
|
Zieht die seit dem letzten Abgleich verbrauchten Sekunden von der verfügbaren Bildschirmzeit ab.
|
||||||
|
|
||||||
|
**Request-Body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"userId": "default_user",
|
||||||
|
"usedSecondsSinceLastSync": 200
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Antwort:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"remainingSeconds": 1600,
|
||||||
|
"isBlocked": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 6. Alle verfügbaren Aufgaben abrufen
|
||||||
|
|
||||||
|
**Methode:** `GET`
|
||||||
|
|
||||||
|
**Pfad:**
|
||||||
|
|
||||||
|
```text
|
||||||
|
/api/v1/tasks
|
||||||
|
```
|
||||||
|
|
||||||
|
**Beschreibung:**
|
||||||
|
|
||||||
|
Liefert eine Liste aller verfügbaren Lernaufgaben.
|
||||||
|
|
||||||
|
**Antwort:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
{
|
{
|
||||||
"userId": "default_user",
|
"id": "t1",
|
||||||
"usedSecondsSinceLastSync": 200
|
"question": "Was ist 12 + 15?",
|
||||||
|
"options": [
|
||||||
|
"25",
|
||||||
|
"27",
|
||||||
|
"30",
|
||||||
|
"22"
|
||||||
|
],
|
||||||
|
"rewardMinutes": 10
|
||||||
}
|
}
|
||||||
```
|
]
|
||||||
- **Antwort**:
|
```
|
||||||
```json
|
|
||||||
{
|
---
|
||||||
"remainingSeconds": 1600,
|
|
||||||
"isBlocked": false
|
#### 7. Antwort überprüfen und Belohnung verbuchen
|
||||||
}
|
|
||||||
```
|
**Methode:** `POST`
|
||||||
|
|
||||||
|
**Pfad:**
|
||||||
|
|
||||||
|
```text
|
||||||
|
/api/v1/tasks/verify
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request-Body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"taskId": "t1",
|
||||||
|
"selectedAnswer": "27"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Antwort – korrekte Lösung:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"isCorrect": true,
|
||||||
|
"earnedMinutes": 10,
|
||||||
|
"message": "Super gemacht! Du hast 10 Minuten gewonnen."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Antwort – falsche Lösung:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"isCorrect": false,
|
||||||
|
"earnedMinutes": 0,
|
||||||
|
"message": "Schade, das war leider falsch. Versuche es nochmal!"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Projektkonfiguration und Umgebungsvariablen
|
## Projektkonfiguration und Umgebungsvariablen
|
||||||
|
|
||||||
Das Projekt wird über die Datei `src/main/resources/application.yaml` konfiguriert. Folgende Umgebungsvariablen können zur flexiblen Docker- oder Deployment-Konfiguration übergeben werden:
|
Die Anwendung wird über folgende Konfigurationsdatei konfiguriert:
|
||||||
|
|
||||||
| Variable | Beschreibung | Standardwert |
|
```text
|
||||||
|----------|--------------|--------------|
|
src/main/resources/application.yaml
|
||||||
| `DB_HOST` | Hostname der PostgreSQL-Datenbank | `localhost` / aus `application.yaml` |
|
```
|
||||||
| `DB_PORT` | Port der PostgreSQL-Datenbank | `5432` / aus `application.yaml` |
|
|
||||||
| `DB_NAME` | Name der PostgreSQL-Datenbank | `kidio_db` |
|
Für eine flexible Konfiguration in lokalen Entwicklungsumgebungen, Docker-Containern oder Deployments können folgende Umgebungsvariablen gesetzt werden:
|
||||||
| `DB_USER` | PostgreSQL-Benutzername | `kidio_admin` |
|
|
||||||
| `DB_PASSWORD` | PostgreSQL-Passwort | `learning` |
|
| Variable | Beschreibung | Standardwert |
|
||||||
| `REDIS_HOST` | Hostname der Redis-Instanz | `localhost` |
|
| :------------ | :----------------------------------------- | :------------------------------- |
|
||||||
| `REDIS_PORT` | Port der Redis-Instanz | `6379` |
|
| `DB_HOST` | Hostname der PostgreSQL-Datenbank | `localhost` |
|
||||||
| `JWT_SECRET` | Geheimer Schlüssel zur Signierung der JWTs | `super-geheimes-secret-fuer-dev` |
|
| `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, Bauen & Testen
|
## Lokale Entwicklung
|
||||||
|
|
||||||
Zur Ausführung des Projekts werden die folgenden Gradle-Tasks bereitgestellt:
|
### Voraussetzungen
|
||||||
|
|
||||||
| Task | Beschreibung |
|
Für die lokale Entwicklung werden benötigt:
|
||||||
|------|-------------|
|
|
||||||
| `./gradlew test` | Führt alle automatisierten Unit- und Integrationstests aus (nutzt die InMemory-Repository-Implementierung). |
|
* JDK / Java
|
||||||
| `./gradlew build` | Kompiliert das Projekt und baut das Artefakt. |
|
* Kotlin bzw. Gradle Wrapper
|
||||||
| `./gradlew run` | Startet den Ktor-Entwicklungsserver lokal auf Port `8080`. |
|
* 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
|
```bash
|
||||||
# Tests ausführen
|
|
||||||
./gradlew test
|
|
||||||
|
|
||||||
# Server starten
|
|
||||||
./gradlew run
|
./gradlew run
|
||||||
```
|
```
|
||||||
|
|
||||||
Sobald der Server erfolgreich gestartet ist, ist er unter `http://localhost:8080` erreichbar.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlin.jvm)
|
alias(libs.plugins.kotlin.jvm)
|
||||||
alias(ktorLibs.plugins.ktor)
|
alias(ktorLibs.plugins.ktor)
|
||||||
|
id("org.jlleitschuh.gradle.ktlint") version "12.1.0"
|
||||||
kotlin("plugin.serialization") version "2.0.0"
|
kotlin("plugin.serialization") version "2.0.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,4 +45,4 @@ dependencies {
|
||||||
|
|
||||||
// redis
|
// redis
|
||||||
implementation("redis.clients:jedis:5.1.2")
|
implementation("redis.clients:jedis:5.1.2")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,4 +19,3 @@ dependencyResolutionManagement {
|
||||||
}
|
}
|
||||||
|
|
||||||
rootProject.name = "Kidio_backend"
|
rootProject.name = "Kidio_backend"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import com.oliver.kidio.backend.plugins.configureRouting
|
|
||||||
import com.oliver.kidio.backend.data.database.DatabaseFactory
|
import com.oliver.kidio.backend.data.database.DatabaseFactory
|
||||||
import com.oliver.kidio.backend.data.database.RedisFactory
|
import com.oliver.kidio.backend.data.database.RedisFactory
|
||||||
import com.oliver.kidio.backend.domain.repository.ExposedKidioRepository
|
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.plugins.configureSecurity
|
||||||
import com.oliver.kidio.backend.security.JwtService
|
import com.oliver.kidio.backend.security.JwtService
|
||||||
import io.ktor.serialization.kotlinx.json.*
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
import io.ktor.server.application.*
|
import io.ktor.server.application.Application
|
||||||
import io.ktor.server.plugins.contentnegotiation.*
|
import io.ktor.server.application.install
|
||||||
|
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
|
||||||
fun Application.module() {
|
fun Application.module() {
|
||||||
// Datenbank initialisieren
|
// Datenbank initialisieren
|
||||||
|
|
@ -25,6 +26,6 @@ fun Application.module() {
|
||||||
|
|
||||||
configureRouting(
|
configureRouting(
|
||||||
repository = repository,
|
repository = repository,
|
||||||
jwtService = jwtService
|
jwtService = jwtService,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -22,6 +22,7 @@ object ScreentimeTable : Table("user_screentime") {
|
||||||
|
|
||||||
override val primaryKey = PrimaryKey(userId)
|
override val primaryKey = PrimaryKey(userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
object UserTable : Table("users") {
|
object UserTable : Table("users") {
|
||||||
val id = varchar("id", 36)
|
val id = varchar("id", 36)
|
||||||
val email = varchar("email", 255).uniqueIndex()
|
val email = varchar("email", 255).uniqueIndex()
|
||||||
|
|
@ -30,7 +31,6 @@ object UserTable : Table("users") {
|
||||||
val childAge = integer("child_age")
|
val childAge = integer("child_age")
|
||||||
val parentPin = varchar("parent_pin", 4)
|
val parentPin = varchar("parent_pin", 4)
|
||||||
|
|
||||||
|
|
||||||
override val primaryKey = PrimaryKey(id)
|
override val primaryKey = PrimaryKey(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,12 +46,13 @@ object DatabaseFactory {
|
||||||
|
|
||||||
val jdbcURL = "jdbc:postgresql://$host:$port/$dbName"
|
val jdbcURL = "jdbc:postgresql://$host:$port/$dbName"
|
||||||
|
|
||||||
val database = Database.connect(
|
val database =
|
||||||
url = jdbcURL,
|
Database.connect(
|
||||||
driver = driverClassName,
|
url = jdbcURL,
|
||||||
user = dbUsername,
|
driver = driverClassName,
|
||||||
password = password
|
user = dbUsername,
|
||||||
)
|
password = password,
|
||||||
|
)
|
||||||
|
|
||||||
transaction(database) {
|
transaction(database) {
|
||||||
SchemaUtils.create(TasksTable, ScreentimeTable, UserTable)
|
SchemaUtils.create(TasksTable, ScreentimeTable, UserTable)
|
||||||
|
|
@ -79,7 +80,7 @@ object DatabaseFactory {
|
||||||
it[remainingSeconds] = 1800
|
it[remainingSeconds] = 1800
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (UserTable.selectAll().empty()) {
|
if (UserTable.selectAll().empty()) {
|
||||||
UserTable.insert {
|
UserTable.insert {
|
||||||
it[id] = "default_user"
|
it[id] = "default_user"
|
||||||
|
|
@ -93,6 +94,5 @@ object DatabaseFactory {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun <T> dbQuery(block: suspend () -> T): T =
|
suspend fun <T> dbQuery(block: suspend () -> T): T = newSuspendedTransaction(Dispatchers.IO) { block() }
|
||||||
newSuspendedTransaction(Dispatchers.IO) { block() }
|
}
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.oliver.kidio.backend.data.database
|
package com.oliver.kidio.backend.data.database
|
||||||
import io.ktor.server.config.*
|
import io.ktor.server.config.ApplicationConfig
|
||||||
|
import io.ktor.server.routing.get
|
||||||
import redis.clients.jedis.JedisPool
|
import redis.clients.jedis.JedisPool
|
||||||
import redis.clients.jedis.JedisPoolConfig
|
import redis.clients.jedis.JedisPoolConfig
|
||||||
|
|
||||||
|
|
@ -7,22 +8,28 @@ object RedisFactory {
|
||||||
private var jedisPool: JedisPool? = null
|
private var jedisPool: JedisPool? = null
|
||||||
|
|
||||||
fun init(config: ApplicationConfig) {
|
fun init(config: ApplicationConfig) {
|
||||||
val host = System.getenv("REDIS_HOST")
|
val host =
|
||||||
?: config.propertyOrNull("storage.redisHost")?.getString()
|
System.getenv("REDIS_HOST")
|
||||||
?: "localhost"
|
?: config.propertyOrNull("storage.redisHost")?.getString()
|
||||||
|
?: "localhost"
|
||||||
|
|
||||||
val port = (System.getenv("REDIS_PORT")
|
val port =
|
||||||
?: config.propertyOrNull("storage.redisPort")?.getString()
|
(
|
||||||
?: "6379").toInt()
|
System.getenv("REDIS_PORT")
|
||||||
|
?: config.propertyOrNull("storage.redisPort")?.getString()
|
||||||
|
?: "6379"
|
||||||
|
).toInt()
|
||||||
|
|
||||||
val poolConfig = JedisPoolConfig().apply {
|
val poolConfig =
|
||||||
maxTotal = 32
|
JedisPoolConfig().apply {
|
||||||
maxIdle = 8
|
maxTotal = 32
|
||||||
minIdle = 2
|
maxIdle = 8
|
||||||
}
|
minIdle = 2
|
||||||
|
}
|
||||||
jedisPool = JedisPool(poolConfig, host, port)
|
jedisPool = JedisPool(poolConfig, host, port)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getResource() = jedisPool?.resource
|
fun getResource() =
|
||||||
?: throw IllegalStateException("Redis Verbindungspool ist nicht initzialisiert")
|
jedisPool?.resource
|
||||||
|
?: throw IllegalStateException("Redis Verbindungspool ist nicht initzialisiert")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,14 @@ data class Task(
|
||||||
val id: String,
|
val id: String,
|
||||||
val question: String,
|
val question: String,
|
||||||
val options: List<String>,
|
val options: List<String>,
|
||||||
val rewardMinutes: Int // Wie viel Belohnung es gibt
|
val rewardMinutes: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Anfrage des Kindes, wenn es eine Aufgabe löst
|
// Anfrage des Kindes, wenn es eine Aufgabe löst
|
||||||
@Serializable
|
@Serializable
|
||||||
data class TaskVerifyRequest(
|
data class TaskVerifyRequest(
|
||||||
val taskId: String,
|
val taskId: String,
|
||||||
val selectedAnswer: String
|
val selectedAnswer: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Antwort vom Server nach der Aufgaben-Prüfung
|
// Antwort vom Server nach der Aufgaben-Prüfung
|
||||||
|
|
@ -23,20 +23,20 @@ data class TaskVerifyRequest(
|
||||||
data class TaskVerifyResponse(
|
data class TaskVerifyResponse(
|
||||||
val isCorrect: Boolean,
|
val isCorrect: Boolean,
|
||||||
val earnedMinutes: Int,
|
val earnedMinutes: Int,
|
||||||
val message: String
|
val message: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Datenabgleich für die Bildschirmzeit (Sync)
|
// Datenabgleich für die Bildschirmzeit (Sync)
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SyncRequest(
|
data class SyncRequest(
|
||||||
val userId: String,
|
val userId: String,
|
||||||
val usedSecondsSinceLastSync: Int
|
val usedSecondsSinceLastSync: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SyncResponse(
|
data class SyncResponse(
|
||||||
val remainingSeconds: Int,
|
val remainingSeconds: Int,
|
||||||
val isBlocked: Boolean
|
val isBlocked: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- NEU: AUTHENTIFIZIERUNG ---
|
// --- NEU: AUTHENTIFIZIERUNG ---
|
||||||
|
|
@ -47,21 +47,21 @@ data class RegisterRequest(
|
||||||
val passwordPlain: String,
|
val passwordPlain: String,
|
||||||
val familyName: String,
|
val familyName: String,
|
||||||
val childAge: Int,
|
val childAge: Int,
|
||||||
val parentPin: String
|
val parentPin: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class LoginRequest(
|
data class LoginRequest(
|
||||||
val email: String,
|
val email: String,
|
||||||
val passwordPlain: String
|
val passwordPlain: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class AuthResponse(
|
data class AuthResponse(
|
||||||
val token: String, // Der digitale "Ausweis" (JWT)
|
val token: String,
|
||||||
val userId: String, // Wichtig für die App, um Aufgaben zuzuordnen
|
val userId: String,
|
||||||
val familyName: String, // Für "Hallo Familie Müller!"
|
val familyName: String,
|
||||||
val childAge: Int
|
val childAge: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Interne Backend-Klasse (Domain Model).
|
// Interne Backend-Klasse (Domain Model).
|
||||||
|
|
@ -71,5 +71,5 @@ data class User(
|
||||||
val passwordHash: String,
|
val passwordHash: String,
|
||||||
val familyName: String,
|
val familyName: String,
|
||||||
val childAge: Int,
|
val childAge: Int,
|
||||||
val parentPin: String
|
val parentPin: String,
|
||||||
)
|
)
|
||||||
|
|
@ -12,53 +12,68 @@ import org.mindrot.jbcrypt.BCrypt
|
||||||
|
|
||||||
interface KidioRepository {
|
interface KidioRepository {
|
||||||
suspend fun getAllTasks(): List<Task>
|
suspend fun getAllTasks(): List<Task>
|
||||||
|
|
||||||
suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>?
|
suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>?
|
||||||
|
|
||||||
suspend fun getScreentime(userId: String): Int
|
suspend fun getScreentime(userId: String): Int
|
||||||
suspend fun updateScreentime(userId: String, seconds: Int)
|
|
||||||
|
suspend fun updateScreentime(
|
||||||
|
userId: String,
|
||||||
|
seconds: Int,
|
||||||
|
)
|
||||||
|
|
||||||
suspend fun findByEmail(email: String): User?
|
suspend fun findByEmail(email: String): User?
|
||||||
suspend fun verifyPassword(password: String, passwordHash: String): Boolean
|
|
||||||
|
suspend fun verifyPassword(
|
||||||
|
password: String,
|
||||||
|
passwordHash: String,
|
||||||
|
): Boolean
|
||||||
|
|
||||||
suspend fun registerUser(user: User): Boolean
|
suspend fun registerUser(user: User): Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
class ExposedKidioRepository : KidioRepository {
|
class ExposedKidioRepository : KidioRepository {
|
||||||
override suspend fun getAllTasks(): List<Task> = DatabaseFactory.dbQuery {
|
override suspend fun getAllTasks(): List<Task> =
|
||||||
TasksTable.selectAll().map {
|
DatabaseFactory.dbQuery {
|
||||||
Task(
|
TasksTable.selectAll().map {
|
||||||
id = it[TasksTable.id],
|
Task(
|
||||||
question = it[TasksTable.question],
|
id = it[TasksTable.id],
|
||||||
options = it[TasksTable.options].split(",").map { opt -> opt.trim() },
|
question = it[TasksTable.question],
|
||||||
rewardMinutes = it[TasksTable.rewardMinutes]
|
options = it[TasksTable.options].split(",").map { opt -> opt.trim() },
|
||||||
)
|
rewardMinutes = it[TasksTable.rewardMinutes],
|
||||||
}
|
)
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
override suspend fun registerUser(user: User): Boolean =
|
||||||
TasksTable.selectAll()
|
DatabaseFactory.dbQuery {
|
||||||
.where { TasksTable.id eq taskId }
|
try {
|
||||||
.map { it[TasksTable.correctAnswer] to it[TasksTable.rewardMinutes] }
|
UserTable.insert {
|
||||||
.singleOrNull()
|
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 {
|
override suspend fun getScreentime(userId: String): Int {
|
||||||
val redisKey = "screentime:$userId"
|
val redisKey = "screentime:$userId"
|
||||||
|
|
@ -75,12 +90,13 @@ class ExposedKidioRepository : KidioRepository {
|
||||||
println("WARNUNG: Konnte nicht aus Redis lesen (Postgres-Fallback genutzt): ${e.message}")
|
println("WARNUNG: Konnte nicht aus Redis lesen (Postgres-Fallback genutzt): ${e.message}")
|
||||||
}
|
}
|
||||||
|
|
||||||
val dbSeconds = DatabaseFactory.dbQuery {
|
val dbSeconds =
|
||||||
ScreentimeTable.selectAll()
|
DatabaseFactory.dbQuery {
|
||||||
.where { ScreentimeTable.userId eq userId }
|
ScreentimeTable.selectAll()
|
||||||
.map { it[ScreentimeTable.remainingSeconds] }
|
.where { ScreentimeTable.userId eq userId }
|
||||||
.singleOrNull() ?: 1800
|
.map { it[ScreentimeTable.remainingSeconds] }
|
||||||
}
|
.singleOrNull() ?: 1800
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
RedisFactory.getResource().use { jedis ->
|
RedisFactory.getResource().use { jedis ->
|
||||||
|
|
@ -93,11 +109,15 @@ class ExposedKidioRepository : KidioRepository {
|
||||||
return dbSeconds
|
return dbSeconds
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateScreentime(userId: String, seconds: Int) {
|
override suspend fun updateScreentime(
|
||||||
|
userId: String,
|
||||||
|
seconds: Int,
|
||||||
|
) {
|
||||||
DatabaseFactory.dbQuery {
|
DatabaseFactory.dbQuery {
|
||||||
val rowsUpdated = ScreentimeTable.update({ ScreentimeTable.userId eq userId }) {
|
val rowsUpdated =
|
||||||
it[remainingSeconds] = seconds
|
ScreentimeTable.update({ ScreentimeTable.userId eq userId }) {
|
||||||
}
|
it[remainingSeconds] = seconds
|
||||||
|
}
|
||||||
if (rowsUpdated == 0) {
|
if (rowsUpdated == 0) {
|
||||||
ScreentimeTable.insert {
|
ScreentimeTable.insert {
|
||||||
it[ScreentimeTable.userId] = userId
|
it[ScreentimeTable.userId] = userId
|
||||||
|
|
@ -116,39 +136,46 @@ class ExposedKidioRepository : KidioRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun findByEmail(email: String): User? = DatabaseFactory.dbQuery {
|
override suspend fun findByEmail(email: String): User? =
|
||||||
UserTable.selectAll()
|
DatabaseFactory.dbQuery {
|
||||||
.where { UserTable.email eq email }
|
UserTable.selectAll()
|
||||||
.map {
|
.where { UserTable.email eq email }
|
||||||
User(
|
.map {
|
||||||
id = it[UserTable.id],
|
User(
|
||||||
email = it[UserTable.email],
|
id = it[UserTable.id],
|
||||||
passwordHash = it[UserTable.passwordHash],
|
email = it[UserTable.email],
|
||||||
familyName = it[UserTable.familyName],
|
passwordHash = it[UserTable.passwordHash],
|
||||||
childAge = it[UserTable.childAge],
|
familyName = it[UserTable.familyName],
|
||||||
parentPin = it[UserTable.parentPin]
|
childAge = it[UserTable.childAge],
|
||||||
)
|
parentPin = it[UserTable.parentPin],
|
||||||
}
|
)
|
||||||
.singleOrNull()
|
}
|
||||||
}
|
.singleOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun verifyPassword(password: String, passwordHash: String): Boolean {
|
override suspend fun verifyPassword(
|
||||||
|
password: String,
|
||||||
|
passwordHash: String,
|
||||||
|
): Boolean {
|
||||||
return BCrypt.checkpw(password, passwordHash)
|
return BCrypt.checkpw(password, passwordHash)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class InMemoryKidioRepository : KidioRepository {
|
class InMemoryKidioRepository : KidioRepository {
|
||||||
private val tasks = mutableListOf(
|
private val tasks =
|
||||||
Task("t1", "Was ist 12 + 15?", listOf("25", "27", "30", "22"), 10),
|
mutableListOf(
|
||||||
Task("t2", "Was ist 8 x 7?", listOf("54", "56", "64", "48"), 15)
|
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),
|
private val answers =
|
||||||
"t2" to ("56" to 15)
|
mutableMapOf(
|
||||||
)
|
"t1" to ("27" to 10),
|
||||||
private val screentimes = mutableMapOf(
|
"t2" to ("56" to 15),
|
||||||
"default_user" to 1800
|
)
|
||||||
)
|
private val screentimes =
|
||||||
|
mutableMapOf(
|
||||||
|
"default_user" to 1800,
|
||||||
|
)
|
||||||
private val users = mutableListOf<User>()
|
private val users = mutableListOf<User>()
|
||||||
|
|
||||||
override suspend fun getAllTasks(): List<Task> = tasks
|
override suspend fun getAllTasks(): List<Task> = tasks
|
||||||
|
|
@ -157,7 +184,10 @@ class InMemoryKidioRepository : KidioRepository {
|
||||||
|
|
||||||
override suspend fun getScreentime(userId: String): Int = screentimes[userId] ?: 1800
|
override suspend fun getScreentime(userId: String): Int = screentimes[userId] ?: 1800
|
||||||
|
|
||||||
override suspend fun updateScreentime(userId: String, seconds: Int) {
|
override suspend fun updateScreentime(
|
||||||
|
userId: String,
|
||||||
|
seconds: Int,
|
||||||
|
) {
|
||||||
screentimes[userId] = seconds
|
screentimes[userId] = seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,7 +195,10 @@ class InMemoryKidioRepository : KidioRepository {
|
||||||
return users.find { it.email == email }
|
return users.find { it.email == email }
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun verifyPassword(password: String, passwordHash: String): Boolean {
|
override suspend fun verifyPassword(
|
||||||
|
password: String,
|
||||||
|
passwordHash: String,
|
||||||
|
): Boolean {
|
||||||
return BCrypt.checkpw(password, passwordHash)
|
return BCrypt.checkpw(password, passwordHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -174,4 +207,4 @@ class InMemoryKidioRepository : KidioRepository {
|
||||||
screentimes[user.id] = 1800
|
screentimes[user.id] = 1800
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,10 @@ fun isValidPassword(password: String): Boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Application.configureRouting(repository: KidioRepository, jwtService : JwtService) {
|
fun Application.configureRouting(repository: KidioRepository, jwtService : JwtService) {
|
||||||
|
fun Application.configureRouting(
|
||||||
|
repository: KidioRepository,
|
||||||
|
jwtService: JwtService,
|
||||||
|
) {
|
||||||
routing {
|
routing {
|
||||||
post("/api/v1/auth/register") {
|
post("/api/v1/auth/register") {
|
||||||
val registerRequest = call.receive<RegisterRequest>()
|
val registerRequest = call.receive<RegisterRequest>()
|
||||||
|
|
@ -61,8 +65,17 @@ fun Application.configureRouting(repository: KidioRepository, jwtService : JwtSe
|
||||||
val userId = UUID.randomUUID().toString()
|
val userId = UUID.randomUUID().toString()
|
||||||
val passwordHash = BCrypt.hashpw(registerRequest.passwordPlain, BCrypt.gensalt())
|
val passwordHash = BCrypt.hashpw(registerRequest.passwordPlain, BCrypt.gensalt())
|
||||||
|
|
||||||
//datenbank insert hinzufügen
|
// datenbank insert hinzufügen
|
||||||
repository.registerUser(User(userId, registerRequest.email, passwordHash, registerRequest.familyName, registerRequest.childAge, registerRequest.parentPin))
|
repository.registerUser(
|
||||||
|
User(
|
||||||
|
userId,
|
||||||
|
registerRequest.email,
|
||||||
|
passwordHash,
|
||||||
|
registerRequest.familyName,
|
||||||
|
registerRequest.childAge,
|
||||||
|
registerRequest.parentPin,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
val token = jwtService.generateToken(userId)
|
val token = jwtService.generateToken(userId)
|
||||||
|
|
||||||
|
|
@ -71,8 +84,8 @@ fun Application.configureRouting(repository: KidioRepository, jwtService : JwtSe
|
||||||
mapOf(
|
mapOf(
|
||||||
"token" to token,
|
"token" to token,
|
||||||
"userId" to userId,
|
"userId" to userId,
|
||||||
"message" to "Registrierung erfolgreich"
|
"message" to "Registrierung erfolgreich",
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Health-Check
|
// Health-Check
|
||||||
|
|
@ -80,24 +93,27 @@ fun Application.configureRouting(repository: KidioRepository, jwtService : JwtSe
|
||||||
call.respond(mapOf("status" to "OK", "message" to "Kidio Backend läuft!"))
|
call.respond(mapOf("status" to "OK", "message" to "Kidio Backend läuft!"))
|
||||||
}
|
}
|
||||||
|
|
||||||
post ("/api/v1/auth/login" ) {
|
post("/api/v1/auth/login") {
|
||||||
val loginRequest = call.receive<LoginRequest>()
|
val loginRequest = call.receive<LoginRequest>()
|
||||||
if(loginRequest.email.isBlank() || loginRequest.passwordPlain.isBlank()){
|
if (loginRequest.email.isBlank() || loginRequest.passwordPlain.isBlank()) {
|
||||||
call.respond(HttpStatusCode.BadRequest, "Bitte füllen Sie alle Felder aus")
|
call.respond(HttpStatusCode.BadRequest, "Bitte füllen Sie alle Felder aus")
|
||||||
return@post
|
return@post
|
||||||
}
|
}
|
||||||
val getUser = repository.findByEmail(loginRequest.email)
|
val getUser = repository.findByEmail(loginRequest.email)
|
||||||
if (getUser == null ||!repository.verifyPassword(loginRequest.passwordPlain, getUser.passwordHash)){
|
if (getUser == null || !repository.verifyPassword(loginRequest.passwordPlain, getUser.passwordHash)) {
|
||||||
call.respond(HttpStatusCode.Unauthorized, "Ungültige Anmeldedaten")
|
call.respond(HttpStatusCode.Unauthorized, "Ungültige Anmeldedaten")
|
||||||
return@post
|
return@post
|
||||||
}
|
}
|
||||||
val token = jwtService.generateToken(getUser.id)
|
val token = jwtService.generateToken(getUser.id)
|
||||||
call.respond(HttpStatusCode.OK, AuthResponse(
|
call.respond(
|
||||||
token,
|
HttpStatusCode.OK,
|
||||||
userId = getUser.id,
|
AuthResponse(
|
||||||
familyName = getUser.familyName,
|
token,
|
||||||
childAge = getUser.childAge
|
userId = getUser.id,
|
||||||
))
|
familyName = getUser.familyName,
|
||||||
|
childAge = getUser.childAge,
|
||||||
|
),
|
||||||
|
)
|
||||||
return@post
|
return@post
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -114,51 +130,68 @@ fun Application.configureRouting(repository: KidioRepository, jwtService : JwtSe
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Antwort auf eine Aufgabe überprüfen
|
// 2. Antwort auf eine Aufgabe überprüfen
|
||||||
post("/api/v1/tasks/verify") {
|
post("/api/v1/tasks/verify") {
|
||||||
val request = call.receive<TaskVerifyRequest>()
|
val request = call.receive<TaskVerifyRequest>()
|
||||||
val principal = call.principal<UserPrincipal>()
|
val principal = call.principal<UserPrincipal>()
|
||||||
val userId = principal?.userId
|
val userId = principal?.userId
|
||||||
if (userId == null){
|
if (userId == null) {
|
||||||
call.respond(HttpStatusCode.Unauthorized, mapOf("error" to "Ungültiges oder abgelaufenes Token"))
|
call.respond(HttpStatusCode.Unauthorized, mapOf("error" to "Ungültiges oder abgelaufenes Token"))
|
||||||
return@post
|
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!"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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!",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Bildschirmzeit synchronisieren
|
// 3. Bildschirmzeit synchronisieren
|
||||||
post("/api/v1/screentime/sync") {
|
post("/api/v1/screentime/sync") {
|
||||||
|
|
@ -177,8 +210,8 @@ fun Application.configureRouting(repository: KidioRepository, jwtService : JwtSe
|
||||||
call.respond(
|
call.respond(
|
||||||
SyncResponse(
|
SyncResponse(
|
||||||
remainingSeconds = newSeconds,
|
remainingSeconds = newSeconds,
|
||||||
isBlocked = newSeconds <= 0
|
isBlocked = newSeconds <= 0,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,14 @@ class JwtService(
|
||||||
private val secret: String = System.getenv("JWT_SECRET") ?: "super-geheimes-secret-fuer-dev",
|
private val secret: String = System.getenv("JWT_SECRET") ?: "super-geheimes-secret-fuer-dev",
|
||||||
private val issuer: String = "https://kidio.oliver.com/",
|
private val issuer: String = "https://kidio.oliver.com/",
|
||||||
private val audience: String = "kidio-users",
|
private val audience: String = "kidio-users",
|
||||||
private val expirationInMs: Long = java.util.concurrent.TimeUnit.DAYS.toMillis(30) // 30 Tage
|
private val expirationInMs: Long = java.util.concurrent.TimeUnit.DAYS.toMillis(30),
|
||||||
) {
|
) {
|
||||||
|
val verifier: JWTVerifier =
|
||||||
val verifier: JWTVerifier = JWT
|
JWT
|
||||||
.require(Algorithm.HMAC256(secret))
|
.require(Algorithm.HMAC256(secret))
|
||||||
.withAudience(audience)
|
.withAudience(audience)
|
||||||
.withIssuer(issuer)
|
.withIssuer(issuer)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generiert ein JWT-Token für einen bestimmten Benutzer.
|
* Generiert ein JWT-Token für einen bestimmten Benutzer.
|
||||||
|
|
|
||||||
|
|
@ -4,98 +4,140 @@ import com.oliver.kidio.backend.domain.repository.InMemoryKidioRepository
|
||||||
import com.oliver.kidio.backend.plugins.configureRouting
|
import com.oliver.kidio.backend.plugins.configureRouting
|
||||||
import com.oliver.kidio.backend.plugins.configureSecurity
|
import com.oliver.kidio.backend.plugins.configureSecurity
|
||||||
import com.oliver.kidio.backend.security.JwtService
|
import com.oliver.kidio.backend.security.JwtService
|
||||||
import io.ktor.client.request.*
|
import io.ktor.client.request.get
|
||||||
import io.ktor.client.statement.*
|
import io.ktor.client.request.header
|
||||||
import io.ktor.http.*
|
import io.ktor.client.request.post
|
||||||
import io.ktor.serialization.kotlinx.json.*
|
import io.ktor.client.request.setBody
|
||||||
import io.ktor.server.application.*
|
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.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation
|
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation
|
||||||
import io.ktor.server.testing.*
|
|
||||||
import kotlinx.serialization.json.*
|
|
||||||
import kotlin.test.*
|
|
||||||
|
|
||||||
class ServerTest {
|
class ServerTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `test health endpoint`() = testApplication {
|
fun `test health endpoint`() =
|
||||||
val jwtService = JwtService()
|
testApplication {
|
||||||
val repository = InMemoryKidioRepository()
|
val jwtService = JwtService()
|
||||||
|
val repository = InMemoryKidioRepository()
|
||||||
|
|
||||||
application {
|
application {
|
||||||
install(ServerContentNegotiation) {
|
install(ServerContentNegotiation) {
|
||||||
json()
|
json()
|
||||||
|
}
|
||||||
|
configureSecurity(jwtService)
|
||||||
|
configureRouting(repository, jwtService)
|
||||||
}
|
}
|
||||||
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!"))
|
||||||
}
|
}
|
||||||
|
|
||||||
val response = client.get("/api/v1/health")
|
|
||||||
assertEquals(HttpStatusCode.OK, response.status)
|
|
||||||
assertTrue(response.bodyAsText().contains("Kidio Backend läuft!"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `test protected endpoints reject unauthorized requests`() = testApplication {
|
fun `test get screentime returns current status`() =
|
||||||
val jwtService = JwtService()
|
testApplication {
|
||||||
val repository = InMemoryKidioRepository()
|
val jwtService = JwtService()
|
||||||
|
val repository = InMemoryKidioRepository()
|
||||||
|
|
||||||
application {
|
application {
|
||||||
install(ServerContentNegotiation) {
|
install(ServerContentNegotiation) { json() }
|
||||||
json()
|
configureSecurity(jwtService)
|
||||||
|
configureRouting(repository, jwtService)
|
||||||
}
|
}
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ohne Token muss 401 Unauthorized kommen
|
|
||||||
val tasksResponse = client.get("/api/v1/tasks")
|
|
||||||
assertEquals(HttpStatusCode.Unauthorized, tasksResponse.status)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `test registration and login flow`() = testApplication {
|
fun `test protected endpoints reject unauthorized requests`() =
|
||||||
val jwtService = JwtService()
|
testApplication {
|
||||||
val repository = InMemoryKidioRepository()
|
val jwtService = JwtService()
|
||||||
|
val repository = InMemoryKidioRepository()
|
||||||
|
|
||||||
application {
|
application {
|
||||||
install(ServerContentNegotiation) {
|
install(ServerContentNegotiation) {
|
||||||
json()
|
json()
|
||||||
|
}
|
||||||
|
configureSecurity(jwtService)
|
||||||
|
configureRouting(repository, jwtService)
|
||||||
}
|
}
|
||||||
configureSecurity(jwtService)
|
|
||||||
configureRouting(repository, jwtService)
|
// Ohne Token muss 401 Unauthorized kommen
|
||||||
|
val tasksResponse = client.get("/api/v1/tasks")
|
||||||
|
assertEquals(HttpStatusCode.Unauthorized, tasksResponse.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Registrierung
|
@Test
|
||||||
val registerResponse = client.post("/api/v1/auth/register") {
|
fun `test registration and login flow`() =
|
||||||
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
|
testApplication {
|
||||||
setBody(
|
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",
|
"email": "test@family.de",
|
||||||
"passwordPlain": "StrengGeheim1!",
|
"passwordPlain": "strengGeheim1",
|
||||||
"familyName": "Familie Schmidt",
|
"familyName": "Familie Schmidt",
|
||||||
"childAge": 8,
|
"childAge": 8,
|
||||||
"parentPin": "4321"
|
"parentPin": "4321"
|
||||||
}"""
|
}""",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
assertEquals(HttpStatusCode.Created, registerResponse.status)
|
assertEquals(HttpStatusCode.Created, registerResponse.status)
|
||||||
assertTrue(registerResponse.bodyAsText().contains("token"))
|
assertTrue(registerResponse.bodyAsText().contains("token"))
|
||||||
|
|
||||||
// 2. Login mit denselben Daten
|
// 2. Login mit denselben Daten
|
||||||
val loginResponse = client.post("/api/v1/auth/login") {
|
val loginResponse =
|
||||||
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
|
client.post("/api/v1/auth/login") {
|
||||||
setBody(
|
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
|
||||||
"""{
|
setBody(
|
||||||
|
"""{
|
||||||
"email": "test@family.de",
|
"email": "test@family.de",
|
||||||
"passwordPlain": "StrengGeheim1!"
|
"passwordPlain": "StrengGeheim1!"
|
||||||
}"""
|
}""",
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
assertEquals(HttpStatusCode.OK, loginResponse.status)
|
||||||
|
val loginJson = Json.decodeFromString<AuthResponse>(loginResponse.bodyAsText())
|
||||||
|
assertNotNull(loginJson.token)
|
||||||
|
assertEquals("Familie Schmidt", loginJson.familyName)
|
||||||
}
|
}
|
||||||
assertEquals(HttpStatusCode.OK, loginResponse.status)
|
|
||||||
val loginJson = Json.decodeFromString<AuthResponse>(loginResponse.bodyAsText())
|
|
||||||
assertNotNull(loginJson.token)
|
|
||||||
assertEquals("Familie Schmidt", loginJson.familyName)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `test registration rejects invalid passwords`() = testApplication {
|
fun `test registration rejects invalid passwords`() = testApplication {
|
||||||
|
|
@ -139,99 +181,107 @@ class ServerTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `test tasks endpoint and verify with jwt`() = testApplication {
|
fun `test tasks endpoint and verify with jwt`() =
|
||||||
val jwtService = JwtService()
|
testApplication {
|
||||||
val repository = InMemoryKidioRepository()
|
val jwtService = JwtService()
|
||||||
|
val repository = InMemoryKidioRepository()
|
||||||
|
|
||||||
application {
|
application {
|
||||||
install(ServerContentNegotiation) {
|
install(ServerContentNegotiation) {
|
||||||
json()
|
json()
|
||||||
|
}
|
||||||
|
configureSecurity(jwtService)
|
||||||
|
configureRouting(repository, jwtService)
|
||||||
}
|
}
|
||||||
configureSecurity(jwtService)
|
|
||||||
configureRouting(repository, jwtService)
|
|
||||||
}
|
|
||||||
|
|
||||||
val token = jwtService.generateToken("default_user")
|
val token = jwtService.generateToken("default_user")
|
||||||
|
|
||||||
// 1. Get Tasks mit Token
|
// 1. Get Tasks mit Token
|
||||||
val tasksResponse = client.get("/api/v1/tasks") {
|
val tasksResponse =
|
||||||
header(HttpHeaders.Authorization, "Bearer $token")
|
client.get("/api/v1/tasks") {
|
||||||
}
|
header(HttpHeaders.Authorization, "Bearer $token")
|
||||||
assertEquals(HttpStatusCode.OK, tasksResponse.status)
|
}
|
||||||
val tasksJson = Json.decodeFromString<List<Task>>(tasksResponse.bodyAsText())
|
assertEquals(HttpStatusCode.OK, tasksResponse.status)
|
||||||
assertEquals(2, tasksJson.size)
|
val tasksJson = Json.decodeFromString<List<Task>>(tasksResponse.bodyAsText())
|
||||||
assertEquals("t1", tasksJson[0].id)
|
assertEquals(2, tasksJson.size)
|
||||||
assertEquals("Was ist 12 + 15?", tasksJson[0].question)
|
assertEquals("t1", tasksJson[0].id)
|
||||||
|
assertEquals("Was ist 12 + 15?", tasksJson[0].question)
|
||||||
|
|
||||||
// 2. Verify Task correct answer
|
// 2. Verify Task correct answer
|
||||||
val verifyResponse = client.post("/api/v1/tasks/verify") {
|
val verifyResponse =
|
||||||
header(HttpHeaders.Authorization, "Bearer $token")
|
client.post("/api/v1/tasks/verify") {
|
||||||
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
|
header(HttpHeaders.Authorization, "Bearer $token")
|
||||||
setBody("""{"taskId":"t1","selectedAnswer":"27"}""")
|
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
|
||||||
}
|
setBody("""{"taskId":"t1","selectedAnswer":"27"}""")
|
||||||
assertEquals(HttpStatusCode.OK, verifyResponse.status)
|
}
|
||||||
val verifyJson = Json.decodeFromString<TaskVerifyResponse>(verifyResponse.bodyAsText())
|
assertEquals(HttpStatusCode.OK, verifyResponse.status)
|
||||||
assertTrue(verifyJson.isCorrect)
|
val verifyJson = Json.decodeFromString<TaskVerifyResponse>(verifyResponse.bodyAsText())
|
||||||
assertEquals(10, verifyJson.earnedMinutes)
|
assertTrue(verifyJson.isCorrect)
|
||||||
|
assertEquals(10, verifyJson.earnedMinutes)
|
||||||
|
|
||||||
// 3. Verify Task incorrect answer
|
// 3. Verify Task incorrect answer
|
||||||
val verifyWrongResponse = client.post("/api/v1/tasks/verify") {
|
val verifyWrongResponse =
|
||||||
header(HttpHeaders.Authorization, "Bearer $token")
|
client.post("/api/v1/tasks/verify") {
|
||||||
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
|
header(HttpHeaders.Authorization, "Bearer $token")
|
||||||
setBody("""{"taskId":"t1","selectedAnswer":"99"}""")
|
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)
|
||||||
}
|
}
|
||||||
assertEquals(HttpStatusCode.OK, verifyWrongResponse.status)
|
|
||||||
val verifyWrongJson = Json.decodeFromString<TaskVerifyResponse>(verifyWrongResponse.bodyAsText())
|
|
||||||
assertFalse(verifyWrongJson.isCorrect)
|
|
||||||
assertEquals(0, verifyWrongJson.earnedMinutes)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `test screentime sync deducts time repeatedly`() = testApplication {
|
fun `test screentime sync deducts time repeatedly`() =
|
||||||
val jwtService = JwtService()
|
testApplication {
|
||||||
val repository = InMemoryKidioRepository()
|
val jwtService = JwtService()
|
||||||
|
val repository = InMemoryKidioRepository()
|
||||||
|
|
||||||
application {
|
application {
|
||||||
install(ServerContentNegotiation) {
|
install(ServerContentNegotiation) {
|
||||||
json()
|
json()
|
||||||
|
}
|
||||||
|
configureSecurity(jwtService)
|
||||||
|
configureRouting(repository, jwtService)
|
||||||
}
|
}
|
||||||
configureSecurity(jwtService)
|
|
||||||
configureRouting(repository, jwtService)
|
|
||||||
}
|
|
||||||
|
|
||||||
val token = jwtService.generateToken("default_user")
|
val token = jwtService.generateToken("default_user")
|
||||||
|
|
||||||
// 1. Sync: 200 Sekunden abziehen (1800 -> 1600)
|
// 1. Sync: 200 Sekunden abziehen (1800 -> 1600)
|
||||||
val syncResponse1 = client.post("/api/v1/screentime/sync") {
|
val syncResponse1 =
|
||||||
header(HttpHeaders.Authorization, "Bearer $token")
|
client.post("/api/v1/screentime/sync") {
|
||||||
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
|
header(HttpHeaders.Authorization, "Bearer $token")
|
||||||
setBody("""{"userId":"default_user","usedSecondsSinceLastSync":200}""")
|
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(HttpStatusCode.OK, syncResponse1.status)
|
||||||
assertEquals(1600, syncJson1.remainingSeconds)
|
val syncJson1 = Json.decodeFromString<SyncResponse>(syncResponse1.bodyAsText())
|
||||||
assertFalse(syncJson1.isBlocked)
|
assertEquals(1600, syncJson1.remainingSeconds)
|
||||||
|
assertFalse(syncJson1.isBlocked)
|
||||||
|
|
||||||
// 2. Erneuter Sync: Weitere 300 Sekunden abziehen (1600 -> 1300) - darf nicht auf 1800 zurückspringen!
|
// 2. Erneuter Sync: Weitere 300 Sekunden abziehen (1600 -> 1300) - darf nicht auf 1800 zurückspringen!
|
||||||
val syncResponse2 = client.post("/api/v1/screentime/sync") {
|
val syncResponse2 =
|
||||||
header(HttpHeaders.Authorization, "Bearer $token")
|
client.post("/api/v1/screentime/sync") {
|
||||||
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
|
header(HttpHeaders.Authorization, "Bearer $token")
|
||||||
setBody("""{"userId":"default_user","usedSecondsSinceLastSync":300}""")
|
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(HttpStatusCode.OK, syncResponse2.status)
|
||||||
assertEquals(1300, syncJson2.remainingSeconds)
|
val syncJson2 = Json.decodeFromString<SyncResponse>(syncResponse2.bodyAsText())
|
||||||
assertFalse(syncJson2.isBlocked)
|
assertEquals(1300, syncJson2.remainingSeconds)
|
||||||
|
assertFalse(syncJson2.isBlocked)
|
||||||
|
|
||||||
// 3. Sync bis zur Sperre (weitere 1500 Sekunden abziehen -> 0)
|
// 3. Sync bis zur Sperre (weitere 1500 Sekunden abziehen -> 0)
|
||||||
val syncResponse3 = client.post("/api/v1/screentime/sync") {
|
val syncResponse3 =
|
||||||
header(HttpHeaders.Authorization, "Bearer $token")
|
client.post("/api/v1/screentime/sync") {
|
||||||
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
|
header(HttpHeaders.Authorization, "Bearer $token")
|
||||||
setBody("""{"userId":"default_user","usedSecondsSinceLastSync":1500}""")
|
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)
|
||||||
}
|
}
|
||||||
assertEquals(HttpStatusCode.OK, syncResponse3.status)
|
|
||||||
val syncJson3 = Json.decodeFromString<SyncResponse>(syncResponse3.bodyAsText())
|
|
||||||
assertEquals(0, syncJson3.remainingSeconds)
|
|
||||||
assertTrue(syncJson3.isBlocked)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue