From b3a01a4f1635fd05e7d81d3da36506888aff4143 Mon Sep 17 00:00:00 2001 From: aldemirm Date: Wed, 16 Sep 2026 08:21:44 +0200 Subject: [PATCH] - add verify pin - restructuring of the backend architecture --- docs/index.html | 500 +++++++++++++++++- .../backend/data/database/DatabaseFactory.kt | 42 +- .../backend/data/tables/ScreentimeTable.kt | 10 + .../kidio/backend/data/tables/TasksTable.kt | 24 + .../kidio/backend/data/tables/UserTable.kt | 14 + .../kidio/backend/domain/models/AuthModels.kt | 37 ++ .../models/{Models.kt => TaskModels.kt} | 50 +- .../kidio/backend/domain/models/UserModels.kt | 26 + .../repository/ExposedKidioRepository.kt | 242 +++++++++ .../repository/InMemoryKidioRepository.kt | 123 +++++ .../domain/repository/KidioRepository.kt | 348 +----------- .../backend/ingestion/H5PIngestionCli.kt | 4 +- .../oliver/kidio/backend/plugins/Routing.kt | 218 +------- .../kidio/backend/routing/AuthRoutes.kt | 93 ++++ .../kidio/backend/routing/ParentRoutes.kt | 58 ++ .../kidio/backend/routing/ScreentimeRoutes.kt | 53 ++ .../kidio/backend/routing/TaskRoutes.kt | 80 +++ .../kidio/backend/security/PasswordUtils.kt | 21 + .../kidio/backend/ingestion}/H5PParserTest.kt | 2 +- .../kidio/backend/routing/AuthRoutesTest.kt | 126 +++++ .../kidio/backend/routing/HealthRouteTest.kt | 37 ++ .../backend/routing/ScreentimeRoutesTest.kt | 108 ++++ .../kidio/backend/routing}/ServerTest.kt | 6 +- .../kidio/backend/routing/TaskRoutesTest.kt | 120 +++++ .../kidio/backend/security/JwtServiceTest.kt | 25 + .../backend/security/PasswordUtilsTest.kt | 35 ++ 26 files changed, 1738 insertions(+), 664 deletions(-) create mode 100644 src/main/kotlin/com/oliver/kidio/backend/data/tables/ScreentimeTable.kt create mode 100644 src/main/kotlin/com/oliver/kidio/backend/data/tables/TasksTable.kt create mode 100644 src/main/kotlin/com/oliver/kidio/backend/data/tables/UserTable.kt create mode 100644 src/main/kotlin/com/oliver/kidio/backend/domain/models/AuthModels.kt rename src/main/kotlin/com/oliver/kidio/backend/domain/models/{Models.kt => TaskModels.kt} (54%) create mode 100644 src/main/kotlin/com/oliver/kidio/backend/domain/models/UserModels.kt create mode 100644 src/main/kotlin/com/oliver/kidio/backend/domain/repository/ExposedKidioRepository.kt create mode 100644 src/main/kotlin/com/oliver/kidio/backend/domain/repository/InMemoryKidioRepository.kt create mode 100644 src/main/kotlin/com/oliver/kidio/backend/routing/AuthRoutes.kt create mode 100644 src/main/kotlin/com/oliver/kidio/backend/routing/ParentRoutes.kt create mode 100644 src/main/kotlin/com/oliver/kidio/backend/routing/ScreentimeRoutes.kt create mode 100644 src/main/kotlin/com/oliver/kidio/backend/routing/TaskRoutes.kt create mode 100644 src/main/kotlin/com/oliver/kidio/backend/security/PasswordUtils.kt rename src/test/kotlin/{ => com/oliver/kidio/backend/ingestion}/H5PParserTest.kt (98%) create mode 100644 src/test/kotlin/com/oliver/kidio/backend/routing/AuthRoutesTest.kt create mode 100644 src/test/kotlin/com/oliver/kidio/backend/routing/HealthRouteTest.kt create mode 100644 src/test/kotlin/com/oliver/kidio/backend/routing/ScreentimeRoutesTest.kt rename src/test/kotlin/{ => com/oliver/kidio/backend/routing}/ServerTest.kt (97%) create mode 100644 src/test/kotlin/com/oliver/kidio/backend/routing/TaskRoutesTest.kt create mode 100644 src/test/kotlin/com/oliver/kidio/backend/security/JwtServiceTest.kt create mode 100644 src/test/kotlin/com/oliver/kidio/backend/security/PasswordUtilsTest.kt diff --git a/docs/index.html b/docs/index.html index 0d0c426..457cd2a 100644 --- a/docs/index.html +++ b/docs/index.html @@ -766,6 +766,21 @@ "earnedMinutes" : { }, "message" : { } } +}; + defs["VerifyPinRequest"] = { + "title" : "VerifyPinRequest", + "required" : [ "pin" ], + "properties" : { + "pin" : { } + } +}; + defs["VerifyPinResponse"] = { + "title" : "VerifyPinResponse", + "required" : [ "message", "success" ], + "properties" : { + "success" : { }, + "message" : { } + } }; @@ -793,6 +808,9 @@
  • apiV1HealthGet
  • +
  • + apiV1ParentVerifyPinPost +
  • apiV1ScreentimeGet
  • @@ -1630,7 +1648,7 @@ except ApiException as e:

    apiV1HealthGet

    -

    Health-Check

    +

    @@ -1710,7 +1728,6 @@ public class DefaultApiExample {
    
     DefaultApi *apiInstance = [[DefaultApi alloc] init];
     
    -// Health-Check
     [apiInstance apiV1HealthGetWithCompletionHandler: 
                   ^(NSError* error) {
                                 if (error) {
    @@ -1756,7 +1773,6 @@ namespace Example
     
                 try
                 {
    -                // Health-Check
                     apiInstance.apiV1HealthGet();
                 }
                 catch (Exception e)
    @@ -1809,7 +1825,6 @@ from pprint import pprint
     api_instance = swagger_client.DefaultApi()
     
     try: 
    -    # Health-Check
         api_instance.api_v1_health_get()
     except ApiException as e:
         print("Exception when calling DefaultApi->apiV1HealthGet: %s\n" % e)
    @@ -1876,6 +1891,468 @@ except ApiException as e:

    +
    +
    +
    +

    apiV1ParentVerifyPinPost

    +

    +
    +
    +
    +

    +

    +

    +
    +
    /api/v1/parent/verify-pin
    +

    +

    Usage and SDK Samples

    +

    + + +
    +
    +
    curl -X POST\
    + -H "Authorization: Bearer [[accessToken]]"\
    +-H "Accept: application/json"\
    +-H "Content-Type: application/json"\
    +"//api/v1/parent/verify-pin"
    +
    +
    +
    import io.swagger.client.*;
    +import io.swagger.client.auth.*;
    +import io.swagger.client.model.*;
    +import io.swagger.client.api.DefaultApi;
    +
    +import java.io.File;
    +import java.util.*;
    +
    +public class DefaultApiExample {
    +
    +    public static void main(String[] args) {
    +        ApiClient defaultClient = Configuration.getDefaultApiClient();
    +
    +
    +        DefaultApi apiInstance = new DefaultApi();
    +        VerifyPinRequest body = ; // VerifyPinRequest | 
    +        try {
    +            VerifyPinResponse result = apiInstance.apiV1ParentVerifyPinPost(body);
    +            System.out.println(result);
    +        } catch (ApiException e) {
    +            System.err.println("Exception when calling DefaultApi#apiV1ParentVerifyPinPost");
    +            e.printStackTrace();
    +        }
    +    }
    +}
    +
    + +
    +
    import io.swagger.client.api.DefaultApi;
    +
    +public class DefaultApiExample {
    +
    +    public static void main(String[] args) {
    +        DefaultApi apiInstance = new DefaultApi();
    +        VerifyPinRequest body = ; // VerifyPinRequest | 
    +        try {
    +            VerifyPinResponse result = apiInstance.apiV1ParentVerifyPinPost(body);
    +            System.out.println(result);
    +        } catch (ApiException e) {
    +            System.err.println("Exception when calling DefaultApi#apiV1ParentVerifyPinPost");
    +            e.printStackTrace();
    +        }
    +    }
    +}
    +
    + +
    +
    Configuration *apiConfig = [Configuration sharedConfig];
    +VerifyPinRequest *body = ; //  (optional)
    +
    +DefaultApi *apiInstance = [[DefaultApi alloc] init];
    +
    +[apiInstance apiV1ParentVerifyPinPostWith:body
    +              completionHandler: ^(VerifyPinResponse output, NSError* error) {
    +                            if (output) {
    +                                NSLog(@"%@", output);
    +                            }
    +                            if (error) {
    +                                NSLog(@"Error: %@", error);
    +                            }
    +                        }];
    +
    +
    + +
    +
    var UntitledApi = require('untitled_api');
    +var defaultClient = UntitledApi.ApiClient.instance;
    +
    +
    +var api = new UntitledApi.DefaultApi()
    +var opts = { 
    +  'body':  // {{VerifyPinRequest}} 
    +};
    +var callback = function(error, data, response) {
    +  if (error) {
    +    console.error(error);
    +  } else {
    +    console.log('API called successfully. Returned data: ' + data);
    +  }
    +};
    +api.apiV1ParentVerifyPinPost(opts, callback);
    +
    +
    + + +
    +
    using System;
    +using System.Diagnostics;
    +using IO.Swagger.Api;
    +using IO.Swagger.Client;
    +using IO.Swagger.Model;
    +
    +namespace Example
    +{
    +    public class apiV1ParentVerifyPinPostExample
    +    {
    +        public void main()
    +        {
    +
    +
    +            var apiInstance = new DefaultApi();
    +            var body = new VerifyPinRequest(); // VerifyPinRequest |  (optional) 
    +
    +            try
    +            {
    +                VerifyPinResponse result = apiInstance.apiV1ParentVerifyPinPost(body);
    +                Debug.WriteLine(result);
    +            }
    +            catch (Exception e)
    +            {
    +                Debug.Print("Exception when calling DefaultApi.apiV1ParentVerifyPinPost: " + e.Message );
    +            }
    +        }
    +    }
    +}
    +
    +
    + +
    +
    <?php
    +require_once(__DIR__ . '/vendor/autoload.php');
    +
    +
    +$api_instance = new Swagger\Client\ApiDefaultApi();
    +$body = ; // VerifyPinRequest | 
    +
    +try {
    +    $result = $api_instance->apiV1ParentVerifyPinPost($body);
    +    print_r($result);
    +} catch (Exception $e) {
    +    echo 'Exception when calling DefaultApi->apiV1ParentVerifyPinPost: ', $e->getMessage(), PHP_EOL;
    +}
    +?>
    +
    + +
    +
    use Data::Dumper;
    +use WWW::SwaggerClient::Configuration;
    +use WWW::SwaggerClient::DefaultApi;
    +
    +
    +my $api_instance = WWW::SwaggerClient::DefaultApi->new();
    +my $body = WWW::SwaggerClient::Object::VerifyPinRequest->new(); # VerifyPinRequest | 
    +
    +eval { 
    +    my $result = $api_instance->apiV1ParentVerifyPinPost(body => $body);
    +    print Dumper($result);
    +};
    +if ($@) {
    +    warn "Exception when calling DefaultApi->apiV1ParentVerifyPinPost: $@\n";
    +}
    +
    + +
    +
    from __future__ import print_statement
    +import time
    +import swagger_client
    +from swagger_client.rest import ApiException
    +from pprint import pprint
    +
    +
    +# create an instance of the API class
    +api_instance = swagger_client.DefaultApi()
    +body =  # VerifyPinRequest |  (optional)
    +
    +try: 
    +    api_response = api_instance.api_v1_parent_verify_pin_post(body=body)
    +    pprint(api_response)
    +except ApiException as e:
    +    print("Exception when calling DefaultApi->apiV1ParentVerifyPinPost: %s\n" % e)
    +
    +
    + +

    Parameters

    + + + +
    Body parameters
    + + + + + + + + +
    NameDescription
    body + + + +
    +
    + + + +

    Responses

    +

    Status: 401 -

    + + + +
    +
    +
    + +
    + +
    +
    + +

    Status: 400 -

    + + + +
    +
    +
    + +
    + +
    +
    + +

    Status: 404 -

    + + + +
    +
    +
    + +
    + +
    +
    + +

    Status: 200 -

    + + + +
    +
    +
    + +
    + +
    +
    + +
    +
    +
    @@ -2194,7 +2671,7 @@ except ApiException as e:

    apiV1ScreentimeSyncPost

    -

    3. Bildschirmzeit synchronisieren

    +

    @@ -2284,7 +2761,6 @@ SyncRequest *body = ; // (optional) DefaultApi *apiInstance = [[DefaultApi alloc] init]; -// 3. Bildschirmzeit synchronisieren [apiInstance apiV1ScreentimeSyncPostWith:body completionHandler: ^(SyncResponse output, NSError* error) { if (output) { @@ -2340,7 +2816,6 @@ namespace Example try { - // 3. Bildschirmzeit synchronisieren SyncResponse result = apiInstance.apiV1ScreentimeSyncPost(body); Debug.WriteLine(result); } @@ -2402,7 +2877,6 @@ api_instance = swagger_client.DefaultApi() body = # SyncRequest | (optional) try: - # 3. Bildschirmzeit synchronisieren api_response = api_instance.api_v1_screentime_sync_post(body=body) pprint(api_response) except ApiException as e: @@ -2515,7 +2989,7 @@ except ApiException as e:

    apiV1TasksGet

    -

    1. Alle verfügbaren Aufgaben abrufen (mit optionaler Filterung nach Fach und Alter)

    +

    @@ -2608,7 +3082,6 @@ public class DefaultApiExample { DefaultApi *apiInstance = [[DefaultApi alloc] init]; -// 1. Alle verfügbaren Aufgaben abrufen (mit optionaler Filterung nach Fach und Alter) [apiInstance apiV1TasksGetWith:subject age:age limit:limit @@ -2667,7 +3140,6 @@ namespace Example try { - // 1. Alle verfügbaren Aufgaben abrufen (mit optionaler Filterung nach Fach und Alter) apiInstance.apiV1TasksGet(subject, age, limit); } catch (Exception e) @@ -2732,7 +3204,6 @@ age = # | (optional) limit = # | (optional) try: - # 1. Alle verfügbaren Aufgaben abrufen (mit optionaler Filterung nach Fach und Alter) api_instance.api_v1_tasks_get(subject=subject, age=age, limit=limit) except ApiException as e: print("Exception when calling DefaultApi->apiV1TasksGet: %s\n" % e) @@ -2860,7 +3331,7 @@ except ApiException as e:

    apiV1TasksVerifyPost

    -

    2. Antwort auf eine Aufgabe überprüfen

    +

    @@ -2950,7 +3421,6 @@ TaskVerifyRequest *body = ; // (optional) DefaultApi *apiInstance = [[DefaultApi alloc] init]; -// 2. Antwort auf eine Aufgabe überprüfen [apiInstance apiV1TasksVerifyPostWith:body completionHandler: ^(TaskVerifyResponse output, NSError* error) { if (output) { @@ -3006,7 +3476,6 @@ namespace Example try { - // 2. Antwort auf eine Aufgabe überprüfen TaskVerifyResponse result = apiInstance.apiV1TasksVerifyPost(body); Debug.WriteLine(result); } @@ -3068,7 +3537,6 @@ api_instance = swagger_client.DefaultApi() body = # TaskVerifyRequest | (optional) try: - # 2. Antwort auf eine Aufgabe überprüfen api_response = api_instance.api_v1_tasks_verify_post(body=body) pprint(api_response) except ApiException as e: diff --git a/src/main/kotlin/com/oliver/kidio/backend/data/database/DatabaseFactory.kt b/src/main/kotlin/com/oliver/kidio/backend/data/database/DatabaseFactory.kt index 80dbf1c..3bc104d 100644 --- a/src/main/kotlin/com/oliver/kidio/backend/data/database/DatabaseFactory.kt +++ b/src/main/kotlin/com/oliver/kidio/backend/data/database/DatabaseFactory.kt @@ -1,50 +1,14 @@ 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", 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) - } -} - -object ScreentimeTable : Table("user_screentime") { - val userId = varchar("user_id", 50) - val remainingSeconds = integer("remaining_seconds") - - override val primaryKey = PrimaryKey(userId) -} - -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) -} - object DatabaseFactory { fun init(config: ApplicationConfig) { val driverClassName = config.propertyOrNull("storage.driverClassName")?.getString() ?: "org.postgresql.Driver" diff --git a/src/main/kotlin/com/oliver/kidio/backend/data/tables/ScreentimeTable.kt b/src/main/kotlin/com/oliver/kidio/backend/data/tables/ScreentimeTable.kt new file mode 100644 index 0000000..35fad0a --- /dev/null +++ b/src/main/kotlin/com/oliver/kidio/backend/data/tables/ScreentimeTable.kt @@ -0,0 +1,10 @@ +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) +} diff --git a/src/main/kotlin/com/oliver/kidio/backend/data/tables/TasksTable.kt b/src/main/kotlin/com/oliver/kidio/backend/data/tables/TasksTable.kt new file mode 100644 index 0000000..c6f49a4 --- /dev/null +++ b/src/main/kotlin/com/oliver/kidio/backend/data/tables/TasksTable.kt @@ -0,0 +1,24 @@ +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) + } +} diff --git a/src/main/kotlin/com/oliver/kidio/backend/data/tables/UserTable.kt b/src/main/kotlin/com/oliver/kidio/backend/data/tables/UserTable.kt new file mode 100644 index 0000000..0cf94e2 --- /dev/null +++ b/src/main/kotlin/com/oliver/kidio/backend/data/tables/UserTable.kt @@ -0,0 +1,14 @@ +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) +} diff --git a/src/main/kotlin/com/oliver/kidio/backend/domain/models/AuthModels.kt b/src/main/kotlin/com/oliver/kidio/backend/domain/models/AuthModels.kt new file mode 100644 index 0000000..5c67b92 --- /dev/null +++ b/src/main/kotlin/com/oliver/kidio/backend/domain/models/AuthModels.kt @@ -0,0 +1,37 @@ +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, +) diff --git a/src/main/kotlin/com/oliver/kidio/backend/domain/models/Models.kt b/src/main/kotlin/com/oliver/kidio/backend/domain/models/TaskModels.kt similarity index 54% rename from src/main/kotlin/com/oliver/kidio/backend/domain/models/Models.kt rename to src/main/kotlin/com/oliver/kidio/backend/domain/models/TaskModels.kt index b143dea..d610973 100644 --- a/src/main/kotlin/com/oliver/kidio/backend/domain/models/Models.kt +++ b/src/main/kotlin/com/oliver/kidio/backend/domain/models/TaskModels.kt @@ -1,4 +1,4 @@ -package com.oliver.kidio.backend +package com.oliver.kidio.backend.domain.models import kotlinx.serialization.Serializable @@ -45,51 +45,3 @@ data class TaskVerifyResponse( 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 SyncResponse( - val remainingSeconds: Int, - val isBlocked: Boolean, -) - -// --- NEU: AUTHENTIFIZIERUNG --- - -@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, -) - -// 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, -) diff --git a/src/main/kotlin/com/oliver/kidio/backend/domain/models/UserModels.kt b/src/main/kotlin/com/oliver/kidio/backend/domain/models/UserModels.kt new file mode 100644 index 0000000..21e21b1 --- /dev/null +++ b/src/main/kotlin/com/oliver/kidio/backend/domain/models/UserModels.kt @@ -0,0 +1,26 @@ +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, +) diff --git a/src/main/kotlin/com/oliver/kidio/backend/domain/repository/ExposedKidioRepository.kt b/src/main/kotlin/com/oliver/kidio/backend/domain/repository/ExposedKidioRepository.kt new file mode 100644 index 0000000..7f322ca --- /dev/null +++ b/src/main/kotlin/com/oliver/kidio/backend/domain/repository/ExposedKidioRepository.kt @@ -0,0 +1,242 @@ +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 = + 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 = getTasks(limit = 100) + + override suspend fun getExistingTaskIds(externalSource: String): Set = + 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? = + 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) + } +} diff --git a/src/main/kotlin/com/oliver/kidio/backend/domain/repository/InMemoryKidioRepository.kt b/src/main/kotlin/com/oliver/kidio/backend/domain/repository/InMemoryKidioRepository.kt new file mode 100644 index 0000000..b9289c7 --- /dev/null +++ b/src/main/kotlin/com/oliver/kidio/backend/domain/repository/InMemoryKidioRepository.kt @@ -0,0 +1,123 @@ +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( + 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", + ), + 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", + ), + ) + private val screentimes = + mutableMapOf( + "default_user" to 1800, + ) + private val users = mutableListOf() + + override suspend fun getAllTasks(): List = getTasks(limit = 100) + + override suspend fun getTasks( + subject: String?, + age: Int?, + limit: Int, + ): List { + 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 { + 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? { + 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 + } +} diff --git a/src/main/kotlin/com/oliver/kidio/backend/domain/repository/KidioRepository.kt b/src/main/kotlin/com/oliver/kidio/backend/domain/repository/KidioRepository.kt index 988365c..143066e 100644 --- a/src/main/kotlin/com/oliver/kidio/backend/domain/repository/KidioRepository.kt +++ b/src/main/kotlin/com/oliver/kidio/backend/domain/repository/KidioRepository.kt @@ -1,32 +1,22 @@ package com.oliver.kidio.backend.domain.repository -import com.oliver.kidio.backend.ImportedTask -import com.oliver.kidio.backend.Task -import com.oliver.kidio.backend.User -import com.oliver.kidio.backend.data.database.DatabaseFactory -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.* -import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq -import org.jetbrains.exposed.sql.SqlExpressionBuilder.greaterEq -import org.jetbrains.exposed.sql.SqlExpressionBuilder.lessEq -import org.jetbrains.exposed.sql.lowerCase -import org.mindrot.jbcrypt.BCrypt +import com.oliver.kidio.backend.domain.models.ImportedTask +import com.oliver.kidio.backend.domain.models.Task +import com.oliver.kidio.backend.domain.models.User interface KidioRepository { suspend fun getAllTasks(): List = 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 - // Mit suspend markiert, damit es zur DatabaseFactory.dbQuery Architektur passt suspend fun getExistingTaskIds(externalSource: String = "zum.de"): Set suspend fun upsertTask(task: ImportedTask): Boolean @@ -49,331 +39,3 @@ interface KidioRepository { suspend fun registerUser(user: User): Boolean } - -class ExposedKidioRepository : KidioRepository { - override suspend fun getTasks( - subject: String?, - age: Int?, - limit: Int, - ): List = - 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 = getTasks(limit = 100) - - // NEU: Implementierung für Exposed (PostgreSQL) - override suspend fun getExistingTaskIds(externalSource: String): Set = - DatabaseFactory.dbQuery { - TasksTable - .select(TasksTable.id) // Wir brauchen nur die ID-Spalte - .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? = - 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 verifyPassword( - password: String, - passwordHash: String, - ): Boolean { - return BCrypt.checkpw(password, passwordHash) - } -} - -class InMemoryKidioRepository : KidioRepository { - private val tasks = - mutableListOf( - 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", - ), - 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", - ), - ) - private val screentimes = - mutableMapOf( - "default_user" to 1800, - ) - private val users = mutableListOf() - - override suspend fun getAllTasks(): List = getTasks(limit = 100) - - override suspend fun getTasks( - subject: String?, - age: Int?, - limit: Int, - ): List { - 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 } - } - - // NEU: Implementierung für InMemory (Mock-Daten) - override suspend fun getExistingTaskIds(externalSource: String): Set { - 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? { - 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 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 - } -} diff --git a/src/main/kotlin/com/oliver/kidio/backend/ingestion/H5PIngestionCli.kt b/src/main/kotlin/com/oliver/kidio/backend/ingestion/H5PIngestionCli.kt index 70e2d99..bd82a80 100644 --- a/src/main/kotlin/com/oliver/kidio/backend/ingestion/H5PIngestionCli.kt +++ b/src/main/kotlin/com/oliver/kidio/backend/ingestion/H5PIngestionCli.kt @@ -1,7 +1,7 @@ package com.oliver.kidio.backend.ingestion -import com.oliver.kidio.backend.ImportedTask -import com.oliver.kidio.backend.data.database.TasksTable +import com.oliver.kidio.backend.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 diff --git a/src/main/kotlin/com/oliver/kidio/backend/plugins/Routing.kt b/src/main/kotlin/com/oliver/kidio/backend/plugins/Routing.kt index 6ea30bd..c7e6843 100644 --- a/src/main/kotlin/com/oliver/kidio/backend/plugins/Routing.kt +++ b/src/main/kotlin/com/oliver/kidio/backend/plugins/Routing.kt @@ -1,38 +1,12 @@ 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.http.HttpStatusCode import io.ktor.server.application.* -import io.ktor.server.auth.authenticate -import io.ktor.server.auth.principal import io.ktor.server.plugins.openapi.* -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 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 -} fun Application.configureRouting( repository: KidioRepository, @@ -41,195 +15,13 @@ fun Application.configureRouting( routing { openAPI(path = "openapi") - post("/api/v1/auth/register") { - val registerRequest = call.receive() - 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 - } - - // Backend generiert die eindeutige User-ID - val userId = UUID.randomUUID().toString() - val passwordHash = BCrypt.hashpw(registerRequest.passwordPlain, BCrypt.gensalt()) - - // datenbank insert hinzufügen - 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", - ), - ) - } - // Health-Check get("/api/v1/health") { call.respond(mapOf("status" to "OK", "message" to "Kidio Backend läuft!")) } - post("/api/v1/auth/login") { - val loginRequest = call.receive() - 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, - ), - ) - return@post - } - - authenticate("auth-jwt") { - // 1. Alle verfügbaren Aufgaben abrufen (mit optionaler Filterung nach Fach und Alter) - get("/api/v1/tasks") { - val principal = call.principal() - val userId = principal?.userId - - val subject = call.request.queryParameters["subject"] - - // Expliziter Query-Parameter 'age' ODER Fallback auf das Alter des Users - 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) - } - - // 2. Antwort auf eine Aufgabe überprüfen - post("/api/v1/tasks/verify") { - val request = call.receive() - val principal = call.principal() - 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!", - ), - ) - } - } - - get("/api/v1/screentime") { - val principal = call.principal() - 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 - post("/api/v1/screentime/sync") { - val request = call.receive() - - // Wir holen die echte, fälschungssichere userId aus dem Login-Token: - val principal = call.principal() - val userId = - principal?.userId ?: request.userId // Fallback auf request.userId, falls token noch fehlt - - 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, - ), - ) - } - } + authRoutes(repository, jwtService) + taskRoutes(repository) + screentimeRoutes(repository) + parentRoutes(repository) } } diff --git a/src/main/kotlin/com/oliver/kidio/backend/routing/AuthRoutes.kt b/src/main/kotlin/com/oliver/kidio/backend/routing/AuthRoutes.kt new file mode 100644 index 0000000..d6f6e9e --- /dev/null +++ b/src/main/kotlin/com/oliver/kidio/backend/routing/AuthRoutes.kt @@ -0,0 +1,93 @@ +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() + 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() + 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, + ), + ) + } +} diff --git a/src/main/kotlin/com/oliver/kidio/backend/routing/ParentRoutes.kt b/src/main/kotlin/com/oliver/kidio/backend/routing/ParentRoutes.kt new file mode 100644 index 0000000..aec75b3 --- /dev/null +++ b/src/main/kotlin/com/oliver/kidio/backend/routing/ParentRoutes.kt @@ -0,0 +1,58 @@ +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() + val principal = call.principal() + 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."), + ) + } + } + } +} diff --git a/src/main/kotlin/com/oliver/kidio/backend/routing/ScreentimeRoutes.kt b/src/main/kotlin/com/oliver/kidio/backend/routing/ScreentimeRoutes.kt new file mode 100644 index 0000000..60f0cc2 --- /dev/null +++ b/src/main/kotlin/com/oliver/kidio/backend/routing/ScreentimeRoutes.kt @@ -0,0 +1,53 @@ +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() + 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() + + val principal = call.principal() + 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, + ), + ) + } + } +} diff --git a/src/main/kotlin/com/oliver/kidio/backend/routing/TaskRoutes.kt b/src/main/kotlin/com/oliver/kidio/backend/routing/TaskRoutes.kt new file mode 100644 index 0000000..13db913 --- /dev/null +++ b/src/main/kotlin/com/oliver/kidio/backend/routing/TaskRoutes.kt @@ -0,0 +1,80 @@ +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() + 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() + val principal = call.principal() + 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!", + ), + ) + } + } + } +} diff --git a/src/main/kotlin/com/oliver/kidio/backend/security/PasswordUtils.kt b/src/main/kotlin/com/oliver/kidio/backend/security/PasswordUtils.kt new file mode 100644 index 0000000..85ab758 --- /dev/null +++ b/src/main/kotlin/com/oliver/kidio/backend/security/PasswordUtils.kt @@ -0,0 +1,21 @@ +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 +} diff --git a/src/test/kotlin/H5PParserTest.kt b/src/test/kotlin/com/oliver/kidio/backend/ingestion/H5PParserTest.kt similarity index 98% rename from src/test/kotlin/H5PParserTest.kt rename to src/test/kotlin/com/oliver/kidio/backend/ingestion/H5PParserTest.kt index 1434094..2f83b17 100644 --- a/src/test/kotlin/H5PParserTest.kt +++ b/src/test/kotlin/com/oliver/kidio/backend/ingestion/H5PParserTest.kt @@ -1,4 +1,4 @@ -package com.oliver.kidio.backend +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 diff --git a/src/test/kotlin/com/oliver/kidio/backend/routing/AuthRoutesTest.kt b/src/test/kotlin/com/oliver/kidio/backend/routing/AuthRoutesTest.kt new file mode 100644 index 0000000..ddfd230 --- /dev/null +++ b/src/test/kotlin/com/oliver/kidio/backend/routing/AuthRoutesTest.kt @@ -0,0 +1,126 @@ +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(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!", + ) + } + } +} diff --git a/src/test/kotlin/com/oliver/kidio/backend/routing/HealthRouteTest.kt b/src/test/kotlin/com/oliver/kidio/backend/routing/HealthRouteTest.kt new file mode 100644 index 0000000..68998d5 --- /dev/null +++ b/src/test/kotlin/com/oliver/kidio/backend/routing/HealthRouteTest.kt @@ -0,0 +1,37 @@ +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!")) + } +} diff --git a/src/test/kotlin/com/oliver/kidio/backend/routing/ScreentimeRoutesTest.kt b/src/test/kotlin/com/oliver/kidio/backend/routing/ScreentimeRoutesTest.kt new file mode 100644 index 0000000..e182656 --- /dev/null +++ b/src/test/kotlin/com/oliver/kidio/backend/routing/ScreentimeRoutesTest.kt @@ -0,0 +1,108 @@ +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(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(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(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(syncResponse3.bodyAsText()) + assertEquals(0, syncJson3.remainingSeconds) + assertTrue(syncJson3.isBlocked) + } +} diff --git a/src/test/kotlin/ServerTest.kt b/src/test/kotlin/com/oliver/kidio/backend/routing/ServerTest.kt similarity index 97% rename from src/test/kotlin/ServerTest.kt rename to src/test/kotlin/com/oliver/kidio/backend/routing/ServerTest.kt index ceda5b0..5ee7b72 100644 --- a/src/test/kotlin/ServerTest.kt +++ b/src/test/kotlin/com/oliver/kidio/backend/routing/ServerTest.kt @@ -1,5 +1,9 @@ -package com.oliver.kidio.backend +package com.oliver.kidio.backend.com.oliver.kidio.backend.routing +import com.oliver.kidio.backend.domain.models.AuthResponse +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 diff --git a/src/test/kotlin/com/oliver/kidio/backend/routing/TaskRoutesTest.kt b/src/test/kotlin/com/oliver/kidio/backend/routing/TaskRoutesTest.kt new file mode 100644 index 0000000..485ecfa --- /dev/null +++ b/src/test/kotlin/com/oliver/kidio/backend/routing/TaskRoutesTest.kt @@ -0,0 +1,120 @@ +package com.oliver.kidio.backend.routing + +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.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() + + 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>(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>(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>(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(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(verifyWrongResponse.bodyAsText()) + assertFalse(verifyWrongJson.isCorrect) + assertEquals(0, verifyWrongJson.earnedMinutes) + } +} diff --git a/src/test/kotlin/com/oliver/kidio/backend/security/JwtServiceTest.kt b/src/test/kotlin/com/oliver/kidio/backend/security/JwtServiceTest.kt new file mode 100644 index 0000000..4792191 --- /dev/null +++ b/src/test/kotlin/com/oliver/kidio/backend/security/JwtServiceTest.kt @@ -0,0 +1,25 @@ +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) + } +} diff --git a/src/test/kotlin/com/oliver/kidio/backend/security/PasswordUtilsTest.kt b/src/test/kotlin/com/oliver/kidio/backend/security/PasswordUtilsTest.kt new file mode 100644 index 0000000..0398c55 --- /dev/null +++ b/src/test/kotlin/com/oliver/kidio/backend/security/PasswordUtilsTest.kt @@ -0,0 +1,35 @@ +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!")) + } +}