From 5f14df7f7a3b4cd4ea21f7945d3d4487474069bb Mon Sep 17 00:00:00 2001 From: Johannes Aamot-Skeidsvoll Date: Tue, 28 Apr 2026 10:05:56 +0200 Subject: [PATCH 01/10] Add spotless gradle formatting --- .editorconfig | 14 ++++++++++++++ build.gradle | 22 +++++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..aced8050 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +root = true + +[*.{kt,kts,gradle}] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +insert_final_newline = true +ktlint_standard_no-wildcard-imports = disabled +max_line_length = off +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/build.gradle b/build.gradle index b868ab4a..93e8b228 100644 --- a/build.gradle +++ b/build.gradle @@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile plugins { id 'jacoco' + id 'com.diffplug.spotless' version '6.25.0' id 'org.jetbrains.kotlin.jvm' version '2.2.21' id 'org.jetbrains.kotlin.plugin.spring' version '2.2.21' id 'org.springframework.boot' version '4.0.5' @@ -69,6 +70,25 @@ allOpen { annotation 'jakarta.persistence.Embeddable' } +spotless { + kotlin { + target 'src/**/*.kt' + ktlint('1.5.0') + trimTrailingWhitespace() + endWithNewline() + } + kotlinGradle { + target '*.gradle', 'gradle/**/*.gradle' + ktlint('1.5.0') + trimTrailingWhitespace() + endWithNewline() + } +} + +tasks.named('check') { + dependsOn tasks.named('spotlessCheck') +} + tasks.named('test') { useJUnitPlatform() environment 'SPRING_DATASOURCE_URL', 'jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=MySQL' @@ -85,4 +105,4 @@ tasks.named('jacocoTestReport') { xml.required = true html.required = true } -} \ No newline at end of file +} From 345b33a9120ffeb1bdef277caa516ee2b87123c4 Mon Sep 17 00:00:00 2001 From: Johannes Aamot-Skeidsvoll Date: Tue, 28 Apr 2026 10:06:03 +0200 Subject: [PATCH 02/10] Add formatting to CI --- .github/workflows/backend.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index ad78424b..c1f6d088 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -25,6 +25,9 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@v4 + - name: Check formatting + run: ./gradlew spotlessCheck --no-daemon + - name: Run backend tests with coverage run: ./gradlew test jacocoTestReport --no-daemon @@ -66,6 +69,6 @@ jobs: uses: actions/upload-artifact@v4 with: name: backend-jacoco-report - path: backend/build/reports/jacoco/test + path: build/reports/jacoco/test if-no-files-found: ignore - retention-days: 14 \ No newline at end of file + retention-days: 14 From 1ec6adb2af3f1405205910bd554e638ed182e695 Mon Sep 17 00:00:00 2001 From: Johannes Aamot-Skeidsvoll Date: Tue, 28 Apr 2026 10:06:10 +0200 Subject: [PATCH 03/10] Update README --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index ca7999fc..0387ef0b 100644 --- a/README.md +++ b/README.md @@ -24,3 +24,12 @@ cp .env.dev.example .env.dev - Docker or prod: container environment variables or a secret manager mounted as env The same property keys are used in every environment, so you do not need to change Kotlin code when moving from local development to CI or production. + +## Formatting + +The project now enforces one shared code style for Kotlin and Gradle files through Gradle and CI. + +- Check formatting: `./gradlew spotlessCheck` +- Apply the project format: `./gradlew spotlessApply` + +`./gradlew check` also runs the formatting check, and GitHub Actions will fail if pushed code does not match the checked-in style. From 6c047be97ddba2a61c3e50eb04238a7a238f0966 Mon Sep 17 00:00:00 2001 From: Johannes Aamot-Skeidsvoll Date: Tue, 28 Apr 2026 10:07:57 +0200 Subject: [PATCH 04/10] Update formatting config --- .editorconfig | 2 -- 1 file changed, 2 deletions(-) diff --git a/.editorconfig b/.editorconfig index aced8050..fbd9aa05 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,8 +6,6 @@ end_of_line = lf indent_style = space indent_size = 4 insert_final_newline = true -ktlint_standard_no-wildcard-imports = disabled -max_line_length = off trim_trailing_whitespace = true [*.md] From 0b1941e0112a26e5e9f29fd717dd2deb3b861af8 Mon Sep 17 00:00:00 2001 From: Johannes Aamot-Skeidsvoll Date: Tue, 28 Apr 2026 10:20:08 +0200 Subject: [PATCH 05/10] Format EVERYTHING --- .editorconfig | 1 + build.gradle | 3 +- .../idi/idatt/backend/BackendApplication.kt | 2 +- .../backend/bootstrap/DevDataBootstrap.kt | 201 +-- .../backend/bootstrap/MapStopBootstrap.kt | 17 +- .../idatt/backend/bootstrap/MedalBootstrap.kt | 2 +- .../idatt/backend/bootstrap/XpBootstrap.kt | 16 +- .../DenSosialeMarkedsplassenTaskSeeds.kt | 174 +-- .../bootstrap/tasks/FinalBossTaskSeeds.kt | 206 +-- .../bootstrap/tasks/FotografenTaskSeeds.kt | 247 ++-- .../tasks/MarkedsplassenTaskSeeds.kt | 336 ++--- .../tasks/NyhetskvartaleTaskSeeds.kt | 231 ++-- .../bootstrap/tasks/PassordbankenTaskSeeds.kt | 231 ++-- .../bootstrap/tasks/PostkontoretTaskSeeds.kt | 411 +++--- .../backend/bootstrap/tasks/TaskBootstrap.kt | 75 +- .../backend/bootstrap/tasks/TaskSeedModels.kt | 10 +- .../classroom/api/ClassroomApiMappings.kt | 40 +- .../classroom/api/ClassroomController.kt | 32 +- .../classroom/api/ClassroomDetailResponse.kt | 4 +- .../classroom/api/ClassroomPupilResponse.kt | 2 +- .../classroom/api/ClassroomResponse.kt | 2 +- .../classroom/api/ClassroomTeacherResponse.kt | 2 +- .../classroom/api/CreateClassroomRequest.kt | 3 +- .../classroom/api/UpdateClassroomRequest.kt | 4 +- .../classroom/application/ClassroomService.kt | 110 +- .../application/CreateClassroomCommand.kt | 2 +- .../application/UpdateClassroomCommand.kt | 2 +- .../backend/classroom/domain/Classroom.kt | 56 +- .../classroom/domain/ClassroomPupilStatus.kt | 2 +- .../classroom/domain/ClassroomStatus.kt | 2 +- .../domain/ClassroomTeacherMembership.kt | 13 +- .../domain/ClassroomTeacherMembershipId.kt | 1 - .../infrastructure/ClassroomRepository.kt | 7 +- .../application/GameAccessContextResolver.kt | 34 +- .../application/PupilGameProgressService.kt | 61 +- .../PupilGameProgressRepository.kt | 5 +- .../mapstops/api/MapStopOverviewResponse.kt | 2 +- .../api/PupilStopProgressController.kt | 5 +- .../game/mapstops/api/StopProgressResponse.kt | 2 +- .../mapstops/api/UpdateStopProgressRequest.kt | 2 +- .../mapstops/application/MapStopService.kt | 57 +- .../PupilProgressSeedingService.kt | 43 +- .../application/PupilStopProgressService.kt | 47 +- .../application/UpdateStopProgressCommand.kt | 2 +- .../backend/game/mapstops/domain/MapStop.kt | 6 +- .../game/mapstops/domain/PupilStopProgress.kt | 10 +- .../infrastructure/MapStopsRepository.kt | 3 +- .../PupilStopProgressRepository.kt | 3 +- .../backend/game/tasks/api/TaskApiMappings.kt | 44 +- .../backend/game/tasks/api/TaskController.kt | 21 +- .../backend/game/tasks/api/TaskRequests.kt | 47 +- .../game/tasks/application/TaskService.kt | 352 +++-- .../tasks/infrastructure/TaskRepository.kt | 5 +- .../idatt/backend/iam/api/AuthController.kt | 28 +- .../backend/iam/application/AuthService.kt | 54 +- .../iam/application/ClassroomUserResolver.kt | 71 +- .../iam/application/CurrentUserResolver.kt | 34 +- .../application/PupilProvisioningService.kt | 27 +- .../application/TeacherRegistrationService.kt | 20 +- .../idatt/backend/iam/domain/RefreshToken.kt | 1 + .../idatt/backend/iam/security/CurrentUser.kt | 3 +- .../idatt/backend/iam/security/JwtService.kt | 34 +- .../backend/iam/security/SecurityConfig.kt | 96 +- .../leaderboard/api/LeaderboardApiMappings.kt | 17 +- .../leaderboard/api/LeaderboardController.kt | 3 +- .../application/LeaderboardService.kt | 3 +- .../backend/medals/api/MedalsController.kt | 46 +- .../medals/application/MedalService.kt | 35 +- .../idi/idatt/backend/medals/domain/Medal.kt | 20 +- .../idatt/backend/medals/domain/MedalType.kt | 2 +- .../idatt/backend/medals/domain/PupilMedal.kt | 18 +- .../medals/infrastructure/MedalRepository.kt | 1 + .../infrastructure/PupilMedalRepository.kt | 12 +- .../notebook/api/NotebookEntryController.kt | 9 +- ...yResponses.kt => NotebookEntryResponse.kt} | 0 .../notebook/application/NotebookService.kt | 51 +- .../backend/notebook/domain/NotebookEntry.kt | 14 +- .../infrastructure/NotebookEntryRepository.kt | 3 +- .../api/NotificationApiMappings.kt | 23 +- .../api/NotificationController.kt | 43 +- .../api/NotificationPresentationResolver.kt | 69 +- .../ClassroomNotificationRecipientResolver.kt | 10 +- .../NotificationDeliveryService.kt | 48 +- .../NotificationEventContextResolver.kt | 33 +- .../application/NotificationInboxService.kt | 43 +- .../application/NotificationMuteService.kt | 22 +- .../TeacherNotificationEventListener.kt | 119 +- .../TeacherNotificationRepository.kt | 5 +- .../users/avatar/api/UserAvatarApiMappings.kt | 2 +- .../avatar/application/UserAvatarService.kt | 79 +- .../infrastructure/AvatarAssetRepository.kt | 5 +- .../infrastructure/UserAvatarRepository.kt | 2 +- .../api/CurrentUserProfileApiMappings.kt | 32 +- .../profile/api/UserProfileController.kt | 10 +- .../application/CurrentUserProfileService.kt | 75 +- .../users/profile/domain/PupilProfile.kt | 1 - .../users/profile/domain/TeacherProfile.kt | 1 - .../settings/api/UserSettingsController.kt | 8 +- .../idatt/backend/xp/application/XpService.kt | 23 +- .../infrastructure/PupilXpEventRepository.kt | 12 +- .../xp/infrastructure/XpEventRepository.kt | 2 +- src/main/resources/application.properties | 2 - .../V10__create_task_translations.sql | 27 +- .../V12__create_pupil_game_progress_table.sql | 21 +- .../V14__create_classroom_teachers_table.sql | 9 +- .../V15__create_notebook_entries_table.sql | 13 +- ...w_null_pupil_for_game_notebook_entries.sql | 8 +- .../migration/V17__create_medals_tables.sql | 27 +- ...18__create_teacher_notifications_table.sql | 15 +- ...reate_teacher_notification_mutes_table.sql | 13 +- .../db/migration/V1__create_users_table.sql | 30 +- .../db/migration/V21__create_xp_tables.sql | 22 +- ...tars_into_user_avatar_and_avatar_asset.sql | 56 +- .../V2__create_teacher_profiles_table.sql | 7 +- .../V3__create_pupil_profiles_table.sql | 7 +- .../V4__create_user_settings_table.sql | 13 +- .../db/migration/V5__create_avatars_table.sql | 9 +- .../migration/V6__create_classrooms_table.sql | 21 +- .../V7__create_classroom_pupils_table.sql | 17 +- .../db/migration/V8__create_map_stops.sql | 40 +- .../migration/V9__create_tasks_and_items.sql | 56 +- .../idatt/backend/BackendApplicationTests.kt | 2 - .../classroom/ClassroomIntegrationTests.kt | 443 ++++--- .../backend/classroom/ClassroomServiceTest.kt | 163 +-- .../game/TeacherPlaytestIntegrationTests.kt | 335 ++--- .../game/mapstops/MapStopServiceTests.kt | 122 +- .../PupilProgressSeedingServiceTests.kt | 61 +- .../mapstops/PupilStopProgressServiceTests.kt | 322 +++-- .../game/tasks/TaskApiIntegrationTests.kt | 1158 +++++++++-------- .../game/tasks/TaskPersistenceTests.kt | 272 ++-- .../iam/AuthControllerIntegrationTests.kt | 587 +++++---- .../idi/idatt/backend/iam/AuthServiceTests.kt | 95 +- .../backend/iam/CurrentUserResolverTests.kt | 41 +- .../idi/idatt/backend/iam/JwtServiceTests.kt | 57 +- .../backend/iam/PasswordChangeServiceTests.kt | 76 +- .../iam/PupilProvisioningServiceTests.kt | 48 +- .../iam/TeacherRegistrationServiceTests.kt | 92 +- .../LeaderboardIntegrationTests.kt | 61 +- .../idatt/backend/medals/MedalServiceTests.kt | 23 +- ...NotebookEntryControllerIntegrationTests.kt | 450 ++++--- .../backend/notebook/NotebookServiceTests.kt | 493 +++---- .../NotificationControllerIntegrationTests.kt | 450 ++++--- .../NotificationDeliveryServiceTests.kt | 76 +- .../NotificationEventIntegrationTests.kt | 108 +- .../NotificationInboxServiceTests.kt | 9 +- .../NotificationPersistenceTests.kt | 389 +++--- .../UserAvatarControllerIntegrationTests.kt | 147 ++- .../users/avatar/UserAvatarServiceTests.kt | 126 +- .../profile/CurrentUserProfileServiceTests.kt | 312 +++-- .../UserProfileControllerIntegrationTests.kt | 232 ++-- .../UserSettingsControllerIntegrationTests.kt | 141 +- .../settings/UserSettingsServiceTests.kt | 79 +- .../idi/idatt/backend/xp/XpServiceTests.kt | 82 +- 153 files changed, 6594 insertions(+), 5338 deletions(-) rename src/main/kotlin/edu/ntnu/idi/idatt/backend/notebook/api/{NotebookEntryResponses.kt => NotebookEntryResponse.kt} (100%) diff --git a/.editorconfig b/.editorconfig index fbd9aa05..c908620e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,6 +6,7 @@ end_of_line = lf indent_style = space indent_size = 4 insert_final_newline = true +max_line_length = off trim_trailing_whitespace = true [*.md] diff --git a/build.gradle b/build.gradle index 93e8b228..3d35b429 100644 --- a/build.gradle +++ b/build.gradle @@ -77,9 +77,8 @@ spotless { trimTrailingWhitespace() endWithNewline() } - kotlinGradle { + groovyGradle { target '*.gradle', 'gradle/**/*.gradle' - ktlint('1.5.0') trimTrailingWhitespace() endWithNewline() } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/BackendApplication.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/BackendApplication.kt index 1a6934e9..c7a289c1 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/BackendApplication.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/BackendApplication.kt @@ -1,7 +1,7 @@ package edu.ntnu.idi.idatt.backend -import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication /** diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/DevDataBootstrap.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/DevDataBootstrap.kt index f85841ad..bfff999f 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/DevDataBootstrap.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/DevDataBootstrap.kt @@ -1,6 +1,5 @@ package edu.ntnu.idi.idatt.backend.bootstrap -import edu.ntnu.idi.idatt.backend.game.mapstops.domain.MapStop import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom import edu.ntnu.idi.idatt.backend.classroom.domain.ClassroomPupilStatus import edu.ntnu.idi.idatt.backend.classroom.domain.ClassroomStatus @@ -9,6 +8,7 @@ import edu.ntnu.idi.idatt.backend.game.gameProgress.domain.PupilGameProgress import edu.ntnu.idi.idatt.backend.game.gameProgress.domain.PupilGameProgressId import edu.ntnu.idi.idatt.backend.game.gameProgress.infrastructure.PupilGameProgressRepository import edu.ntnu.idi.idatt.backend.game.mapstops.application.PupilProgressSeedingService +import edu.ntnu.idi.idatt.backend.game.mapstops.domain.MapStop import edu.ntnu.idi.idatt.backend.game.mapstops.domain.PupilStopProgress import edu.ntnu.idi.idatt.backend.game.mapstops.domain.PupilStopProgressId import edu.ntnu.idi.idatt.backend.game.mapstops.domain.StopProgressStatus @@ -70,39 +70,45 @@ class DevDataBootstrap( @Transactional override fun run(args: ApplicationArguments) { - val admin = seedUser( - email = "admin@nettdetektivene.local", - password = "Admin123!", - role = UserRole.ADMIN, - ) - val teacher = seedUser( - email = "teacher@nettdetektivene.local", - password = "Teacher123!", - role = UserRole.TEACHER, - ) - val pupilAda = seedUser( - username = "ada", - password = "Pupil123!", - role = UserRole.PUPIL, - ) - val pupilNoah = seedUser( - username = "noah", - password = "Pupil123!", - role = UserRole.PUPIL, - ) - val pupilSofia = seedUser( - username = "sofia", - password = "Pupil123!", - role = UserRole.PUPIL, - ) + val admin = + seedUser( + email = "admin@nettdetektivene.local", + password = "Admin123!", + role = UserRole.ADMIN, + ) + val teacher = + seedUser( + email = "teacher@nettdetektivene.local", + password = "Teacher123!", + role = UserRole.TEACHER, + ) + val pupilAda = + seedUser( + username = "ada", + password = "Pupil123!", + role = UserRole.PUPIL, + ) + val pupilNoah = + seedUser( + username = "noah", + password = "Pupil123!", + role = UserRole.PUPIL, + ) + val pupilSofia = + seedUser( + username = "sofia", + password = "Pupil123!", + role = UserRole.PUPIL, + ) seedTeacherProfile(teacher, "Ada Lunde", "Trondheim Montessori") - val sampleClassroom = seedClassroom( - ownerTeacher = teacher, - title = "Mysterieklubben 5A", - description = "Eksempelklasserom for lokal utvikling", - joinCode = "CLASS42", - ) + val sampleClassroom = + seedClassroom( + ownerTeacher = teacher, + title = "Mysterieklubben 5A", + description = "Eksempelklasserom for lokal utvikling", + joinCode = "CLASS42", + ) seedClassroomMembership(sampleClassroom, pupilAda, "Ada") seedClassroomMembership(sampleClassroom, pupilNoah, "Noah") seedClassroomMembership(sampleClassroom, pupilSofia, "Sofia") @@ -209,11 +215,12 @@ class DevDataBootstrap( password: String, role: UserRole, ): User { - val existingUser = when { - email != null -> userRepository.findByEmailIgnoreCase(email) - username != null -> userRepository.findByUsernameIgnoreCase(username) - else -> null - } + val existingUser = + when { + email != null -> userRepository.findByEmailIgnoreCase(email) + username != null -> userRepository.findByUsernameIgnoreCase(username) + else -> null + } if (existingUser != null) { existingUser.email = email existingUser.username = username @@ -302,10 +309,11 @@ class DevDataBootstrap( } pupilGameProgressRepository.save( PupilGameProgress( - id = PupilGameProgressId( - pupilUserId = pupilUserId, - classroomId = classroomId, - ), + id = + PupilGameProgressId( + pupilUserId = pupilUserId, + classroomId = classroomId, + ), pupil = pupil, classroom = classroom, totalScore = totalScore, @@ -334,10 +342,11 @@ class DevDataBootstrap( nicknameInClass: String, ) { val pupilUserId = requireNotNull(pupil.id) { "Pupil seed user must have an id" } - val membership = classroom.pupilMemberships.firstOrNull { it.id.pupilUserId == pupilUserId } ?: run { - classroom.addPupilMembership(pupil) - classroom.pupilMemberships.first { it.id.pupilUserId == pupilUserId } - } + val membership = + classroom.pupilMemberships.firstOrNull { it.id.pupilUserId == pupilUserId } ?: run { + classroom.addPupilMembership(pupil) + classroom.pupilMemberships.first { it.id.pupilUserId == pupilUserId } + } membership.nicknameInClass = nicknameInClass membership.status = ClassroomPupilStatus.ACTIVE membership.approvedAt = membership.approvedAt ?: Instant.now() @@ -352,25 +361,28 @@ class DevDataBootstrap( joinCode: String, ): Classroom { val teacherId = requireNotNull(ownerTeacher.id) { "Teacher seed user must have an id" } - val classroom = classroomRepository.findByJoinCode(joinCode)?.let { existingClassroom -> - existingClassroom.ownerTeacherId = requireNotNull(ownerTeacher.id) { "Teacher seed user must have an id" } - existingClassroom.title = title - existingClassroom.description = description - existingClassroom.status = ClassroomStatus.ACTIVE - return existingClassroom - } ?: classroomRepository.save( - Classroom( - ownerTeacherId = requireNotNull(ownerTeacher.id) { "Teacher seed user must have an id" }, - title = title, - description = description, - joinCode = joinCode, - status = ClassroomStatus.ACTIVE, - ), - ) + val classroom = + classroomRepository.findByJoinCode(joinCode)?.let { existingClassroom -> + existingClassroom.ownerTeacherId = + requireNotNull(ownerTeacher.id) { "Teacher seed user must have an id" } + existingClassroom.title = title + existingClassroom.description = description + existingClassroom.status = ClassroomStatus.ACTIVE + return existingClassroom + } ?: classroomRepository.save( + Classroom( + ownerTeacherId = requireNotNull(ownerTeacher.id) { "Teacher seed user must have an id" }, + title = title, + description = description, + joinCode = joinCode, + status = ClassroomStatus.ACTIVE, + ), + ) - val alreadyMember = classroom.teacherMemberships.any { - it.id.teacherUserId == teacherId - } + val alreadyMember = + classroom.teacherMemberships.any { + it.id.teacherUserId == teacherId + } if (!alreadyMember) { classroom.addTeacher(ownerTeacher) @@ -439,11 +451,12 @@ class DevDataBootstrap( stop: MapStop, ) { val stopTitle = requireNotNull(stop.title) { "Seeded map stop must have a title" } - val notebookEntries = listOf( - "Hva vi oppdaget" to "Spillet oppsummerte de viktigste sporene fra $stopTitle og viste hvilke detaljer som burde undersøkes nærmere.", - "Tegn å passe på" to "Notatet fra $stopTitle fremhevet faresignaler som uklar avsender, manglende kontekst eller forsøk på å skape stress.", - "Huskeliste" to "Spillet foreslo å dobbeltsjekke kilder, sammenligne informasjon og ta vare på bevis før man går videre fra $stopTitle.", - ) + val notebookEntries = + listOf( + "Hva vi oppdaget" to "Spillet oppsummerte de viktigste sporene fra $stopTitle og viste hvilke detaljer som burde undersøkes nærmere.", + "Tegn å passe på" to "Notatet fra $stopTitle fremhevet faresignaler som uklar avsender, manglende kontekst eller forsøk på å skape stress.", + "Huskeliste" to "Spillet foreslo å dobbeltsjekke kilder, sammenligne informasjon og ta vare på bevis før man går videre fra $stopTitle.", + ) notebookEntries.forEach { (title, content) -> seedNotebookEntry( @@ -467,13 +480,14 @@ class DevDataBootstrap( ) { val classroomId = classroom.id val stopId = requireNotNull(stop.id) { "Seeded map stop must have an id" } - val existingEntry = notebookEntryRepository.findAll().firstOrNull { - it.classroom.id == classroomId && - it.stop.id == stopId && - it.title == title && - it.sourceType == sourceType && - it.pupil?.id == pupil?.id - } + val existingEntry = + notebookEntryRepository.findAll().firstOrNull { + it.classroom.id == classroomId && + it.stop.id == stopId && + it.title == title && + it.sourceType == sourceType && + it.pupil?.id == pupil?.id + } if (existingEntry != null) { existingEntry.sourceType = sourceType existingEntry.content = content @@ -499,26 +513,29 @@ class DevDataBootstrap( ) { val pupilId = requireNotNull(pupil.id) { "Pupil seed user must have an id" } val classroomId = classroom.id - val alreadySeeded = pupilStopProgressRepository - .findAllByIdPupilUserIdAndIdClassroomId(pupilId, classroomId) - .isNotEmpty() + val alreadySeeded = + pupilStopProgressRepository + .findAllByIdPupilUserIdAndIdClassroomId(pupilId, classroomId) + .isNotEmpty() if (alreadySeeded) return - val records = stops.mapNotNull { stop -> - val stopId = stop.id ?: return@mapNotNull null - PupilStopProgress( - id = PupilStopProgressId( - pupilUserId = pupilId, - classroomId = classroomId, - stopId = stopId, - ), - pupil = pupil, - classroom = classroom, - stop = stop, - ).apply { - status = StopProgressStatus.NOT_STARTED + val records = + stops.mapNotNull { stop -> + val stopId = stop.id ?: return@mapNotNull null + PupilStopProgress( + id = + PupilStopProgressId( + pupilUserId = pupilId, + classroomId = classroomId, + stopId = stopId, + ), + pupil = pupil, + classroom = classroom, + stop = stop, + ).apply { + status = StopProgressStatus.NOT_STARTED + } } - } pupilStopProgressRepository.saveAll(records) } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/MapStopBootstrap.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/MapStopBootstrap.kt index 75ec39cc..02a3c5ac 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/MapStopBootstrap.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/MapStopBootstrap.kt @@ -8,7 +8,6 @@ import org.springframework.stereotype.Component class MapStopBootstrap( private val mapStopRepository: MapStopsRepository, ) { - fun seedAllMapStops() { seedMapStop( slug = "nyhetskvartalet", @@ -16,7 +15,7 @@ class MapStopBootstrap( description = "Nyhetskvartalet", category = "stop", displayOrder = 1, - maxScore = 100 + maxScore = 100, ) seedMapStop( @@ -25,7 +24,7 @@ class MapStopBootstrap( description = "Fotografen", category = "stop", displayOrder = 2, - maxScore = 100 + maxScore = 100, ) seedMapStop( @@ -34,7 +33,7 @@ class MapStopBootstrap( description = "Postkontoret", category = "stop", displayOrder = 3, - maxScore = 100 + maxScore = 100, ) seedMapStop( @@ -43,7 +42,7 @@ class MapStopBootstrap( description = "Markedsplassen", category = "stop", displayOrder = 4, - maxScore = 100 + maxScore = 100, ) seedMapStop( @@ -52,7 +51,7 @@ class MapStopBootstrap( description = "Den Sosiale Markedsplassen", category = "stop", displayOrder = 5, - maxScore = 100 + maxScore = 100, ) seedMapStop( @@ -61,7 +60,7 @@ class MapStopBootstrap( description = "Passordbanken", category = "stop", displayOrder = 6, - maxScore = 100 + maxScore = 100, ) seedMapStop( @@ -71,7 +70,7 @@ class MapStopBootstrap( category = "stop", displayOrder = 7, maxScore = 100, - isFinalBoss = true + isFinalBoss = true, ) } @@ -100,4 +99,4 @@ class MapStopBootstrap( ), ) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/MedalBootstrap.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/MedalBootstrap.kt index aa50a785..354c1c01 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/MedalBootstrap.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/MedalBootstrap.kt @@ -18,7 +18,7 @@ class MedalBootstrap( val stopId = requireNotNull(stop.id) { "Seeded map stop must have an id" } val slug = requireNotNull(stop.slug) { "Seeded map stop must have a slug" } val title = requireNotNull(stop.title) { "Seeded map stop must have a title" } - val code = "${slug}-completion" + val code = "$slug-completion" if (medalRepository.findByCode(code) == null) { medalRepository.save( diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/XpBootstrap.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/XpBootstrap.kt index 319904d9..16ae2909 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/XpBootstrap.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/XpBootstrap.kt @@ -1,12 +1,12 @@ package edu.ntnu.idi.idatt.backend.bootstrap +import edu.ntnu.idi.idatt.backend.iam.domain.User import edu.ntnu.idi.idatt.backend.xp.domain.PupilXpEvent import edu.ntnu.idi.idatt.backend.xp.domain.PupilXpEventId import edu.ntnu.idi.idatt.backend.xp.domain.XpEvent import edu.ntnu.idi.idatt.backend.xp.domain.XpEventCode import edu.ntnu.idi.idatt.backend.xp.infrastructure.PupilXpEventRepository import edu.ntnu.idi.idatt.backend.xp.infrastructure.XpEventRepository -import edu.ntnu.idi.idatt.backend.iam.domain.User import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.stereotype.Component @@ -60,8 +60,9 @@ class XpBootstrap( fun seedPupilXpEvent(pupil: User) { val pupilId = requireNotNull(pupil.id) { "Seeded pupil must have an id" } - val xpEvent = xpEventRepository.findByCode(XpEventCode.STOP_COMPLETED) - ?: throw IllegalStateException("XP event ${XpEventCode.STOP_COMPLETED} must be seeded before pupil XP rows") + val xpEvent = + xpEventRepository.findByCode(XpEventCode.STOP_COMPLETED) + ?: throw IllegalStateException("XP event ${XpEventCode.STOP_COMPLETED} must be seeded before pupil XP rows") val xpEventId = requireNotNull(xpEvent.id) { "Seeded XP event must have an id" } val existingPupilXpEvent = pupilXpEventRepository.findByIdPupilUserIdAndIdXpEventId(pupilId, xpEventId) @@ -73,10 +74,11 @@ class XpBootstrap( pupilXpEventRepository.save( PupilXpEvent( - id = PupilXpEventId( - pupilUserId = pupilId, - xpEventId = xpEventId, - ), + id = + PupilXpEventId( + pupilUserId = pupilId, + xpEventId = xpEventId, + ), pupil = pupil, xpEvent = xpEvent, completionCount = 1, diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/DenSosialeMarkedsplassenTaskSeeds.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/DenSosialeMarkedsplassenTaskSeeds.kt index c693f49e..c2bb7896 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/DenSosialeMarkedsplassenTaskSeeds.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/DenSosialeMarkedsplassenTaskSeeds.kt @@ -7,88 +7,112 @@ internal fun denSosialeMarkedsplassenTaskSeeds(): List = binaryChoiceTask( difficultyLevel = 1, taskType = TaskType.SOCIAL_SCENARIO, - title = localized("Den sosiale markedsplassen 1: Tenk før du deler", "Social Marketplace 1: Think before sharing"), - introText = localizedOptional( - "Ikke alt som deles av venner, er sant.", - "Not everything shared by friends is true.", - ), - promptText = localized( - "Hva er smartest å gjøre før du deler en dramatisk melding videre?", - "What is smartest to do before sharing a dramatic message?", - ), - correctOption = explainedOption( - 1, - "Sjekke hvor meldingen kommer fra og om andre kilder sier det samme.", - "Check where the message comes from and whether other sources say the same thing.", - true, - "Det hjelper deg å stoppe rykter før de sprer seg.", - "That helps you stop rumors before they spread.", - ), - incorrectOption = explainedOption( - 2, - "Dele den raskt fordi mange andre allerede har gjort det.", - "Share it quickly because many others have already done it.", - false, - "At mange deler noe, betyr ikke at det er sant.", - "Just because many people share something does not make it true.", - ), + title = + localized( + "Den sosiale markedsplassen 1: Tenk før du deler", + "Social Marketplace 1: Think before sharing", + ), + introText = + localizedOptional( + "Ikke alt som deles av venner, er sant.", + "Not everything shared by friends is true.", + ), + promptText = + localized( + "Hva er smartest å gjøre før du deler en dramatisk melding videre?", + "What is smartest to do before sharing a dramatic message?", + ), + correctOption = + explainedOption( + 1, + "Sjekke hvor meldingen kommer fra og om andre kilder sier det samme.", + "Check where the message comes from and whether other sources say the same thing.", + true, + "Det hjelper deg å stoppe rykter før de sprer seg.", + "That helps you stop rumors before they spread.", + ), + incorrectOption = + explainedOption( + 2, + "Dele den raskt fordi mange andre allerede har gjort det.", + "Share it quickly because many others have already done it.", + false, + "At mange deler noe, betyr ikke at det er sant.", + "Just because many people share something does not make it true.", + ), ), binaryChoiceTask( difficultyLevel = 2, taskType = TaskType.SOCIAL_SCENARIO, - title = localized("Den sosiale markedsplassen 2: Følelser kan lure", "Social Marketplace 2: Feelings can mislead"), - introText = localizedOptional( - "Sterke følelser kan gjøre at vi glemmer å tenke kritisk.", - "Strong feelings can make us forget to think critically.", - ), - promptText = localized( - "Hvilken melding prøver mest å manipulere følelsene dine?", - "Which message tries hardest to manipulate your feelings?", - ), - correctOption = explainedOption( - 1, - "Hvis du bryr deg om vennene dine, må du dele dette akkurat nå!", - "If you care about your friends, you must share this right now!", - true, - "Den bruker skyldfølelse og hastverk for å presse deg.", - "It uses guilt and urgency to pressure you.", - ), - incorrectOption = explainedOption( - 2, - "Se denne informasjonen og tenk over om den virker troverdig.", - "Look at this information and think about whether it seems trustworthy.", - false, - "Den prøver ikke å presse deg følelsesmessig.", - "It does not try to pressure you emotionally.", - ), + title = + localized( + "Den sosiale markedsplassen 2: Følelser kan lure", + "Social Marketplace 2: Feelings can mislead", + ), + introText = + localizedOptional( + "Sterke følelser kan gjøre at vi glemmer å tenke kritisk.", + "Strong feelings can make us forget to think critically.", + ), + promptText = + localized( + "Hvilken melding prøver mest å manipulere følelsene dine?", + "Which message tries hardest to manipulate your feelings?", + ), + correctOption = + explainedOption( + 1, + "Hvis du bryr deg om vennene dine, må du dele dette akkurat nå!", + "If you care about your friends, you must share this right now!", + true, + "Den bruker skyldfølelse og hastverk for å presse deg.", + "It uses guilt and urgency to pressure you.", + ), + incorrectOption = + explainedOption( + 2, + "Se denne informasjonen og tenk over om den virker troverdig.", + "Look at this information and think about whether it seems trustworthy.", + false, + "Den prøver ikke å presse deg følelsesmessig.", + "It does not try to pressure you emotionally.", + ), ), binaryChoiceTask( difficultyLevel = 3, taskType = TaskType.SOCIAL_SCENARIO, - title = localized("Den sosiale markedsplassen 3: Stå imot gruppepress", "Social Marketplace 3: Resist peer pressure"), - introText = localizedOptional( - "Venner kan ta feil. Det er lov å stoppe opp.", - "Friends can be wrong. It is okay to pause.", - ), - promptText = localized( - "Hva er den beste reaksjonen når alle andre sier at et rykte må være sant?", - "What is the best response when everyone else says a rumor must be true?", - ), - correctOption = explainedOption( - 1, - "Be om kilde og vente med å dele til du vet mer.", - "Ask for a source and wait to share until you know more.", - true, - "Du trenger ikke følge gruppa når informasjonen er usikker.", - "You do not have to follow the group when the information is uncertain.", - ), - incorrectOption = explainedOption( - 2, - "Stole på ryktet fordi så mange mener det samme.", - "Trust the rumor because so many people agree.", - false, - "Mange like meninger er ikke det samme som bevis.", - "Many matching opinions are not the same as evidence.", - ), + title = + localized( + "Den sosiale markedsplassen 3: Stå imot gruppepress", + "Social Marketplace 3: Resist peer pressure", + ), + introText = + localizedOptional( + "Venner kan ta feil. Det er lov å stoppe opp.", + "Friends can be wrong. It is okay to pause.", + ), + promptText = + localized( + "Hva er den beste reaksjonen når alle andre sier at et rykte må være sant?", + "What is the best response when everyone else says a rumor must be true?", + ), + correctOption = + explainedOption( + 1, + "Be om kilde og vente med å dele til du vet mer.", + "Ask for a source and wait to share until you know more.", + true, + "Du trenger ikke følge gruppa når informasjonen er usikker.", + "You do not have to follow the group when the information is uncertain.", + ), + incorrectOption = + explainedOption( + 2, + "Stole på ryktet fordi så mange mener det samme.", + "Trust the rumor because so many people agree.", + false, + "Mange like meninger er ikke det samme som bevis.", + "Many matching opinions are not the same as evidence.", + ), ), ) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/FinalBossTaskSeeds.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/FinalBossTaskSeeds.kt index 8577bf9c..3a5b2620 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/FinalBossTaskSeeds.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/FinalBossTaskSeeds.kt @@ -7,115 +7,117 @@ internal fun finalBossTaskSeed(): SeedTask = difficultyLevel = 1, taskType = TaskType.FINAL_BOSS, title = localized("Datasenteret: Finalekamp", "Data Center: Final showdown"), - introText = localizedOptional( - "Datatyven bruker triks fra hele spillet. Stopp opp, tenk, og velg klokt.", - "The data thief uses tricks from the whole game. Pause, think, and choose carefully.", - ), - items = listOf( - binaryChoiceItem( - 1, - "Datatyven sender en overskrift med masse store bokstaver og sterke følelser. Hva er det første faresignalet?", - "The data thief sends a headline with lots of capital letters and strong feelings. What is the first warning sign?", - explainedOption( - 1, - "At overskriften prøver å få deg til å reagere før du tenker.", - "That the headline tries to make you react before you think.", - true, - "Sensasjonsspråk brukes ofte for å presse fram raske delinger.", - "Sensational language is often used to push quick sharing.", - ), - explainedOption( - 2, - "At teksten er kort.", - "That the text is short.", - false, - "Kort tekst er ikke i seg selv et faresignal.", - "Short text is not a warning sign by itself.", - ), - ), - binaryChoiceItem( - 2, - "Du får et bilde som ser ekte ut, men hendene har for mange fingre. Hva bør du tenke?", - "You get an image that looks real, but the hands have too many fingers. What should you think?", - explainedOption( - 1, - "At bildet kan være KI-generert eller manipulert.", - "That the image may be AI-generated or manipulated.", - true, - "Rare detaljer er et kjent tegn på kunstige bilder.", - "Odd details are a known sign of artificial images.", - ), - explainedOption( - 2, - "At personen bare beveget hendene fort.", - "That the person just moved their hands quickly.", - false, - "Det forklarer ikke ekstra fingre.", - "That does not explain extra fingers.", - ), + introText = + localizedOptional( + "Datatyven bruker triks fra hele spillet. Stopp opp, tenk, og velg klokt.", + "The data thief uses tricks from the whole game. Pause, think, and choose carefully.", ), - binaryChoiceItem( - 3, - "En e-post sier at du må klikke på en lenke med én gang. Hva gjør du?", - "An email says you must click a link right away. What do you do?", - explainedOption( + items = + listOf( + binaryChoiceItem( 1, - "Stopper opp og sjekker avsenderen før jeg gjør noe.", - "Pause and check the sender before I do anything.", - true, - "Hastverk er vanlig i svindel.", - "Urgency is common in scams.", + "Datatyven sender en overskrift med masse store bokstaver og sterke følelser. Hva er det første faresignalet?", + "The data thief sends a headline with lots of capital letters and strong feelings. What is the first warning sign?", + explainedOption( + 1, + "At overskriften prøver å få deg til å reagere før du tenker.", + "That the headline tries to make you react before you think.", + true, + "Sensasjonsspråk brukes ofte for å presse fram raske delinger.", + "Sensational language is often used to push quick sharing.", + ), + explainedOption( + 2, + "At teksten er kort.", + "That the text is short.", + false, + "Kort tekst er ikke i seg selv et faresignal.", + "Short text is not a warning sign by itself.", + ), ), - explainedOption( + binaryChoiceItem( 2, - "Klikker fort så meldingen forsvinner.", - "Click quickly so the message goes away.", - false, - "Det er akkurat det en svindler ønsker.", - "That is exactly what a scammer wants.", - ), - ), - binaryChoiceItem( - 4, - "En ukjent nettside bruker en pen logo, men har et rart domenenavn. Hva stoler du mest på?", - "An unknown website uses a nice logo, but has a strange domain name. What do you trust more?", - explainedOption( - 1, - "Domenenavnet og om informasjonen kan dobbeltsjekkes.", - "The domain name and whether the information can be double-checked.", - true, - "Utseende alene er ikke nok.", - "Appearance alone is not enough.", + "Du får et bilde som ser ekte ut, men hendene har for mange fingre. Hva bør du tenke?", + "You get an image that looks real, but the hands have too many fingers. What should you think?", + explainedOption( + 1, + "At bildet kan være KI-generert eller manipulert.", + "That the image may be AI-generated or manipulated.", + true, + "Rare detaljer er et kjent tegn på kunstige bilder.", + "Odd details are a known sign of artificial images.", + ), + explainedOption( + 2, + "At personen bare beveget hendene fort.", + "That the person just moved their hands quickly.", + false, + "Det forklarer ikke ekstra fingre.", + "That does not explain extra fingers.", + ), ), - explainedOption( - 2, - "Logoen, fordi profesjonelle logoer betyr at siden er trygg.", - "The logo, because professional logos mean the site is safe.", - false, - "Logoer kan kopieres.", - "Logos can be copied.", + binaryChoiceItem( + 3, + "En e-post sier at du må klikke på en lenke med én gang. Hva gjør du?", + "An email says you must click a link right away. What do you do?", + explainedOption( + 1, + "Stopper opp og sjekker avsenderen før jeg gjør noe.", + "Pause and check the sender before I do anything.", + true, + "Hastverk er vanlig i svindel.", + "Urgency is common in scams.", + ), + explainedOption( + 2, + "Klikker fort så meldingen forsvinner.", + "Click quickly so the message goes away.", + false, + "Det er akkurat det en svindler ønsker.", + "That is exactly what a scammer wants.", + ), ), - ), - binaryChoiceItem( - 5, - "Vennene dine sier du må dele et rykte nå. Hva er beste valg?", - "Your friends say you must share a rumor now. What is the best choice?", - explainedOption( - 1, - "Vent, spør etter kilden, og del bare hvis informasjonen kan bekreftes.", - "Wait, ask for the source, and only share if the information can be confirmed.", - true, - "Du stopper feilinformasjon fra å spre seg videre.", - "You stop misinformation from spreading further.", + binaryChoiceItem( + 4, + "En ukjent nettside bruker en pen logo, men har et rart domenenavn. Hva stoler du mest på?", + "An unknown website uses a nice logo, but has a strange domain name. What do you trust more?", + explainedOption( + 1, + "Domenenavnet og om informasjonen kan dobbeltsjekkes.", + "The domain name and whether the information can be double-checked.", + true, + "Utseende alene er ikke nok.", + "Appearance alone is not enough.", + ), + explainedOption( + 2, + "Logoen, fordi profesjonelle logoer betyr at siden er trygg.", + "The logo, because professional logos mean the site is safe.", + false, + "Logoer kan kopieres.", + "Logos can be copied.", + ), ), - explainedOption( - 2, - "Del det først og undersøk senere.", - "Share it first and investigate later.", - false, - "Da kan ryktet allerede ha spredt seg.", - "By then the rumor may already have spread.", + binaryChoiceItem( + 5, + "Vennene dine sier du må dele et rykte nå. Hva er beste valg?", + "Your friends say you must share a rumor now. What is the best choice?", + explainedOption( + 1, + "Vent, spør etter kilden, og del bare hvis informasjonen kan bekreftes.", + "Wait, ask for the source, and only share if the information can be confirmed.", + true, + "Du stopper feilinformasjon fra å spre seg videre.", + "You stop misinformation from spreading further.", + ), + explainedOption( + 2, + "Del det først og undersøk senere.", + "Share it first and investigate later.", + false, + "Da kan ryktet allerede ha spredt seg.", + "By then the rumor may already have spread.", + ), ), ), - ), ) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/FotografenTaskSeeds.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/FotografenTaskSeeds.kt index b7c4112d..47b4c062 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/FotografenTaskSeeds.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/FotografenTaskSeeds.kt @@ -9,123 +9,136 @@ internal fun fotografenTaskSeeds(): List = listOf( imageAssessmentTask( difficultyLevel = 1, - title = localized("Fotografen 1: KI, manipulert eller ekte", "The Photographer 1: AI, manipulated, or real"), - introText = localizedOptional( - "Se etter detaljer som ikke passer helt sammen.", - "Look for details that do not quite fit together.", - ), - promptText = localized( - "Hva slags bilde viser motivet?", - "What kind of image does the scene show?", - ), - mediaUrl = "https://res.cloudinary.com/jerrick/image/upload/d_642250b563292b35f27461a7.png,f_jpg,q_auto,w_720/680a88ffffc192001d18eaee.jpg", - correctImageOptionOrder = 1, - reasonOptions = listOf( - explainedOption( - 1, - "Bildet har flere unaturlige detaljer, som ekstra kroppsdeler, lys og skygger som ikke stemmer.", - "The image has several unnatural details, such as extra body parts, light, and shadows that do not match.", - true, - "Flere konkrete feil i samme bilde er et sterkt tegn på KI-generering.", - "Several concrete errors in the same image are a strong sign of AI generation.", + title = + localized( + "Fotografen 1: KI, manipulert eller ekte", + "The Photographer 1: AI, manipulated, or real", ), - explainedOption( - 2, - "Bildet er ekte fordi det har høy oppløsning og ser profesjonelt ut.", - "The image is real because it has high resolution and looks professional.", - false, - "Høy bildekvalitet er ikke nok til å avgjøre om et bilde er ekte.", - "High image quality is not enough to decide whether an image is real.", + introText = + localizedOptional( + "Se etter detaljer som ikke passer helt sammen.", + "Look for details that do not quite fit together.", ), - explainedOption( - 3, - "Bildet må være manipulert fordi alle bilder på nett kan være redigert.", - "The image must be manipulated because all online images can be edited.", - false, - "Mulighet for redigering er ikke det samme som konkrete tegn i bildet.", - "The possibility of editing is not the same as concrete evidence in the image.", + promptText = + localized( + "Hva slags bilde viser motivet?", + "What kind of image does the scene show?", + ), + mediaUrl = "https://res.cloudinary.com/jerrick/image/upload/d_642250b563292b35f27461a7.png,f_jpg,q_auto,w_720/680a88ffffc192001d18eaee.jpg", + correctImageOptionOrder = 1, + reasonOptions = + listOf( + explainedOption( + 1, + "Bildet har flere unaturlige detaljer, som ekstra kroppsdeler, lys og skygger som ikke stemmer.", + "The image has several unnatural details, such as extra body parts, light, and shadows that do not match.", + true, + "Flere konkrete feil i samme bilde er et sterkt tegn på KI-generering.", + "Several concrete errors in the same image are a strong sign of AI generation.", + ), + explainedOption( + 2, + "Bildet er ekte fordi det har høy oppløsning og ser profesjonelt ut.", + "The image is real because it has high resolution and looks professional.", + false, + "Høy bildekvalitet er ikke nok til å avgjøre om et bilde er ekte.", + "High image quality is not enough to decide whether an image is real.", + ), + explainedOption( + 3, + "Bildet må være manipulert fordi alle bilder på nett kan være redigert.", + "The image must be manipulated because all online images can be edited.", + false, + "Mulighet for redigering er ikke det samme som konkrete tegn i bildet.", + "The possibility of editing is not the same as concrete evidence in the image.", + ), ), - ), ), imageAssessmentTask( difficultyLevel = 2, title = localized("Fotografen 2: Finn manipulasjonen", "The Photographer 2: Find the manipulation"), - introText = localizedOptional( - "Velg vurderingen som passer best til sporene i bildet.", - "Choose the assessment that best fits the traces in the image.", - ), - promptText = localized( - "Hva passer best for dette bildet?", - "What best describes this image?", - ), - mediaUrl = "https://witonosfreestyle.wordpress.com/wp-content/uploads/2014/08/s8023188.jpg", - correctImageOptionOrder = 2, - reasonOptions = listOf( - explainedOption( - 1, - "Ujevne kanter og uskarp overgang rundt ett motiv tyder på at noe er klippet inn eller endret.", - "Uneven edges and a blurry transition around one subject suggest something was pasted in or edited.", - true, - "Dette er konkrete spor på manipulering av et bilde.", - "These are concrete signs of image manipulation.", + introText = + localizedOptional( + "Velg vurderingen som passer best til sporene i bildet.", + "Choose the assessment that best fits the traces in the image.", ), - explainedOption( - 2, - "Bildet er KI-generert fordi det finnes mennesker i motivet.", - "The image is AI-generated because there are people in the scene.", - false, - "Mennesker i et bilde er ikke i seg selv et tegn på KI.", - "People in an image are not by themselves a sign of AI.", + promptText = + localized( + "Hva passer best for dette bildet?", + "What best describes this image?", ), - explainedOption( - 3, - "Bildet er ekte fordi fargene ser naturlige ut.", - "The image is real because the colours look natural.", - false, - "Naturlige farger kan finnes både i ekte og redigerte bilder.", - "Natural colours can appear in both real and edited images.", + mediaUrl = "https://witonosfreestyle.wordpress.com/wp-content/uploads/2014/08/s8023188.jpg", + correctImageOptionOrder = 2, + reasonOptions = + listOf( + explainedOption( + 1, + "Ujevne kanter og uskarp overgang rundt ett motiv tyder på at noe er klippet inn eller endret.", + "Uneven edges and a blurry transition around one subject suggest something was pasted in or edited.", + true, + "Dette er konkrete spor på manipulering av et bilde.", + "These are concrete signs of image manipulation.", + ), + explainedOption( + 2, + "Bildet er KI-generert fordi det finnes mennesker i motivet.", + "The image is AI-generated because there are people in the scene.", + false, + "Mennesker i et bilde er ikke i seg selv et tegn på KI.", + "People in an image are not by themselves a sign of AI.", + ), + explainedOption( + 3, + "Bildet er ekte fordi fargene ser naturlige ut.", + "The image is real because the colours look natural.", + false, + "Naturlige farger kan finnes både i ekte og redigerte bilder.", + "Natural colours can appear in both real and edited images.", + ), ), - ), ), imageAssessmentTask( difficultyLevel = 3, title = localized("Fotografen 3: Begrunn vurderingen", "The Photographer 3: Justify the assessment"), - introText = localizedOptional( - "Ikke bare velg svar, men tenk over hvorfor.", - "Do not just pick an answer, think about why.", - ), - promptText = localized( - "Hvordan bør dette bildet vurderes?", - "How should this image be assessed?", - ), - mediaUrl = "https://static.boredpanda.com/blog/wp-content/uploads/2018/07/fb-image-sharing-dashboard_5b48c2cb8b9ed__700__700.jpg", - correctImageOptionOrder = 2, - reasonOptions = listOf( - explainedOption( - 1, - "Når bare enkelte deler ser feil ut, er det ofte et ekte bilde som er redigert i etterkant.", - "When only parts of the image look wrong, it is often a real photo that was edited afterward.", - true, - "Målrettede feil i enkelte områder peker ofte mot manipulering.", - "Targeted errors in specific areas often point to manipulation.", + introText = + localizedOptional( + "Ikke bare velg svar, men tenk over hvorfor.", + "Do not just pick an answer, think about why.", ), - explainedOption( - 2, - "Bildet er ekte så lenge det ikke har synlige KI-feil.", - "The image is real as long as it does not have visible AI errors.", - false, - "Et bilde kan være manipulert selv om det ikke er KI-generert.", - "An image can be manipulated even when it is not AI-generated.", + promptText = + localized( + "Hvordan bør dette bildet vurderes?", + "How should this image be assessed?", ), - explainedOption( - 3, - "Bildet er KI-generert fordi det virker litt rart.", - "The image is AI-generated because it feels a bit strange.", - false, - "Magefølelse alene er svakere enn konkrete observasjoner.", - "A gut feeling alone is weaker than concrete observations.", + mediaUrl = "https://static.boredpanda.com/blog/wp-content/uploads/2018/07/fb-image-sharing-dashboard_5b48c2cb8b9ed__700__700.jpg", + correctImageOptionOrder = 2, + reasonOptions = + listOf( + explainedOption( + 1, + "Når bare enkelte deler ser feil ut, er det ofte et ekte bilde som er redigert i etterkant.", + "When only parts of the image look wrong, it is often a real photo that was edited afterward.", + true, + "Målrettede feil i enkelte områder peker ofte mot manipulering.", + "Targeted errors in specific areas often point to manipulation.", + ), + explainedOption( + 2, + "Bildet er ekte så lenge det ikke har synlige KI-feil.", + "The image is real as long as it does not have visible AI errors.", + false, + "Et bilde kan være manipulert selv om det ikke er KI-generert.", + "An image can be manipulated even when it is not AI-generated.", + ), + explainedOption( + 3, + "Bildet er KI-generert fordi det virker litt rart.", + "The image is AI-generated because it feels a bit strange.", + false, + "Magefølelse alene er svakere enn konkrete observasjoner.", + "A gut feeling alone is weaker than concrete observations.", + ), ), - ), ), ) @@ -143,24 +156,26 @@ private fun imageAssessmentTask( taskType = TaskType.IMAGE_ASSESSMENT, title = title, introText = introText, - items = listOf( - SeedItem( - itemOrder = IMAGE_ITEM_ORDER, - promptText = promptText, - mediaUrl = mediaUrl, - metadataJson = """{"role":"imageChoice"}""", - options = imageTypeOptions(correctOptionOrder = correctImageOptionOrder), - ), - SeedItem( - itemOrder = REASON_ITEM_ORDER, - promptText = localized( - "Hvorfor er dette den beste vurderingen?", - "Why is this the best assessment?", + items = + listOf( + SeedItem( + itemOrder = IMAGE_ITEM_ORDER, + promptText = promptText, + mediaUrl = mediaUrl, + metadataJson = """{"role":"imageChoice"}""", + options = imageTypeOptions(correctOptionOrder = correctImageOptionOrder), + ), + SeedItem( + itemOrder = REASON_ITEM_ORDER, + promptText = + localized( + "Hvorfor er dette den beste vurderingen?", + "Why is this the best assessment?", + ), + metadataJson = """{"role":"reasonChoice"}""", + options = reasonOptions, ), - metadataJson = """{"role":"reasonChoice"}""", - options = reasonOptions, ), - ), ) private fun imageTypeOptions(correctOptionOrder: Int): List = diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/MarkedsplassenTaskSeeds.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/MarkedsplassenTaskSeeds.kt index 2d6dbd9f..62c11227 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/MarkedsplassenTaskSeeds.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/MarkedsplassenTaskSeeds.kt @@ -7,160 +7,196 @@ internal fun markedsplassenTaskSeeds(): List = // ── Task 1 — difficulty 1 — finn.no used bike ──────────────────────── webpageTask( difficultyLevel = 1, - title = localized( - "Markedsplassen 1: Ny selger på Finn", - "Market Square 1: New seller on Finn", - ), - introText = localizedOptional( - "Trykk på det som er verdt å legge merke til.", - "Tap the part that is worth noticing.", - ), + title = + localized( + "Markedsplassen 1: Ny selger på Finn", + "Market Square 1: New seller on Finn", + ), + introText = + localizedOptional( + "Trykk på det som er verdt å legge merke til.", + "Tap the part that is worth noticing.", + ), imageUrlNb = "/assets/stops/markedsplassen/task1-nb.png", imageUrlEn = "/assets/stops/markedsplassen/task1-en.png", - items = listOf( - hotspot( - itemOrder = 2, - x = 9.71, y = 48.28, width = 75.67, height = 13.09, - feedbackNb = "Kontoen ble opprettet i går og har ingen salgshistorikk. Det betyr ikke alltid svindel — men det er lurt å be om å betale ved henting, ikke på forhånd.", - feedbackEn = "The account was created yesterday and has no sales history. That does not always mean it is a scam — but it is smart to pay on pickup, not in advance.", + items = + listOf( + hotspot( + itemOrder = 2, + x = 9.71, + y = 48.28, + width = 75.67, + height = 13.09, + feedbackNb = "Kontoen ble opprettet i går og har ingen salgshistorikk. Det betyr ikke alltid svindel — men det er lurt å be om å betale ved henting, ikke på forhånd.", + feedbackEn = "The account was created yesterday and has no sales history. That does not always mean it is a scam — but it is smart to pay on pickup, not in advance.", + ), ), - ), ), - // ── Task 2 — difficulty 2 — fake Nike shoe webshop ────────────────── webpageTask( difficultyLevel = 2, - title = localized( - "Markedsplassen 2: Er denne nettbutikken ekte?", - "Market Square 2: Is this webshop real?", - ), - introText = localizedOptional( - "Trykk på delene av nettsiden som virker mistenkelige.", - "Tap the parts of the website that seem suspicious.", - ), - imageUrlNb = "/assets/stops/markedsplassen/task2-nb.png", - imageUrlEn = "/assets/stops/markedsplassen/task2-en.png", - items = listOf( - hotspot( - itemOrder = 2, - x = 11.70, y = 0.65, width = 85.96, height = 4.26, - feedbackNb = "Domenet slutter på .net og inneholder et kjent merkenavn — ikke det offisielle nettstedet. Ekte Nike-nettstedet er www.nike.com. Merkevare-squatting er et vanlig triks!", - feedbackEn = "The domain ends in .net and contains a well-known brand name — this is not the official website. The real Nike site is www.nike.com. Brand-name squatting is a common trick!", + title = + localized( + "Markedsplassen 2: Er denne nettbutikken ekte?", + "Market Square 2: Is this webshop real?", ), - hotspot( - itemOrder = 3, - x = 45.38, y = 18.49, width = 16.61, height = 9.33, - feedbackNb = "89 kr for en sko som normalt koster 1 699 kr? En rabatt på 95 % er et klart faresignal. Ekte nettbutikker har sjelden rabatter over 50–60 %.", - feedbackEn = "89 kr for a shoe that normally costs 1 699 kr? A 95 % discount is a clear warning sign. Real webshops rarely offer more than 50–60 % off.", + introText = + localizedOptional( + "Trykk på delene av nettsiden som virker mistenkelige.", + "Tap the parts of the website that seem suspicious.", ), - hotspot( - itemOrder = 4, - x = 67.02, y = 18.0, width = 16.96, height = 10.64, - feedbackNb = "Seriøse nettbutikker tilbyr kort eller faktura — ikke bare direkte overføring til et kontonummer. Det er nesten umulig å få pengene tilbake etterpå.", - feedbackEn = "Serious webshops offer card or invoice — not just a direct bank transfer to an account number. Getting your money back afterwards is nearly impossible.", - ), - hotspot( - itemOrder = 5, - x = 8.77, y = 67.10, width = 48.54, height = 27.0, - feedbackNb = "Tre identiske anmeldelser med ulike brukernavn er et tydelig faresignal. Ekte anmeldelser varierer i innhold og stil. Alle positive og ingen negative?", - feedbackEn = "Three identical reviews with different usernames is a clear warning sign. Real reviews vary in content and style. All positive and none negative?", - hasActionQuestion = true, - options = listOf( - explainedOption( - 1, - "Ikke handle — for mange faresignaler.", - "Don't buy — too many warning signs.", - true, - "Riktig! Mistenkelig URL, urealistisk pris, usikker betaling og kopierte anmeldelser er mer enn nok til å la være.", - "Correct! A suspicious URL, unrealistic price, unsafe payment, and copied reviews are more than enough reason to walk away.", - ), - explainedOption( - 2, - "Kjøpe — prisen er jo så lav!", - "Buy — the price is so low!", - false, - "Det er akkurat det svindlerne håper på. Du vil sannsynligvis aldri motta skoa, og pengene er borte.", - "That is exactly what the scammers are counting on. You will most likely never receive the shoes, and the money is gone.", - ), - explainedOption( - 3, - "Spørre en voksen og søke opp butikken.", - "Ask an adult and search for the shop.", - true, - "Også lurt! Et raskt søk avslører om det er en kjent svindel.", - "Also smart! A quick search reveals whether it is a known scam.", - ), + imageUrlNb = "/assets/stops/markedsplassen/task2-nb.png", + imageUrlEn = "/assets/stops/markedsplassen/task2-en.png", + items = + listOf( + hotspot( + itemOrder = 2, + x = 11.70, + y = 0.65, + width = 85.96, + height = 4.26, + feedbackNb = "Domenet slutter på .net og inneholder et kjent merkenavn — ikke det offisielle nettstedet. Ekte Nike-nettstedet er www.nike.com. Merkevare-squatting er et vanlig triks!", + feedbackEn = "The domain ends in .net and contains a well-known brand name — this is not the official website. The real Nike site is www.nike.com. Brand-name squatting is a common trick!", + ), + hotspot( + itemOrder = 3, + x = 45.38, + y = 18.49, + width = 16.61, + height = 9.33, + feedbackNb = "89 kr for en sko som normalt koster 1 699 kr? En rabatt på 95 % er et klart faresignal. Ekte nettbutikker har sjelden rabatter over 50–60 %.", + feedbackEn = "89 kr for a shoe that normally costs 1 699 kr? A 95 % discount is a clear warning sign. Real webshops rarely offer more than 50–60 % off.", + ), + hotspot( + itemOrder = 4, + x = 67.02, + y = 18.0, + width = 16.96, + height = 10.64, + feedbackNb = "Seriøse nettbutikker tilbyr kort eller faktura — ikke bare direkte overføring til et kontonummer. Det er nesten umulig å få pengene tilbake etterpå.", + feedbackEn = "Serious webshops offer card or invoice — not just a direct bank transfer to an account number. Getting your money back afterwards is nearly impossible.", + ), + hotspot( + itemOrder = 5, + x = 8.77, + y = 67.10, + width = 48.54, + height = 27.0, + feedbackNb = "Tre identiske anmeldelser med ulike brukernavn er et tydelig faresignal. Ekte anmeldelser varierer i innhold og stil. Alle positive og ingen negative?", + feedbackEn = "Three identical reviews with different usernames is a clear warning sign. Real reviews vary in content and style. All positive and none negative?", + hasActionQuestion = true, + options = + listOf( + explainedOption( + 1, + "Ikke handle — for mange faresignaler.", + "Don't buy — too many warning signs.", + true, + "Riktig! Mistenkelig URL, urealistisk pris, usikker betaling og kopierte anmeldelser er mer enn nok til å la være.", + "Correct! A suspicious URL, unrealistic price, unsafe payment, and copied reviews are more than enough reason to walk away.", + ), + explainedOption( + 2, + "Kjøpe — prisen er jo så lav!", + "Buy — the price is so low!", + false, + "Det er akkurat det svindlerne håper på. Du vil sannsynligvis aldri motta skoa, og pengene er borte.", + "That is exactly what the scammers are counting on. You will most likely never receive the shoes, and the money is gone.", + ), + explainedOption( + 3, + "Spørre en voksen og søke opp butikken.", + "Ask an adult and search for the shop.", + true, + "Også lurt! Et raskt søk avslører om det er en kjent svindel.", + "Also smart! A quick search reveals whether it is a known scam.", + ), + ), ), ), - ), ), - // ── Task 3 — difficulty 3 — convincing fake PS5 webshop ───────────── webpageTask( difficultyLevel = 3, - title = localized( - "Markedsplassen 3: Ser dette trygt ut?", - "Market Square 3: Does this look safe?", - ), - introText = localizedOptional( - "Trykk på delene av nettsiden som virker mistenkelige.", - "Tap the parts of the website that seem suspicious.", - ), - imageUrlNb = "/assets/stops/markedsplassen/task3-nb.png", - imageUrlEn = "/assets/stops/markedsplassen/task3-en.png", - items = listOf( - hotspot( - itemOrder = 2, - x = 11.70, y = 0.65, width = 85.96, height = 4.26, - feedbackNb = "Domenet er deals-no.com — ikke en kjent norsk butikk, og det slutter på .com, ikke .no. Prøv å søke opp butikknavnet for å se om den finnes fra før.", - feedbackEn = "The domain is deals-no.com — not a known Norwegian shop, and it ends in .com, not .no. Try searching for the shop name to see if it exists elsewhere.", + title = + localized( + "Markedsplassen 3: Ser dette trygt ut?", + "Market Square 3: Does this look safe?", ), - hotspot( - itemOrder = 3, - x = 45.38, y = 17.68, width = 16.61, height = 9.0, - feedbackNb = "2 499 kr for en PS5 som normalt koster over 5 000 kr er mistenkelig lavt. En rabatt på over 40 % kan virke fristende, men er sjelden ekte.", - feedbackEn = "2 499 kr for a PS5 that normally costs over 5 000 kr is suspiciously low. A discount of over 40 % can seem tempting but is rarely genuine.", + introText = + localizedOptional( + "Trykk på delene av nettsiden som virker mistenkelige.", + "Tap the parts of the website that seem suspicious.", ), - hotspot( - itemOrder = 4, - x = 67.02, y = 17.68, width = 17.78, height = 10.47, - feedbackNb = "Ingen angrerett og kun forhåndsbetaling betyr at du ikke kan klage eller få pengene tilbake hvis varen aldri kommer. Hva ville du gjort?", - feedbackEn = "No return policy and prepayment only means you cannot complain or get your money back if the item never arrives. What would you do?", - hasActionQuestion = true, - options = listOf( - explainedOption( - 1, - "Ikke kjøpe — noe stemmer ikke.", - "Don't buy — something is off.", - true, - "Riktig! Ukjent domene, unormalt lav pris og ingen angrerett er tre gode grunner til å la være.", - "Correct! Unknown domain, unusually low price, and no return policy are three good reasons to walk away.", - ), - explainedOption( - 2, - "Kjøpe — en PS5 for 2 499 kr er for godt til å gå glipp av!", - "Buy — a PS5 for 2 499 kr is too good to pass up!", - false, - "Dessverre er det akkurat slik svindlere tenker at du skal reagere. Du sender pengene og varen kommer aldri.", - "Unfortunately that is exactly how scammers want you to react. You send the money and the item never arrives.", - ), - explainedOption( - 3, - "Søke opp butikken og spørre en voksen først.", - "Search for the shop and ask an adult first.", - true, - "Lurt! Sjekk om butikken har ekte anmeldelser på Trustpilot eller andre steder før du bestemmer deg.", - "Smart! Check whether the shop has real reviews on Trustpilot or elsewhere before deciding.", - ), + imageUrlNb = "/assets/stops/markedsplassen/task3-nb.png", + imageUrlEn = "/assets/stops/markedsplassen/task3-en.png", + items = + listOf( + hotspot( + itemOrder = 2, + x = 11.70, + y = 0.65, + width = 85.96, + height = 4.26, + feedbackNb = "Domenet er deals-no.com — ikke en kjent norsk butikk, og det slutter på .com, ikke .no. Prøv å søke opp butikknavnet for å se om den finnes fra før.", + feedbackEn = "The domain is deals-no.com — not a known Norwegian shop, and it ends in .com, not .no. Try searching for the shop name to see if it exists elsewhere.", + ), + hotspot( + itemOrder = 3, + x = 45.38, + y = 17.68, + width = 16.61, + height = 9.0, + feedbackNb = "2 499 kr for en PS5 som normalt koster over 5 000 kr er mistenkelig lavt. En rabatt på over 40 % kan virke fristende, men er sjelden ekte.", + feedbackEn = "2 499 kr for a PS5 that normally costs over 5 000 kr is suspiciously low. A discount of over 40 % can seem tempting but is rarely genuine.", + ), + hotspot( + itemOrder = 4, + x = 67.02, + y = 17.68, + width = 17.78, + height = 10.47, + feedbackNb = "Ingen angrerett og kun forhåndsbetaling betyr at du ikke kan klage eller få pengene tilbake hvis varen aldri kommer. Hva ville du gjort?", + feedbackEn = "No return policy and prepayment only means you cannot complain or get your money back if the item never arrives. What would you do?", + hasActionQuestion = true, + options = + listOf( + explainedOption( + 1, + "Ikke kjøpe — noe stemmer ikke.", + "Don't buy — something is off.", + true, + "Riktig! Ukjent domene, unormalt lav pris og ingen angrerett er tre gode grunner til å la være.", + "Correct! Unknown domain, unusually low price, and no return policy are three good reasons to walk away.", + ), + explainedOption( + 2, + "Kjøpe — en PS5 for 2 499 kr er for godt til å gå glipp av!", + "Buy — a PS5 for 2 499 kr is too good to pass up!", + false, + "Dessverre er det akkurat slik svindlere tenker at du skal reagere. Du sender pengene og varen kommer aldri.", + "Unfortunately that is exactly how scammers want you to react. You send the money and the item never arrives.", + ), + explainedOption( + 3, + "Søke opp butikken og spørre en voksen først.", + "Search for the shop and ask an adult first.", + true, + "Lurt! Sjekk om butikken har ekte anmeldelser på Trustpilot eller andre steder før du bestemmer deg.", + "Smart! Check whether the shop has real reviews on Trustpilot or elsewhere before deciding.", + ), + ), + ), + hotspot( + itemOrder = 5, + x = 8.77, + y = 67.92, + width = 48.54, + height = 15.55, + feedbackNb = "Anmeldelsene er vage — ingen nevner produktet konkret eller beskriver levering. Slike anmeldelser er lette å fabrikkere.", + feedbackEn = "The reviews are vague — none mention the product specifically or describe delivery. Reviews like these are easy to fabricate.", ), ), - hotspot( - itemOrder = 5, - x = 8.77, y = 67.92, width = 48.54, height = 15.55, - feedbackNb = "Anmeldelsene er vage — ingen nevner produktet konkret eller beskriver levering. Slike anmeldelser er lette å fabrikkere.", - feedbackEn = "The reviews are vague — none mention the product specifically or describe delivery. Reviews like these are easy to fabricate.", - ), - ), ), ) @@ -179,14 +215,15 @@ private fun webpageTask( taskType = TaskType.IMAGE_SELECT, title = title, introText = introText, - items = listOf( - SeedItem( - itemOrder = 1, - promptText = localized("", ""), - metadataJson = """{"mediaUrl":{"nb":"$imageUrlNb","en":"$imageUrlEn"}}""", - options = emptyList(), - ), - ) + items, + items = + listOf( + SeedItem( + itemOrder = 1, + promptText = localized("", ""), + metadataJson = """{"mediaUrl":{"nb":"$imageUrlNb","en":"$imageUrlEn"}}""", + options = emptyList(), + ), + ) + items, ) private fun hotspot( @@ -200,15 +237,16 @@ private fun hotspot( hasActionQuestion: Boolean = false, options: List = emptyList(), ): SeedItem { - val meta = buildString { - append("""{"x":$x,"y":$y,"width":$width,"height":$height,"isHotspot":true""") - if (hasActionQuestion) append(""","hasActionQuestion":true""") - append("}") - } + val meta = + buildString { + append("""{"x":$x,"y":$y,"width":$width,"height":$height,"isHotspot":true""") + if (hasActionQuestion) append(""","hasActionQuestion":true""") + append("}") + } return SeedItem( itemOrder = itemOrder, promptText = localized(feedbackNb, feedbackEn), metadataJson = meta, options = options, ) -} \ No newline at end of file +} diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/NyhetskvartaleTaskSeeds.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/NyhetskvartaleTaskSeeds.kt index f733d064..b5dae79c 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/NyhetskvartaleTaskSeeds.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/NyhetskvartaleTaskSeeds.kt @@ -7,127 +7,146 @@ internal fun nyhetskvartaleTaskSeeds(): List = SeedTask( difficultyLevel = 1, taskType = TaskType.NEWS_COMPARISON, - title = localized("Nyhetskvartalet 1: Finn den troverdige saken", "News Quarter 1: Find the trustworthy story"), - introText = localizedOptional( - "Les de to artiklene og velg den som virker mest troverdig.", - "Read both articles and select the one that appears most trustworthy.", - ), - items = listOf( - SeedItem( - itemOrder = 1, - promptText = localized( - "Hvilken artikkel virker mest troverdig?", - "Which article appears most trustworthy?", - ), - metadataJson = """ - {"articles":[ - {"optionOrder":1,"domain":"helserevolusjonen.no","headline":{"nb":"HELT UTROLIG! Forskning beviser at sjokolade er sunt!","en":"UNBELIEVABLE! Research proves chocolate is healthy!"},"body":{"nb":"En ny, banebrekkende studie har ENDELIG bevist det vi alle har håpet på: sjokolade er like sunt som – eller kanskje til og med sunnere enn – grønnsaker! Legen som ledet forskningen ble selv overrasket over resultatene.\n\n\"Vi kan ikke tro det vi ser. Sjokolade kurerer nesten alt,\" skal en navnløs forsker ha sagt.\n\nEksperter fra hele verden er nå i sjokk. Det anbefales å spise minst 200 gram sjokolade daglig for å holde seg frisk. Grønnsaker kan trygt droppes!\nDel denne artikkelen med alle du kjenner FØR myndighetene sensurerer den – Big Pharma vil ikke at du skal vite dette!","en":"A groundbreaking new study has FINALLY proven what we all hoped: chocolate is just as healthy as - or perhaps even healthier than - vegetables! The doctor who led the research was surprised by the results.\n\n\"We cannot believe what we are seeing. Chocolate cures almost everything,\" an anonymous researcher allegedly said.\n\nExperts worldwide are now in shock. It is recommended to eat at least 200 grams of chocolate daily to stay healthy. Vegetables can safely be dropped!\nShare this article with everyone you know BEFORE the authorities censor it - Big Pharma does not want you to know this!"},"mediaUrl":null}, - {"optionOrder":2,"domain":"forskning.no","headline":{"nb":"Studie: Mørk sjokolade kan ha helsegevinster","en":"Study: Dark chocolate may have health benefits"},"body":{"nb":"En ny studie publisert i tidsskriftet Nutrients viser at flavonoider i mørk sjokolade (70 % kakao eller mer) kan bidra til lavere blodtrykk og bedre insulinfølsomhet hos noen voksne.\n\n\"Effekten er reell, men begrenset. Dette er ikke en erstatning for et sunt kosthold med grønnsaker, frukt og fiber,\" sier ernæringsforsker Tone Berg ved UiO.\n\nStudien fulgte 200 deltakere over 12 uker og målte blodtrykk og betennelsesmarkører. Forskerne understreker at porsjonsstørrelse er avgjørende – rundt 20–30 gram per dag var mengden brukt i forsøket. Helsedirektoratet minner om at sjokolade fortsatt er kaloririk og ikke inngår i de generelle kostrådene.","en":"A new study published in the journal Nutrients shows that flavonoids in dark chocolate (70% cocoa or more) may contribute to lower blood pressure and better insulin sensitivity in some adults.\n\n\"The effect is real but limited. This is not a replacement for a healthy diet with vegetables, fruit, and fibre,\" says nutritional researcher Tone Berg at UiO.\n\nThe study followed 200 participants over 12 weeks and measured blood pressure and inflammation markers. Researchers emphasise that portion size is decisive - around 20-30 grams per day was the amount used in the trial. The Directorate of Health notes that chocolate remains calorie-dense and is not part of general dietary guidelines."},"mediaUrl":"https://images.unsplash.com/photo-1481391319762-47dff72954d9?w=600&q=80"} - ]} - """.trimIndent(), - options = listOf( - explainedOption( - 1, - "HELT UTROLIG! Forskning beviser at sjokolade er sunt!\n\nEn ny, banebrekkende studie har ENDELIG bevist det vi alle har håpet på: sjokolade er like sunt som – eller kanskje til og med sunnere enn – grønnsaker! Legen som ledet forskningen ble selv overrasket over resultatene.\n\n\"Vi kan ikke tro det vi ser. Sjokolade kurerer nesten alt,\" skal en navnløs forsker ha sagt.\n\nEksperter fra hele verden er nå i sjokk. Det anbefales å spise minst 200 gram sjokolade daglig for å holde seg frisk. Grønnsaker kan trygt droppes!\nDel denne artikkelen med alle du kjenner FØR myndighetene sensurerer den – Big Pharma vil ikke at du skal vite dette!", - "UNBELIEVABLE! Research proves chocolate is healthy!\n\nA groundbreaking new study has FINALLY proven what we all hoped: chocolate is just as healthy as - or perhaps even healthier than - vegetables! The doctor who led the research was surprised by the results.\n\n\"We cannot believe what we are seeing. Chocolate cures almost everything,\" an anonymous researcher allegedly said.\n\nExperts worldwide are now in shock. It is recommended to eat at least 200 grams of chocolate daily to stay healthy. Vegetables can safely be dropped!\nShare this article with everyone you know BEFORE the authorities censor it - Big Pharma does not want you to know this!", - false, - "Denne saken bruker sensasjonelt språk, anonyme kilder og overdrevne påstander uten troverdige bevis.", - "This story uses sensational language, anonymous sources, and exaggerated claims without credible evidence.", - ), - explainedOption( - 2, - "Studie: Mørk sjokolade kan ha helsegevinster\n\nEn ny studie publisert i tidsskriftet Nutrients viser at flavonoider i mørk sjokolade (70 % kakao eller mer) kan bidra til lavere blodtrykk og bedre insulinfølsomhet hos noen voksne.\n\n\"Effekten er reell, men begrenset. Dette er ikke en erstatning for et sunt kosthold med grønnsaker, frukt og fiber,\" sier ernæringsforsker Tone Berg ved UiO.\n\nStudien fulgte 200 deltakere over 12 uker og målte blodtrykk og betennelsesmarkører. Forskerne understreker at porsjonsstørrelse er avgjørende – rundt 20–30 gram per dag var mengden brukt i forsøket. Helsedirektoratet minner om at sjokolade fortsatt er kaloririk og ikke inngår i de generelle kostrådene.", - "Study: Dark chocolate may have health benefits\n\nA new study published in the journal Nutrients shows that flavonoids in dark chocolate (70% cocoa or more) may contribute to lower blood pressure and better insulin sensitivity in some adults.\n\n\"The effect is real but limited. This is not a replacement for a healthy diet with vegetables, fruit, and fibre,\" says nutritional researcher Tone Berg at UiO.\n\nThe study followed 200 participants over 12 weeks and measured blood pressure and inflammation markers. Researchers emphasise that portion size is decisive - around 20-30 grams per day was the amount used in the trial. The Directorate of Health notes that chocolate remains calorie-dense and is not part of general dietary guidelines.", - true, - "Denne saken viser til en konkret studie, oppgir kilde og forklarer begrensningene i funnene.", - "This story cites a concrete study, names the source, and explains the limits of the findings.", - ), + title = + localized( + "Nyhetskvartalet 1: Finn den troverdige saken", + "News Quarter 1: Find the trustworthy story", + ), + introText = + localizedOptional( + "Les de to artiklene og velg den som virker mest troverdig.", + "Read both articles and select the one that appears most trustworthy.", + ), + items = + listOf( + SeedItem( + itemOrder = 1, + promptText = + localized( + "Hvilken artikkel virker mest troverdig?", + "Which article appears most trustworthy?", + ), + metadataJson = + """ + {"articles":[ + {"optionOrder":1,"domain":"helserevolusjonen.no","headline":{"nb":"HELT UTROLIG! Forskning beviser at sjokolade er sunt!","en":"UNBELIEVABLE! Research proves chocolate is healthy!"},"body":{"nb":"En ny, banebrekkende studie har ENDELIG bevist det vi alle har håpet på: sjokolade er like sunt som – eller kanskje til og med sunnere enn – grønnsaker! Legen som ledet forskningen ble selv overrasket over resultatene.\n\n\"Vi kan ikke tro det vi ser. Sjokolade kurerer nesten alt,\" skal en navnløs forsker ha sagt.\n\nEksperter fra hele verden er nå i sjokk. Det anbefales å spise minst 200 gram sjokolade daglig for å holde seg frisk. Grønnsaker kan trygt droppes!\nDel denne artikkelen med alle du kjenner FØR myndighetene sensurerer den – Big Pharma vil ikke at du skal vite dette!","en":"A groundbreaking new study has FINALLY proven what we all hoped: chocolate is just as healthy as - or perhaps even healthier than - vegetables! The doctor who led the research was surprised by the results.\n\n\"We cannot believe what we are seeing. Chocolate cures almost everything,\" an anonymous researcher allegedly said.\n\nExperts worldwide are now in shock. It is recommended to eat at least 200 grams of chocolate daily to stay healthy. Vegetables can safely be dropped!\nShare this article with everyone you know BEFORE the authorities censor it - Big Pharma does not want you to know this!"},"mediaUrl":null}, + {"optionOrder":2,"domain":"forskning.no","headline":{"nb":"Studie: Mørk sjokolade kan ha helsegevinster","en":"Study: Dark chocolate may have health benefits"},"body":{"nb":"En ny studie publisert i tidsskriftet Nutrients viser at flavonoider i mørk sjokolade (70 % kakao eller mer) kan bidra til lavere blodtrykk og bedre insulinfølsomhet hos noen voksne.\n\n\"Effekten er reell, men begrenset. Dette er ikke en erstatning for et sunt kosthold med grønnsaker, frukt og fiber,\" sier ernæringsforsker Tone Berg ved UiO.\n\nStudien fulgte 200 deltakere over 12 uker og målte blodtrykk og betennelsesmarkører. Forskerne understreker at porsjonsstørrelse er avgjørende – rundt 20–30 gram per dag var mengden brukt i forsøket. Helsedirektoratet minner om at sjokolade fortsatt er kaloririk og ikke inngår i de generelle kostrådene.","en":"A new study published in the journal Nutrients shows that flavonoids in dark chocolate (70% cocoa or more) may contribute to lower blood pressure and better insulin sensitivity in some adults.\n\n\"The effect is real but limited. This is not a replacement for a healthy diet with vegetables, fruit, and fibre,\" says nutritional researcher Tone Berg at UiO.\n\nThe study followed 200 participants over 12 weeks and measured blood pressure and inflammation markers. Researchers emphasise that portion size is decisive - around 20-30 grams per day was the amount used in the trial. The Directorate of Health notes that chocolate remains calorie-dense and is not part of general dietary guidelines."},"mediaUrl":"https://images.unsplash.com/photo-1481391319762-47dff72954d9?w=600&q=80"} + ]} + """.trimIndent(), + options = + listOf( + explainedOption( + 1, + "HELT UTROLIG! Forskning beviser at sjokolade er sunt!\n\nEn ny, banebrekkende studie har ENDELIG bevist det vi alle har håpet på: sjokolade er like sunt som – eller kanskje til og med sunnere enn – grønnsaker! Legen som ledet forskningen ble selv overrasket over resultatene.\n\n\"Vi kan ikke tro det vi ser. Sjokolade kurerer nesten alt,\" skal en navnløs forsker ha sagt.\n\nEksperter fra hele verden er nå i sjokk. Det anbefales å spise minst 200 gram sjokolade daglig for å holde seg frisk. Grønnsaker kan trygt droppes!\nDel denne artikkelen med alle du kjenner FØR myndighetene sensurerer den – Big Pharma vil ikke at du skal vite dette!", + "UNBELIEVABLE! Research proves chocolate is healthy!\n\nA groundbreaking new study has FINALLY proven what we all hoped: chocolate is just as healthy as - or perhaps even healthier than - vegetables! The doctor who led the research was surprised by the results.\n\n\"We cannot believe what we are seeing. Chocolate cures almost everything,\" an anonymous researcher allegedly said.\n\nExperts worldwide are now in shock. It is recommended to eat at least 200 grams of chocolate daily to stay healthy. Vegetables can safely be dropped!\nShare this article with everyone you know BEFORE the authorities censor it - Big Pharma does not want you to know this!", + false, + "Denne saken bruker sensasjonelt språk, anonyme kilder og overdrevne påstander uten troverdige bevis.", + "This story uses sensational language, anonymous sources, and exaggerated claims without credible evidence.", + ), + explainedOption( + 2, + "Studie: Mørk sjokolade kan ha helsegevinster\n\nEn ny studie publisert i tidsskriftet Nutrients viser at flavonoider i mørk sjokolade (70 % kakao eller mer) kan bidra til lavere blodtrykk og bedre insulinfølsomhet hos noen voksne.\n\n\"Effekten er reell, men begrenset. Dette er ikke en erstatning for et sunt kosthold med grønnsaker, frukt og fiber,\" sier ernæringsforsker Tone Berg ved UiO.\n\nStudien fulgte 200 deltakere over 12 uker og målte blodtrykk og betennelsesmarkører. Forskerne understreker at porsjonsstørrelse er avgjørende – rundt 20–30 gram per dag var mengden brukt i forsøket. Helsedirektoratet minner om at sjokolade fortsatt er kaloririk og ikke inngår i de generelle kostrådene.", + "Study: Dark chocolate may have health benefits\n\nA new study published in the journal Nutrients shows that flavonoids in dark chocolate (70% cocoa or more) may contribute to lower blood pressure and better insulin sensitivity in some adults.\n\n\"The effect is real but limited. This is not a replacement for a healthy diet with vegetables, fruit, and fibre,\" says nutritional researcher Tone Berg at UiO.\n\nThe study followed 200 participants over 12 weeks and measured blood pressure and inflammation markers. Researchers emphasise that portion size is decisive - around 20-30 grams per day was the amount used in the trial. The Directorate of Health notes that chocolate remains calorie-dense and is not part of general dietary guidelines.", + true, + "Denne saken viser til en konkret studie, oppgir kilde og forklarer begrensningene i funnene.", + "This story cites a concrete study, names the source, and explains the limits of the findings.", + ), + ), ), ), - ), ), SeedTask( difficultyLevel = 2, taskType = TaskType.NEWS_COMPARISON, title = localized("Nyhetskvartalet 2: Oppdag clickbait", "News Quarter 2: Spot clickbait"), - introText = localizedOptional( - "Velg artikkelen som bruker seriøst og nøytralt språk.", - "Select the article that uses serious and neutral language.", - ), - items = listOf( - SeedItem( - itemOrder = 1, - promptText = localized( - "Hvilken artikkel bruker mest seriøst og nøytralt språk?", - "Which article uses the most serious and neutral language?", - ), - metadataJson = """ - {"articles":[ - {"optionOrder":1,"domain":"helseavsloringer.net","headline":{"nb":"LEGEN HAR IKKE FORTALT DEG DETTE! Én enkel ting kurerer alle sykdommer","en":"YOUR DOCTOR NEVER TOLD YOU THIS! One simple thing cures all diseases"},"body":{"nb":"Legemiddelindustrien skjuler en hemmelighet som kan endre livet ditt. En ukjent doktor avslørte nylig at du ALDRI trenger å bli syk igjen. Klikk her for å finne ut hva Big Pharma ikke vil du skal vite.","en":"The pharmaceutical industry is hiding a secret that could change your life. An unknown doctor recently revealed you NEVER have to get sick again. Click here to find out what Big Pharma does not want you to know."},"mediaUrl":null}, - {"optionOrder":2,"domain":"fhi.no","headline":{"nb":"Ny rapport: Regelmessig mosjon reduserer risiko for hjerte- og karsykdommer","en":"New report: Regular exercise reduces risk of cardiovascular disease"},"body":{"nb":"Folkehelseinstituttet presenterte i dag en ny rapport som viser at 150 minutter moderat fysisk aktivitet per uke er forbundet med 35 prosent lavere risiko for hjerteinfarkt hos voksne over 40 år. Rapporten bygger på data fra over 50 000 nordmenn fulgt over ti år.","en":"The Norwegian Institute of Public Health today presented a new report showing that 150 minutes of moderate physical activity per week is associated with a 35 percent lower risk of heart attack in adults over 40. The report is based on data from over 50,000 Norwegians followed over ten years."},"mediaUrl":null} - ]} - """.trimIndent(), - options = listOf( - explainedOption( - 1, - "LEGEN HAR IKKE FORTALT DEG DETTE! Én enkel ting kurerer alle sykdommer\n\nLegemiddelindustrien skjuler en hemmelighet som kan endre livet ditt. En ukjent doktor avslørte nylig at du ALDRI trenger å bli syk igjen. Klikk her for å finne ut hva Big Pharma ikke vil du skal vite.", - "YOUR DOCTOR NEVER TOLD YOU THIS! One simple thing cures all diseases\n\nThe pharmaceutical industry is hiding a secret that could change your life. An unknown doctor recently revealed you NEVER have to get sick again. Click here to find out what Big Pharma does not want you to know.", - false, - "Denne saken bruker clickbait, sterke følelsesord og udokumenterte påstander for å lokke deg til å klikke.", - "This story uses clickbait, emotional language, and unsupported claims to make you click.", - ), - explainedOption( - 2, - "Ny rapport: Regelmessig mosjon reduserer risiko for hjerte- og karsykdommer\n\nFolkehelseinstituttet presenterte i dag en ny rapport som viser at 150 minutter moderat fysisk aktivitet per uke er forbundet med 35 prosent lavere risiko for hjerteinfarkt hos voksne over 40 år. Rapporten bygger på data fra over 50 000 nordmenn fulgt over ti år.", - "New report: Regular exercise reduces risk of cardiovascular disease\n\nThe Norwegian Institute of Public Health today presented a new report showing that 150 minutes of moderate physical activity per week is associated with a 35 percent lower risk of heart attack in adults over 40. The report is based on data from over 50,000 Norwegians followed over ten years.", - true, - "Denne saken bruker nøkternt språk, oppgir avsender og beskriver datagrunnlaget på en etterprøvbar måte.", - "This story uses neutral language, names the source, and describes the evidence in a verifiable way.", - ), + introText = + localizedOptional( + "Velg artikkelen som bruker seriøst og nøytralt språk.", + "Select the article that uses serious and neutral language.", + ), + items = + listOf( + SeedItem( + itemOrder = 1, + promptText = + localized( + "Hvilken artikkel bruker mest seriøst og nøytralt språk?", + "Which article uses the most serious and neutral language?", + ), + metadataJson = + """ + {"articles":[ + {"optionOrder":1,"domain":"helseavsloringer.net","headline":{"nb":"LEGEN HAR IKKE FORTALT DEG DETTE! Én enkel ting kurerer alle sykdommer","en":"YOUR DOCTOR NEVER TOLD YOU THIS! One simple thing cures all diseases"},"body":{"nb":"Legemiddelindustrien skjuler en hemmelighet som kan endre livet ditt. En ukjent doktor avslørte nylig at du ALDRI trenger å bli syk igjen. Klikk her for å finne ut hva Big Pharma ikke vil du skal vite.","en":"The pharmaceutical industry is hiding a secret that could change your life. An unknown doctor recently revealed you NEVER have to get sick again. Click here to find out what Big Pharma does not want you to know."},"mediaUrl":null}, + {"optionOrder":2,"domain":"fhi.no","headline":{"nb":"Ny rapport: Regelmessig mosjon reduserer risiko for hjerte- og karsykdommer","en":"New report: Regular exercise reduces risk of cardiovascular disease"},"body":{"nb":"Folkehelseinstituttet presenterte i dag en ny rapport som viser at 150 minutter moderat fysisk aktivitet per uke er forbundet med 35 prosent lavere risiko for hjerteinfarkt hos voksne over 40 år. Rapporten bygger på data fra over 50 000 nordmenn fulgt over ti år.","en":"The Norwegian Institute of Public Health today presented a new report showing that 150 minutes of moderate physical activity per week is associated with a 35 percent lower risk of heart attack in adults over 40. The report is based on data from over 50,000 Norwegians followed over ten years."},"mediaUrl":null} + ]} + """.trimIndent(), + options = + listOf( + explainedOption( + 1, + "LEGEN HAR IKKE FORTALT DEG DETTE! Én enkel ting kurerer alle sykdommer\n\nLegemiddelindustrien skjuler en hemmelighet som kan endre livet ditt. En ukjent doktor avslørte nylig at du ALDRI trenger å bli syk igjen. Klikk her for å finne ut hva Big Pharma ikke vil du skal vite.", + "YOUR DOCTOR NEVER TOLD YOU THIS! One simple thing cures all diseases\n\nThe pharmaceutical industry is hiding a secret that could change your life. An unknown doctor recently revealed you NEVER have to get sick again. Click here to find out what Big Pharma does not want you to know.", + false, + "Denne saken bruker clickbait, sterke følelsesord og udokumenterte påstander for å lokke deg til å klikke.", + "This story uses clickbait, emotional language, and unsupported claims to make you click.", + ), + explainedOption( + 2, + "Ny rapport: Regelmessig mosjon reduserer risiko for hjerte- og karsykdommer\n\nFolkehelseinstituttet presenterte i dag en ny rapport som viser at 150 minutter moderat fysisk aktivitet per uke er forbundet med 35 prosent lavere risiko for hjerteinfarkt hos voksne over 40 år. Rapporten bygger på data fra over 50 000 nordmenn fulgt over ti år.", + "New report: Regular exercise reduces risk of cardiovascular disease\n\nThe Norwegian Institute of Public Health today presented a new report showing that 150 minutes of moderate physical activity per week is associated with a 35 percent lower risk of heart attack in adults over 40. The report is based on data from over 50,000 Norwegians followed over ten years.", + true, + "Denne saken bruker nøkternt språk, oppgir avsender og beskriver datagrunnlaget på en etterprøvbar måte.", + "This story uses neutral language, names the source, and describes the evidence in a verifiable way.", + ), + ), ), ), - ), ), SeedTask( difficultyLevel = 3, taskType = TaskType.NEWS_COMPARISON, title = localized("Nyhetskvartalet 3: Følelser og fakta", "News Quarter 3: Feelings and facts"), - introText = localizedOptional( - "Velg teksten som bruker mest fakta og minst følelsespress.", - "Choose the text that uses the most facts and the least emotional pressure.", - ), - items = listOf( - SeedItem( - itemOrder = 1, - promptText = localized( - "Hvilken tekst bruker mest fakta og minst følelsespress?", - "Which text uses the most facts and the least emotional pressure?", - ), - metadataJson = """ - {"articles":[ - {"optionOrder":1,"domain":null,"headline":{"nb":"Tre klasser testet lesetidene sine i to uker","en":"Three classes tested their reading times for two weeks"},"body":{"nb":"og skrev ned resultatene.","en":"and wrote down the results."},"mediaUrl":null}, - {"optionOrder":2,"domain":null,"headline":{"nb":"Alle er rasende etter den utrolig skumle nyheten","en":"Everyone is furious after the incredibly scary news"},"body":{"nb":"som sprer seg overalt.","en":"spreading everywhere."},"mediaUrl":null} - ]} - """.trimIndent(), - options = listOf( - explainedOption( - 1, - "Tre klasser testet lesetidene sine i to uker og skrev ned resultatene.", - "Three classes tested their reading times for two weeks and wrote down the results.", - true, - "Denne teksten beskriver observerbare fakta uten å presse fram en følelsesmessig reaksjon.", - "This text describes observable facts without trying to force an emotional reaction.", - ), - explainedOption( - 2, - "Alle er rasende etter den utrolig skumle nyheten som sprer seg overalt.", - "Everyone is furious after the incredibly scary news spreading everywhere.", - false, - "Denne teksten bruker følelsesladde ord som 'rasende' og 'skumle' i stedet for konkrete fakta.", - "This text uses emotional words like 'furious' and 'scary' instead of concrete facts.", - ), + introText = + localizedOptional( + "Velg teksten som bruker mest fakta og minst følelsespress.", + "Choose the text that uses the most facts and the least emotional pressure.", + ), + items = + listOf( + SeedItem( + itemOrder = 1, + promptText = + localized( + "Hvilken tekst bruker mest fakta og minst følelsespress?", + "Which text uses the most facts and the least emotional pressure?", + ), + metadataJson = + """ + {"articles":[ + {"optionOrder":1,"domain":null,"headline":{"nb":"Tre klasser testet lesetidene sine i to uker","en":"Three classes tested their reading times for two weeks"},"body":{"nb":"og skrev ned resultatene.","en":"and wrote down the results."},"mediaUrl":null}, + {"optionOrder":2,"domain":null,"headline":{"nb":"Alle er rasende etter den utrolig skumle nyheten","en":"Everyone is furious after the incredibly scary news"},"body":{"nb":"som sprer seg overalt.","en":"spreading everywhere."},"mediaUrl":null} + ]} + """.trimIndent(), + options = + listOf( + explainedOption( + 1, + "Tre klasser testet lesetidene sine i to uker og skrev ned resultatene.", + "Three classes tested their reading times for two weeks and wrote down the results.", + true, + "Denne teksten beskriver observerbare fakta uten å presse fram en følelsesmessig reaksjon.", + "This text describes observable facts without trying to force an emotional reaction.", + ), + explainedOption( + 2, + "Alle er rasende etter den utrolig skumle nyheten som sprer seg overalt.", + "Everyone is furious after the incredibly scary news spreading everywhere.", + false, + "Denne teksten bruker følelsesladde ord som 'rasende' og 'skumle' i stedet for konkrete fakta.", + "This text uses emotional words like 'furious' and 'scary' instead of concrete facts.", + ), + ), ), ), - ), ), ) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/PassordbankenTaskSeeds.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/PassordbankenTaskSeeds.kt index 2c07e9c4..609e20bb 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/PassordbankenTaskSeeds.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/PassordbankenTaskSeeds.kt @@ -8,127 +8,144 @@ internal fun passordbankenTaskSeeds(): List = difficultyLevel = 1, taskType = TaskType.PASSWORD_EVALUATION, title = localized("Passordbanken 1: Sterkt eller svakt", "Password Bank 1: Strong or weak"), - introText = localizedOptional( - "Et godt passord er vanskelig å gjette og ikke knyttet til deg personlig.", - "A good password is hard to guess and not tied to personal information.", - ), - promptText = localized( - "Hvilket passord er sterkest?", - "Which password is the strongest?", - ), - correctOption = explainedOption( - 1, - "Skois-Pizza7-Lyseblå", - "Skois-Pizza7-Lyseblå", - true, - "Det er langt, uvanlig og lettere å huske som en frase.", - "It is long, unusual, and easier to remember as a phrase.", - ), - incorrectOption = explainedOption( - 2, - "Ada2015", - "Ada2015", - false, - "Navn og årstall er lettere å gjette.", - "Names and years are easier to guess.", - ), + introText = + localizedOptional( + "Et godt passord er vanskelig å gjette og ikke knyttet til deg personlig.", + "A good password is hard to guess and not tied to personal information.", + ), + promptText = + localized( + "Hvilket passord er sterkest?", + "Which password is the strongest?", + ), + correctOption = + explainedOption( + 1, + "Skois-Pizza7-Lyseblå", + "Skois-Pizza7-Lyseblå", + true, + "Det er langt, uvanlig og lettere å huske som en frase.", + "It is long, unusual, and easier to remember as a phrase.", + ), + incorrectOption = + explainedOption( + 2, + "Ada2015", + "Ada2015", + false, + "Navn og årstall er lettere å gjette.", + "Names and years are easier to guess.", + ), ), binaryChoiceTask( difficultyLevel = 2, taskType = TaskType.PASSWORD_EVALUATION, title = localized("Passordbanken 2: Ikke bruk samme overalt", "Password Bank 2: Do not reuse passwords"), - introText = localizedOptional( - "Hvis ett sted blir hacket, kan samme passord brukes mot flere kontoer.", - "If one place is hacked, the same password can be used against several accounts.", - ), - promptText = localized( - "Hvorfor er det lurt å ha ulike passord på ulike steder?", - "Why is it smart to use different passwords in different places?", - ), - correctOption = explainedOption( - 1, - "Fordi ett lekket passord ellers kan gi tilgang til flere kontoer.", - "Because one leaked password could otherwise unlock several accounts.", - true, - "Passordgjenbruk sprer problemet fra ett sted til mange.", - "Password reuse spreads the problem from one place to many.", - ), - incorrectOption = explainedOption( - 2, - "Fordi nettsider blir forvirret hvis du bruker samme passord.", - "Because websites get confused if you use the same password.", - false, - "Det er ikke den virkelige risikoen.", - "That is not the real risk.", - ), + introText = + localizedOptional( + "Hvis ett sted blir hacket, kan samme passord brukes mot flere kontoer.", + "If one place is hacked, the same password can be used against several accounts.", + ), + promptText = + localized( + "Hvorfor er det lurt å ha ulike passord på ulike steder?", + "Why is it smart to use different passwords in different places?", + ), + correctOption = + explainedOption( + 1, + "Fordi ett lekket passord ellers kan gi tilgang til flere kontoer.", + "Because one leaked password could otherwise unlock several accounts.", + true, + "Passordgjenbruk sprer problemet fra ett sted til mange.", + "Password reuse spreads the problem from one place to many.", + ), + incorrectOption = + explainedOption( + 2, + "Fordi nettsider blir forvirret hvis du bruker samme passord.", + "Because websites get confused if you use the same password.", + false, + "Det er ikke den virkelige risikoen.", + "That is not the real risk.", + ), ), binaryChoiceTask( difficultyLevel = 3, taskType = TaskType.PASSWORD_EVALUATION, title = localized("Passordbanken 3: Personlig info er farlig", "Password Bank 3: Personal info is risky"), - introText = localizedOptional( - "Ikke bruk ting andre lett kan vite om deg.", - "Do not use things others can easily know about you.", - ), - promptText = localized( - "Hvilken informasjon bør du unngå i passordet ditt?", - "Which information should you avoid in your password?", - ), - correctOption = explainedOption( - 1, - "Navn, fødselsdato og favorittlag som andre kan gjette.", - "Names, birth dates, and favorite teams that others can guess.", - true, - "Personlig informasjon gjør passordet lettere å finne ut av.", - "Personal information makes the password easier to figure out.", - ), - incorrectOption = explainedOption( - 2, - "Ord som bare gir mening for deg når de kombineres i en lang frase.", - "Words that only make sense to you when combined into a long phrase.", - false, - "En lang frase kan være et godt valg.", - "A long phrase can be a good choice.", - ), + introText = + localizedOptional( + "Ikke bruk ting andre lett kan vite om deg.", + "Do not use things others can easily know about you.", + ), + promptText = + localized( + "Hvilken informasjon bør du unngå i passordet ditt?", + "Which information should you avoid in your password?", + ), + correctOption = + explainedOption( + 1, + "Navn, fødselsdato og favorittlag som andre kan gjette.", + "Names, birth dates, and favorite teams that others can guess.", + true, + "Personlig informasjon gjør passordet lettere å finne ut av.", + "Personal information makes the password easier to figure out.", + ), + incorrectOption = + explainedOption( + 2, + "Ord som bare gir mening for deg når de kombineres i en lang frase.", + "Words that only make sense to you when combined into a long phrase.", + false, + "En lang frase kan være et godt valg.", + "A long phrase can be a good choice.", + ), ), SeedTask( difficultyLevel = 4, taskType = TaskType.PASSWORD_EVALUATION, - title = localized( - "Passordbanken 4: Lag et sterkt passord", - "Password Bank 4: Build a strong password", - ), - introText = localizedOptional( - "Lag et passord som klarer alle reglene.", - "Create a password that passes all the rules.", - ), - items = listOf( - SeedItem( - itemOrder = 1, - promptText = localized( - "Skriv et passord med minst 10 tegn, stor bokstav, liten bokstav, tall og spesialtegn.", - "Create a password with at least 10 characters, an uppercase letter, a lowercase letter, a number, and a special character.", - ), - metadataJson = """{"role":"passwordBuilder","passOptionOrder":1,"failOptionOrder":2}""", - options = listOf( - explainedOption( - 1, - "Godkjent", - "Approved", - true, - "Passordet oppfyller alle reglene.", - "The password meets all the rules.", - ), - explainedOption( - 2, - "Ikke godkjent ennå", - "Not approved yet", - false, - "Passordet mangler fortsatt ett eller flere krav.", - "The password is still missing one or more requirements.", - ), + title = + localized( + "Passordbanken 4: Lag et sterkt passord", + "Password Bank 4: Build a strong password", + ), + introText = + localizedOptional( + "Lag et passord som klarer alle reglene.", + "Create a password that passes all the rules.", + ), + items = + listOf( + SeedItem( + itemOrder = 1, + promptText = + localized( + "Skriv et passord med minst 10 tegn, stor bokstav, liten bokstav, tall og spesialtegn.", + "Create a password with at least 10 characters, an uppercase letter, a lowercase letter, a number, and a special character.", + ), + metadataJson = """{"role":"passwordBuilder","passOptionOrder":1,"failOptionOrder":2}""", + options = + listOf( + explainedOption( + 1, + "Godkjent", + "Approved", + true, + "Passordet oppfyller alle reglene.", + "The password meets all the rules.", + ), + explainedOption( + 2, + "Ikke godkjent ennå", + "Not approved yet", + false, + "Passordet mangler fortsatt ett eller flere krav.", + "The password is still missing one or more requirements.", + ), + ), ), ), - ), - ) + ), ) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/PostkontoretTaskSeeds.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/PostkontoretTaskSeeds.kt index 3ff4d5e7..85f164f4 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/PostkontoretTaskSeeds.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/PostkontoretTaskSeeds.kt @@ -7,197 +7,234 @@ internal fun postkontoretTaskSeeds(): List = // ── Task 1 — difficulty 1 — camera competition scam ───────────────── imageSelectTask( difficultyLevel = 1, - title = localized( - "Postkontoret 1: Du har vunnet!", - "Post Office 1: You have won!", - ), - introText = localizedOptional( - "Les e-posten og trykk på delene som virker mistenkelige.", - "Read the email and tap the parts that seem suspicious.", - ), - imageUrlNb = "/assets/stops/postkontoret/task1-nb.png", - imageUrlEn = "/assets/stops/postkontoret/task1-en.png", - items = listOf( - hotspot( - itemOrder = 2, - x = 8.24, y = 5.37, width = 81.86, height = 5.37, - feedbackNb = "Avsenderadressen slutter på @gratis-premie.xyz – ikke et kjent norsk domene. Falske avsendere bruker ofte rare adresser!", - feedbackEn = "The sender address ends in @gratis-premie.xyz – not a known Norwegian domain. Scammers often use strange addresses!", + title = + localized( + "Postkontoret 1: Du har vunnet!", + "Post Office 1: You have won!", ), - hotspot( - itemOrder = 3, - x = 8.24, y = 21.47, width = 81.86, height = 16.99, - feedbackNb = "«Svar innen 24 timer» er et vanlig triks for å få deg til å handle raskt uten å tenke.", - feedbackEn = "\"Reply within 24 hours\" is a classic trick to make you act fast without thinking.", + introText = + localizedOptional( + "Les e-posten og trykk på delene som virker mistenkelige.", + "Read the email and tap the parts that seem suspicious.", ), - hotspot( - itemOrder = 4, - x = 8.24, y = 40.79, width = 81.86, height = 11.99, - feedbackNb = "Ingen seriøs konkurranse trenger kontonummeret ditt for å sende en pakke. Hva ville du gjort?", - feedbackEn = "No real competition needs your bank account number to send a package. What would you do?", - hasActionQuestion = true, - options = listOf( - explainedOption( - 1, - "Fortelle en voksen og slette e-posten.", - "Tell an adult and delete the email.", - true, - "En voksen kan hjelpe deg å dobbeltsjekke uten at du trykker på noe farlig.", - "An adult can help you double-check without clicking anything dangerous.", - ), - explainedOption( - 2, - "Sende kontonummeret – premien ser ekte ut!", - "Send my account number – the prize looks real!", - false, - "Svindlerne håpet du ville gjøre akkurat det.", - "That is exactly what the scammers were hoping for.", - ), - explainedOption( - 3, - "Videresende e-posten til alle i klassen så de også kan vinne.", - "Forward the email to everyone in class so they can win too.", - false, - "Da sprer du svindelen videre – ingen i klassen din vil takke deg for det!", - "That spreads the scam further – none of your classmates will thank you for it!", - ), - explainedOption( - 4, - "Trykke på lenken «bare for å se» hva som skjer.", - "Click the link \"just to see\" what happens.", - false, - "Mistenkelige lenker kan laste ned noe farlig uten at du merker det.", - "Suspicious links can download something harmful without you noticing.", - ), + imageUrlNb = "/assets/stops/postkontoret/task1-nb.png", + imageUrlEn = "/assets/stops/postkontoret/task1-en.png", + items = + listOf( + hotspot( + itemOrder = 2, + x = 8.24, + y = 5.37, + width = 81.86, + height = 5.37, + feedbackNb = "Avsenderadressen slutter på @gratis-premie.xyz – ikke et kjent norsk domene. Falske avsendere bruker ofte rare adresser!", + feedbackEn = "The sender address ends in @gratis-premie.xyz – not a known Norwegian domain. Scammers often use strange addresses!", + ), + hotspot( + itemOrder = 3, + x = 8.24, + y = 21.47, + width = 81.86, + height = 16.99, + feedbackNb = "«Svar innen 24 timer» er et vanlig triks for å få deg til å handle raskt uten å tenke.", + feedbackEn = "\"Reply within 24 hours\" is a classic trick to make you act fast without thinking.", + ), + hotspot( + itemOrder = 4, + x = 8.24, + y = 40.79, + width = 81.86, + height = 11.99, + feedbackNb = "Ingen seriøs konkurranse trenger kontonummeret ditt for å sende en pakke. Hva ville du gjort?", + feedbackEn = "No real competition needs your bank account number to send a package. What would you do?", + hasActionQuestion = true, + options = + listOf( + explainedOption( + 1, + "Fortelle en voksen og slette e-posten.", + "Tell an adult and delete the email.", + true, + "En voksen kan hjelpe deg å dobbeltsjekke uten at du trykker på noe farlig.", + "An adult can help you double-check without clicking anything dangerous.", + ), + explainedOption( + 2, + "Sende kontonummeret – premien ser ekte ut!", + "Send my account number – the prize looks real!", + false, + "Svindlerne håpet du ville gjøre akkurat det.", + "That is exactly what the scammers were hoping for.", + ), + explainedOption( + 3, + "Videresende e-posten til alle i klassen så de også kan vinne.", + "Forward the email to everyone in class so they can win too.", + false, + "Da sprer du svindelen videre – ingen i klassen din vil takke deg for det!", + "That spreads the scam further – none of your classmates will thank you for it!", + ), + explainedOption( + 4, + "Trykke på lenken «bare for å se» hva som skjer.", + "Click the link \"just to see\" what happens.", + false, + "Mistenkelige lenker kan laste ned noe farlig uten at du merker det.", + "Suspicious links can download something harmful without you noticing.", + ), + ), + ), + hotspot( + itemOrder = 5, + x = 8.24, + y = 55.46, + width = 81.86, + height = 12.52, + feedbackNb = "En veldig lang og rotete lenke er et faresignal. Ekte nettsteder har korte, enkle adresser.", + feedbackEn = "A very long and messy link is a warning sign. Real websites have short, simple addresses.", ), ), - hotspot( - itemOrder = 5, - x = 8.24, y = 55.46, width = 81.86, height = 12.52, - feedbackNb = "En veldig lang og rotete lenke er et faresignal. Ekte nettsteder har korte, enkle adresser.", - feedbackEn = "A very long and messy link is a warning sign. Real websites have short, simple addresses.", - ), - ), ), - // ── Task 2 — difficulty 2 — IT department password phish ──────────── imageSelectTask( difficultyLevel = 2, - title = localized( - "Postkontoret 2: IT-avdelingen trenger hjelp", - "Post Office 2: IT department needs help", - ), - introText = localizedOptional( - "Denne e-posten ser mer ekte ut. Klarer du å finne faresignalene?", - "This email looks more convincing. Can you spot the warning signs?", - ), - imageUrlNb = "/assets/stops/postkontoret/task2-nb.png", - imageUrlEn = "/assets/stops/postkontoret/task2-en.png", - items = listOf( - hotspot( - itemOrder = 2, - x = 8.24, y = 5.01, width = 81.86, height = 5.37, - feedbackNb = "Skolens IT-avdeling bruker skolens eget domene – ikke @skole-hjelp.net. Sjekk alltid om adressen stemmer med organisasjonen.", - feedbackEn = "The school's IT department uses the school's own domain – not @skole-hjelp.net. Always check if the address matches the organisation.", + title = + localized( + "Postkontoret 2: IT-avdelingen trenger hjelp", + "Post Office 2: IT department needs help", ), - hotspot( - itemOrder = 3, - x = 8.24, y = 10.20, width = 81.86, height = 5.37, - feedbackNb = "«1 time» – hastverk igjen! Ekte IT-systemer gir deg alltid god tid.", - feedbackEn = "\"1 hour\" – urgency again! Real IT systems always give you plenty of time.", + introText = + localizedOptional( + "Denne e-posten ser mer ekte ut. Klarer du å finne faresignalene?", + "This email looks more convincing. Can you spot the warning signs?", ), - hotspot( - itemOrder = 4, - x = 8.24, y = 31.84, width = 81.86, height = 11.99, - feedbackNb = "Ingen IT-avdeling trenger passordet ditt – de kan alltid tilbakestille det selv. Hva ville du gjort?", - feedbackEn = "No IT department ever needs your password – they can always reset it themselves. What would you do?", - hasActionQuestion = true, - options = listOf( - explainedOption( - 1, - "Spørre en lærer om e-posten er ekte før jeg gjør noe.", - "Ask a teacher if the email is real before doing anything.", - true, - "Alltid dobbeltsjekk med en du stoler på før du oppgir passord.", - "Always double-check with someone you trust before giving out passwords.", - ), - explainedOption( - 2, - "Skrive inn passordet – jeg vil ikke miste tilgangen!", - "Enter my password – I do not want to lose access!", - false, - "Det er akkurat frykten svindlerne utnytter.", - "That is exactly the fear the scammers are exploiting.", - ), - explainedOption( - 3, - "Svare med passordet og be dem slutte å sende slike e-poster.", - "Reply with my password and ask them to stop sending these emails.", - false, - "Da har du gitt bort passordet – og svindleren bryr seg ikke om klagen din.", - "You have handed over your password – and the scammer does not care about your complaint.", - ), + imageUrlNb = "/assets/stops/postkontoret/task2-nb.png", + imageUrlEn = "/assets/stops/postkontoret/task2-en.png", + items = + listOf( + hotspot( + itemOrder = 2, + x = 8.24, + y = 5.01, + width = 81.86, + height = 5.37, + feedbackNb = "Skolens IT-avdeling bruker skolens eget domene – ikke @skole-hjelp.net. Sjekk alltid om adressen stemmer med organisasjonen.", + feedbackEn = "The school's IT department uses the school's own domain – not @skole-hjelp.net. Always check if the address matches the organisation.", + ), + hotspot( + itemOrder = 3, + x = 8.24, + y = 10.20, + width = 81.86, + height = 5.37, + feedbackNb = "«1 time» – hastverk igjen! Ekte IT-systemer gir deg alltid god tid.", + feedbackEn = "\"1 hour\" – urgency again! Real IT systems always give you plenty of time.", + ), + hotspot( + itemOrder = 4, + x = 8.24, + y = 31.84, + width = 81.86, + height = 11.99, + feedbackNb = "Ingen IT-avdeling trenger passordet ditt – de kan alltid tilbakestille det selv. Hva ville du gjort?", + feedbackEn = "No IT department ever needs your password – they can always reset it themselves. What would you do?", + hasActionQuestion = true, + options = + listOf( + explainedOption( + 1, + "Spørre en lærer om e-posten er ekte før jeg gjør noe.", + "Ask a teacher if the email is real before doing anything.", + true, + "Alltid dobbeltsjekk med en du stoler på før du oppgir passord.", + "Always double-check with someone you trust before giving out passwords.", + ), + explainedOption( + 2, + "Skrive inn passordet – jeg vil ikke miste tilgangen!", + "Enter my password – I do not want to lose access!", + false, + "Det er akkurat frykten svindlerne utnytter.", + "That is exactly the fear the scammers are exploiting.", + ), + explainedOption( + 3, + "Svare med passordet og be dem slutte å sende slike e-poster.", + "Reply with my password and ask them to stop sending these emails.", + false, + "Da har du gitt bort passordet – og svindleren bryr seg ikke om klagen din.", + "You have handed over your password – and the scammer does not care about your complaint.", + ), + ), ), ), - ), ), - // ── Task 3 — difficulty 3 — safe library reminder ─────────────────── imageSelectTask( difficultyLevel = 3, - title = localized( - "Postkontoret 3: En trygg e-post", - "Post Office 3: A safe email", - ), - introText = localizedOptional( - "Denne e-posten er faktisk trygg. Finn elementene som viser at den er ekte.", - "This email is actually safe. Find the elements that show it is genuine.", - ), + title = + localized( + "Postkontoret 3: En trygg e-post", + "Post Office 3: A safe email", + ), + introText = + localizedOptional( + "Denne e-posten er faktisk trygg. Finn elementene som viser at den er ekte.", + "This email is actually safe. Find the elements that show it is genuine.", + ), imageUrlNb = "/assets/stops/postkontoret/task3-nb.png", imageUrlEn = "/assets/stops/postkontoret/task3-en.png", - items = listOf( - hotspot( - itemOrder = 2, - x = 8.24, y = 5.01, width = 81.86, height = 5.37, - feedbackNb = "@trondheim.kommune.no er et ekte offentlig domene. Det er mye vanskeligere å forfalske enn .net eller .xyz.", - feedbackEn = "@trondheim.kommune.no is a real public domain. It is much harder to fake than .net or .xyz.", - isTrustworthy = true, - ), - hotspot( - itemOrder = 3, - x = 8.24, y = 37.57, width = 81.86, height = 13.42, - feedbackNb = "Meldingen er rolig, konkret og ber deg ikke om passord eller penger. Hva gjør deg mest trygg på en e-post?", - feedbackEn = "The message is calm, specific, and does not ask for a password or money. What makes you most confident an email is genuine?", - isTrustworthy = true, - hasActionQuestion = true, - options = listOf( - explainedOption( - 1, - "Kjent avsenderdomene, rolig språk og ingen forespørsel om personlig informasjon.", - "Familiar sender domain, calm language, and no request for personal information.", - true, - "Disse tre tingene til sammen er de sterkeste signalene på en trygg e-post.", - "These three things together are the strongest signs of a safe email.", - ), - explainedOption( - 2, - "At e-posten har en fin logo og ser profesjonell ut.", - "That the email has a nice logo and looks professional.", - false, - "Logoer og design kan kopieres av hvem som helst.", - "Logos and design can be copied by anyone.", - ), - explainedOption( - 3, - "At jeg har fått e-post fra dem før.", - "That I have received email from them before.", - false, - "Svindlere kan forfalske kjente avsendernavn – domenet er viktigere.", - "Scammers can fake familiar sender names – the domain is what matters.", - ), + items = + listOf( + hotspot( + itemOrder = 2, + x = 8.24, + y = 5.01, + width = 81.86, + height = 5.37, + feedbackNb = "@trondheim.kommune.no er et ekte offentlig domene. Det er mye vanskeligere å forfalske enn .net eller .xyz.", + feedbackEn = "@trondheim.kommune.no is a real public domain. It is much harder to fake than .net or .xyz.", + isTrustworthy = true, + ), + hotspot( + itemOrder = 3, + x = 8.24, + y = 37.57, + width = 81.86, + height = 13.42, + feedbackNb = "Meldingen er rolig, konkret og ber deg ikke om passord eller penger. Hva gjør deg mest trygg på en e-post?", + feedbackEn = "The message is calm, specific, and does not ask for a password or money. What makes you most confident an email is genuine?", + isTrustworthy = true, + hasActionQuestion = true, + options = + listOf( + explainedOption( + 1, + "Kjent avsenderdomene, rolig språk og ingen forespørsel om personlig informasjon.", + "Familiar sender domain, calm language, and no request for personal information.", + true, + "Disse tre tingene til sammen er de sterkeste signalene på en trygg e-post.", + "These three things together are the strongest signs of a safe email.", + ), + explainedOption( + 2, + "At e-posten har en fin logo og ser profesjonell ut.", + "That the email has a nice logo and looks professional.", + false, + "Logoer og design kan kopieres av hvem som helst.", + "Logos and design can be copied by anyone.", + ), + explainedOption( + 3, + "At jeg har fått e-post fra dem før.", + "That I have received email from them before.", + false, + "Svindlere kan forfalske kjente avsendernavn – domenet er viktigere.", + "Scammers can fake familiar sender names – the domain is what matters.", + ), + ), ), ), - ), ), ) @@ -216,14 +253,15 @@ private fun imageSelectTask( taskType = TaskType.IMAGE_SELECT, title = title, introText = introText, - items = listOf( - SeedItem( - itemOrder = 1, - promptText = localized("", ""), - metadataJson = """{"mediaUrl":{"nb":"$imageUrlNb","en":"$imageUrlEn"}}""", - options = emptyList(), - ), - ) + items, + items = + listOf( + SeedItem( + itemOrder = 1, + promptText = localized("", ""), + metadataJson = """{"mediaUrl":{"nb":"$imageUrlNb","en":"$imageUrlEn"}}""", + options = emptyList(), + ), + ) + items, ) private fun hotspot( @@ -238,16 +276,17 @@ private fun hotspot( hasActionQuestion: Boolean = false, options: List = emptyList(), ): SeedItem { - val meta = buildString { - append("""{"x":$x,"y":$y,"width":$width,"height":$height,"isHotspot":true""") - if (isTrustworthy) append(""","isTrustworthy":true""") - if (hasActionQuestion) append(""","hasActionQuestion":true""") - append("}") - } + val meta = + buildString { + append("""{"x":$x,"y":$y,"width":$width,"height":$height,"isHotspot":true""") + if (isTrustworthy) append(""","isTrustworthy":true""") + if (hasActionQuestion) append(""","hasActionQuestion":true""") + append("}") + } return SeedItem( itemOrder = itemOrder, promptText = localized(feedbackNb, feedbackEn), metadataJson = meta, options = options, ) -} \ No newline at end of file +} diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/TaskBootstrap.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/TaskBootstrap.kt index b48a73be..ac2156e9 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/TaskBootstrap.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/TaskBootstrap.kt @@ -24,29 +24,34 @@ class TaskBootstrap( private val taskRepository: TaskRepository, private val mapStopsRepository: MapStopsRepository, ) { - fun seedAllTasks(classroom: Classroom, teacher: User): List { + fun seedAllTasks( + classroom: Classroom, + teacher: User, + ): List { val stops = mapStopsRepository.findAllByIsActiveTrueOrderByDisplayOrderAsc() val classroomId = classroom.id - val tasksByStopId = taskRepository - .findAllByClassroomIdOrderByStopIdAscDifficultyLevelAsc(classroomId) - .groupBy { it.stopId } - .mapValues { (_, tasks) -> tasks.associateBy { it.difficultyLevel } } + val tasksByStopId = + taskRepository + .findAllByClassroomIdOrderByStopIdAscDifficultyLevelAsc(classroomId) + .groupBy { it.stopId } + .mapValues { (_, tasks) -> tasks.associateBy { it.difficultyLevel } } stops.forEach { stop -> val stopId = requireNotNull(stop.id) { "Seeded map stop must have an id" } val existingTasks = tasksByStopId[stopId].orEmpty() taskSeedsForStop(stop).forEach { seed -> - val task = existingTasks[seed.difficultyLevel] ?: Task( - stopId = stopId, - classroom = classroom, - createdByTeacher = teacher, - title = seed.title.nb, - difficultyLevel = seed.difficultyLevel, - taskType = seed.taskType, - passingRule = seed.passingRule, - passingScore = seed.passingScore, - maxScore = seed.maxScore, - ) + val task = + existingTasks[seed.difficultyLevel] ?: Task( + stopId = stopId, + classroom = classroom, + createdByTeacher = teacher, + title = seed.title.nb, + difficultyLevel = seed.difficultyLevel, + taskType = seed.taskType, + passingRule = seed.passingRule, + passingScore = seed.passingScore, + maxScore = seed.maxScore, + ) if (task.id != null) { task.items.clear() task.translations.clear() @@ -93,20 +98,22 @@ class TaskBootstrap( task.isPublished = seed.isPublished addTaskTranslations(task, seed.title, seed.introText) seed.items.forEach { itemSeed -> - val item = TaskItem( - itemOrder = itemSeed.itemOrder, - promptText = itemSeed.promptText.nb, - mediaUrl = itemSeed.mediaUrl, - metadataJson = itemSeed.metadataJson, - ) + val item = + TaskItem( + itemOrder = itemSeed.itemOrder, + promptText = itemSeed.promptText.nb, + mediaUrl = itemSeed.mediaUrl, + metadataJson = itemSeed.metadataJson, + ) addItemTranslations(item, itemSeed.promptText) itemSeed.options.forEach { optionSeed -> - val option = TaskOption( - optionOrder = optionSeed.optionOrder, - optionText = optionSeed.optionText.nb, - isCorrect = optionSeed.isCorrect, - explanationText = optionSeed.explanationText?.nb, - ) + val option = + TaskOption( + optionOrder = optionSeed.optionOrder, + optionText = optionSeed.optionText.nb, + isCorrect = optionSeed.isCorrect, + explanationText = optionSeed.explanationText?.nb, + ) addOptionTranslations(option, optionSeed.optionText, optionSeed.explanationText) item.addOption(option) } @@ -114,7 +121,11 @@ class TaskBootstrap( } } - private fun addTaskTranslations(task: Task, title: LocalizedText, introText: LocalizedOptionalText?) { + private fun addTaskTranslations( + task: Task, + title: LocalizedText, + introText: LocalizedOptionalText?, + ) { task.addTranslation( TaskTranslation( id = TaskTranslationId(locale = TaskLocale.NB), @@ -133,7 +144,10 @@ class TaskBootstrap( ) } - private fun addItemTranslations(item: TaskItem, promptText: LocalizedText) { + private fun addItemTranslations( + item: TaskItem, + promptText: LocalizedText, + ) { item.addTranslation( TaskItemTranslation( id = TaskItemTranslationId(locale = TaskLocale.NB), @@ -173,4 +187,3 @@ class TaskBootstrap( ) } } - diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/TaskSeedModels.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/TaskSeedModels.kt index 5434d1b2..54e2063e 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/TaskSeedModels.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/TaskSeedModels.kt @@ -42,9 +42,15 @@ internal data class SeedTask( // ── Builder helpers ────────────────────────────────────────────────────────── -internal fun localized(nb: String, en: String) = LocalizedText(nb = nb, en = en) +internal fun localized( + nb: String, + en: String, +) = LocalizedText(nb = nb, en = en) -internal fun localizedOptional(nb: String?, en: String?) = LocalizedOptionalText(nb = nb, en = en) +internal fun localizedOptional( + nb: String?, + en: String?, +) = LocalizedOptionalText(nb = nb, en = en) internal fun explainedOption( optionOrder: Int, diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomApiMappings.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomApiMappings.kt index 9b700d93..b6ac1310 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomApiMappings.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomApiMappings.kt @@ -3,9 +3,9 @@ package edu.ntnu.idi.idatt.backend.classroom.api import edu.ntnu.idi.idatt.backend.classroom.application.CreateClassroomCommand import edu.ntnu.idi.idatt.backend.classroom.application.JoinClassroomCommand import edu.ntnu.idi.idatt.backend.classroom.application.JoinClassroomResult +import edu.ntnu.idi.idatt.backend.classroom.application.OwnedClassroomSummary import edu.ntnu.idi.idatt.backend.classroom.application.UpdateClassroomCommand import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom -import edu.ntnu.idi.idatt.backend.classroom.application.OwnedClassroomSummary /** * Converts the join classroom request to the application command. @@ -37,7 +37,7 @@ fun CreateClassroomRequest.toCommand(subject: String): CreateClassroomCommand = */ fun UpdateClassroomRequest.toCommand( id: Long, - authenticatedSubject: String + authenticatedSubject: String, ): UpdateClassroomCommand = UpdateClassroomCommand( classroomId = id, @@ -71,7 +71,7 @@ fun Classroom.toResponse(): ClassroomResponse = title = title, description = description, joinCode = joinCode ?: "", - teacherUserIds = teacherMemberships.mapNotNull { it.teacher?.id } + teacherUserIds = teacherMemberships.mapNotNull { it.teacher?.id }, ) /** @@ -85,25 +85,25 @@ fun Classroom.toDetailResponse(pupilDisplayNames: Map = emptyMap() title = title, description = description, joinCode = joinCode ?: "", + teachers = + teacherMemberships.mapNotNull { membership -> + val teacher = membership.teacher ?: return@mapNotNull null - teachers = teacherMemberships.mapNotNull { membership -> - val teacher = membership.teacher ?: return@mapNotNull null - - ClassroomTeacherResponse( - userId = teacher.id ?: return@mapNotNull null, - email = teacher.email, - ) - }, - - pupils = pupilMemberships.mapNotNull { membership -> - val pupil = membership.pupil ?: return@mapNotNull null - val pupilId = pupil.id ?: return@mapNotNull null + ClassroomTeacherResponse( + userId = teacher.id ?: return@mapNotNull null, + email = teacher.email, + ) + }, + pupils = + pupilMemberships.mapNotNull { membership -> + val pupil = membership.pupil ?: return@mapNotNull null + val pupilId = pupil.id ?: return@mapNotNull null - ClassroomPupilResponse( - userId = pupilId, - displayName = pupilDisplayNames[pupilId] ?: pupil.username ?: "Unknown", - ) - } + ClassroomPupilResponse( + userId = pupilId, + displayName = pupilDisplayNames[pupilId] ?: pupil.username ?: "Unknown", + ) + }, ) fun OwnedClassroomSummary.toOwnedClassroomResponse(): OwnedClassroomResponse = diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomController.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomController.kt index 1af58cca..1706643b 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomController.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomController.kt @@ -5,10 +5,11 @@ import edu.ntnu.idi.idatt.backend.iam.security.CurrentUser import edu.ntnu.idi.idatt.backend.users.profile.infrastructure.PupilProfileRepository import jakarta.validation.Valid import org.springframework.security.core.annotation.AuthenticationPrincipal -import org.springframework.web.bind.annotation.* +import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @@ -51,9 +52,10 @@ class ClassroomController( @RequestBody @Valid request: CreateClassroomRequest, @AuthenticationPrincipal currentUser: CurrentUser, ): ClassroomResponse = - classroomService.createClassroom( - request.toCommand(currentUser.subject) - ).toResponse() + classroomService + .createClassroom( + request.toCommand(currentUser.subject), + ).toResponse() /** * Retrieves all classrooms the authenticated user is a member of. @@ -62,8 +64,9 @@ class ClassroomController( * @return a list of classrooms accessible to the user. */ @GetMapping - fun getClassrooms(@AuthenticationPrincipal currentUser: CurrentUser): List = - classroomService.getClassrooms(currentUser.subject).map { it.toResponse() } + fun getClassrooms( + @AuthenticationPrincipal currentUser: CurrentUser, + ): List = classroomService.getClassrooms(currentUser.subject).map { it.toResponse() } /** * Endpoint for joining a classroom. @@ -77,7 +80,8 @@ class ClassroomController( @RequestBody @Valid request: JoinClassroomRequest, @AuthenticationPrincipal currentUser: CurrentUser, ): JoinClassroomResponse = - classroomService.joinClassroom(request.toCommand(currentUser.subject)) + classroomService + .joinClassroom(request.toCommand(currentUser.subject)) .toJoinClassroomResponse() /** @@ -94,9 +98,10 @@ class ClassroomController( ): ClassroomDetailResponse = classroomService.getClassroomById(id, currentUser.subject).let { classroom -> val pupilIds = classroom.pupilMemberships.mapNotNull { membership -> membership.pupil?.id } - val pupilDisplayNames = pupilProfileRepository - .findAllByUserIdIn(pupilIds) - .associate { profile -> requireNotNull(profile.userId) to profile.displayName } + val pupilDisplayNames = + pupilProfileRepository + .findAllByUserIdIn(pupilIds) + .associate { profile -> requireNotNull(profile.userId) to profile.displayName } classroom.toDetailResponse(pupilDisplayNames) } @@ -114,9 +119,10 @@ class ClassroomController( @RequestBody @Valid request: UpdateClassroomRequest, @AuthenticationPrincipal currentUser: CurrentUser, ): ClassroomResponse = - classroomService.updateClassroom( - request.toCommand(id, currentUser.subject) - ).toResponse() + classroomService + .updateClassroom( + request.toCommand(id, currentUser.subject), + ).toResponse() /** * Removes a pupil from a classroom. diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomDetailResponse.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomDetailResponse.kt index 34e2236f..0aa37e2d 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomDetailResponse.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomDetailResponse.kt @@ -16,5 +16,5 @@ data class ClassroomDetailResponse( val description: String?, val joinCode: String, val teachers: List, - val pupils: List -) \ No newline at end of file + val pupils: List, +) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomPupilResponse.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomPupilResponse.kt index bf32893e..51860229 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomPupilResponse.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomPupilResponse.kt @@ -9,4 +9,4 @@ package edu.ntnu.idi.idatt.backend.classroom.api data class ClassroomPupilResponse( val userId: Long, val displayName: String, -) \ No newline at end of file +) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomResponse.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomResponse.kt index 399cb5b0..b2f2d0f8 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomResponse.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomResponse.kt @@ -15,4 +15,4 @@ data class ClassroomResponse( val description: String?, val joinCode: String, val teacherUserIds: List, -) \ No newline at end of file +) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomTeacherResponse.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomTeacherResponse.kt index 4a5ca51d..34bd1e3c 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomTeacherResponse.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/ClassroomTeacherResponse.kt @@ -9,4 +9,4 @@ package edu.ntnu.idi.idatt.backend.classroom.api data class ClassroomTeacherResponse( val userId: Long, val email: String?, -) \ No newline at end of file +) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/CreateClassroomRequest.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/CreateClassroomRequest.kt index 5512335f..3cddb666 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/CreateClassroomRequest.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/CreateClassroomRequest.kt @@ -13,6 +13,5 @@ data class CreateClassroomRequest( @field:NotBlank @field:Size(max = 100) val title: String, - val description: String? = null, -) \ No newline at end of file +) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/UpdateClassroomRequest.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/UpdateClassroomRequest.kt index 87299f8c..09bd1a89 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/UpdateClassroomRequest.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/api/UpdateClassroomRequest.kt @@ -12,8 +12,6 @@ import jakarta.validation.constraints.NotBlank data class UpdateClassroomRequest( @field:NotBlank val title: String, - val description: String? = null, - val teacherEmails: List = emptyList(), -) \ No newline at end of file +) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/ClassroomService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/ClassroomService.kt index 9bf8a3fc..adbbe6fb 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/ClassroomService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/ClassroomService.kt @@ -5,19 +5,19 @@ import edu.ntnu.idi.idatt.backend.classroom.domain.ClassroomPupilStatus import edu.ntnu.idi.idatt.backend.classroom.domain.ClassroomStatus import edu.ntnu.idi.idatt.backend.classroom.infrastructure.ClassroomRepository import edu.ntnu.idi.idatt.backend.game.gameProgress.application.PupilGameProgressService +import edu.ntnu.idi.idatt.backend.game.tasks.infrastructure.TaskRepository import edu.ntnu.idi.idatt.backend.iam.application.ClassroomUserResolver import edu.ntnu.idi.idatt.backend.iam.application.CurrentUserResolver -import edu.ntnu.idi.idatt.backend.iam.infrastructure.UserRepository -import edu.ntnu.idi.idatt.backend.game.tasks.infrastructure.TaskRepository import edu.ntnu.idi.idatt.backend.iam.domain.UserRole -import edu.ntnu.idi.idatt.backend.users.profile.infrastructure.PupilProfileRepository -import org.springframework.context.ApplicationEventPublisher +import edu.ntnu.idi.idatt.backend.iam.infrastructure.UserRepository import edu.ntnu.idi.idatt.backend.shared.events.DomainEventPublisher +import edu.ntnu.idi.idatt.backend.users.profile.infrastructure.PupilProfileRepository import org.springframework.http.HttpStatus import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.springframework.web.server.ResponseStatusException -import java.util.* +import java.util.Locale +import java.util.UUID /** * Application service for classroom membership actions. @@ -41,7 +41,6 @@ class ClassroomService( private val pupilProfileRepository: PupilProfileRepository, private val domainEventPublisher: DomainEventPublisher, ) { - /** * Creates a new classroom owned by the authenticated teacher. * A unique code is generated for the classroom, and the creating user is automatically added as a teacher member. @@ -54,13 +53,14 @@ class ClassroomService( val teacher = currentUserResolver.requireTeacherOrAdmin(command.authenticatedSubject) val joinCode = generateJoinCode() - val classroom = Classroom( - ownerTeacherId = teacher.id!!, - title = command.title, - description = command.description, - joinCode = joinCode, - status = ClassroomStatus.ACTIVE, - ) + val classroom = + Classroom( + ownerTeacherId = teacher.id!!, + title = command.title, + description = command.description, + joinCode = joinCode, + status = ClassroomStatus.ACTIVE, + ) classroom.addTeacher(teacher) @@ -72,11 +72,12 @@ class ClassroomService( val user = currentUserResolver.requireTeacherOrAdmin(authenticatedSubject) val userId = requireNotNull(user.id) { "Authenticated user is missing an id" } - val classrooms = if (user.role == UserRole.ADMIN) { - classroomRepository.findAll().sortedWith(compareBy({ it.title }, { it.id })) - } else { - classroomRepository.findAllByOwnerTeacherIdOrderByTitleAscIdAsc(userId) - } + val classrooms = + if (user.role == UserRole.ADMIN) { + classroomRepository.findAll().sortedWith(compareBy({ it.title }, { it.id })) + } else { + classroomRepository.findAllByOwnerTeacherIdOrderByTitleAscIdAsc(userId) + } return classrooms.map(::toOwnedClassroomSummary) } @@ -87,10 +88,12 @@ class ClassroomService( classroomId: Long, ): OwnedClassroomSummary { val user = currentUserResolver.requireTeacherOrAdmin(authenticatedSubject) - val classroom = classroomRepository.findById(classroomId) - .orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") - } + val classroom = + classroomRepository + .findById(classroomId) + .orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") + } val userId = requireNotNull(user.id) { "Authenticated user is missing an id" } if (user.role != UserRole.ADMIN && classroom.ownerTeacherId != userId) { @@ -113,8 +116,9 @@ class ClassroomService( val user = currentUserResolver.requirePupil(command.authenticatedSubject) val userId = requireNotNull(user.id) { "Authenticated user is missing an id" } - val classroom = classroomRepository.findByJoinCode(normalizedClassroomCode) - ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") + val classroom = + classroomRepository.findByJoinCode(normalizedClassroomCode) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") if (classroom.status == ClassroomStatus.INACTIVE) { throw ResponseStatusException(HttpStatus.CONFLICT, "Cannot join an inactive classroom") @@ -125,15 +129,16 @@ class ClassroomService( } classroom.addPupilMembership(user) - val profile = pupilProfileRepository.findById(userId) - .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Profile not found") } + val profile = + pupilProfileRepository + .findById(userId) + .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Profile not found") } profile.currentClassroom = classroom pupilProfileRepository.save(profile) classroomRepository.save(classroom) pupilGameProgressService.createPupilGameProgressEntry( pupilId = userId, classroomId = requireNotNull(classroom.id) { "Classroom is missing an id" }, - ) domainEventPublisher.publish(PupilJoinedClassroomEvent(pupil = user, classroom = classroom)) @@ -154,12 +159,17 @@ class ClassroomService( * @throws ResponseStatusException if the user is not a teacher in the classroom, if the classroom does not exist or if the pupil is not in the classroom. */ @Transactional - fun approvePupil(classroomId: Long, pupilUserId: Long, authenticatedSubject: String) { + fun approvePupil( + classroomId: Long, + pupilUserId: Long, + authenticatedSubject: String, + ) { val currentUser = currentUserResolver.requireTeacherOrAdmin(authenticatedSubject) classroomUserResolver.requireClassroomTeacherAccess(currentUser, classroomId) - val classroom = classroomRepository.findById(classroomId).orElse(null) - ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") + val classroom = + classroomRepository.findById(classroomId).orElse(null) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") try { classroom.approvePupil(pupilUserId) @@ -179,12 +189,17 @@ class ClassroomService( * @throws ResponseStatusException if the user does not own the classroom or the classroom does not exist. */ @Transactional - fun removePupil(classroomId: Long, pupilUserId: Long, authenticatedSubject: String) { + fun removePupil( + classroomId: Long, + pupilUserId: Long, + authenticatedSubject: String, + ) { val currentUser = currentUserResolver.requireTeacherOrAdmin(authenticatedSubject) classroomUserResolver.requireClassroomOwner(currentUser, classroomId) - val classroom = classroomRepository.findById(classroomId).orElse(null) - ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") + val classroom = + classroomRepository.findById(classroomId).orElse(null) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") try { classroom.removePupil(pupilUserId) @@ -208,8 +223,9 @@ class ClassroomService( val currentUser = currentUserResolver.requireTeacherOrAdmin(command.authenticatedSubject) classroomUserResolver.requireClassroomOwner(currentUser, command.classroomId) - val classroom = classroomRepository.findById(command.classroomId).orElse(null) - ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") + val classroom = + classroomRepository.findById(command.classroomId).orElse(null) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") classroom.title = command.title classroom.description = command.description @@ -242,12 +258,16 @@ class ClassroomService( * @throws ResponseStatusException if the user is not a member of the classroom or the classroom does not exist. */ @Transactional(readOnly = true) - fun getClassroomById(classroomId: Long, authenticatedSubject: String): Classroom { - val user = currentUserResolver.requireUser(authenticatedSubject) + fun getClassroomById( + classroomId: Long, + authenticatedSubject: String, + ): Classroom { + val user = currentUserResolver.requireUser(authenticatedSubject) classroomUserResolver.requireClassroomMember(user, classroomId) - val classroom = classroomRepository.findById(classroomId).orElse(null) - ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") + val classroom = + classroomRepository.findById(classroomId).orElse(null) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") return classroom } @@ -274,7 +294,10 @@ class ClassroomService( * @throws ResponseStatusException if the user does not own the classroom or the classroom does not exist. */ @Transactional - fun deleteClassroom(classroomId: Long, authenticatedSubject: String) { + fun deleteClassroom( + classroomId: Long, + authenticatedSubject: String, + ) { val user = currentUserResolver.requireTeacherOrAdmin(authenticatedSubject) classroomUserResolver.requireClassroomOwner(user, classroomId) @@ -300,14 +323,17 @@ class ClassroomService( return normalizedCode } - /** * Generates a random uppercase join code for a classroom. * * @return a random 8-character join code. */ private fun generateJoinCode(): String = - UUID.randomUUID().toString().take(8).uppercase() + UUID + .randomUUID() + .toString() + .take(8) + .uppercase() private fun toOwnedClassroomSummary(classroom: Classroom): OwnedClassroomSummary = OwnedClassroomSummary( diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/CreateClassroomCommand.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/CreateClassroomCommand.kt index d5b1b9d8..06d9ce58 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/CreateClassroomCommand.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/CreateClassroomCommand.kt @@ -13,4 +13,4 @@ data class CreateClassroomCommand( val title: String, val description: String?, val authenticatedSubject: String, -) \ No newline at end of file +) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/UpdateClassroomCommand.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/UpdateClassroomCommand.kt index ae2639eb..5b1adc83 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/UpdateClassroomCommand.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/UpdateClassroomCommand.kt @@ -17,4 +17,4 @@ data class UpdateClassroomCommand( val description: String?, val teacherEmails: List, val authenticatedSubject: String, -) \ No newline at end of file +) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/domain/Classroom.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/domain/Classroom.kt index b7d3854b..1d40f056 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/domain/Classroom.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/domain/Classroom.kt @@ -1,7 +1,16 @@ package edu.ntnu.idi.idatt.backend.classroom.domain import edu.ntnu.idi.idatt.backend.iam.domain.User -import jakarta.persistence.* +import jakarta.persistence.CascadeType +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.OneToMany +import jakarta.persistence.Table import org.hibernate.annotations.CreationTimestamp import org.hibernate.annotations.UpdateTimestamp import java.time.Instant @@ -43,26 +52,23 @@ class Classroom( @UpdateTimestamp @Column(name = "updated_at", nullable = false) var updatedAt: Instant? = null, - @OneToMany( mappedBy = "classroom", cascade = [CascadeType.ALL], - orphanRemoval = true + orphanRemoval = true, ) var teacherMemberships: MutableSet = mutableSetOf(), - @OneToMany( mappedBy = "classroom", cascade = [CascadeType.ALL], orphanRemoval = true, ) - var pupilMemberships: MutableSet = mutableSetOf() + var pupilMemberships: MutableSet = mutableSetOf(), ) { /** * Returns whether the classroom already has a membership row for the given pupil. */ - fun hasPupilMembership(pupilUserId: Long): Boolean = - pupilMemberships.any { it.id.pupilUserId == pupilUserId } + fun hasPupilMembership(pupilUserId: Long): Boolean = pupilMemberships.any { it.id.pupilUserId == pupilUserId } /** * Adds a new pending pupil membership to the classroom. @@ -71,10 +77,11 @@ class Classroom( val pupilUserId = requireNotNull(pupil.id) { "Pupil is missing an id" } pupilMemberships.add( ClassroomPupilMembership( - id = ClassroomPupilMembershipId( - classroomId = id, - pupilUserId = pupilUserId, - ), + id = + ClassroomPupilMembershipId( + classroomId = id, + pupilUserId = pupilUserId, + ), classroom = this, pupil = pupil, status = ClassroomPupilStatus.PENDING, @@ -102,8 +109,7 @@ class Classroom( * @param pupilUserId the id of the pupil. * @return the matching membership, or null if not found. */ - fun getMembership(pupilUserId: Long): ClassroomPupilMembership? = - pupilMemberships.find { it.id.pupilUserId == pupilUserId } + fun getMembership(pupilUserId: Long): ClassroomPupilMembership? = pupilMemberships.find { it.id.pupilUserId == pupilUserId } /** * Approves a pending pupil membership. @@ -114,8 +120,9 @@ class Classroom( * @throws IllegalArgumentException if no membership exists for the pupil. */ fun approvePupil(pupilUserId: Long) { - val membership = getMembership(pupilUserId) - ?: throw IllegalArgumentException("No membership found") + val membership = + getMembership(pupilUserId) + ?: throw IllegalArgumentException("No membership found") membership.status = ClassroomPupilStatus.ACTIVE membership.approvedAt = Instant.now() @@ -128,9 +135,10 @@ class Classroom( * @throws IllegalArgumentException if the pupil is not a member of the classroom. */ fun removePupil(pupilUserId: Long) { - val removed = pupilMemberships.removeIf { - it.id.pupilUserId == pupilUserId - } + val removed = + pupilMemberships.removeIf { + it.id.pupilUserId == pupilUserId + } if (!removed) { throw IllegalArgumentException("Pupil $pupilUserId is not a member of this classroom") @@ -144,18 +152,18 @@ class Classroom( * @throws IllegalArgumentException if the teacher has no id. */ fun addTeacher(teacher: User) { - val userId = requireNotNull(teacher.id) teacherMemberships.add( ClassroomTeacherMembership( - id = ClassroomTeacherMembershipId( - classroomId = id, - teacherUserId = userId, - ), + id = + ClassroomTeacherMembershipId( + classroomId = id, + teacherUserId = userId, + ), classroom = this, teacher = teacher, - ) + ), ) } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/domain/ClassroomPupilStatus.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/domain/ClassroomPupilStatus.kt index 9d09edc3..747957cb 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/domain/ClassroomPupilStatus.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/domain/ClassroomPupilStatus.kt @@ -9,4 +9,4 @@ package edu.ntnu.idi.idatt.backend.classroom.domain enum class ClassroomPupilStatus { ACTIVE, PENDING, -} \ No newline at end of file +} diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/domain/ClassroomStatus.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/domain/ClassroomStatus.kt index 9fd615e3..8b3255e0 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/domain/ClassroomStatus.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/domain/ClassroomStatus.kt @@ -9,4 +9,4 @@ package edu.ntnu.idi.idatt.backend.classroom.domain enum class ClassroomStatus { ACTIVE, INACTIVE, -} \ No newline at end of file +} diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/domain/ClassroomTeacherMembership.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/domain/ClassroomTeacherMembership.kt index 2b0e5390..6712a290 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/domain/ClassroomTeacherMembership.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/domain/ClassroomTeacherMembership.kt @@ -2,10 +2,13 @@ package edu.ntnu.idi.idatt.backend.classroom.domain import edu.ntnu.idi.idatt.backend.iam.domain.User import jakarta.persistence.EmbeddedId -import jakarta.persistence.Table import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne import jakarta.persistence.MapsId -import jakarta.persistence.* +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint /** * Represents the association between a classroom and a teacher. @@ -16,7 +19,10 @@ import jakarta.persistence.* * @property teacher The teacher assigned to the classroom. */ @Entity -@Table(name = "classroom_teachers", uniqueConstraints = [UniqueConstraint(columnNames = ["classroom_id", "teacher_user_id"])]) +@Table( + name = "classroom_teachers", + uniqueConstraints = [UniqueConstraint(columnNames = ["classroom_id", "teacher_user_id"])], +) class ClassroomTeacherMembership( @EmbeddedId var id: ClassroomTeacherMembershipId = ClassroomTeacherMembershipId(), @@ -29,4 +35,3 @@ class ClassroomTeacherMembership( @JoinColumn(name = "teacher_user_id") var teacher: User? = null, ) - diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/domain/ClassroomTeacherMembershipId.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/domain/ClassroomTeacherMembershipId.kt index d02c3e45..de07f846 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/domain/ClassroomTeacherMembershipId.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/domain/ClassroomTeacherMembershipId.kt @@ -16,4 +16,3 @@ data class ClassroomTeacherMembershipId( @Column(name = "teacher_user_id") var teacherUserId: Long = 0, ) : Serializable - diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/infrastructure/ClassroomRepository.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/infrastructure/ClassroomRepository.kt index 9897c162..d8b08fdf 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/infrastructure/ClassroomRepository.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/infrastructure/ClassroomRepository.kt @@ -4,7 +4,6 @@ import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param - import org.springframework.stereotype.Repository @Repository @@ -39,9 +38,11 @@ interface ClassroomRepository : JpaRepository { LEFT JOIN c.teacherMemberships tm LEFT JOIN c.pupilMemberships pm WHERE tm.teacher.id = :userId OR pm.pupil.id = :userId - """ + """, ) - fun findAllByUserId(@Param("userId") userId: Long): List + fun findAllByUserId( + @Param("userId") userId: Long, + ): List /** * Returns all classrooms with the average total score across their pupils. diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/application/GameAccessContextResolver.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/application/GameAccessContextResolver.kt index df8d6c91..30c6192d 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/application/GameAccessContextResolver.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/application/GameAccessContextResolver.kt @@ -39,10 +39,12 @@ class GameAccessContextResolver( readMode: GameReadMode = GameReadMode.DEFAULT, ): GameAccessContext { val user = currentUserResolver.requireUser(currentUser.subject) - val classroom = classroomRepository.findById(classroomId) - .orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") - } + val classroom = + classroomRepository + .findById(classroomId) + .orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") + } val userId = requireNotNull(user.id) { "Authenticated user is missing an id" } if (readMode == GameReadMode.TEACHER_PLAYTEST) { @@ -50,7 +52,10 @@ class GameAccessContextResolver( throw ResponseStatusException(HttpStatus.FORBIDDEN, "Only teachers and admins may use teacher playtest") } if (user.role == UserRole.TEACHER && classroom.ownerTeacherId != userId) { - throw ResponseStatusException(HttpStatus.FORBIDDEN, "Only the classroom owner may playtest this classroom") + throw ResponseStatusException( + HttpStatus.FORBIDDEN, + "Only the classroom owner may playtest this classroom", + ) } return GameAccessContext( classroom = classroom, @@ -94,15 +99,16 @@ class GameAccessContextResolver( ) } - UserRole.ADMIN -> GameAccessContext( - classroom = classroom, - user = user, - pupilUserId = null, - publishedOnly = false, - includeAnswers = true, - allStopsOpen = true, - readOnly = false, - ) + UserRole.ADMIN -> + GameAccessContext( + classroom = classroom, + user = user, + pupilUserId = null, + publishedOnly = false, + includeAnswers = true, + allStopsOpen = true, + readOnly = false, + ) } } } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/application/PupilGameProgressService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/application/PupilGameProgressService.kt index 0881d792..04f5f316 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/application/PupilGameProgressService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/application/PupilGameProgressService.kt @@ -1,6 +1,7 @@ package edu.ntnu.idi.idatt.backend.game.gameProgress.application import edu.ntnu.idi.idatt.backend.classroom.infrastructure.ClassroomRepository +import edu.ntnu.idi.idatt.backend.game.gameProgress.application.PupilGameProgressService.Companion.XP_PER_LEVEL import edu.ntnu.idi.idatt.backend.game.gameProgress.domain.PupilGameProgress import edu.ntnu.idi.idatt.backend.game.gameProgress.domain.PupilGameProgressId import edu.ntnu.idi.idatt.backend.game.gameProgress.infrastructure.PupilGameProgressRepository @@ -45,13 +46,17 @@ class PupilGameProgressService( * If no progress row exists for the active classroom, the update is skipped. */ @Transactional - fun updateUserXpAndLevel(currentUser: CurrentUser, totalXp: Int) { + fun updateUserXpAndLevel( + currentUser: CurrentUser, + totalXp: Int, + ) { val pupil = currentUserResolver.requirePupil(currentUser.subject) val pupilId = requireNotNull(pupil.id) { "Pupil is missing an id" } val classroom = currentUserResolver.getClassroomByUser(currentUser) val classroomId = requireNotNull(classroom.id) { "Classroom is missing an id" } - val pupilGameProgress = pupilGameProgressRepository.findByPupilIdAndClassroomId(pupilId, classroomId) - ?: return + val pupilGameProgress = + pupilGameProgressRepository.findByPupilIdAndClassroomId(pupilId, classroomId) + ?: return val currentLevel = calculateLevelFromXp(totalXp) @@ -90,8 +95,10 @@ class PupilGameProgressService( * @return classroom-scoped progress row, or `null` when none exists */ @Transactional(readOnly = true) - fun getPupilProgress(pupilId: Long, classroomId: Long): PupilGameProgress? = - pupilGameProgressRepository.findByPupilIdAndClassroomId(pupilId, classroomId) + fun getPupilProgress( + pupilId: Long, + classroomId: Long, + ): PupilGameProgress? = pupilGameProgressRepository.findByPupilIdAndClassroomId(pupilId, classroomId) /** * Creates an empty classroom-scoped progress row for a pupil if it does not already exist. @@ -101,24 +108,30 @@ class PupilGameProgressService( * @throws ResponseStatusException when the pupil or classroom does not exist */ @Transactional - fun createPupilGameProgressEntry(pupilId: Long, classroomId: Long) { + fun createPupilGameProgressEntry( + pupilId: Long, + classroomId: Long, + ) { if (pupilGameProgressRepository.findByPupilIdAndClassroomId(pupilId, classroomId) != null) { return } - val pupil = userRepository.findById(pupilId).orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil not found") - } - val classroom = classroomRepository.findById(classroomId).orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") - } + val pupil = + userRepository.findById(pupilId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil not found") + } + val classroom = + classroomRepository.findById(classroomId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") + } pupilGameProgressRepository.save( PupilGameProgress( - id = PupilGameProgressId( - pupilUserId = pupilId, - classroomId = requireNotNull(classroom.id) { "Classroom is missing an id" }, - ), + id = + PupilGameProgressId( + pupilUserId = pupilId, + classroomId = requireNotNull(classroom.id) { "Classroom is missing an id" }, + ), pupil = pupil, classroom = classroom, ), @@ -141,12 +154,13 @@ class PupilGameProgressService( val pupil = currentUserResolver.requirePupil(currentUser.subject) val pupilId = requireNotNull(pupil.id) { "Authenticated pupil is missing an id" } - val classroomId = try { - val classroom = currentUserResolver.getClassroomByUser(currentUser) - requireNotNull(classroom.id) { "Active classroom is missing an id" } - } catch (_: ResponseStatusException) { - return 1 - } + val classroomId = + try { + val classroom = currentUserResolver.getClassroomByUser(currentUser) + requireNotNull(classroom.id) { "Active classroom is missing an id" } + } catch (_: ResponseStatusException) { + return 1 + } return pupilGameProgressRepository.findByPupilIdAndClassroomId(pupilId, classroomId)?.currentLevel ?: 1 } @@ -159,6 +173,5 @@ class PupilGameProgressService( * @param totalXp classroom-scoped accumulated XP * @return derived 1-based level. */ - private fun calculateLevelFromXp(totalXp: Int): Int = - (totalXp / XP_PER_LEVEL) + 1 + private fun calculateLevelFromXp(totalXp: Int): Int = (totalXp / XP_PER_LEVEL) + 1 } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/infrastructure/PupilGameProgressRepository.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/infrastructure/PupilGameProgressRepository.kt index b5089e7f..bbcf872e 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/infrastructure/PupilGameProgressRepository.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/infrastructure/PupilGameProgressRepository.kt @@ -15,7 +15,10 @@ interface PupilGameProgressRepository : JpaRepository = - mapStopsRepository.findAllByIsActiveTrueOrderByDisplayOrderAsc() + fun getAllMapStops(): List = mapStopsRepository.findAllByIsActiveTrueOrderByDisplayOrderAsc() /** * Returns a single active map stop by its URL slug. @@ -40,11 +39,11 @@ class MapStopService( * @return matching map stop * @throws ResponseStatusException 404 if no stop matches the slug */ - fun getMapStopBySlug(slug: String): MapStop { - return mapStopsRepository.findBySlugIgnoreCase(slug) + fun getMapStopBySlug(slug: String): MapStop = + mapStopsRepository + .findBySlugIgnoreCase(slug) ?.takeIf { it.isActive == true } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "No map stop found for slug: $slug") - } /** * Returns all active map stops enriched with the pupil's progress, stars, and lock state, @@ -64,9 +63,10 @@ class MapStopService( if (accessContext.allStopsOpen || accessContext.pupilUserId == null) { return stops.map { stop -> stop.toOpenOverviewEntry() } } - val progressByStopId = pupilStopProgressRepository - .findAllByIdPupilUserIdAndIdClassroomId(accessContext.pupilUserId, classroomId) - .associateBy { it.id.stopId } + val progressByStopId = + pupilStopProgressRepository + .findAllByIdPupilUserIdAndIdClassroomId(accessContext.pupilUserId, classroomId) + .associateBy { it.id.stopId } val maxScoreByStopId = loadPublishedStopMaxScores(classroomId) return stops.mapNotNull { stop -> @@ -107,12 +107,14 @@ class MapStopService( if (accessContext.allStopsOpen || accessContext.pupilUserId == null) { return stop.toOpenOverviewEntry() } - val stopId = stop.id - ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Map stop has no id") + val stopId = + stop.id + ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Map stop has no id") val maxScoreByStopId = loadPublishedStopMaxScores(classroomId) - val progress = pupilStopProgressRepository - .findByIdPupilUserIdAndIdClassroomIdAndIdStopId(accessContext.pupilUserId, classroomId, stopId) + val progress = + pupilStopProgressRepository + .findByIdPupilUserIdAndIdClassroomIdAndIdStopId(accessContext.pupilUserId, classroomId, stopId) return MapStopOverviewEntry( stopId = stopId, @@ -131,13 +133,17 @@ class MapStopService( * Loads the total max score for all published tasks at each stop in a classroom. */ private fun loadPublishedStopMaxScores(classroomId: Long): Map = - taskRepository.findAllByClassroomIdOrderByStopIdAscDifficultyLevelAsc(classroomId) + taskRepository + .findAllByClassroomIdOrderByStopIdAscDifficultyLevelAsc(classroomId) .asSequence() .filter { it.isPublished } .groupBy { it.stopId } .mapValues { (_, tasks) -> tasks.sumOf { it.maxScore } } - private fun computeStars(bestScore: Int, maxScore: Int): Int { + private fun computeStars( + bestScore: Int, + maxScore: Int, + ): Int { if (maxScore == 0) return 0 val ratio = bestScore.toDouble() / maxScore return when { @@ -150,17 +156,18 @@ class MapStopService( } private fun MapStop.toOpenOverviewEntry(): MapStopOverviewEntry { - val stopId = id ?: return MapStopOverviewEntry( - stopId = 0, - slug = slug, - title = title, - category = category, - displayOrder = displayOrder ?: 0, - isFinalBoss = isFinalBoss ?: false, - status = StopProgressStatus.NOT_STARTED, - stars = 0, - medalAwarded = false, - ) + val stopId = + id ?: return MapStopOverviewEntry( + stopId = 0, + slug = slug, + title = title, + category = category, + displayOrder = displayOrder ?: 0, + isFinalBoss = isFinalBoss ?: false, + status = StopProgressStatus.NOT_STARTED, + stars = 0, + medalAwarded = false, + ) return MapStopOverviewEntry( stopId = stopId, slug = slug, diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/application/PupilProgressSeedingService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/application/PupilProgressSeedingService.kt index 63786832..d7fa02d6 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/application/PupilProgressSeedingService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/application/PupilProgressSeedingService.kt @@ -22,7 +22,6 @@ class PupilProgressSeedingService( private val mapStopsRepository: MapStopsRepository, private val pupilStopProgressRepository: PupilStopProgressRepository, ) { - /** * Seeds progress records for a pupil in a classroom if none exist yet. * The first stop (lowest display order) is seeded as [StopProgressStatus.NOT_STARTED], @@ -39,31 +38,35 @@ class PupilProgressSeedingService( pupil: User, classroom: Classroom, ) { - val alreadySeeded = pupilStopProgressRepository - .findAllByIdPupilUserIdAndIdClassroomId(pupil.id, classroom.id) - .isNotEmpty() + val alreadySeeded = + pupilStopProgressRepository + .findAllByIdPupilUserIdAndIdClassroomId(pupil.id, classroom.id) + .isNotEmpty() if (alreadySeeded) return val stops = mapStopsRepository.findAllByIsActiveTrueOrderByDisplayOrderAsc() if (stops.isEmpty()) return - val records = stops.mapIndexed { index, stop -> - val stopId = stop.id ?: return@mapIndexed null - PupilStopProgress( - id = PupilStopProgressId( - pupilUserId = pupil.id, - classroomId = classroom.id, - stopId = stopId, - ), - pupil = pupil, - classroom = classroom, - stop = stop, - ).apply { - status = if (index == 0) StopProgressStatus.NOT_STARTED else StopProgressStatus.LOCKED - } - }.filterNotNull() + val records = + stops + .mapIndexed { index, stop -> + val stopId = stop.id ?: return@mapIndexed null + PupilStopProgress( + id = + PupilStopProgressId( + pupilUserId = pupil.id, + classroomId = classroom.id, + stopId = stopId, + ), + pupil = pupil, + classroom = classroom, + stop = stop, + ).apply { + status = if (index == 0) StopProgressStatus.NOT_STARTED else StopProgressStatus.LOCKED + } + }.filterNotNull() pupilStopProgressRepository.saveAll(records) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/application/PupilStopProgressService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/application/PupilStopProgressService.kt index fd98c294..c6ddf2cc 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/application/PupilStopProgressService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/application/PupilStopProgressService.kt @@ -7,13 +7,13 @@ import edu.ntnu.idi.idatt.backend.game.mapstops.infrastructure.MapStopsRepositor import edu.ntnu.idi.idatt.backend.game.mapstops.infrastructure.PupilStopProgressRepository import edu.ntnu.idi.idatt.backend.iam.application.CurrentUserResolver import edu.ntnu.idi.idatt.backend.iam.security.CurrentUser -import edu.ntnu.idi.idatt.backend.xp.application.XpService -import edu.ntnu.idi.idatt.backend.xp.domain.XpEventCode import edu.ntnu.idi.idatt.backend.shared.events.DomainEventPublisher import edu.ntnu.idi.idatt.backend.shared.events.MapStopCompletedEvent import edu.ntnu.idi.idatt.backend.shared.events.PupilGameCompletedEvent import edu.ntnu.idi.idatt.backend.shared.events.PupilRecoveredFromStuckEvent import edu.ntnu.idi.idatt.backend.shared.events.PupilStuckThresholdReachedEvent +import edu.ntnu.idi.idatt.backend.xp.application.XpService +import edu.ntnu.idi.idatt.backend.xp.domain.XpEventCode import org.springframework.http.HttpStatus import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -46,8 +46,7 @@ class PupilStopProgressService( fun getProgressForPupil( pupilUserId: Long, classroomId: Long, - ): List = - progressRepository.findAllByIdPupilUserIdAndIdClassroomId(pupilUserId, classroomId) + ): List = progressRepository.findAllByIdPupilUserIdAndIdClassroomId(pupilUserId, classroomId) /** * Returns progress for a single stop for a pupil in a classroom. @@ -64,7 +63,9 @@ class PupilStopProgressService( stopId: Long, ): PupilStopProgress = progressRepository.findByIdPupilUserIdAndIdClassroomIdAndIdStopId( - pupilUserId, classroomId, stopId, + pupilUserId, + classroomId, + stopId, ) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Progress record not found") /** @@ -205,9 +206,12 @@ class PupilStopProgressService( if (currentIndex <= 0) return val previousStop = activeStops[currentIndex - 1] - val previousProgress = progressRepository.findByIdPupilUserIdAndIdClassroomIdAndIdStopId( - pupilUserId, classroomId, requireNotNull(previousStop.id), - ) + val previousProgress = + progressRepository.findByIdPupilUserIdAndIdClassroomIdAndIdStopId( + pupilUserId, + classroomId, + requireNotNull(previousStop.id), + ) if (previousProgress?.status != StopProgressStatus.COMPLETED) { throw ResponseStatusException( @@ -243,9 +247,12 @@ class PupilStopProgressService( if (currentIndex == -1 || currentIndex >= activeStops.lastIndex) return val nextStop = activeStops[currentIndex + 1] - val nextProgress = progressRepository.findByIdPupilUserIdAndIdClassroomIdAndIdStopId( - pupilUserId, classroomId, requireNotNull(nextStop.id), - ) ?: return + val nextProgress = + progressRepository.findByIdPupilUserIdAndIdClassroomIdAndIdStopId( + pupilUserId, + classroomId, + requireNotNull(nextStop.id), + ) ?: return if (nextProgress.status == StopProgressStatus.LOCKED) { nextProgress.status = StopProgressStatus.NOT_STARTED @@ -253,13 +260,17 @@ class PupilStopProgressService( } } - private fun validateTransition(current: StopProgressStatus, next: StopProgressStatus) { - val allowed = mapOf( - StopProgressStatus.LOCKED to setOf(StopProgressStatus.NOT_STARTED), - StopProgressStatus.NOT_STARTED to setOf(StopProgressStatus.IN_PROGRESS), - StopProgressStatus.IN_PROGRESS to setOf(StopProgressStatus.COMPLETED), - StopProgressStatus.COMPLETED to emptySet(), - ) + private fun validateTransition( + current: StopProgressStatus, + next: StopProgressStatus, + ) { + val allowed = + mapOf( + StopProgressStatus.LOCKED to setOf(StopProgressStatus.NOT_STARTED), + StopProgressStatus.NOT_STARTED to setOf(StopProgressStatus.IN_PROGRESS), + StopProgressStatus.IN_PROGRESS to setOf(StopProgressStatus.COMPLETED), + StopProgressStatus.COMPLETED to emptySet(), + ) if (next !in allowed.getValue(current)) { throw ResponseStatusException( HttpStatus.UNPROCESSABLE_ENTITY, diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/application/UpdateStopProgressCommand.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/application/UpdateStopProgressCommand.kt index 525ceefc..978f07a5 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/application/UpdateStopProgressCommand.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/application/UpdateStopProgressCommand.kt @@ -15,4 +15,4 @@ data class UpdateStopProgressCommand( val currentTaskId: Long?, val bestScore: Int?, val medalAwarded: Boolean?, -) \ No newline at end of file +) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/domain/MapStop.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/domain/MapStop.kt index e9292b77..e58cd233 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/domain/MapStop.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/domain/MapStop.kt @@ -25,7 +25,7 @@ import jakarta.persistence.Table */ @Entity @Table(name = "map_stops") -class MapStop ( +class MapStop( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) var id: Long? = null, @@ -41,8 +41,8 @@ class MapStop ( var displayOrder: Int? = null, @Column(name = "max_score") var maxScore: Int? = null, - @Column(name="is_final_boss") + @Column(name = "is_final_boss") var isFinalBoss: Boolean? = false, @Column(name = "is_active") var isActive: Boolean? = false, -) \ No newline at end of file +) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/domain/PupilStopProgress.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/domain/PupilStopProgress.kt index 34b12d57..e46e78dd 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/domain/PupilStopProgress.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/domain/PupilStopProgress.kt @@ -1,8 +1,9 @@ package edu.ntnu.idi.idatt.backend.game.mapstops.domain -import edu.ntnu.idi.idatt.backend.iam.domain.User import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom +import edu.ntnu.idi.idatt.backend.iam.domain.User import jakarta.persistence.Column +import jakarta.persistence.Embeddable import jakarta.persistence.EmbeddedId import jakarta.persistence.Entity import jakarta.persistence.EnumType @@ -12,7 +13,6 @@ import jakarta.persistence.JoinColumn import jakarta.persistence.ManyToOne import jakarta.persistence.MapsId import jakarta.persistence.Table -import jakarta.persistence.Embeddable import java.io.Serializable import java.time.Instant @@ -35,7 +35,6 @@ import java.time.Instant @Entity @Table(name = "pupil_stop_progress") class PupilStopProgress( - @EmbeddedId var id: PupilStopProgressId, @ManyToOne(fetch = FetchType.LAZY) @@ -54,14 +53,19 @@ class PupilStopProgress( @Enumerated(EnumType.STRING) @Column(nullable = false) var status: StopProgressStatus = StopProgressStatus.NOT_STARTED + @Column var currentTaskId: Long? = null + @Column(nullable = false) var bestScore: Int = 0 + @Column(name = "consecutive_wrong_attempts", nullable = false) var consecutiveWrongAttempts: Int = 0 + @Column(nullable = false) var medalAwarded: Boolean = false + @Column var completedAt: Instant? = null } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/infrastructure/MapStopsRepository.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/infrastructure/MapStopsRepository.kt index c2177602..19321d56 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/infrastructure/MapStopsRepository.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/infrastructure/MapStopsRepository.kt @@ -9,7 +9,6 @@ import org.springframework.stereotype.Repository */ @Repository interface MapStopsRepository : JpaRepository { - /** * Check if a MapStop exists by its slug. */ @@ -24,4 +23,4 @@ interface MapStopsRepository : JpaRepository { * Find a MapStop from the slug. */ fun findBySlugIgnoreCase(slug: String): MapStop? -} \ No newline at end of file +} diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/infrastructure/PupilStopProgressRepository.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/infrastructure/PupilStopProgressRepository.kt index e3950aca..d07eef63 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/infrastructure/PupilStopProgressRepository.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/infrastructure/PupilStopProgressRepository.kt @@ -10,7 +10,6 @@ import org.springframework.stereotype.Repository */ @Repository interface PupilStopProgressRepository : JpaRepository { - /** * Find pupilsStopProgress by pupilUserId and classroomId. */ @@ -27,4 +26,4 @@ interface PupilStopProgressRepository : JpaRepository - TaskItemResponse( - id = item.id, - itemOrder = item.itemOrder, - promptText = item.promptText.toResponse(), - mediaUrl = item.mediaUrl, - metadataJson = item.metadataJson, - options = item.options.map { option -> - TaskOptionResponse( - id = option.id, - optionOrder = option.optionOrder, - optionText = option.optionText.toResponse(), - isCorrect = option.isCorrect.takeIf { includeAnswers }, - explanationText = option.explanationText?.takeIf { includeAnswers }?.toResponse(), - ) - }, - ) - }, + items = + items.map { item -> + TaskItemResponse( + id = item.id, + itemOrder = item.itemOrder, + promptText = item.promptText.toResponse(), + mediaUrl = item.mediaUrl, + metadataJson = item.metadataJson, + options = + item.options.map { option -> + TaskOptionResponse( + id = option.id, + optionOrder = option.optionOrder, + optionText = option.optionText.toResponse(), + isCorrect = option.isCorrect.takeIf { includeAnswers }, + explanationText = option.explanationText?.takeIf { includeAnswers }?.toResponse(), + ) + }, + ) + }, ) -private fun LocalizedText.toResponse(): LocalizedTextResponse = - LocalizedTextResponse(nb = nb, en = en) +private fun LocalizedText.toResponse(): LocalizedTextResponse = LocalizedTextResponse(nb = nb, en = en) -private fun LocalizedOptionalText.toResponse(): LocalizedOptionalTextResponse = - LocalizedOptionalTextResponse(nb = nb, en = en) +private fun LocalizedOptionalText.toResponse(): LocalizedOptionalTextResponse = LocalizedOptionalTextResponse(nb = nb, en = en) /** * Maps the stop submission result to the API response. diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/tasks/api/TaskController.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/tasks/api/TaskController.kt index 839eee13..c00ebbd0 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/tasks/api/TaskController.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/tasks/api/TaskController.kt @@ -1,13 +1,14 @@ package edu.ntnu.idi.idatt.backend.game.tasks.api -import edu.ntnu.idi.idatt.backend.iam.security.CurrentUser import edu.ntnu.idi.idatt.backend.game.tasks.application.TaskService +import edu.ntnu.idi.idatt.backend.iam.security.CurrentUser import jakarta.validation.Valid +import org.springframework.http.HttpStatus import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestBody @@ -15,7 +16,6 @@ import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController -import org.springframework.http.HttpStatus /** * REST API for listing, authoring, publishing, and deleting classroom tasks. @@ -33,8 +33,7 @@ class TaskController( @PathVariable classroomId: Long, @RequestParam(required = false) stopId: Long?, @AuthenticationPrincipal currentUser: CurrentUser, - ): List = - taskService.listTasks(currentUser, classroomId, stopId).map { it.toResponse() } + ): List = taskService.listTasks(currentUser, classroomId, stopId).map { it.toResponse() } /** * Returns the full contents of one task. @@ -59,8 +58,7 @@ class TaskController( @PathVariable classroomId: Long, @RequestBody @Valid request: SubmitStopAnswersRequest, @AuthenticationPrincipal currentUser: CurrentUser, - ): StopSubmissionResponse = - taskService.submitStopAnswers(request.toCommand(classroomId, currentUser)).toResponse() + ): StopSubmissionResponse = taskService.submitStopAnswers(request.toCommand(classroomId, currentUser)).toResponse() /** * Creates a new draft task owned by the classroom's teacher. @@ -71,8 +69,7 @@ class TaskController( @PathVariable classroomId: Long, @RequestBody @Valid request: CreateTaskRequest, @AuthenticationPrincipal currentUser: CurrentUser, - ): TaskResponse = - taskService.createTask(request.toCommand(classroomId, currentUser)).toResponse() + ): TaskResponse = taskService.createTask(request.toCommand(classroomId, currentUser)).toResponse() /** * Replaces the editable contents of an existing task. @@ -83,8 +80,7 @@ class TaskController( @PathVariable taskId: Long, @RequestBody @Valid request: UpdateTaskRequest, @AuthenticationPrincipal currentUser: CurrentUser, - ): TaskResponse = - taskService.updateTask(request.toCommand(classroomId, taskId, currentUser)).toResponse() + ): TaskResponse = taskService.updateTask(request.toCommand(classroomId, taskId, currentUser)).toResponse() /** * Publishes or unpublishes a task. @@ -95,8 +91,7 @@ class TaskController( @PathVariable taskId: Long, @RequestBody request: UpdateTaskPublishStateRequest, @AuthenticationPrincipal currentUser: CurrentUser, - ): TaskResponse = - taskService.updatePublishState(currentUser, classroomId, taskId, request.published).toResponse() + ): TaskResponse = taskService.updatePublishState(currentUser, classroomId, taskId, request.published).toResponse() /** * Deletes a task from the classroom. diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/tasks/api/TaskRequests.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/tasks/api/TaskRequests.kt index 1ad78b64..988a0ae3 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/tasks/api/TaskRequests.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/tasks/api/TaskRequests.kt @@ -199,22 +199,21 @@ private fun List.toCommands(): List = promptText = item.promptText.toLocalizedText(), mediaUrl = item.mediaUrl, metadataJson = item.metadataJson, - options = item.options.map { option -> - ReplaceTaskOptionCommand( - optionOrder = option.optionOrder, - optionText = option.optionText.toLocalizedText(), - isCorrect = option.isCorrect, - explanationText = option.explanationText?.toLocalizedOptionalText(), - ) - }, + options = + item.options.map { option -> + ReplaceTaskOptionCommand( + optionOrder = option.optionOrder, + optionText = option.optionText.toLocalizedText(), + isCorrect = option.isCorrect, + explanationText = option.explanationText?.toLocalizedOptionalText(), + ) + }, ) } -private fun LocalizedRequiredTextRequest.toLocalizedText(): LocalizedText = - LocalizedText(nb = nb, en = en) +private fun LocalizedRequiredTextRequest.toLocalizedText(): LocalizedText = LocalizedText(nb = nb, en = en) -private fun LocalizedOptionalTextRequest.toLocalizedOptionalText(): LocalizedOptionalText = - LocalizedOptionalText(nb = nb, en = en) +private fun LocalizedOptionalTextRequest.toLocalizedOptionalText(): LocalizedOptionalText = LocalizedOptionalText(nb = nb, en = en) fun SubmitStopAnswersRequest.toCommand( classroomId: Long, @@ -223,15 +222,17 @@ fun SubmitStopAnswersRequest.toCommand( currentUser = currentUser, classroomId = classroomId, stopId = stopId, - taskAnswers = taskAnswers.map { taskAnswer -> - edu.ntnu.idi.idatt.backend.game.tasks.application.SubmitTaskAnswerCommand( - taskId = taskAnswer.taskId, - itemAnswers = taskAnswer.itemAnswers.map { itemAnswer -> - edu.ntnu.idi.idatt.backend.game.tasks.application.SubmitItemAnswerCommand( - itemId = itemAnswer.itemId, - selectedOptionId = itemAnswer.selectedOptionId, - ) - }, - ) - }, + taskAnswers = + taskAnswers.map { taskAnswer -> + edu.ntnu.idi.idatt.backend.game.tasks.application.SubmitTaskAnswerCommand( + taskId = taskAnswer.taskId, + itemAnswers = + taskAnswer.itemAnswers.map { itemAnswer -> + edu.ntnu.idi.idatt.backend.game.tasks.application.SubmitItemAnswerCommand( + itemId = itemAnswer.itemId, + selectedOptionId = itemAnswer.selectedOptionId, + ) + }, + ) + }, ) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/tasks/application/TaskService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/tasks/application/TaskService.kt index 59b50880..454bab5a 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/tasks/application/TaskService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/tasks/application/TaskService.kt @@ -2,12 +2,11 @@ package edu.ntnu.idi.idatt.backend.game.tasks.application import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom import edu.ntnu.idi.idatt.backend.classroom.infrastructure.ClassroomRepository +import edu.ntnu.idi.idatt.backend.game.application.GameAccessContextResolver +import edu.ntnu.idi.idatt.backend.game.application.GameReadMode import edu.ntnu.idi.idatt.backend.game.mapstops.application.PupilStopProgressService import edu.ntnu.idi.idatt.backend.game.mapstops.application.UpdateStopProgressCommand import edu.ntnu.idi.idatt.backend.game.mapstops.domain.StopProgressStatus -import edu.ntnu.idi.idatt.backend.game.application.GameAccessContextResolver -import edu.ntnu.idi.idatt.backend.game.application.GameReadMode -import edu.ntnu.idi.idatt.backend.iam.security.CurrentUser import edu.ntnu.idi.idatt.backend.game.tasks.domain.Task import edu.ntnu.idi.idatt.backend.game.tasks.domain.TaskItem import edu.ntnu.idi.idatt.backend.game.tasks.domain.TaskItemTranslation @@ -23,6 +22,7 @@ import edu.ntnu.idi.idatt.backend.game.tasks.infrastructure.TaskRepository import edu.ntnu.idi.idatt.backend.iam.application.CurrentUserResolver import edu.ntnu.idi.idatt.backend.iam.domain.User import edu.ntnu.idi.idatt.backend.iam.domain.UserRole +import edu.ntnu.idi.idatt.backend.iam.security.CurrentUser import org.springframework.dao.DataIntegrityViolationException import org.springframework.http.HttpStatus import org.springframework.stereotype.Service @@ -57,11 +57,15 @@ class TaskService( readMode: GameReadMode = GameReadMode.DEFAULT, ): List { val accessContext = gameAccessContextResolver.resolveReadContext(currentUser, classroomId, readMode) - val tasks = if (stopId != null) { - taskRepository.findAllByClassroomIdAndStopIdOrderByDifficultyLevelAsc(accessContext.classroom.id, stopId) - } else { - taskRepository.findAllByClassroomIdOrderByStopIdAscDifficultyLevelAsc(accessContext.classroom.id) - } + val tasks = + if (stopId != null) { + taskRepository.findAllByClassroomIdAndStopIdOrderByDifficultyLevelAsc( + accessContext.classroom.id, + stopId, + ) + } else { + taskRepository.findAllByClassroomIdOrderByStopIdAscDifficultyLevelAsc(accessContext.classroom.id) + } return tasks .filter { if (accessContext.publishedOnly) it.isPublished else true } .map { it.toSummary() } @@ -116,18 +120,23 @@ class TaskService( items = command.items, ) - val task = Task( - stopId = command.stopId, - classroom = classroom, - createdByTeacher = teacher, - title = command.title.nb.trim(), - introText = command.introText?.nb?.trim()?.takeIf { it.isNotBlank() }, - difficultyLevel = command.difficultyLevel, - taskType = command.taskType, - passingRule = command.passingRule, - passingScore = command.passingScore, - maxScore = command.maxScore, - ) + val task = + Task( + stopId = command.stopId, + classroom = classroom, + createdByTeacher = teacher, + title = command.title.nb.trim(), + introText = + command.introText + ?.nb + ?.trim() + ?.takeIf { it.isNotBlank() }, + difficultyLevel = command.difficultyLevel, + taskType = command.taskType, + passingRule = command.passingRule, + passingScore = command.passingScore, + maxScore = command.maxScore, + ) replaceTaskTranslations(task, command.title, command.introText) replaceItems(task, command.items) @@ -165,7 +174,11 @@ class TaskService( task.stopId = command.stopId task.title = command.title.nb.trim() - task.introText = command.introText?.nb?.trim()?.takeIf { it.isNotBlank() } + task.introText = + command.introText + ?.nb + ?.trim() + ?.takeIf { it.isNotBlank() } task.difficultyLevel = command.difficultyLevel task.taskType = command.taskType task.passingRule = command.passingRule @@ -243,8 +256,10 @@ class TaskService( throw ResponseStatusException(HttpStatus.FORBIDDEN, "Stop is locked") } - val tasks = taskRepository.findAllByClassroomIdAndStopIdOrderByDifficultyLevelAsc(command.classroomId, command.stopId) - .filter { it.isPublished } + val tasks = + taskRepository + .findAllByClassroomIdAndStopIdOrderByDifficultyLevelAsc(command.classroomId, command.stopId) + .filter { it.isPublished } if (tasks.isEmpty()) { throw ResponseStatusException(HttpStatus.NOT_FOUND, "No published tasks found for stop") @@ -260,25 +275,28 @@ class TaskService( ) } - val taskResults = tasks.map { task -> - val taskId = requireNotNull(task.id) - val taskAnswer = answersByTaskId[taskId] - ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Missing answer for task $taskId") - evaluateTaskSubmission(task, taskAnswer) - } + val taskResults = + tasks.map { task -> + val taskId = requireNotNull(task.id) + val taskAnswer = + answersByTaskId[taskId] + ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Missing answer for task $taskId") + evaluateTaskSubmission(task, taskAnswer) + } val totalScore = taskResults.sumOf { it.score } val totalMaxScore = tasks.sumOf { it.maxScore } val passed = taskResults.all { it.passed } - val updatedProgress = updateProgressAfterSubmission( - currentUser = command.currentUser, - classroomId = command.classroomId, - stopId = command.stopId, - currentStatus = progress.status, - score = totalScore, - passed = passed, - firstFailedTaskId = taskResults.firstOrNull { !it.passed }?.taskId, - ) + val updatedProgress = + updateProgressAfterSubmission( + currentUser = command.currentUser, + classroomId = command.classroomId, + stopId = command.stopId, + currentStatus = progress.status, + score = totalScore, + passed = passed, + firstFailedTaskId = taskResults.firstOrNull { !it.passed }?.taskId, + ) return StopSubmissionResult( stopId = command.stopId, @@ -291,32 +309,43 @@ class TaskService( ) } - private fun requireOwnedClassroom(authenticatedSubject: String, classroomId: Long): Classroom { + private fun requireOwnedClassroom( + authenticatedSubject: String, + classroomId: Long, + ): Classroom { val user = currentUserResolver.requireTeacherOrAdmin(authenticatedSubject) val userId = requireNotNull(user.id) { "Authenticated user is missing an id" } - val classroom = classroomRepository.findById(classroomId) - .orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") - } + val classroom = + classroomRepository + .findById(classroomId) + .orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") + } if (user.role != UserRole.ADMIN && classroom.ownerTeacherId != userId) { throw ResponseStatusException(HttpStatus.FORBIDDEN, "Only the classroom owner may manage tasks") } return classroom } - private fun requireParticipatingClassroom(authenticatedSubject: String, classroomId: Long): Pair { + private fun requireParticipatingClassroom( + authenticatedSubject: String, + classroomId: Long, + ): Pair { val user = currentUserResolver.requireUser(authenticatedSubject) val userId = requireNotNull(user.id) { "Authenticated user is missing an id" } - val classroom = classroomRepository.findById(classroomId) - .orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") - } + val classroom = + classroomRepository + .findById(classroomId) + .orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") + } - val hasAccess = when (user.role) { - UserRole.ADMIN -> true - UserRole.TEACHER -> classroom.ownerTeacherId == userId - UserRole.PUPIL -> classroom.hasPupilMembership(userId) - } + val hasAccess = + when (user.role) { + UserRole.ADMIN -> true + UserRole.TEACHER -> classroom.ownerTeacherId == userId + UserRole.PUPIL -> classroom.hasPupilMembership(userId) + } if (!hasAccess) { throw ResponseStatusException(HttpStatus.FORBIDDEN, "User does not have access to this classroom") @@ -324,7 +353,11 @@ class TaskService( return Pair(classroom, user) } - private fun requireTask(classroomId: Long, taskId: Long): Task = + + private fun requireTask( + classroomId: Long, + taskId: Long, + ): Task = taskRepository.findByIdAndClassroomId(taskId, classroomId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Task not found") @@ -362,23 +395,32 @@ class TaskService( ) } - private fun replaceItems(task: Task, itemCommands: List) { + private fun replaceItems( + task: Task, + itemCommands: List, + ) { task.items.clear() itemCommands.sortedBy { it.itemOrder }.forEach { itemCommand -> - val item = TaskItem( - itemOrder = itemCommand.itemOrder, - promptText = itemCommand.promptText.nb.trim(), - mediaUrl = itemCommand.mediaUrl?.trim()?.takeIf { it.isNotBlank() }, - metadataJson = itemCommand.metadataJson?.trim()?.takeIf { it.isNotBlank() }, - ) + val item = + TaskItem( + itemOrder = itemCommand.itemOrder, + promptText = itemCommand.promptText.nb.trim(), + mediaUrl = itemCommand.mediaUrl?.trim()?.takeIf { it.isNotBlank() }, + metadataJson = itemCommand.metadataJson?.trim()?.takeIf { it.isNotBlank() }, + ) replaceItemTranslations(item, itemCommand.promptText) itemCommand.options.sortedBy { it.optionOrder }.forEach { optionCommand -> - val option = TaskOption( - optionOrder = optionCommand.optionOrder, - optionText = optionCommand.optionText.nb.trim(), - isCorrect = optionCommand.isCorrect, - explanationText = optionCommand.explanationText?.nb?.trim()?.takeIf { it.isNotBlank() }, - ) + val option = + TaskOption( + optionOrder = optionCommand.optionOrder, + optionText = optionCommand.optionText.nb.trim(), + isCorrect = optionCommand.isCorrect, + explanationText = + optionCommand.explanationText + ?.nb + ?.trim() + ?.takeIf { it.isNotBlank() }, + ) replaceOptionTranslations(option, optionCommand.optionText, optionCommand.explanationText) item.addOption(option) } @@ -489,7 +531,10 @@ class TaskService( private fun validatePublishable(task: Task) { if (task.items.isEmpty()) { - throw ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "Published tasks must include at least one item") + throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "Published tasks must include at least one item", + ) } task.items.filter { it.options.isNotEmpty() }.forEach { item -> @@ -511,8 +556,7 @@ class TaskService( private fun evaluateTaskSubmission( task: Task, taskAnswer: SubmitTaskAnswerCommand, - ): TaskSubmissionResult = - evaluateOptionBasedTask(task, taskAnswer) + ): TaskSubmissionResult = evaluateOptionBasedTask(task, taskAnswer) private fun evaluateOptionBasedTask( task: Task, @@ -536,21 +580,24 @@ class TaskService( answerableItems.forEach { item -> val itemId = requireNotNull(item.id) - val itemAnswer = answerByItemId[itemId] - ?: throw ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Missing answer for item $itemId in task ${requireNotNull(task.id)}", - ) - val selectedOption = item.options.firstOrNull { it.id == itemAnswer.selectedOptionId } - ?: throw ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Selected option ${itemAnswer.selectedOptionId} does not belong to item $itemId", - ) + val itemAnswer = + answerByItemId[itemId] + ?: throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Missing answer for item $itemId in task ${requireNotNull(task.id)}", + ) + val selectedOption = + item.options.firstOrNull { it.id == itemAnswer.selectedOptionId } + ?: throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Selected option ${itemAnswer.selectedOptionId} does not belong to item $itemId", + ) if (selectedOption.isCorrect) { score += baseItemScore } else { allAnswersCorrect = false - selectedOption.resolveExplanationText() + selectedOption + .resolveExplanationText() .takeIf { it.nb != null || it.en != null } ?.let(feedback::add) } @@ -570,7 +617,10 @@ class TaskService( ) } - private fun passesTask(task: Task, score: Int): Boolean = + private fun passesTask( + task: Task, + score: Int, + ): Boolean = when (task.passingRule) { TaskPassingRule.ALL_CORRECT -> score >= task.maxScore TaskPassingRule.MINIMUM_SCORE -> score >= (task.passingScore ?: 0) @@ -584,34 +634,34 @@ class TaskService( score: Int, passed: Boolean, firstFailedTaskId: Long?, - ) = - when { - passed && currentStatus == StopProgressStatus.NOT_STARTED -> { - pupilStopProgressService.updateProgress( - currentUser, - classroomId, - stopId, - UpdateStopProgressCommand( - status = StopProgressStatus.IN_PROGRESS, - currentTaskId = null, - bestScore = score, - medalAwarded = null, - ), - ) - pupilStopProgressService.updateProgress( - currentUser, - classroomId, - stopId, - UpdateStopProgressCommand( - status = StopProgressStatus.COMPLETED, - currentTaskId = null, - bestScore = score, - medalAwarded = true, - ), - ) - } + ) = when { + passed && currentStatus == StopProgressStatus.NOT_STARTED -> { + pupilStopProgressService.updateProgress( + currentUser, + classroomId, + stopId, + UpdateStopProgressCommand( + status = StopProgressStatus.IN_PROGRESS, + currentTaskId = null, + bestScore = score, + medalAwarded = null, + ), + ) + pupilStopProgressService.updateProgress( + currentUser, + classroomId, + stopId, + UpdateStopProgressCommand( + status = StopProgressStatus.COMPLETED, + currentTaskId = null, + bestScore = score, + medalAwarded = true, + ), + ) + } - passed && currentStatus == StopProgressStatus.IN_PROGRESS -> pupilStopProgressService.updateProgress( + passed && currentStatus == StopProgressStatus.IN_PROGRESS -> + pupilStopProgressService.updateProgress( currentUser, classroomId, stopId, @@ -623,7 +673,8 @@ class TaskService( ), ) - passed -> pupilStopProgressService.updateProgress( + passed -> + pupilStopProgressService.updateProgress( currentUser, classroomId, stopId, @@ -635,7 +686,8 @@ class TaskService( ), ) - currentStatus == StopProgressStatus.NOT_STARTED -> pupilStopProgressService.updateProgress( + currentStatus == StopProgressStatus.NOT_STARTED -> + pupilStopProgressService.updateProgress( currentUser, classroomId, stopId, @@ -647,7 +699,8 @@ class TaskService( ), ) - else -> pupilStopProgressService.updateProgress( + else -> + pupilStopProgressService.updateProgress( currentUser, classroomId, stopId, @@ -658,32 +711,45 @@ class TaskService( medalAwarded = null, ), ) - } + } private fun combineFeedback(values: List): LocalizedOptionalText? { if (values.isEmpty()) return null return LocalizedOptionalText( - nb = values.mapNotNull { it.nb?.trim()?.takeIf(String::isNotBlank) } - .distinct() - .takeIf { it.isNotEmpty() } - ?.joinToString("\n\n"), - en = values.mapNotNull { it.en?.trim()?.takeIf(String::isNotBlank) } - .distinct() - .takeIf { it.isNotEmpty() } - ?.joinToString("\n\n"), + nb = + values + .mapNotNull { it.nb?.trim()?.takeIf(String::isNotBlank) } + .distinct() + .takeIf { it.isNotEmpty() } + ?.joinToString("\n\n"), + en = + values + .mapNotNull { it.en?.trim()?.takeIf(String::isNotBlank) } + .distinct() + .takeIf { it.isNotEmpty() } + ?.joinToString("\n\n"), ) } - private fun requireUniquePositiveOrders(values: List, fieldName: String) { + private fun requireUniquePositiveOrders( + values: List, + fieldName: String, + ) { if (values.any { it <= 0 }) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "$fieldName must be greater than zero") } if (values.size != values.toSet().size) { - throw ResponseStatusException(HttpStatus.BAD_REQUEST, "$fieldName values must be unique within their parent") + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "$fieldName values must be unique within their parent", + ) } } - private fun validateLocalizedRequiredText(value: LocalizedText, fieldName: String) { + private fun validateLocalizedRequiredText( + value: LocalizedText, + fieldName: String, + ) { if (value.nb.isBlank() || value.en.isBlank()) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "$fieldName must include non-blank nb and en values") } @@ -719,24 +785,26 @@ private fun Task.toDetails(): TaskDetails = isPublished = isPublished, createdAt = createdAt, updatedAt = updatedAt, - items = items.sortedBy { it.itemOrder }.map { item -> - TaskItemDetails( - id = requireNotNull(item.id), - itemOrder = item.itemOrder, - promptText = item.resolvePromptText(), - mediaUrl = item.mediaUrl, - metadataJson = item.metadataJson, - options = item.options.sortedBy { it.optionOrder }.map { option -> - TaskOptionDetails( - id = requireNotNull(option.id), - optionOrder = option.optionOrder, - optionText = option.resolveOptionText(), - isCorrect = option.isCorrect, - explanationText = option.resolveExplanationText(), - ) - }, - ) - }, + items = + items.sortedBy { it.itemOrder }.map { item -> + TaskItemDetails( + id = requireNotNull(item.id), + itemOrder = item.itemOrder, + promptText = item.resolvePromptText(), + mediaUrl = item.mediaUrl, + metadataJson = item.metadataJson, + options = + item.options.sortedBy { it.optionOrder }.map { option -> + TaskOptionDetails( + id = requireNotNull(option.id), + optionOrder = option.optionOrder, + optionText = option.resolveOptionText(), + isCorrect = option.isCorrect, + explanationText = option.resolveExplanationText(), + ) + }, + ) + }, ) private fun Task.resolveTitle(): LocalizedText = diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/tasks/infrastructure/TaskRepository.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/tasks/infrastructure/TaskRepository.kt index 80a67365..05ded186 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/tasks/infrastructure/TaskRepository.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/tasks/infrastructure/TaskRepository.kt @@ -15,5 +15,8 @@ interface TaskRepository : JpaRepository { stopId: Long, ): List - fun findByIdAndClassroomId(taskId: Long, classroomId: Long): Task? + fun findByIdAndClassroomId( + taskId: Long, + classroomId: Long, + ): Task? } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/api/AuthController.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/api/AuthController.kt index 06f04ce1..c610d58a 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/api/AuthController.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/api/AuthController.kt @@ -65,7 +65,8 @@ class AuthController( @Valid @RequestBody request: TeacherLoginRequest, ): ResponseEntity { val session = authService.teacherLogin(request.toCommand()) - return ResponseEntity.ok() + return ResponseEntity + .ok() .header(HttpHeaders.SET_COOKIE, refreshCookie(session.refreshToken).toString()) .body(session.toLoginResponse()) } @@ -79,7 +80,8 @@ class AuthController( @PostMapping("/teacher/refresh") fun teacherRefresh(request: HttpServletRequest): ResponseEntity { val session = authService.teacherRefresh(extractRefreshCookie(request)) - return ResponseEntity.ok() + return ResponseEntity + .ok() .header(HttpHeaders.SET_COOKIE, refreshCookie(session.refreshToken).toString()) .body(session.toLoginResponse()) } @@ -93,7 +95,8 @@ class AuthController( @PostMapping("/teacher/logout") fun teacherLogout(request: HttpServletRequest): ResponseEntity { authService.logout(extractRefreshCookie(request)) - return ResponseEntity.status(HttpStatus.NO_CONTENT) + return ResponseEntity + .status(HttpStatus.NO_CONTENT) .header(HttpHeaders.SET_COOKIE, clearRefreshCookie().toString()) .build() } @@ -110,7 +113,8 @@ class AuthController( @Valid @RequestBody request: PupilLoginRequest, ): ResponseEntity { val session = authService.pupilLoginOrCreateUser(request.toCommand()) - return ResponseEntity.ok() + return ResponseEntity + .ok() .header(HttpHeaders.SET_COOKIE, refreshCookie(session.refreshToken).toString()) .body(session.toLoginResponse()) } @@ -124,7 +128,8 @@ class AuthController( @PostMapping("/pupil/refresh") fun pupilRefresh(request: HttpServletRequest): ResponseEntity { val session = authService.pupilRefresh(extractRefreshCookie(request)) - return ResponseEntity.ok() + return ResponseEntity + .ok() .header(HttpHeaders.SET_COOKIE, refreshCookie(session.refreshToken).toString()) .body(session.toLoginResponse()) } @@ -138,7 +143,8 @@ class AuthController( @PostMapping("/pupil/logout") fun pupilLogout(request: HttpServletRequest): ResponseEntity { authService.logout(extractRefreshCookie(request)) - return ResponseEntity.status(HttpStatus.NO_CONTENT) + return ResponseEntity + .status(HttpStatus.NO_CONTENT) .header(HttpHeaders.SET_COOKIE, clearRefreshCookie().toString()) .build() } @@ -174,7 +180,9 @@ class AuthController( * @return current user payload exposed to the frontend */ @GetMapping("/me") - fun me(@AuthenticationPrincipal currentUser: CurrentUser): CurrentUserResponse = + fun me( + @AuthenticationPrincipal currentUser: CurrentUser, + ): CurrentUserResponse = CurrentUserResponse( subject = currentUser.subject, portalType = currentUser.portalType, @@ -199,7 +207,8 @@ class AuthController( } private fun refreshCookie(rawToken: String): ResponseCookie = - ResponseCookie.from(securityProperties.refresh.cookieName, rawToken) + ResponseCookie + .from(securityProperties.refresh.cookieName, rawToken) .httpOnly(true) .secure(securityProperties.refresh.secureCookie) .path(securityProperties.refresh.cookiePath) @@ -208,7 +217,8 @@ class AuthController( .build() private fun clearRefreshCookie(): ResponseCookie = - ResponseCookie.from(securityProperties.refresh.cookieName, "") + ResponseCookie + .from(securityProperties.refresh.cookieName, "") .httpOnly(true) .secure(securityProperties.refresh.secureCookie) .path(securityProperties.refresh.cookiePath) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/application/AuthService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/application/AuthService.kt index 209042a0..a215286a 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/application/AuthService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/application/AuthService.kt @@ -3,8 +3,8 @@ package edu.ntnu.idi.idatt.backend.iam.application import edu.ntnu.idi.idatt.backend.iam.domain.RefreshToken import edu.ntnu.idi.idatt.backend.iam.domain.User import edu.ntnu.idi.idatt.backend.iam.domain.UserRole -import edu.ntnu.idi.idatt.backend.iam.infrastructure.UserRepository import edu.ntnu.idi.idatt.backend.iam.infrastructure.RefreshTokenRepository +import edu.ntnu.idi.idatt.backend.iam.infrastructure.UserRepository import edu.ntnu.idi.idatt.backend.iam.security.AppSecurityProperties import edu.ntnu.idi.idatt.backend.iam.security.JwtService import edu.ntnu.idi.idatt.backend.iam.security.JwtSubject @@ -113,10 +113,12 @@ class AuthService( * @throws ResponseStatusException when the credentials are invalid or the user is not allowed */ private fun authenticateTeacher(command: TeacherLoginCommand): AuthSession { - val user = userRepository.findByEmailIgnoreCase(normalizeEmail(command.email)) - ?.takeIf { it.isActive() && it.role in setOf(UserRole.TEACHER, UserRole.ADMIN) } - ?.takeIf { passwordEncoder.matches(command.password, it.passwordHash) } - ?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid email or password") + val user = + userRepository + .findByEmailIgnoreCase(normalizeEmail(command.email)) + ?.takeIf { it.isActive() && it.role in setOf(UserRole.TEACHER, UserRole.ADMIN) } + ?.takeIf { passwordEncoder.matches(command.password, it.passwordHash) } + ?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid email or password") return issueSession(user, "TEACHER") } @@ -129,10 +131,12 @@ class AuthService( * @throws ResponseStatusException when the credentials are invalid or the user is not allowed */ private fun authenticatePupil(command: PupilLoginCommand): AuthSession { - val user = userRepository.findByUsernameIgnoreCase(normalizeUsername(command.username)) - ?.takeIf { it.isActive() && it.role == UserRole.PUPIL } - ?.takeIf { passwordEncoder.matches(command.password, it.passwordHash) } - ?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid username or password") + val user = + userRepository + .findByUsernameIgnoreCase(normalizeUsername(command.username)) + ?.takeIf { it.isActive() && it.role == UserRole.PUPIL } + ?.takeIf { passwordEncoder.matches(command.password, it.passwordHash) } + ?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid username or password") return issueSession(user, "PUPIL") } @@ -171,16 +175,18 @@ class AuthService( portalType: String, ): AuthSession { val now = Instant.now() - val subject = user.id?.toString() - ?: throw IllegalStateException("Authenticated user is missing an id") - val accessToken = jwtService.issueAccessToken( - JwtSubject( - subject = subject, - portalType = portalType, - email = user.email, - authorities = authoritiesFor(user, portalType), - ), - ) + val subject = + user.id?.toString() + ?: throw IllegalStateException("Authenticated user is missing an id") + val accessToken = + jwtService.issueAccessToken( + JwtSubject( + subject = subject, + portalType = portalType, + email = user.email, + authorities = authoritiesFor(user, portalType), + ), + ) val rawRefreshToken = generateRawRefreshToken() refreshTokenRepository.save( @@ -196,10 +202,11 @@ class AuthService( refreshToken = rawRefreshToken, expiresInSeconds = accessToken.expiresInSeconds, portalType = portalType, - user = AuthenticatedUser( - subject = subject, - email = user.email, - ), + user = + AuthenticatedUser( + subject = subject, + email = user.email, + ), ) } @@ -264,5 +271,4 @@ class AuthService( ?.takeIf { it.isActiveAt(now) && it.user.isActive() && it.user.role in allowedRoles } ?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid refresh token") } - } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/application/ClassroomUserResolver.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/application/ClassroomUserResolver.kt index 3de5e045..e43cc441 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/application/ClassroomUserResolver.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/application/ClassroomUserResolver.kt @@ -28,11 +28,16 @@ class ClassroomUserResolver( * @throws ResponseStatusException 404 if the classroom does not exist * @throws ResponseStatusException 403 if [user] is not the owner of the classroom */ - fun requireClassroomOwner(user: User, classroomId: Long): Classroom { - val classroom = classroomRepository.findById(classroomId) - .orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") - } + fun requireClassroomOwner( + user: User, + classroomId: Long, + ): Classroom { + val classroom = + classroomRepository + .findById(classroomId) + .orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") + } if (classroom.ownerTeacherId != user.id) { throw ResponseStatusException(HttpStatus.FORBIDDEN, "Only the classroom owner is allowed") } @@ -48,18 +53,24 @@ class ClassroomUserResolver( * @throws ResponseStatusException 404 if the classroom does not exist. * @throws ResponseStatusException 403 if [user] is not a teacher in the classroom. */ - fun requireClassroomTeacherAccess(user: User, classroomId: Long): Classroom { - val classroom = classroomRepository.findById(classroomId) - .orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") - } + fun requireClassroomTeacherAccess( + user: User, + classroomId: Long, + ): Classroom { + val classroom = + classroomRepository + .findById(classroomId) + .orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") + } val userId = requireNotNull(user.id) val isOwner = classroom.ownerTeacherId == userId - val isTeacherMember = classroom.teacherMemberships.any { - it.id.teacherUserId == userId - } + val isTeacherMember = + classroom.teacherMemberships.any { + it.id.teacherUserId == userId + } if (!isOwner && !isTeacherMember) { throw ResponseStatusException(HttpStatus.FORBIDDEN, "Teacher access required") @@ -77,21 +88,28 @@ class ClassroomUserResolver( * @throws ResponseStatusException 404 if the classroom does not exist * @throws ResponseStatusException 403 if [user] is not a member of the classroom */ - fun requireClassroomMember(user: User, classroomId: Long): Classroom { - val classroom = classroomRepository.findById(classroomId) - .orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") - } + fun requireClassroomMember( + user: User, + classroomId: Long, + ): Classroom { + val classroom = + classroomRepository + .findById(classroomId) + .orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") + } val userId = requireNotNull(user.id) val isOwner = classroom.ownerTeacherId == userId - val isTeacherMember = classroom.teacherMemberships.any { - it.id.teacherUserId == userId - } - val isPupilMember = classroom.pupilMemberships.any { - it.id.pupilUserId == userId - } + val isTeacherMember = + classroom.teacherMemberships.any { + it.id.teacherUserId == userId + } + val isPupilMember = + classroom.pupilMemberships.any { + it.id.pupilUserId == userId + } if (!isOwner && !isTeacherMember && !isPupilMember) { throw ResponseStatusException(HttpStatus.FORBIDDEN, "Access requires a member of the classroom") @@ -107,7 +125,10 @@ class ClassroomUserResolver( * @param pupilUserId pupil user id to verify. * @throws ResponseStatusException 404 if the pupil is not a member of the classroom. */ - fun requirePupilInClassroom(classroom: Classroom, pupilUserId: Long) { + fun requirePupilInClassroom( + classroom: Classroom, + pupilUserId: Long, + ) { if (!classroom.hasPupilMembership(pupilUserId)) { throw ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil not found in classroom") } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/application/CurrentUserResolver.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/application/CurrentUserResolver.kt index 46cd5bb6..1f32b567 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/application/CurrentUserResolver.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/application/CurrentUserResolver.kt @@ -29,9 +29,11 @@ class CurrentUserResolver( * @return authenticated persisted user */ fun requireUser(authenticatedSubject: String): User { - val userId = authenticatedSubject.toLongOrNull() - ?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid authenticated user subject") - return userRepository.findById(userId) + val userId = + authenticatedSubject.toLongOrNull() + ?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid authenticated user subject") + return userRepository + .findById(userId) .orElseThrow { ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authenticated user was not found") } @@ -72,28 +74,32 @@ class CurrentUserResolver( * @param currentUser authenticated principal from the access token * @return resolved classroom for the effective session context */ - fun getClassroomByUser(currentUser: CurrentUser): Classroom = - resolveDefaultClassroom(requireUser(currentUser.subject)) + fun getClassroomByUser(currentUser: CurrentUser): Classroom = resolveDefaultClassroom(requireUser(currentUser.subject)) private fun resolveDefaultClassroom(user: User): Classroom { val userId = requireNotNull(user.id) { "Authenticated user is missing an id" } return when (user.role) { UserRole.PUPIL -> { - val pupilProfile = pupilProfileRepository.findById(userId) - .orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil profile was not found") - } - val classroomId = pupilProfile.currentClassroom?.id - ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Pupil has no current classroom") - classroomRepository.findById(classroomId) + val pupilProfile = + pupilProfileRepository + .findById(userId) + .orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil profile was not found") + } + val classroomId = + pupilProfile.currentClassroom?.id + ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Pupil has no current classroom") + classroomRepository + .findById(classroomId) .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom was not found") } } - UserRole.TEACHER -> classroomRepository.findFirstByOwnerTeacherIdOrderByIdAsc(userId) - ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Teacher does not own any classroom") + UserRole.TEACHER -> + classroomRepository.findFirstByOwnerTeacherIdOrderByIdAsc(userId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Teacher does not own any classroom") UserRole.ADMIN -> throw ResponseStatusException( HttpStatus.FORBIDDEN, diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/application/PupilProvisioningService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/application/PupilProvisioningService.kt index e12ac74c..8de35012 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/application/PupilProvisioningService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/application/PupilProvisioningService.kt @@ -29,9 +29,10 @@ class PupilProvisioningService( private val passwordEncoder: PasswordEncoder, transactionManager: PlatformTransactionManager, ) { - private val createTransaction = TransactionTemplate(transactionManager).apply { - propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW - } + private val createTransaction = + TransactionTemplate(transactionManager).apply { + propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW + } /** * Returns an existing pupil user for the username or creates one on first login. @@ -68,8 +69,7 @@ class PupilProvisioningService( * @param rawPassword raw password from the login request * @return encoded password hash */ - private fun encodedPassword(rawPassword: String): String = - requireNotNull(passwordEncoder.encode(rawPassword)) + private fun encodedPassword(rawPassword: String): String = requireNotNull(passwordEncoder.encode(rawPassword)) /** * Creates a new pupil user in its own transaction so duplicate-key races can be recovered cleanly. @@ -84,14 +84,15 @@ class PupilProvisioningService( ): User = requireNotNull( createTransaction.execute { - val savedUser = userRepository.saveAndFlush( - User( - username = username, - passwordHash = encodedPassword(rawPassword), - role = UserRole.PUPIL, - status = UserStatus.ACTIVE, - ), - ) + val savedUser = + userRepository.saveAndFlush( + User( + username = username, + passwordHash = encodedPassword(rawPassword), + role = UserRole.PUPIL, + status = UserStatus.ACTIVE, + ), + ) pupilProfileRepository.save( PupilProfile( diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/application/TeacherRegistrationService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/application/TeacherRegistrationService.kt index d4513856..6ca73a78 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/application/TeacherRegistrationService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/application/TeacherRegistrationService.kt @@ -43,14 +43,15 @@ class TeacherRegistrationService( } try { - val savedUser = userRepository.save( - User( - email = normalizedEmail, - passwordHash = encodePassword(command.password), - role = UserRole.TEACHER, - status = UserStatus.ACTIVE, - ), - ) + val savedUser = + userRepository.save( + User( + email = normalizedEmail, + passwordHash = encodePassword(command.password), + role = UserRole.TEACHER, + status = UserStatus.ACTIVE, + ), + ) teacherProfileRepository.save( TeacherProfile( user = savedUser, @@ -75,8 +76,7 @@ class TeacherRegistrationService( * @param rawPassword raw password supplied during registration * @return encoded password hash */ - private fun encodePassword(rawPassword: String): String = - requireNotNull(passwordEncoder.encode(rawPassword)) + private fun encodePassword(rawPassword: String): String = requireNotNull(passwordEncoder.encode(rawPassword)) /** * Normalizes teacher profile text and rejects values that contain HTML-like markup characters. diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/domain/RefreshToken.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/domain/RefreshToken.kt index 42117fff..1e5714d7 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/domain/RefreshToken.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/domain/RefreshToken.kt @@ -1,4 +1,5 @@ package edu.ntnu.idi.idatt.backend.iam.domain + import jakarta.persistence.Column import jakarta.persistence.Entity import jakarta.persistence.FetchType diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/security/CurrentUser.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/security/CurrentUser.kt index 1710db74..d6401ebd 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/security/CurrentUser.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/security/CurrentUser.kt @@ -21,6 +21,5 @@ data class CurrentUser( * @param role The role to check for * @return true or false */ - fun hasRole(role: UserRole): Boolean = - "ROLE_${role.name}" in authorities + fun hasRole(role: UserRole): Boolean = "ROLE_${role.name}" in authorities } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/security/JwtService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/security/JwtService.kt index 04969b09..40b2b8b2 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/security/JwtService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/security/JwtService.kt @@ -1,10 +1,10 @@ package edu.ntnu.idi.idatt.backend.iam.security import org.springframework.security.oauth2.jose.jws.MacAlgorithm +import org.springframework.security.oauth2.jwt.JwsHeader import org.springframework.security.oauth2.jwt.JwtClaimsSet import org.springframework.security.oauth2.jwt.JwtEncoder import org.springframework.security.oauth2.jwt.JwtEncoderParameters -import org.springframework.security.oauth2.jwt.JwsHeader import org.springframework.stereotype.Service import java.time.Instant @@ -28,25 +28,29 @@ class JwtService( fun issueAccessToken(principal: JwtSubject): IssuedAccessToken { val issuedAt = Instant.now() val expiresAt = issuedAt.plus(securityProperties.jwt.accessTokenTtl) - val claimsBuilder = JwtClaimsSet.builder() - .issuer(securityProperties.jwt.issuer) - .audience(listOf(securityProperties.jwt.audience)) - .issuedAt(issuedAt) - .expiresAt(expiresAt) - .subject(principal.subject) - .claim("portal_type", principal.portalType) - .claim("authorities", principal.authorities.toList()) + val claimsBuilder = + JwtClaimsSet + .builder() + .issuer(securityProperties.jwt.issuer) + .audience(listOf(securityProperties.jwt.audience)) + .issuedAt(issuedAt) + .expiresAt(expiresAt) + .subject(principal.subject) + .claim("portal_type", principal.portalType) + .claim("authorities", principal.authorities.toList()) if (principal.email != null) { claimsBuilder.claim("email", principal.email) } val claims = claimsBuilder.build() - val token = jwtEncoder.encode( - JwtEncoderParameters.from( - JwsHeader.with(MacAlgorithm.HS256).build(), - claims, - ), - ).tokenValue + val token = + jwtEncoder + .encode( + JwtEncoderParameters.from( + JwsHeader.with(MacAlgorithm.HS256).build(), + claims, + ), + ).tokenValue return IssuedAccessToken( token = token, diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/security/SecurityConfig.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/security/SecurityConfig.kt index 235ee36f..1ac97f1b 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/security/SecurityConfig.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/security/SecurityConfig.kt @@ -1,9 +1,9 @@ package edu.ntnu.idi.idatt.backend.iam.security import com.nimbusds.jose.jwk.source.ImmutableSecret +import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration -import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.http.HttpMethod import org.springframework.security.authentication.AbstractAuthenticationToken import org.springframework.security.authentication.UsernamePasswordAuthenticationToken @@ -51,8 +51,7 @@ class SecurityConfig( * @return configured CSRF token repository */ @Bean - fun csrfTokenRepository(): CsrfTokenRepository = - CookieCsrfTokenRepository.withHttpOnlyFalse() + fun csrfTokenRepository(): CsrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse() /** * Builds the application security filter chain. @@ -67,39 +66,41 @@ class SecurityConfig( fun securityFilterChain( http: HttpSecurity, csrfTokenRepository: CsrfTokenRepository, - ): SecurityFilterChain = http - .cors(Customizer.withDefaults()) - .csrf { - it.csrfTokenRepository(csrfTokenRepository) - .csrfTokenRequestHandler(SpaCsrfTokenRequestHandler()) - } - .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } - .authorizeHttpRequests { - it.requestMatchers( - "/v3/api-docs/**", - "/swagger-ui/**", - "/swagger-ui.html", - "/assets/**", - "/error", - ).permitAll() - .requestMatchers(HttpMethod.GET, "/api/v1/auth/csrf").permitAll() - .requestMatchers( - HttpMethod.POST, - "/api/v1/auth/teacher/register", - "/api/v1/auth/teacher/login", - "/api/v1/auth/teacher/refresh", - "/api/v1/auth/teacher/logout", - "/api/v1/auth/pupil/login", - "/api/v1/auth/pupil/refresh", - "/api/v1/auth/pupil/logout", - ).permitAll() - .anyRequest().authenticated() - } - .oauth2ResourceServer { - it.jwt { jwt -> jwt.jwtAuthenticationConverter(::toAuthentication) } - } - .addFilterAfter(CsrfCookieFilter(), CsrfFilter::class.java) - .build() + ): SecurityFilterChain = + http + .cors(Customizer.withDefaults()) + .csrf { + it + .csrfTokenRepository(csrfTokenRepository) + .csrfTokenRequestHandler(SpaCsrfTokenRequestHandler()) + }.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .authorizeHttpRequests { + it + .requestMatchers( + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html", + "/assets/**", + "/error", + ).permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/auth/csrf") + .permitAll() + .requestMatchers( + HttpMethod.POST, + "/api/v1/auth/teacher/register", + "/api/v1/auth/teacher/login", + "/api/v1/auth/teacher/refresh", + "/api/v1/auth/teacher/logout", + "/api/v1/auth/pupil/login", + "/api/v1/auth/pupil/refresh", + "/api/v1/auth/pupil/logout", + ).permitAll() + .anyRequest() + .authenticated() + }.oauth2ResourceServer { + it.jwt { jwt -> jwt.jwtAuthenticationConverter(::toAuthentication) } + }.addFilterAfter(CsrfCookieFilter(), CsrfFilter::class.java) + .build() /** * Creates the global CORS configuration source for browser clients. @@ -154,19 +155,26 @@ class SecurityConfig( @Bean fun jwtEncoder(): JwtEncoder = NimbusJwtEncoder(ImmutableSecret(secretKey())) - private fun secretKey(): SecretKeySpec = - SecretKeySpec(securityProperties.jwt.secret.toByteArray(StandardCharsets.UTF_8), "HmacSHA256") + private fun secretKey(): SecretKeySpec = SecretKeySpec(securityProperties.jwt.secret.toByteArray(StandardCharsets.UTF_8), "HmacSHA256") private fun toAuthentication(jwt: Jwt): AbstractAuthenticationToken { val authorities = jwt.getClaimAsStringList("authorities")?.toSet().orEmpty() val portalType = jwt.getClaimAsString("portal_type") ?: "UNKNOWN" - val principal = CurrentUser( - subject = jwt.subject, - portalType = portalType, - email = jwt.getClaimAsString("email"), - authorities = authorities, + val principal = + CurrentUser( + subject = jwt.subject, + portalType = portalType, + email = jwt.getClaimAsString("email"), + authorities = authorities, + ) + return UsernamePasswordAuthenticationToken( + principal, + jwt, + authorities.map { + org.springframework.security.core.authority + .SimpleGrantedAuthority(it) + }, ) - return UsernamePasswordAuthenticationToken(principal, jwt, authorities.map { org.springframework.security.core.authority.SimpleGrantedAuthority(it) }) } private fun audienceValidator(): OAuth2TokenValidator = diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/leaderboard/api/LeaderboardApiMappings.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/leaderboard/api/LeaderboardApiMappings.kt index 097d3d82..5a9f03cb 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/leaderboard/api/LeaderboardApiMappings.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/leaderboard/api/LeaderboardApiMappings.kt @@ -9,12 +9,13 @@ import edu.ntnu.idi.idatt.backend.leaderboard.application.LeaderboardEntry */ fun List.toLeaderboardResponse(): LeaderboardResponse = LeaderboardResponse( - entries = map { entry -> - LeaderboardEntryResponse( - rank = entry.rank, - classroomId = entry.classroomId, - classroomTitle = entry.classroomTitle, - averageTotalScore = entry.averageTotalScore, - ) - }, + entries = + map { entry -> + LeaderboardEntryResponse( + rank = entry.rank, + classroomId = entry.classroomId, + classroomTitle = entry.classroomTitle, + averageTotalScore = entry.averageTotalScore, + ) + }, ) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/leaderboard/api/LeaderboardController.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/leaderboard/api/LeaderboardController.kt index 3fbc68f8..bd25b206 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/leaderboard/api/LeaderboardController.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/leaderboard/api/LeaderboardController.kt @@ -26,6 +26,5 @@ class LeaderboardController( @GetMapping fun getLeaderboard( @AuthenticationPrincipal currentUser: CurrentUser, - ): LeaderboardResponse = - leaderboardService.getLeaderboardList(currentUser).toLeaderboardResponse() + ): LeaderboardResponse = leaderboardService.getLeaderboardList(currentUser).toLeaderboardResponse() } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/leaderboard/application/LeaderboardService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/leaderboard/application/LeaderboardService.kt index da28b054..e990555f 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/leaderboard/application/LeaderboardService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/leaderboard/application/LeaderboardService.kt @@ -29,7 +29,8 @@ class LeaderboardService( fun getLeaderboardList(currentUser: CurrentUser): List { currentUserResolver.requireUser(currentUser.subject) - return classroomRepository.findAllWithAverageTotalScore() + return classroomRepository + .findAllWithAverageTotalScore() .mapIndexed { index, classroom -> LeaderboardEntry( rank = index + 1, diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/medals/api/MedalsController.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/medals/api/MedalsController.kt index 95279608..ab3f9044 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/medals/api/MedalsController.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/medals/api/MedalsController.kt @@ -1,13 +1,15 @@ package edu.ntnu.idi.idatt.backend.medals.api -import edu.ntnu.idi.idatt.backend.medals.application.MedalService -import edu.ntnu.idi.idatt.backend.medals.domain.Medal -import edu.ntnu.idi.idatt.backend.medals.domain.PupilMedal import edu.ntnu.idi.idatt.backend.iam.application.CurrentUserResolver import edu.ntnu.idi.idatt.backend.iam.security.CurrentUser +import edu.ntnu.idi.idatt.backend.medals.application.MedalService +import edu.ntnu.idi.idatt.backend.medals.domain.Medal import org.springframework.http.HttpStatus import org.springframework.security.core.annotation.AuthenticationPrincipal -import org.springframework.web.bind.annotation.* +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController import org.springframework.web.server.ResponseStatusException /** @@ -20,7 +22,7 @@ import org.springframework.web.server.ResponseStatusException @RequestMapping("/api/v1/pupil/medals") class MedalsController( private val medalService: MedalService, - private val currentUserResolver: CurrentUserResolver + private val currentUserResolver: CurrentUserResolver, ) { /** * Retrieves an overview of all medals and whether the current pupil has earned them. @@ -29,22 +31,25 @@ class MedalsController( * @return a response containing all medals and their earned status */ @GetMapping - fun getMedals(@AuthenticationPrincipal currentUser: CurrentUser): MedalOverviewResponse { + fun getMedals( + @AuthenticationPrincipal currentUser: CurrentUser, + ): MedalOverviewResponse { val pupil = currentUserResolver.requirePupil(currentUser.subject) val classroom = currentUserResolver.getClassroomByUser(currentUser) - + val allMedals = medalService.getAllMedals() val earnedMedals = medalService.getPupilMedals(pupil, classroom) - + val earnedMedalIds = earnedMedals.map { it.medal.id }.toSet() - + return MedalOverviewResponse( - medals = allMedals.map { medal -> - MedalEntry( - medal = medal, - earned = earnedMedalIds.contains(medal.id) - ) - } + medals = + allMedals.map { medal -> + MedalEntry( + medal = medal, + earned = earnedMedalIds.contains(medal.id), + ) + }, ) } @@ -56,10 +61,11 @@ class MedalsController( * @throws ResponseStatusException 404 if the medal is not found */ @GetMapping("/{id}") - fun getMedal(@PathVariable id: Long): Medal { - return medalService.getMedalById(id) + fun getMedal( + @PathVariable id: Long, + ): Medal = + medalService.getMedalById(id) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Medal not found") - } } /** @@ -68,7 +74,7 @@ class MedalsController( * @property medals list of medal entries */ data class MedalOverviewResponse( - val medals: List + val medals: List, ) /** @@ -79,5 +85,5 @@ data class MedalOverviewResponse( */ data class MedalEntry( val medal: Medal, - val earned: Boolean + val earned: Boolean, ) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/medals/application/MedalService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/medals/application/MedalService.kt index 7dccdfd7..e2b290ac 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/medals/application/MedalService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/medals/application/MedalService.kt @@ -1,12 +1,12 @@ package edu.ntnu.idi.idatt.backend.medals.application +import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom +import edu.ntnu.idi.idatt.backend.iam.domain.User +import edu.ntnu.idi.idatt.backend.iam.security.CurrentUser import edu.ntnu.idi.idatt.backend.medals.domain.Medal import edu.ntnu.idi.idatt.backend.medals.domain.PupilMedal import edu.ntnu.idi.idatt.backend.medals.infrastructure.MedalRepository import edu.ntnu.idi.idatt.backend.medals.infrastructure.PupilMedalRepository -import edu.ntnu.idi.idatt.backend.iam.domain.User -import edu.ntnu.idi.idatt.backend.iam.security.CurrentUser -import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom import edu.ntnu.idi.idatt.backend.xp.application.XpService import edu.ntnu.idi.idatt.backend.xp.domain.XpEventCode import org.springframework.dao.DataIntegrityViolationException @@ -30,9 +30,10 @@ class MedalService( private val xpService: XpService, transactionManager: PlatformTransactionManager, ) { - private val awardTransaction = TransactionTemplate(transactionManager).apply { - propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW - } + private val awardTransaction = + TransactionTemplate(transactionManager).apply { + propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW + } /** * Retrieves a medal by its ID. @@ -59,7 +60,10 @@ class MedalService( * @return a list of earned medals */ @Transactional(readOnly = true) - fun getPupilMedals(pupil: User, classroom: Classroom): List { + fun getPupilMedals( + pupil: User, + classroom: Classroom, + ): List { val pupilId = requireNotNull(pupil.id) { "Pupil is missing an id" } val classroomId = requireNotNull(classroom.id) { "Classroom is missing an id" } return pupilMedalRepository.findAllByPupilIdAndClassroomId(pupilId, classroomId).collect(Collectors.toList()) @@ -72,7 +76,11 @@ class MedalService( * @param classroom the classroom context * @param stopId the ID of the stop that triggers the medal */ - fun awardStopMedal(pupil: User, classroom: Classroom, stopId: Long) { + fun awardStopMedal( + pupil: User, + classroom: Classroom, + stopId: Long, + ) { val medal = medalRepository.findByStopId(stopId) ?: return val pupilId = requireNotNull(pupil.id) { "Pupil is missing an id" } val classroomId = requireNotNull(classroom.id) { "Classroom is missing an id" } @@ -97,11 +105,12 @@ class MedalService( ) } } catch (exception: DataIntegrityViolationException) { - val alreadyAwarded = pupilMedalRepository.existsByPupilIdAndClassroomIdAndMedalId( - pupilId, - classroomId, - medal.id, - ) + val alreadyAwarded = + pupilMedalRepository.existsByPupilIdAndClassroomIdAndMedalId( + pupilId, + classroomId, + medal.id, + ) if (!alreadyAwarded) { throw exception } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/medals/domain/Medal.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/medals/domain/Medal.kt index 4699d7e6..2dfc2547 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/medals/domain/Medal.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/medals/domain/Medal.kt @@ -1,7 +1,17 @@ package edu.ntnu.idi.idatt.backend.medals.domain import edu.ntnu.idi.idatt.backend.game.mapstops.domain.MapStop -import jakarta.persistence.* +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table /** * Represents a medal that can be earned by a pupil. @@ -20,24 +30,18 @@ class Medal( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long = 0, - @Column(nullable = false, unique = true) val code: String, - @Column(nullable = false) val title: String, - @Column val description: String?, - @Enumerated(EnumType.STRING) @Column(nullable = false) val medalType: MedalType, - @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "stop_id") val stop: MapStop?, - @Column - val iconUrl: String? + val iconUrl: String?, ) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/medals/domain/MedalType.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/medals/domain/MedalType.kt index 645c9dde..b151994d 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/medals/domain/MedalType.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/medals/domain/MedalType.kt @@ -2,5 +2,5 @@ package edu.ntnu.idi.idatt.backend.medals.domain enum class MedalType { STOP_COMPLETION, - EXAMPLE_APPROVAL + EXAMPLE_APPROVAL, } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/medals/domain/PupilMedal.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/medals/domain/PupilMedal.kt index 90d1bb81..de8d2a31 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/medals/domain/PupilMedal.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/medals/domain/PupilMedal.kt @@ -1,8 +1,16 @@ package edu.ntnu.idi.idatt.backend.medals.domain -import edu.ntnu.idi.idatt.backend.iam.domain.User import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom -import jakarta.persistence.* +import edu.ntnu.idi.idatt.backend.iam.domain.User +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table import java.time.Instant /** @@ -20,19 +28,15 @@ class PupilMedal( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long = 0, - @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "pupil_user_id", nullable = false) val pupil: User, - @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "classroom_id", nullable = false) val classroom: Classroom, - @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "medal_id", nullable = false) val medal: Medal, - @Column(nullable = false) - val awardedAt: Instant = Instant.now() + val awardedAt: Instant = Instant.now(), ) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/medals/infrastructure/MedalRepository.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/medals/infrastructure/MedalRepository.kt index 6726ecc8..0c91475c 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/medals/infrastructure/MedalRepository.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/medals/infrastructure/MedalRepository.kt @@ -10,5 +10,6 @@ import org.springframework.stereotype.Repository @Repository interface MedalRepository : JpaRepository { fun findByCode(code: String): Medal? + fun findByStopId(stopId: Long): Medal? } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/medals/infrastructure/PupilMedalRepository.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/medals/infrastructure/PupilMedalRepository.kt index 49914cd4..6fc1770e 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/medals/infrastructure/PupilMedalRepository.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/medals/infrastructure/PupilMedalRepository.kt @@ -10,6 +10,14 @@ import java.util.stream.Stream */ @Repository interface PupilMedalRepository : JpaRepository { - fun findAllByPupilIdAndClassroomId(pupilId: Long, classroomId: Long): Stream - fun existsByPupilIdAndClassroomIdAndMedalId(pupilId: Long, classroomId: Long, medalId: Long): Boolean + fun findAllByPupilIdAndClassroomId( + pupilId: Long, + classroomId: Long, + ): Stream + + fun existsByPupilIdAndClassroomIdAndMedalId( + pupilId: Long, + classroomId: Long, + medalId: Long, + ): Boolean } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notebook/api/NotebookEntryController.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notebook/api/NotebookEntryController.kt index e92c99ed..04b89c20 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notebook/api/NotebookEntryController.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notebook/api/NotebookEntryController.kt @@ -34,8 +34,7 @@ class NotebookEntryController( @GetMapping fun getNotebookEntries( @AuthenticationPrincipal currentUser: CurrentUser, - ): List = - notebookService.getAllNotebookEntries(currentUser).map { it.toResponse() } + ): List = notebookService.getAllNotebookEntries(currentUser).map { it.toResponse() } /** * Creates a new notebook entry for the authenticated pupil. @@ -49,8 +48,7 @@ class NotebookEntryController( fun createNotebookEntry( @AuthenticationPrincipal currentUser: CurrentUser, @Valid @RequestBody request: CreateNotebookEntryRequest, - ): NotebookEntryResponse = - notebookService.createNotebookEntry(request.toCommand(currentUser)).toResponse() + ): NotebookEntryResponse = notebookService.createNotebookEntry(request.toCommand(currentUser)).toResponse() /** * Updates one notebook entry owned by the authenticated pupil. @@ -65,8 +63,7 @@ class NotebookEntryController( @AuthenticationPrincipal currentUser: CurrentUser, @PathVariable notebookEntryId: Long, @Valid @RequestBody request: UpdateNotebookEntryRequest, - ): NotebookEntryResponse = - notebookService.updateNotebookEntry(request.toCommand(currentUser, notebookEntryId)).toResponse() + ): NotebookEntryResponse = notebookService.updateNotebookEntry(request.toCommand(currentUser, notebookEntryId)).toResponse() /** * Deletes one notebook entry owned by the authenticated pupil. diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notebook/api/NotebookEntryResponses.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notebook/api/NotebookEntryResponse.kt similarity index 100% rename from src/main/kotlin/edu/ntnu/idi/idatt/backend/notebook/api/NotebookEntryResponses.kt rename to src/main/kotlin/edu/ntnu/idi/idatt/backend/notebook/api/NotebookEntryResponse.kt diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notebook/application/NotebookService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notebook/application/NotebookService.kt index f77c9894..3f7cc8e9 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notebook/application/NotebookService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notebook/application/NotebookService.kt @@ -97,24 +97,30 @@ class NotebookService( } if (classroom.id != command.classroomId) { - throw ResponseStatusException(HttpStatus.FORBIDDEN, "Pupil cannot create notebook entries outside the active classroom") + throw ResponseStatusException( + HttpStatus.FORBIDDEN, + "Pupil cannot create notebook entries outside the active classroom", + ) } - val stop = mapStopsRepository.findById(command.stopId) - .orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Map stop not found") - } + val stop = + mapStopsRepository + .findById(command.stopId) + .orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Map stop not found") + } xpService.awardXpEvent(command.currentUser, XpEventCode.NOTEBOOK_ENTRY_CREATED) - val notebookEntry = NotebookEntry( - pupil = if (command.sourceType == NotebookSourceType.PUPIL) pupil else null, - classroom = classroom, - stop = stop, - sourceType = command.sourceType, - title = command.title, - content = command.content, - ) + val notebookEntry = + NotebookEntry( + pupil = if (command.sourceType == NotebookSourceType.PUPIL) pupil else null, + classroom = classroom, + stop = stop, + sourceType = command.sourceType, + title = command.title, + content = command.content, + ) return notebookEntryRepository.save(notebookEntry) } @@ -167,9 +173,10 @@ class NotebookService( val classroom = classroomUserResolver.requireClassroomTeacherAccess(teacher, classroomId) classroomUserResolver.requirePupilInClassroom(classroom, pupilUserId) - val notebookEntry = notebookEntryRepository.findById(notebookEntryId).orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Notebook entry not found") - } + val notebookEntry = + notebookEntryRepository.findById(notebookEntryId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Notebook entry not found") + } if (notebookEntry.sourceType == NotebookSourceType.GAME) { throw ResponseStatusException(HttpStatus.FORBIDDEN, "Game-generated notebook entries cannot be deleted") @@ -191,12 +198,16 @@ class NotebookService( * @param entryId notebook entry id to validate * @return notebook entry after ownership has been verified */ - private fun verifyOwnership(pupilId: Long?, entryId: Long): NotebookEntry { + private fun verifyOwnership( + pupilId: Long?, + entryId: Long, + ): NotebookEntry { val resolvedPupilId = requireNotNull(pupilId) { "Authenticated pupil is missing an id" } - val notebookEntry = notebookEntryRepository.findById(entryId).orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Notebook entry not found") - } + val notebookEntry = + notebookEntryRepository.findById(entryId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Notebook entry not found") + } if (notebookEntry.sourceType == NotebookSourceType.GAME) { throw ResponseStatusException(HttpStatus.FORBIDDEN, "Game-generated notebook entries cannot be deleted") diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notebook/domain/NotebookEntry.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notebook/domain/NotebookEntry.kt index 188b394a..e43410f0 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notebook/domain/NotebookEntry.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notebook/domain/NotebookEntry.kt @@ -66,13 +66,15 @@ class NotebookEntry( @PreUpdate fun validatePupilOwnership() { when (sourceType) { - NotebookSourceType.GAME -> require(pupil == null) { - "Notebook entries with source type GAME must not reference a pupil" - } + NotebookSourceType.GAME -> + require(pupil == null) { + "Notebook entries with source type GAME must not reference a pupil" + } - NotebookSourceType.PUPIL -> requireNotNull(pupil) { - "Notebook entries with source type PUPIL must reference a pupil" - } + NotebookSourceType.PUPIL -> + requireNotNull(pupil) { + "Notebook entries with source type PUPIL must reference a pupil" + } } } } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notebook/infrastructure/NotebookEntryRepository.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notebook/infrastructure/NotebookEntryRepository.kt index c46deb7a..5993f2f1 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notebook/infrastructure/NotebookEntryRepository.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notebook/infrastructure/NotebookEntryRepository.kt @@ -10,7 +10,6 @@ import org.springframework.stereotype.Repository */ @Repository interface NotebookEntryRepository : JpaRepository { - /** * Finds all notebook entries visible in a pupil notebook context: * - pupil-authored entries owned by the pupil in the classroom @@ -29,7 +28,7 @@ interface NotebookEntryRepository : JpaRepository { or (notebookEntry.pupil is null and notebookEntry.classroom.id = :classroomId)) and notebookEntry.classroom.id = :classroomId and notebookEntry.stop.displayOrder <= :completedStops + 1 - """ + """, ) fun findAllVisibleInNotebook( pupilId: Long, diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/api/NotificationApiMappings.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/api/NotificationApiMappings.kt index 83d3e5e9..80614e96 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/api/NotificationApiMappings.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/api/NotificationApiMappings.kt @@ -40,17 +40,18 @@ fun NotificationPage.toResponse( presentationById: Map, ): NotificationPageResponse = NotificationPageResponse( - items = items.map { notification -> - val notificationId = requireNotNull(notification.id) { "Notification is missing an id" } - notification.toResponse( - presentationById[notificationId] - ?: NotificationPresentationData( - classroomTitle = "Classroom ${notification.classroomId}", - pupilDisplayName = null, - stopTitle = null, - ), - ) - }, + items = + items.map { notification -> + val notificationId = requireNotNull(notification.id) { "Notification is missing an id" } + notification.toResponse( + presentationById[notificationId] + ?: NotificationPresentationData( + classroomTitle = "Classroom ${notification.classroomId}", + pupilDisplayName = null, + stopTitle = null, + ), + ) + }, page = page, size = size, totalElements = totalElements, diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/api/NotificationController.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/api/NotificationController.kt index ece2912d..0acceae1 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/api/NotificationController.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/api/NotificationController.kt @@ -5,7 +5,6 @@ import edu.ntnu.idi.idatt.backend.notification.application.NotificationInboxFilt import edu.ntnu.idi.idatt.backend.notification.application.NotificationInboxService import edu.ntnu.idi.idatt.backend.notification.application.NotificationMuteService import edu.ntnu.idi.idatt.backend.notification.domain.NotificationType -import java.time.Instant import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping @@ -15,6 +14,7 @@ import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import java.time.Instant /** * Exposes the teacher notification inbox and mute preference endpoints. @@ -58,20 +58,21 @@ class NotificationController( @RequestParam(defaultValue = "0") page: Int, @RequestParam(defaultValue = "20") size: Int, ): NotificationPageResponse { - val notificationPage = notificationInboxService.getInbox( - currentUser.subject, - NotificationInboxFilters( - classroomIds = (classroomIds.orEmpty() + listOfNotNull(classroomId)).distinct(), - types = (types.orEmpty() + listOfNotNull(type)).distinct(), - pupilUserIds = pupilUserIds.orEmpty().distinct(), - query = q, - from = from, - to = to, - unreadOnly = unreadOnly, - ), - page, - size, - ) + val notificationPage = + notificationInboxService.getInbox( + currentUser.subject, + NotificationInboxFilters( + classroomIds = (classroomIds.orEmpty() + listOfNotNull(classroomId)).distinct(), + types = (types.orEmpty() + listOfNotNull(type)).distinct(), + pupilUserIds = pupilUserIds.orEmpty().distinct(), + query = q, + from = from, + to = to, + unreadOnly = unreadOnly, + ), + page, + size, + ) return notificationPage.toResponse(notificationPresentationResolver.resolve(notificationPage.items)) } @@ -85,8 +86,7 @@ class NotificationController( @GetMapping("/summary") fun getNotificationSummary( @AuthenticationPrincipal currentUser: CurrentUser, - ): NotificationSummaryResponse = - notificationInboxService.getSummary(currentUser.subject).toResponse() + ): NotificationSummaryResponse = notificationInboxService.getSummary(currentUser.subject).toResponse() /** * Marks one notification as read for the authenticated teacher. @@ -100,7 +100,8 @@ class NotificationController( @AuthenticationPrincipal currentUser: CurrentUser, @PathVariable notificationId: Long, ): NotificationResponse = - notificationInboxService.markRead(currentUser.subject, notificationId) + notificationInboxService + .markRead(currentUser.subject, notificationId) .let { notification -> notification.toResponse(notificationPresentationResolver.resolve(notification)) } /** @@ -115,7 +116,8 @@ class NotificationController( @AuthenticationPrincipal currentUser: CurrentUser, @PathVariable notificationId: Long, ): NotificationResponse = - notificationInboxService.dismiss(currentUser.subject, notificationId) + notificationInboxService + .dismiss(currentUser.subject, notificationId) .let { notification -> notification.toResponse(notificationPresentationResolver.resolve(notification)) } /** @@ -145,8 +147,7 @@ class NotificationController( @AuthenticationPrincipal currentUser: CurrentUser, @PathVariable classroomId: Long, @PathVariable type: NotificationType, - ): NotificationMuteResponse = - notificationMuteService.mute(currentUser.subject, classroomId, type).toResponse() + ): NotificationMuteResponse = notificationMuteService.mute(currentUser.subject, classroomId, type).toResponse() /** * Removes one mute preference for the authenticated teacher. diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/api/NotificationPresentationResolver.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/api/NotificationPresentationResolver.kt index 1562c2f0..e61ff96d 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/api/NotificationPresentationResolver.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/api/NotificationPresentationResolver.kt @@ -37,39 +37,48 @@ class NotificationPresentationResolver( return emptyMap() } - val classroomTitles = classroomRepository.findAllById(notifications.map(TeacherNotification::classroomId).distinct()) - .associate { classroom -> classroom.id to classroom.title } + val classroomTitles = + classroomRepository + .findAllById(notifications.map(TeacherNotification::classroomId).distinct()) + .associate { classroom -> classroom.id to classroom.title } val pupilUserIds = notifications.mapNotNull(TeacherNotification::pupilUserId).distinct() - val pupilDisplayNames = if (pupilUserIds.isEmpty()) { - emptyMap() - } else { - pupilProfileRepository.findAllByUserIdIn(pupilUserIds) - .associate { profile -> requireNotNull(profile.userId) to profile.displayName } - } + val pupilDisplayNames = + if (pupilUserIds.isEmpty()) { + emptyMap() + } else { + pupilProfileRepository + .findAllByUserIdIn(pupilUserIds) + .associate { profile -> requireNotNull(profile.userId) to profile.displayName } + } val unresolvedPupilIds = pupilUserIds.filterNot(pupilDisplayNames::containsKey) - val pupilUsernames = if (unresolvedPupilIds.isEmpty()) { - emptyMap() - } else { - userRepository.findAllById(unresolvedPupilIds) - .associate { user -> requireNotNull(user.id) to user.username } - } + val pupilUsernames = + if (unresolvedPupilIds.isEmpty()) { + emptyMap() + } else { + userRepository + .findAllById(unresolvedPupilIds) + .associate { user -> requireNotNull(user.id) to user.username } + } return notifications.associate { notification -> val notificationId = requireNotNull(notification.id) { "Notification is missing an id" } val renderArgs = parseRenderArgs(notification.renderArgsJson) - notificationId to NotificationPresentationData( - classroomTitle = renderArgs["classroomTitle"] - ?: classroomTitles[notification.classroomId] - ?: fallback(notification).classroomTitle, - pupilDisplayName = renderArgs["pupilDisplayName"] - ?: notification.pupilUserId?.let { pupilUserId -> - pupilDisplayNames[pupilUserId] ?: pupilUsernames[pupilUserId] - }, - stopTitle = renderArgs["stopTitle"], - ) + notificationId to + NotificationPresentationData( + classroomTitle = + renderArgs["classroomTitle"] + ?: classroomTitles[notification.classroomId] + ?: fallback(notification).classroomTitle, + pupilDisplayName = + renderArgs["pupilDisplayName"] + ?: notification.pupilUserId?.let { pupilUserId -> + pupilDisplayNames[pupilUserId] ?: pupilUsernames[pupilUserId] + }, + stopTitle = renderArgs["stopTitle"], + ) } } @@ -97,8 +106,16 @@ class NotificationPresentationResolver( } } - private fun MutableMap.appendText(root: JsonNode, fieldName: String) { - val value = root.get(fieldName)?.asText(null)?.trim()?.takeIf(String::isNotBlank) ?: return + private fun MutableMap.appendText( + root: JsonNode, + fieldName: String, + ) { + val value = + root + .get(fieldName) + ?.asText(null) + ?.trim() + ?.takeIf(String::isNotBlank) ?: return put(fieldName, value) } } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/ClassroomNotificationRecipientResolver.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/ClassroomNotificationRecipientResolver.kt index fe31ec1c..7f1964f2 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/ClassroomNotificationRecipientResolver.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/ClassroomNotificationRecipientResolver.kt @@ -20,10 +20,12 @@ class ClassroomNotificationRecipientResolver( * @throws ResponseStatusException if the classroom does not exist. */ fun resolveTeacherRecipientIds(classroomId: Long): Set { - val classroom = classroomRepository.findById(classroomId) - .orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") - } + val classroom = + classroomRepository + .findById(classroomId) + .orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") + } return buildSet { add(classroom.ownerTeacherId) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/NotificationDeliveryService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/NotificationDeliveryService.kt index 9fff2c46..a3f28787 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/NotificationDeliveryService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/NotificationDeliveryService.kt @@ -31,26 +31,28 @@ class NotificationDeliveryService( val notifications = mutableListOf() recipients.forEach { teacherUserId -> - val isMuted = teacherNotificationMuteRepository.existsByTeacherUserIdAndClassroomIdAndType( - teacherUserId = teacherUserId, - classroomId = draft.classroomId, - type = draft.type, - ) + val isMuted = + teacherNotificationMuteRepository.existsByTeacherUserIdAndClassroomIdAndType( + teacherUserId = teacherUserId, + classroomId = draft.classroomId, + type = draft.type, + ) if (isMuted) { return@forEach } - val notification = TeacherNotification( - teacherUserId = teacherUserId, - classroomId = draft.classroomId, - pupilUserId = draft.pupilUserId, - type = draft.type, - threadKey = draft.threadKey, - activeThreadKey = draft.threadKey, - renderCode = draft.renderCode.code, - renderArgsJson = draft.renderArgsJson, - payloadJson = draft.payloadJson, - ) + val notification = + TeacherNotification( + teacherUserId = teacherUserId, + classroomId = draft.classroomId, + pupilUserId = draft.pupilUserId, + type = draft.type, + threadKey = draft.threadKey, + activeThreadKey = draft.threadKey, + renderCode = draft.renderCode.code, + renderArgsJson = draft.renderArgsJson, + payloadJson = draft.payloadJson, + ) try { notifications += teacherNotificationRepository.saveAndFlush(notification) @@ -77,12 +79,16 @@ class NotificationDeliveryService( * @return number of notifications resolved */ @Transactional(propagation = Propagation.REQUIRES_NEW) - fun resolveThread(classroomId: Long, threadKey: String): Int { + fun resolveThread( + classroomId: Long, + threadKey: String, + ): Int { val now = Instant.now() - val activeNotifications = teacherNotificationRepository.findAllByClassroomIdAndThreadKeyAndResolvedAtIsNull( - classroomId = classroomId, - threadKey = threadKey, - ) + val activeNotifications = + teacherNotificationRepository.findAllByClassroomIdAndThreadKeyAndResolvedAtIsNull( + classroomId = classroomId, + threadKey = threadKey, + ) if (activeNotifications.isEmpty()) { return 0 diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/NotificationEventContextResolver.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/NotificationEventContextResolver.kt index be814be2..ad6af95b 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/NotificationEventContextResolver.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/NotificationEventContextResolver.kt @@ -48,19 +48,25 @@ class NotificationEventContextResolver( pupilUserId: Long, stopId: Long, ): StopNotificationContext { - val classroom = classroomRepository.findById(classroomId) - .orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") - } + val classroom = + classroomRepository + .findById(classroomId) + .orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") + } val pupilProfile = pupilProfileRepository.findById(pupilUserId).orElse(null) - val pupil = userRepository.findById(pupilUserId) - .orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil not found") - } - val stop = mapStopsRepository.findById(stopId) - .orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Map stop not found") - } + val pupil = + userRepository + .findById(pupilUserId) + .orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil not found") + } + val stop = + mapStopsRepository + .findById(stopId) + .orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Map stop not found") + } return StopNotificationContext( classroomTitle = classroom.title, @@ -75,6 +81,5 @@ class NotificationEventContextResolver( * @param entries ordered key-value pairs to serialize. * @return JSON object string containing the provided entries. */ - fun json(vararg entries: Pair): String = - objectMapper.writeValueAsString(linkedMapOf(*entries)) + fun json(vararg entries: Pair): String = objectMapper.writeValueAsString(linkedMapOf(*entries)) } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/NotificationInboxService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/NotificationInboxService.kt index e84b4db2..1cecf82f 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/NotificationInboxService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/NotificationInboxService.kt @@ -44,22 +44,23 @@ class NotificationInboxService( val pupilUserIds = filters.pupilUserIds.distinct() val normalizedQuery = filters.query?.trim()?.takeIf(String::isNotBlank) val searchedTypes = normalizedQuery?.let(::findTypesMatchingQuery).orEmpty() - val inboxPage = teacherNotificationRepository.findInboxPage( - teacherUserId = teacherId, - classroomIds = classroomIds.ifEmpty { listOf(-1L) }, - classroomIdsEnabled = classroomIds.isNotEmpty(), - types = types.ifEmpty { listOf(NotificationType.MAP_STOP_COMPLETED) }, - typesEnabled = types.isNotEmpty(), - pupilUserIds = pupilUserIds.ifEmpty { listOf(-1L) }, - pupilUserIdsEnabled = pupilUserIds.isNotEmpty(), - q = normalizedQuery, - searchedTypes = searchedTypes.ifEmpty { listOf(NotificationType.MAP_STOP_COMPLETED) }, - searchedTypesEnabled = searchedTypes.isNotEmpty(), - from = filters.from, - to = filters.to, - unreadOnly = filters.unreadOnly, - pageable = PageRequest.of(requestedPage, requestedSize), - ) + val inboxPage = + teacherNotificationRepository.findInboxPage( + teacherUserId = teacherId, + classroomIds = classroomIds.ifEmpty { listOf(-1L) }, + classroomIdsEnabled = classroomIds.isNotEmpty(), + types = types.ifEmpty { listOf(NotificationType.MAP_STOP_COMPLETED) }, + typesEnabled = types.isNotEmpty(), + pupilUserIds = pupilUserIds.ifEmpty { listOf(-1L) }, + pupilUserIdsEnabled = pupilUserIds.isNotEmpty(), + q = normalizedQuery, + searchedTypes = searchedTypes.ifEmpty { listOf(NotificationType.MAP_STOP_COMPLETED) }, + searchedTypesEnabled = searchedTypes.isNotEmpty(), + from = filters.from, + to = filters.to, + unreadOnly = filters.unreadOnly, + pageable = PageRequest.of(requestedPage, requestedSize), + ) return NotificationPage( items = inboxPage.content, @@ -78,7 +79,10 @@ class NotificationInboxService( return NotificationType.entries.filter { type -> type.name.lowercase().contains(normalizedQuery) || - type.name.lowercase().replace('_', ' ').contains(normalizedQuery) + type.name + .lowercase() + .replace('_', ' ') + .contains(normalizedQuery) } } @@ -93,7 +97,10 @@ class NotificationInboxService( val teacher = currentUserResolver.requireTeacherOrAdmin(authenticatedSubject) val teacherId = requireNotNull(teacher.id) { "Authenticated teacher is missing an id" } return NotificationSummary( - unreadCount = teacherNotificationRepository.countByTeacherUserIdAndReadAtIsNullAndDismissedAtIsNull(teacherId), + unreadCount = + teacherNotificationRepository.countByTeacherUserIdAndReadAtIsNullAndDismissedAtIsNull( + teacherId, + ), ) } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/NotificationMuteService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/NotificationMuteService.kt index 99216eff..0b4b193a 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/NotificationMuteService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/NotificationMuteService.kt @@ -48,11 +48,12 @@ class NotificationMuteService( classroomUserResolver.requireClassroomTeacherAccess(teacher, classroomId) val teacherId = requireNotNull(teacher.id) { "Authenticated teacher is missing an id" } - val existingMute = teacherNotificationMuteRepository.findByTeacherUserIdAndClassroomIdAndType( - teacherUserId = teacherId, - classroomId = classroomId, - type = type, - ) + val existingMute = + teacherNotificationMuteRepository.findByTeacherUserIdAndClassroomIdAndType( + teacherUserId = teacherId, + classroomId = classroomId, + type = type, + ) if (existingMute != null) { return existingMute } @@ -83,10 +84,11 @@ class NotificationMuteService( classroomUserResolver.requireClassroomTeacherAccess(teacher, classroomId) val teacherId = requireNotNull(teacher.id) { "Authenticated teacher is missing an id" } - teacherNotificationMuteRepository.findByTeacherUserIdAndClassroomIdAndType( - teacherUserId = teacherId, - classroomId = classroomId, - type = type, - )?.let(teacherNotificationMuteRepository::delete) + teacherNotificationMuteRepository + .findByTeacherUserIdAndClassroomIdAndType( + teacherUserId = teacherId, + classroomId = classroomId, + type = type, + )?.let(teacherNotificationMuteRepository::delete) } } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/TeacherNotificationEventListener.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/TeacherNotificationEventListener.kt index a950c8c6..a09addf3 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/TeacherNotificationEventListener.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/application/TeacherNotificationEventListener.kt @@ -25,11 +25,12 @@ class TeacherNotificationEventListener( */ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) fun onMapStopCompleted(event: MapStopCompletedEvent) { - val context = notificationEventContextResolver.resolveStopContext( - classroomId = event.classroomId, - pupilUserId = event.pupilUserId, - stopId = event.stopId, - ) + val context = + notificationEventContextResolver.resolveStopContext( + classroomId = event.classroomId, + pupilUserId = event.pupilUserId, + stopId = event.stopId, + ) val threadKey = stopCompletedThreadKey(event.classroomId, event.pupilUserId, event.stopId) notificationDeliveryService.deliverToClassroomTeachers( @@ -39,16 +40,18 @@ class TeacherNotificationEventListener( type = NotificationType.MAP_STOP_COMPLETED, threadKey = threadKey, renderCode = NotificationRenderCode.MAP_STOP_COMPLETED, - renderArgsJson = notificationEventContextResolver.json( - "classroomTitle" to context.classroomTitle, - "pupilDisplayName" to context.pupilDisplayName, - "stopTitle" to context.stopTitle, - ), - payloadJson = notificationEventContextResolver.json( - "classroomId" to event.classroomId, - "pupilUserId" to event.pupilUserId, - "stopId" to event.stopId, - ), + renderArgsJson = + notificationEventContextResolver.json( + "classroomTitle" to context.classroomTitle, + "pupilDisplayName" to context.pupilDisplayName, + "stopTitle" to context.stopTitle, + ), + payloadJson = + notificationEventContextResolver.json( + "classroomId" to event.classroomId, + "pupilUserId" to event.pupilUserId, + "stopId" to event.stopId, + ), ), ) } @@ -60,11 +63,12 @@ class TeacherNotificationEventListener( */ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) fun onGameCompleted(event: PupilGameCompletedEvent) { - val context = notificationEventContextResolver.resolveStopContext( - classroomId = event.classroomId, - pupilUserId = event.pupilUserId, - stopId = event.stopId, - ) + val context = + notificationEventContextResolver.resolveStopContext( + classroomId = event.classroomId, + pupilUserId = event.pupilUserId, + stopId = event.stopId, + ) val threadKey = gameCompletedThreadKey(event.classroomId, event.pupilUserId) notificationDeliveryService.deliverToClassroomTeachers( @@ -74,15 +78,17 @@ class TeacherNotificationEventListener( type = NotificationType.GAME_COMPLETED, threadKey = threadKey, renderCode = NotificationRenderCode.GAME_COMPLETED, - renderArgsJson = notificationEventContextResolver.json( - "classroomTitle" to context.classroomTitle, - "pupilDisplayName" to context.pupilDisplayName, - ), - payloadJson = notificationEventContextResolver.json( - "classroomId" to event.classroomId, - "pupilUserId" to event.pupilUserId, - "stopId" to event.stopId, - ), + renderArgsJson = + notificationEventContextResolver.json( + "classroomTitle" to context.classroomTitle, + "pupilDisplayName" to context.pupilDisplayName, + ), + payloadJson = + notificationEventContextResolver.json( + "classroomId" to event.classroomId, + "pupilUserId" to event.pupilUserId, + "stopId" to event.stopId, + ), ), ) } @@ -95,11 +101,12 @@ class TeacherNotificationEventListener( */ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) fun onPupilStuck(event: PupilStuckThresholdReachedEvent) { - val context = notificationEventContextResolver.resolveStopContext( - classroomId = event.classroomId, - pupilUserId = event.pupilUserId, - stopId = event.stopId, - ) + val context = + notificationEventContextResolver.resolveStopContext( + classroomId = event.classroomId, + pupilUserId = event.pupilUserId, + stopId = event.stopId, + ) val threadKey = stuckThreadKey(event.classroomId, event.pupilUserId, event.stopId) notificationDeliveryService.deliverToClassroomTeachers( @@ -109,17 +116,19 @@ class TeacherNotificationEventListener( type = NotificationType.PUPIL_STUCK, threadKey = threadKey, renderCode = NotificationRenderCode.PUPIL_STUCK, - renderArgsJson = notificationEventContextResolver.json( - "classroomTitle" to context.classroomTitle, - "pupilDisplayName" to context.pupilDisplayName, - "stopTitle" to context.stopTitle, - "consecutiveWrongAttempts" to event.consecutiveWrongAttempts, - ), - payloadJson = notificationEventContextResolver.json( - "classroomId" to event.classroomId, - "pupilUserId" to event.pupilUserId, - "stopId" to event.stopId, - ), + renderArgsJson = + notificationEventContextResolver.json( + "classroomTitle" to context.classroomTitle, + "pupilDisplayName" to context.pupilDisplayName, + "stopTitle" to context.stopTitle, + "consecutiveWrongAttempts" to event.consecutiveWrongAttempts, + ), + payloadJson = + notificationEventContextResolver.json( + "classroomId" to event.classroomId, + "pupilUserId" to event.pupilUserId, + "stopId" to event.stopId, + ), ), ) } @@ -146,8 +155,11 @@ class TeacherNotificationEventListener( * @param stopId stop that was completed * @return deterministic notification thread key */ - fun stopCompletedThreadKey(classroomId: Long, pupilUserId: Long, stopId: Long): String = - "stop-completed:$classroomId:$pupilUserId:$stopId" + fun stopCompletedThreadKey( + classroomId: Long, + pupilUserId: Long, + stopId: Long, + ): String = "stop-completed:$classroomId:$pupilUserId:$stopId" /** * Stable thread key for game-completion notifications for one pupil/classroom tuple. @@ -156,8 +168,10 @@ class TeacherNotificationEventListener( * @param pupilUserId pupil that completed the game * @return deterministic notification thread key */ - fun gameCompletedThreadKey(classroomId: Long, pupilUserId: Long): String = - "game-completed:$classroomId:$pupilUserId" + fun gameCompletedThreadKey( + classroomId: Long, + pupilUserId: Long, + ): String = "game-completed:$classroomId:$pupilUserId" /** * Stable thread key for stuck notifications for one pupil/stop/classroom tuple. @@ -167,7 +181,10 @@ class TeacherNotificationEventListener( * @param stopId stop where the pupil is stuck * @return deterministic notification thread key */ - fun stuckThreadKey(classroomId: Long, pupilUserId: Long, stopId: Long): String = - "stuck:$classroomId:$pupilUserId:$stopId" + fun stuckThreadKey( + classroomId: Long, + pupilUserId: Long, + stopId: Long, + ): String = "stuck:$classroomId:$pupilUserId:$stopId" } } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/infrastructure/TeacherNotificationRepository.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/infrastructure/TeacherNotificationRepository.kt index a59bc6f4..4dfadb37 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/infrastructure/TeacherNotificationRepository.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/notification/infrastructure/TeacherNotificationRepository.kt @@ -85,7 +85,10 @@ interface TeacherNotificationRepository : JpaRepository currentLevel) { - throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Current level is not high enough to use the avatar asset") + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Current level is not high enough to use the avatar asset", + ) } } private fun findUserAvatar(user: User): UserAvatar { val userId = requireNotNull(user.id) { "Authenticated user is missing an id" } - return userAvatarRepository.findById(userId) + return userAvatarRepository + .findById(userId) .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil avatar was not found") } @@ -152,18 +166,27 @@ class UserAvatarService( skinColorId = resolveAssetName(userAvatar.skinColorId, AvatarAssetType.SKIN_COLOR), ) - private fun resolveAssetId(name: String, type: AvatarAssetType): Long = - avatarAssetRepository.findByTypeAndName(type, name) + private fun resolveAssetId( + name: String, + type: AvatarAssetType, + ): Long = + avatarAssetRepository + .findByTypeAndName(type, name) .map { requireNotNull(it.id) { "Avatar asset is missing an id" } } .orElseThrow { ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown avatar asset '$name' for type $type") } - private fun resolveAssetName(id: Long, type: AvatarAssetType): String { - val asset = avatarAssetRepository.findById(id) - .orElseThrow { - ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Avatar asset $id was not found") - } + private fun resolveAssetName( + id: Long, + type: AvatarAssetType, + ): String { + val asset = + avatarAssetRepository + .findById(id) + .orElseThrow { + ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Avatar asset $id was not found") + } if (asset.type != type) { throw ResponseStatusException( diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/infrastructure/AvatarAssetRepository.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/infrastructure/AvatarAssetRepository.kt index d412ec8b..cf166653 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/infrastructure/AvatarAssetRepository.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/infrastructure/AvatarAssetRepository.kt @@ -9,7 +9,10 @@ import java.util.Optional * Repository for avatar asset persistence. */ interface AvatarAssetRepository : JpaRepository { - fun findByTypeAndName(type: AvatarAssetType, name: String): Optional + fun findByTypeAndName( + type: AvatarAssetType, + name: String, + ): Optional fun findAllByOrderByTypeAscUnlocksAtLevelAscNameAsc(): List } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/infrastructure/UserAvatarRepository.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/infrastructure/UserAvatarRepository.kt index 82f7d8c6..ae7cfcfc 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/infrastructure/UserAvatarRepository.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/infrastructure/UserAvatarRepository.kt @@ -6,4 +6,4 @@ import org.springframework.data.jpa.repository.JpaRepository /** * Repository for current pupil avatar persistence. */ -interface UserAvatarRepository : JpaRepository \ No newline at end of file +interface UserAvatarRepository : JpaRepository diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/profile/api/CurrentUserProfileApiMappings.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/profile/api/CurrentUserProfileApiMappings.kt index 56601375..df14ce97 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/profile/api/CurrentUserProfileApiMappings.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/profile/api/CurrentUserProfileApiMappings.kt @@ -15,21 +15,23 @@ fun CurrentUserProfile.toCurrentUserProfileResponse(): CurrentUserProfileRespons role = role, email = email, username = username, - pupilProfile = pupilProfile?.let { - PupilProfileResponse( - displayName = it.displayName, - currentClassroomId = it.currentClassroomId, - totalXp = it.totalXp, - currentLevel = it.currentLevel, - hasCompletedOnboarding = it.hasCompletedOnboarding, - ) - }, - teacherProfile = teacherProfile?.let { - TeacherProfileResponse( - teacherName = it.teacherName, - schoolName = it.schoolName, - ) - }, + pupilProfile = + pupilProfile?.let { + PupilProfileResponse( + displayName = it.displayName, + currentClassroomId = it.currentClassroomId, + totalXp = it.totalXp, + currentLevel = it.currentLevel, + hasCompletedOnboarding = it.hasCompletedOnboarding, + ) + }, + teacherProfile = + teacherProfile?.let { + TeacherProfileResponse( + teacherName = it.teacherName, + schoolName = it.schoolName, + ) + }, ) /** diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/profile/api/UserProfileController.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/profile/api/UserProfileController.kt index 326d962a..52b247d7 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/profile/api/UserProfileController.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/profile/api/UserProfileController.kt @@ -29,19 +29,19 @@ class UserProfileController( @GetMapping fun getCurrentUserProfile( @AuthenticationPrincipal currentUser: CurrentUser, - ): CurrentUserProfileResponse = - currentUserProfileService.getCurrentUserProfile(currentUser.subject).toCurrentUserProfileResponse() + ): CurrentUserProfileResponse = currentUserProfileService.getCurrentUserProfile(currentUser.subject).toCurrentUserProfileResponse() @PutMapping fun updateCurrentUserDisplayName( @AuthenticationPrincipal currentUser: CurrentUser, @Valid @RequestBody request: UpdateCurrentUserDisplayNameRequest, ): PupilProfileResponse = - currentUserProfileService.updateCurrentUserDisplayName(currentUser.subject, request.toCommand()).toPupilProfileResponse() + currentUserProfileService + .updateCurrentUserDisplayName(currentUser.subject, request.toCommand()) + .toPupilProfileResponse() @PutMapping("/onboarding/completed") fun completeCurrentUserOnboarding( @AuthenticationPrincipal currentUser: CurrentUser, - ): PupilProfileResponse = - currentUserProfileService.completeCurrentUserOnboarding(currentUser.subject).toPupilProfileResponse() + ): PupilProfileResponse = currentUserProfileService.completeCurrentUserOnboarding(currentUser.subject).toPupilProfileResponse() } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/profile/application/CurrentUserProfileService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/profile/application/CurrentUserProfileService.kt index 25486fba..b14618d8 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/profile/application/CurrentUserProfileService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/profile/application/CurrentUserProfileService.kt @@ -38,40 +38,42 @@ class CurrentUserProfileService( val user = currentUserResolver.requireUser(authenticatedSubject) val userId = requireNotNull(user.id) { "Authenticated user is missing an id" } - val pupilProfile = if (user.role == UserRole.PUPIL) { - pupilProfileRepository.findById(userId) - .map { - val progress = getCurrentClassroomProgress(userId, it.currentClassroom?.id) + val pupilProfile = + if (user.role == UserRole.PUPIL) { + pupilProfileRepository + .findById(userId) + .map { + val progress = getCurrentClassroomProgress(userId, it.currentClassroom?.id) - PupilProfileDetails( - displayName = it.displayName, - currentClassroomId = it.currentClassroom?.id, - totalXp = progress?.totalXp ?: 0, - currentLevel = progress?.currentLevel ?: 1, - hasCompletedOnboarding = it.hasCompletedOnboarding, - ) - } - .orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil profile was not found") - } - } else { - null - } + PupilProfileDetails( + displayName = it.displayName, + currentClassroomId = it.currentClassroom?.id, + totalXp = progress?.totalXp ?: 0, + currentLevel = progress?.currentLevel ?: 1, + hasCompletedOnboarding = it.hasCompletedOnboarding, + ) + }.orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil profile was not found") + } + } else { + null + } - val teacherProfile = if (user.role == UserRole.TEACHER) { - teacherProfileRepository.findById(userId) - .map { - TeacherProfileDetails( - teacherName = it.teacherName, - schoolName = it.schoolName, - ) - } - .orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Teacher profile was not found") - } - } else { - null - } + val teacherProfile = + if (user.role == UserRole.TEACHER) { + teacherProfileRepository + .findById(userId) + .map { + TeacherProfileDetails( + teacherName = it.teacherName, + schoolName = it.schoolName, + ) + }.orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Teacher profile was not found") + } + } else { + null + } return CurrentUserProfile( userId = userId, @@ -142,7 +144,8 @@ class CurrentUserProfileService( * @return persisted pupil profile row */ private fun findPupilProfile(userId: Long): PupilProfile = - pupilProfileRepository.findById(userId) + pupilProfileRepository + .findById(userId) .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil profile was not found") } @@ -154,8 +157,10 @@ class CurrentUserProfileService( * @param classroomId current classroom id from the pupil profile, or `null` * @return classroom-scoped progress row, or `null` when no classroom or progress row exists */ - private fun getCurrentClassroomProgress(userId: Long, classroomId: Long?): PupilGameProgress? = - classroomId?.let { pupilGameProgressService.getPupilProgress(userId, it) } + private fun getCurrentClassroomProgress( + userId: Long, + classroomId: Long?, + ): PupilGameProgress? = classroomId?.let { pupilGameProgressService.getPupilProgress(userId, it) } } /** diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/profile/domain/PupilProfile.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/profile/domain/PupilProfile.kt index f8fbc7f2..79932a0c 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/profile/domain/PupilProfile.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/profile/domain/PupilProfile.kt @@ -2,7 +2,6 @@ package edu.ntnu.idi.idatt.backend.users.profile.domain import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom import edu.ntnu.idi.idatt.backend.iam.domain.User - import jakarta.persistence.Column import jakarta.persistence.Entity import jakarta.persistence.FetchType diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/profile/domain/TeacherProfile.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/profile/domain/TeacherProfile.kt index 64ee24a8..6737e43b 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/profile/domain/TeacherProfile.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/profile/domain/TeacherProfile.kt @@ -1,7 +1,6 @@ package edu.ntnu.idi.idatt.backend.users.profile.domain import edu.ntnu.idi.idatt.backend.iam.domain.User - import jakarta.persistence.Column import jakarta.persistence.Entity import jakarta.persistence.FetchType diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/settings/api/UserSettingsController.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/settings/api/UserSettingsController.kt index 6ae0e77a..66daf021 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/settings/api/UserSettingsController.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/settings/api/UserSettingsController.kt @@ -27,8 +27,9 @@ class UserSettingsController( * @return current user settings */ @GetMapping - fun getSettings(@AuthenticationPrincipal currentUser: CurrentUser): UserSettingsResponse = - userSettingsService.getUserSettings(currentUser.subject).toUserSettingsResponse() + fun getSettings( + @AuthenticationPrincipal currentUser: CurrentUser, + ): UserSettingsResponse = userSettingsService.getUserSettings(currentUser.subject).toUserSettingsResponse() /** * Replaces the current authenticated user's settings with the submitted values. @@ -41,6 +42,5 @@ class UserSettingsController( fun updateSettings( @AuthenticationPrincipal currentUser: CurrentUser, @Valid @RequestBody request: UpdateUserSettingsRequest, - ): UserSettingsResponse = - userSettingsService.updateUserSettings(currentUser.subject, request.toCommand()).toUserSettingsResponse() + ): UserSettingsResponse = userSettingsService.updateUserSettings(currentUser.subject, request.toCommand()).toUserSettingsResponse() } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/xp/application/XpService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/xp/application/XpService.kt index 595c98e7..0edbe64c 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/xp/application/XpService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/xp/application/XpService.kt @@ -1,13 +1,13 @@ package edu.ntnu.idi.idatt.backend.xp.application import edu.ntnu.idi.idatt.backend.game.gameProgress.application.PupilGameProgressService +import edu.ntnu.idi.idatt.backend.iam.application.CurrentUserResolver +import edu.ntnu.idi.idatt.backend.iam.security.CurrentUser import edu.ntnu.idi.idatt.backend.xp.domain.PupilXpEvent import edu.ntnu.idi.idatt.backend.xp.domain.PupilXpEventId import edu.ntnu.idi.idatt.backend.xp.domain.XpEventCode import edu.ntnu.idi.idatt.backend.xp.infrastructure.PupilXpEventRepository import edu.ntnu.idi.idatt.backend.xp.infrastructure.XpEventRepository -import edu.ntnu.idi.idatt.backend.iam.application.CurrentUserResolver -import edu.ntnu.idi.idatt.backend.iam.security.CurrentUser import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.springframework.web.server.ResponseStatusException @@ -31,7 +31,6 @@ class XpService( private val pupilGameProgressService: PupilGameProgressService, private val currentUserResolver: CurrentUserResolver, ) { - /** * Awards one XP event to the authenticated pupil. * @@ -44,7 +43,10 @@ class XpService( * @throws ResponseStatusException when the XP event definition does not exist */ @Transactional - fun awardXpEvent(currentUser: CurrentUser, eventCode: XpEventCode) { + fun awardXpEvent( + currentUser: CurrentUser, + eventCode: XpEventCode, + ) { val pupil = currentUserResolver.requirePupil(currentUser.subject) val pupilId = requireNotNull(pupil.id) { "Pupil is missing an id" } @@ -95,12 +97,14 @@ class XpService( } val pupilXpEvents = pupilXpEventRepository.findAllByIdPupilUserId(pupilId) - val totalXp = pupilXpEvents.sumOf { pupilXpEvent -> - val xpEvent = requireNotNull(pupilXpEvent.xpEvent) { - "Pupil XP event is missing its XP event reference" + val totalXp = + pupilXpEvents.sumOf { pupilXpEvent -> + val xpEvent = + requireNotNull(pupilXpEvent.xpEvent) { + "Pupil XP event is missing its XP event reference" + } + xpEvent.xpScore * pupilXpEvent.completionCount } - xpEvent.xpScore * pupilXpEvent.completionCount - } try { pupilGameProgressService.updateUserXpAndLevel(currentUser, totalXp) @@ -108,5 +112,4 @@ class XpService( return } } - } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/xp/infrastructure/PupilXpEventRepository.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/xp/infrastructure/PupilXpEventRepository.kt index b34c04de..e885a2d2 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/xp/infrastructure/PupilXpEventRepository.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/xp/infrastructure/PupilXpEventRepository.kt @@ -1,7 +1,7 @@ package edu.ntnu.idi.idatt.backend.xp.infrastructure -import edu.ntnu.idi.idatt.backend.xp.domain.PupilXpEventId import edu.ntnu.idi.idatt.backend.xp.domain.PupilXpEvent +import edu.ntnu.idi.idatt.backend.xp.domain.PupilXpEventId import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository @@ -25,7 +25,10 @@ interface PupilXpEventRepository : JpaRepository { * @param xpEventId identifier of the XP event definition. * @return `true` when the composite award row already exists. */ - fun existsByIdPupilUserIdAndIdXpEventId(pupilUserId: Long, xpEventId: Long): Boolean + fun existsByIdPupilUserIdAndIdXpEventId( + pupilUserId: Long, + xpEventId: Long, + ): Boolean /** * Finds the awarded XP row for one pupil-event pair. @@ -34,5 +37,8 @@ interface PupilXpEventRepository : JpaRepository { * @param xpEventId identifier of the XP event definition. * @return awarded XP row, or `null` when absent. */ - fun findByIdPupilUserIdAndIdXpEventId(pupilUserId: Long, xpEventId: Long): PupilXpEvent? + fun findByIdPupilUserIdAndIdXpEventId( + pupilUserId: Long, + xpEventId: Long, + ): PupilXpEvent? } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/xp/infrastructure/XpEventRepository.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/xp/infrastructure/XpEventRepository.kt index 31eb1752..e4e15a31 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/xp/infrastructure/XpEventRepository.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/xp/infrastructure/XpEventRepository.kt @@ -1,7 +1,7 @@ package edu.ntnu.idi.idatt.backend.xp.infrastructure -import edu.ntnu.idi.idatt.backend.xp.domain.XpEventCode import edu.ntnu.idi.idatt.backend.xp.domain.XpEvent +import edu.ntnu.idi.idatt.backend.xp.domain.XpEventCode import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index bc20d06b..efbb097f 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,8 +1,6 @@ spring.application.name=backend spring.config.import=optional:file:.env[.properties],optional:file:.env.dev[.properties] - app.bootstrap.enabled=${APP_BOOTSTRAP_ENABLED:false} - app.security.jwt.issuer=${APP_SECURITY_JWT_ISSUER} app.security.jwt.audience=${APP_SECURITY_JWT_AUDIENCE} app.security.jwt.secret=${APP_SECURITY_JWT_SECRET} diff --git a/src/main/resources/db/migration/V10__create_task_translations.sql b/src/main/resources/db/migration/V10__create_task_translations.sql index 78e57c37..2337a3ef 100644 --- a/src/main/resources/db/migration/V10__create_task_translations.sql +++ b/src/main/resources/db/migration/V10__create_task_translations.sql @@ -1,7 +1,8 @@ -CREATE TABLE task_translations ( - task_id BIGINT NOT NULL, - locale VARCHAR(10) NOT NULL, - title VARCHAR(255) NOT NULL, +CREATE TABLE task_translations +( + task_id BIGINT NOT NULL, + locale VARCHAR(10) NOT NULL, + title VARCHAR(255) NOT NULL, intro_text TEXT, CONSTRAINT pk_task_translations PRIMARY KEY (task_id, locale), CONSTRAINT fk_task_translations_task @@ -9,20 +10,22 @@ CREATE TABLE task_translations ( CONSTRAINT chk_task_translations_locale CHECK (locale IN ('NB', 'EN')) ); -CREATE TABLE task_item_translations ( - task_item_id BIGINT NOT NULL, - locale VARCHAR(10) NOT NULL, - prompt_text TEXT NOT NULL, +CREATE TABLE task_item_translations +( + task_item_id BIGINT NOT NULL, + locale VARCHAR(10) NOT NULL, + prompt_text TEXT NOT NULL, CONSTRAINT pk_task_item_translations PRIMARY KEY (task_item_id, locale), CONSTRAINT fk_task_item_translations_task_item FOREIGN KEY (task_item_id) REFERENCES task_items (id) ON DELETE CASCADE, CONSTRAINT chk_task_item_translations_locale CHECK (locale IN ('NB', 'EN')) ); -CREATE TABLE task_option_translations ( - task_option_id BIGINT NOT NULL, - locale VARCHAR(10) NOT NULL, - option_text TEXT NOT NULL, +CREATE TABLE task_option_translations +( + task_option_id BIGINT NOT NULL, + locale VARCHAR(10) NOT NULL, + option_text TEXT NOT NULL, explanation_text TEXT, CONSTRAINT pk_task_option_translations PRIMARY KEY (task_option_id, locale), CONSTRAINT fk_task_option_translations_task_option diff --git a/src/main/resources/db/migration/V12__create_pupil_game_progress_table.sql b/src/main/resources/db/migration/V12__create_pupil_game_progress_table.sql index 262dcdb0..a2d9ce70 100644 --- a/src/main/resources/db/migration/V12__create_pupil_game_progress_table.sql +++ b/src/main/resources/db/migration/V12__create_pupil_game_progress_table.sql @@ -1,13 +1,14 @@ -CREATE TABLE pupil_game_progress ( - pupil_user_id BIGINT NOT NULL, - classroom_id BIGINT NOT NULL, - total_score INT NOT NULL DEFAULT 0, - total_xp INT NOT NULL DEFAULT 0, - current_level INT NOT NULL DEFAULT 1, - completed_stops_count INT NOT NULL DEFAULT 0, - completed_tasks_count INT NOT NULL DEFAULT 0, - final_boss_completed BOOLEAN NOT NULL DEFAULT FALSE, - last_activity_at DATETIME(6) NOT NULL, +CREATE TABLE pupil_game_progress +( + pupil_user_id BIGINT NOT NULL, + classroom_id BIGINT NOT NULL, + total_score INT NOT NULL DEFAULT 0, + total_xp INT NOT NULL DEFAULT 0, + current_level INT NOT NULL DEFAULT 1, + completed_stops_count INT NOT NULL DEFAULT 0, + completed_tasks_count INT NOT NULL DEFAULT 0, + final_boss_completed BOOLEAN NOT NULL DEFAULT FALSE, + last_activity_at DATETIME(6) NOT NULL, CONSTRAINT pk_pupil_game_progress PRIMARY KEY (pupil_user_id, classroom_id), CONSTRAINT fk_pgp_pupil FOREIGN KEY (pupil_user_id) REFERENCES users (id) ON DELETE CASCADE, diff --git a/src/main/resources/db/migration/V14__create_classroom_teachers_table.sql b/src/main/resources/db/migration/V14__create_classroom_teachers_table.sql index c726523d..60974295 100644 --- a/src/main/resources/db/migration/V14__create_classroom_teachers_table.sql +++ b/src/main/resources/db/migration/V14__create_classroom_teachers_table.sql @@ -1,8 +1,9 @@ -CREATE TABLE classroom_teachers ( - classroom_id BIGINT NOT NULL, +CREATE TABLE classroom_teachers +( + classroom_id BIGINT NOT NULL, teacher_user_id BIGINT NOT NULL, PRIMARY KEY (classroom_id, teacher_user_id), - CONSTRAINT fk_classroom_teacher_classroom FOREIGN KEY (classroom_id) REFERENCES classrooms(id) ON DELETE CASCADE, - CONSTRAINT fk_classroom_teacher_user FOREIGN KEY (teacher_user_id) REFERENCES users(id) ON DELETE CASCADE + CONSTRAINT fk_classroom_teacher_classroom FOREIGN KEY (classroom_id) REFERENCES classrooms (id) ON DELETE CASCADE, + CONSTRAINT fk_classroom_teacher_user FOREIGN KEY (teacher_user_id) REFERENCES users (id) ON DELETE CASCADE ); \ No newline at end of file diff --git a/src/main/resources/db/migration/V15__create_notebook_entries_table.sql b/src/main/resources/db/migration/V15__create_notebook_entries_table.sql index 18f8cc99..cb380b08 100644 --- a/src/main/resources/db/migration/V15__create_notebook_entries_table.sql +++ b/src/main/resources/db/migration/V15__create_notebook_entries_table.sql @@ -1,9 +1,12 @@ -CREATE TABLE notebook_entries ( - id BIGINT NOT NULL AUTO_INCREMENT, +CREATE TABLE notebook_entries +( + id BIGINT NOT NULL AUTO_INCREMENT, pupil_user_id BIGINT NOT NULL, - classroom_id BIGINT NOT NULL, - stop_id BIGINT NOT NULL, - source_type ENUM('GAME', 'PUPIL') NOT NULL, + classroom_id BIGINT NOT NULL, + stop_id BIGINT NOT NULL, + source_type ENUM('GAME', + 'PUPIL' +) NOT NULL, title VARCHAR(255) NOT NULL, content TEXT NOT NULL, created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), diff --git a/src/main/resources/db/migration/V16__allow_null_pupil_for_game_notebook_entries.sql b/src/main/resources/db/migration/V16__allow_null_pupil_for_game_notebook_entries.sql index b7d25ca4..ba8ac357 100644 --- a/src/main/resources/db/migration/V16__allow_null_pupil_for_game_notebook_entries.sql +++ b/src/main/resources/db/migration/V16__allow_null_pupil_for_game_notebook_entries.sql @@ -7,7 +7,7 @@ WHERE source_type = 'GAME'; ALTER TABLE notebook_entries ADD CONSTRAINT chk_notebook_entries_source_type_pupil - CHECK ( - (source_type = 'GAME' AND pupil_user_id IS NULL) OR - (source_type = 'PUPIL' AND pupil_user_id IS NOT NULL) - ); + CHECK ( + (source_type = 'GAME' AND pupil_user_id IS NULL) OR + (source_type = 'PUPIL' AND pupil_user_id IS NOT NULL) + ); diff --git a/src/main/resources/db/migration/V17__create_medals_tables.sql b/src/main/resources/db/migration/V17__create_medals_tables.sql index ba90fb38..f09046d5 100644 --- a/src/main/resources/db/migration/V17__create_medals_tables.sql +++ b/src/main/resources/db/migration/V17__create_medals_tables.sql @@ -1,9 +1,12 @@ -CREATE TABLE medals ( - id BIGINT NOT NULL AUTO_INCREMENT, - code VARCHAR(100) NOT NULL, - title VARCHAR(255) NOT NULL, +CREATE TABLE medals +( + id BIGINT NOT NULL AUTO_INCREMENT, + code VARCHAR(100) NOT NULL, + title VARCHAR(255) NOT NULL, description TEXT, - medal_type ENUM('STOP_COMPLETION', 'EXAMPLE_APPROVAL') NOT NULL, + medal_type ENUM('STOP_COMPLETION', + 'EXAMPLE_APPROVAL' +) NOT NULL, stop_id BIGINT, icon_url VARCHAR(255), CONSTRAINT pk_medals PRIMARY KEY (id), @@ -11,12 +14,14 @@ CREATE TABLE medals ( CONSTRAINT fk_medals_stop FOREIGN KEY (stop_id) REFERENCES map_stops (id) ON DELETE SET NULL ); -CREATE TABLE pupil_medals ( - id BIGINT NOT NULL AUTO_INCREMENT, - pupil_user_id BIGINT NOT NULL, - classroom_id BIGINT NOT NULL, - medal_id BIGINT NOT NULL, - awarded_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), +CREATE TABLE pupil_medals +( + id BIGINT NOT NULL AUTO_INCREMENT, + pupil_user_id BIGINT NOT NULL, + classroom_id BIGINT NOT NULL, + medal_id BIGINT NOT NULL, + awarded_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6 +) , CONSTRAINT pk_pupil_medals PRIMARY KEY (id), CONSTRAINT uk_pupil_medals_pupil_classroom_medal UNIQUE (pupil_user_id, classroom_id, medal_id), CONSTRAINT fk_pupil_medals_pupil FOREIGN KEY (pupil_user_id) REFERENCES users (id) ON DELETE CASCADE, diff --git a/src/main/resources/db/migration/V18__create_teacher_notifications_table.sql b/src/main/resources/db/migration/V18__create_teacher_notifications_table.sql index 87aac659..9c8a9a5f 100644 --- a/src/main/resources/db/migration/V18__create_teacher_notifications_table.sql +++ b/src/main/resources/db/migration/V18__create_teacher_notifications_table.sql @@ -1,9 +1,14 @@ -CREATE TABLE teacher_notifications ( - id BIGINT NOT NULL AUTO_INCREMENT, +CREATE TABLE teacher_notifications +( + id BIGINT NOT NULL AUTO_INCREMENT, teacher_user_id BIGINT NOT NULL, - classroom_id BIGINT NOT NULL, - pupil_user_id BIGINT, - type ENUM('MAP_STOP_COMPLETED', 'GAME_COMPLETED', 'PUPIL_STUCK', 'WEEKLY_MYSTERY_SUBMITTED') NOT NULL, + classroom_id BIGINT NOT NULL, + pupil_user_id BIGINT, + type ENUM('MAP_STOP_COMPLETED', + 'GAME_COMPLETED', + 'PUPIL_STUCK', + 'WEEKLY_MYSTERY_SUBMITTED' +) NOT NULL, thread_key VARCHAR(255), active_thread_key VARCHAR(255), render_code VARCHAR(255) NOT NULL, diff --git a/src/main/resources/db/migration/V19__create_teacher_notification_mutes_table.sql b/src/main/resources/db/migration/V19__create_teacher_notification_mutes_table.sql index 34a9741d..6c63002d 100644 --- a/src/main/resources/db/migration/V19__create_teacher_notification_mutes_table.sql +++ b/src/main/resources/db/migration/V19__create_teacher_notification_mutes_table.sql @@ -1,8 +1,13 @@ -CREATE TABLE teacher_notification_mutes ( - id BIGINT NOT NULL AUTO_INCREMENT, +CREATE TABLE teacher_notification_mutes +( + id BIGINT NOT NULL AUTO_INCREMENT, teacher_user_id BIGINT NOT NULL, - classroom_id BIGINT NOT NULL, - type ENUM('MAP_STOP_COMPLETED', 'GAME_COMPLETED', 'PUPIL_STUCK', 'WEEKLY_MYSTERY_SUBMITTED') NOT NULL, + classroom_id BIGINT NOT NULL, + type ENUM('MAP_STOP_COMPLETED', + 'GAME_COMPLETED', + 'PUPIL_STUCK', + 'WEEKLY_MYSTERY_SUBMITTED' +) NOT NULL, created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), CONSTRAINT pk_teacher_notification_mutes PRIMARY KEY (id), CONSTRAINT uk_teacher_notification_mutes_scope UNIQUE (teacher_user_id, classroom_id, type), diff --git a/src/main/resources/db/migration/V1__create_users_table.sql b/src/main/resources/db/migration/V1__create_users_table.sql index 44805059..a96a9616 100644 --- a/src/main/resources/db/migration/V1__create_users_table.sql +++ b/src/main/resources/db/migration/V1__create_users_table.sql @@ -1,24 +1,26 @@ -CREATE TABLE users ( - id BIGINT NOT NULL AUTO_INCREMENT, - email VARCHAR(255), - username VARCHAR(100), - password_hash VARCHAR(255) NOT NULL, - role ENUM('PUPIL', 'TEACHER', 'ADMIN') NOT NULL, - status ENUM('ACTIVE', 'INACTIVE', 'SUSPENDED') NOT NULL, - created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), - updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), +CREATE TABLE users +( + id BIGINT NOT NULL AUTO_INCREMENT, + email VARCHAR(255), + username VARCHAR(100), + password_hash VARCHAR(255) NOT NULL, + role ENUM ('PUPIL', 'TEACHER', 'ADMIN') NOT NULL, + status ENUM ('ACTIVE', 'INACTIVE', 'SUSPENDED') NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), CONSTRAINT pk_users PRIMARY KEY (id), CONSTRAINT chk_users_login_identifier CHECK ( (role = 'PUPIL' AND username IS NOT NULL AND email IS NULL) - OR (role IN ('TEACHER', 'ADMIN') AND email IS NOT NULL AND username IS NULL) - ), + OR (role IN ('TEACHER', 'ADMIN') AND email IS NOT NULL AND username IS NULL) + ), CONSTRAINT uk_users_email UNIQUE (email), CONSTRAINT uk_users_username UNIQUE (username) ); -CREATE TABLE refresh_tokens ( - id BIGINT NOT NULL AUTO_INCREMENT, - user_id BIGINT NOT NULL, +CREATE TABLE refresh_tokens +( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL, token_hash VARCHAR(64) NOT NULL, expires_at DATETIME(6) NOT NULL, revoked_at DATETIME(6), diff --git a/src/main/resources/db/migration/V21__create_xp_tables.sql b/src/main/resources/db/migration/V21__create_xp_tables.sql index 10a02809..bfba542d 100644 --- a/src/main/resources/db/migration/V21__create_xp_tables.sql +++ b/src/main/resources/db/migration/V21__create_xp_tables.sql @@ -1,17 +1,19 @@ -CREATE TABLE xp_events ( - id BIGINT NOT NULL AUTO_INCREMENT, - code VARCHAR(100) NOT NULL, - title VARCHAR(255) NOT NULL, - xp_score INT NOT NULL, - repeatable BOOLEAN NOT NULL DEFAULT FALSE, +CREATE TABLE xp_events +( + id BIGINT NOT NULL AUTO_INCREMENT, + code VARCHAR(100) NOT NULL, + title VARCHAR(255) NOT NULL, + xp_score INT NOT NULL, + repeatable BOOLEAN NOT NULL DEFAULT FALSE, CONSTRAINT pk_xp_events PRIMARY KEY (id), CONSTRAINT uk_xp_events_code UNIQUE (code) ); -CREATE TABLE pupil_xp_events ( - pupil_user_id BIGINT NOT NULL, - xp_event_id BIGINT NOT NULL, - completion_count INT NOT NULL DEFAULT 1, +CREATE TABLE pupil_xp_events +( + pupil_user_id BIGINT NOT NULL, + xp_event_id BIGINT NOT NULL, + completion_count INT NOT NULL DEFAULT 1, CONSTRAINT pk_pupil_xp_events PRIMARY KEY (pupil_user_id, xp_event_id), CONSTRAINT fk_pupil_xp_events_pupil FOREIGN KEY (pupil_user_id) REFERENCES users (id) ON DELETE CASCADE, diff --git a/src/main/resources/db/migration/V23__split_avatars_into_user_avatar_and_avatar_asset.sql b/src/main/resources/db/migration/V23__split_avatars_into_user_avatar_and_avatar_asset.sql index b3b9e949..21056cbf 100644 --- a/src/main/resources/db/migration/V23__split_avatars_into_user_avatar_and_avatar_asset.sql +++ b/src/main/resources/db/migration/V23__split_avatars_into_user_avatar_and_avatar_asset.sql @@ -1,22 +1,22 @@ -CREATE TABLE avatar_asset ( - id BIGINT NOT NULL AUTO_INCREMENT, - name VARCHAR(100) NOT NULL, - type VARCHAR(20) NOT NULL, - unlocks_at_level INT NOT NULL DEFAULT 0, +CREATE TABLE avatar_asset +( + id BIGINT NOT NULL AUTO_INCREMENT, + name VARCHAR(100) NOT NULL, + type VARCHAR(20) NOT NULL, + unlocks_at_level INT NOT NULL DEFAULT 0, CONSTRAINT pk_avatar_asset PRIMARY KEY (id), CONSTRAINT uq_avatar_asset_type_name UNIQUE (type, name), CONSTRAINT chk_avatar_asset_type CHECK (type IN ('HAT', 'OUTFIT', 'SKIN_COLOR')) ); INSERT INTO avatar_asset (id, name, type, unlocks_at_level) -VALUES - (1, 'detective-default', 'HAT', 0), - (2, 'starter', 'OUTFIT', 0), - (3, 'light', 'SKIN_COLOR', 0), - (4, 'wizard-hat', 'HAT', 3), - (5, 'standard2', 'OUTFIT', 0), - (6, 'dark', 'SKIN_COLOR', 0), - (7, 'eilif', 'SKIN_COLOR', 4); +VALUES (1, 'detective-default', 'HAT', 0), + (2, 'starter', 'OUTFIT', 0), + (3, 'light', 'SKIN_COLOR', 0), + (4, 'wizard-hat', 'HAT', 3), + (5, 'standard2', 'OUTFIT', 0), + (6, 'dark', 'SKIN_COLOR', 0), + (7, 'eilif', 'SKIN_COLOR', 4); INSERT INTO avatar_asset (name, type, unlocks_at_level) SELECT DISTINCT body_type, 'HAT', 0 @@ -36,10 +36,11 @@ FROM avatars WHERE skin_tone IS NOT NULL AND skin_tone NOT IN ('light', 'dark'); -CREATE TABLE user_avatar ( - user_id BIGINT NOT NULL, - hat_id BIGINT NOT NULL, - outfit_id BIGINT NOT NULL, +CREATE TABLE user_avatar +( + user_id BIGINT NOT NULL, + hat_id BIGINT NOT NULL, + outfit_id BIGINT NOT NULL, skin_color_id BIGINT NOT NULL, CONSTRAINT pk_user_avatar PRIMARY KEY (user_id), CONSTRAINT fk_user_avatar_user @@ -53,17 +54,16 @@ CREATE TABLE user_avatar ( ); INSERT INTO user_avatar (user_id, hat_id, outfit_id, skin_color_id) -SELECT - legacy.user_id, - hat_asset.id, - outfit_asset.id, - skin_asset.id +SELECT legacy.user_id, + hat_asset.id, + outfit_asset.id, + skin_asset.id FROM avatars legacy -JOIN avatar_asset hat_asset - ON hat_asset.type = 'HAT' AND hat_asset.name = legacy.body_type -JOIN avatar_asset outfit_asset - ON outfit_asset.type = 'OUTFIT' AND outfit_asset.name = legacy.outfit_style -JOIN avatar_asset skin_asset - ON skin_asset.type = 'SKIN_COLOR' AND skin_asset.name = legacy.skin_tone; + JOIN avatar_asset hat_asset + ON hat_asset.type = 'HAT' AND hat_asset.name = legacy.body_type + JOIN avatar_asset outfit_asset + ON outfit_asset.type = 'OUTFIT' AND outfit_asset.name = legacy.outfit_style + JOIN avatar_asset skin_asset + ON skin_asset.type = 'SKIN_COLOR' AND skin_asset.name = legacy.skin_tone; DROP TABLE avatars; diff --git a/src/main/resources/db/migration/V2__create_teacher_profiles_table.sql b/src/main/resources/db/migration/V2__create_teacher_profiles_table.sql index e86e496a..8de7123a 100644 --- a/src/main/resources/db/migration/V2__create_teacher_profiles_table.sql +++ b/src/main/resources/db/migration/V2__create_teacher_profiles_table.sql @@ -1,7 +1,8 @@ -CREATE TABLE teacher_profiles ( - user_id BIGINT NOT NULL, +CREATE TABLE teacher_profiles +( + user_id BIGINT NOT NULL, teacher_name VARCHAR(255) NOT NULL, - school_name VARCHAR(255) NOT NULL, + school_name VARCHAR(255) NOT NULL, CONSTRAINT pk_teacher_profiles PRIMARY KEY (user_id), CONSTRAINT fk_teacher_profiles_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE diff --git a/src/main/resources/db/migration/V3__create_pupil_profiles_table.sql b/src/main/resources/db/migration/V3__create_pupil_profiles_table.sql index 81e1acf8..cc52a440 100644 --- a/src/main/resources/db/migration/V3__create_pupil_profiles_table.sql +++ b/src/main/resources/db/migration/V3__create_pupil_profiles_table.sql @@ -1,6 +1,7 @@ -CREATE TABLE pupil_profiles ( - user_id BIGINT NOT NULL, - display_name VARCHAR(100) NOT NULL, +CREATE TABLE pupil_profiles +( + user_id BIGINT NOT NULL, + display_name VARCHAR(100) NOT NULL, current_classroom_id BIGINT, CONSTRAINT pk_pupil_profiles PRIMARY KEY (user_id), CONSTRAINT fk_pupil_profiles_user diff --git a/src/main/resources/db/migration/V4__create_user_settings_table.sql b/src/main/resources/db/migration/V4__create_user_settings_table.sql index f5f8ba2a..1ab56dc6 100644 --- a/src/main/resources/db/migration/V4__create_user_settings_table.sql +++ b/src/main/resources/db/migration/V4__create_user_settings_table.sql @@ -1,10 +1,11 @@ -CREATE TABLE user_settings ( - user_id BIGINT NOT NULL, +CREATE TABLE user_settings +( + user_id BIGINT NOT NULL, music_enabled BOOLEAN NOT NULL DEFAULT TRUE, - sfx_enabled BOOLEAN NOT NULL DEFAULT TRUE, - volume_master INT NOT NULL DEFAULT 100, - volume_music INT NOT NULL DEFAULT 100, - volume_sfx INT NOT NULL DEFAULT 100, + sfx_enabled BOOLEAN NOT NULL DEFAULT TRUE, + volume_master INT NOT NULL DEFAULT 100, + volume_music INT NOT NULL DEFAULT 100, + volume_sfx INT NOT NULL DEFAULT 100, CONSTRAINT pk_user_settings PRIMARY KEY (user_id), CONSTRAINT fk_user_settings_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, diff --git a/src/main/resources/db/migration/V5__create_avatars_table.sql b/src/main/resources/db/migration/V5__create_avatars_table.sql index 375b96fe..1a61b727 100644 --- a/src/main/resources/db/migration/V5__create_avatars_table.sql +++ b/src/main/resources/db/migration/V5__create_avatars_table.sql @@ -1,7 +1,8 @@ -CREATE TABLE avatars ( - user_id BIGINT NOT NULL, - body_type VARCHAR(50) NOT NULL, - skin_tone VARCHAR(50) NOT NULL, +CREATE TABLE avatars +( + user_id BIGINT NOT NULL, + body_type VARCHAR(50) NOT NULL, + skin_tone VARCHAR(50) NOT NULL, outfit_style VARCHAR(50) NOT NULL, CONSTRAINT pk_avatars PRIMARY KEY (user_id), CONSTRAINT fk_avatars_user diff --git a/src/main/resources/db/migration/V6__create_classrooms_table.sql b/src/main/resources/db/migration/V6__create_classrooms_table.sql index cd36808b..dc9b8618 100644 --- a/src/main/resources/db/migration/V6__create_classrooms_table.sql +++ b/src/main/resources/db/migration/V6__create_classrooms_table.sql @@ -1,15 +1,16 @@ -CREATE TABLE classrooms( - id BIGINT NOT NULL AUTO_INCREMENT, - owner_teacher_id BIGINT NOT NULL, - title VARCHAR(100) NOT NULL, - description TEXT, - join_code VARCHAR(20), - status ENUM('ACTIVE', 'INACTIVE') NOT NULL, - created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), - updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), +CREATE TABLE classrooms +( + id BIGINT NOT NULL AUTO_INCREMENT, + owner_teacher_id BIGINT NOT NULL, + title VARCHAR(100) NOT NULL, + description TEXT, + join_code VARCHAR(20), + status ENUM ('ACTIVE', 'INACTIVE') NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), CONSTRAINT pk_classrooms PRIMARY KEY (id), - CONSTRAINT fk_classrooms FOREIGN KEY (owner_teacher_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_classrooms FOREIGN KEY (owner_teacher_id) REFERENCES users (id) ON DELETE CASCADE, CONSTRAINT uk_classrooms_join_code UNIQUE (join_code) ); \ No newline at end of file diff --git a/src/main/resources/db/migration/V7__create_classroom_pupils_table.sql b/src/main/resources/db/migration/V7__create_classroom_pupils_table.sql index a8263e1b..062202f1 100644 --- a/src/main/resources/db/migration/V7__create_classroom_pupils_table.sql +++ b/src/main/resources/db/migration/V7__create_classroom_pupils_table.sql @@ -1,13 +1,14 @@ -CREATE TABLE classroom_pupils( - classroom_id BIGINT NOT NULL, - pupil_user_id BIGINT NOT NULL, +CREATE TABLE classroom_pupils +( + classroom_id BIGINT NOT NULL, + pupil_user_id BIGINT NOT NULL, nickname_in_class VARCHAR(100), - status ENUM('ACTIVE', 'PENDING') NOT NULL, - joined_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), - approved_at DATETIME(6), + status ENUM ('ACTIVE', 'PENDING') NOT NULL, + joined_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + approved_at DATETIME(6), CONSTRAINT pk_classroom_pupils PRIMARY KEY (classroom_id, pupil_user_id), - CONSTRAINT fk_classroom_id FOREIGN KEY (classroom_id) REFERENCES classrooms(id) ON DELETE CASCADE, - CONSTRAINT fk_pupil_user_id FOREIGN KEY (pupil_user_id) REFERENCES users(id) ON DELETE CASCADE + CONSTRAINT fk_classroom_id FOREIGN KEY (classroom_id) REFERENCES classrooms (id) ON DELETE CASCADE, + CONSTRAINT fk_pupil_user_id FOREIGN KEY (pupil_user_id) REFERENCES users (id) ON DELETE CASCADE ); \ No newline at end of file diff --git a/src/main/resources/db/migration/V8__create_map_stops.sql b/src/main/resources/db/migration/V8__create_map_stops.sql index b396fa02..0aa67874 100644 --- a/src/main/resources/db/migration/V8__create_map_stops.sql +++ b/src/main/resources/db/migration/V8__create_map_stops.sql @@ -1,22 +1,28 @@ -CREATE TABLE map_stops ( - id BIGINT NOT NULL AUTO_INCREMENT, - slug VARCHAR(255), - title VARCHAR(255), - description VARCHAR(255), - category VARCHAR(255), - display_order INT, - max_score INT, - is_final_boss BOOLEAN NOT NULL DEFAULT FALSE, - is_active BOOLEAN NOT NULL DEFAULT FALSE, - CONSTRAINT pk_map_stops PRIMARY KEY (id), - CONSTRAINT uk_map_stops_slug UNIQUE (slug) +CREATE TABLE map_stops +( + id BIGINT NOT NULL AUTO_INCREMENT, + slug VARCHAR(255), + title VARCHAR(255), + description VARCHAR(255), + category VARCHAR(255), + display_order INT, + max_score INT, + is_final_boss BOOLEAN NOT NULL DEFAULT FALSE, + is_active BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT pk_map_stops PRIMARY KEY (id), + CONSTRAINT uk_map_stops_slug UNIQUE (slug) ); -CREATE TABLE pupil_stop_progress ( - pupil_user_id BIGINT NOT NULL, - classroom_id BIGINT NOT NULL, - stop_id BIGINT NOT NULL, - status ENUM('LOCKED', 'NOT_STARTED', 'IN_PROGRESS', 'COMPLETED') NOT NULL DEFAULT 'LOCKED', +CREATE TABLE pupil_stop_progress +( + pupil_user_id BIGINT NOT NULL, + classroom_id BIGINT NOT NULL, + stop_id BIGINT NOT NULL, + status ENUM('LOCKED', + 'NOT_STARTED', + 'IN_PROGRESS', + 'COMPLETED' +) NOT NULL DEFAULT 'LOCKED', current_task_id BIGINT, best_score INT NOT NULL DEFAULT 0, medal_awarded BOOLEAN NOT NULL DEFAULT FALSE, diff --git a/src/main/resources/db/migration/V9__create_tasks_and_items.sql b/src/main/resources/db/migration/V9__create_tasks_and_items.sql index 17cd7e19..98d2d3bf 100644 --- a/src/main/resources/db/migration/V9__create_tasks_and_items.sql +++ b/src/main/resources/db/migration/V9__create_tasks_and_items.sql @@ -1,17 +1,19 @@ -CREATE TABLE tasks ( - id BIGINT NOT NULL AUTO_INCREMENT, - stop_id BIGINT NOT NULL, - classroom_id BIGINT NOT NULL, - created_by_teacher_id BIGINT NOT NULL, - title VARCHAR(255) NOT NULL, - intro_text TEXT, - difficulty_level INT NOT NULL, - task_type VARCHAR(100) NOT NULL, - passing_rule VARCHAR(100) NOT NULL, - passing_score INT, - max_score INT NOT NULL, - is_published BOOLEAN NOT NULL DEFAULT FALSE, - created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), +CREATE TABLE tasks +( + id BIGINT NOT NULL AUTO_INCREMENT, + stop_id BIGINT NOT NULL, + classroom_id BIGINT NOT NULL, + created_by_teacher_id BIGINT NOT NULL, + title VARCHAR(255) NOT NULL, + intro_text TEXT, + difficulty_level INT NOT NULL, + task_type VARCHAR(100) NOT NULL, + passing_rule VARCHAR(100) NOT NULL, + passing_score INT, + max_score INT NOT NULL, + is_published BOOLEAN NOT NULL DEFAULT FALSE, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6 +) , updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), CONSTRAINT pk_tasks PRIMARY KEY (id), CONSTRAINT fk_tasks_classroom @@ -30,12 +32,13 @@ CREATE INDEX idx_tasks_classroom_stop ON tasks (classroom_id, stop_id); CREATE INDEX idx_tasks_created_by_teacher ON tasks (created_by_teacher_id); CREATE INDEX idx_tasks_published ON tasks (classroom_id, is_published); -CREATE TABLE task_items ( - id BIGINT NOT NULL AUTO_INCREMENT, - task_id BIGINT NOT NULL, - item_order INT NOT NULL, - prompt_text TEXT NOT NULL, - media_url VARCHAR(2048), +CREATE TABLE task_items +( + id BIGINT NOT NULL AUTO_INCREMENT, + task_id BIGINT NOT NULL, + item_order INT NOT NULL, + prompt_text TEXT NOT NULL, + media_url VARCHAR(2048), metadata_json TEXT, CONSTRAINT pk_task_items PRIMARY KEY (id), CONSTRAINT fk_task_items_task @@ -46,12 +49,13 @@ CREATE TABLE task_items ( CREATE INDEX idx_task_items_task_id ON task_items (task_id); -CREATE TABLE task_options ( - id BIGINT NOT NULL AUTO_INCREMENT, - task_item_id BIGINT NOT NULL, - option_order INT NOT NULL, - option_text TEXT NOT NULL, - is_correct BOOLEAN NOT NULL DEFAULT FALSE, +CREATE TABLE task_options +( + id BIGINT NOT NULL AUTO_INCREMENT, + task_item_id BIGINT NOT NULL, + option_order INT NOT NULL, + option_text TEXT NOT NULL, + is_correct BOOLEAN NOT NULL DEFAULT FALSE, explanation_text TEXT, CONSTRAINT pk_task_options PRIMARY KEY (id), CONSTRAINT fk_task_options_task_item diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/BackendApplicationTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/BackendApplicationTests.kt index 5b79b820..38d50791 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/BackendApplicationTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/BackendApplicationTests.kt @@ -5,9 +5,7 @@ import org.springframework.boot.test.context.SpringBootTest @SpringBootTest class BackendApplicationTests { - @Test fun contextLoads() { } - } diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/classroom/ClassroomIntegrationTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/classroom/ClassroomIntegrationTests.kt index 03f3c4ae..aa1f966f 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/classroom/ClassroomIntegrationTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/classroom/ClassroomIntegrationTests.kt @@ -3,9 +3,9 @@ package edu.ntnu.idi.idatt.backend.classroom import com.fasterxml.jackson.databind.json.JsonMapper import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom import edu.ntnu.idi.idatt.backend.classroom.domain.ClassroomStatus +import edu.ntnu.idi.idatt.backend.classroom.infrastructure.ClassroomRepository import edu.ntnu.idi.idatt.backend.game.gameProgress.domain.PupilGameProgress import edu.ntnu.idi.idatt.backend.game.gameProgress.domain.PupilGameProgressId -import edu.ntnu.idi.idatt.backend.classroom.infrastructure.ClassroomRepository import edu.ntnu.idi.idatt.backend.game.gameProgress.infrastructure.PupilGameProgressRepository import edu.ntnu.idi.idatt.backend.iam.domain.User import edu.ntnu.idi.idatt.backend.iam.domain.UserRole @@ -83,11 +83,12 @@ class ClassroomIntegrationTests { @Test fun `join classroom requires authentication`() { - mockMvc.post("/api/v1/classrooms/join") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"classroomCode":"ABC123"}""" - }.andExpect { + mockMvc + .post("/api/v1/classrooms/join") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"classroomCode":"ABC123"}""" + }.andExpect { status { isUnauthorized() } } } @@ -99,40 +100,43 @@ class ClassroomIntegrationTests { pupilProfileRepository.save(PupilProfile(user = pupil, displayName = "ada", currentClassroom = classroom)) val accessToken = pupilAccessToken(username = "ada", password = "secret123") + mockMvc + .post("/api/v1/classrooms/join") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + content = """{"classroomCode":"CLASS42"}""" + }.andExpect { + status { isOk() } + jsonPath("$.classroomId") { value(classroom.id.toInt()) } + jsonPath("$.classroomTitle") { value("Class CLASS42") } + jsonPath("$.membershipStatus") { value("PENDING") } + jsonPath("$.message") { value("Join request submitted") } + } - mockMvc.post("/api/v1/classrooms/join") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - content = """{"classroomCode":"CLASS42"}""" - }.andExpect { - status { isOk() } - jsonPath("$.classroomId") { value(classroom.id.toInt()) } - jsonPath("$.classroomTitle") { value("Class CLASS42") } - jsonPath("$.membershipStatus") { value("PENDING") } - jsonPath("$.message") { value("Join request submitted") } - } - - val updatedProfile = pupilProfileRepository - .findById(requireNotNull(pupil.id)) - .orElseThrow() + val updatedProfile = + pupilProfileRepository + .findById(requireNotNull(pupil.id)) + .orElseThrow() assertEquals(classroom.id, updatedProfile.currentClassroom?.id) - val membershipCount = jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM classroom_pupils WHERE classroom_id = ? AND pupil_user_id = ?", - Int::class.java, - classroom.id, - requireNotNull(pupil.id), - ) ?: 0 + val membershipCount = + jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM classroom_pupils WHERE classroom_id = ? AND pupil_user_id = ?", + Int::class.java, + classroom.id, + requireNotNull(pupil.id), + ) ?: 0 assertEquals(1, membershipCount) - val progressCount = jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM pupil_game_progress WHERE classroom_id = ? AND pupil_user_id = ?", - Int::class.java, - classroom.id, - requireNotNull(pupil.id), - ) ?: 0 + val progressCount = + jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM pupil_game_progress WHERE classroom_id = ? AND pupil_user_id = ?", + Int::class.java, + classroom.id, + requireNotNull(pupil.id), + ) ?: 0 assertEquals(1, progressCount) } @@ -143,59 +147,64 @@ class ClassroomIntegrationTests { pupilProfileRepository.save(PupilProfile(user = pupil, displayName = "padding", currentClassroom = classroom)) val accessToken = pupilAccessToken(username = "padding-check", password = "secret123") - mockMvc.post("/api/v1/classrooms/join") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - content = """{"classroomCode":" class42 "}""" - }.andExpect { - status { isOk() } - jsonPath("$.classroomTitle") { value("Class CLASS42") } - jsonPath("$.membershipStatus") { value("PENDING") } - } + mockMvc + .post("/api/v1/classrooms/join") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + content = """{"classroomCode":" class42 "}""" + }.andExpect { + status { isOk() } + jsonPath("$.classroomTitle") { value("Class CLASS42") } + jsonPath("$.membershipStatus") { value("PENDING") } + } - val updatedProfile = pupilProfileRepository - .findById(requireNotNull(pupil.id)) - .orElseThrow() + val updatedProfile = + pupilProfileRepository + .findById(requireNotNull(pupil.id)) + .orElseThrow() assertEquals( classroomRepository.findByJoinCode("CLASS42")!!.id, - updatedProfile.currentClassroom?.id + updatedProfile.currentClassroom?.id, ) - val membershipCount = jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM classroom_pupils cp JOIN users u ON u.id = cp.pupil_user_id WHERE cp.classroom_id = (SELECT id FROM classrooms WHERE join_code = ?) AND u.username = ?", - Int::class.java, - "CLASS42", - "padding-check", - ) ?: 0 + val membershipCount = + jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM classroom_pupils cp JOIN users u ON u.id = cp.pupil_user_id WHERE cp.classroom_id = (SELECT id FROM classrooms WHERE join_code = ?) AND u.username = ?", + Int::class.java, + "CLASS42", + "padding-check", + ) ?: 0 assertEquals(1, membershipCount) - val progressCount = jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM pupil_game_progress pgp JOIN users u ON u.id = pgp.pupil_user_id WHERE pgp.classroom_id = (SELECT id FROM classrooms WHERE join_code = ?) AND u.username = ?", - Int::class.java, - "CLASS42", - "padding-check", - ) ?: 0 + val progressCount = + jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM pupil_game_progress pgp JOIN users u ON u.id = pgp.pupil_user_id WHERE pgp.classroom_id = (SELECT id FROM classrooms WHERE join_code = ?) AND u.username = ?", + Int::class.java, + "CLASS42", + "padding-check", + ) ?: 0 assertEquals(1, progressCount) } @Test fun `teacher can list owned classrooms`() { val owner = userRepository.save(activeTeacher(email = "teacher-owned@example.com")) - classroomRepository.save(Classroom(ownerTeacherId = requireNotNull(owner.id), title = "Alpha classroom", description = "Owned by teacher", joinCode = "OWN01", status = ClassroomStatus.ACTIVE, createdAt = Instant.now(), updatedAt = Instant.now(),),) - classroomRepository.save(Classroom(ownerTeacherId = requireNotNull(owner.id), title = "Beta classroom", description = "Owned by teacher", joinCode = "OWN02", status = ClassroomStatus.ACTIVE, createdAt = Instant.now(), updatedAt = Instant.now()),) + classroomRepository.save(Classroom(ownerTeacherId = requireNotNull(owner.id), title = "Alpha classroom", description = "Owned by teacher", joinCode = "OWN01", status = ClassroomStatus.ACTIVE, createdAt = Instant.now(), updatedAt = Instant.now())) + classroomRepository.save(Classroom(ownerTeacherId = requireNotNull(owner.id), title = "Beta classroom", description = "Owned by teacher", joinCode = "OWN02", status = ClassroomStatus.ACTIVE, createdAt = Instant.now(), updatedAt = Instant.now())) createClassroom("OTHER1") val accessToken = teacherPortalAccessToken(email = "teacher-owned@example.com", password = "secret123") - mockMvc.get("/api/v1/classrooms/owned") { - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - }.andExpect { - status { isOk() } - jsonPath("$.length()") { value(2) } - jsonPath("$[0].title") { value("Alpha classroom") } - jsonPath("$[1].title") { value("Beta classroom") } - } + mockMvc + .get("/api/v1/classrooms/owned") { + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + }.andExpect { + status { isOk() } + jsonPath("$.length()") { value(2) } + jsonPath("$[0].title") { value("Alpha classroom") } + jsonPath("$[1].title") { value("Beta classroom") } + } } @Test @@ -204,14 +213,15 @@ class ClassroomIntegrationTests { userRepository.save(activeTeacher(email = "teacher@example.com")) val accessToken = teacherPortalAccessToken(email = "teacher@example.com", password = "secret123") - mockMvc.post("/api/v1/classrooms/join") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - content = """{"classroomCode":"AB12"}""" - }.andExpect { - status { isForbidden() } - } + mockMvc + .post("/api/v1/classrooms/join") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + content = """{"classroomCode":"AB12"}""" + }.andExpect { + status { isForbidden() } + } } @Test @@ -220,14 +230,15 @@ class ClassroomIntegrationTests { userRepository.save(activeAdmin(email = "admin@example.com")) val accessToken = teacherPortalAccessToken(email = "admin@example.com", password = "secret123") - mockMvc.post("/api/v1/classrooms/join") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - content = """{"classroomCode":"ABC123"}""" - }.andExpect { - status { isForbidden() } - } + mockMvc + .post("/api/v1/classrooms/join") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + content = """{"classroomCode":"ABC123"}""" + }.andExpect { + status { isForbidden() } + } } @Test @@ -235,14 +246,15 @@ class ClassroomIntegrationTests { userRepository.save(activePupil(username = "bodycheck")) val accessToken = pupilAccessToken(username = "bodycheck", password = "secret123") - mockMvc.post("/api/v1/classrooms/join") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - content = """{"classroomCode":" "}""" - }.andExpect { - status { isBadRequest() } - } + mockMvc + .post("/api/v1/classrooms/join") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + content = """{"classroomCode":" "}""" + }.andExpect { + status { isBadRequest() } + } } @Test @@ -252,22 +264,24 @@ class ClassroomIntegrationTests { pupilProfileRepository.save(PupilProfile(user = pupil, displayName = "get", currentClassroom = classroom)) val token = pupilAccessToken(username = "getuser", password = "secret123") - mockMvc.post("/api/v1/classrooms/join") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer $token") - content = """{"classroomCode":"GET1"}""" - }.andExpect { - status { isOk() } - } + mockMvc + .post("/api/v1/classrooms/join") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer $token") + content = """{"classroomCode":"GET1"}""" + }.andExpect { + status { isOk() } + } - mockMvc.get("/api/v1/classrooms") { - header(HttpHeaders.AUTHORIZATION, "Bearer $token") - }.andExpect { - status { isOk() } - jsonPath("$[0].classroomId") { value(classroom.id.toInt()) } - jsonPath("$[0].title") { value("Class GET1") } - } + mockMvc + .get("/api/v1/classrooms") { + header(HttpHeaders.AUTHORIZATION, "Bearer $token") + }.andExpect { + status { isOk() } + jsonPath("$[0].classroomId") { value(classroom.id.toInt()) } + jsonPath("$[0].title") { value("Class GET1") } + } } @Test @@ -277,22 +291,24 @@ class ClassroomIntegrationTests { pupilProfileRepository.save(PupilProfile(user = pupil, displayName = "detail", currentClassroom = classroom)) val token = pupilAccessToken(username = "detailuser", password = "secret123") - mockMvc.post("/api/v1/classrooms/join") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer $token") - content = """{"classroomCode":"DETAIL1"}""" - }.andExpect { - status { isOk() } - } + mockMvc + .post("/api/v1/classrooms/join") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer $token") + content = """{"classroomCode":"DETAIL1"}""" + }.andExpect { + status { isOk() } + } - mockMvc.get("/api/v1/classrooms/${classroom.id}") { - header(HttpHeaders.AUTHORIZATION, "Bearer $token") - }.andExpect { - status { isOk() } - jsonPath("$.id") { value(classroom.id.toInt()) } - jsonPath("$.title") { value("Class DETAIL1") } - } + mockMvc + .get("/api/v1/classrooms/${classroom.id}") { + header(HttpHeaders.AUTHORIZATION, "Bearer $token") + }.andExpect { + status { isOk() } + jsonPath("$.id") { value(classroom.id.toInt()) } + jsonPath("$.title") { value("Class DETAIL1") } + } } @Test @@ -300,23 +316,25 @@ class ClassroomIntegrationTests { val teacher = userRepository.save(activeTeacher("teacher-create@test.com")) val token = teacherPortalAccessToken("teacher-create@test.com", "secret123") - val request = """ + val request = + """ { "title": "Test Classroom", "description": "Test Description" } """.trimIndent() - mockMvc.post("/api/v1/classrooms") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer $token") - content = request - }.andExpect { - status { isOk() } - jsonPath("$.title") { value("Test Classroom") } - jsonPath("$.description") { value("Test Description") } - } + mockMvc + .post("/api/v1/classrooms") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer $token") + content = request + }.andExpect { + status { isOk() } + jsonPath("$.title") { value("Test Classroom") } + jsonPath("$.description") { value("Test Description") } + } } @Test @@ -326,24 +344,26 @@ class ClassroomIntegrationTests { val ownerEmail = "owner-UPDATE1@example.com" val token = teacherPortalAccessToken(ownerEmail, "secret123") - val request = """ + val request = + """ { "title": "Updated Classroom", "description": "Updated Classroom Description", "teacherEmails": [] } - """.trimIndent() + """.trimIndent() - mockMvc.put("/api/v1/classrooms/${classroom.id}") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer $token") - content = request - }.andExpect { - status { isOk() } - jsonPath("$.title") { value("Updated Classroom") } - jsonPath("$.description") { value("Updated Classroom Description") } - } + mockMvc + .put("/api/v1/classrooms/${classroom.id}") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer $token") + content = request + }.andExpect { + status { isOk() } + jsonPath("$.title") { value("Updated Classroom") } + jsonPath("$.description") { value("Updated Classroom Description") } + } } @Test @@ -362,12 +382,13 @@ class ClassroomIntegrationTests { content = """{"classroomCode":"APPROVE1"}""" } - mockMvc.post("/api/v1/classrooms/${classroom.id}/pupils/${pupil.id}/approve") { - with(csrf()) - header(HttpHeaders.AUTHORIZATION, "Bearer $teacherToken") - }.andExpect { - status { isOk() } - } + mockMvc + .post("/api/v1/classrooms/${classroom.id}/pupils/${pupil.id}/approve") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer $teacherToken") + }.andExpect { + status { isOk() } + } } @Test @@ -383,26 +404,28 @@ class ClassroomIntegrationTests { contentType = MediaType.APPLICATION_JSON header( HttpHeaders.AUTHORIZATION, - "Bearer ${pupilAccessToken("removeuser", "secret123")}" + "Bearer ${pupilAccessToken("removeuser", "secret123")}", ) content = """{"classroomCode":"REMOVE1"}""" } val teacherToken = teacherPortalAccessToken("owner-REMOVE1@example.com", "secret123") - mockMvc.delete("/api/v1/classrooms/${classroom.id}/pupils/${pupil.id}") { - with(csrf()) - header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken}") - }.andExpect { - status { isOk() } - } + mockMvc + .delete("/api/v1/classrooms/${classroom.id}/pupils/${pupil.id}") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer $teacherToken") + }.andExpect { + status { isOk() } + } - val count = jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM classroom_pupils WHERE classroom_id = ? AND pupil_user_id = ?", - Int::class.java, - classroom.id, - pupil.id - ) ?: 0 + val count = + jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM classroom_pupils WHERE classroom_id = ? AND pupil_user_id = ?", + Int::class.java, + classroom.id, + pupil.id, + ) ?: 0 assertEquals(0, count) } @@ -425,28 +448,31 @@ class ClassroomIntegrationTests { pupilGameProgressRepository.saveAll( listOf( PupilGameProgress( - id = PupilGameProgressId( - pupilUserId = requireNotNull(firstPupil.id), - classroomId = firstClassroom.id, - ), + id = + PupilGameProgressId( + pupilUserId = requireNotNull(firstPupil.id), + classroomId = firstClassroom.id, + ), pupil = firstPupil, classroom = firstClassroom, totalScore = 10, ), PupilGameProgress( - id = PupilGameProgressId( - pupilUserId = requireNotNull(secondPupil.id), - classroomId = firstClassroom.id, - ), + id = + PupilGameProgressId( + pupilUserId = requireNotNull(secondPupil.id), + classroomId = firstClassroom.id, + ), pupil = secondPupil, classroom = firstClassroom, totalScore = 20, ), PupilGameProgress( - id = PupilGameProgressId( - pupilUserId = requireNotNull(thirdPupil.id), - classroomId = secondClassroom.id, - ), + id = + PupilGameProgressId( + pupilUserId = requireNotNull(thirdPupil.id), + classroomId = secondClassroom.id, + ), pupil = thirdPupil, classroom = secondClassroom, totalScore = 30, @@ -460,7 +486,6 @@ class ClassroomIntegrationTests { assertEquals(15.0, averages.first { it.classroomId == firstClassroom.id }.averageTotalScore) assertEquals(30.0, averages.first { it.classroomId == secondClassroom.id }.averageTotalScore) assertEquals(0.0, averages.first { it.classroomId == emptyClassroom.id }.averageTotalScore) - } private fun createClassroom(classroomCode: String): Classroom { @@ -478,27 +503,41 @@ class ClassroomIntegrationTests { ) } - private fun pupilAccessToken(username: String, password: String): String = - jsonMapper.readTree( - mockMvc.post("/api/v1/auth/pupil/login") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"username":"$username","password":"$password"}""" - }.andExpect { - status { isOk() } - }.andReturn().response.contentAsString, - ).get("accessToken").asText() - - private fun teacherPortalAccessToken(email: String, password: String): String = - jsonMapper.readTree( - mockMvc.post("/api/v1/auth/teacher/login") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"email":"$email","password":"$password"}""" - }.andExpect { - status { isOk() } - }.andReturn().response.contentAsString, - ).get("accessToken").asText() + private fun pupilAccessToken( + username: String, + password: String, + ): String = + jsonMapper + .readTree( + mockMvc + .post("/api/v1/auth/pupil/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"username":"$username","password":"$password"}""" + }.andExpect { + status { isOk() } + }.andReturn() + .response.contentAsString, + ).get("accessToken") + .asText() + + private fun teacherPortalAccessToken( + email: String, + password: String, + ): String = + jsonMapper + .readTree( + mockMvc + .post("/api/v1/auth/teacher/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"email":"$email","password":"$password"}""" + }.andExpect { + status { isOk() } + }.andReturn() + .response.contentAsString, + ).get("accessToken") + .asText() private fun activePupil(username: String): User = User( @@ -515,6 +554,7 @@ class ClassroomIntegrationTests { role = UserRole.TEACHER, status = UserStatus.ACTIVE, ) + private fun activeAdmin(email: String): User = User( email = email, @@ -522,7 +562,4 @@ class ClassroomIntegrationTests { role = UserRole.ADMIN, status = UserStatus.ACTIVE, ) - } - - diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/classroom/ClassroomServiceTest.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/classroom/ClassroomServiceTest.kt index c6d53f41..f2913e7c 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/classroom/ClassroomServiceTest.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/classroom/ClassroomServiceTest.kt @@ -11,16 +11,16 @@ import edu.ntnu.idi.idatt.backend.classroom.domain.ClassroomPupilStatus import edu.ntnu.idi.idatt.backend.classroom.domain.ClassroomStatus import edu.ntnu.idi.idatt.backend.classroom.infrastructure.ClassroomRepository import edu.ntnu.idi.idatt.backend.game.gameProgress.application.PupilGameProgressService -import edu.ntnu.idi.idatt.backend.iam.application.ClassroomUserResolver import edu.ntnu.idi.idatt.backend.game.tasks.infrastructure.TaskRepository +import edu.ntnu.idi.idatt.backend.iam.application.ClassroomUserResolver import edu.ntnu.idi.idatt.backend.iam.application.CurrentUserResolver import edu.ntnu.idi.idatt.backend.iam.domain.User import edu.ntnu.idi.idatt.backend.iam.domain.UserRole import edu.ntnu.idi.idatt.backend.iam.domain.UserStatus import edu.ntnu.idi.idatt.backend.iam.infrastructure.UserRepository +import edu.ntnu.idi.idatt.backend.shared.events.DomainEventPublisher import edu.ntnu.idi.idatt.backend.users.profile.domain.PupilProfile import edu.ntnu.idi.idatt.backend.users.profile.infrastructure.PupilProfileRepository -import edu.ntnu.idi.idatt.backend.shared.events.DomainEventPublisher import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach @@ -33,10 +33,8 @@ import org.mockito.Mockito.verify import org.mockito.Mockito.`when` import org.springframework.http.HttpStatus import org.springframework.web.server.ResponseStatusException -import org.mockito.Mockito.any import java.util.Optional - class ClassroomServiceTest { private lateinit var classroomRepository: ClassroomRepository private lateinit var currentUserResolver: CurrentUserResolver @@ -58,32 +56,34 @@ class ClassroomServiceTest { taskRepository = mock(TaskRepository::class.java) pupilProfileRepository = mock(PupilProfileRepository::class.java) eventPublisher = mock(DomainEventPublisher::class.java) - classroomService = ClassroomService( - classroomRepository, - currentUserResolver, - userRepository, - classroomUserResolver, - pupilGameProgressService, - taskRepository, - pupilProfileRepository, - eventPublisher, - ) + classroomService = + ClassroomService( + classroomRepository, + currentUserResolver, + userRepository, + classroomUserResolver, + pupilGameProgressService, + taskRepository, + pupilProfileRepository, + eventPublisher, + ) } @Test fun `should create classroom and assign teacher`() { - val teacher = User(id = 1, email = "teacher@test.com", username = "teacher", passwordHash = "pw", role = UserRole.TEACHER, status = UserStatus.ACTIVE,) + val teacher = User(id = 1, email = "teacher@test.com", username = "teacher", passwordHash = "pw", role = UserRole.TEACHER, status = UserStatus.ACTIVE) `when`(currentUserResolver.requireTeacherOrAdmin("1")).thenReturn(teacher) `when`(classroomRepository.save(any())).thenAnswer { it.arguments[0] } - val result = classroomService.createClassroom( - CreateClassroomCommand( - title = "Math", - description = "description", - authenticatedSubject = "1" + val result = + classroomService.createClassroom( + CreateClassroomCommand( + title = "Math", + description = "description", + authenticatedSubject = "1", + ), ) - ) assertEquals("Math", result.title) assertEquals("description", result.description) @@ -99,7 +99,7 @@ class ClassroomServiceTest { val user = User(id = 3, email = "test@example.com", username = "user", passwordHash = "password", role = UserRole.PUPIL, status = UserStatus.ACTIVE) - val pupilProfile = PupilProfile(userId = 3, user = user, displayName = "test", currentClassroom = null,) + val pupilProfile = PupilProfile(userId = 3, user = user, displayName = "test", currentClassroom = null) `when`(classroomRepository.findByJoinCode(classroomCode)).thenReturn(classroom) `when`(currentUserResolver.requirePupil("3")).thenReturn(user) @@ -130,9 +130,10 @@ class ClassroomServiceTest { `when`(currentUserResolver.requirePupil("3")).thenReturn(user) val command = JoinClassroomCommand(classroomCode = classroomCode, authenticatedSubject = "3") - val exception = org.junit.jupiter.api.Assertions.assertThrows(ResponseStatusException::class.java) { - classroomService.joinClassroom(command) - } + val exception = + org.junit.jupiter.api.Assertions.assertThrows(ResponseStatusException::class.java) { + classroomService.joinClassroom(command) + } assertEquals(HttpStatus.CONFLICT, exception.statusCode) verify(classroomRepository, never()).save(classroom) @@ -148,9 +149,10 @@ class ClassroomServiceTest { ) val command = JoinClassroomCommand(classroomCode = classroomCode, authenticatedSubject = "3") - val exception = org.junit.jupiter.api.Assertions.assertThrows(ResponseStatusException::class.java) { - classroomService.joinClassroom(command) - } + val exception = + org.junit.jupiter.api.Assertions.assertThrows(ResponseStatusException::class.java) { + classroomService.joinClassroom(command) + } assertEquals(HttpStatus.NOT_FOUND, exception.statusCode) verify(pupilGameProgressService, never()).createPupilGameProgressEntry(anyLong(), anyLong()) } @@ -166,16 +168,17 @@ class ClassroomServiceTest { classroom = classroom, pupil = user, status = ClassroomPupilStatus.PENDING, - ) + ), ) `when`(classroomRepository.findByJoinCode(classroomCode)).thenReturn(classroom) `when`(currentUserResolver.requirePupil("3")).thenReturn(user) val command = JoinClassroomCommand(classroomCode = classroomCode, authenticatedSubject = "3") - val exception = org.junit.jupiter.api.Assertions.assertThrows(ResponseStatusException::class.java) { - classroomService.joinClassroom(command) - } + val exception = + org.junit.jupiter.api.Assertions.assertThrows(ResponseStatusException::class.java) { + classroomService.joinClassroom(command) + } assertEquals(HttpStatus.CONFLICT, exception.statusCode) verify(pupilGameProgressService, never()).createPupilGameProgressEntry(anyLong(), anyLong()) @@ -193,9 +196,10 @@ class ClassroomServiceTest { ) val command = JoinClassroomCommand(classroomCode = classroomCode, authenticatedSubject = "3") - val exception = org.junit.jupiter.api.Assertions.assertThrows(ResponseStatusException::class.java) { - classroomService.joinClassroom(command) - } + val exception = + org.junit.jupiter.api.Assertions.assertThrows(ResponseStatusException::class.java) { + classroomService.joinClassroom(command) + } assertEquals(HttpStatus.FORBIDDEN, exception.statusCode) verify(classroomRepository, never()).save(classroom) @@ -214,9 +218,10 @@ class ClassroomServiceTest { ) val command = JoinClassroomCommand(classroomCode = classroomCode, authenticatedSubject = "3") - val exception = org.junit.jupiter.api.Assertions.assertThrows(ResponseStatusException::class.java) { - classroomService.joinClassroom(command) - } + val exception = + org.junit.jupiter.api.Assertions.assertThrows(ResponseStatusException::class.java) { + classroomService.joinClassroom(command) + } assertEquals(HttpStatus.FORBIDDEN, exception.statusCode) verify(classroomRepository, never()).save(classroom) @@ -230,9 +235,10 @@ class ClassroomServiceTest { ) val command = JoinClassroomCommand(classroomCode = "ABC123", authenticatedSubject = "not-a-number") - val exception = org.junit.jupiter.api.Assertions.assertThrows(ResponseStatusException::class.java) { - classroomService.joinClassroom(command) - } + val exception = + org.junit.jupiter.api.Assertions.assertThrows(ResponseStatusException::class.java) { + classroomService.joinClassroom(command) + } assertEquals(HttpStatus.UNAUTHORIZED, exception.statusCode) verify(pupilGameProgressService, never()).createPupilGameProgressEntry(anyLong(), anyLong()) @@ -244,7 +250,7 @@ class ClassroomServiceTest { val user = User(id = 3, email = "test@example.com", username = "user", passwordHash = "password", role = UserRole.PUPIL, status = UserStatus.ACTIVE) - val pupilProfile = PupilProfile(userId = 3, user = user, displayName = "test", currentClassroom = null,) + val pupilProfile = PupilProfile(userId = 3, user = user, displayName = "test", currentClassroom = null) `when`(classroomRepository.findByJoinCode("ABC123")).thenReturn(classroom) `when`(currentUserResolver.requirePupil("3")).thenReturn(user) @@ -263,12 +269,12 @@ class ClassroomServiceTest { fun `should be able to approve students`() { val classroom = Classroom(id = 1, ownerTeacherId = 2, title = "class", description = "description", joinCode = "ABC123", status = ClassroomStatus.ACTIVE) val pupil = User(id = 3, email = null, username = "pupil", passwordHash = "password", role = UserRole.PUPIL, status = UserStatus.ACTIVE) - val teacher = User(id = 2, email = "test@test.com", username = "teacher", passwordHash = "password", role = UserRole.TEACHER, status = UserStatus.ACTIVE ) + val teacher = User(id = 2, email = "test@test.com", username = "teacher", passwordHash = "password", role = UserRole.TEACHER, status = UserStatus.ACTIVE) classroom.addTeacher(teacher) classroom.addPupilMembership(pupil) - `when`(classroomRepository.findById(1)).thenReturn(java.util.Optional.of(classroom)) + `when`(classroomRepository.findById(1)).thenReturn(Optional.of(classroom)) `when`(currentUserResolver.requireTeacherOrAdmin("teacher")).thenReturn(teacher) classroomService.approvePupil(1, 3, "teacher") @@ -282,13 +288,14 @@ class ClassroomServiceTest { @Test fun `should throw when approving pupil in non-existing classroom`() { - val teacher = User(id = 2, email = "test@test.com", username = "teacher", passwordHash = "password", role = UserRole.TEACHER, status = UserStatus.ACTIVE ) + val teacher = User(id = 2, email = "test@test.com", username = "teacher", passwordHash = "password", role = UserRole.TEACHER, status = UserStatus.ACTIVE) `when`(currentUserResolver.requireTeacherOrAdmin("teacher")).thenReturn(teacher) - `when`(classroomRepository.findById(1)).thenReturn(java.util.Optional.empty()) + `when`(classroomRepository.findById(1)).thenReturn(Optional.empty()) - val exception = org.junit.jupiter.api.Assertions.assertThrows(ResponseStatusException::class.java) { - classroomService.approvePupil(1, 3, "teacher") - } + val exception = + org.junit.jupiter.api.Assertions.assertThrows(ResponseStatusException::class.java) { + classroomService.approvePupil(1, 3, "teacher") + } assertEquals(HttpStatus.NOT_FOUND, exception.statusCode) verify(classroomRepository, never()).save(any()) @@ -298,14 +305,14 @@ class ClassroomServiceTest { fun `should remove pupil from classroom`() { val classroom = Classroom(id = 1, ownerTeacherId = 2, title = "class", description = "description", joinCode = "ABC123", status = ClassroomStatus.ACTIVE) val pupil = User(id = 3, email = null, username = "pupil", passwordHash = "password", role = UserRole.PUPIL, status = UserStatus.ACTIVE) - val teacher = User(id = 2, email = "test@test.com", username = "teacher", passwordHash = "password", role = UserRole.TEACHER, status = UserStatus.ACTIVE ) + val teacher = User(id = 2, email = "test@test.com", username = "teacher", passwordHash = "password", role = UserRole.TEACHER, status = UserStatus.ACTIVE) classroom.addTeacher(teacher) classroom.addPupilMembership(pupil) - `when`(classroomRepository.findById(1)).thenReturn(java.util.Optional.of(classroom)) + `when`(classroomRepository.findById(1)).thenReturn(Optional.of(classroom)) `when`(currentUserResolver.requireTeacherOrAdmin("teacher")).thenReturn(teacher) - classroomService.removePupil(1, 3, "teacher") + classroomService.removePupil(1, 3, "teacher") verify(classroomRepository).findById(1) verify(classroomRepository).save(classroom) @@ -315,14 +322,14 @@ class ClassroomServiceTest { @Test fun `should throw when removing pupil from non-existing classroom`() { - val teacher = User(id = 2, email = "test@test.com", username = "teacher", passwordHash = "password", role = UserRole.TEACHER, status = UserStatus.ACTIVE ) + val teacher = User(id = 2, email = "test@test.com", username = "teacher", passwordHash = "password", role = UserRole.TEACHER, status = UserStatus.ACTIVE) `when`(currentUserResolver.requireTeacherOrAdmin("teacher")).thenReturn(teacher) - `when`(classroomRepository.findById(1)).thenReturn(java.util.Optional.empty()) - + `when`(classroomRepository.findById(1)).thenReturn(Optional.empty()) - val exception = org.junit.jupiter.api.Assertions.assertThrows(ResponseStatusException::class.java) { - classroomService.removePupil(1, 3, "teacher") - } + val exception = + org.junit.jupiter.api.Assertions.assertThrows(ResponseStatusException::class.java) { + classroomService.removePupil(1, 3, "teacher") + } assertEquals(HttpStatus.NOT_FOUND, exception.statusCode) verify(classroomRepository, never()).save(any()) @@ -333,7 +340,7 @@ class ClassroomServiceTest { val classroom = Classroom(id = 1, ownerTeacherId = 2, title = "old", description = "old", joinCode = "ABC123", status = ClassroomStatus.ACTIVE) val teacher = User(id = 10, email = "teacher@test.com", username = "teacher", passwordHash = "password", role = UserRole.TEACHER, status = UserStatus.ACTIVE) - `when`(classroomRepository.findById(1)).thenReturn(java.util.Optional.of(classroom)) + `when`(classroomRepository.findById(1)).thenReturn(Optional.of(classroom)) `when`(userRepository.findAllByEmailIgnoreCaseIn(listOf("teacher@test.com"))).thenReturn(listOf(teacher)) `when`(classroomRepository.save(any())).thenAnswer { it.arguments[0] } @@ -351,19 +358,20 @@ class ClassroomServiceTest { @Test fun `should throw when updating classroom with unknown teacher emails`() { val classroom = Classroom(id = 1, ownerTeacherId = 2, title = "old", description = "old", joinCode = "ABC123", status = ClassroomStatus.ACTIVE) - val teacher = User(id = 2, email = "test@test.com", username = "teacher", passwordHash = "password", role = UserRole.TEACHER, status = UserStatus.ACTIVE ) + val teacher = User(id = 2, email = "test@test.com", username = "teacher", passwordHash = "password", role = UserRole.TEACHER, status = UserStatus.ACTIVE) `when`(currentUserResolver.requireTeacherOrAdmin("teacher")).thenReturn(teacher) - `when`(classroomUserResolver.requireClassroomOwner(teacher, 1L)).thenAnswer { } + `when`(classroomUserResolver.requireClassroomOwner(teacher, 1L)).thenAnswer { } - `when`(classroomRepository.findById(1)).thenReturn(java.util.Optional.of(classroom)) + `when`(classroomRepository.findById(1)).thenReturn(Optional.of(classroom)) `when`(userRepository.findAllByEmailIgnoreCaseIn(listOf("missing@test.com"))).thenReturn(emptyList()) val command = UpdateClassroomCommand(classroomId = 1, title = "new", description = "new", teacherEmails = listOf("missing@test.com"), authenticatedSubject = "2") - val exception = org.junit.jupiter.api.Assertions.assertThrows(ResponseStatusException::class.java) { - classroomService.updateClassroom(command) - } + val exception = + org.junit.jupiter.api.Assertions.assertThrows(ResponseStatusException::class.java) { + classroomService.updateClassroom(command) + } assertEquals(HttpStatus.CONFLICT, exception.statusCode) verify(classroomRepository, never()).save(any(Classroom::class.java)) @@ -371,11 +379,12 @@ class ClassroomServiceTest { @Test fun `should return all classrooms`() { - val classrooms = listOf( - Classroom(id = 1, ownerTeacherId = 2, title = "classroom 1", description = "description", joinCode = "ABC123", status = ClassroomStatus.ACTIVE), - Classroom(id = 3, ownerTeacherId = 2, title = "classroom 2", description = "description", joinCode = "123ABC", status = ClassroomStatus.ACTIVE) - ) - val teacher = User(id = 2, email = "test@test.com", username = "teacher", passwordHash = "password", role = UserRole.TEACHER, status = UserStatus.ACTIVE ) + val classrooms = + listOf( + Classroom(id = 1, ownerTeacherId = 2, title = "classroom 1", description = "description", joinCode = "ABC123", status = ClassroomStatus.ACTIVE), + Classroom(id = 3, ownerTeacherId = 2, title = "classroom 2", description = "description", joinCode = "123ABC", status = ClassroomStatus.ACTIVE), + ) + val teacher = User(id = 2, email = "test@test.com", username = "teacher", passwordHash = "password", role = UserRole.TEACHER, status = UserStatus.ACTIVE) `when`(currentUserResolver.requireUser("2")).thenReturn(teacher) @@ -390,7 +399,7 @@ class ClassroomServiceTest { @Test fun `should delete classroom`() { val classroom = Classroom(id = 1, ownerTeacherId = 2, title = "old", description = "old", joinCode = "ABC123", status = ClassroomStatus.ACTIVE) - val teacher = User(id = 2, email = "test@test.com", username = "teacher", passwordHash = "password", role = UserRole.TEACHER, status = UserStatus.ACTIVE ) + val teacher = User(id = 2, email = "test@test.com", username = "teacher", passwordHash = "password", role = UserRole.TEACHER, status = UserStatus.ACTIVE) `when`(currentUserResolver.requireTeacherOrAdmin("teacher")).thenReturn(teacher) `when`(classroomUserResolver.requireClassroomOwner(teacher, 1L)).thenReturn(classroom) @@ -404,16 +413,16 @@ class ClassroomServiceTest { @Test fun `should throw when deleting non-existing classroom`() { - val teacher = User(id = 2, email = "test@test.com", username = "teacher", passwordHash = "password", role = UserRole.TEACHER, status = UserStatus.ACTIVE ) + val teacher = User(id = 2, email = "test@test.com", username = "teacher", passwordHash = "password", role = UserRole.TEACHER, status = UserStatus.ACTIVE) `when`(currentUserResolver.requireTeacherOrAdmin("teacher")).thenReturn(teacher) - `when`(classroomRepository.findById(1)).thenReturn(java.util.Optional.empty()) - + `when`(classroomRepository.findById(1)).thenReturn(Optional.empty()) `when`(classroomRepository.existsById(1)).thenReturn(false) - val exception = org.junit.jupiter.api.Assertions.assertThrows(ResponseStatusException::class.java) { - classroomService.deleteClassroom(1, "teacher") - } + val exception = + org.junit.jupiter.api.Assertions.assertThrows(ResponseStatusException::class.java) { + classroomService.deleteClassroom(1, "teacher") + } assertEquals(HttpStatus.NOT_FOUND, exception.statusCode) verify(classroomRepository, never()).deleteById(1) diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/game/TeacherPlaytestIntegrationTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/game/TeacherPlaytestIntegrationTests.kt index d3a7aea1..611ecb35 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/game/TeacherPlaytestIntegrationTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/game/TeacherPlaytestIntegrationTests.kt @@ -94,15 +94,16 @@ class TeacherPlaytestIntegrationTests { createStop("boss-stop", "Boss stop", 2, isFinalBoss = true) val accessToken = teacherAccessToken("owner@example.com", "secret123") - mockMvc.get("/api/v1/teacher/classrooms/${classroom.id}/playtest/map-stops") { - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - }.andExpect { - status { isOk() } - jsonPath("$.length()") { value(2) } - jsonPath("$[0].status") { value("NOT_STARTED") } - jsonPath("$[0].stars") { value(0) } - jsonPath("$[1].status") { value("NOT_STARTED") } - } + mockMvc + .get("/api/v1/teacher/classrooms/${classroom.id}/playtest/map-stops") { + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + }.andExpect { + status { isOk() } + jsonPath("$.length()") { value(2) } + jsonPath("$[0].status") { value("NOT_STARTED") } + jsonPath("$[0].stars") { value(0) } + jsonPath("$[1].status") { value("NOT_STARTED") } + } } @Test @@ -112,11 +113,12 @@ class TeacherPlaytestIntegrationTests { val classroom = createClassroom(owner, "PLAYTEST2") val accessToken = teacherAccessToken("intruder@example.com", "secret123") - mockMvc.get("/api/v1/teacher/classrooms/${classroom.id}/playtest/map-stops") { - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - }.andExpect { - status { isForbidden() } - } + mockMvc + .get("/api/v1/teacher/classrooms/${classroom.id}/playtest/map-stops") { + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + }.andExpect { + status { isForbidden() } + } } @Test @@ -125,45 +127,50 @@ class TeacherPlaytestIntegrationTests { val classroom = createClassroom(teacher, "PLAYTEST3") val stop = createStop("task-stop", "Task stop", 1) val teacherAccessToken = teacherAccessToken("owner@example.com", "secret123") - val publishedTaskId = createTask( - classroomId = classroom.id, - stopId = requireNotNull(stop.id), - accessToken = teacherAccessToken, - titleEn = "Published task", - difficultyLevel = 1, - publish = true, - ) - val draftTaskId = createTask( - classroomId = classroom.id, - stopId = requireNotNull(stop.id), - accessToken = teacherAccessToken, - titleEn = "Draft task", - difficultyLevel = 2, - publish = false, - ) - - mockMvc.get("/api/v1/teacher/classrooms/${classroom.id}/playtest/tasks") { - header(HttpHeaders.AUTHORIZATION, "Bearer $teacherAccessToken") - param("stopId", stop.id!!.toString()) - }.andExpect { - status { isOk() } - jsonPath("$.length()") { value(1) } - jsonPath("$[0].id") { value(publishedTaskId.toInt()) } - } + val publishedTaskId = + createTask( + classroomId = classroom.id, + stopId = requireNotNull(stop.id), + accessToken = teacherAccessToken, + titleEn = "Published task", + difficultyLevel = 1, + publish = true, + ) + val draftTaskId = + createTask( + classroomId = classroom.id, + stopId = requireNotNull(stop.id), + accessToken = teacherAccessToken, + titleEn = "Draft task", + difficultyLevel = 2, + publish = false, + ) + + mockMvc + .get("/api/v1/teacher/classrooms/${classroom.id}/playtest/tasks") { + header(HttpHeaders.AUTHORIZATION, "Bearer $teacherAccessToken") + param("stopId", stop.id!!.toString()) + }.andExpect { + status { isOk() } + jsonPath("$.length()") { value(1) } + jsonPath("$[0].id") { value(publishedTaskId.toInt()) } + } - mockMvc.get("/api/v1/teacher/classrooms/${classroom.id}/playtest/tasks/$publishedTaskId") { - header(HttpHeaders.AUTHORIZATION, "Bearer $teacherAccessToken") - }.andExpect { - status { isOk() } - jsonPath("$.items[0].options[0].isCorrect") { value(true) } - jsonPath("$.items[0].options[0].explanationText.en") { value("This source is verified.") } - } + mockMvc + .get("/api/v1/teacher/classrooms/${classroom.id}/playtest/tasks/$publishedTaskId") { + header(HttpHeaders.AUTHORIZATION, "Bearer $teacherAccessToken") + }.andExpect { + status { isOk() } + jsonPath("$.items[0].options[0].isCorrect") { value(true) } + jsonPath("$.items[0].options[0].explanationText.en") { value("This source is verified.") } + } - mockMvc.get("/api/v1/teacher/classrooms/${classroom.id}/playtest/tasks/$draftTaskId") { - header(HttpHeaders.AUTHORIZATION, "Bearer $teacherAccessToken") - }.andExpect { - status { isForbidden() } - } + mockMvc + .get("/api/v1/teacher/classrooms/${classroom.id}/playtest/tasks/$draftTaskId") { + header(HttpHeaders.AUTHORIZATION, "Bearer $teacherAccessToken") + }.andExpect { + status { isForbidden() } + } } @Test @@ -175,36 +182,41 @@ class TeacherPlaytestIntegrationTests { classroomRepository.saveAndFlush(classroom) val pupilAccessToken = pupilAccessToken("playtest_pupil", "secret123") - mockMvc.get("/api/v1/teacher/classrooms/${classroom.id}/playtest/map-stops") { - header(HttpHeaders.AUTHORIZATION, "Bearer $pupilAccessToken") - }.andExpect { - status { isForbidden() } - } + mockMvc + .get("/api/v1/teacher/classrooms/${classroom.id}/playtest/map-stops") { + header(HttpHeaders.AUTHORIZATION, "Bearer $pupilAccessToken") + }.andExpect { + status { isForbidden() } + } } @Test fun `teacher login response no longer exposes preview metadata and old preview endpoint is gone`() { userRepository.save(activeTeacher("owner@example.com")) - val loginResponse = mockMvc.post("/api/v1/auth/teacher/login") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"email":"owner@example.com","password":"secret123"}""" - }.andExpect { - status { isOk() } - jsonPath("$.portalType") { value("TEACHER") } - }.andReturn().response + val loginResponse = + mockMvc + .post("/api/v1/auth/teacher/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"email":"owner@example.com","password":"secret123"}""" + }.andExpect { + status { isOk() } + jsonPath("$.portalType") { value("TEACHER") } + }.andReturn() + .response val body = loginResponse.contentAsString assert(!body.contains("sessionMode")) assert(!body.contains("viewAs")) assert(!body.contains("previewClassroomId")) - mockMvc.post("/api/v1/auth/teacher/game-preview/session/1") { - with(csrf()) - header(HttpHeaders.AUTHORIZATION, "Bearer ${extractAccessToken(body)}") - }.andExpect { - status { isNotFound() } - } + mockMvc + .post("/api/v1/auth/teacher/game-preview/session/1") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer ${extractAccessToken(body)}") + }.andExpect { + status { isNotFound() } + } } @Test @@ -228,33 +240,39 @@ class TeacherPlaytestIntegrationTests { val teacherAccessToken = teacherAccessToken("owner@example.com", "secret123") val pupilAccessToken = pupilAccessToken("progress_pupil", "secret123") - mockMvc.post("/api/v1/teacher/classrooms/${classroom.id}/playtest/tasks") { - header(HttpHeaders.AUTHORIZATION, "Bearer $teacherAccessToken") - }.andExpect { - status { isMethodNotAllowed() } - } + mockMvc + .post("/api/v1/teacher/classrooms/${classroom.id}/playtest/tasks") { + header(HttpHeaders.AUTHORIZATION, "Bearer $teacherAccessToken") + }.andExpect { + status { isMethodNotAllowed() } + } - mockMvc.patch("/api/v1/classrooms/${classroom.id}/pupils/${pupil.id}/stop-progress/${stop.id}") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer $teacherAccessToken") - content = """{"status":"IN_PROGRESS"}""" - }.andExpect { - status { isForbidden() } - } + mockMvc + .patch("/api/v1/classrooms/${classroom.id}/pupils/${pupil.id}/stop-progress/${stop.id}") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer $teacherAccessToken") + content = """{"status":"IN_PROGRESS"}""" + }.andExpect { + status { isForbidden() } + } - mockMvc.patch("/api/v1/classrooms/${classroom.id}/pupils/${pupil.id}/stop-progress/${stop.id}") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer $pupilAccessToken") - content = """{"status":"IN_PROGRESS"}""" - }.andExpect { - status { isOk() } - jsonPath("$.status") { value("IN_PROGRESS") } - } + mockMvc + .patch("/api/v1/classrooms/${classroom.id}/pupils/${pupil.id}/stop-progress/${stop.id}") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer $pupilAccessToken") + content = """{"status":"IN_PROGRESS"}""" + }.andExpect { + status { isOk() } + jsonPath("$.status") { value("IN_PROGRESS") } + } } - private fun createClassroom(owner: User, joinCode: String): Classroom = + private fun createClassroom( + owner: User, + joinCode: String, + ): Classroom = classroomRepository.save( Classroom( ownerTeacherId = requireNotNull(owner.id), @@ -291,26 +309,35 @@ class TeacherPlaytestIntegrationTests { difficultyLevel: Int, publish: Boolean, ): Long { - val createResponse = mockMvc.post("/api/v1/classrooms/$classroomId/tasks") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - content = validTaskPayload(stopId = stopId, titleEn = titleEn, difficultyLevel = difficultyLevel) - }.andExpect { - status { isCreated() } - }.andReturn().response - - val taskId = Regex(""""id":(\d+)""").find(createResponse.contentAsString)?.groupValues?.get(1)?.toLong() - ?: error("Task id missing from create response") + val createResponse = + mockMvc + .post("/api/v1/classrooms/$classroomId/tasks") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + content = validTaskPayload(stopId = stopId, titleEn = titleEn, difficultyLevel = difficultyLevel) + }.andExpect { + status { isCreated() } + }.andReturn() + .response + + val taskId = + Regex(""""id":(\d+)""") + .find(createResponse.contentAsString) + ?.groupValues + ?.get(1) + ?.toLong() + ?: error("Task id missing from create response") if (publish) { - mockMvc.patch("/api/v1/classrooms/$classroomId/tasks/$taskId/publish") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - content = """{"published":true}""" - }.andExpect { - status { isOk() } - } + mockMvc + .patch("/api/v1/classrooms/$classroomId/tasks/$taskId/publish") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + content = """{"published":true}""" + }.andExpect { + status { isOk() } + } } return taskId } @@ -320,13 +347,15 @@ class TeacherPlaytestIntegrationTests { password: String, ): String = extractAccessToken( - mockMvc.post("/api/v1/auth/teacher/login") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"email":"$email","password":"$password"}""" - }.andExpect { - status { isOk() } - }.andReturn().response.contentAsString, + mockMvc + .post("/api/v1/auth/teacher/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"email":"$email","password":"$password"}""" + }.andExpect { + status { isOk() } + }.andReturn() + .response.contentAsString, ) private fun pupilAccessToken( @@ -334,13 +363,15 @@ class TeacherPlaytestIntegrationTests { password: String, ): String = extractAccessToken( - mockMvc.post("/api/v1/auth/pupil/login") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"username":"$username","password":"$password"}""" - }.andExpect { - status { isOk() } - }.andReturn().response.contentAsString, + mockMvc + .post("/api/v1/auth/pupil/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"username":"$username","password":"$password"}""" + }.andExpect { + status { isOk() } + }.andReturn() + .response.contentAsString, ) private fun extractAccessToken(body: String): String = @@ -369,36 +400,36 @@ class TeacherPlaytestIntegrationTests { difficultyLevel: Int, ): String = """ + { + "stopId": $stopId, + "title": {"nb":"Finn den ekte artikkelen","en":"$titleEn"}, + "introText": {"nb":"Velg den troverdige artikkelen.","en":"Choose the trustworthy article."}, + "difficultyLevel": $difficultyLevel, + "taskType": "NEWS_COMPARISON", + "passingRule": "ALL_CORRECT", + "passingScore": 100, + "maxScore": 100, + "items": [ { - "stopId": $stopId, - "title": {"nb":"Finn den ekte artikkelen","en":"$titleEn"}, - "introText": {"nb":"Velg den troverdige artikkelen.","en":"Choose the trustworthy article."}, - "difficultyLevel": $difficultyLevel, - "taskType": "NEWS_COMPARISON", - "passingRule": "ALL_CORRECT", - "passingScore": 100, - "maxScore": 100, - "items": [ + "itemOrder": 1, + "promptText": {"nb":"Hvilken artikkel er troverdig?","en":"Which article is reliable?"}, + "metadataJson": "{\"articleCount\":2}", + "options": [ + { + "optionOrder": 1, + "optionText": {"nb":"Artikkel A","en":"Article A"}, + "isCorrect": true, + "explanationText": {"nb":"Denne kilden er verifisert.","en":"This source is verified."} + }, { - "itemOrder": 1, - "promptText": {"nb":"Hvilken artikkel er troverdig?","en":"Which article is reliable?"}, - "metadataJson": "{\"articleCount\":2}", - "options": [ - { - "optionOrder": 1, - "optionText": {"nb":"Artikkel A","en":"Article A"}, - "isCorrect": true, - "explanationText": {"nb":"Denne kilden er verifisert.","en":"This source is verified."} - }, - { - "optionOrder": 2, - "optionText": {"nb":"Artikkel B","en":"Article B"}, - "isCorrect": false, - "explanationText": {"nb":"Denne kilden bruker klikkagn.","en":"This source uses clickbait."} - } - ] + "optionOrder": 2, + "optionText": {"nb":"Artikkel B","en":"Article B"}, + "isCorrect": false, + "explanationText": {"nb":"Denne kilden bruker klikkagn.","en":"This source uses clickbait."} } ] } + ] + } """.trimIndent() } diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/MapStopServiceTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/MapStopServiceTests.kt index 8b1dd4a2..ab5479e9 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/MapStopServiceTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/MapStopServiceTests.kt @@ -75,9 +75,10 @@ class MapStopServiceTests { Mockito.`when`(context.mapStopsRepository.findBySlugIgnoreCase("not a stop")).thenReturn(null) - val exception = assertThrows { - context.service.getMapStopBySlug("not-a-stop") - } + val exception = + assertThrows { + context.service.getMapStopBySlug("not-a-stop") + } assertEquals(HttpStatus.NOT_FOUND, exception.statusCode) } @@ -109,7 +110,8 @@ class MapStopServiceTests { stubPupilRead(context) Mockito.`when`(context.mapStopsRepository.findAllByIsActiveTrueOrderByDisplayOrderAsc()).thenReturn(listOf(stop)) Mockito.`when`(context.progressRepository.findAllByIdPupilUserIdAndIdClassroomId(1L, 2L)).thenReturn(listOf(progress)) - Mockito.`when`(context.taskRepository.findAllByClassroomIdOrderByStopIdAscDifficultyLevelAsc(2L)) + Mockito + .`when`(context.taskRepository.findAllByClassroomIdOrderByStopIdAscDifficultyLevelAsc(2L)) .thenReturn(listOf(publishedTask(stopId = 10L, maxScore = 100))) val result = context.service.getMapOverview(currentUser = pupilCurrentUser(), classroomId = 2L) @@ -140,16 +142,19 @@ class MapStopServiceTests { stubPupilRead(context) Mockito.`when`(context.mapStopsRepository.findBySlugIgnoreCase("data-center")).thenReturn(stop) - Mockito.`when`(context.progressRepository.findByIdPupilUserIdAndIdClassroomIdAndIdStopId(1L, 2L, 5L)) + Mockito + .`when`(context.progressRepository.findByIdPupilUserIdAndIdClassroomIdAndIdStopId(1L, 2L, 5L)) .thenReturn(progress) - Mockito.`when`(context.taskRepository.findAllByClassroomIdOrderByStopIdAscDifficultyLevelAsc(2L)) + Mockito + .`when`(context.taskRepository.findAllByClassroomIdOrderByStopIdAscDifficultyLevelAsc(2L)) .thenReturn(listOf(publishedTask(stopId = 5L, maxScore = 100))) - val result = context.service.getMapStopOverviewBySlug( - currentUser = pupilCurrentUser(), - classroomId = 2L, - slug = "data-center", - ) + val result = + context.service.getMapStopOverviewBySlug( + currentUser = pupilCurrentUser(), + classroomId = 2L, + slug = "data-center", + ) assertEquals(5L, result.stopId) assertEquals(StopProgressStatus.IN_PROGRESS, result.status) @@ -163,15 +168,17 @@ class MapStopServiceTests { stubPupilRead(context) Mockito.`when`(context.mapStopsRepository.findBySlugIgnoreCase("data-center")).thenReturn(stop) - Mockito.`when`(context.progressRepository.findByIdPupilUserIdAndIdClassroomIdAndIdStopId(1L, 2L, 5L)) + Mockito + .`when`(context.progressRepository.findByIdPupilUserIdAndIdClassroomIdAndIdStopId(1L, 2L, 5L)) .thenReturn(null) Mockito.`when`(context.taskRepository.findAllByClassroomIdOrderByStopIdAscDifficultyLevelAsc(2L)).thenReturn(emptyList()) - val result = context.service.getMapStopOverviewBySlug( - currentUser = pupilCurrentUser(), - classroomId = 2L, - slug = "data-center", - ) + val result = + context.service.getMapStopOverviewBySlug( + currentUser = pupilCurrentUser(), + classroomId = 2L, + slug = "data-center", + ) assertEquals(StopProgressStatus.LOCKED, result.status) assertEquals(0, result.stars) @@ -184,13 +191,14 @@ class MapStopServiceTests { stubPupilRead(context) Mockito.`when`(context.mapStopsRepository.findBySlugIgnoreCase("data center")).thenReturn(null) - val exception = assertThrows { - context.service.getMapStopOverviewBySlug( - currentUser = pupilCurrentUser(), - classroomId = 2L, - slug = "data-center", - ) - } + val exception = + assertThrows { + context.service.getMapStopOverviewBySlug( + currentUser = pupilCurrentUser(), + classroomId = 2L, + slug = "data-center", + ) + } assertEquals(HttpStatus.NOT_FOUND, exception.statusCode) } @@ -204,7 +212,8 @@ class MapStopServiceTests { stubPupilRead(context) Mockito.`when`(context.mapStopsRepository.findAllByIsActiveTrueOrderByDisplayOrderAsc()).thenReturn(listOf(stop)) Mockito.`when`(context.progressRepository.findAllByIdPupilUserIdAndIdClassroomId(1L, 2L)).thenReturn(listOf(progress)) - Mockito.`when`(context.taskRepository.findAllByClassroomIdOrderByStopIdAscDifficultyLevelAsc(2L)) + Mockito + .`when`(context.taskRepository.findAllByClassroomIdOrderByStopIdAscDifficultyLevelAsc(2L)) .thenReturn(listOf(publishedTask(stopId = 1L, maxScore = 100))) assertEquals(0, context.service.getMapOverview(pupilCurrentUser(), 2L)[0].stars) @@ -219,7 +228,8 @@ class MapStopServiceTests { stubPupilRead(context) Mockito.`when`(context.mapStopsRepository.findAllByIsActiveTrueOrderByDisplayOrderAsc()).thenReturn(listOf(stop)) Mockito.`when`(context.progressRepository.findAllByIdPupilUserIdAndIdClassroomId(1L, 2L)).thenReturn(listOf(progress)) - Mockito.`when`(context.taskRepository.findAllByClassroomIdOrderByStopIdAscDifficultyLevelAsc(2L)) + Mockito + .`when`(context.taskRepository.findAllByClassroomIdOrderByStopIdAscDifficultyLevelAsc(2L)) .thenReturn(listOf(publishedTask(stopId = 1L, maxScore = 100))) assertEquals(1, context.service.getMapOverview(pupilCurrentUser(), 2L)[0].stars) @@ -234,7 +244,8 @@ class MapStopServiceTests { stubPupilRead(context) Mockito.`when`(context.mapStopsRepository.findAllByIsActiveTrueOrderByDisplayOrderAsc()).thenReturn(listOf(stop)) Mockito.`when`(context.progressRepository.findAllByIdPupilUserIdAndIdClassroomId(1L, 2L)).thenReturn(listOf(progress)) - Mockito.`when`(context.taskRepository.findAllByClassroomIdOrderByStopIdAscDifficultyLevelAsc(2L)) + Mockito + .`when`(context.taskRepository.findAllByClassroomIdOrderByStopIdAscDifficultyLevelAsc(2L)) .thenReturn(listOf(publishedTask(stopId = 1L, maxScore = 100))) assertEquals(2, context.service.getMapOverview(pupilCurrentUser(), 2L)[0].stars) @@ -255,9 +266,10 @@ class MapStopServiceTests { } private fun stubPupilRead(context: TestContext) { - Mockito.`when`( - context.resolver.resolveReadContext(pupilCurrentUser(), 2L, GameReadMode.DEFAULT), - ).thenReturn(pupilAccessContext()) + Mockito + .`when`( + context.resolver.resolveReadContext(pupilCurrentUser(), 2L, GameReadMode.DEFAULT), + ).thenReturn(pupilAccessContext()) } private fun pupilCurrentUser(): CurrentUser = @@ -271,13 +283,14 @@ class MapStopServiceTests { private fun pupilAccessContext(): GameAccessContext = GameAccessContext( classroom = Classroom(id = 2L, ownerTeacherId = 99L, title = "Test Classroom"), - user = User( - id = 1L, - username = "pupil-one", - passwordHash = "hash", - role = UserRole.PUPIL, - status = UserStatus.ACTIVE, - ), + user = + User( + id = 1L, + username = "pupil-one", + passwordHash = "hash", + role = UserRole.PUPIL, + status = UserStatus.ACTIVE, + ), pupilUserId = 1L, publishedOnly = true, includeAnswers = false, @@ -307,13 +320,14 @@ class MapStopServiceTests { bestScore: Int = 0, medalAwarded: Boolean = false, ): PupilStopProgress { - val pupil = User( - id = 1L, - username = "pupil-one", - passwordHash = "hash", - role = UserRole.PUPIL, - status = UserStatus.ACTIVE, - ) + val pupil = + User( + id = 1L, + username = "pupil-one", + passwordHash = "hash", + role = UserRole.PUPIL, + status = UserStatus.ACTIVE, + ) val classroom = Classroom(ownerTeacherId = 1L, title = "Test Classroom") return PupilStopProgress( id = PupilStopProgressId(pupilUserId = 1L, classroomId = 2L, stopId = stopId), @@ -327,17 +341,21 @@ class MapStopServiceTests { } } - private fun publishedTask(stopId: Long, maxScore: Int): Task = + private fun publishedTask( + stopId: Long, + maxScore: Int, + ): Task = Task( stopId = stopId, classroom = Classroom(ownerTeacherId = 1L, title = "Test Classroom"), - createdByTeacher = User( - id = 2L, - email = "teacher@example.com", - passwordHash = "hash", - role = UserRole.TEACHER, - status = UserStatus.ACTIVE, - ), + createdByTeacher = + User( + id = 2L, + email = "teacher@example.com", + passwordHash = "hash", + role = UserRole.TEACHER, + status = UserStatus.ACTIVE, + ), title = "Task $stopId", difficultyLevel = 1, taskType = TaskType.GENERIC, diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/PupilProgressSeedingServiceTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/PupilProgressSeedingServiceTests.kt index b9d77702..25a1a478 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/PupilProgressSeedingServiceTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/PupilProgressSeedingServiceTests.kt @@ -19,7 +19,6 @@ import org.mockito.Mockito.never import org.mockito.Mockito.verify class PupilProgressSeedingServiceTests { - @Test fun `seed if absent is a no-op when progress records already exist for the pupil`() { val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) @@ -27,7 +26,8 @@ class PupilProgressSeedingServiceTests { val service = PupilProgressSeedingService(mapStopsRepository, progressRepository) val (pupil, classroom) = pupilAndClassroom() - Mockito.`when`(progressRepository.findAllByIdPupilUserIdAndIdClassroomId(1L, 2L)) + Mockito + .`when`(progressRepository.findAllByIdPupilUserIdAndIdClassroomId(1L, 2L)) .thenReturn(listOf(existingProgress(stopId = 1L))) service.seedIfAbsent(pupil = pupil, classroom = classroom) @@ -82,7 +82,8 @@ class PupilProgressSeedingServiceTests { val (pupil, classroom) = pupilAndClassroom() Mockito.`when`(progressRepository.findAllByIdPupilUserIdAndIdClassroomId(1L, 2L)).thenReturn(emptyList()) - Mockito.`when`(mapStopsRepository.findAllByIsActiveTrueOrderByDisplayOrderAsc()) + Mockito + .`when`(mapStopsRepository.findAllByIsActiveTrueOrderByDisplayOrderAsc()) .thenReturn(listOf(mapStop(id = 1L))) @Suppress("UNCHECKED_CAST") @@ -104,7 +105,8 @@ class PupilProgressSeedingServiceTests { val (pupil, classroom) = pupilAndClassroom(pupilId = 7L, classroomId = 99L) Mockito.`when`(progressRepository.findAllByIdPupilUserIdAndIdClassroomId(7L, 99L)).thenReturn(emptyList()) - Mockito.`when`(mapStopsRepository.findAllByIsActiveTrueOrderByDisplayOrderAsc()) + Mockito + .`when`(mapStopsRepository.findAllByIsActiveTrueOrderByDisplayOrderAsc()) .thenReturn(listOf(mapStop(id = 1L), mapStop(id = 2L))) @Suppress("UNCHECKED_CAST") @@ -122,21 +124,31 @@ class PupilProgressSeedingServiceTests { assertEquals(2L, saved[1].id.stopId) } - private data class PupilAndClassroom(val pupil: User, val classroom: Classroom) - - private fun pupilAndClassroom(pupilId: Long = 1L, classroomId: Long = 2L): PupilAndClassroom { - val pupil = User( - id = pupilId, - username = "pupil-$pupilId", - passwordHash = "hash", - role = UserRole.PUPIL, - status = UserStatus.ACTIVE, - ) - val classroom = Classroom(id = classroomId,ownerTeacherId = 1L, title = "Test Classroom") + private data class PupilAndClassroom( + val pupil: User, + val classroom: Classroom, + ) + + private fun pupilAndClassroom( + pupilId: Long = 1L, + classroomId: Long = 2L, + ): PupilAndClassroom { + val pupil = + User( + id = pupilId, + username = "pupil-$pupilId", + passwordHash = "hash", + role = UserRole.PUPIL, + status = UserStatus.ACTIVE, + ) + val classroom = Classroom(id = classroomId, ownerTeacherId = 1L, title = "Test Classroom") return PupilAndClassroom(pupil, classroom) } - private fun mapStop(id: Long, displayOrder: Int = id.toInt()) = MapStop().apply { + private fun mapStop( + id: Long, + displayOrder: Int = id.toInt(), + ) = MapStop().apply { this.id = id this.slug = "stop-$id" this.title = "Stop $id" @@ -145,13 +157,14 @@ class PupilProgressSeedingServiceTests { } private fun existingProgress(stopId: Long): PupilStopProgress { - val pupil = User( - id = 1L, - username = "pupil-one", - passwordHash = "hash", - role = UserRole.PUPIL, - status = UserStatus.ACTIVE, - ) + val pupil = + User( + id = 1L, + username = "pupil-one", + passwordHash = "hash", + role = UserRole.PUPIL, + status = UserStatus.ACTIVE, + ) val classroom = Classroom(ownerTeacherId = 1L, title = "Test Classroom") return PupilStopProgress( id = PupilStopProgressId(pupilUserId = 1L, classroomId = 2L, stopId = stopId), @@ -160,4 +173,4 @@ class PupilProgressSeedingServiceTests { stop = mapStop(id = stopId), ) } -} \ No newline at end of file +} diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/PupilStopProgressServiceTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/PupilStopProgressServiceTests.kt index e0da7563..0d80639e 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/PupilStopProgressServiceTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/PupilStopProgressServiceTests.kt @@ -9,7 +9,6 @@ import edu.ntnu.idi.idatt.backend.game.mapstops.domain.PupilStopProgressId import edu.ntnu.idi.idatt.backend.game.mapstops.domain.StopProgressStatus import edu.ntnu.idi.idatt.backend.game.mapstops.infrastructure.MapStopsRepository import edu.ntnu.idi.idatt.backend.game.mapstops.infrastructure.PupilStopProgressRepository -import edu.ntnu.idi.idatt.backend.xp.application.XpService import edu.ntnu.idi.idatt.backend.iam.application.CurrentUserResolver import edu.ntnu.idi.idatt.backend.iam.domain.User import edu.ntnu.idi.idatt.backend.iam.domain.UserRole @@ -21,6 +20,7 @@ import edu.ntnu.idi.idatt.backend.shared.events.MapStopCompletedEvent import edu.ntnu.idi.idatt.backend.shared.events.PupilGameCompletedEvent import edu.ntnu.idi.idatt.backend.shared.events.PupilRecoveredFromStuckEvent import edu.ntnu.idi.idatt.backend.shared.events.PupilStuckThresholdReachedEvent +import edu.ntnu.idi.idatt.backend.xp.application.XpService import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull @@ -33,7 +33,6 @@ import org.springframework.http.HttpStatus import org.springframework.web.server.ResponseStatusException class PupilStopProgressServiceTests { - @Test fun `get progress for pupil returns all progress records for given pupil and classroom`() { val progressRepository = Mockito.mock(PupilStopProgressRepository::class.java) @@ -67,7 +66,8 @@ class PupilStopProgressServiceTests { val service = service(progressRepository, mapStopRepository) val record = progress(stopId = 3L) - Mockito.`when`(progressRepository.findByIdPupilUserIdAndIdClassroomIdAndIdStopId(1L, 2L, 3L)) + Mockito + .`when`(progressRepository.findByIdPupilUserIdAndIdClassroomIdAndIdStopId(1L, 2L, 3L)) .thenReturn(record) val result = service.getProgressForStop(pupilUserId = 1L, classroomId = 2L, stopId = 3L) @@ -81,12 +81,14 @@ class PupilStopProgressServiceTests { val mapStopRepository = Mockito.mock(MapStopsRepository::class.java) val service = service(progressRepository, mapStopRepository) - Mockito.`when`(progressRepository.findByIdPupilUserIdAndIdClassroomIdAndIdStopId(1L, 2L, 99L)) + Mockito + .`when`(progressRepository.findByIdPupilUserIdAndIdClassroomIdAndIdStopId(1L, 2L, 99L)) .thenReturn(null) - val exception = assertThrows { - service.getProgressForStop(pupilUserId = 1L, classroomId = 2L, stopId = 99L) - } + val exception = + assertThrows { + service.getProgressForStop(pupilUserId = 1L, classroomId = 2L, stopId = 99L) + } assertEquals(HttpStatus.NOT_FOUND, exception.statusCode) } @@ -101,11 +103,13 @@ class PupilStopProgressServiceTests { stubFindAndSave(progressRepository, record) - val result = service.updateProgress( - currentUser = pupilCurrentUser(), - classroomId = 2L, stopId = 1L, - command = UpdateStopProgressCommand(status = StopProgressStatus.NOT_STARTED, null, null, null), - ) + val result = + service.updateProgress( + currentUser = pupilCurrentUser(), + classroomId = 2L, + stopId = 1L, + command = UpdateStopProgressCommand(status = StopProgressStatus.NOT_STARTED, null, null, null), + ) assertEquals(StopProgressStatus.NOT_STARTED, result.status) Mockito.verifyNoInteractions(domainEventPublisher) @@ -121,11 +125,13 @@ class PupilStopProgressServiceTests { stubFindAndSave(progressRepository, record) - val result = service.updateProgress( - currentUser = pupilCurrentUser(), - classroomId = 2L, stopId = 1L, - command = UpdateStopProgressCommand(status = StopProgressStatus.IN_PROGRESS, null, null, null), - ) + val result = + service.updateProgress( + currentUser = pupilCurrentUser(), + classroomId = 2L, + stopId = 1L, + command = UpdateStopProgressCommand(status = StopProgressStatus.IN_PROGRESS, null, null, null), + ) assertEquals(StopProgressStatus.IN_PROGRESS, result.status) Mockito.verifyNoInteractions(domainEventPublisher) @@ -141,11 +147,13 @@ class PupilStopProgressServiceTests { stubFindAndSave(progressRepository, record) - val result = service.updateProgress( - currentUser = pupilCurrentUser(), - classroomId = 2L, stopId = 1L, - command = UpdateStopProgressCommand(status = StopProgressStatus.COMPLETED, null, null, null), - ) + val result = + service.updateProgress( + currentUser = pupilCurrentUser(), + classroomId = 2L, + stopId = 1L, + command = UpdateStopProgressCommand(status = StopProgressStatus.COMPLETED, null, null, null), + ) assertEquals(StopProgressStatus.COMPLETED, result.status) assertNotNull(result.completedAt) @@ -165,7 +173,8 @@ class PupilStopProgressServiceTests { // first completion service.updateProgress( currentUser = pupilCurrentUser(), - classroomId = 2L, stopId = 1L, + classroomId = 2L, + stopId = 1L, command = UpdateStopProgressCommand(status = StopProgressStatus.COMPLETED, null, null, null), ) val firstCompletedAt = record.completedAt @@ -185,13 +194,15 @@ class PupilStopProgressServiceTests { stubFind(progressRepository, record) - val exception = assertThrows { - service.updateProgress( - currentUser = pupilCurrentUser(), - classroomId = 2L, stopId = 1L, - command = UpdateStopProgressCommand(status = StopProgressStatus.IN_PROGRESS, null, null, null), - ) - } + val exception = + assertThrows { + service.updateProgress( + currentUser = pupilCurrentUser(), + classroomId = 2L, + stopId = 1L, + command = UpdateStopProgressCommand(status = StopProgressStatus.IN_PROGRESS, null, null, null), + ) + } assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, exception.statusCode) Mockito.verifyNoInteractions(domainEventPublisher) @@ -207,13 +218,15 @@ class PupilStopProgressServiceTests { stubFind(progressRepository, record) - val exception = assertThrows { - service.updateProgress( - currentUser = pupilCurrentUser(), - classroomId = 2L, stopId = 1L, - command = UpdateStopProgressCommand(status = StopProgressStatus.COMPLETED, null, null, null), - ) - } + val exception = + assertThrows { + service.updateProgress( + currentUser = pupilCurrentUser(), + classroomId = 2L, + stopId = 1L, + command = UpdateStopProgressCommand(status = StopProgressStatus.COMPLETED, null, null, null), + ) + } assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, exception.statusCode) Mockito.verifyNoInteractions(domainEventPublisher) @@ -228,11 +241,13 @@ class PupilStopProgressServiceTests { stubFindAndSave(progressRepository, record) - val result = service.updateProgress( - currentUser = pupilCurrentUser(), - classroomId = 2L, stopId = 1L, - command = UpdateStopProgressCommand(status = null, currentTaskId = null, bestScore = 80, medalAwarded = null), - ) + val result = + service.updateProgress( + currentUser = pupilCurrentUser(), + classroomId = 2L, + stopId = 1L, + command = UpdateStopProgressCommand(status = null, currentTaskId = null, bestScore = 80, medalAwarded = null), + ) assertEquals(80, result.bestScore) } @@ -246,11 +261,13 @@ class PupilStopProgressServiceTests { stubFindAndSave(progressRepository, record) - val result = service.updateProgress( - currentUser = pupilCurrentUser(), - classroomId = 2L, stopId = 1L, - command = UpdateStopProgressCommand(status = null, currentTaskId = null, bestScore = 50, medalAwarded = null), - ) + val result = + service.updateProgress( + currentUser = pupilCurrentUser(), + classroomId = 2L, + stopId = 1L, + command = UpdateStopProgressCommand(status = null, currentTaskId = null, bestScore = 50, medalAwarded = null), + ) assertEquals(90, result.bestScore) } @@ -264,11 +281,13 @@ class PupilStopProgressServiceTests { stubFindAndSave(progressRepository, record) - val result = service.updateProgress( - currentUser = pupilCurrentUser(), - classroomId = 2L, stopId = 1L, - command = UpdateStopProgressCommand(status = null, currentTaskId = 7L, bestScore = null, medalAwarded = null), - ) + val result = + service.updateProgress( + currentUser = pupilCurrentUser(), + classroomId = 2L, + stopId = 1L, + command = UpdateStopProgressCommand(status = null, currentTaskId = 7L, bestScore = null, medalAwarded = null), + ) assertEquals(7L, result.currentTaskId) } @@ -279,22 +298,26 @@ class PupilStopProgressServiceTests { val mapStopRepository = Mockito.mock(MapStopsRepository::class.java) val domainEventPublisher = Mockito.mock(DomainEventPublisher::class.java) val service = service(progressRepository, mapStopRepository, domainEventPublisher) - val record = progress(stopId = 1L, status = StopProgressStatus.IN_PROGRESS).apply { - currentTaskId = 7L - } + val record = + progress(stopId = 1L, status = StopProgressStatus.IN_PROGRESS).apply { + currentTaskId = 7L + } stubFindAndSave(progressRepository, record) - val result = service.updateProgress( - currentUser = pupilCurrentUser(), - classroomId = 2L, stopId = 1L, - command = UpdateStopProgressCommand( - status = StopProgressStatus.COMPLETED, - currentTaskId = 99L, - bestScore = null, - medalAwarded = null, - ), - ) + val result = + service.updateProgress( + currentUser = pupilCurrentUser(), + classroomId = 2L, + stopId = 1L, + command = + UpdateStopProgressCommand( + status = StopProgressStatus.COMPLETED, + currentTaskId = 99L, + bestScore = null, + medalAwarded = null, + ), + ) assertNull(result.currentTaskId) } @@ -308,11 +331,13 @@ class PupilStopProgressServiceTests { stubFindAndSave(progressRepository, record) - val result = service.updateProgress( - currentUser = pupilCurrentUser(), - classroomId = 2L, stopId = 1L, - command = UpdateStopProgressCommand(status = null, currentTaskId = null, bestScore = null, medalAwarded = true), - ) + val result = + service.updateProgress( + currentUser = pupilCurrentUser(), + classroomId = 2L, + stopId = 1L, + command = UpdateStopProgressCommand(status = null, currentTaskId = null, bestScore = null, medalAwarded = true), + ) assertEquals(true, result.medalAwarded) } @@ -327,11 +352,13 @@ class PupilStopProgressServiceTests { stubFindAndSave(progressRepository, record) - val result = service.updateProgress( - currentUser = pupilCurrentUser(), - classroomId = 2L, stopId = 1L, - command = UpdateStopProgressCommand(status = null, currentTaskId = null, bestScore = null, medalAwarded = null), - ) + val result = + service.updateProgress( + currentUser = pupilCurrentUser(), + classroomId = 2L, + stopId = 1L, + command = UpdateStopProgressCommand(status = null, currentTaskId = null, bestScore = null, medalAwarded = null), + ) assertEquals(StopProgressStatus.IN_PROGRESS, result.status) assertEquals(55, result.bestScore) @@ -372,12 +399,13 @@ class PupilStopProgressServiceTests { stubFindAndSave(progressRepository, record) - val result = service.updateProgress( - currentUser = pupilCurrentUser(), - classroomId = 2L, - stopId = 1L, - command = UpdateStopProgressCommand(status = StopProgressStatus.COMPLETED, null, null, null), - ) + val result = + service.updateProgress( + currentUser = pupilCurrentUser(), + classroomId = 2L, + stopId = 1L, + command = UpdateStopProgressCommand(status = StopProgressStatus.COMPLETED, null, null, null), + ) assertEquals(0, result.consecutiveWrongAttempts) val publishedEvents = publishedEvents(domainEventPublisher, 2) @@ -394,12 +422,13 @@ class PupilStopProgressServiceTests { stubFindAndSave(progressRepository, record) - val result = service.recordVerifiedAttemptOutcome( - pupilUserId = 1L, - classroomId = 2L, - stopId = 1L, - correct = false, - ) + val result = + service.recordVerifiedAttemptOutcome( + pupilUserId = 1L, + classroomId = 2L, + stopId = 1L, + correct = false, + ) assertEquals(5, result.consecutiveWrongAttempts) val publishedEvents = publishedEvents(domainEventPublisher, 1) @@ -416,12 +445,13 @@ class PupilStopProgressServiceTests { stubFindAndSave(progressRepository, record) - val result = service.recordVerifiedAttemptOutcome( - pupilUserId = 1L, - classroomId = 2L, - stopId = 1L, - correct = false, - ) + val result = + service.recordVerifiedAttemptOutcome( + pupilUserId = 1L, + classroomId = 2L, + stopId = 1L, + correct = false, + ) assertEquals(6, result.consecutiveWrongAttempts) Mockito.verifyNoInteractions(domainEventPublisher) @@ -437,12 +467,13 @@ class PupilStopProgressServiceTests { stubFindAndSave(progressRepository, record) - val result = service.recordVerifiedAttemptOutcome( - pupilUserId = 1L, - classroomId = 2L, - stopId = 1L, - correct = true, - ) + val result = + service.recordVerifiedAttemptOutcome( + pupilUserId = 1L, + classroomId = 2L, + stopId = 1L, + correct = true, + ) assertEquals(0, result.consecutiveWrongAttempts) val publishedEvents = publishedEvents(domainEventPublisher, 1) @@ -459,12 +490,13 @@ class PupilStopProgressServiceTests { stubFindAndSave(progressRepository, record) - val result = service.recordVerifiedAttemptOutcome( - pupilUserId = 1L, - classroomId = 2L, - stopId = 1L, - correct = true, - ) + val result = + service.recordVerifiedAttemptOutcome( + pupilUserId = 1L, + classroomId = 2L, + stopId = 1L, + correct = true, + ) assertEquals(0, result.consecutiveWrongAttempts) Mockito.verifyNoInteractions(domainEventPublisher) @@ -527,12 +559,13 @@ class PupilStopProgressServiceTests { stubFind(progressRepository, record) - val result = service.recordVerifiedAttemptOutcome( - pupilUserId = 1L, - classroomId = 2L, - stopId = 1L, - correct = false, - ) + val result = + service.recordVerifiedAttemptOutcome( + pupilUserId = 1L, + classroomId = 2L, + stopId = 1L, + correct = false, + ) assertEquals(5, result.consecutiveWrongAttempts) Mockito.verify(progressRepository, Mockito.never()).save(any(PupilStopProgress::class.java)) @@ -547,18 +580,20 @@ class PupilStopProgressServiceTests { isFinalBoss: Boolean = false, isActive: Boolean = true, ): PupilStopProgress { - val pupil = User( - id = 1L, - username = "pupil-one", - passwordHash = "hash", - role = UserRole.PUPIL, - status = UserStatus.ACTIVE, - ) - val stop = MapStop().apply { - id = stopId - this.isFinalBoss = isFinalBoss - this.isActive = isActive - } + val pupil = + User( + id = 1L, + username = "pupil-one", + passwordHash = "hash", + role = UserRole.PUPIL, + status = UserStatus.ACTIVE, + ) + val stop = + MapStop().apply { + id = stopId + this.isFinalBoss = isFinalBoss + this.isActive = isActive + } val classroom = Classroom(ownerTeacherId = 1L, title = "Test Classroom") return PupilStopProgress( id = PupilStopProgressId(pupilUserId = 1L, classroomId = 2L, stopId = stopId), @@ -576,12 +611,12 @@ class PupilStopProgressServiceTests { progressRepository: PupilStopProgressRepository, mapStopRepository: MapStopsRepository, domainEventPublisher: DomainEventPublisher = Mockito.mock(DomainEventPublisher::class.java), - currentUserResolver: CurrentUserResolver = Mockito.mock(CurrentUserResolver::class.java).also { - Mockito.`when`(it.requirePupil("1")).thenReturn(pupil()) - }, + currentUserResolver: CurrentUserResolver = + Mockito.mock(CurrentUserResolver::class.java).also { + Mockito.`when`(it.requirePupil("1")).thenReturn(pupil()) + }, xpService: XpService = Mockito.mock(XpService::class.java), - ): PupilStopProgressService = - PupilStopProgressService(progressRepository, mapStopRepository, currentUserResolver, domainEventPublisher, xpService) + ): PupilStopProgressService = PupilStopProgressService(progressRepository, mapStopRepository, currentUserResolver, domainEventPublisher, xpService) private fun pupilCurrentUser(): CurrentUser = CurrentUser( @@ -600,23 +635,38 @@ class PupilStopProgressServiceTests { status = UserStatus.ACTIVE, ) - private fun stubFind(repo: PupilStopProgressRepository, record: PupilStopProgress) { - Mockito.`when`(repo.findByIdPupilUserIdAndIdClassroomIdAndIdStopId( - record.id.pupilUserId, record.id.classroomId, record.id.stopId, - )).thenReturn(record) + private fun stubFind( + repo: PupilStopProgressRepository, + record: PupilStopProgress, + ) { + Mockito + .`when`( + repo.findByIdPupilUserIdAndIdClassroomIdAndIdStopId( + record.id.pupilUserId, + record.id.classroomId, + record.id.stopId, + ), + ).thenReturn(record) } - private fun stubFindAndSave(repo: PupilStopProgressRepository, record: PupilStopProgress) { + private fun stubFindAndSave( + repo: PupilStopProgressRepository, + record: PupilStopProgress, + ) { stubFind(repo, record) Mockito.`when`(repo.save(any(PupilStopProgress::class.java))).thenAnswer { it.arguments[0] } } + private fun publishedEvents( domainEventPublisher: DomainEventPublisher, times: Int, ): List { - val events = Mockito.mockingDetails(domainEventPublisher).invocations - .filter { invocation -> invocation.method.name == "publish" } - .map { invocation -> invocation.arguments.single() as DomainEvent } + val events = + Mockito + .mockingDetails(domainEventPublisher) + .invocations + .filter { invocation -> invocation.method.name == "publish" } + .map { invocation -> invocation.arguments.single() as DomainEvent } assertEquals(times, events.size) assertTrue(events.all { event -> event.occurredAt != null }) return events diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/game/tasks/TaskApiIntegrationTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/game/tasks/TaskApiIntegrationTests.kt index 539f983a..41a3fb0a 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/game/tasks/TaskApiIntegrationTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/game/tasks/TaskApiIntegrationTests.kt @@ -5,11 +5,6 @@ import com.fasterxml.jackson.databind.json.JsonMapper import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom import edu.ntnu.idi.idatt.backend.classroom.domain.ClassroomStatus import edu.ntnu.idi.idatt.backend.classroom.infrastructure.ClassroomRepository -import edu.ntnu.idi.idatt.backend.iam.domain.User -import edu.ntnu.idi.idatt.backend.iam.domain.UserRole -import edu.ntnu.idi.idatt.backend.iam.domain.UserStatus -import edu.ntnu.idi.idatt.backend.iam.infrastructure.RefreshTokenRepository -import edu.ntnu.idi.idatt.backend.iam.infrastructure.UserRepository import edu.ntnu.idi.idatt.backend.game.mapstops.domain.MapStop import edu.ntnu.idi.idatt.backend.game.mapstops.domain.PupilStopProgress import edu.ntnu.idi.idatt.backend.game.mapstops.domain.PupilStopProgressId @@ -17,6 +12,11 @@ import edu.ntnu.idi.idatt.backend.game.mapstops.domain.StopProgressStatus import edu.ntnu.idi.idatt.backend.game.mapstops.infrastructure.MapStopsRepository import edu.ntnu.idi.idatt.backend.game.mapstops.infrastructure.PupilStopProgressRepository import edu.ntnu.idi.idatt.backend.game.tasks.infrastructure.TaskRepository +import edu.ntnu.idi.idatt.backend.iam.domain.User +import edu.ntnu.idi.idatt.backend.iam.domain.UserRole +import edu.ntnu.idi.idatt.backend.iam.domain.UserStatus +import edu.ntnu.idi.idatt.backend.iam.infrastructure.RefreshTokenRepository +import edu.ntnu.idi.idatt.backend.iam.infrastructure.UserRepository import edu.ntnu.idi.idatt.backend.users.avatar.infrastructure.UserAvatarRepository import edu.ntnu.idi.idatt.backend.users.profile.infrastructure.PupilProfileRepository import edu.ntnu.idi.idatt.backend.users.profile.infrastructure.TeacherProfileRepository @@ -94,31 +94,35 @@ class TaskApiIntegrationTests { val classroom = createClassroom(owner, "TASK42") val accessToken = teacherAccessToken("owner@example.com", "secret123") - val createdBody = readJson( - mockMvc.post("/api/v1/classrooms/${classroom.id}/tasks") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - content = validTaskPayload() - }.andExpect { - status { isCreated() } - jsonPath("$.title.nb") { value("Finn den ekte artikkelen") } - jsonPath("$.title.en") { value("Find the real article") } - jsonPath("$.published") { value(false) } - jsonPath("$.items[0].options[0].optionText.en") { value("Article A") } - }.andReturn().response.contentAsString, - ) + val createdBody = + readJson( + mockMvc + .post("/api/v1/classrooms/${classroom.id}/tasks") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + content = validTaskPayload() + }.andExpect { + status { isCreated() } + jsonPath("$.title.nb") { value("Finn den ekte artikkelen") } + jsonPath("$.title.en") { value("Find the real article") } + jsonPath("$.published") { value(false) } + jsonPath("$.items[0].options[0].optionText.en") { value("Article A") } + }.andReturn() + .response.contentAsString, + ) val taskId = createdBody.get("id").asLong() - mockMvc.get("/api/v1/classrooms/${classroom.id}/tasks/$taskId") { - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - }.andExpect { - status { isOk() } - jsonPath("$.id") { value(taskId) } - jsonPath("$.stopId") { value(1) } - jsonPath("$.difficultyLevel") { value(1) } - } + mockMvc + .get("/api/v1/classrooms/${classroom.id}/tasks/$taskId") { + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + }.andExpect { + status { isOk() } + jsonPath("$.id") { value(taskId) } + jsonPath("$.stopId") { value(1) } + jsonPath("$.difficultyLevel") { value(1) } + } } @Test @@ -128,14 +132,15 @@ class TaskApiIntegrationTests { val classroom = createClassroom(owner, "TASK43") val accessToken = teacherAccessToken("intruder@example.com", "secret123") - mockMvc.post("/api/v1/classrooms/${classroom.id}/tasks") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - content = validTaskPayload() - }.andExpect { - status { isForbidden() } - } + mockMvc + .post("/api/v1/classrooms/${classroom.id}/tasks") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + content = validTaskPayload() + }.andExpect { + status { isForbidden() } + } assertEquals(0, taskRepository.count()) } @@ -146,37 +151,42 @@ class TaskApiIntegrationTests { val classroom = createClassroom(owner, "TASK44") val accessToken = teacherAccessToken("owner@example.com", "secret123") - val createdBody = readJson( - mockMvc.post("/api/v1/classrooms/${classroom.id}/tasks") { + val createdBody = + readJson( + mockMvc + .post("/api/v1/classrooms/${classroom.id}/tasks") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + content = + """ + { + "stopId": 1, + "title": {"nb":"Utkastoppgave","en":"Draft task"}, + "difficultyLevel": 1, + "taskType": "GENERIC", + "passingRule": "ALL_CORRECT", + "passingScore": 10, + "maxScore": 10, + "items": [] + } + """.trimIndent() + }.andExpect { + status { isCreated() } + }.andReturn() + .response.contentAsString, + ) + val taskId = createdBody.get("id").asLong() + + mockMvc + .patch("/api/v1/classrooms/${classroom.id}/tasks/$taskId/publish") { with(csrf()) contentType = MediaType.APPLICATION_JSON header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - content = """ - { - "stopId": 1, - "title": {"nb":"Utkastoppgave","en":"Draft task"}, - "difficultyLevel": 1, - "taskType": "GENERIC", - "passingRule": "ALL_CORRECT", - "passingScore": 10, - "maxScore": 10, - "items": [] - } - """.trimIndent() + content = """{"published":true}""" }.andExpect { - status { isCreated() } - }.andReturn().response.contentAsString, - ) - val taskId = createdBody.get("id").asLong() - - mockMvc.patch("/api/v1/classrooms/${classroom.id}/tasks/$taskId/publish") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - content = """{"published":true}""" - }.andExpect { - status { isUnprocessableContent() } - } + status { isUnprocessableContent() } + } } @Test @@ -185,53 +195,60 @@ class TaskApiIntegrationTests { val classroom = createClassroom(owner, "TASK45") val accessToken = teacherAccessToken("owner@example.com", "secret123") - val createdBody = readJson( - mockMvc.post("/api/v1/classrooms/${classroom.id}/tasks") { + val createdBody = + readJson( + mockMvc + .post("/api/v1/classrooms/${classroom.id}/tasks") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + content = validTaskPayload() + }.andExpect { + status { isCreated() } + }.andReturn() + .response.contentAsString, + ) + val taskId = createdBody.get("id").asLong() + + mockMvc + .patch("/api/v1/classrooms/${classroom.id}/tasks/$taskId/publish") { with(csrf()) contentType = MediaType.APPLICATION_JSON header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - content = validTaskPayload() + content = """{"published":true}""" }.andExpect { - status { isCreated() } - }.andReturn().response.contentAsString, - ) - val taskId = createdBody.get("id").asLong() - - mockMvc.patch("/api/v1/classrooms/${classroom.id}/tasks/$taskId/publish") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - content = """{"published":true}""" - }.andExpect { - status { isOk() } - jsonPath("$.published") { value(true) } - } + status { isOk() } + jsonPath("$.published") { value(true) } + } - mockMvc.put("/api/v1/classrooms/${classroom.id}/tasks/$taskId") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - content = validTaskPayload(title = "Updated title", difficultyLevel = 2) - }.andExpect { - status { isOk() } - jsonPath("$.title.en") { value("Updated title") } - jsonPath("$.difficultyLevel") { value(2) } - } + mockMvc + .put("/api/v1/classrooms/${classroom.id}/tasks/$taskId") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + content = validTaskPayload(title = "Updated title", difficultyLevel = 2) + }.andExpect { + status { isOk() } + jsonPath("$.title.en") { value("Updated title") } + jsonPath("$.difficultyLevel") { value(2) } + } - mockMvc.get("/api/v1/classrooms/${classroom.id}/tasks") { - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - }.andExpect { - status { isOk() } - jsonPath("$[0].title.en") { value("Updated title") } - jsonPath("$[0].published") { value(true) } - } + mockMvc + .get("/api/v1/classrooms/${classroom.id}/tasks") { + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + }.andExpect { + status { isOk() } + jsonPath("$[0].title.en") { value("Updated title") } + jsonPath("$[0].published") { value(true) } + } - mockMvc.delete("/api/v1/classrooms/${classroom.id}/tasks/$taskId") { - with(csrf()) - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - }.andExpect { - status { isNoContent() } - } + mockMvc + .delete("/api/v1/classrooms/${classroom.id}/tasks/$taskId") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + }.andExpect { + status { isNoContent() } + } assertEquals(0, taskRepository.count()) } @@ -242,46 +259,52 @@ class TaskApiIntegrationTests { val classroom = createClassroom(owner, "TASK47") val accessToken = teacherAccessToken("owner@example.com", "secret123") - val createdBody = readJson( - mockMvc.post("/api/v1/classrooms/${classroom.id}/tasks") { + val createdBody = + readJson( + mockMvc + .post("/api/v1/classrooms/${classroom.id}/tasks") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + content = validTaskPayload() + }.andExpect { + status { isCreated() } + }.andReturn() + .response.contentAsString, + ) + val taskId = createdBody.get("id").asLong() + + mockMvc + .patch("/api/v1/classrooms/${classroom.id}/tasks/$taskId/publish") { with(csrf()) contentType = MediaType.APPLICATION_JSON header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - content = validTaskPayload() + content = """{"published":true}""" }.andExpect { - status { isCreated() } - }.andReturn().response.contentAsString, - ) - val taskId = createdBody.get("id").asLong() - - mockMvc.patch("/api/v1/classrooms/${classroom.id}/tasks/$taskId/publish") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - content = """{"published":true}""" - }.andExpect { - status { isOk() } - } + status { isOk() } + } - mockMvc.put("/api/v1/classrooms/${classroom.id}/tasks/$taskId") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - content = """ - { - "stopId": 1, - "title": {"nb":"Fortsatt publisert","en":"Still published"}, - "difficultyLevel": 1, - "taskType": "GENERIC", - "passingRule": "ALL_CORRECT", - "passingScore": 10, - "maxScore": 10, - "items": [] - } - """.trimIndent() - }.andExpect { - status { isUnprocessableContent() } - } + mockMvc + .put("/api/v1/classrooms/${classroom.id}/tasks/$taskId") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + content = + """ + { + "stopId": 1, + "title": {"nb":"Fortsatt publisert","en":"Still published"}, + "difficultyLevel": 1, + "taskType": "GENERIC", + "passingRule": "ALL_CORRECT", + "passingScore": 10, + "maxScore": 10, + "items": [] + } + """.trimIndent() + }.andExpect { + status { isUnprocessableContent() } + } } @Test @@ -290,42 +313,47 @@ class TaskApiIntegrationTests { val classroom = createClassroom(owner, "TASK46") val teacherAccessToken = teacherAccessToken("owner@example.com", "secret123") - val createdBody = readJson( - mockMvc.post("/api/v1/classrooms/${classroom.id}/tasks") { + val createdBody = + readJson( + mockMvc + .post("/api/v1/classrooms/${classroom.id}/tasks") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer $teacherAccessToken") + content = validTaskPayload() + }.andExpect { + status { isCreated() } + }.andReturn() + .response.contentAsString, + ) + val taskId = createdBody.get("id").asLong() + + mockMvc + .patch("/api/v1/classrooms/${classroom.id}/tasks/$taskId/publish") { with(csrf()) contentType = MediaType.APPLICATION_JSON header(HttpHeaders.AUTHORIZATION, "Bearer $teacherAccessToken") - content = validTaskPayload() + content = """{"published":true}""" }.andExpect { - status { isCreated() } - }.andReturn().response.contentAsString, - ) - val taskId = createdBody.get("id").asLong() - - mockMvc.patch("/api/v1/classrooms/${classroom.id}/tasks/$taskId/publish") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer $teacherAccessToken") - content = """{"published":true}""" - }.andExpect { - status { isOk() } - } + status { isOk() } + } val pupil = userRepository.save(activePupil("pupil_task_reader")) classroom.addPupilMembership(pupil) classroomRepository.saveAndFlush(classroom) val pupilAccessToken = pupilAccessToken("pupil_task_reader", "secret123") - mockMvc.get("/api/v1/classrooms/${classroom.id}/tasks/$taskId") { - header(HttpHeaders.AUTHORIZATION, "Bearer $pupilAccessToken") - }.andExpect { - status { isOk() } - jsonPath("$.items[0].options[0].optionText.en") { value("Article A") } - jsonPath("$.items[0].options[0].isCorrect") { doesNotExist() } - jsonPath("$.items[0].options[0].explanationText") { doesNotExist() } - jsonPath("$.items[0].options[1].isCorrect") { doesNotExist() } - jsonPath("$.items[0].options[1].explanationText") { doesNotExist() } - } + mockMvc + .get("/api/v1/classrooms/${classroom.id}/tasks/$taskId") { + header(HttpHeaders.AUTHORIZATION, "Bearer $pupilAccessToken") + }.andExpect { + status { isOk() } + jsonPath("$.items[0].options[0].optionText.en") { value("Article A") } + jsonPath("$.items[0].options[0].isCorrect") { doesNotExist() } + jsonPath("$.items[0].options[0].explanationText") { doesNotExist() } + jsonPath("$.items[0].options[1].isCorrect") { doesNotExist() } + jsonPath("$.items[0].options[1].explanationText") { doesNotExist() } + } } @Test @@ -335,28 +363,37 @@ class TaskApiIntegrationTests { val stop = createMapStop(slug = "nyhetskvartalet", title = "News Quarter", displayOrder = 1) val teacherAccessToken = teacherAccessToken("owner@example.com", "secret123") - val createdBody = readJson( - mockMvc.post("/api/v1/classrooms/${classroom.id}/tasks") { + val createdBody = + readJson( + mockMvc + .post("/api/v1/classrooms/${classroom.id}/tasks") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer $teacherAccessToken") + content = newsComparisonTaskPayload(stopId = requireNotNull(stop.id)) + }.andExpect { + status { isCreated() } + }.andReturn() + .response.contentAsString, + ) + val taskId = createdBody.get("id").asLong() + val taskItemId = createdBody.get("items")[0].get("id").asLong() + val wrongOptionId = + createdBody + .get("items")[0] + .get("options")[0] + .get("id") + .asLong() + + mockMvc + .patch("/api/v1/classrooms/${classroom.id}/tasks/$taskId/publish") { with(csrf()) contentType = MediaType.APPLICATION_JSON header(HttpHeaders.AUTHORIZATION, "Bearer $teacherAccessToken") - content = newsComparisonTaskPayload(stopId = requireNotNull(stop.id)) + content = """{"published":true}""" }.andExpect { - status { isCreated() } - }.andReturn().response.contentAsString, - ) - val taskId = createdBody.get("id").asLong() - val taskItemId = createdBody.get("items")[0].get("id").asLong() - val wrongOptionId = createdBody.get("items")[0].get("options")[0].get("id").asLong() - - mockMvc.patch("/api/v1/classrooms/${classroom.id}/tasks/$taskId/publish") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer $teacherAccessToken") - content = """{"published":true}""" - }.andExpect { - status { isOk() } - } + status { isOk() } + } val pupil = userRepository.save(activePupil("submitter_wrong")) classroom.addPupilMembership(pupil) @@ -364,41 +401,44 @@ class TaskApiIntegrationTests { createStopProgress(pupil, classroom, stop, StopProgressStatus.NOT_STARTED) val pupilAccessToken = pupilAccessToken("submitter_wrong", "secret123") - mockMvc.post("/api/v1/classrooms/${classroom.id}/tasks/submit-stop") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer $pupilAccessToken") - content = """ - { - "stopId": ${requireNotNull(stop.id)}, - "taskAnswers": [ + mockMvc + .post("/api/v1/classrooms/${classroom.id}/tasks/submit-stop") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer $pupilAccessToken") + content = + """ { - "taskId": $taskId, - "itemAnswers": [ + "stopId": ${requireNotNull(stop.id)}, + "taskAnswers": [ { - "itemId": $taskItemId, - "selectedOptionId": $wrongOptionId + "taskId": $taskId, + "itemAnswers": [ + { + "itemId": $taskItemId, + "selectedOptionId": $wrongOptionId + } + ] } ] } - ] + """.trimIndent() + }.andExpect { + status { isOk() } + jsonPath("$.passed") { value(false) } + jsonPath("$.stopStatus") { value("IN_PROGRESS") } + jsonPath("$.taskResults[0].passed") { value(false) } + jsonPath("$.taskResults[0].feedbackText.en") { + value("This article uses sensational language and makes unsupported claims.") } - """.trimIndent() - }.andExpect { - status { isOk() } - jsonPath("$.passed") { value(false) } - jsonPath("$.stopStatus") { value("IN_PROGRESS") } - jsonPath("$.taskResults[0].passed") { value(false) } - jsonPath("$.taskResults[0].feedbackText.en") { - value("This article uses sensational language and makes unsupported claims.") } - } - val progress = pupilStopProgressRepository.findByIdPupilUserIdAndIdClassroomIdAndIdStopId( - requireNotNull(pupil.id), - requireNotNull(classroom.id), - requireNotNull(stop.id), - ) + val progress = + pupilStopProgressRepository.findByIdPupilUserIdAndIdClassroomIdAndIdStopId( + requireNotNull(pupil.id), + requireNotNull(classroom.id), + requireNotNull(stop.id), + ) assertEquals(StopProgressStatus.IN_PROGRESS, progress?.status) assertEquals(taskId, progress?.currentTaskId) } @@ -410,28 +450,37 @@ class TaskApiIntegrationTests { val stop = createMapStop(slug = "nyhetskvartalet", title = "News Quarter", displayOrder = 1) val teacherAccessToken = teacherAccessToken("owner@example.com", "secret123") - val createdBody = readJson( - mockMvc.post("/api/v1/classrooms/${classroom.id}/tasks") { + val createdBody = + readJson( + mockMvc + .post("/api/v1/classrooms/${classroom.id}/tasks") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer $teacherAccessToken") + content = newsComparisonTaskPayload(stopId = requireNotNull(stop.id)) + }.andExpect { + status { isCreated() } + }.andReturn() + .response.contentAsString, + ) + val taskId = createdBody.get("id").asLong() + val taskItemId = createdBody.get("items")[0].get("id").asLong() + val correctOptionId = + createdBody + .get("items")[0] + .get("options")[1] + .get("id") + .asLong() + + mockMvc + .patch("/api/v1/classrooms/${classroom.id}/tasks/$taskId/publish") { with(csrf()) contentType = MediaType.APPLICATION_JSON header(HttpHeaders.AUTHORIZATION, "Bearer $teacherAccessToken") - content = newsComparisonTaskPayload(stopId = requireNotNull(stop.id)) + content = """{"published":true}""" }.andExpect { - status { isCreated() } - }.andReturn().response.contentAsString, - ) - val taskId = createdBody.get("id").asLong() - val taskItemId = createdBody.get("items")[0].get("id").asLong() - val correctOptionId = createdBody.get("items")[0].get("options")[1].get("id").asLong() - - mockMvc.patch("/api/v1/classrooms/${classroom.id}/tasks/$taskId/publish") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer $teacherAccessToken") - content = """{"published":true}""" - }.andExpect { - status { isOk() } - } + status { isOk() } + } val pupil = userRepository.save(activePupil("submitter_correct")) classroom.addPupilMembership(pupil) @@ -439,39 +488,42 @@ class TaskApiIntegrationTests { createStopProgress(pupil, classroom, stop, StopProgressStatus.NOT_STARTED) val pupilAccessToken = pupilAccessToken("submitter_correct", "secret123") - mockMvc.post("/api/v1/classrooms/${classroom.id}/tasks/submit-stop") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer $pupilAccessToken") - content = """ - { - "stopId": ${requireNotNull(stop.id)}, - "taskAnswers": [ + mockMvc + .post("/api/v1/classrooms/${classroom.id}/tasks/submit-stop") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer $pupilAccessToken") + content = + """ { - "taskId": $taskId, - "itemAnswers": [ + "stopId": ${requireNotNull(stop.id)}, + "taskAnswers": [ { - "itemId": $taskItemId, - "selectedOptionId": $correctOptionId + "taskId": $taskId, + "itemAnswers": [ + { + "itemId": $taskItemId, + "selectedOptionId": $correctOptionId + } + ] } ] } - ] - } - """.trimIndent() - }.andExpect { - status { isOk() } - jsonPath("$.passed") { value(true) } - jsonPath("$.score") { value(100) } - jsonPath("$.stopStatus") { value("COMPLETED") } - jsonPath("$.taskResults[0].passed") { value(true) } - } + """.trimIndent() + }.andExpect { + status { isOk() } + jsonPath("$.passed") { value(true) } + jsonPath("$.score") { value(100) } + jsonPath("$.stopStatus") { value("COMPLETED") } + jsonPath("$.taskResults[0].passed") { value(true) } + } - val progress = pupilStopProgressRepository.findByIdPupilUserIdAndIdClassroomIdAndIdStopId( - requireNotNull(pupil.id), - requireNotNull(classroom.id), - requireNotNull(stop.id), - ) + val progress = + pupilStopProgressRepository.findByIdPupilUserIdAndIdClassroomIdAndIdStopId( + requireNotNull(pupil.id), + requireNotNull(classroom.id), + requireNotNull(stop.id), + ) assertEquals(StopProgressStatus.COMPLETED, progress?.status) assertEquals(100, progress?.bestScore) assertEquals(true, progress?.medalAwarded) @@ -485,109 +537,130 @@ class TaskApiIntegrationTests { val stop = createMapStop(slug = "nyhetskvartalet", title = "News Quarter", displayOrder = 1) val teacherAccessToken = teacherAccessToken("owner@example.com", "secret123") - val createdBody = readJson( - mockMvc.post("/api/v1/classrooms/${classroom.id}/tasks") { + val createdBody = + readJson( + mockMvc + .post("/api/v1/classrooms/${classroom.id}/tasks") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer $teacherAccessToken") + content = newsComparisonTaskPayload(stopId = requireNotNull(stop.id)) + }.andExpect { + status { isCreated() } + }.andReturn() + .response.contentAsString, + ) + val taskId = createdBody.get("id").asLong() + val taskItemId = createdBody.get("items")[0].get("id").asLong() + val correctOptionId = + createdBody + .get("items")[0] + .get("options")[1] + .get("id") + .asLong() + val wrongOptionId = + createdBody + .get("items")[0] + .get("options")[0] + .get("id") + .asLong() + + mockMvc + .patch("/api/v1/classrooms/${classroom.id}/tasks/$taskId/publish") { with(csrf()) contentType = MediaType.APPLICATION_JSON header(HttpHeaders.AUTHORIZATION, "Bearer $teacherAccessToken") - content = newsComparisonTaskPayload(stopId = requireNotNull(stop.id)) + content = """{"published":true}""" }.andExpect { - status { isCreated() } - }.andReturn().response.contentAsString, - ) - val taskId = createdBody.get("id").asLong() - val taskItemId = createdBody.get("items")[0].get("id").asLong() - val correctOptionId = createdBody.get("items")[0].get("options")[1].get("id").asLong() - val wrongOptionId = createdBody.get("items")[0].get("options")[0].get("id").asLong() - - mockMvc.patch("/api/v1/classrooms/${classroom.id}/tasks/$taskId/publish") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer $teacherAccessToken") - content = """{"published":true}""" - }.andExpect { - status { isOk() } - } + status { isOk() } + } val pupil = userRepository.save(activePupil("submitter_completed")) classroom.addPupilMembership(pupil) classroomRepository.saveAndFlush(classroom) createStopProgress(pupil, classroom, stop, StopProgressStatus.COMPLETED) // Set initial best score to 50 - val progress = pupilStopProgressRepository.findByIdPupilUserIdAndIdClassroomIdAndIdStopId( - requireNotNull(pupil.id), - requireNotNull(classroom.id), - requireNotNull(stop.id), - ) + val progress = + pupilStopProgressRepository.findByIdPupilUserIdAndIdClassroomIdAndIdStopId( + requireNotNull(pupil.id), + requireNotNull(classroom.id), + requireNotNull(stop.id), + ) progress?.bestScore = 50 pupilStopProgressRepository.save(progress!!) - + val pupilAccessToken = pupilAccessToken("submitter_completed", "secret123") // 1. Submit with a lower score - mockMvc.post("/api/v1/classrooms/${classroom.id}/tasks/submit-stop") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer $pupilAccessToken") - content = """ - { - "stopId": ${requireNotNull(stop.id)}, - "taskAnswers": [ + mockMvc + .post("/api/v1/classrooms/${classroom.id}/tasks/submit-stop") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer $pupilAccessToken") + content = + """ { - "taskId": $taskId, - "itemAnswers": [ + "stopId": ${requireNotNull(stop.id)}, + "taskAnswers": [ { - "itemId": $taskItemId, - "selectedOptionId": $wrongOptionId + "taskId": $taskId, + "itemAnswers": [ + { + "itemId": $taskItemId, + "selectedOptionId": $wrongOptionId + } + ] } ] } - ] - } - """.trimIndent() - }.andExpect { - status { isOk() } - jsonPath("$.score") { value(0) } - } - - val progressAfterLower = pupilStopProgressRepository.findByIdPupilUserIdAndIdClassroomIdAndIdStopId( - requireNotNull(pupil.id), - requireNotNull(classroom.id), - requireNotNull(stop.id), - ) + """.trimIndent() + }.andExpect { + status { isOk() } + jsonPath("$.score") { value(0) } + } + + val progressAfterLower = + pupilStopProgressRepository.findByIdPupilUserIdAndIdClassroomIdAndIdStopId( + requireNotNull(pupil.id), + requireNotNull(classroom.id), + requireNotNull(stop.id), + ) assertEquals(50, progressAfterLower?.bestScore) // 2. Submit with a higher score - mockMvc.post("/api/v1/classrooms/${classroom.id}/tasks/submit-stop") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer $pupilAccessToken") - content = """ - { - "stopId": ${requireNotNull(stop.id)}, - "taskAnswers": [ + mockMvc + .post("/api/v1/classrooms/${classroom.id}/tasks/submit-stop") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer $pupilAccessToken") + content = + """ { - "taskId": $taskId, - "itemAnswers": [ + "stopId": ${requireNotNull(stop.id)}, + "taskAnswers": [ { - "itemId": $taskItemId, - "selectedOptionId": $correctOptionId + "taskId": $taskId, + "itemAnswers": [ + { + "itemId": $taskItemId, + "selectedOptionId": $correctOptionId + } + ] } ] } - ] - } - """.trimIndent() - }.andExpect { - status { isOk() } - jsonPath("$.score") { value(100) } - } + """.trimIndent() + }.andExpect { + status { isOk() } + jsonPath("$.score") { value(100) } + } - val progressAfterHigher = pupilStopProgressRepository.findByIdPupilUserIdAndIdClassroomIdAndIdStopId( - requireNotNull(pupil.id), - requireNotNull(classroom.id), - requireNotNull(stop.id), - ) + val progressAfterHigher = + pupilStopProgressRepository.findByIdPupilUserIdAndIdClassroomIdAndIdStopId( + requireNotNull(pupil.id), + requireNotNull(classroom.id), + requireNotNull(stop.id), + ) assertEquals(100, progressAfterHigher?.bestScore) } @@ -598,28 +671,37 @@ class TaskApiIntegrationTests { val stop = createMapStop(slug = "bildekvartalet", title = "Image Quarter", displayOrder = 1) val teacherAccessToken = teacherAccessToken("owner@example.com", "secret123") - val createdBody = readJson( - mockMvc.post("/api/v1/classrooms/${classroom.id}/tasks") { + val createdBody = + readJson( + mockMvc + .post("/api/v1/classrooms/${classroom.id}/tasks") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer $teacherAccessToken") + content = optionTaskPayload(stopId = requireNotNull(stop.id)) + }.andExpect { + status { isCreated() } + }.andReturn() + .response.contentAsString, + ) + val taskId = createdBody.get("id").asLong() + val taskItemId = createdBody.get("items")[0].get("id").asLong() + val wrongOptionId = + createdBody + .get("items")[0] + .get("options")[1] + .get("id") + .asLong() + + mockMvc + .patch("/api/v1/classrooms/${classroom.id}/tasks/$taskId/publish") { with(csrf()) contentType = MediaType.APPLICATION_JSON header(HttpHeaders.AUTHORIZATION, "Bearer $teacherAccessToken") - content = optionTaskPayload(stopId = requireNotNull(stop.id)) + content = """{"published":true}""" }.andExpect { - status { isCreated() } - }.andReturn().response.contentAsString, - ) - val taskId = createdBody.get("id").asLong() - val taskItemId = createdBody.get("items")[0].get("id").asLong() - val wrongOptionId = createdBody.get("items")[0].get("options")[1].get("id").asLong() - - mockMvc.patch("/api/v1/classrooms/${classroom.id}/tasks/$taskId/publish") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer $teacherAccessToken") - content = """{"published":true}""" - }.andExpect { - status { isOk() } - } + status { isOk() } + } val pupil = userRepository.save(activePupil("submitter_option_wrong")) classroom.addPupilMembership(pupil) @@ -627,35 +709,37 @@ class TaskApiIntegrationTests { createStopProgress(pupil, classroom, stop, StopProgressStatus.NOT_STARTED) val pupilAccessToken = pupilAccessToken("submitter_option_wrong", "secret123") - mockMvc.post("/api/v1/classrooms/${classroom.id}/tasks/submit-stop") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer $pupilAccessToken") - content = """ - { - "stopId": ${requireNotNull(stop.id)}, - "taskAnswers": [ + mockMvc + .post("/api/v1/classrooms/${classroom.id}/tasks/submit-stop") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer $pupilAccessToken") + content = + """ { - "taskId": $taskId, - "itemAnswers": [ + "stopId": ${requireNotNull(stop.id)}, + "taskAnswers": [ { - "itemId": $taskItemId, - "selectedOptionId": $wrongOptionId + "taskId": $taskId, + "itemAnswers": [ + { + "itemId": $taskItemId, + "selectedOptionId": $wrongOptionId + } + ] } ] } - ] + """.trimIndent() + }.andExpect { + status { isOk() } + jsonPath("$.passed") { value(false) } + jsonPath("$.stopStatus") { value("IN_PROGRESS") } + jsonPath("$.taskResults[0].passed") { value(false) } + jsonPath("$.taskResults[0].feedbackText.en") { + value("This option ignores the warning signs in the image.") } - """.trimIndent() - }.andExpect { - status { isOk() } - jsonPath("$.passed") { value(false) } - jsonPath("$.stopStatus") { value("IN_PROGRESS") } - jsonPath("$.taskResults[0].passed") { value(false) } - jsonPath("$.taskResults[0].feedbackText.en") { - value("This option ignores the warning signs in the image.") } - } } @Test @@ -665,28 +749,37 @@ class TaskApiIntegrationTests { val stop = createMapStop(slug = "bildekvartalet", title = "Image Quarter", displayOrder = 1) val teacherAccessToken = teacherAccessToken("owner@example.com", "secret123") - val createdBody = readJson( - mockMvc.post("/api/v1/classrooms/${classroom.id}/tasks") { + val createdBody = + readJson( + mockMvc + .post("/api/v1/classrooms/${classroom.id}/tasks") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer $teacherAccessToken") + content = optionTaskPayload(stopId = requireNotNull(stop.id), includeWrongExplanation = false) + }.andExpect { + status { isCreated() } + }.andReturn() + .response.contentAsString, + ) + val taskId = createdBody.get("id").asLong() + val taskItemId = createdBody.get("items")[0].get("id").asLong() + val wrongOptionId = + createdBody + .get("items")[0] + .get("options")[1] + .get("id") + .asLong() + + mockMvc + .patch("/api/v1/classrooms/${classroom.id}/tasks/$taskId/publish") { with(csrf()) contentType = MediaType.APPLICATION_JSON header(HttpHeaders.AUTHORIZATION, "Bearer $teacherAccessToken") - content = optionTaskPayload(stopId = requireNotNull(stop.id), includeWrongExplanation = false) + content = """{"published":true}""" }.andExpect { - status { isCreated() } - }.andReturn().response.contentAsString, - ) - val taskId = createdBody.get("id").asLong() - val taskItemId = createdBody.get("items")[0].get("id").asLong() - val wrongOptionId = createdBody.get("items")[0].get("options")[1].get("id").asLong() - - mockMvc.patch("/api/v1/classrooms/${classroom.id}/tasks/$taskId/publish") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer $teacherAccessToken") - content = """{"published":true}""" - }.andExpect { - status { isOk() } - } + status { isOk() } + } val pupil = userRepository.save(activePupil("submitter_option_no_feedback")) classroom.addPupilMembership(pupil) @@ -694,37 +787,42 @@ class TaskApiIntegrationTests { createStopProgress(pupil, classroom, stop, StopProgressStatus.NOT_STARTED) val pupilAccessToken = pupilAccessToken("submitter_option_no_feedback", "secret123") - mockMvc.post("/api/v1/classrooms/${classroom.id}/tasks/submit-stop") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer $pupilAccessToken") - content = """ - { - "stopId": ${requireNotNull(stop.id)}, - "taskAnswers": [ + mockMvc + .post("/api/v1/classrooms/${classroom.id}/tasks/submit-stop") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer $pupilAccessToken") + content = + """ { - "taskId": $taskId, - "itemAnswers": [ + "stopId": ${requireNotNull(stop.id)}, + "taskAnswers": [ { - "itemId": $taskItemId, - "selectedOptionId": $wrongOptionId + "taskId": $taskId, + "itemAnswers": [ + { + "itemId": $taskItemId, + "selectedOptionId": $wrongOptionId + } + ] } ] } - ] - } - """.trimIndent() - }.andExpect { - status { isOk() } - jsonPath("$.passed") { value(false) } - jsonPath("$.score") { value(0) } - jsonPath("$.stopStatus") { value("IN_PROGRESS") } - jsonPath("$.taskResults[0].passed") { value(false) } - jsonPath("$.taskResults[0].feedbackText") { doesNotExist() } - } + """.trimIndent() + }.andExpect { + status { isOk() } + jsonPath("$.passed") { value(false) } + jsonPath("$.score") { value(0) } + jsonPath("$.stopStatus") { value("IN_PROGRESS") } + jsonPath("$.taskResults[0].passed") { value(false) } + jsonPath("$.taskResults[0].feedbackText") { doesNotExist() } + } } - private fun createClassroom(owner: User, joinCode: String): Classroom = + private fun createClassroom( + owner: User, + joinCode: String, + ): Classroom = classroomRepository.save( Classroom( ownerTeacherId = requireNotNull(owner.id), @@ -735,7 +833,11 @@ class TaskApiIntegrationTests { ), ) - private fun createMapStop(slug: String, title: String, displayOrder: Int): MapStop = + private fun createMapStop( + slug: String, + title: String, + displayOrder: Int, + ): MapStop = mapStopsRepository.save( MapStop( slug = slug, @@ -763,26 +865,36 @@ class TaskApiIntegrationTests { }, ) - private fun teacherAccessToken(email: String, password: String): String = + private fun teacherAccessToken( + email: String, + password: String, + ): String = readJson( - mockMvc.post("/api/v1/auth/teacher/login") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"email":"$email","password":"$password"}""" - }.andExpect { - status { isOk() } - }.andReturn().response.contentAsString, + mockMvc + .post("/api/v1/auth/teacher/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"email":"$email","password":"$password"}""" + }.andExpect { + status { isOk() } + }.andReturn() + .response.contentAsString, ).get("accessToken").asText() - private fun pupilAccessToken(username: String, password: String): String = + private fun pupilAccessToken( + username: String, + password: String, + ): String = readJson( - mockMvc.post("/api/v1/auth/pupil/login") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"username":"$username","password":"$password"}""" - }.andExpect { - status { isOk() } - }.andReturn().response.contentAsString, + mockMvc + .post("/api/v1/auth/pupil/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"username":"$username","password":"$password"}""" + }.andExpect { + status { isOk() } + }.andReturn() + .response.contentAsString, ).get("accessToken").asText() private fun readJson(content: String): JsonNode = jsonMapper.readTree(content) @@ -808,120 +920,120 @@ class TaskApiIntegrationTests { difficultyLevel: Int = 1, ): String = """ + { + "stopId": 1, + "title": {"nb":"Finn den ekte artikkelen","en":"$title"}, + "introText": {"nb":"Velg den troverdige artikkelen.","en":"Choose the trustworthy article."}, + "difficultyLevel": $difficultyLevel, + "taskType": "NEWS_COMPARISON", + "passingRule": "ALL_CORRECT", + "passingScore": 100, + "maxScore": 100, + "items": [ { - "stopId": 1, - "title": {"nb":"Finn den ekte artikkelen","en":"$title"}, - "introText": {"nb":"Velg den troverdige artikkelen.","en":"Choose the trustworthy article."}, - "difficultyLevel": $difficultyLevel, - "taskType": "NEWS_COMPARISON", - "passingRule": "ALL_CORRECT", - "passingScore": 100, - "maxScore": 100, - "items": [ + "itemOrder": 1, + "promptText": {"nb":"Hvilken artikkel er troverdig?","en":"Which article is reliable?"}, + "metadataJson": "{\"articleCount\":2}", + "options": [ { - "itemOrder": 1, - "promptText": {"nb":"Hvilken artikkel er troverdig?","en":"Which article is reliable?"}, - "metadataJson": "{\"articleCount\":2}", - "options": [ - { - "optionOrder": 1, - "optionText": {"nb":"Artikkel A","en":"Article A"}, - "isCorrect": true, - "explanationText": {"nb":"Denne kilden er verifisert.","en":"This source is verified."} - }, - { - "optionOrder": 2, - "optionText": {"nb":"Artikkel B","en":"Article B"}, - "isCorrect": false, - "explanationText": {"nb":"Denne kilden bruker klikkagn.","en":"This source uses clickbait."} - } - ] + "optionOrder": 1, + "optionText": {"nb":"Artikkel A","en":"Article A"}, + "isCorrect": true, + "explanationText": {"nb":"Denne kilden er verifisert.","en":"This source is verified."} + }, + { + "optionOrder": 2, + "optionText": {"nb":"Artikkel B","en":"Article B"}, + "isCorrect": false, + "explanationText": {"nb":"Denne kilden bruker klikkagn.","en":"This source uses clickbait."} } ] } + ] + } """.trimIndent() private fun jsonString(value: String): String = jsonMapper.writeValueAsString(value) private fun newsComparisonMetadataJson(): String = """ + { + "articles": [ { - "articles": [ - { - "optionOrder": 1, - "domain": "clickbait.example", - "headline": { - "nb": "Sensasjonell artikkel", - "en": "Sensational article" - }, - "body": { - "nb": "Denne artikkelen bruker sensasjonelt språk og udokumenterte påstander.", - "en": "This article uses sensational language and makes unsupported claims." - }, - "mediaUrl": null - }, - { - "optionOrder": 2, - "domain": "news.example", - "headline": { - "nb": "Nøktern artikkel", - "en": "Measured article" - }, - "body": { - "nb": "Denne artikkelen oppgir kilde og forklarer fakta på en etterprøvbar måte.", - "en": "This article names its source and explains the facts in a verifiable way." - }, - "mediaUrl": null - } - ] + "optionOrder": 1, + "domain": "clickbait.example", + "headline": { + "nb": "Sensasjonell artikkel", + "en": "Sensational article" + }, + "body": { + "nb": "Denne artikkelen bruker sensasjonelt språk og udokumenterte påstander.", + "en": "This article uses sensational language and makes unsupported claims." + }, + "mediaUrl": null + }, + { + "optionOrder": 2, + "domain": "news.example", + "headline": { + "nb": "Nøktern artikkel", + "en": "Measured article" + }, + "body": { + "nb": "Denne artikkelen oppgir kilde og forklarer fakta på en etterprøvbar måte.", + "en": "This article names its source and explains the facts in a verifiable way." + }, + "mediaUrl": null } + ] + } """.trimIndent() private fun newsComparisonTaskPayload(stopId: Long): String = """ + { + "stopId": $stopId, + "title": {"nb":"Finn den troverdige artikkelen","en":"Find the trustworthy article"}, + "introText": {"nb":"Velg artikkelen som er mest troverdig.","en":"Pick the article that is most trustworthy."}, + "difficultyLevel": 1, + "taskType": "NEWS_COMPARISON", + "passingRule": "ALL_CORRECT", + "passingScore": 100, + "maxScore": 100, + "items": [ { - "stopId": $stopId, - "title": {"nb":"Finn den troverdige artikkelen","en":"Find the trustworthy article"}, - "introText": {"nb":"Velg artikkelen som er mest troverdig.","en":"Pick the article that is most trustworthy."}, - "difficultyLevel": 1, - "taskType": "NEWS_COMPARISON", - "passingRule": "ALL_CORRECT", - "passingScore": 100, - "maxScore": 100, - "items": [ + "itemOrder": 1, + "promptText": {"nb":"Hvilken artikkel er mest troverdig?","en":"Which article is most trustworthy?"}, + "metadataJson": ${jsonString(newsComparisonMetadataJson())}, + "options": [ { - "itemOrder": 1, - "promptText": {"nb":"Hvilken artikkel er mest troverdig?","en":"Which article is most trustworthy?"}, - "metadataJson": ${jsonString(newsComparisonMetadataJson())}, - "options": [ - { - "optionOrder": 1, - "optionText": { - "nb":"Sensasjonell artikkel", - "en":"Sensational article" - }, - "isCorrect": false, - "explanationText": { - "nb":"Denne artikkelen bruker sensasjonelt språk og udokumenterte påstander.", - "en":"This article uses sensational language and makes unsupported claims." - } - }, - { - "optionOrder": 2, - "optionText": { - "nb":"Nøktern artikkel", - "en":"Measured article" - }, - "isCorrect": true, - "explanationText": { - "nb":"Denne artikkelen oppgir kilde og forklarer fakta på en etterprøvbar måte.", - "en":"This article names its source and explains the facts in a verifiable way." - } - } - ] + "optionOrder": 1, + "optionText": { + "nb":"Sensasjonell artikkel", + "en":"Sensational article" + }, + "isCorrect": false, + "explanationText": { + "nb":"Denne artikkelen bruker sensasjonelt språk og udokumenterte påstander.", + "en":"This article uses sensational language and makes unsupported claims." + } + }, + { + "optionOrder": 2, + "optionText": { + "nb":"Nøktern artikkel", + "en":"Measured article" + }, + "isCorrect": true, + "explanationText": { + "nb":"Denne artikkelen oppgir kilde og forklarer fakta på en etterprøvbar måte.", + "en":"This article names its source and explains the facts in a verifiable way." + } } ] } + ] + } """.trimIndent() private fun optionTaskPayload( @@ -931,10 +1043,10 @@ class TaskApiIntegrationTests { val wrongExplanation = if (includeWrongExplanation) { """ - "explanationText": { - "nb":"Dette alternativet overser faresignalene i bildet.", - "en":"This option ignores the warning signs in the image." - } + "explanationText": { + "nb":"Dette alternativet overser faresignalene i bildet.", + "en":"This option ignores the warning signs in the image." + } """.trimIndent() } else { "" @@ -974,6 +1086,6 @@ class TaskApiIntegrationTests { } ] } - """.trimIndent() + """.trimIndent() } } diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/game/tasks/TaskPersistenceTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/game/tasks/TaskPersistenceTests.kt index 6b793ec7..4c898dcd 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/game/tasks/TaskPersistenceTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/game/tasks/TaskPersistenceTests.kt @@ -3,16 +3,16 @@ package edu.ntnu.idi.idatt.backend.game.tasks import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom import edu.ntnu.idi.idatt.backend.classroom.domain.ClassroomStatus import edu.ntnu.idi.idatt.backend.classroom.infrastructure.ClassroomRepository -import edu.ntnu.idi.idatt.backend.iam.domain.User -import edu.ntnu.idi.idatt.backend.iam.domain.UserRole -import edu.ntnu.idi.idatt.backend.iam.domain.UserStatus -import edu.ntnu.idi.idatt.backend.iam.infrastructure.UserRepository import edu.ntnu.idi.idatt.backend.game.tasks.domain.Task import edu.ntnu.idi.idatt.backend.game.tasks.domain.TaskItem import edu.ntnu.idi.idatt.backend.game.tasks.domain.TaskOption import edu.ntnu.idi.idatt.backend.game.tasks.domain.TaskPassingRule import edu.ntnu.idi.idatt.backend.game.tasks.domain.TaskType import edu.ntnu.idi.idatt.backend.game.tasks.infrastructure.TaskRepository +import edu.ntnu.idi.idatt.backend.iam.domain.User +import edu.ntnu.idi.idatt.backend.iam.domain.UserRole +import edu.ntnu.idi.idatt.backend.iam.domain.UserStatus +import edu.ntnu.idi.idatt.backend.iam.infrastructure.UserRepository import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Test @@ -23,149 +23,159 @@ import org.springframework.dao.DataIntegrityViolationException import java.util.concurrent.atomic.AtomicInteger @SpringBootTest -class TaskPersistenceTests @Autowired constructor( - private val userRepository: UserRepository, - private val classroomRepository: ClassroomRepository, - private val taskRepository: TaskRepository, -) { - companion object { - private val sequence = AtomicInteger() - } +class TaskPersistenceTests + @Autowired + constructor( + private val userRepository: UserRepository, + private val classroomRepository: ClassroomRepository, + private val taskRepository: TaskRepository, + ) { + companion object { + private val sequence = AtomicInteger() + } - @Test - fun `persists task with ordered items and options`() { - val teacher = teacher() - val classroom = classroom(teacher) - val stopId = stopId() - - val task = Task( - stopId = stopId, - classroom = classroom, - createdByTeacher = teacher, - title = "Find the real article", - introText = "Choose the trustworthy article.", - difficultyLevel = 1, - taskType = TaskType.NEWS_COMPARISON, - passingRule = TaskPassingRule.ALL_CORRECT, - maxScore = 100, - passingScore = 100, - isPublished = true, - ) - - val firstItem = TaskItem( - itemOrder = 1, - promptText = "Which article is reliable?", - metadataJson = """{"articleCount":2}""", - ) - firstItem.addOption(TaskOption(optionOrder = 1, optionText = "Article A", isCorrect = true)) - firstItem.addOption(TaskOption(optionOrder = 2, optionText = "Article B")) - task.addItem(firstItem) - - val saved = taskRepository.saveAndFlush(task) - - assertNotNull(saved.id) - assertEquals(1, saved.items.size) - assertEquals(2, saved.items.first().options.size) - } + @Test + fun `persists task with ordered items and options`() { + val teacher = teacher() + val classroom = classroom(teacher) + val stopId = stopId() + + val task = + Task( + stopId = stopId, + classroom = classroom, + createdByTeacher = teacher, + title = "Find the real article", + introText = "Choose the trustworthy article.", + difficultyLevel = 1, + taskType = TaskType.NEWS_COMPARISON, + passingRule = TaskPassingRule.ALL_CORRECT, + maxScore = 100, + passingScore = 100, + isPublished = true, + ) + + val firstItem = + TaskItem( + itemOrder = 1, + promptText = "Which article is reliable?", + metadataJson = """{"articleCount":2}""", + ) + firstItem.addOption(TaskOption(optionOrder = 1, optionText = "Article A", isCorrect = true)) + firstItem.addOption(TaskOption(optionOrder = 2, optionText = "Article B")) + task.addItem(firstItem) + + val saved = taskRepository.saveAndFlush(task) + + assertNotNull(saved.id) + assertEquals(1, saved.items.size) + assertEquals( + 2, + saved.items + .first() + .options.size, + ) + } + + @Test + fun `enforces one difficulty slot per classroom stop`() { + val teacher = teacher() + val classroom = classroom(teacher) + val stopId = stopId() - @Test - fun `enforces one difficulty slot per classroom stop`() { - val teacher = teacher() - val classroom = classroom(teacher) - val stopId = stopId() - - taskRepository.saveAndFlush( - Task( - stopId = stopId, - classroom = classroom, - createdByTeacher = teacher, - title = "Task 1", - difficultyLevel = 1, - taskType = TaskType.GENERIC, - passingRule = TaskPassingRule.ALL_CORRECT, - maxScore = 10, - ), - ) - - assertThrows { taskRepository.saveAndFlush( Task( stopId = stopId, classroom = classroom, createdByTeacher = teacher, - title = "Task 1 duplicate", + title = "Task 1", difficultyLevel = 1, taskType = TaskType.GENERIC, passingRule = TaskPassingRule.ALL_CORRECT, maxScore = 10, ), ) + + assertThrows { + taskRepository.saveAndFlush( + Task( + stopId = stopId, + classroom = classroom, + createdByTeacher = teacher, + title = "Task 1 duplicate", + difficultyLevel = 1, + taskType = TaskType.GENERIC, + passingRule = TaskPassingRule.ALL_CORRECT, + maxScore = 10, + ), + ) + } } - } - @Test - fun `finds tasks ordered by difficulty`() { - val teacher = teacher() - val classroom = classroom(teacher) - val stopId = stopId() - - taskRepository.saveAndFlush( - Task( - stopId = stopId, - classroom = classroom, - createdByTeacher = teacher, - title = "Harder", - difficultyLevel = 2, - taskType = TaskType.GENERIC, - passingRule = TaskPassingRule.ALL_CORRECT, - maxScore = 10, - ), - ) - taskRepository.saveAndFlush( - Task( - stopId = stopId, - classroom = classroom, - createdByTeacher = teacher, - title = "Easier", - difficultyLevel = 1, - taskType = TaskType.GENERIC, - passingRule = TaskPassingRule.ALL_CORRECT, - maxScore = 10, - ), - ) - - val tasks = taskRepository.findAllByClassroomIdAndStopIdOrderByDifficultyLevelAsc( - classroomId = classroom.id, - stopId = stopId, - ) - - assertEquals(listOf("Easier", "Harder"), tasks.map { it.title }) - } + @Test + fun `finds tasks ordered by difficulty`() { + val teacher = teacher() + val classroom = classroom(teacher) + val stopId = stopId() - private fun teacher(): User = - sequence.incrementAndGet().let { suffix -> - userRepository.saveAndFlush( - User( - email = "teacher$suffix@example.com", - passwordHash = "hash", - role = UserRole.TEACHER, - status = UserStatus.ACTIVE, - ), - ) - } + taskRepository.saveAndFlush( + Task( + stopId = stopId, + classroom = classroom, + createdByTeacher = teacher, + title = "Harder", + difficultyLevel = 2, + taskType = TaskType.GENERIC, + passingRule = TaskPassingRule.ALL_CORRECT, + maxScore = 10, + ), + ) + taskRepository.saveAndFlush( + Task( + stopId = stopId, + classroom = classroom, + createdByTeacher = teacher, + title = "Easier", + difficultyLevel = 1, + taskType = TaskType.GENERIC, + passingRule = TaskPassingRule.ALL_CORRECT, + maxScore = 10, + ), + ) - private fun classroom(ownerTeacher: User): Classroom = - sequence.incrementAndGet().let { suffix -> - classroomRepository.saveAndFlush( - Classroom( - ownerTeacherId = requireNotNull(ownerTeacher.id), - title = "5A-$suffix", - description = "Investigators", - joinCode = "ABCDE$suffix", - status = ClassroomStatus.ACTIVE, - ), - ) + val tasks = + taskRepository.findAllByClassroomIdAndStopIdOrderByDifficultyLevelAsc( + classroomId = classroom.id, + stopId = stopId, + ) + + assertEquals(listOf("Easier", "Harder"), tasks.map { it.title }) } - private fun stopId(): Long = sequence.incrementAndGet().toLong() -} + private fun teacher(): User = + sequence.incrementAndGet().let { suffix -> + userRepository.saveAndFlush( + User( + email = "teacher$suffix@example.com", + passwordHash = "hash", + role = UserRole.TEACHER, + status = UserStatus.ACTIVE, + ), + ) + } + + private fun classroom(ownerTeacher: User): Classroom = + sequence.incrementAndGet().let { suffix -> + classroomRepository.saveAndFlush( + Classroom( + ownerTeacherId = requireNotNull(ownerTeacher.id), + title = "5A-$suffix", + description = "Investigators", + joinCode = "ABCDE$suffix", + status = ClassroomStatus.ACTIVE, + ), + ) + } + + private fun stopId(): Long = sequence.incrementAndGet().toLong() + } diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/AuthControllerIntegrationTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/AuthControllerIntegrationTests.kt index 113adfed..7c8e2fe7 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/AuthControllerIntegrationTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/AuthControllerIntegrationTests.kt @@ -1,17 +1,17 @@ package edu.ntnu.idi.idatt.backend.iam -import edu.ntnu.idi.idatt.backend.iam.security.AppSecurityProperties import edu.ntnu.idi.idatt.backend.iam.domain.User import edu.ntnu.idi.idatt.backend.iam.domain.UserRole import edu.ntnu.idi.idatt.backend.iam.domain.UserStatus import edu.ntnu.idi.idatt.backend.iam.infrastructure.RefreshTokenRepository import edu.ntnu.idi.idatt.backend.iam.infrastructure.UserRepository +import edu.ntnu.idi.idatt.backend.iam.security.AppSecurityProperties import edu.ntnu.idi.idatt.backend.users.avatar.domain.UserAvatar import edu.ntnu.idi.idatt.backend.users.avatar.infrastructure.UserAvatarRepository import edu.ntnu.idi.idatt.backend.users.profile.infrastructure.TeacherProfileRepository import jakarta.servlet.http.Cookie -import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNotEquals import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull @@ -70,12 +70,12 @@ class AuthControllerIntegrationTests { userRepository.deleteAll() } - private fun encodedPassword(rawPassword: String): String = - requireNotNull(passwordEncoder.encode(rawPassword)) + private fun encodedPassword(rawPassword: String): String = requireNotNull(passwordEncoder.encode(rawPassword)) @Test fun `csrf endpoint is public and returns token payload`() { - mockMvc.get("/api/v1/auth/csrf") + mockMvc + .get("/api/v1/auth/csrf") .andExpect { status { isOk() } jsonPath("$.headerName") { value("X-XSRF-TOKEN") } @@ -88,17 +88,20 @@ class AuthControllerIntegrationTests { fun `teacher login persists refresh token and sets refresh cookie`() { userRepository.save(activeTeacher()) - val response = mockMvc.post("/api/v1/auth/teacher/login") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"email":"teacher@example.com","password":"secret123"}""" - }.andExpect { - status { isOk() } - jsonPath("$.tokenType") { value("Bearer") } - jsonPath("$.portalType") { value("TEACHER") } - jsonPath("$.user.email") { value("teacher@example.com") } - cookie { exists("nettdetektivene_refresh_token") } - }.andReturn().response + val response = + mockMvc + .post("/api/v1/auth/teacher/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"email":"teacher@example.com","password":"secret123"}""" + }.andExpect { + status { isOk() } + jsonPath("$.tokenType") { value("Bearer") } + jsonPath("$.portalType") { value("TEACHER") } + jsonPath("$.user.email") { value("teacher@example.com") } + cookie { exists("nettdetektivene_refresh_token") } + }.andReturn() + .response val rawRefreshToken = extractRefreshToken(response.getHeaders("Set-Cookie")) val persistedToken = refreshTokenRepository.findAll().single() @@ -111,18 +114,20 @@ class AuthControllerIntegrationTests { @Test fun `teacher registration creates teacher user and profile`() { - mockMvc.post("/api/v1/auth/teacher/register") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """ - {"email":"teacher@example.com","password":"secret123","fullName":"Ada Lovelace","schoolName":"Trondheim skole"} - """.trimIndent() - }.andExpect { - status { isCreated() } - jsonPath("$.id") { exists() } - jsonPath("$.role") { value("TEACHER") } - jsonPath("$.status") { value("ACTIVE") } - } + mockMvc + .post("/api/v1/auth/teacher/register") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = + """ + {"email":"teacher@example.com","password":"secret123","fullName":"Ada Lovelace","schoolName":"Trondheim skole"} + """.trimIndent() + }.andExpect { + status { isCreated() } + jsonPath("$.id") { exists() } + jsonPath("$.role") { value("TEACHER") } + jsonPath("$.status") { value("ACTIVE") } + } val persistedUser = requireNotNull(userRepository.findByEmailIgnoreCase("teacher@example.com")) val persistedProfile = teacherProfileRepository.findAll().single() @@ -138,40 +143,45 @@ class AuthControllerIntegrationTests { @Test fun `registered teacher can log in afterwards`() { - mockMvc.post("/api/v1/auth/teacher/register") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """ - {"email":"teacher@example.com","password":"secret123","fullName":"Ada Lovelace","schoolName":"Trondheim skole"} - """.trimIndent() - }.andExpect { - status { isCreated() } - } - - mockMvc.post("/api/v1/auth/teacher/login") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"email":"teacher@example.com","password":"secret123"}""" - }.andExpect { - status { isOk() } - jsonPath("$.portalType") { value("TEACHER") } - cookie { exists("nettdetektivene_refresh_token") } - } + mockMvc + .post("/api/v1/auth/teacher/register") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = + """ + {"email":"teacher@example.com","password":"secret123","fullName":"Ada Lovelace","schoolName":"Trondheim skole"} + """.trimIndent() + }.andExpect { + status { isCreated() } + } + + mockMvc + .post("/api/v1/auth/teacher/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"email":"teacher@example.com","password":"secret123"}""" + }.andExpect { + status { isOk() } + jsonPath("$.portalType") { value("TEACHER") } + cookie { exists("nettdetektivene_refresh_token") } + } } @Test fun `teacher registration rejects duplicate email`() { userRepository.save(activeTeacher()) - mockMvc.post("/api/v1/auth/teacher/register") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """ - {"email":"teacher@example.com","password":"secret123","fullName":"Ada Lovelace","schoolName":"Trondheim skole"} - """.trimIndent() - }.andExpect { - status { isConflict() } - } + mockMvc + .post("/api/v1/auth/teacher/register") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = + """ + {"email":"teacher@example.com","password":"secret123","fullName":"Ada Lovelace","schoolName":"Trondheim skole"} + """.trimIndent() + }.andExpect { + status { isConflict() } + } assertEquals(1, userRepository.count()) assertEquals(0, teacherProfileRepository.count()) @@ -179,15 +189,17 @@ class AuthControllerIntegrationTests { @Test fun `teacher registration validates request body`() { - mockMvc.post("/api/v1/auth/teacher/register") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """ - {"email":"teacher@example.com","password":"short","fullName":"","schoolName":"Trondheim skole"} - """.trimIndent() - }.andExpect { - status { isBadRequest() } - } + mockMvc + .post("/api/v1/auth/teacher/register") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = + """ + {"email":"teacher@example.com","password":"short","fullName":"","schoolName":"Trondheim skole"} + """.trimIndent() + }.andExpect { + status { isBadRequest() } + } assertEquals(0, userRepository.count()) assertEquals(0, teacherProfileRepository.count()) @@ -195,15 +207,17 @@ class AuthControllerIntegrationTests { @Test fun `teacher registration rejects html like profile text`() { - mockMvc.post("/api/v1/auth/teacher/register") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """ - {"email":"teacher@example.com","password":"secret123","fullName":"","schoolName":"Trondheim skole"} - """.trimIndent() - }.andExpect { - status { isBadRequest() } - } + mockMvc + .post("/api/v1/auth/teacher/register") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = + """ + {"email":"teacher@example.com","password":"secret123","fullName":"","schoolName":"Trondheim skole"} + """.trimIndent() + }.andExpect { + status { isBadRequest() } + } assertEquals(0, userRepository.count()) assertEquals(0, teacherProfileRepository.count()) @@ -211,16 +225,19 @@ class AuthControllerIntegrationTests { @Test fun `pupil login creates missing pupil user and sets refresh cookie`() { - val response = mockMvc.post("/api/v1/auth/pupil/login") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"username":"pupil_one","password":"secret123"}""" - }.andExpect { - status { isOk() } - jsonPath("$.tokenType") { value("Bearer") } - jsonPath("$.portalType") { value("PUPIL") } - cookie { exists("nettdetektivene_refresh_token") } - }.andReturn().response + val response = + mockMvc + .post("/api/v1/auth/pupil/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"username":"pupil_one","password":"secret123"}""" + }.andExpect { + status { isOk() } + jsonPath("$.tokenType") { value("Bearer") } + jsonPath("$.portalType") { value("PUPIL") } + cookie { exists("nettdetektivene_refresh_token") } + }.andReturn() + .response val persistedUser = requireNotNull(userRepository.findByUsernameIgnoreCase("pupil_one")) val rawRefreshToken = extractRefreshToken(response.getHeaders("Set-Cookie")) @@ -234,8 +251,10 @@ class AuthControllerIntegrationTests { assertNotEquals("secret123", persistedUser.passwordHash) assertNotNull(rawRefreshToken) assertEquals(64, persistedToken.tokenHash.length) - val persistedAvatar = userAvatarRepository.findById(requireNotNull(persistedUser.id)) - .orElseThrow { AssertionError("Expected default avatar row for pupil") } + val persistedAvatar = + userAvatarRepository + .findById(requireNotNull(persistedUser.id)) + .orElseThrow { AssertionError("Expected default avatar row for pupil") } assertEquals(UserAvatar.DEFAULT_HAT_ID, persistedAvatar.hatId) assertEquals(UserAvatar.DEFAULT_OUTFIT_ID, persistedAvatar.outfitId) assertEquals(UserAvatar.DEFAULT_SKIN_COLOR_ID, persistedAvatar.skinColorId) @@ -245,14 +264,15 @@ class AuthControllerIntegrationTests { fun `existing pupil login does not create duplicate user`() { userRepository.save(activePupil()) - mockMvc.post("/api/v1/auth/pupil/login") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"username":"pupil_one","password":"secret123"}""" - }.andExpect { - status { isOk() } - jsonPath("$.portalType") { value("PUPIL") } - } + mockMvc + .post("/api/v1/auth/pupil/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"username":"pupil_one","password":"secret123"}""" + }.andExpect { + status { isOk() } + jsonPath("$.portalType") { value("PUPIL") } + } assertEquals(1, userRepository.count()) } @@ -261,13 +281,14 @@ class AuthControllerIntegrationTests { fun `existing pupil with wrong password returns unauthorized`() { userRepository.save(activePupil()) - mockMvc.post("/api/v1/auth/pupil/login") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"username":"pupil_one","password":"wrong-password"}""" - }.andExpect { - status { isUnauthorized() } - } + mockMvc + .post("/api/v1/auth/pupil/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"username":"pupil_one","password":"wrong-password"}""" + }.andExpect { + status { isUnauthorized() } + } assertEquals(1, userRepository.count()) } @@ -280,13 +301,14 @@ class AuthControllerIntegrationTests { }, ) - mockMvc.post("/api/v1/auth/pupil/login") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"username":"pupil_one","password":"secret123"}""" - }.andExpect { - status { isUnauthorized() } - } + mockMvc + .post("/api/v1/auth/pupil/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"username":"pupil_one","password":"secret123"}""" + }.andExpect { + status { isUnauthorized() } + } assertEquals(1, userRepository.count()) } @@ -295,13 +317,14 @@ class AuthControllerIntegrationTests { fun `teacher login rejects pupil credentials`() { userRepository.save(activePupil(username = "pupil_only")) - mockMvc.post("/api/v1/auth/teacher/login") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"email":"pupil_only@example.com","password":"secret123"}""" - }.andExpect { - status { isUnauthorized() } - } + mockMvc + .post("/api/v1/auth/teacher/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"email":"pupil_only@example.com","password":"secret123"}""" + }.andExpect { + status { isUnauthorized() } + } } @Test @@ -311,14 +334,17 @@ class AuthControllerIntegrationTests { val oldRefreshToken = extractRefreshToken(loginResponse.getHeaders("Set-Cookie")) val oldTokenHash = refreshTokenRepository.findAll().single().tokenHash - val refreshResponse = mockMvc.post("/api/v1/auth/teacher/refresh") { - with(csrf()) - cookie(Cookie("nettdetektivene_refresh_token", oldRefreshToken)) - }.andExpect { - status { isOk() } - jsonPath("$.portalType") { value("TEACHER") } - cookie { exists("nettdetektivene_refresh_token") } - }.andReturn().response + val refreshResponse = + mockMvc + .post("/api/v1/auth/teacher/refresh") { + with(csrf()) + cookie(Cookie("nettdetektivene_refresh_token", oldRefreshToken)) + }.andExpect { + status { isOk() } + jsonPath("$.portalType") { value("TEACHER") } + cookie { exists("nettdetektivene_refresh_token") } + }.andReturn() + .response val newRefreshToken = extractRefreshToken(refreshResponse.getHeaders("Set-Cookie")) val allTokens = refreshTokenRepository.findAll().sortedBy { it.id } @@ -337,14 +363,17 @@ class AuthControllerIntegrationTests { val oldRefreshToken = extractRefreshToken(loginResponse.getHeaders("Set-Cookie")) val oldTokenHash = refreshTokenRepository.findAll().single().tokenHash - val refreshResponse = mockMvc.post("/api/v1/auth/pupil/refresh") { - with(csrf()) - cookie(Cookie("nettdetektivene_refresh_token", oldRefreshToken)) - }.andExpect { - status { isOk() } - jsonPath("$.portalType") { value("PUPIL") } - cookie { exists("nettdetektivene_refresh_token") } - }.andReturn().response + val refreshResponse = + mockMvc + .post("/api/v1/auth/pupil/refresh") { + with(csrf()) + cookie(Cookie("nettdetektivene_refresh_token", oldRefreshToken)) + }.andExpect { + status { isOk() } + jsonPath("$.portalType") { value("PUPIL") } + cookie { exists("nettdetektivene_refresh_token") } + }.andReturn() + .response val newRefreshToken = extractRefreshToken(refreshResponse.getHeaders("Set-Cookie")) val allTokens = refreshTokenRepository.findAll().sortedBy { it.id } @@ -362,34 +391,37 @@ class AuthControllerIntegrationTests { val loginResponse = teacherLogin() val refreshToken = extractRefreshToken(loginResponse.getHeaders("Set-Cookie")) - mockMvc.post("/api/v1/auth/teacher/logout") { - with(csrf()) - cookie(Cookie("nettdetektivene_refresh_token", refreshToken)) - }.andExpect { - status { isNoContent() } - } + mockMvc + .post("/api/v1/auth/teacher/logout") { + with(csrf()) + cookie(Cookie("nettdetektivene_refresh_token", refreshToken)) + }.andExpect { + status { isNoContent() } + } val persistedToken = refreshTokenRepository.findAll().single() assertNotNull(persistedToken.revokedAt) - mockMvc.post("/api/v1/auth/teacher/refresh") { - with(csrf()) - cookie(Cookie("nettdetektivene_refresh_token", refreshToken)) - }.andExpect { - status { isUnauthorized() } - } + mockMvc + .post("/api/v1/auth/teacher/refresh") { + with(csrf()) + cookie(Cookie("nettdetektivene_refresh_token", refreshToken)) + }.andExpect { + status { isUnauthorized() } + } } @Test fun `invalid refresh token is rejected`() { userRepository.save(activeTeacher()) - mockMvc.post("/api/v1/auth/teacher/refresh") { - with(csrf()) - cookie(Cookie("nettdetektivene_refresh_token", "invalid-token")) - }.andExpect { - status { isUnauthorized() } - } + mockMvc + .post("/api/v1/auth/teacher/refresh") { + with(csrf()) + cookie(Cookie("nettdetektivene_refresh_token", "invalid-token")) + }.andExpect { + status { isUnauthorized() } + } } @Test @@ -401,12 +433,13 @@ class AuthControllerIntegrationTests { persistedToken.expiresAt = Instant.now().minusSeconds(60) refreshTokenRepository.save(persistedToken) - mockMvc.post("/api/v1/auth/teacher/refresh") { - with(csrf()) - cookie(Cookie("nettdetektivene_refresh_token", refreshToken)) - }.andExpect { - status { isUnauthorized() } - } + mockMvc + .post("/api/v1/auth/teacher/refresh") { + with(csrf()) + cookie(Cookie("nettdetektivene_refresh_token", refreshToken)) + }.andExpect { + status { isUnauthorized() } + } } @Test @@ -414,33 +447,36 @@ class AuthControllerIntegrationTests { userRepository.save(activeTeacher()) val accessToken = teacherAccessToken(email = "teacher@example.com", password = "secret123") - mockMvc.put("/api/v1/auth/me/password") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header("Authorization", "Bearer $accessToken") - content = """{"currentPassword":"secret123","newPassword":"newSecret123"}""" - }.andExpect { - status { isNoContent() } - } + mockMvc + .put("/api/v1/auth/me/password") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header("Authorization", "Bearer $accessToken") + content = """{"currentPassword":"secret123","newPassword":"newSecret123"}""" + }.andExpect { + status { isNoContent() } + } val persistedUser = requireNotNull(userRepository.findByEmailIgnoreCase("teacher@example.com")) assertTrue(passwordEncoder.matches("newSecret123", persistedUser.passwordHash)) - mockMvc.post("/api/v1/auth/teacher/login") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"email":"teacher@example.com","password":"secret123"}""" - }.andExpect { - status { isUnauthorized() } - } - - mockMvc.post("/api/v1/auth/teacher/login") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"email":"teacher@example.com","password":"newSecret123"}""" - }.andExpect { - status { isOk() } - } + mockMvc + .post("/api/v1/auth/teacher/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"email":"teacher@example.com","password":"secret123"}""" + }.andExpect { + status { isUnauthorized() } + } + + mockMvc + .post("/api/v1/auth/teacher/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"email":"teacher@example.com","password":"newSecret123"}""" + }.andExpect { + status { isOk() } + } } @Test @@ -448,14 +484,15 @@ class AuthControllerIntegrationTests { userRepository.save(activeAdmin()) val accessToken = teacherAccessToken(email = "admin@example.com", password = "secret123") - mockMvc.put("/api/v1/auth/me/password") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header("Authorization", "Bearer $accessToken") - content = """{"currentPassword":"secret123","newPassword":"newSecret123"}""" - }.andExpect { - status { isNoContent() } - } + mockMvc + .put("/api/v1/auth/me/password") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header("Authorization", "Bearer $accessToken") + content = """{"currentPassword":"secret123","newPassword":"newSecret123"}""" + }.andExpect { + status { isNoContent() } + } val persistedUser = requireNotNull(userRepository.findByEmailIgnoreCase("admin@example.com")) assertTrue(passwordEncoder.matches("newSecret123", persistedUser.passwordHash)) @@ -466,14 +503,15 @@ class AuthControllerIntegrationTests { userRepository.save(activePupil()) val accessToken = pupilAccessToken() - mockMvc.put("/api/v1/auth/me/password") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header("Authorization", "Bearer $accessToken") - content = """{"currentPassword":"secret123","newPassword":"newSecret123"}""" - }.andExpect { - status { isForbidden() } - } + mockMvc + .put("/api/v1/auth/me/password") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header("Authorization", "Bearer $accessToken") + content = """{"currentPassword":"secret123","newPassword":"newSecret123"}""" + }.andExpect { + status { isForbidden() } + } } @Test @@ -481,25 +519,27 @@ class AuthControllerIntegrationTests { userRepository.save(activeTeacher()) val accessToken = teacherAccessToken(email = "teacher@example.com", password = "secret123") - mockMvc.put("/api/v1/auth/me/password") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header("Authorization", "Bearer $accessToken") - content = """{"currentPassword":"","newPassword":"short"}""" - }.andExpect { - status { isBadRequest() } - } + mockMvc + .put("/api/v1/auth/me/password") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header("Authorization", "Bearer $accessToken") + content = """{"currentPassword":"","newPassword":"short"}""" + }.andExpect { + status { isBadRequest() } + } } @Test fun `password change without authentication is rejected`() { - mockMvc.put("/api/v1/auth/me/password") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"currentPassword":"secret123","newPassword":"newSecret123"}""" - }.andExpect { - status { isUnauthorized() } - } + mockMvc + .put("/api/v1/auth/me/password") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"currentPassword":"secret123","newPassword":"newSecret123"}""" + }.andExpect { + status { isUnauthorized() } + } } @Test @@ -507,18 +547,20 @@ class AuthControllerIntegrationTests { userRepository.save(activeTeacher()) val accessToken = teacherAccessToken(email = "teacher@example.com", password = "secret123") - mockMvc.put("/api/v1/auth/me/password") { - contentType = MediaType.APPLICATION_JSON - header("Authorization", "Bearer $accessToken") - content = """{"currentPassword":"secret123","newPassword":"newSecret123"}""" - }.andExpect { - status { isNoContent() } - } + mockMvc + .put("/api/v1/auth/me/password") { + contentType = MediaType.APPLICATION_JSON + header("Authorization", "Bearer $accessToken") + content = """{"currentPassword":"secret123","newPassword":"newSecret123"}""" + }.andExpect { + status { isNoContent() } + } } @Test fun `protected endpoint requires authentication`() { - mockMvc.get("/api/v1/auth/me") + mockMvc + .get("/api/v1/auth/me") .andExpect { status { isUnauthorized() } } @@ -527,19 +569,23 @@ class AuthControllerIntegrationTests { @Test fun `auth me accepts legacy tokens without preview metadata claims`() { val pupil = userRepository.save(activePupil()) - val token = legacyAccessToken( - subject = requireNotNull(pupil.id).toString(), - portalType = "PUPIL", - authorities = listOf("PORTAL_PUPIL", "ROLE_PUPIL"), - ) - - val response = mockMvc.get("/api/v1/auth/me") { - header("Authorization", "Bearer $token") - }.andExpect { - status { isOk() } - jsonPath("$.portalType") { value("PUPIL") } - jsonPath("$.authorities[0]") { value("PORTAL_PUPIL") } - }.andReturn().response + val token = + legacyAccessToken( + subject = requireNotNull(pupil.id).toString(), + portalType = "PUPIL", + authorities = listOf("PORTAL_PUPIL", "ROLE_PUPIL"), + ) + + val response = + mockMvc + .get("/api/v1/auth/me") { + header("Authorization", "Bearer $token") + }.andExpect { + status { isOk() } + jsonPath("$.portalType") { value("PUPIL") } + jsonPath("$.authorities[0]") { value("PORTAL_PUPIL") } + }.andReturn() + .response assertFalse(response.contentAsString.contains("TEACHER")) assertFalse(response.contentAsString.contains("sessionMode")) @@ -547,33 +593,40 @@ class AuthControllerIntegrationTests { } private fun teacherLogin() = - mockMvc.post("/api/v1/auth/teacher/login") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"email":"teacher@example.com","password":"secret123"}""" - }.andReturn().response + mockMvc + .post("/api/v1/auth/teacher/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"email":"teacher@example.com","password":"secret123"}""" + }.andReturn() + .response private fun teacherAccessToken( email: String, password: String, ): String = - mockMvc.post("/api/v1/auth/teacher/login") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"email":"$email","password":"$password"}""" - }.andReturn().response.contentAsString + mockMvc + .post("/api/v1/auth/teacher/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"email":"$email","password":"$password"}""" + }.andReturn() + .response.contentAsString .substringAfter(""""accessToken":"""") .substringBefore('"') private fun pupilLogin(password: String = "secret123") = - mockMvc.post("/api/v1/auth/pupil/login") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"username":"pupil_one","password":"$password"}""" - }.andReturn().response + mockMvc + .post("/api/v1/auth/pupil/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"username":"pupil_one","password":"$password"}""" + }.andReturn() + .response private fun pupilAccessToken(password: String = "secret123"): String = - pupilLogin(password).contentAsString + pupilLogin(password) + .contentAsString .substringAfter(""""accessToken":"""") .substringBefore('"') @@ -583,22 +636,25 @@ class AuthControllerIntegrationTests { authorities: List, ): String { val now = Instant.now() - val claims = JwtClaimsSet.builder() - .issuer(securityProperties.jwt.issuer) - .audience(listOf(securityProperties.jwt.audience)) - .subject(subject) - .issuedAt(now) - .expiresAt(now.plus(securityProperties.jwt.accessTokenTtl)) - .claim("portal_type", portalType) - .claim("authorities", authorities) - .build() - - return jwtEncoder.encode( - JwtEncoderParameters.from( - JwsHeader.with(MacAlgorithm.HS256).build(), - claims, - ), - ).tokenValue + val claims = + JwtClaimsSet + .builder() + .issuer(securityProperties.jwt.issuer) + .audience(listOf(securityProperties.jwt.audience)) + .subject(subject) + .issuedAt(now) + .expiresAt(now.plus(securityProperties.jwt.accessTokenTtl)) + .claim("portal_type", portalType) + .claim("authorities", authorities) + .build() + + return jwtEncoder + .encode( + JwtEncoderParameters.from( + JwsHeader.with(MacAlgorithm.HS256).build(), + claims, + ), + ).tokenValue } private fun activeTeacher() = @@ -626,9 +682,10 @@ class AuthControllerIntegrationTests { ) private fun extractRefreshToken(setCookieHeaders: List): String { - val refreshCookieHeader = setCookieHeaders - .firstOrNull { it.startsWith("nettdetektivene_refresh_token=") } - ?: error("Refresh token cookie was not present") + val refreshCookieHeader = + setCookieHeaders + .firstOrNull { it.startsWith("nettdetektivene_refresh_token=") } + ?: error("Refresh token cookie was not present") return refreshCookieHeader .substringAfter("nettdetektivene_refresh_token=") .substringBefore(";") diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/AuthServiceTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/AuthServiceTests.kt index bb13228c..865b3914 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/AuthServiceTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/AuthServiceTests.kt @@ -1,8 +1,8 @@ package edu.ntnu.idi.idatt.backend.iam import edu.ntnu.idi.idatt.backend.iam.application.AuthService -import edu.ntnu.idi.idatt.backend.iam.application.PupilProvisioningService import edu.ntnu.idi.idatt.backend.iam.application.PupilLoginCommand +import edu.ntnu.idi.idatt.backend.iam.application.PupilProvisioningService import edu.ntnu.idi.idatt.backend.iam.domain.User import edu.ntnu.idi.idatt.backend.iam.domain.UserRole import edu.ntnu.idi.idatt.backend.iam.domain.UserStatus @@ -33,36 +33,40 @@ class AuthServiceTests { val pupilProfileRepository = Mockito.mock(PupilProfileRepository::class.java) val userAvatarRepository = Mockito.mock(UserAvatarRepository::class.java) val passwordEncoder = Mockito.mock(PasswordEncoder::class.java) - val pupilProvisioningService = PupilProvisioningService( - userRepository, - pupilProfileRepository, - userAvatarRepository, - passwordEncoder, - transactionManager(), - ) - val authService = AuthService( - userRepository = userRepository, - refreshTokenRepository = refreshTokenRepository, - passwordEncoder = passwordEncoder, - jwtService = jwtService(), - securityProperties = securityProperties(), - pupilProvisioningService = pupilProvisioningService, - ) - val conflictingUser = User( - id = 7L, - username = "shared-name", - passwordHash = "encoded-secret", - role = UserRole.ADMIN, - status = UserStatus.ACTIVE, - ) + val pupilProvisioningService = + PupilProvisioningService( + userRepository, + pupilProfileRepository, + userAvatarRepository, + passwordEncoder, + transactionManager(), + ) + val authService = + AuthService( + userRepository = userRepository, + refreshTokenRepository = refreshTokenRepository, + passwordEncoder = passwordEncoder, + jwtService = jwtService(), + securityProperties = securityProperties(), + pupilProvisioningService = pupilProvisioningService, + ) + val conflictingUser = + User( + id = 7L, + username = "shared-name", + passwordHash = "encoded-secret", + role = UserRole.ADMIN, + status = UserStatus.ACTIVE, + ) Mockito.`when`(userRepository.findByUsernameIgnoreCase("shared-name")).thenReturn(conflictingUser) - val exception = assertThrows(ResponseStatusException::class.java) { - authService.pupilLoginOrCreateUser( - PupilLoginCommand(username = "shared-name", password = "secret123"), - ) - } + val exception = + assertThrows(ResponseStatusException::class.java) { + authService.pupilLoginOrCreateUser( + PupilLoginCommand(username = "shared-name", password = "secret123"), + ) + } assertEquals(HttpStatus.UNAUTHORIZED, exception.statusCode) @@ -78,22 +82,25 @@ class AuthServiceTests { private fun securityProperties() = AppSecurityProperties( - jwt = AppSecurityProperties.Jwt( - issuer = "https://issuer.test", - audience = "test-audience", - secret = "0123456789012345678901234567890123456789012345678901234567890123", - accessTokenTtl = Duration.ofMinutes(15), - ), - refresh = AppSecurityProperties.Refresh( - cookieName = "nettdetektivene_refresh_token", - cookiePath = "/api/v1/auth", - sameSite = "Lax", - secureCookie = false, - ttl = Duration.ofDays(14), - ), - cors = AppSecurityProperties.Cors( - allowedOrigins = listOf("http://localhost:5173"), - ), + jwt = + AppSecurityProperties.Jwt( + issuer = "https://issuer.test", + audience = "test-audience", + secret = "0123456789012345678901234567890123456789012345678901234567890123", + accessTokenTtl = Duration.ofMinutes(15), + ), + refresh = + AppSecurityProperties.Refresh( + cookieName = "nettdetektivene_refresh_token", + cookiePath = "/api/v1/auth", + sameSite = "Lax", + secureCookie = false, + ttl = Duration.ofDays(14), + ), + cors = + AppSecurityProperties.Cors( + allowedOrigins = listOf("http://localhost:5173"), + ), ) private fun transactionManager(): PlatformTransactionManager = diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/CurrentUserResolverTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/CurrentUserResolverTests.kt index a11546b7..e43a7a2a 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/CurrentUserResolverTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/CurrentUserResolverTests.kt @@ -41,9 +41,10 @@ class CurrentUserResolverTests { val classroomRepository = Mockito.mock(ClassroomRepository::class.java) val resolver = CurrentUserResolver(userRepository, pupilProfileRepository, classroomRepository) - val exception = org.junit.jupiter.api.assertThrows { - resolver.requireUser("not-a-number") - } + val exception = + org.junit.jupiter.api.assertThrows { + resolver.requireUser("not-a-number") + } assertEquals(HttpStatus.UNAUTHORIZED, exception.statusCode) } @@ -57,9 +58,10 @@ class CurrentUserResolverTests { Mockito.`when`(userRepository.findById(42L)).thenReturn(Optional.empty()) - val exception = org.junit.jupiter.api.assertThrows { - resolver.requireUser("42") - } + val exception = + org.junit.jupiter.api.assertThrows { + resolver.requireUser("42") + } assertEquals(HttpStatus.UNAUTHORIZED, exception.statusCode) } @@ -74,9 +76,10 @@ class CurrentUserResolverTests { Mockito.`when`(userRepository.findById(7L)).thenReturn(Optional.of(user)) - val exception = org.junit.jupiter.api.assertThrows { - resolver.requirePupil("7") - } + val exception = + org.junit.jupiter.api.assertThrows { + resolver.requirePupil("7") + } assertEquals(HttpStatus.FORBIDDEN, exception.statusCode) } @@ -91,9 +94,10 @@ class CurrentUserResolverTests { Mockito.`when`(userRepository.findById(9L)).thenReturn(Optional.of(user)) - val exception = org.junit.jupiter.api.assertThrows { - resolver.requireTeacherOrAdmin("9") - } + val exception = + org.junit.jupiter.api.assertThrows { + resolver.requireTeacherOrAdmin("9") + } assertEquals(HttpStatus.FORBIDDEN, exception.statusCode) } @@ -106,12 +110,13 @@ class CurrentUserResolverTests { val resolver = CurrentUserResolver(userRepository, pupilProfileRepository, classroomRepository) val user = user(id = 11L, role = UserRole.PUPIL) val classroom = Classroom(id = 101L, ownerTeacherId = 7L, title = "Class A") - val pupilProfile = PupilProfile( - userId = 11L, - user = user, - displayName = "Pupil One", - currentClassroom = classroom, - ) + val pupilProfile = + PupilProfile( + userId = 11L, + user = user, + displayName = "Pupil One", + currentClassroom = classroom, + ) Mockito.`when`(userRepository.findById(11L)).thenReturn(Optional.of(user)) Mockito.`when`(pupilProfileRepository.findById(11L)).thenReturn(Optional.of(pupilProfile)) diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/JwtServiceTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/JwtServiceTests.kt index cc7bcbba..3adfd5ec 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/JwtServiceTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/JwtServiceTests.kt @@ -12,35 +12,40 @@ import java.time.Duration class JwtServiceTests { @Test fun `issued token contains expected claims and can be decoded`() { - val properties = AppSecurityProperties( - jwt = AppSecurityProperties.Jwt( - issuer = "https://issuer.test", - audience = "test-audience", - secret = "0123456789012345678901234567890123456789012345678901234567890123", - accessTokenTtl = Duration.ofMinutes(15), - ), - refresh = AppSecurityProperties.Refresh( - cookieName = "nettdetektivene_refresh_token", - cookiePath = "/api/v1/auth", - sameSite = "Lax", - secureCookie = false, - ttl = Duration.ofDays(14), - ), - cors = AppSecurityProperties.Cors( - allowedOrigins = listOf("http://localhost:5173"), - ), - ) + val properties = + AppSecurityProperties( + jwt = + AppSecurityProperties.Jwt( + issuer = "https://issuer.test", + audience = "test-audience", + secret = "0123456789012345678901234567890123456789012345678901234567890123", + accessTokenTtl = Duration.ofMinutes(15), + ), + refresh = + AppSecurityProperties.Refresh( + cookieName = "nettdetektivene_refresh_token", + cookiePath = "/api/v1/auth", + sameSite = "Lax", + secureCookie = false, + ttl = Duration.ofDays(14), + ), + cors = + AppSecurityProperties.Cors( + allowedOrigins = listOf("http://localhost:5173"), + ), + ) val securityConfig = SecurityConfig(properties) val jwtService = JwtService(securityConfig.jwtEncoder(), properties) - val issuedToken = jwtService.issueAccessToken( - JwtSubject( - subject = "teacher-123", - portalType = "TEACHER", - email = "teacher@example.com", - authorities = setOf("PORTAL_TEACHER"), - ), - ) + val issuedToken = + jwtService.issueAccessToken( + JwtSubject( + subject = "teacher-123", + portalType = "TEACHER", + email = "teacher@example.com", + authorities = setOf("PORTAL_TEACHER"), + ), + ) val decoded = securityConfig.jwtDecoder().decode(issuedToken.token) assertNotNull(decoded) diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/PasswordChangeServiceTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/PasswordChangeServiceTests.kt index 2a66b22e..d4b78856 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/PasswordChangeServiceTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/PasswordChangeServiceTests.kt @@ -81,15 +81,16 @@ class PasswordChangeServiceTests { Mockito.`when`(currentUserResolver.requireTeacherOrAdmin("11")).thenThrow(exception) - val thrown = assertThrows(ResponseStatusException::class.java) { - service.changePassword( - ChangePasswordCommand( - subject = "11", - currentPassword = "oldSecret123", - newPassword = "newSecret123", - ), - ) - } + val thrown = + assertThrows(ResponseStatusException::class.java) { + service.changePassword( + ChangePasswordCommand( + subject = "11", + currentPassword = "oldSecret123", + newPassword = "newSecret123", + ), + ) + } assertEquals(HttpStatus.FORBIDDEN, thrown.statusCode) } @@ -105,15 +106,16 @@ class PasswordChangeServiceTests { Mockito.`when`(currentUserResolver.requireTeacherOrAdmin("7")).thenReturn(user) Mockito.`when`(passwordEncoder.matches("wrong-password", "encoded-old")).thenReturn(false) - val exception = assertThrows(ResponseStatusException::class.java) { - service.changePassword( - ChangePasswordCommand( - subject = "7", - currentPassword = "wrong-password", - newPassword = "newSecret123", - ), - ) - } + val exception = + assertThrows(ResponseStatusException::class.java) { + service.changePassword( + ChangePasswordCommand( + subject = "7", + currentPassword = "wrong-password", + newPassword = "newSecret123", + ), + ) + } assertEquals(HttpStatus.UNAUTHORIZED, exception.statusCode) } @@ -128,15 +130,16 @@ class PasswordChangeServiceTests { Mockito.`when`(currentUserResolver.requireTeacherOrAdmin("not-a-number")).thenThrow(exception) - val thrown = assertThrows(ResponseStatusException::class.java) { - service.changePassword( - ChangePasswordCommand( - subject = "not-a-number", - currentPassword = "oldSecret123", - newPassword = "newSecret123", - ), - ) - } + val thrown = + assertThrows(ResponseStatusException::class.java) { + service.changePassword( + ChangePasswordCommand( + subject = "not-a-number", + currentPassword = "oldSecret123", + newPassword = "newSecret123", + ), + ) + } assertEquals(HttpStatus.UNAUTHORIZED, thrown.statusCode) } @@ -151,15 +154,16 @@ class PasswordChangeServiceTests { Mockito.`when`(currentUserResolver.requireTeacherOrAdmin("7")).thenThrow(exception) - val thrown = assertThrows(ResponseStatusException::class.java) { - service.changePassword( - ChangePasswordCommand( - subject = "7", - currentPassword = "oldSecret123", - newPassword = "newSecret123", - ), - ) - } + val thrown = + assertThrows(ResponseStatusException::class.java) { + service.changePassword( + ChangePasswordCommand( + subject = "7", + currentPassword = "oldSecret123", + newPassword = "newSecret123", + ), + ) + } assertEquals(HttpStatus.UNAUTHORIZED, thrown.statusCode) } diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/PupilProvisioningServiceTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/PupilProvisioningServiceTests.kt index aa8f6fc4..6fb0329d 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/PupilProvisioningServiceTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/PupilProvisioningServiceTests.kt @@ -27,13 +27,14 @@ class PupilProvisioningServiceTests { val pupilProfileRepository = Mockito.mock(PupilProfileRepository::class.java) val userAvatarRepository = Mockito.mock(UserAvatarRepository::class.java) val passwordEncoder = Mockito.mock(PasswordEncoder::class.java) - val provisioningService = PupilProvisioningService( - userRepository, - pupilProfileRepository, - userAvatarRepository, - passwordEncoder, - transactionManager(), - ) + val provisioningService = + PupilProvisioningService( + userRepository, + pupilProfileRepository, + userAvatarRepository, + passwordEncoder, + transactionManager(), + ) val request = PupilLoginCommand(username = "fresh-pupil", password = "secret123") Mockito.`when`(userRepository.findByUsernameIgnoreCase("fresh-pupil")).thenReturn(null) @@ -68,23 +69,26 @@ class PupilProvisioningServiceTests { val pupilProfileRepository = Mockito.mock(PupilProfileRepository::class.java) val userAvatarRepository = Mockito.mock(UserAvatarRepository::class.java) val passwordEncoder = Mockito.mock(PasswordEncoder::class.java) - val provisioningService = PupilProvisioningService( - userRepository, - pupilProfileRepository, - userAvatarRepository, - passwordEncoder, - transactionManager(), - ) + val provisioningService = + PupilProvisioningService( + userRepository, + pupilProfileRepository, + userAvatarRepository, + passwordEncoder, + transactionManager(), + ) val request = PupilLoginCommand(username = "racing-pupil", password = "secret123") - val existingUser = User( - id = 10L, - username = "racing-pupil", - passwordHash = "encoded-secret", - role = UserRole.PUPIL, - status = UserStatus.ACTIVE, - ) + val existingUser = + User( + id = 10L, + username = "racing-pupil", + passwordHash = "encoded-secret", + role = UserRole.PUPIL, + status = UserStatus.ACTIVE, + ) - Mockito.`when`(userRepository.findByUsernameIgnoreCase("racing-pupil")) + Mockito + .`when`(userRepository.findByUsernameIgnoreCase("racing-pupil")) .thenReturn(null) .thenReturn(existingUser) Mockito.`when`(passwordEncoder.encode("secret123")).thenReturn("encoded-secret") diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/TeacherRegistrationServiceTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/TeacherRegistrationServiceTests.kt index 871c85b8..d48dac64 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/TeacherRegistrationServiceTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/TeacherRegistrationServiceTests.kt @@ -23,11 +23,12 @@ class TeacherRegistrationServiceTests { val userRepository = Mockito.mock(UserRepository::class.java) val teacherProfileRepository = Mockito.mock(TeacherProfileRepository::class.java) val passwordEncoder = Mockito.mock(PasswordEncoder::class.java) - val registrationService = TeacherRegistrationService( - userRepository, - teacherProfileRepository, - passwordEncoder, - ) + val registrationService = + TeacherRegistrationService( + userRepository, + teacherProfileRepository, + passwordEncoder, + ) Mockito.`when`(userRepository.findByEmailIgnoreCase("teacher@example.com")).thenReturn(null) Mockito.`when`(passwordEncoder.encode("secret123")).thenReturn("encoded-password") @@ -40,14 +41,15 @@ class TeacherRegistrationServiceTests { invocation.arguments[0] as TeacherProfile } - val registeredTeacher = registrationService.registerTeacher( - TeacherRegistrationCommand( - email = "teacher@example.com", - password = "secret123", - fullName = "Ada Lovelace", - schoolName = "Trondheim skole", - ), - ) + val registeredTeacher = + registrationService.registerTeacher( + TeacherRegistrationCommand( + email = "teacher@example.com", + password = "secret123", + fullName = "Ada Lovelace", + schoolName = "Trondheim skole", + ), + ) assertEquals(15L, registeredTeacher.id) assertEquals(UserRole.TEACHER, registeredTeacher.role) @@ -59,11 +61,12 @@ class TeacherRegistrationServiceTests { val userRepository = Mockito.mock(UserRepository::class.java) val teacherProfileRepository = Mockito.mock(TeacherProfileRepository::class.java) val passwordEncoder = Mockito.mock(PasswordEncoder::class.java) - val registrationService = TeacherRegistrationService( - userRepository, - teacherProfileRepository, - passwordEncoder, - ) + val registrationService = + TeacherRegistrationService( + userRepository, + teacherProfileRepository, + passwordEncoder, + ) Mockito.`when`(userRepository.findByEmailIgnoreCase("teacher@example.com")).thenReturn( User( @@ -75,16 +78,17 @@ class TeacherRegistrationServiceTests { ), ) - val exception = assertThrows(ResponseStatusException::class.java) { - registrationService.registerTeacher( - TeacherRegistrationCommand( - email = "teacher@example.com", - password = "secret123", - fullName = "Ada Lovelace", - schoolName = "Trondheim skole", - ), - ) - } + val exception = + assertThrows(ResponseStatusException::class.java) { + registrationService.registerTeacher( + TeacherRegistrationCommand( + email = "teacher@example.com", + password = "secret123", + fullName = "Ada Lovelace", + schoolName = "Trondheim skole", + ), + ) + } assertEquals(HttpStatus.CONFLICT, exception.statusCode) } @@ -94,22 +98,24 @@ class TeacherRegistrationServiceTests { val userRepository = Mockito.mock(UserRepository::class.java) val teacherProfileRepository = Mockito.mock(TeacherProfileRepository::class.java) val passwordEncoder = Mockito.mock(PasswordEncoder::class.java) - val registrationService = TeacherRegistrationService( - userRepository, - teacherProfileRepository, - passwordEncoder, - ) - - val exception = assertThrows(ResponseStatusException::class.java) { - registrationService.registerTeacher( - TeacherRegistrationCommand( - email = "teacher@example.com", - password = "secret123", - fullName = "", - schoolName = "Trondheim skole", - ), + val registrationService = + TeacherRegistrationService( + userRepository, + teacherProfileRepository, + passwordEncoder, ) - } + + val exception = + assertThrows(ResponseStatusException::class.java) { + registrationService.registerTeacher( + TeacherRegistrationCommand( + email = "teacher@example.com", + password = "secret123", + fullName = "", + schoolName = "Trondheim skole", + ), + ) + } assertEquals(HttpStatus.BAD_REQUEST, exception.statusCode) } diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/leaderboard/LeaderboardIntegrationTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/leaderboard/LeaderboardIntegrationTests.kt index 1762709e..9358edf5 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/leaderboard/LeaderboardIntegrationTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/leaderboard/LeaderboardIntegrationTests.kt @@ -111,34 +111,45 @@ class LeaderboardIntegrationTests { val accessToken = teacherAccessToken("owner@example.com", "secret123") - mockMvc.get("/api/v1/leaderboard") { - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - }.andExpect { - status { isOk() } - jsonPath("$.entries.length()") { value(2) } - jsonPath("$.entries[0].rank") { value(1) } - jsonPath("$.entries[0].classroomId") { value(firstClassroom.id.toInt()) } - jsonPath("$.entries[0].classroomTitle") { value(firstClassroom.title) } - jsonPath("$.entries[0].averageTotalScore") { value(15.0) } - jsonPath("$.entries[1].rank") { value(2) } - jsonPath("$.entries[1].classroomId") { value(secondClassroom.id.toInt()) } - jsonPath("$.entries[1].classroomTitle") { value(secondClassroom.title) } - jsonPath("$.entries[1].averageTotalScore") { value(30.0) } - } - } - - private fun teacherAccessToken(email: String, password: String): String = - jsonMapper.readTree( - mockMvc.post("/api/v1/auth/teacher/login") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"email":"$email","password":"$password"}""" + mockMvc + .get("/api/v1/leaderboard") { + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") }.andExpect { status { isOk() } - }.andReturn().response.contentAsString, - ).get("accessToken").asText() + jsonPath("$.entries.length()") { value(2) } + jsonPath("$.entries[0].rank") { value(1) } + jsonPath("$.entries[0].classroomId") { value(firstClassroom.id.toInt()) } + jsonPath("$.entries[0].classroomTitle") { value(firstClassroom.title) } + jsonPath("$.entries[0].averageTotalScore") { value(15.0) } + jsonPath("$.entries[1].rank") { value(2) } + jsonPath("$.entries[1].classroomId") { value(secondClassroom.id.toInt()) } + jsonPath("$.entries[1].classroomTitle") { value(secondClassroom.title) } + jsonPath("$.entries[1].averageTotalScore") { value(30.0) } + } + } - private fun createClassroom(owner: User, joinCode: String): Classroom = + private fun teacherAccessToken( + email: String, + password: String, + ): String = + jsonMapper + .readTree( + mockMvc + .post("/api/v1/auth/teacher/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"email":"$email","password":"$password"}""" + }.andExpect { + status { isOk() } + }.andReturn() + .response.contentAsString, + ).get("accessToken") + .asText() + + private fun createClassroom( + owner: User, + joinCode: String, + ): Classroom = classroomRepository.save( Classroom( ownerTeacherId = requireNotNull(owner.id), diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/medals/MedalServiceTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/medals/MedalServiceTests.kt index 23a77123..cbb0b696 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/medals/MedalServiceTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/medals/MedalServiceTests.kt @@ -1,6 +1,9 @@ package edu.ntnu.idi.idatt.backend.medals import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom +import edu.ntnu.idi.idatt.backend.iam.domain.User +import edu.ntnu.idi.idatt.backend.iam.domain.UserRole +import edu.ntnu.idi.idatt.backend.iam.domain.UserStatus import edu.ntnu.idi.idatt.backend.medals.application.MedalService import edu.ntnu.idi.idatt.backend.medals.domain.Medal import edu.ntnu.idi.idatt.backend.medals.domain.MedalType @@ -8,9 +11,6 @@ import edu.ntnu.idi.idatt.backend.medals.domain.PupilMedal import edu.ntnu.idi.idatt.backend.medals.infrastructure.MedalRepository import edu.ntnu.idi.idatt.backend.medals.infrastructure.PupilMedalRepository import edu.ntnu.idi.idatt.backend.xp.application.XpService -import edu.ntnu.idi.idatt.backend.iam.domain.User -import edu.ntnu.idi.idatt.backend.iam.domain.UserRole -import edu.ntnu.idi.idatt.backend.iam.domain.UserStatus import org.junit.jupiter.api.Assertions.assertDoesNotThrow import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows @@ -21,7 +21,6 @@ import org.springframework.transaction.PlatformTransactionManager import org.springframework.transaction.support.SimpleTransactionStatus class MedalServiceTests { - @Test fun `award stop medal ignores duplicate award race`() { val medalRepository = Mockito.mock(MedalRepository::class.java) @@ -30,7 +29,8 @@ class MedalServiceTests { val service = MedalService(medalRepository, pupilMedalRepository, xpService, transactionManager()) Mockito.`when`(medalRepository.findByStopId(5L)).thenReturn(medal(stopId = 5L)) - Mockito.`when`(pupilMedalRepository.existsByPupilIdAndClassroomIdAndMedalId(1L, 2L, 10L)) + Mockito + .`when`(pupilMedalRepository.existsByPupilIdAndClassroomIdAndMedalId(1L, 2L, 10L)) .thenReturn(false) .thenReturn(true) Mockito.`when`(pupilMedalRepository.saveAndFlush(any(PupilMedal::class.java))).thenThrow( @@ -51,16 +51,19 @@ class MedalServiceTests { val exception = DataIntegrityViolationException("foreign key failure") Mockito.`when`(medalRepository.findByStopId(5L)).thenReturn(medal(stopId = 5L)) - Mockito.`when`(pupilMedalRepository.existsByPupilIdAndClassroomIdAndMedalId(1L, 2L, 10L)) + Mockito + .`when`(pupilMedalRepository.existsByPupilIdAndClassroomIdAndMedalId(1L, 2L, 10L)) .thenReturn(false) .thenReturn(false) Mockito.`when`(pupilMedalRepository.saveAndFlush(any(PupilMedal::class.java))).thenThrow(exception) - val thrown = assertThrows { - service.awardStopMedal(pupil(), classroom(), 5L) - } + val thrown = + assertThrows { + service.awardStopMedal(pupil(), classroom(), 5L) + } - org.junit.jupiter.api.Assertions.assertSame(exception, thrown) + org.junit.jupiter.api.Assertions + .assertSame(exception, thrown) } private fun pupil(): User = diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/notebook/NotebookEntryControllerIntegrationTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/notebook/NotebookEntryControllerIntegrationTests.kt index ebb0fa09..659431e4 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/notebook/NotebookEntryControllerIntegrationTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/notebook/NotebookEntryControllerIntegrationTests.kt @@ -75,12 +75,14 @@ class NotebookEntryControllerIntegrationTests { @Test fun `authenticated pupil lists own notebook entries`() { val teacher = userRepository.save(activeTeacher()) - val classroom = classroomRepository.save( - Classroom(ownerTeacherId = requireNotNull(teacher.id), title = "Class A") - ) - val stop = mapStopsRepository.save( - MapStop(slug = "intro", title = "Intro", displayOrder = 1) - ) + val classroom = + classroomRepository.save( + Classroom(ownerTeacherId = requireNotNull(teacher.id), title = "Class A"), + ) + val stop = + mapStopsRepository.save( + MapStop(slug = "intro", title = "Intro", displayOrder = 1), + ) val pupil = userRepository.save(activePupil("ada")) val otherPupil = userRepository.save(activePupil("grace")) pupilProfileRepository.save(pupilProfile(pupil, "Ada", classroom)) @@ -107,13 +109,16 @@ class NotebookEntryControllerIntegrationTests { ) val accessToken = pupilAccessToken("ada", "secret123") - val body = readJson( - mockMvc.get("/api/v1/users/me/notebook-entries") { - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - }.andExpect { - status { isOk() } - }.andReturn().response.contentAsString, - ) + val body = + readJson( + mockMvc + .get("/api/v1/users/me/notebook-entries") { + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + }.andExpect { + status { isOk() } + }.andReturn() + .response.contentAsString, + ) assertEquals(1, body.size()) assertEquals("My note", body[0].get("title").asText()) @@ -123,12 +128,14 @@ class NotebookEntryControllerIntegrationTests { @Test fun `authenticated teacher lists pupil notebook entries`() { val teacher = userRepository.save(activeTeacher()) - val classroom = classroomRepository.save( - Classroom(ownerTeacherId = requireNotNull(teacher.id), title = "Class A") - ) - val stop = mapStopsRepository.save( - MapStop(slug = "intro", title = "Intro", displayOrder = 1) - ) + val classroom = + classroomRepository.save( + Classroom(ownerTeacherId = requireNotNull(teacher.id), title = "Class A"), + ) + val stop = + mapStopsRepository.save( + MapStop(slug = "intro", title = "Intro", displayOrder = 1), + ) val pupil = userRepository.save(activePupil("ada")) val otherPupil = userRepository.save(activePupil("grace")) classroom.addPupilMembership(pupil) @@ -168,13 +175,16 @@ class NotebookEntryControllerIntegrationTests { ) val accessToken = teacherAccessToken("teacher@example.com", "secret123") - val body = readJson( - mockMvc.get("/api/v1/classrooms/${classroom.id}/pupils/${pupil.id}/notebook-entries") { - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - }.andExpect { - status { isOk() } - }.andReturn().response.contentAsString, - ) + val body = + readJson( + mockMvc + .get("/api/v1/classrooms/${classroom.id}/pupils/${pupil.id}/notebook-entries") { + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + }.andExpect { + status { isOk() } + }.andReturn() + .response.contentAsString, + ) assertEquals(2, body.size()) assertEquals(listOf("Ada note", "Game note"), body.map { it.get("title").asText() }) @@ -184,59 +194,66 @@ class NotebookEntryControllerIntegrationTests { fun `teacher cannot list pupil notebook outside classroom access`() { val owner = userRepository.save(activeTeacher("owner@example.com")) val outsider = userRepository.save(activeTeacher("outsider@example.com")) - val classroom = classroomRepository.save( - Classroom(ownerTeacherId = requireNotNull(owner.id), title = "Class A") - ) + val classroom = + classroomRepository.save( + Classroom(ownerTeacherId = requireNotNull(owner.id), title = "Class A"), + ) val pupil = userRepository.save(activePupil("ada")) classroom.addPupilMembership(pupil) classroomRepository.save(classroom) pupilProfileRepository.save(pupilProfile(pupil, "Ada", classroom)) val accessToken = teacherAccessToken(requireNotNull(outsider.email), "secret123") - mockMvc.get("/api/v1/classrooms/${classroom.id}/pupils/${pupil.id}/notebook-entries") { - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - }.andExpect { - status { isForbidden() } - } + mockMvc + .get("/api/v1/classrooms/${classroom.id}/pupils/${pupil.id}/notebook-entries") { + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + }.andExpect { + status { isForbidden() } + } } @Test fun `authenticated pupil creates notebook entry`() { val teacher = userRepository.save(activeTeacher()) - val classroom = classroomRepository.save( - Classroom(ownerTeacherId = requireNotNull(teacher.id), title = "Class A") - ) - val stop = mapStopsRepository.save( - MapStop(slug = "intro", title = "Intro", displayOrder = 1) - ) + val classroom = + classroomRepository.save( + Classroom(ownerTeacherId = requireNotNull(teacher.id), title = "Class A"), + ) + val stop = + mapStopsRepository.save( + MapStop(slug = "intro", title = "Intro", displayOrder = 1), + ) val pupil = userRepository.save(activePupil("ada")) pupilProfileRepository.save(pupilProfile(pupil, "Ada", classroom)) val accessToken = pupilAccessToken("ada", "secret123") - val body = readJson( - mockMvc.post("/api/v1/users/me/notebook-entries") { - with(csrf()) - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - contentType = MediaType.APPLICATION_JSON - content = - """ - { - "classroomId": ${classroom.id}, - "stopId": ${stop.id}, - "sourceType": "PUPIL", - "title": "Field note", - "content": "Remember this clue" - } - """.trimIndent() - }.andExpect { - status { isCreated() } - jsonPath("$.title") { value("Field note") } - jsonPath("$.content") { value("Remember this clue") } - jsonPath("$.classroomId") { value(classroom.id) } - jsonPath("$.stopId") { value(stop.id) } - jsonPath("$.sourceType") { value("PUPIL") } - }.andReturn().response.contentAsString, - ) + val body = + readJson( + mockMvc + .post("/api/v1/users/me/notebook-entries") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + contentType = MediaType.APPLICATION_JSON + content = + """ + { + "classroomId": ${classroom.id}, + "stopId": ${stop.id}, + "sourceType": "PUPIL", + "title": "Field note", + "content": "Remember this clue" + } + """.trimIndent() + }.andExpect { + status { isCreated() } + jsonPath("$.title") { value("Field note") } + jsonPath("$.content") { value("Remember this clue") } + jsonPath("$.classroomId") { value(classroom.id) } + jsonPath("$.stopId") { value(stop.id) } + jsonPath("$.sourceType") { value("PUPIL") } + }.andReturn() + .response.contentAsString, + ) assertEquals(1, notebookEntryRepository.findAll().size) assertEquals("Field note", body.get("title").asText()) @@ -245,36 +262,40 @@ class NotebookEntryControllerIntegrationTests { @Test fun `authenticated pupil updates own notebook entry`() { val teacher = userRepository.save(activeTeacher()) - val classroom = classroomRepository.save( - Classroom(ownerTeacherId = requireNotNull(teacher.id), title = "Class A") - ) - val stop = mapStopsRepository.save( - MapStop(slug = "intro", title = "Intro", displayOrder = 1) - ) + val classroom = + classroomRepository.save( + Classroom(ownerTeacherId = requireNotNull(teacher.id), title = "Class A"), + ) + val stop = + mapStopsRepository.save( + MapStop(slug = "intro", title = "Intro", displayOrder = 1), + ) val pupil = userRepository.save(activePupil("ada")) pupilProfileRepository.save(pupilProfile(pupil, "Ada", classroom)) - val entry = notebookEntryRepository.save( - NotebookEntry( - pupil = pupil, - classroom = classroom, - stop = stop, - sourceType = NotebookSourceType.PUPIL, - title = "Old title", - content = "Old content", - ), - ) + val entry = + notebookEntryRepository.save( + NotebookEntry( + pupil = pupil, + classroom = classroom, + stop = stop, + sourceType = NotebookSourceType.PUPIL, + title = "Old title", + content = "Old content", + ), + ) val accessToken = pupilAccessToken("ada", "secret123") - mockMvc.put("/api/v1/users/me/notebook-entries/${entry.id}") { - with(csrf()) - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - contentType = MediaType.APPLICATION_JSON - content = """{"title":"New title","content":"New content"}""" - }.andExpect { - status { isOk() } - jsonPath("$.title") { value("New title") } - jsonPath("$.content") { value("New content") } - } + mockMvc + .put("/api/v1/users/me/notebook-entries/${entry.id}") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + contentType = MediaType.APPLICATION_JSON + content = """{"title":"New title","content":"New content"}""" + }.andExpect { + status { isOk() } + jsonPath("$.title") { value("New title") } + jsonPath("$.content") { value("New content") } + } val persisted = notebookEntryRepository.findById(requireNotNull(entry.id)).orElseThrow() assertEquals("New title", persisted.title) @@ -284,34 +305,38 @@ class NotebookEntryControllerIntegrationTests { @Test fun `authenticated pupil cannot delete another pupils notebook entry`() { val teacher = userRepository.save(activeTeacher()) - val classroom = classroomRepository.save( - Classroom(ownerTeacherId = requireNotNull(teacher.id), title = "Class A") - ) - val stop = mapStopsRepository.save( - MapStop(slug = "intro", title = "Intro", displayOrder = 1) - ) + val classroom = + classroomRepository.save( + Classroom(ownerTeacherId = requireNotNull(teacher.id), title = "Class A"), + ) + val stop = + mapStopsRepository.save( + MapStop(slug = "intro", title = "Intro", displayOrder = 1), + ) val pupil = userRepository.save(activePupil("ada")) val otherPupil = userRepository.save(activePupil("grace")) pupilProfileRepository.save(pupilProfile(pupil, "Ada", classroom)) pupilProfileRepository.save(pupilProfile(otherPupil, "Grace", classroom)) - val entry = notebookEntryRepository.save( - NotebookEntry( - pupil = otherPupil, - classroom = classroom, - stop = stop, - sourceType = NotebookSourceType.PUPIL, - title = "Other title", - content = "Other content", - ), - ) + val entry = + notebookEntryRepository.save( + NotebookEntry( + pupil = otherPupil, + classroom = classroom, + stop = stop, + sourceType = NotebookSourceType.PUPIL, + title = "Other title", + content = "Other content", + ), + ) val accessToken = pupilAccessToken("ada", "secret123") - mockMvc.delete("/api/v1/users/me/notebook-entries/${entry.id}") { - with(csrf()) - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - }.andExpect { - status { isForbidden() } - } + mockMvc + .delete("/api/v1/users/me/notebook-entries/${entry.id}") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + }.andExpect { + status { isForbidden() } + } assertTrue(notebookEntryRepository.findById(requireNotNull(entry.id)).isPresent) } @@ -319,36 +344,40 @@ class NotebookEntryControllerIntegrationTests { @Test fun `authenticated teacher deletes pupil authored notebook entry`() { val teacher = userRepository.save(activeTeacher()) - val classroom = classroomRepository.save( - Classroom(ownerTeacherId = requireNotNull(teacher.id), title = "Class A") - ) - val stop = mapStopsRepository.save( - MapStop(slug = "intro", title = "Intro", displayOrder = 1) - ) + val classroom = + classroomRepository.save( + Classroom(ownerTeacherId = requireNotNull(teacher.id), title = "Class A"), + ) + val stop = + mapStopsRepository.save( + MapStop(slug = "intro", title = "Intro", displayOrder = 1), + ) val pupil = userRepository.save(activePupil("ada")) classroom.addPupilMembership(pupil) classroomRepository.save(classroom) pupilProfileRepository.save(pupilProfile(pupil, "Ada", classroom)) - val entry = notebookEntryRepository.save( - NotebookEntry( - pupil = pupil, - classroom = classroom, - stop = stop, - sourceType = NotebookSourceType.PUPIL, - title = "Bad note", - content = "Delete me", - ), - ) + val entry = + notebookEntryRepository.save( + NotebookEntry( + pupil = pupil, + classroom = classroom, + stop = stop, + sourceType = NotebookSourceType.PUPIL, + title = "Bad note", + content = "Delete me", + ), + ) val accessToken = teacherAccessToken("teacher@example.com", "secret123") - mockMvc.delete( - "/api/v1/classrooms/${classroom.id}/pupils/${pupil.id}/notebook-entries/${entry.id}", - ) { - with(csrf()) - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - }.andExpect { - status { isNoContent() } - } + mockMvc + .delete( + "/api/v1/classrooms/${classroom.id}/pupils/${pupil.id}/notebook-entries/${entry.id}", + ) { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + }.andExpect { + status { isNoContent() } + } assertTrue(notebookEntryRepository.findById(requireNotNull(entry.id)).isEmpty) } @@ -356,94 +385,115 @@ class NotebookEntryControllerIntegrationTests { @Test fun `authenticated teacher cannot delete game authored notebook entry`() { val teacher = userRepository.save(activeTeacher()) - val classroom = classroomRepository.save( - Classroom(ownerTeacherId = requireNotNull(teacher.id), title = "Class A") - ) - val stop = mapStopsRepository.save( - MapStop(slug = "intro", title = "Intro", displayOrder = 1) - ) + val classroom = + classroomRepository.save( + Classroom(ownerTeacherId = requireNotNull(teacher.id), title = "Class A"), + ) + val stop = + mapStopsRepository.save( + MapStop(slug = "intro", title = "Intro", displayOrder = 1), + ) val pupil = userRepository.save(activePupil("ada")) classroom.addPupilMembership(pupil) classroomRepository.save(classroom) pupilProfileRepository.save(pupilProfile(pupil, "Ada", classroom)) - val entry = notebookEntryRepository.save( - NotebookEntry( - pupil = null, - classroom = classroom, - stop = stop, - sourceType = NotebookSourceType.GAME, - title = "Game note", - content = "Keep me", - ), - ) + val entry = + notebookEntryRepository.save( + NotebookEntry( + pupil = null, + classroom = classroom, + stop = stop, + sourceType = NotebookSourceType.GAME, + title = "Game note", + content = "Keep me", + ), + ) val accessToken = teacherAccessToken("teacher@example.com", "secret123") - mockMvc.delete( - "/api/v1/classrooms/${classroom.id}/pupils/${pupil.id}/notebook-entries/${entry.id}", - ) { - with(csrf()) - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - }.andExpect { - status { isForbidden() } - } + mockMvc + .delete( + "/api/v1/classrooms/${classroom.id}/pupils/${pupil.id}/notebook-entries/${entry.id}", + ) { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + }.andExpect { + status { isForbidden() } + } assertTrue(notebookEntryRepository.findById(requireNotNull(entry.id)).isPresent) } @Test fun `notebook endpoints require authentication`() { - mockMvc.get("/api/v1/users/me/notebook-entries") + mockMvc + .get("/api/v1/users/me/notebook-entries") .andExpect { status { isUnauthorized() } } - mockMvc.post("/api/v1/users/me/notebook-entries") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"classroomId":1,"stopId":1,"sourceType":"PUPIL","title":"A","content":"B"}""" - }.andExpect { - status { isUnauthorized() } - } + mockMvc + .post("/api/v1/users/me/notebook-entries") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"classroomId":1,"stopId":1,"sourceType":"PUPIL","title":"A","content":"B"}""" + }.andExpect { + status { isUnauthorized() } + } } private fun readJson(content: String): JsonNode = jsonMapper.readTree(content) - private fun pupilAccessToken(username: String, password: String): String = + private fun pupilAccessToken( + username: String, + password: String, + ): String = readJson( - mockMvc.post("/api/v1/auth/pupil/login") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"username":"$username","password":"$password"}""" - }.andExpect { - status { isOk() } - }.andReturn().response.contentAsString, + mockMvc + .post("/api/v1/auth/pupil/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"username":"$username","password":"$password"}""" + }.andExpect { + status { isOk() } + }.andReturn() + .response.contentAsString, ).get("accessToken").asText() - private fun teacherAccessToken(email: String, password: String): String = + private fun teacherAccessToken( + email: String, + password: String, + ): String = readJson( - mockMvc.post("/api/v1/auth/teacher/login") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"email":"$email","password":"$password"}""" - }.andExpect { - status { isOk() } - }.andReturn().response.contentAsString, + mockMvc + .post("/api/v1/auth/teacher/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"email":"$email","password":"$password"}""" + }.andExpect { + status { isOk() } + }.andReturn() + .response.contentAsString, ).get("accessToken").asText() - private fun activePupil(username: String) = User( - username = username, - passwordHash = requireNotNull(passwordEncoder.encode("secret123")), - role = UserRole.PUPIL, - status = UserStatus.ACTIVE, - ) - - private fun activeTeacher(email: String = "teacher@example.com") = User( - email = email, - passwordHash = requireNotNull(passwordEncoder.encode("secret123")), - role = UserRole.TEACHER, - status = UserStatus.ACTIVE, - ) - - private fun pupilProfile(pupil: User, displayName: String, classroom: Classroom) = - PupilProfile(user = pupil, displayName = displayName, currentClassroom = classroom) + private fun activePupil(username: String) = + User( + username = username, + passwordHash = requireNotNull(passwordEncoder.encode("secret123")), + role = UserRole.PUPIL, + status = UserStatus.ACTIVE, + ) + + private fun activeTeacher(email: String = "teacher@example.com") = + User( + email = email, + passwordHash = requireNotNull(passwordEncoder.encode("secret123")), + role = UserRole.TEACHER, + status = UserStatus.ACTIVE, + ) + + private fun pupilProfile( + pupil: User, + displayName: String, + classroom: Classroom, + ) = PupilProfile(user = pupil, displayName = displayName, currentClassroom = classroom) } diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/notebook/NotebookServiceTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/notebook/NotebookServiceTests.kt index 04d6e338..b5fe9417 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/notebook/NotebookServiceTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/notebook/NotebookServiceTests.kt @@ -4,7 +4,6 @@ import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom import edu.ntnu.idi.idatt.backend.game.gameProgress.application.PupilGameProgressService import edu.ntnu.idi.idatt.backend.game.mapstops.domain.MapStop import edu.ntnu.idi.idatt.backend.game.mapstops.infrastructure.MapStopsRepository -import edu.ntnu.idi.idatt.backend.xp.application.XpService import edu.ntnu.idi.idatt.backend.iam.application.ClassroomUserResolver import edu.ntnu.idi.idatt.backend.iam.application.CurrentUserResolver import edu.ntnu.idi.idatt.backend.iam.domain.User @@ -18,6 +17,7 @@ import edu.ntnu.idi.idatt.backend.notebook.application.UpdateNotebookEntryComman import edu.ntnu.idi.idatt.backend.notebook.domain.NotebookEntry import edu.ntnu.idi.idatt.backend.notebook.domain.NotebookSourceType import edu.ntnu.idi.idatt.backend.notebook.infrastructure.NotebookEntryRepository +import edu.ntnu.idi.idatt.backend.xp.application.XpService import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertSame import org.junit.jupiter.api.Test @@ -37,14 +37,15 @@ class NotebookServiceTests { val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) - val service = NotebookService( - notebookEntryRepository, - currentUserResolver, - classroomUserResolver, - mapStopsRepository, - pupilGameProgressService, - xpService, - ) + val service = + NotebookService( + notebookEntryRepository, + currentUserResolver, + classroomUserResolver, + mapStopsRepository, + pupilGameProgressService, + xpService, + ) val currentUser = currentUser("42") val pupil = pupilUser(42L) val classroom = classroom(7L) @@ -68,14 +69,15 @@ class NotebookServiceTests { val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) - val service = NotebookService( - notebookEntryRepository, - currentUserResolver, - classroomUserResolver, - mapStopsRepository, - pupilGameProgressService, - xpService, - ) + val service = + NotebookService( + notebookEntryRepository, + currentUserResolver, + classroomUserResolver, + mapStopsRepository, + pupilGameProgressService, + xpService, + ) val currentUser = teacherCurrentUser("500") val teacher = teacherUser(500L) val pupil = pupilUser(42L) @@ -100,27 +102,30 @@ class NotebookServiceTests { val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) - val service = NotebookService( - notebookEntryRepository, - currentUserResolver, - classroomUserResolver, - mapStopsRepository, - pupilGameProgressService, - xpService, - ) + val service = + NotebookService( + notebookEntryRepository, + currentUserResolver, + classroomUserResolver, + mapStopsRepository, + pupilGameProgressService, + xpService, + ) val currentUser = teacherCurrentUser("500") val teacher = teacherUser(500L) val classroom = classroom(7L) Mockito.`when`(currentUserResolver.requireTeacherOrAdmin("500")).thenReturn(teacher) Mockito.`when`(classroomUserResolver.requireClassroomTeacherAccess(teacher, 7L)).thenReturn(classroom) - Mockito.doThrow(ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil not found in classroom")) + Mockito + .doThrow(ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil not found in classroom")) .`when`(classroomUserResolver) .requirePupilInClassroom(classroom, 42L) - val exception = assertThrows { - service.getNotebookEntriesForTeacherReview(currentUser, 7L, 42L) - } + val exception = + assertThrows { + service.getNotebookEntriesForTeacherReview(currentUser, 7L, 42L) + } assertEquals(HttpStatus.NOT_FOUND, exception.statusCode) } @@ -133,31 +138,34 @@ class NotebookServiceTests { val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) - val service = NotebookService( - notebookEntryRepository, - currentUserResolver, - classroomUserResolver, - mapStopsRepository, - pupilGameProgressService, - xpService, - ) + val service = + NotebookService( + notebookEntryRepository, + currentUserResolver, + classroomUserResolver, + mapStopsRepository, + pupilGameProgressService, + xpService, + ) val currentUser = currentUser("42") val pupil = pupilUser(42L) val classroom = classroom(7L) val stop = stop(9L) - val command = CreateNotebookEntryCommand( - currentUser = currentUser, - classroomId = 7L, - stopId = 9L, - sourceType = NotebookSourceType.PUPIL, - title = "My clue", - content = "Important note", - ) + val command = + CreateNotebookEntryCommand( + currentUser = currentUser, + classroomId = 7L, + stopId = 9L, + sourceType = NotebookSourceType.PUPIL, + title = "My clue", + content = "Important note", + ) Mockito.`when`(currentUserResolver.requirePupil("42")).thenReturn(pupil) Mockito.`when`(currentUserResolver.getClassroomByUser(currentUser)).thenReturn(classroom) Mockito.`when`(mapStopsRepository.findById(9L)).thenReturn(Optional.of(stop)) - Mockito.`when`(notebookEntryRepository.save(Mockito.any(NotebookEntry::class.java))) + Mockito + .`when`(notebookEntryRepository.save(Mockito.any(NotebookEntry::class.java))) .thenAnswer { it.arguments[0] as NotebookEntry } val result = service.createNotebookEntry(command) @@ -182,32 +190,35 @@ class NotebookServiceTests { val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) - val service = NotebookService( - notebookEntryRepository, - currentUserResolver, - classroomUserResolver, - mapStopsRepository, - pupilGameProgressService, - xpService, - ) + val service = + NotebookService( + notebookEntryRepository, + currentUserResolver, + classroomUserResolver, + mapStopsRepository, + pupilGameProgressService, + xpService, + ) val currentUser = currentUser("42") val pupil = pupilUser(42L) val classroom = classroom(7L) - val command = CreateNotebookEntryCommand( - currentUser = currentUser, - classroomId = 7L, - stopId = 9L, - sourceType = NotebookSourceType.GAME, - title = "Auto note", - content = "Generated note", - ) + val command = + CreateNotebookEntryCommand( + currentUser = currentUser, + classroomId = 7L, + stopId = 9L, + sourceType = NotebookSourceType.GAME, + title = "Auto note", + content = "Generated note", + ) Mockito.`when`(currentUserResolver.requirePupil("42")).thenReturn(pupil) Mockito.`when`(currentUserResolver.getClassroomByUser(currentUser)).thenReturn(classroom) - val exception = assertThrows { - service.createNotebookEntry(command) - } + val exception = + assertThrows { + service.createNotebookEntry(command) + } assertEquals(HttpStatus.FORBIDDEN, exception.statusCode) } @@ -220,32 +231,35 @@ class NotebookServiceTests { val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) - val service = NotebookService( - notebookEntryRepository, - currentUserResolver, - classroomUserResolver, - mapStopsRepository, - pupilGameProgressService, - xpService, - ) + val service = + NotebookService( + notebookEntryRepository, + currentUserResolver, + classroomUserResolver, + mapStopsRepository, + pupilGameProgressService, + xpService, + ) val currentUser = currentUser("42") val pupil = pupilUser(42L) val classroom = classroom(7L) - val command = CreateNotebookEntryCommand( - currentUser = currentUser, - classroomId = 99L, - stopId = 9L, - sourceType = NotebookSourceType.PUPIL, - title = "My clue", - content = "Important note", - ) + val command = + CreateNotebookEntryCommand( + currentUser = currentUser, + classroomId = 99L, + stopId = 9L, + sourceType = NotebookSourceType.PUPIL, + title = "My clue", + content = "Important note", + ) Mockito.`when`(currentUserResolver.requirePupil("42")).thenReturn(pupil) Mockito.`when`(currentUserResolver.getClassroomByUser(currentUser)).thenReturn(classroom) - val exception = assertThrows { - service.createNotebookEntry(command) - } + val exception = + assertThrows { + service.createNotebookEntry(command) + } assertEquals(HttpStatus.FORBIDDEN, exception.statusCode) } @@ -258,33 +272,36 @@ class NotebookServiceTests { val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) - val service = NotebookService( - notebookEntryRepository, - currentUserResolver, - classroomUserResolver, - mapStopsRepository, - pupilGameProgressService, - xpService, - ) + val service = + NotebookService( + notebookEntryRepository, + currentUserResolver, + classroomUserResolver, + mapStopsRepository, + pupilGameProgressService, + xpService, + ) val currentUser = currentUser("42") val pupil = pupilUser(42L) val classroom = classroom(7L) - val command = CreateNotebookEntryCommand( - currentUser = currentUser, - classroomId = 7L, - stopId = 9L, - sourceType = NotebookSourceType.PUPIL, - title = "Auto note", - content = "Generated note", - ) + val command = + CreateNotebookEntryCommand( + currentUser = currentUser, + classroomId = 7L, + stopId = 9L, + sourceType = NotebookSourceType.PUPIL, + title = "Auto note", + content = "Generated note", + ) Mockito.`when`(currentUserResolver.requirePupil("42")).thenReturn(pupil) Mockito.`when`(currentUserResolver.getClassroomByUser(currentUser)).thenReturn(classroom) Mockito.`when`(mapStopsRepository.findById(9L)).thenReturn(Optional.empty()) - val exception = assertThrows { - service.createNotebookEntry(command) - } + val exception = + assertThrows { + service.createNotebookEntry(command) + } assertEquals(HttpStatus.NOT_FOUND, exception.statusCode) } @@ -297,26 +314,29 @@ class NotebookServiceTests { val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) - val service = NotebookService( - notebookEntryRepository, - currentUserResolver, - classroomUserResolver, - mapStopsRepository, - pupilGameProgressService, - xpService, - ) + val service = + NotebookService( + notebookEntryRepository, + currentUserResolver, + classroomUserResolver, + mapStopsRepository, + pupilGameProgressService, + xpService, + ) val currentUser = currentUser("42") val pupil = pupilUser(42L) - val existing = notebookEntry(15L, pupil).apply { - title = "Old title" - content = "Old content" - } - val command = UpdateNotebookEntryCommand( - currentUser = currentUser, - notebookEntryId = 15L, - title = "New title", - content = "New content", - ) + val existing = + notebookEntry(15L, pupil).apply { + title = "Old title" + content = "Old content" + } + val command = + UpdateNotebookEntryCommand( + currentUser = currentUser, + notebookEntryId = 15L, + title = "New title", + content = "New content", + ) Mockito.`when`(currentUserResolver.requirePupil("42")).thenReturn(pupil) Mockito.`when`(notebookEntryRepository.findById(15L)).thenReturn(Optional.of(existing)) @@ -337,30 +357,33 @@ class NotebookServiceTests { val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) - val service = NotebookService( - notebookEntryRepository, - currentUserResolver, - classroomUserResolver, - mapStopsRepository, - pupilGameProgressService, - xpService, - ) + val service = + NotebookService( + notebookEntryRepository, + currentUserResolver, + classroomUserResolver, + mapStopsRepository, + pupilGameProgressService, + xpService, + ) val currentUser = currentUser("42") val pupil = pupilUser(42L) val existing = notebookEntry(15L, pupilUser(99L)) - val command = UpdateNotebookEntryCommand( - currentUser = currentUser, - notebookEntryId = 15L, - title = "New title", - content = "New content", - ) + val command = + UpdateNotebookEntryCommand( + currentUser = currentUser, + notebookEntryId = 15L, + title = "New title", + content = "New content", + ) Mockito.`when`(currentUserResolver.requirePupil("42")).thenReturn(pupil) Mockito.`when`(notebookEntryRepository.findById(15L)).thenReturn(Optional.of(existing)) - val exception = assertThrows { - service.updateNotebookEntry(command) - } + val exception = + assertThrows { + service.updateNotebookEntry(command) + } assertEquals(HttpStatus.FORBIDDEN, exception.statusCode) } @@ -373,21 +396,23 @@ class NotebookServiceTests { val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) - val service = NotebookService( - notebookEntryRepository, - currentUserResolver, - classroomUserResolver, - mapStopsRepository, - pupilGameProgressService, - xpService, - ) + val service = + NotebookService( + notebookEntryRepository, + currentUserResolver, + classroomUserResolver, + mapStopsRepository, + pupilGameProgressService, + xpService, + ) val currentUser = currentUser("42") val pupil = pupilUser(42L) val existing = notebookEntry(15L, pupil) - val command = DeleteNotebookEntryCommand( - currentUser = currentUser, - notebookEntryId = 15L, - ) + val command = + DeleteNotebookEntryCommand( + currentUser = currentUser, + notebookEntryId = 15L, + ) Mockito.`when`(currentUserResolver.requirePupil("42")).thenReturn(pupil) Mockito.`when`(notebookEntryRepository.findById(15L)).thenReturn(Optional.of(existing)) @@ -405,28 +430,31 @@ class NotebookServiceTests { val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) - val service = NotebookService( - notebookEntryRepository, - currentUserResolver, - classroomUserResolver, - mapStopsRepository, - pupilGameProgressService, - xpService, - ) + val service = + NotebookService( + notebookEntryRepository, + currentUserResolver, + classroomUserResolver, + mapStopsRepository, + pupilGameProgressService, + xpService, + ) val currentUser = currentUser("42") val pupil = pupilUser(42L) val existing = notebookEntry(15L, pupilUser(99L)) - val command = DeleteNotebookEntryCommand( - currentUser = currentUser, - notebookEntryId = 15L, - ) + val command = + DeleteNotebookEntryCommand( + currentUser = currentUser, + notebookEntryId = 15L, + ) Mockito.`when`(currentUserResolver.requirePupil("42")).thenReturn(pupil) Mockito.`when`(notebookEntryRepository.findById(15L)).thenReturn(Optional.of(existing)) - val exception = assertThrows { - service.deleteNotebookEntry(command) - } + val exception = + assertThrows { + service.deleteNotebookEntry(command) + } assertEquals(HttpStatus.FORBIDDEN, exception.statusCode) } @@ -439,14 +467,15 @@ class NotebookServiceTests { val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) - val service = NotebookService( - notebookEntryRepository, - currentUserResolver, - classroomUserResolver, - mapStopsRepository, - pupilGameProgressService, - xpService, - ) + val service = + NotebookService( + notebookEntryRepository, + currentUserResolver, + classroomUserResolver, + mapStopsRepository, + pupilGameProgressService, + xpService, + ) val currentUser = teacherCurrentUser("500") val teacher = teacherUser(500L) val pupil = pupilUser(42L) @@ -470,78 +499,90 @@ class NotebookServiceTests { val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) - val service = NotebookService( - notebookEntryRepository, - currentUserResolver, - classroomUserResolver, - mapStopsRepository, - pupilGameProgressService, - xpService, - ) + val service = + NotebookService( + notebookEntryRepository, + currentUserResolver, + classroomUserResolver, + mapStopsRepository, + pupilGameProgressService, + xpService, + ) val currentUser = teacherCurrentUser("500") val teacher = teacherUser(500L) val pupil = pupilUser(42L) val classroom = classroom(7L).apply { addPupilMembership(pupil) } - val existing = notebookEntry(15L, null).apply { - sourceType = NotebookSourceType.GAME - } + val existing = + notebookEntry(15L, null).apply { + sourceType = NotebookSourceType.GAME + } Mockito.`when`(currentUserResolver.requireTeacherOrAdmin("500")).thenReturn(teacher) Mockito.`when`(classroomUserResolver.requireClassroomTeacherAccess(teacher, 7L)).thenReturn(classroom) Mockito.`when`(notebookEntryRepository.findById(15L)).thenReturn(Optional.of(existing)) - val exception = assertThrows { - service.deleteNotebookEntryForTeacherReview(currentUser, 7L, 42L, 15L) - } + val exception = + assertThrows { + service.deleteNotebookEntryForTeacherReview(currentUser, 7L, 42L, 15L) + } assertEquals(HttpStatus.FORBIDDEN, exception.statusCode) } - private fun currentUser(subject: String) = CurrentUser( - subject = subject, - portalType = "pupil", - email = null, - authorities = setOf("ROLE_PUPIL"), - ) + private fun currentUser(subject: String) = + CurrentUser( + subject = subject, + portalType = "pupil", + email = null, + authorities = setOf("ROLE_PUPIL"), + ) - private fun teacherCurrentUser(subject: String) = CurrentUser( - subject = subject, - portalType = "teacher", - email = "teacher@example.com", - authorities = setOf("ROLE_TEACHER"), - ) + private fun teacherCurrentUser(subject: String) = + CurrentUser( + subject = subject, + portalType = "teacher", + email = "teacher@example.com", + authorities = setOf("ROLE_TEACHER"), + ) - private fun pupilUser(id: Long) = User( - id = id, - username = "pupil-$id", - passwordHash = "encoded-secret", - role = UserRole.PUPIL, - status = UserStatus.ACTIVE, - ) + private fun pupilUser(id: Long) = + User( + id = id, + username = "pupil-$id", + passwordHash = "encoded-secret", + role = UserRole.PUPIL, + status = UserStatus.ACTIVE, + ) - private fun teacherUser(id: Long) = User( - id = id, - email = "teacher-$id@example.com", - passwordHash = "encoded-secret", - role = UserRole.TEACHER, - status = UserStatus.ACTIVE, - ) + private fun teacherUser(id: Long) = + User( + id = id, + email = "teacher-$id@example.com", + passwordHash = "encoded-secret", + role = UserRole.TEACHER, + status = UserStatus.ACTIVE, + ) - private fun classroom(id: Long) = Classroom( - id = id, - ownerTeacherId = 500L, - title = "Classroom $id", - description = null, - joinCode = "JOIN$id", - ) + private fun classroom(id: Long) = + Classroom( + id = id, + ownerTeacherId = 500L, + title = "Classroom $id", + description = null, + joinCode = "JOIN$id", + ) - private fun stop(id: Long) = MapStop( - id = id, - slug = "stop-$id", - title = "Stop $id", - ) + private fun stop(id: Long) = + MapStop( + id = id, + slug = "stop-$id", + title = "Stop $id", + ) - private fun notebookEntry(id: Long, pupil: User?) = NotebookEntry( + private fun notebookEntry( + id: Long, + pupil: User?, + ) = NotebookEntry( id = id, pupil = pupil, classroom = classroom(7L), diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/notification/NotificationControllerIntegrationTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/notification/NotificationControllerIntegrationTests.kt index 7aa4f7b8..42d7c7ec 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/notification/NotificationControllerIntegrationTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/notification/NotificationControllerIntegrationTests.kt @@ -91,11 +91,12 @@ class NotificationControllerIntegrationTests { teacherUserId = requireNotNull(teacher.id), classroomId = classroom.id, type = NotificationType.MAP_STOP_COMPLETED, - renderArgsJson = jsonArgs( - "classroomTitle" to classroom.title, - "pupilDisplayName" to "Ada", - "stopTitle" to "Passordbanken", - ), + renderArgsJson = + jsonArgs( + "classroomTitle" to classroom.title, + "pupilDisplayName" to "Ada", + "stopTitle" to "Passordbanken", + ), ), notification( teacherUserId = requireNotNull(teacher.id), @@ -119,30 +120,32 @@ class NotificationControllerIntegrationTests { val accessToken = teacherAccessToken("owner-filter@example.com", "secret123") - mockMvc.get("/api/v1/users/me/notifications") { - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - param("classroomId", classroom.id.toString()) - param("type", NotificationType.MAP_STOP_COMPLETED.name) - param("unreadOnly", "true") - param("page", "0") - param("size", "10") - }.andExpect { - status { isOk() } - jsonPath("$.items.length()") { value(1) } - jsonPath("$.items[0].type") { value(NotificationType.MAP_STOP_COMPLETED.name) } - jsonPath("$.items[0].renderCode") { value(NotificationRenderCode.MAP_STOP_COMPLETED.code) } - jsonPath("$.items[0].classroomTitle") { value(classroom.title) } - jsonPath("$.items[0].pupilDisplayName") { value("Ada") } - jsonPath("$.items[0].stopTitle") { value("Passordbanken") } - jsonPath("$.totalElements") { value(1) } - } - - mockMvc.get("/api/v1/users/me/notifications/summary") { - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - }.andExpect { - status { isOk() } - jsonPath("$.unreadCount") { value(1) } - } + mockMvc + .get("/api/v1/users/me/notifications") { + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + param("classroomId", classroom.id.toString()) + param("type", NotificationType.MAP_STOP_COMPLETED.name) + param("unreadOnly", "true") + param("page", "0") + param("size", "10") + }.andExpect { + status { isOk() } + jsonPath("$.items.length()") { value(1) } + jsonPath("$.items[0].type") { value(NotificationType.MAP_STOP_COMPLETED.name) } + jsonPath("$.items[0].renderCode") { value(NotificationRenderCode.MAP_STOP_COMPLETED.code) } + jsonPath("$.items[0].classroomTitle") { value(classroom.title) } + jsonPath("$.items[0].pupilDisplayName") { value("Ada") } + jsonPath("$.items[0].stopTitle") { value("Passordbanken") } + jsonPath("$.totalElements") { value(1) } + } + + mockMvc + .get("/api/v1/users/me/notifications/summary") { + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + }.andExpect { + status { isOk() } + jsonPath("$.unreadCount") { value(1) } + } } @Test @@ -150,11 +153,12 @@ class NotificationControllerIntegrationTests { userRepository.save(activePupil("notify-pupil")) val accessToken = pupilAccessToken("notify-pupil", "secret123") - mockMvc.get("/api/v1/users/me/notifications") { - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - }.andExpect { - status { isForbidden() } - } + mockMvc + .get("/api/v1/users/me/notifications") { + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + }.andExpect { + status { isForbidden() } + } } @Test @@ -167,68 +171,75 @@ class NotificationControllerIntegrationTests { val linus = userRepository.save(activePupil("multi-linus")) val accessToken = teacherAccessToken("owner-multi@example.com", "secret123") - val adaNotification = teacherNotificationRepository.saveAndFlush( - notification( - teacherUserId = requireNotNull(teacher.id), - classroomId = classroom.id, - pupilUserId = requireNotNull(ada.id), - type = NotificationType.PUPIL_STUCK, - renderArgsJson = jsonArgs( - "classroomTitle" to classroom.title, - "pupilDisplayName" to "Ada", - "stopTitle" to "Passordbanken", + val adaNotification = + teacherNotificationRepository.saveAndFlush( + notification( + teacherUserId = requireNotNull(teacher.id), + classroomId = classroom.id, + pupilUserId = requireNotNull(ada.id), + type = NotificationType.PUPIL_STUCK, + renderArgsJson = + jsonArgs( + "classroomTitle" to classroom.title, + "pupilDisplayName" to "Ada", + "stopTitle" to "Passordbanken", + ), ), - ), - ) - val graceNotification = teacherNotificationRepository.saveAndFlush( - notification( - teacherUserId = requireNotNull(teacher.id), - classroomId = secondClassroom.id, - pupilUserId = requireNotNull(grace.id), - type = NotificationType.GAME_COMPLETED, - renderArgsJson = jsonArgs( - "classroomTitle" to secondClassroom.title, - "pupilDisplayName" to "Grace", + ) + val graceNotification = + teacherNotificationRepository.saveAndFlush( + notification( + teacherUserId = requireNotNull(teacher.id), + classroomId = secondClassroom.id, + pupilUserId = requireNotNull(grace.id), + type = NotificationType.GAME_COMPLETED, + renderArgsJson = + jsonArgs( + "classroomTitle" to secondClassroom.title, + "pupilDisplayName" to "Grace", + ), ), - ), - ) - val linusNotification = teacherNotificationRepository.saveAndFlush( - notification( - teacherUserId = requireNotNull(teacher.id), - classroomId = classroom.id, - pupilUserId = requireNotNull(linus.id), - type = NotificationType.MAP_STOP_COMPLETED, - renderArgsJson = jsonArgs( - "classroomTitle" to classroom.title, - "pupilDisplayName" to "Linus", - "stopTitle" to "Nyhetskvartalet", + ) + val linusNotification = + teacherNotificationRepository.saveAndFlush( + notification( + teacherUserId = requireNotNull(teacher.id), + classroomId = classroom.id, + pupilUserId = requireNotNull(linus.id), + type = NotificationType.MAP_STOP_COMPLETED, + renderArgsJson = + jsonArgs( + "classroomTitle" to classroom.title, + "pupilDisplayName" to "Linus", + "stopTitle" to "Nyhetskvartalet", + ), ), - ), - ) + ) setCreatedAt(requireNotNull(adaNotification.id), Instant.parse("2026-04-22T10:00:00Z")) setCreatedAt(requireNotNull(graceNotification.id), Instant.parse("2026-04-22T11:00:00Z")) setCreatedAt(requireNotNull(linusNotification.id), Instant.parse("2026-04-10T08:00:00Z")) - mockMvc.get("/api/v1/users/me/notifications") { - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - param("classroomIds", classroom.id.toString(), secondClassroom.id.toString()) - param("types", NotificationType.PUPIL_STUCK.name, NotificationType.GAME_COMPLETED.name) - param("pupilUserIds", requireNotNull(ada.id).toString(), requireNotNull(grace.id).toString()) - param("q", "ada") - param("from", "2026-04-22T00:00:00Z") - param("to", "2026-04-23T00:00:00Z") - param("page", "0") - param("size", "10") - }.andExpect { - status { isOk() } - jsonPath("$.items.length()") { value(1) } - jsonPath("$.items[0].type") { value(NotificationType.PUPIL_STUCK.name) } - jsonPath("$.items[0].pupilDisplayName") { value("Ada") } - jsonPath("$.items[0].classroomTitle") { value(classroom.title) } - jsonPath("$.items[0].stopTitle") { value("Passordbanken") } - jsonPath("$.totalElements") { value(1) } - } + mockMvc + .get("/api/v1/users/me/notifications") { + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + param("classroomIds", classroom.id.toString(), secondClassroom.id.toString()) + param("types", NotificationType.PUPIL_STUCK.name, NotificationType.GAME_COMPLETED.name) + param("pupilUserIds", requireNotNull(ada.id).toString(), requireNotNull(grace.id).toString()) + param("q", "ada") + param("from", "2026-04-22T00:00:00Z") + param("to", "2026-04-23T00:00:00Z") + param("page", "0") + param("size", "10") + }.andExpect { + status { isOk() } + jsonPath("$.items.length()") { value(1) } + jsonPath("$.items[0].type") { value(NotificationType.PUPIL_STUCK.name) } + jsonPath("$.items[0].pupilDisplayName") { value("Ada") } + jsonPath("$.items[0].classroomTitle") { value(classroom.title) } + jsonPath("$.items[0].stopTitle") { value("Passordbanken") } + jsonPath("$.totalElements") { value(1) } + } } @Test @@ -242,81 +253,89 @@ class NotificationControllerIntegrationTests { teacherUserId = requireNotNull(teacher.id), classroomId = classroom.id, type = NotificationType.GAME_COMPLETED, - renderArgsJson = jsonArgs( - "classroomTitle" to classroom.title, - "pupilDisplayName" to "Ada", - ), + renderArgsJson = + jsonArgs( + "classroomTitle" to classroom.title, + "pupilDisplayName" to "Ada", + ), ), ) - mockMvc.get("/api/v1/users/me/notifications") { - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - param("q", "game") - }.andExpect { - status { isOk() } - jsonPath("$.items.length()") { value(1) } - jsonPath("$.items[0].type") { value(NotificationType.GAME_COMPLETED.name) } - } + mockMvc + .get("/api/v1/users/me/notifications") { + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + param("q", "game") + }.andExpect { + status { isOk() } + jsonPath("$.items.length()") { value(1) } + jsonPath("$.items[0].type") { value(NotificationType.GAME_COMPLETED.name) } + } } @Test fun `mark read is idempotent`() { val teacher = userRepository.save(activeTeacher("owner-read@example.com")) val classroom = createClassroom("READ1", teacher) - val notification = teacherNotificationRepository.save( - notification( - teacherUserId = requireNotNull(teacher.id), - classroomId = classroom.id, - type = NotificationType.MAP_STOP_COMPLETED, - ), - ) + val notification = + teacherNotificationRepository.save( + notification( + teacherUserId = requireNotNull(teacher.id), + classroomId = classroom.id, + type = NotificationType.MAP_STOP_COMPLETED, + ), + ) val accessToken = teacherAccessToken("owner-read@example.com", "secret123") - mockMvc.patch("/api/v1/users/me/notifications/${notification.id}/read") { - with(csrf()) - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - }.andExpect { - status { isOk() } - jsonPath("$.readAt") { isNotEmpty() } - } + mockMvc + .patch("/api/v1/users/me/notifications/${notification.id}/read") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + }.andExpect { + status { isOk() } + jsonPath("$.readAt") { isNotEmpty() } + } - mockMvc.patch("/api/v1/users/me/notifications/${notification.id}/read") { - with(csrf()) - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - }.andExpect { - status { isOk() } - jsonPath("$.readAt") { isNotEmpty() } - } + mockMvc + .patch("/api/v1/users/me/notifications/${notification.id}/read") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + }.andExpect { + status { isOk() } + jsonPath("$.readAt") { isNotEmpty() } + } } @Test fun `dismiss hides notification from inbox`() { val teacher = userRepository.save(activeTeacher("owner-dismiss@example.com")) val classroom = createClassroom("DISMISS1", teacher) - val notification = teacherNotificationRepository.save( - notification( - teacherUserId = requireNotNull(teacher.id), - classroomId = classroom.id, - type = NotificationType.PUPIL_STUCK, - ), - ) + val notification = + teacherNotificationRepository.save( + notification( + teacherUserId = requireNotNull(teacher.id), + classroomId = classroom.id, + type = NotificationType.PUPIL_STUCK, + ), + ) val accessToken = teacherAccessToken("owner-dismiss@example.com", "secret123") - mockMvc.patch("/api/v1/users/me/notifications/${notification.id}/dismiss") { - with(csrf()) - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - }.andExpect { - status { isOk() } - jsonPath("$.dismissedAt") { isNotEmpty() } - jsonPath("$.readAt") { isNotEmpty() } - } + mockMvc + .patch("/api/v1/users/me/notifications/${notification.id}/dismiss") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + }.andExpect { + status { isOk() } + jsonPath("$.dismissedAt") { isNotEmpty() } + jsonPath("$.readAt") { isNotEmpty() } + } - mockMvc.get("/api/v1/users/me/notifications") { - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - }.andExpect { - status { isOk() } - jsonPath("$.items.length()") { value(0) } - } + mockMvc + .get("/api/v1/users/me/notifications") { + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + }.andExpect { + status { isOk() } + jsonPath("$.items.length()") { value(0) } + } } @Test @@ -328,35 +347,39 @@ class NotificationControllerIntegrationTests { classroomRepository.saveAndFlush(classroom) val accessToken = teacherAccessToken("member-mute@example.com", "secret123") - mockMvc.put("/api/v1/users/me/notifications/mutes/${classroom.id}/${NotificationType.PUPIL_STUCK}") { - with(csrf()) - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - }.andExpect { - status { isOk() } - jsonPath("$.classroomId") { value(classroom.id.toInt()) } - jsonPath("$.type") { value(NotificationType.PUPIL_STUCK.name) } - } + mockMvc + .put("/api/v1/users/me/notifications/mutes/${classroom.id}/${NotificationType.PUPIL_STUCK}") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + }.andExpect { + status { isOk() } + jsonPath("$.classroomId") { value(classroom.id.toInt()) } + jsonPath("$.type") { value(NotificationType.PUPIL_STUCK.name) } + } - mockMvc.get("/api/v1/users/me/notifications/mutes") { - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - }.andExpect { - status { isOk() } - jsonPath("$.length()") { value(1) } - } + mockMvc + .get("/api/v1/users/me/notifications/mutes") { + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + }.andExpect { + status { isOk() } + jsonPath("$.length()") { value(1) } + } - mockMvc.delete("/api/v1/users/me/notifications/mutes/${classroom.id}/${NotificationType.PUPIL_STUCK}") { - with(csrf()) - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - }.andExpect { - status { isOk() } - } + mockMvc + .delete("/api/v1/users/me/notifications/mutes/${classroom.id}/${NotificationType.PUPIL_STUCK}") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + }.andExpect { + status { isOk() } + } - mockMvc.get("/api/v1/users/me/notifications/mutes") { - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - }.andExpect { - status { isOk() } - jsonPath("$.length()") { value(0) } - } + mockMvc + .get("/api/v1/users/me/notifications/mutes") { + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + }.andExpect { + status { isOk() } + jsonPath("$.length()") { value(0) } + } } @Test @@ -366,12 +389,13 @@ class NotificationControllerIntegrationTests { val classroom = createClassroom("MUTE2", owner) val accessToken = teacherAccessToken("outsider-no-mute@example.com", "secret123") - mockMvc.put("/api/v1/users/me/notifications/mutes/${classroom.id}/${NotificationType.PUPIL_STUCK}") { - with(csrf()) - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - }.andExpect { - status { isForbidden() } - } + mockMvc + .put("/api/v1/users/me/notifications/mutes/${classroom.id}/${NotificationType.PUPIL_STUCK}") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + }.andExpect { + status { isForbidden() } + } } private fun notification( @@ -392,19 +416,22 @@ class NotificationControllerIntegrationTests { threadKey = if (type == NotificationType.PUPIL_STUCK) "stuck:$classroomId:$teacherUserId" else null, activeThreadKey = if (type == NotificationType.PUPIL_STUCK) "stuck:$classroomId:$teacherUserId" else null, renderCode = renderCodeFor(type), - renderArgsJson = renderArgsJson ?: jsonArgs( - "classroomTitle" to "Class $classroomId", - "type" to type.name, - ), + renderArgsJson = + renderArgsJson ?: jsonArgs( + "classroomTitle" to "Class $classroomId", + "type" to type.name, + ), readAt = readAt, dismissedAt = dismissedAt, createdAt = createdAt, ) - private fun jsonArgs(vararg entries: Pair): String = - jsonMapper.writeValueAsString(linkedMapOf(*entries)) + private fun jsonArgs(vararg entries: Pair): String = jsonMapper.writeValueAsString(linkedMapOf(*entries)) - private fun setCreatedAt(notificationId: Long, createdAt: Instant) { + private fun setCreatedAt( + notificationId: Long, + createdAt: Instant, + ) { jdbcTemplate.update( "UPDATE teacher_notifications SET created_at = ?, updated_at = ? WHERE id = ?", Timestamp.from(createdAt), @@ -421,7 +448,10 @@ class NotificationControllerIntegrationTests { NotificationType.WEEKLY_MYSTERY_SUBMITTED -> NotificationRenderCode.WEEKLY_MYSTERY_SUBMITTED.code } - private fun createClassroom(code: String, owner: User): Classroom = + private fun createClassroom( + code: String, + owner: User, + ): Classroom = classroomRepository.saveAndFlush( Classroom( ownerTeacherId = requireNotNull(owner.id), @@ -432,27 +462,41 @@ class NotificationControllerIntegrationTests { ), ) - private fun teacherAccessToken(email: String, password: String): String = - jsonMapper.readTree( - mockMvc.post("/api/v1/auth/teacher/login") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"email":"$email","password":"$password"}""" - }.andExpect { - status { isOk() } - }.andReturn().response.contentAsString, - ).get("accessToken").asText() - - private fun pupilAccessToken(username: String, password: String): String = - jsonMapper.readTree( - mockMvc.post("/api/v1/auth/pupil/login") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"username":"$username","password":"$password"}""" - }.andExpect { - status { isOk() } - }.andReturn().response.contentAsString, - ).get("accessToken").asText() + private fun teacherAccessToken( + email: String, + password: String, + ): String = + jsonMapper + .readTree( + mockMvc + .post("/api/v1/auth/teacher/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"email":"$email","password":"$password"}""" + }.andExpect { + status { isOk() } + }.andReturn() + .response.contentAsString, + ).get("accessToken") + .asText() + + private fun pupilAccessToken( + username: String, + password: String, + ): String = + jsonMapper + .readTree( + mockMvc + .post("/api/v1/auth/pupil/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"username":"$username","password":"$password"}""" + }.andExpect { + status { isOk() } + }.andReturn() + .response.contentAsString, + ).get("accessToken") + .asText() private fun activeTeacher(email: String): User = User( diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/notification/NotificationDeliveryServiceTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/notification/NotificationDeliveryServiceTests.kt index e87356f7..e71d5098 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/notification/NotificationDeliveryServiceTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/notification/NotificationDeliveryServiceTests.kt @@ -30,24 +30,26 @@ class NotificationDeliveryServiceTests { teacherNotificationRepository = mock(TeacherNotificationRepository::class.java) teacherNotificationMuteRepository = mock(TeacherNotificationMuteRepository::class.java) recipientResolver = mock(ClassroomNotificationRecipientResolver::class.java) - notificationDeliveryService = NotificationDeliveryService( - teacherNotificationRepository = teacherNotificationRepository, - teacherNotificationMuteRepository = teacherNotificationMuteRepository, - recipientResolver = recipientResolver, - ) + notificationDeliveryService = + NotificationDeliveryService( + teacherNotificationRepository = teacherNotificationRepository, + teacherNotificationMuteRepository = teacherNotificationMuteRepository, + recipientResolver = recipientResolver, + ) } @Test fun `deliver skips muted and duplicate active thread recipients`() { - val draft = NotificationDraft( - classroomId = 42L, - pupilUserId = 7L, - type = NotificationType.PUPIL_STUCK, - threadKey = "stuck:42:7:3", - renderCode = NotificationRenderCode.PUPIL_STUCK, - renderArgsJson = """{"stopTitle":"Omrade 3"}""", - payloadJson = """{"stopId":3}""", - ) + val draft = + NotificationDraft( + classroomId = 42L, + pupilUserId = 7L, + type = NotificationType.PUPIL_STUCK, + threadKey = "stuck:42:7:3", + renderCode = NotificationRenderCode.PUPIL_STUCK, + renderArgsJson = """{"stopTitle":"Omrade 3"}""", + payloadJson = """{"stopId":3}""", + ) `when`(recipientResolver.resolveTeacherRecipientIds(42L)).thenReturn(setOf(10L, 11L, 12L)) `when`( @@ -102,28 +104,30 @@ class NotificationDeliveryServiceTests { @Test fun `resolve thread marks matching notifications as resolved`() { - val unresolvedFirst = TeacherNotification( - id = 1L, - teacherUserId = 10L, - classroomId = 42L, - pupilUserId = 7L, - type = NotificationType.PUPIL_STUCK, - threadKey = "stuck:42:7:3", - activeThreadKey = "stuck:42:7:3", - renderCode = NotificationRenderCode.PUPIL_STUCK.code, - renderArgsJson = """{"stopTitle":"Omrade 3"}""", - ) - val unresolvedSecond = TeacherNotification( - id = 2L, - teacherUserId = 11L, - classroomId = 42L, - pupilUserId = 7L, - type = NotificationType.PUPIL_STUCK, - threadKey = "stuck:42:7:3", - activeThreadKey = "stuck:42:7:3", - renderCode = NotificationRenderCode.PUPIL_STUCK.code, - renderArgsJson = """{"stopTitle":"Omrade 3"}""", - ) + val unresolvedFirst = + TeacherNotification( + id = 1L, + teacherUserId = 10L, + classroomId = 42L, + pupilUserId = 7L, + type = NotificationType.PUPIL_STUCK, + threadKey = "stuck:42:7:3", + activeThreadKey = "stuck:42:7:3", + renderCode = NotificationRenderCode.PUPIL_STUCK.code, + renderArgsJson = """{"stopTitle":"Omrade 3"}""", + ) + val unresolvedSecond = + TeacherNotification( + id = 2L, + teacherUserId = 11L, + classroomId = 42L, + pupilUserId = 7L, + type = NotificationType.PUPIL_STUCK, + threadKey = "stuck:42:7:3", + activeThreadKey = "stuck:42:7:3", + renderCode = NotificationRenderCode.PUPIL_STUCK.code, + renderArgsJson = """{"stopTitle":"Omrade 3"}""", + ) `when`( teacherNotificationRepository.findAllByClassroomIdAndThreadKeyAndResolvedAtIsNull( 42L, diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/notification/NotificationEventIntegrationTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/notification/NotificationEventIntegrationTests.kt index e924dce0..73f3a68b 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/notification/NotificationEventIntegrationTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/notification/NotificationEventIntegrationTests.kt @@ -86,14 +86,15 @@ class NotificationEventIntegrationTests { seedProgress(pupil, classroom, stop, StopProgressStatus.IN_PROGRESS) val pupilToken = pupilAccessToken("pupil-stop-event", "secret123") - mockMvc.patch("/api/v1/classrooms/${classroom.id}/pupils/${pupil.id}/stop-progress/${stop.id}") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer $pupilToken") - content = """{"status":"COMPLETED"}""" - }.andExpect { - status { isOk() } - } + mockMvc + .patch("/api/v1/classrooms/${classroom.id}/pupils/${pupil.id}/stop-progress/${stop.id}") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer $pupilToken") + content = """{"status":"COMPLETED"}""" + }.andExpect { + status { isOk() } + } val notifications = teacherNotificationRepository.findAll() assertEquals(1, notifications.size) @@ -110,14 +111,15 @@ class NotificationEventIntegrationTests { seedProgress(pupil, classroom, stop, StopProgressStatus.IN_PROGRESS) val pupilToken = pupilAccessToken("pupil-game-event", "secret123") - mockMvc.patch("/api/v1/classrooms/${classroom.id}/pupils/${pupil.id}/stop-progress/${stop.id}") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer $pupilToken") - content = """{"status":"COMPLETED"}""" - }.andExpect { - status { isOk() } - } + mockMvc + .patch("/api/v1/classrooms/${classroom.id}/pupils/${pupil.id}/stop-progress/${stop.id}") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer $pupilToken") + content = """{"status":"COMPLETED"}""" + }.andExpect { + status { isOk() } + } val notifications = teacherNotificationRepository.findAll() assertEquals(2, notifications.size) @@ -134,19 +136,23 @@ class NotificationEventIntegrationTests { seedProgress(pupil, classroom, stop, StopProgressStatus.IN_PROGRESS) val pupilToken = pupilAccessToken("pupil-stuck-event", "secret123") - mockMvc.post("/api/v1/classrooms/${classroom.id}/pupils/${pupil.id}/stop-progress/${stop.id}/attempt-outcomes") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer $pupilToken") - content = """{"correct":false}""" - }.andExpect { - status { isNotFound() } - } + mockMvc + .post("/api/v1/classrooms/${classroom.id}/pupils/${pupil.id}/stop-progress/${stop.id}/attempt-outcomes") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer $pupilToken") + content = """{"correct":false}""" + }.andExpect { + status { isNotFound() } + } assertEquals(0, teacherNotificationRepository.count()) } - private fun createClassroom(code: String, owner: User): Classroom = + private fun createClassroom( + code: String, + owner: User, + ): Classroom = classroomRepository.saveAndFlush( Classroom( ownerTeacherId = requireNotNull(owner.id), @@ -157,7 +163,12 @@ class NotificationEventIntegrationTests { ), ) - private fun createStop(slug: String, title: String, displayOrder: Int, isFinalBoss: Boolean = false): MapStop = + private fun createStop( + slug: String, + title: String, + displayOrder: Int, + isFinalBoss: Boolean = false, + ): MapStop = mapStopsRepository.saveAndFlush( MapStop( slug = slug, @@ -168,14 +179,20 @@ class NotificationEventIntegrationTests { ), ) - private fun seedProgress(pupil: User, classroom: Classroom, stop: MapStop, status: StopProgressStatus) { + private fun seedProgress( + pupil: User, + classroom: Classroom, + stop: MapStop, + status: StopProgressStatus, + ) { pupilStopProgressRepository.saveAndFlush( PupilStopProgress( - id = PupilStopProgressId( - pupilUserId = requireNotNull(pupil.id), - classroomId = classroom.id, - stopId = requireNotNull(stop.id), - ), + id = + PupilStopProgressId( + pupilUserId = requireNotNull(pupil.id), + classroomId = classroom.id, + stopId = requireNotNull(stop.id), + ), pupil = pupil, classroom = classroom, stop = stop, @@ -185,16 +202,23 @@ class NotificationEventIntegrationTests { ) } - private fun pupilAccessToken(username: String, password: String): String = - jsonMapper.readTree( - mockMvc.post("/api/v1/auth/pupil/login") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"username":"$username","password":"$password"}""" - }.andExpect { - status { isOk() } - }.andReturn().response.contentAsString, - ).get("accessToken").asText() + private fun pupilAccessToken( + username: String, + password: String, + ): String = + jsonMapper + .readTree( + mockMvc + .post("/api/v1/auth/pupil/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"username":"$username","password":"$password"}""" + }.andExpect { + status { isOk() } + }.andReturn() + .response.contentAsString, + ).get("accessToken") + .asText() private fun activeTeacher(email: String): User = User( diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/notification/NotificationInboxServiceTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/notification/NotificationInboxServiceTests.kt index 767a00f7..6be71127 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/notification/NotificationInboxServiceTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/notification/NotificationInboxServiceTests.kt @@ -29,10 +29,11 @@ class NotificationInboxServiceTests { fun setUp() { teacherNotificationRepository = mock(TeacherNotificationRepository::class.java) currentUserResolver = mock(CurrentUserResolver::class.java) - notificationInboxService = NotificationInboxService( - teacherNotificationRepository = teacherNotificationRepository, - currentUserResolver = currentUserResolver, - ) + notificationInboxService = + NotificationInboxService( + teacherNotificationRepository = teacherNotificationRepository, + currentUserResolver = currentUserResolver, + ) } @Test diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/notification/NotificationPersistenceTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/notification/NotificationPersistenceTests.kt index 71103779..42762c63 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/notification/NotificationPersistenceTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/notification/NotificationPersistenceTests.kt @@ -25,138 +25,129 @@ import java.time.Instant import java.util.concurrent.atomic.AtomicInteger @SpringBootTest -class NotificationPersistenceTests @Autowired constructor( - private val userRepository: UserRepository, - private val classroomRepository: ClassroomRepository, - private val teacherNotificationRepository: TeacherNotificationRepository, - private val teacherNotificationMuteRepository: TeacherNotificationMuteRepository, -) { - companion object { - private val sequence = AtomicInteger() - } +class NotificationPersistenceTests + @Autowired + constructor( + private val userRepository: UserRepository, + private val classroomRepository: ClassroomRepository, + private val teacherNotificationRepository: TeacherNotificationRepository, + private val teacherNotificationMuteRepository: TeacherNotificationMuteRepository, + ) { + companion object { + private val sequence = AtomicInteger() + } - @Test - fun `finds teacher inbox entries with filters`() { - val teacher = teacher() - val otherTeacher = teacher() - val classroom = classroom(teacher) - val otherClassroom = classroom(teacher) - - teacherNotificationRepository.saveAndFlush( - notification( - teacherUserId = requireNotNull(teacher.id), - classroomId = classroom.id, - type = NotificationType.MAP_STOP_COMPLETED, - ), - ) - teacherNotificationRepository.saveAndFlush( - notification( - teacherUserId = requireNotNull(teacher.id), - classroomId = classroom.id, - type = NotificationType.PUPIL_STUCK, - readAt = Instant.now(), - ), - ) - teacherNotificationRepository.saveAndFlush( - notification( - teacherUserId = requireNotNull(teacher.id), - classroomId = otherClassroom.id, - type = NotificationType.GAME_COMPLETED, - dismissedAt = Instant.now(), - ), - ) - teacherNotificationRepository.saveAndFlush( - notification( - teacherUserId = requireNotNull(otherTeacher.id), - classroomId = classroom.id, - type = NotificationType.MAP_STOP_COMPLETED, - ), - ) - - val allInbox = teacherNotificationRepository.findInboxPage( - teacherUserId = requireNotNull(teacher.id), - classroomIds = listOf(-1L), - classroomIdsEnabled = false, - types = listOf(NotificationType.MAP_STOP_COMPLETED), - typesEnabled = false, - pupilUserIds = listOf(-1L), - pupilUserIdsEnabled = false, - q = null, - searchedTypes = listOf(NotificationType.MAP_STOP_COMPLETED), - searchedTypesEnabled = false, - from = null, - to = null, - unreadOnly = false, - pageable = PageRequest.of(0, 20), - ) - val unreadInbox = teacherNotificationRepository.findInboxPage( - teacherUserId = requireNotNull(teacher.id), - classroomIds = listOf(classroom.id), - classroomIdsEnabled = true, - types = listOf(NotificationType.MAP_STOP_COMPLETED), - typesEnabled = true, - pupilUserIds = listOf(-1L), - pupilUserIdsEnabled = false, - q = null, - searchedTypes = listOf(NotificationType.MAP_STOP_COMPLETED), - searchedTypesEnabled = false, - from = null, - to = null, - unreadOnly = true, - pageable = PageRequest.of(0, 20), - ) - - assertEquals(2, allInbox.totalElements) - assertEquals(1, unreadInbox.totalElements) - assertEquals(1, teacherNotificationRepository.countByTeacherUserIdAndReadAtIsNullAndDismissedAtIsNull(requireNotNull(teacher.id))) - } + @Test + fun `finds teacher inbox entries with filters`() { + val teacher = teacher() + val otherTeacher = teacher() + val classroom = classroom(teacher) + val otherClassroom = classroom(teacher) - @Test - fun `persists notification payload and thread key`() { - val teacher = teacher() - val classroom = classroom(teacher) + teacherNotificationRepository.saveAndFlush( + notification( + teacherUserId = requireNotNull(teacher.id), + classroomId = classroom.id, + type = NotificationType.MAP_STOP_COMPLETED, + ), + ) + teacherNotificationRepository.saveAndFlush( + notification( + teacherUserId = requireNotNull(teacher.id), + classroomId = classroom.id, + type = NotificationType.PUPIL_STUCK, + readAt = Instant.now(), + ), + ) + teacherNotificationRepository.saveAndFlush( + notification( + teacherUserId = requireNotNull(teacher.id), + classroomId = otherClassroom.id, + type = NotificationType.GAME_COMPLETED, + dismissedAt = Instant.now(), + ), + ) + teacherNotificationRepository.saveAndFlush( + notification( + teacherUserId = requireNotNull(otherTeacher.id), + classroomId = classroom.id, + type = NotificationType.MAP_STOP_COMPLETED, + ), + ) - val saved = teacherNotificationRepository.saveAndFlush( - TeacherNotification( - teacherUserId = requireNotNull(teacher.id), - classroomId = classroom.id, - pupilUserId = null, - type = NotificationType.PUPIL_STUCK, - threadKey = "stuck:1:2:3", - activeThreadKey = "stuck:1:2:3", - renderCode = NotificationRenderCode.PUPIL_STUCK.code, - renderArgsJson = """{"stopTitle":"Omrade 3"}""", - payloadJson = """{"stopId":3}""", - ), - ) - - assertNotNull(saved.id) - assertEquals("stuck:1:2:3", saved.threadKey) - assertEquals("stuck:1:2:3", saved.activeThreadKey) - assertEquals(NotificationRenderCode.PUPIL_STUCK.code, saved.renderCode) - assertEquals("""{"stopTitle":"Omrade 3"}""", saved.renderArgsJson) - assertEquals("""{"stopId":3}""", saved.payloadJson) - } + val allInbox = + teacherNotificationRepository.findInboxPage( + teacherUserId = requireNotNull(teacher.id), + classroomIds = listOf(-1L), + classroomIdsEnabled = false, + types = listOf(NotificationType.MAP_STOP_COMPLETED), + typesEnabled = false, + pupilUserIds = listOf(-1L), + pupilUserIdsEnabled = false, + q = null, + searchedTypes = listOf(NotificationType.MAP_STOP_COMPLETED), + searchedTypesEnabled = false, + from = null, + to = null, + unreadOnly = false, + pageable = PageRequest.of(0, 20), + ) + val unreadInbox = + teacherNotificationRepository.findInboxPage( + teacherUserId = requireNotNull(teacher.id), + classroomIds = listOf(classroom.id), + classroomIdsEnabled = true, + types = listOf(NotificationType.MAP_STOP_COMPLETED), + typesEnabled = true, + pupilUserIds = listOf(-1L), + pupilUserIdsEnabled = false, + q = null, + searchedTypes = listOf(NotificationType.MAP_STOP_COMPLETED), + searchedTypesEnabled = false, + from = null, + to = null, + unreadOnly = true, + pageable = PageRequest.of(0, 20), + ) - @Test - fun `enforces one active thread per teacher`() { - val teacher = teacher() - val classroom = classroom(teacher) + assertEquals(2, allInbox.totalElements) + assertEquals(1, unreadInbox.totalElements) + assertEquals(1, teacherNotificationRepository.countByTeacherUserIdAndReadAtIsNullAndDismissedAtIsNull(requireNotNull(teacher.id))) + } + + @Test + fun `persists notification payload and thread key`() { + val teacher = teacher() + val classroom = classroom(teacher) + + val saved = + teacherNotificationRepository.saveAndFlush( + TeacherNotification( + teacherUserId = requireNotNull(teacher.id), + classroomId = classroom.id, + pupilUserId = null, + type = NotificationType.PUPIL_STUCK, + threadKey = "stuck:1:2:3", + activeThreadKey = "stuck:1:2:3", + renderCode = NotificationRenderCode.PUPIL_STUCK.code, + renderArgsJson = """{"stopTitle":"Omrade 3"}""", + payloadJson = """{"stopId":3}""", + ), + ) + + assertNotNull(saved.id) + assertEquals("stuck:1:2:3", saved.threadKey) + assertEquals("stuck:1:2:3", saved.activeThreadKey) + assertEquals(NotificationRenderCode.PUPIL_STUCK.code, saved.renderCode) + assertEquals("""{"stopTitle":"Omrade 3"}""", saved.renderArgsJson) + assertEquals("""{"stopId":3}""", saved.payloadJson) + } + + @Test + fun `enforces one active thread per teacher`() { + val teacher = teacher() + val classroom = classroom(teacher) - teacherNotificationRepository.saveAndFlush( - TeacherNotification( - teacherUserId = requireNotNull(teacher.id), - classroomId = classroom.id, - pupilUserId = null, - type = NotificationType.PUPIL_STUCK, - threadKey = "stuck:1:2:3", - activeThreadKey = "stuck:1:2:3", - renderCode = NotificationRenderCode.PUPIL_STUCK.code, - renderArgsJson = """{"stopTitle":"Omrade 3"}""", - ), - ) - - assertThrows { teacherNotificationRepository.saveAndFlush( TeacherNotification( teacherUserId = requireNotNull(teacher.id), @@ -169,23 +160,28 @@ class NotificationPersistenceTests @Autowired constructor( renderArgsJson = """{"stopTitle":"Omrade 3"}""", ), ) - } - } - @Test - fun `enforces one mute per teacher classroom and type`() { - val teacher = teacher() - val classroom = classroom(teacher) + assertThrows { + teacherNotificationRepository.saveAndFlush( + TeacherNotification( + teacherUserId = requireNotNull(teacher.id), + classroomId = classroom.id, + pupilUserId = null, + type = NotificationType.PUPIL_STUCK, + threadKey = "stuck:1:2:3", + activeThreadKey = "stuck:1:2:3", + renderCode = NotificationRenderCode.PUPIL_STUCK.code, + renderArgsJson = """{"stopTitle":"Omrade 3"}""", + ), + ) + } + } - teacherNotificationMuteRepository.saveAndFlush( - TeacherNotificationMute( - teacherUserId = requireNotNull(teacher.id), - classroomId = classroom.id, - type = NotificationType.PUPIL_STUCK, - ), - ) + @Test + fun `enforces one mute per teacher classroom and type`() { + val teacher = teacher() + val classroom = classroom(teacher) - assertThrows { teacherNotificationMuteRepository.saveAndFlush( TeacherNotificationMute( teacherUserId = requireNotNull(teacher.id), @@ -193,59 +189,68 @@ class NotificationPersistenceTests @Autowired constructor( type = NotificationType.PUPIL_STUCK, ), ) - } - } - private fun notification( - teacherUserId: Long, - classroomId: Long, - type: NotificationType, - readAt: Instant? = null, - dismissedAt: Instant? = null, - ): TeacherNotification = - TeacherNotification( - teacherUserId = teacherUserId, - classroomId = classroomId, - pupilUserId = null, - type = type, - threadKey = null, - activeThreadKey = null, - renderCode = renderCodeFor(type), - renderArgsJson = """{"type":"$type"}""", - readAt = readAt, - dismissedAt = dismissedAt, - ) - - private fun renderCodeFor(type: NotificationType): String = - when (type) { - NotificationType.MAP_STOP_COMPLETED -> NotificationRenderCode.MAP_STOP_COMPLETED.code - NotificationType.GAME_COMPLETED -> NotificationRenderCode.GAME_COMPLETED.code - NotificationType.PUPIL_STUCK -> NotificationRenderCode.PUPIL_STUCK.code - NotificationType.WEEKLY_MYSTERY_SUBMITTED -> NotificationRenderCode.WEEKLY_MYSTERY_SUBMITTED.code + assertThrows { + teacherNotificationMuteRepository.saveAndFlush( + TeacherNotificationMute( + teacherUserId = requireNotNull(teacher.id), + classroomId = classroom.id, + type = NotificationType.PUPIL_STUCK, + ), + ) + } } - private fun teacher(): User = - sequence.incrementAndGet().let { suffix -> - userRepository.saveAndFlush( - User( - email = "notify-teacher-$suffix@example.com", - passwordHash = "hash", - role = UserRole.TEACHER, - status = UserStatus.ACTIVE, - ), + private fun notification( + teacherUserId: Long, + classroomId: Long, + type: NotificationType, + readAt: Instant? = null, + dismissedAt: Instant? = null, + ): TeacherNotification = + TeacherNotification( + teacherUserId = teacherUserId, + classroomId = classroomId, + pupilUserId = null, + type = type, + threadKey = null, + activeThreadKey = null, + renderCode = renderCodeFor(type), + renderArgsJson = """{"type":"$type"}""", + readAt = readAt, + dismissedAt = dismissedAt, ) - } - private fun classroom(ownerTeacher: User): Classroom = - sequence.incrementAndGet().let { suffix -> - classroomRepository.saveAndFlush( - Classroom( - ownerTeacherId = requireNotNull(ownerTeacher.id), - title = "Notify-$suffix", - description = "Notification classroom", - joinCode = "NTY$suffix", - status = ClassroomStatus.ACTIVE, - ), - ) - } -} + private fun renderCodeFor(type: NotificationType): String = + when (type) { + NotificationType.MAP_STOP_COMPLETED -> NotificationRenderCode.MAP_STOP_COMPLETED.code + NotificationType.GAME_COMPLETED -> NotificationRenderCode.GAME_COMPLETED.code + NotificationType.PUPIL_STUCK -> NotificationRenderCode.PUPIL_STUCK.code + NotificationType.WEEKLY_MYSTERY_SUBMITTED -> NotificationRenderCode.WEEKLY_MYSTERY_SUBMITTED.code + } + + private fun teacher(): User = + sequence.incrementAndGet().let { suffix -> + userRepository.saveAndFlush( + User( + email = "notify-teacher-$suffix@example.com", + passwordHash = "hash", + role = UserRole.TEACHER, + status = UserStatus.ACTIVE, + ), + ) + } + + private fun classroom(ownerTeacher: User): Classroom = + sequence.incrementAndGet().let { suffix -> + classroomRepository.saveAndFlush( + Classroom( + ownerTeacherId = requireNotNull(ownerTeacher.id), + title = "Notify-$suffix", + description = "Notification classroom", + joinCode = "NTY$suffix", + status = ClassroomStatus.ACTIVE, + ), + ) + } + } diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarControllerIntegrationTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarControllerIntegrationTests.kt index 6de01774..207074c3 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarControllerIntegrationTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarControllerIntegrationTests.kt @@ -14,7 +14,6 @@ import edu.ntnu.idi.idatt.backend.users.avatar.infrastructure.UserAvatarReposito import edu.ntnu.idi.idatt.backend.users.profile.infrastructure.PupilProfileRepository import edu.ntnu.idi.idatt.backend.users.profile.infrastructure.TeacherProfileRepository import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired @@ -80,13 +79,16 @@ class UserAvatarControllerIntegrationTests { ) val accessToken = pupilAccessToken("ada", "secret123") - val body = readJson( - mockMvc.get("/api/v1/users/me/avatar") { - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - }.andExpect { - status { isOk() } - }.andReturn().response.contentAsString, - ) + val body = + readJson( + mockMvc + .get("/api/v1/users/me/avatar") { + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + }.andExpect { + status { isOk() } + }.andReturn() + .response.contentAsString, + ) assertEquals("detective-default", body.get("hatId").asText()) assertEquals("starter", body.get("outfitId").asText()) @@ -98,16 +100,18 @@ class UserAvatarControllerIntegrationTests { userRepository.save(activeTeacher()) val accessToken = teacherAccessToken() - mockMvc.get("/api/v1/users/me/avatar") { - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - }.andExpect { - status { isForbidden() } - } + mockMvc + .get("/api/v1/users/me/avatar") { + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + }.andExpect { + status { isForbidden() } + } } @Test fun `avatar endpoint requires authentication`() { - mockMvc.get("/api/v1/users/me/avatar") + mockMvc + .get("/api/v1/users/me/avatar") .andExpect { status { isUnauthorized() } } @@ -115,32 +119,40 @@ class UserAvatarControllerIntegrationTests { @Test fun `newly provisioned pupil gets default avatar immediately`() { - val body = readJson( - mockMvc.post("/api/v1/auth/pupil/login") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"username":"fresh-pupil","password":"secret123"}""" - }.andExpect { - status { isOk() } - }.andReturn().response.contentAsString, - ) + val body = + readJson( + mockMvc + .post("/api/v1/auth/pupil/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"username":"fresh-pupil","password":"secret123"}""" + }.andExpect { + status { isOk() } + }.andReturn() + .response.contentAsString, + ) val accessToken = body.get("accessToken").asText() val persistedUser = requireNotNull(userRepository.findByUsernameIgnoreCase("fresh-pupil")) - val persistedAvatar = userAvatarRepository.findById(requireNotNull(persistedUser.id)) - .orElseThrow { AssertionError("Expected default avatar row for pupil") } + val persistedAvatar = + userAvatarRepository + .findById(requireNotNull(persistedUser.id)) + .orElseThrow { AssertionError("Expected default avatar row for pupil") } assertEquals(UserAvatar.DEFAULT_HAT_ID, persistedAvatar.hatId) assertEquals(UserAvatar.DEFAULT_OUTFIT_ID, persistedAvatar.outfitId) assertEquals(UserAvatar.DEFAULT_SKIN_COLOR_ID, persistedAvatar.skinColorId) - val avatarBody = readJson( - mockMvc.get("/api/v1/users/me/avatar") { - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - }.andExpect { - status { isOk() } - }.andReturn().response.contentAsString, - ) + val avatarBody = + readJson( + mockMvc + .get("/api/v1/users/me/avatar") { + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + }.andExpect { + status { isOk() } + }.andReturn() + .response.contentAsString, + ) assertEquals("detective-default", avatarBody.get("hatId").asText()) assertEquals("starter", avatarBody.get("outfitId").asText()) @@ -149,8 +161,10 @@ class UserAvatarControllerIntegrationTests { @Test fun `authenticated pupil gets avatar assets with unlock state`() { - val wizardHat = avatarAssetRepository.findByTypeAndName(AvatarAssetType.HAT, "wizard-hat") - .orElseThrow { AssertionError("Expected wizard-hat avatar asset") } + val wizardHat = + avatarAssetRepository + .findByTypeAndName(AvatarAssetType.HAT, "wizard-hat") + .orElseThrow { AssertionError("Expected wizard-hat avatar asset") } wizardHat.unlocksAtLevel = 5 avatarAssetRepository.save(wizardHat) @@ -158,21 +172,25 @@ class UserAvatarControllerIntegrationTests { userAvatarRepository.save(UserAvatar.defaultsFor(pupil)) val accessToken = pupilAccessToken("asset-pupil", "secret123") - val body = readJson( - mockMvc.get("/api/v1/users/me/avatar/assets") { - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - }.andExpect { - status { isOk() } - }.andReturn().response.contentAsString, - ) + val body = + readJson( + mockMvc + .get("/api/v1/users/me/avatar/assets") { + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + }.andExpect { + status { isOk() } + }.andReturn() + .response.contentAsString, + ) assertEquals(1, body.get("currentLevel").asInt()) assertEquals(true, body.get("hats").isArray) assertEquals(true, body.get("outfits").isArray) assertEquals(true, body.get("skinColors").isArray) - val wizardHatNode = body.get("hats").firstOrNull { it.get("id").asText() == "wizard-hat" } - ?: throw AssertionError("Expected wizard-hat option in hats response") + val wizardHatNode = + body.get("hats").firstOrNull { it.get("id").asText() == "wizard-hat" } + ?: throw AssertionError("Expected wizard-hat option in hats response") assertEquals(5, wizardHatNode.get("unlocksAtLevel").asInt()) assertEquals(false, wizardHatNode.get("unlocked").asBoolean()) @@ -189,33 +207,42 @@ class UserAvatarControllerIntegrationTests { AvatarAssetType.SKIN_COLOR to "light", AvatarAssetType.SKIN_COLOR to "dark", ).forEach { (type, name) -> - val asset = avatarAssetRepository.findByTypeAndName(type, name) - .orElseThrow { AssertionError("Expected avatar asset $name for type $type") } + val asset = + avatarAssetRepository + .findByTypeAndName(type, name) + .orElseThrow { AssertionError("Expected avatar asset $name for type $type") } asset.unlocksAtLevel = 0 avatarAssetRepository.save(asset) } } - private fun pupilAccessToken(username: String, password: String): String = + private fun pupilAccessToken( + username: String, + password: String, + ): String = readJson( - mockMvc.post("/api/v1/auth/pupil/login") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"username":"$username","password":"$password"}""" - }.andExpect { - status { isOk() } - }.andReturn().response.contentAsString, + mockMvc + .post("/api/v1/auth/pupil/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"username":"$username","password":"$password"}""" + }.andExpect { + status { isOk() } + }.andReturn() + .response.contentAsString, ).get("accessToken").asText() private fun teacherAccessToken(): String = readJson( - mockMvc.post("/api/v1/auth/teacher/login") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"email":"teacher@example.com","password":"secret123"}""" - }.andExpect { - status { isOk() } - }.andReturn().response.contentAsString, + mockMvc + .post("/api/v1/auth/teacher/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"email":"teacher@example.com","password":"secret123"}""" + }.andExpect { + status { isOk() } + }.andReturn() + .response.contentAsString, ).get("accessToken").asText() private fun activePupil(username: String) = diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarServiceTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarServiceTests.kt index b6967226..f3147f1c 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarServiceTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarServiceTests.kt @@ -1,18 +1,18 @@ package edu.ntnu.idi.idatt.backend.users.avatar +import edu.ntnu.idi.idatt.backend.game.gameProgress.application.PupilGameProgressService import edu.ntnu.idi.idatt.backend.iam.application.CurrentUserResolver import edu.ntnu.idi.idatt.backend.iam.domain.User import edu.ntnu.idi.idatt.backend.iam.domain.UserRole import edu.ntnu.idi.idatt.backend.iam.domain.UserStatus import edu.ntnu.idi.idatt.backend.iam.security.CurrentUser -import edu.ntnu.idi.idatt.backend.game.gameProgress.application.PupilGameProgressService +import edu.ntnu.idi.idatt.backend.users.avatar.application.UserAvatarService import edu.ntnu.idi.idatt.backend.users.avatar.domain.AvatarAsset import edu.ntnu.idi.idatt.backend.users.avatar.domain.AvatarAssetType -import edu.ntnu.idi.idatt.backend.xp.application.XpService -import edu.ntnu.idi.idatt.backend.users.avatar.application.UserAvatarService import edu.ntnu.idi.idatt.backend.users.avatar.domain.UserAvatar import edu.ntnu.idi.idatt.backend.users.avatar.infrastructure.AvatarAssetRepository import edu.ntnu.idi.idatt.backend.users.avatar.infrastructure.UserAvatarRepository +import edu.ntnu.idi.idatt.backend.xp.application.XpService import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.mockito.Mockito @@ -28,22 +28,24 @@ class UserAvatarServiceTests { val avatarAssetRepository = Mockito.mock(AvatarAssetRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) - val service = UserAvatarService( - currentUserResolver, - userAvatarRepository, - avatarAssetRepository, - pupilGameProgressService, - xpService, - ) + val service = + UserAvatarService( + currentUserResolver, + userAvatarRepository, + avatarAssetRepository, + pupilGameProgressService, + xpService, + ) val user = pupilUser(42L) - val avatar = UserAvatar( - user = user, - hatId = 1L, - outfitId = 2L, - skinColorId = 3L, - ).apply { - userId = 42L - } + val avatar = + UserAvatar( + user = user, + hatId = 1L, + outfitId = 2L, + skinColorId = 3L, + ).apply { + userId = 42L + } Mockito.`when`(currentUserResolver.requirePupil("42")).thenReturn(user) Mockito.`when`(userAvatarRepository.findById(42L)).thenReturn(Optional.of(avatar)) @@ -71,20 +73,22 @@ class UserAvatarServiceTests { val avatarAssetRepository = Mockito.mock(AvatarAssetRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) - val service = UserAvatarService( - currentUserResolver, - userAvatarRepository, - avatarAssetRepository, - pupilGameProgressService, - xpService, - ) + val service = + UserAvatarService( + currentUserResolver, + userAvatarRepository, + avatarAssetRepository, + pupilGameProgressService, + xpService, + ) val exception = ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid authenticated user subject") Mockito.`when`(currentUserResolver.requirePupil("not-a-number")).thenThrow(exception) - val thrown = org.junit.jupiter.api.assertThrows { - service.getUserAvatar(currentPupilUser("not-a-number")) - } + val thrown = + org.junit.jupiter.api.assertThrows { + service.getUserAvatar(currentPupilUser("not-a-number")) + } assertEquals(HttpStatus.UNAUTHORIZED, thrown.statusCode) } @@ -96,20 +100,22 @@ class UserAvatarServiceTests { val avatarAssetRepository = Mockito.mock(AvatarAssetRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) - val service = UserAvatarService( - currentUserResolver, - userAvatarRepository, - avatarAssetRepository, - pupilGameProgressService, - xpService, - ) + val service = + UserAvatarService( + currentUserResolver, + userAvatarRepository, + avatarAssetRepository, + pupilGameProgressService, + xpService, + ) val exception = ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authenticated user was not found") Mockito.`when`(currentUserResolver.requirePupil("42")).thenThrow(exception) - val thrown = org.junit.jupiter.api.assertThrows { - service.getUserAvatar(currentPupilUser("42")) - } + val thrown = + org.junit.jupiter.api.assertThrows { + service.getUserAvatar(currentPupilUser("42")) + } assertEquals(HttpStatus.UNAUTHORIZED, thrown.statusCode) } @@ -121,20 +127,22 @@ class UserAvatarServiceTests { val avatarAssetRepository = Mockito.mock(AvatarAssetRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) - val service = UserAvatarService( - currentUserResolver, - userAvatarRepository, - avatarAssetRepository, - pupilGameProgressService, - xpService, - ) + val service = + UserAvatarService( + currentUserResolver, + userAvatarRepository, + avatarAssetRepository, + pupilGameProgressService, + xpService, + ) val exception = ResponseStatusException(HttpStatus.FORBIDDEN, "Only pupils are allowed") Mockito.`when`(currentUserResolver.requirePupil("7")).thenThrow(exception) - val thrown = org.junit.jupiter.api.assertThrows { - service.getUserAvatar(currentPupilUser("7")) - } + val thrown = + org.junit.jupiter.api.assertThrows { + service.getUserAvatar(currentPupilUser("7")) + } assertEquals(HttpStatus.FORBIDDEN, thrown.statusCode) } @@ -146,21 +154,23 @@ class UserAvatarServiceTests { val avatarAssetRepository = Mockito.mock(AvatarAssetRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) - val service = UserAvatarService( - currentUserResolver, - userAvatarRepository, - avatarAssetRepository, - pupilGameProgressService, - xpService, - ) + val service = + UserAvatarService( + currentUserResolver, + userAvatarRepository, + avatarAssetRepository, + pupilGameProgressService, + xpService, + ) val user = pupilUser(42L) Mockito.`when`(currentUserResolver.requirePupil("42")).thenReturn(user) Mockito.`when`(userAvatarRepository.findById(42L)).thenReturn(Optional.empty()) - val exception = org.junit.jupiter.api.assertThrows { - service.getUserAvatar(currentPupilUser("42")) - } + val exception = + org.junit.jupiter.api.assertThrows { + service.getUserAvatar(currentPupilUser("42")) + } assertEquals(HttpStatus.NOT_FOUND, exception.statusCode) } diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/profile/CurrentUserProfileServiceTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/profile/CurrentUserProfileServiceTests.kt index 8da9751f..a43eb161 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/profile/CurrentUserProfileServiceTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/profile/CurrentUserProfileServiceTests.kt @@ -1,7 +1,8 @@ package edu.ntnu.idi.idatt.backend.users.profile + import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom -import edu.ntnu.idi.idatt.backend.game.gameProgress.domain.PupilGameProgress import edu.ntnu.idi.idatt.backend.game.gameProgress.application.PupilGameProgressService +import edu.ntnu.idi.idatt.backend.game.gameProgress.domain.PupilGameProgress import edu.ntnu.idi.idatt.backend.iam.application.CurrentUserResolver import edu.ntnu.idi.idatt.backend.iam.domain.User import edu.ntnu.idi.idatt.backend.iam.domain.UserRole @@ -28,20 +29,22 @@ class CurrentUserProfileServiceTests { val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val teacherProfileRepository = Mockito.mock(TeacherProfileRepository::class.java) val classroom = Classroom(id = 101L, ownerTeacherId = 7L, title = "Class A") - val service = CurrentUserProfileService( - currentUserResolver, - pupilProfileRepository, - pupilGameProgressService, - teacherProfileRepository, - ) + val service = + CurrentUserProfileService( + currentUserResolver, + pupilProfileRepository, + pupilGameProgressService, + teacherProfileRepository, + ) val user = pupilUser(42L) - val pupilProfile = PupilProfile( - user = user, - displayName = "Ada", - currentClassroom = classroom, - ).apply { - userId = 42L - } + val pupilProfile = + PupilProfile( + user = user, + displayName = "Ada", + currentClassroom = classroom, + ).apply { + userId = 42L + } Mockito.`when`(currentUserResolver.requireUser("42")).thenReturn(user) Mockito.`when`(pupilProfileRepository.findById(42L)).thenReturn(Optional.of(pupilProfile)) @@ -69,20 +72,22 @@ class CurrentUserProfileServiceTests { val pupilProfileRepository = Mockito.mock(PupilProfileRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val teacherProfileRepository = Mockito.mock(TeacherProfileRepository::class.java) - val service = CurrentUserProfileService( - currentUserResolver, - pupilProfileRepository, - pupilGameProgressService, - teacherProfileRepository, - ) + val service = + CurrentUserProfileService( + currentUserResolver, + pupilProfileRepository, + pupilGameProgressService, + teacherProfileRepository, + ) val user = pupilUser(42L) - val pupilProfile = PupilProfile( - user = user, - displayName = "Ada", - currentClassroom = null, - ).apply { - userId = 42L - } + val pupilProfile = + PupilProfile( + user = user, + displayName = "Ada", + currentClassroom = null, + ).apply { + userId = 42L + } Mockito.`when`(currentUserResolver.requireUser("42")).thenReturn(user) Mockito.`when`(pupilProfileRepository.findById(42L)).thenReturn(Optional.of(pupilProfile)) @@ -101,20 +106,22 @@ class CurrentUserProfileServiceTests { val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val teacherProfileRepository = Mockito.mock(TeacherProfileRepository::class.java) val classroom = Classroom(id = 101L, ownerTeacherId = 7L, title = "Class A") - val service = CurrentUserProfileService( - currentUserResolver, - pupilProfileRepository, - pupilGameProgressService, - teacherProfileRepository, - ) + val service = + CurrentUserProfileService( + currentUserResolver, + pupilProfileRepository, + pupilGameProgressService, + teacherProfileRepository, + ) val user = pupilUser(42L) - val pupilProfile = PupilProfile( - user = user, - displayName = "Ada", - currentClassroom = classroom, - ).apply { - userId = 42L - } + val pupilProfile = + PupilProfile( + user = user, + displayName = "Ada", + currentClassroom = classroom, + ).apply { + userId = 42L + } Mockito.`when`(currentUserResolver.requireUser("42")).thenReturn(user) Mockito.`when`(pupilProfileRepository.findById(42L)).thenReturn(Optional.of(pupilProfile)) @@ -132,20 +139,22 @@ class CurrentUserProfileServiceTests { val pupilProfileRepository = Mockito.mock(PupilProfileRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val teacherProfileRepository = Mockito.mock(TeacherProfileRepository::class.java) - val service = CurrentUserProfileService( - currentUserResolver, - pupilProfileRepository, - pupilGameProgressService, - teacherProfileRepository, - ) + val service = + CurrentUserProfileService( + currentUserResolver, + pupilProfileRepository, + pupilGameProgressService, + teacherProfileRepository, + ) val user = teacherUser(7L) - val teacherProfile = TeacherProfile( - user = user, - teacherName = "Ada Lovelace", - schoolName = "Trondheim skole", - ).apply { - userId = 7L - } + val teacherProfile = + TeacherProfile( + user = user, + teacherName = "Ada Lovelace", + schoolName = "Trondheim skole", + ).apply { + userId = 7L + } Mockito.`when`(currentUserResolver.requireUser("7")).thenReturn(user) Mockito.`when`(teacherProfileRepository.findById(7L)).thenReturn(Optional.of(teacherProfile)) @@ -167,19 +176,21 @@ class CurrentUserProfileServiceTests { val pupilProfileRepository = Mockito.mock(PupilProfileRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val teacherProfileRepository = Mockito.mock(TeacherProfileRepository::class.java) - val service = CurrentUserProfileService( - currentUserResolver, - pupilProfileRepository, - pupilGameProgressService, - teacherProfileRepository, - ) + val service = + CurrentUserProfileService( + currentUserResolver, + pupilProfileRepository, + pupilGameProgressService, + teacherProfileRepository, + ) val exception = ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid authenticated user subject") Mockito.`when`(currentUserResolver.requireUser("not-a-number")).thenThrow(exception) - val thrown = org.junit.jupiter.api.assertThrows { - service.getCurrentUserProfile("not-a-number") - } + val thrown = + org.junit.jupiter.api.assertThrows { + service.getCurrentUserProfile("not-a-number") + } assertEquals(HttpStatus.UNAUTHORIZED, thrown.statusCode) } @@ -190,19 +201,21 @@ class CurrentUserProfileServiceTests { val pupilProfileRepository = Mockito.mock(PupilProfileRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val teacherProfileRepository = Mockito.mock(TeacherProfileRepository::class.java) - val service = CurrentUserProfileService( - currentUserResolver, - pupilProfileRepository, - pupilGameProgressService, - teacherProfileRepository, - ) + val service = + CurrentUserProfileService( + currentUserResolver, + pupilProfileRepository, + pupilGameProgressService, + teacherProfileRepository, + ) val exception = ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authenticated user was not found") Mockito.`when`(currentUserResolver.requireUser("42")).thenThrow(exception) - val thrown = org.junit.jupiter.api.assertThrows { - service.getCurrentUserProfile("42") - } + val thrown = + org.junit.jupiter.api.assertThrows { + service.getCurrentUserProfile("42") + } assertEquals(HttpStatus.UNAUTHORIZED, thrown.statusCode) } @@ -213,20 +226,22 @@ class CurrentUserProfileServiceTests { val pupilProfileRepository = Mockito.mock(PupilProfileRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val teacherProfileRepository = Mockito.mock(TeacherProfileRepository::class.java) - val service = CurrentUserProfileService( - currentUserResolver, - pupilProfileRepository, - pupilGameProgressService, - teacherProfileRepository, - ) + val service = + CurrentUserProfileService( + currentUserResolver, + pupilProfileRepository, + pupilGameProgressService, + teacherProfileRepository, + ) val user = pupilUser(42L) Mockito.`when`(currentUserResolver.requireUser("42")).thenReturn(user) Mockito.`when`(pupilProfileRepository.findById(42L)).thenReturn(Optional.empty()) - val exception = org.junit.jupiter.api.assertThrows { - service.getCurrentUserProfile("42") - } + val exception = + org.junit.jupiter.api.assertThrows { + service.getCurrentUserProfile("42") + } assertEquals(HttpStatus.NOT_FOUND, exception.statusCode) } @@ -237,20 +252,22 @@ class CurrentUserProfileServiceTests { val pupilProfileRepository = Mockito.mock(PupilProfileRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val teacherProfileRepository = Mockito.mock(TeacherProfileRepository::class.java) - val service = CurrentUserProfileService( - currentUserResolver, - pupilProfileRepository, - pupilGameProgressService, - teacherProfileRepository, - ) + val service = + CurrentUserProfileService( + currentUserResolver, + pupilProfileRepository, + pupilGameProgressService, + teacherProfileRepository, + ) val user = teacherUser(7L) Mockito.`when`(currentUserResolver.requireUser("7")).thenReturn(user) Mockito.`when`(teacherProfileRepository.findById(7L)).thenReturn(Optional.empty()) - val exception = org.junit.jupiter.api.assertThrows { - service.getCurrentUserProfile("7") - } + val exception = + org.junit.jupiter.api.assertThrows { + service.getCurrentUserProfile("7") + } assertEquals(HttpStatus.NOT_FOUND, exception.statusCode) } @@ -262,20 +279,22 @@ class CurrentUserProfileServiceTests { val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val teacherProfileRepository = Mockito.mock(TeacherProfileRepository::class.java) val classroom = Classroom(id = 101L, ownerTeacherId = 7L, title = "Class A") - val service = CurrentUserProfileService( - currentUserResolver, - pupilProfileRepository, - pupilGameProgressService, - teacherProfileRepository, - ) + val service = + CurrentUserProfileService( + currentUserResolver, + pupilProfileRepository, + pupilGameProgressService, + teacherProfileRepository, + ) val user = pupilUser(42L) - val pupilProfile = PupilProfile( - user = user, - displayName = "Ada", - currentClassroom = classroom, - ).apply { - userId = 42L - } + val pupilProfile = + PupilProfile( + user = user, + displayName = "Ada", + currentClassroom = classroom, + ).apply { + userId = 42L + } Mockito.`when`(currentUserResolver.requirePupil("42")).thenReturn(user) Mockito.`when`(pupilProfileRepository.findById(42L)).thenReturn(Optional.of(pupilProfile)) @@ -286,10 +305,11 @@ class CurrentUserProfileServiceTests { PupilGameProgress(totalXp = 480, currentLevel = 4), ) - val updatedProfile = service.updateCurrentUserDisplayName( - "42", - UpdateCurrentUserDisplayNameCommand(displayName = " Ada Lovelace "), - ) + val updatedProfile = + service.updateCurrentUserDisplayName( + "42", + UpdateCurrentUserDisplayNameCommand(displayName = " Ada Lovelace "), + ) assertEquals("Ada Lovelace", updatedProfile.displayName) assertEquals(101L, updatedProfile.currentClassroomId) @@ -306,20 +326,22 @@ class CurrentUserProfileServiceTests { val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val teacherProfileRepository = Mockito.mock(TeacherProfileRepository::class.java) val classroom = Classroom(id = 101L, ownerTeacherId = 7L, title = "Class A") - val service = CurrentUserProfileService( - currentUserResolver, - pupilProfileRepository, - pupilGameProgressService, - teacherProfileRepository, - ) + val service = + CurrentUserProfileService( + currentUserResolver, + pupilProfileRepository, + pupilGameProgressService, + teacherProfileRepository, + ) val user = pupilUser(42L) - val pupilProfile = PupilProfile( - user = user, - displayName = "Ada", - currentClassroom = classroom, - ).apply { - userId = 42L - } + val pupilProfile = + PupilProfile( + user = user, + displayName = "Ada", + currentClassroom = classroom, + ).apply { + userId = 42L + } Mockito.`when`(currentUserResolver.requirePupil("42")).thenReturn(user) Mockito.`when`(pupilProfileRepository.findById(42L)).thenReturn(Optional.of(pupilProfile)) @@ -346,22 +368,24 @@ class CurrentUserProfileServiceTests { val pupilProfileRepository = Mockito.mock(PupilProfileRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val teacherProfileRepository = Mockito.mock(TeacherProfileRepository::class.java) - val service = CurrentUserProfileService( - currentUserResolver, - pupilProfileRepository, - pupilGameProgressService, - teacherProfileRepository, - ) + val service = + CurrentUserProfileService( + currentUserResolver, + pupilProfileRepository, + pupilGameProgressService, + teacherProfileRepository, + ) val exception = ResponseStatusException(HttpStatus.FORBIDDEN, "Only pupils are allowed") Mockito.`when`(currentUserResolver.requirePupil("7")).thenThrow(exception) - val thrown = org.junit.jupiter.api.assertThrows { - service.updateCurrentUserDisplayName( - "7", - UpdateCurrentUserDisplayNameCommand(displayName = "Teacher"), - ) - } + val thrown = + org.junit.jupiter.api.assertThrows { + service.updateCurrentUserDisplayName( + "7", + UpdateCurrentUserDisplayNameCommand(displayName = "Teacher"), + ) + } assertEquals(HttpStatus.FORBIDDEN, thrown.statusCode) } @@ -372,23 +396,25 @@ class CurrentUserProfileServiceTests { val pupilProfileRepository = Mockito.mock(PupilProfileRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val teacherProfileRepository = Mockito.mock(TeacherProfileRepository::class.java) - val service = CurrentUserProfileService( - currentUserResolver, - pupilProfileRepository, - pupilGameProgressService, - teacherProfileRepository, - ) + val service = + CurrentUserProfileService( + currentUserResolver, + pupilProfileRepository, + pupilGameProgressService, + teacherProfileRepository, + ) val user = pupilUser(42L) Mockito.`when`(currentUserResolver.requirePupil("42")).thenReturn(user) Mockito.`when`(pupilProfileRepository.findById(42L)).thenReturn(Optional.empty()) - val thrown = org.junit.jupiter.api.assertThrows { - service.updateCurrentUserDisplayName( - "42", - UpdateCurrentUserDisplayNameCommand(displayName = "Ada"), - ) - } + val thrown = + org.junit.jupiter.api.assertThrows { + service.updateCurrentUserDisplayName( + "42", + UpdateCurrentUserDisplayNameCommand(displayName = "Ada"), + ) + } assertEquals(HttpStatus.NOT_FOUND, thrown.statusCode) } diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/profile/UserProfileControllerIntegrationTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/profile/UserProfileControllerIntegrationTests.kt index 20f77271..db388158 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/profile/UserProfileControllerIntegrationTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/profile/UserProfileControllerIntegrationTests.kt @@ -2,6 +2,8 @@ package edu.ntnu.idi.idatt.backend.users.profile import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.json.JsonMapper +import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom +import edu.ntnu.idi.idatt.backend.classroom.infrastructure.ClassroomRepository import edu.ntnu.idi.idatt.backend.game.gameProgress.domain.PupilGameProgress import edu.ntnu.idi.idatt.backend.game.gameProgress.domain.PupilGameProgressId import edu.ntnu.idi.idatt.backend.game.gameProgress.infrastructure.PupilGameProgressRepository @@ -10,8 +12,6 @@ import edu.ntnu.idi.idatt.backend.iam.domain.UserRole import edu.ntnu.idi.idatt.backend.iam.domain.UserStatus import edu.ntnu.idi.idatt.backend.iam.infrastructure.RefreshTokenRepository import edu.ntnu.idi.idatt.backend.iam.infrastructure.UserRepository -import edu.ntnu.idi.idatt.backend.classroom.infrastructure.ClassroomRepository -import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom import edu.ntnu.idi.idatt.backend.users.profile.domain.PupilProfile import edu.ntnu.idi.idatt.backend.users.profile.domain.TeacherProfile import edu.ntnu.idi.idatt.backend.users.profile.infrastructure.PupilProfileRepository @@ -75,9 +75,10 @@ class UserProfileControllerIntegrationTests { fun `authenticated pupil get returns current pupil profile`() { val pupil = userRepository.save(activePupil()) val teacher = userRepository.save(activeTeacher()) - val classroom = classroomRepository.save( - Classroom(ownerTeacherId = requireNotNull(teacher.id), title = "Test Class") - ) + val classroom = + classroomRepository.save( + Classroom(ownerTeacherId = requireNotNull(teacher.id), title = "Test Class"), + ) pupilProfileRepository.save( PupilProfile( user = pupil, @@ -97,13 +98,16 @@ class UserProfileControllerIntegrationTests { val accessToken = pupilAccessToken("ada", "secret123") - val body = readJson( - mockMvc.get("/api/v1/users/me") { - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - }.andExpect { - status { isOk() } - }.andReturn().response.contentAsString, - ) + val body = + readJson( + mockMvc + .get("/api/v1/users/me") { + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + }.andExpect { + status { isOk() } + }.andReturn() + .response.contentAsString, + ) assertEquals(pupil.id, body.get("userId").asLong()) assertEquals("PUPIL", body.get("role").asText()) @@ -121,9 +125,10 @@ class UserProfileControllerIntegrationTests { fun `authenticated pupil get returns default progress when current classroom has no progress row`() { val pupil = userRepository.save(activePupil()) val teacher = userRepository.save(activeTeacher()) - val classroom = classroomRepository.save( - Classroom(ownerTeacherId = requireNotNull(teacher.id), title = "Test Class") - ) + val classroom = + classroomRepository.save( + Classroom(ownerTeacherId = requireNotNull(teacher.id), title = "Test Class"), + ) pupilProfileRepository.save( PupilProfile( user = pupil, @@ -134,13 +139,16 @@ class UserProfileControllerIntegrationTests { val accessToken = pupilAccessToken("ada", "secret123") - val body = readJson( - mockMvc.get("/api/v1/users/me") { - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - }.andExpect { - status { isOk() } - }.andReturn().response.contentAsString, - ) + val body = + readJson( + mockMvc + .get("/api/v1/users/me") { + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + }.andExpect { + status { isOk() } + }.andReturn() + .response.contentAsString, + ) assertEquals(0, body.get("pupilProfile").get("totalXp").asInt()) assertEquals(1, body.get("pupilProfile").get("currentLevel").asInt()) @@ -158,13 +166,16 @@ class UserProfileControllerIntegrationTests { ) val accessToken = teacherAccessToken() - val body = readJson( - mockMvc.get("/api/v1/users/me") { - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - }.andExpect { - status { isOk() } - }.andReturn().response.contentAsString, - ) + val body = + readJson( + mockMvc + .get("/api/v1/users/me") { + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + }.andExpect { + status { isOk() } + }.andReturn() + .response.contentAsString, + ) assertEquals(teacher.id, body.get("userId").asLong()) assertEquals("TEACHER", body.get("role").asText()) @@ -178,17 +189,19 @@ class UserProfileControllerIntegrationTests { @Test fun `authenticated pupil can update display name`() { val pupil = userRepository.save(activePupil()) - val teacher = userRepository.save( - User( - email = "anotherteacher@example.com", - passwordHash = requireNotNull(passwordEncoder.encode("secret123")), - role = UserRole.TEACHER, - status = UserStatus.ACTIVE, + val teacher = + userRepository.save( + User( + email = "anotherteacher@example.com", + passwordHash = requireNotNull(passwordEncoder.encode("secret123")), + role = UserRole.TEACHER, + status = UserStatus.ACTIVE, + ), + ) + val classroom = + classroomRepository.save( + Classroom(ownerTeacherId = requireNotNull(teacher.id), title = "Test Class"), ) - ) - val classroom = classroomRepository.save( - Classroom(ownerTeacherId = requireNotNull(teacher.id), title = "Test Class") - ) pupilProfileRepository.save( PupilProfile( user = pupil, @@ -208,20 +221,23 @@ class UserProfileControllerIntegrationTests { val accessToken = pupilAccessToken("ada", "secret123") - val body = readJson( - mockMvc.put("/api/v1/users/me") { - with(csrf()) - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - contentType = MediaType.APPLICATION_JSON - content = """{"displayName":" Ada Lovelace "}""" - }.andExpect { - status { isOk() } - jsonPath("$.displayName") { value("Ada Lovelace") } - jsonPath("$.currentClassroomId") { value(classroom.id) } - jsonPath("$.totalXp") { value(480) } - jsonPath("$.currentLevel") { value(4) } - }.andReturn().response.contentAsString, - ) + val body = + readJson( + mockMvc + .put("/api/v1/users/me") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + contentType = MediaType.APPLICATION_JSON + content = """{"displayName":" Ada Lovelace "}""" + }.andExpect { + status { isOk() } + jsonPath("$.displayName") { value("Ada Lovelace") } + jsonPath("$.currentClassroomId") { value(classroom.id) } + jsonPath("$.totalXp") { value(480) } + jsonPath("$.currentLevel") { value(4) } + }.andReturn() + .response.contentAsString, + ) assertEquals("Ada Lovelace", body.get("displayName").asText()) assertEquals(480, body.get("totalXp").asInt()) @@ -241,14 +257,15 @@ class UserProfileControllerIntegrationTests { ) val accessToken = teacherAccessToken() - mockMvc.put("/api/v1/users/me") { - with(csrf()) - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - contentType = MediaType.APPLICATION_JSON - content = """{"displayName":"Teacher"}""" - }.andExpect { - status { isForbidden() } - } + mockMvc + .put("/api/v1/users/me") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + contentType = MediaType.APPLICATION_JSON + content = """{"displayName":"Teacher"}""" + }.andExpect { + status { isForbidden() } + } } @Test @@ -262,15 +279,18 @@ class UserProfileControllerIntegrationTests { ) val accessToken = pupilAccessToken("ada", "secret123") - val body = readJson( - mockMvc.put("/api/v1/users/me/onboarding/completed") { - with(csrf()) - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - }.andExpect { - status { isOk() } - jsonPath("$.hasCompletedOnboarding") { value(true) } - }.andReturn().response.contentAsString, - ) + val body = + readJson( + mockMvc + .put("/api/v1/users/me/onboarding/completed") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + }.andExpect { + status { isOk() } + jsonPath("$.hasCompletedOnboarding") { value(true) } + }.andReturn() + .response.contentAsString, + ) assertEquals(true, body.get("hasCompletedOnboarding").asBoolean()) assertEquals( @@ -281,18 +301,20 @@ class UserProfileControllerIntegrationTests { @Test fun `user profile endpoint requires authentication`() { - mockMvc.get("/api/v1/users/me") + mockMvc + .get("/api/v1/users/me") .andExpect { status { isUnauthorized() } } - mockMvc.put("/api/v1/users/me") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"displayName":"Ada"}""" - }.andExpect { - status { isUnauthorized() } - } + mockMvc + .put("/api/v1/users/me") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"displayName":"Ada"}""" + }.andExpect { + status { isUnauthorized() } + } } @Test @@ -300,38 +322,46 @@ class UserProfileControllerIntegrationTests { userRepository.save(activePupil()) val accessToken = pupilAccessToken("ada", "secret123") - mockMvc.put("/api/v1/users/me") { - with(csrf()) - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - contentType = MediaType.APPLICATION_JSON - content = """{"displayName":" "}""" - }.andExpect { - status { isBadRequest() } - } + mockMvc + .put("/api/v1/users/me") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + contentType = MediaType.APPLICATION_JSON + content = """{"displayName":" "}""" + }.andExpect { + status { isBadRequest() } + } } private fun readJson(content: String): JsonNode = jsonMapper.readTree(content) - private fun pupilAccessToken(username: String, password: String): String = + private fun pupilAccessToken( + username: String, + password: String, + ): String = readJson( - mockMvc.post("/api/v1/auth/pupil/login") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"username":"$username","password":"$password"}""" - }.andExpect { - status { isOk() } - }.andReturn().response.contentAsString, + mockMvc + .post("/api/v1/auth/pupil/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"username":"$username","password":"$password"}""" + }.andExpect { + status { isOk() } + }.andReturn() + .response.contentAsString, ).get("accessToken").asText() private fun teacherAccessToken(): String = readJson( - mockMvc.post("/api/v1/auth/teacher/login") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"email":"teacher@example.com","password":"secret123"}""" - }.andExpect { - status { isOk() } - }.andReturn().response.contentAsString, + mockMvc + .post("/api/v1/auth/teacher/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"email":"teacher@example.com","password":"secret123"}""" + }.andExpect { + status { isOk() } + }.andReturn() + .response.contentAsString, ).get("accessToken").asText() private fun activePupil() = diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/settings/UserSettingsControllerIntegrationTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/settings/UserSettingsControllerIntegrationTests.kt index c1099380..63091e1e 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/settings/UserSettingsControllerIntegrationTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/settings/UserSettingsControllerIntegrationTests.kt @@ -59,16 +59,17 @@ class UserSettingsControllerIntegrationTests { userRepository.save(activePupil()) val accessToken = pupilAccessToken() - mockMvc.get("/api/v1/users/me/settings") { - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - }.andExpect { - status { isOk() } - jsonPath("$.musicEnabled") { value(true) } - jsonPath("$.sfxEnabled") { value(true) } - jsonPath("$.volumeMaster") { value(100) } - jsonPath("$.volumeMusic") { value(100) } - jsonPath("$.volumeSfx") { value(100) } - } + mockMvc + .get("/api/v1/users/me/settings") { + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + }.andExpect { + status { isOk() } + jsonPath("$.musicEnabled") { value(true) } + jsonPath("$.sfxEnabled") { value(true) } + jsonPath("$.volumeMaster") { value(100) } + jsonPath("$.volumeMusic") { value(100) } + jsonPath("$.volumeSfx") { value(100) } + } val persistedSettings = userSettingsRepository.findAll().single() assertNotNull(persistedSettings.userId) @@ -81,11 +82,12 @@ class UserSettingsControllerIntegrationTests { val accessToken = pupilAccessToken() repeat(2) { - mockMvc.get("/api/v1/users/me/settings") { - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - }.andExpect { - status { isOk() } - } + mockMvc + .get("/api/v1/users/me/settings") { + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + }.andExpect { + status { isOk() } + } } assertEquals(1, userSettingsRepository.count()) @@ -96,21 +98,23 @@ class UserSettingsControllerIntegrationTests { userRepository.save(activePupil()) val accessToken = pupilAccessToken() - mockMvc.put("/api/v1/users/me/settings") { - with(csrf()) - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - contentType = MediaType.APPLICATION_JSON - content = """ - {"musicEnabled":false,"sfxEnabled":false,"volumeMaster":25,"volumeMusic":15,"volumeSfx":35} - """.trimIndent() - }.andExpect { - status { isOk() } - jsonPath("$.musicEnabled") { value(false) } - jsonPath("$.sfxEnabled") { value(false) } - jsonPath("$.volumeMaster") { value(25) } - jsonPath("$.volumeMusic") { value(15) } - jsonPath("$.volumeSfx") { value(35) } - } + mockMvc + .put("/api/v1/users/me/settings") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + contentType = MediaType.APPLICATION_JSON + content = + """ + {"musicEnabled":false,"sfxEnabled":false,"volumeMaster":25,"volumeMusic":15,"volumeSfx":35} + """.trimIndent() + }.andExpect { + status { isOk() } + jsonPath("$.musicEnabled") { value(false) } + jsonPath("$.sfxEnabled") { value(false) } + jsonPath("$.volumeMaster") { value(25) } + jsonPath("$.volumeMusic") { value(15) } + jsonPath("$.volumeSfx") { value(35) } + } val persistedSettings = userSettingsRepository.findAll().single() assertFalse(persistedSettings.musicEnabled) @@ -122,20 +126,23 @@ class UserSettingsControllerIntegrationTests { @Test fun `settings endpoints require authentication`() { - mockMvc.get("/api/v1/users/me/settings") + mockMvc + .get("/api/v1/users/me/settings") .andExpect { status { isUnauthorized() } } - mockMvc.put("/api/v1/users/me/settings") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """ - {"musicEnabled":true,"sfxEnabled":true,"volumeMaster":100,"volumeMusic":100,"volumeSfx":100} - """.trimIndent() - }.andExpect { - status { isUnauthorized() } - } + mockMvc + .put("/api/v1/users/me/settings") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = + """ + {"musicEnabled":true,"sfxEnabled":true,"volumeMaster":100,"volumeMusic":100,"volumeSfx":100} + """.trimIndent() + }.andExpect { + status { isUnauthorized() } + } } @Test @@ -143,32 +150,40 @@ class UserSettingsControllerIntegrationTests { userRepository.save(activePupil()) val accessToken = pupilAccessToken() - mockMvc.put("/api/v1/users/me/settings") { - header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") - contentType = MediaType.APPLICATION_JSON - content = """ - {"musicEnabled":true,"sfxEnabled":false,"volumeMaster":80,"volumeMusic":70,"volumeSfx":60} - """.trimIndent() - }.andExpect { - status { isOk() } - jsonPath("$.musicEnabled") { value(true) } - jsonPath("$.sfxEnabled") { value(false) } - jsonPath("$.volumeMaster") { value(80) } - jsonPath("$.volumeMusic") { value(70) } - jsonPath("$.volumeSfx") { value(60) } - } - } - - private fun pupilAccessToken(): String = - JsonMapper.builder().build().readTree( - mockMvc.post("/api/v1/auth/pupil/login") { - with(csrf()) + mockMvc + .put("/api/v1/users/me/settings") { + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") contentType = MediaType.APPLICATION_JSON - content = """{"username":"pupil_one","password":"secret123"}""" + content = + """ + {"musicEnabled":true,"sfxEnabled":false,"volumeMaster":80,"volumeMusic":70,"volumeSfx":60} + """.trimIndent() }.andExpect { status { isOk() } - }.andReturn().response.contentAsString, - ).get("accessToken").asText() + jsonPath("$.musicEnabled") { value(true) } + jsonPath("$.sfxEnabled") { value(false) } + jsonPath("$.volumeMaster") { value(80) } + jsonPath("$.volumeMusic") { value(70) } + jsonPath("$.volumeSfx") { value(60) } + } + } + + private fun pupilAccessToken(): String = + JsonMapper + .builder() + .build() + .readTree( + mockMvc + .post("/api/v1/auth/pupil/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"username":"pupil_one","password":"secret123"}""" + }.andExpect { + status { isOk() } + }.andReturn() + .response.contentAsString, + ).get("accessToken") + .asText() private fun activePupil() = User( diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/settings/UserSettingsServiceTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/settings/UserSettingsServiceTests.kt index 3e94afd7..4ce3179f 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/settings/UserSettingsServiceTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/settings/UserSettingsServiceTests.kt @@ -24,15 +24,16 @@ class UserSettingsServiceTests { val userSettingsRepository = Mockito.mock(UserSettingsRepository::class.java) val service = UserSettingsService(currentUserResolver, userSettingsRepository) val user = activeUser(id = 42L) - val existingSettings = UserSettings( - userId = 42L, - user = user, - musicEnabled = false, - sfxEnabled = true, - volumeMaster = 60, - volumeMusic = 50, - volumeSfx = 70, - ) + val existingSettings = + UserSettings( + userId = 42L, + user = user, + musicEnabled = false, + sfxEnabled = true, + volumeMaster = 60, + volumeMusic = 50, + volumeSfx = 70, + ) Mockito.`when`(currentUserResolver.requireUser("42")).thenReturn(user) Mockito.`when`(userSettingsRepository.findById(42L)).thenReturn(Optional.of(existingSettings)) @@ -73,9 +74,10 @@ class UserSettingsServiceTests { val userSettingsRepository = Mockito.mock(UserSettingsRepository::class.java) val service = UserSettingsService(currentUserResolver, userSettingsRepository) val user = activeUser(id = 42L) - val existingSettings = UserSettings.defaultsFor(user).apply { - userId = 42L - } + val existingSettings = + UserSettings.defaultsFor(user).apply { + userId = 42L + } Mockito.`when`(currentUserResolver.requireUser("42")).thenReturn(user) Mockito.`when`(userSettingsRepository.findById(42L)).thenReturn(Optional.of(existingSettings)) @@ -83,16 +85,17 @@ class UserSettingsServiceTests { invocation.arguments[0] as UserSettings } - val updatedSettings = service.updateUserSettings( - "42", - UpdateUserSettingsCommand( - musicEnabled = false, - sfxEnabled = false, - volumeMaster = 20, - volumeMusic = 10, - volumeSfx = 30, - ), - ) + val updatedSettings = + service.updateUserSettings( + "42", + UpdateUserSettingsCommand( + musicEnabled = false, + sfxEnabled = false, + volumeMaster = 20, + volumeMusic = 10, + volumeSfx = 30, + ), + ) assertEquals(false, updatedSettings.musicEnabled) assertEquals(false, updatedSettings.sfxEnabled) @@ -107,18 +110,19 @@ class UserSettingsServiceTests { val userSettingsRepository = Mockito.mock(UserSettingsRepository::class.java) val service = UserSettingsService(currentUserResolver, userSettingsRepository) - val exception = org.junit.jupiter.api.assertThrows { - service.updateUserSettings( - "42", - UpdateUserSettingsCommand( - musicEnabled = true, - sfxEnabled = true, - volumeMaster = 101, - volumeMusic = 100, - volumeSfx = 100, - ), - ) - } + val exception = + org.junit.jupiter.api.assertThrows { + service.updateUserSettings( + "42", + UpdateUserSettingsCommand( + musicEnabled = true, + sfxEnabled = true, + volumeMaster = 101, + volumeMusic = 100, + volumeSfx = 100, + ), + ) + } assertEquals(HttpStatus.BAD_REQUEST, exception.statusCode) } @@ -132,9 +136,10 @@ class UserSettingsServiceTests { Mockito.`when`(currentUserResolver.requireUser("42")).thenThrow(resolverException) - val thrown = org.junit.jupiter.api.assertThrows { - service.getUserSettings("42") - } + val thrown = + org.junit.jupiter.api.assertThrows { + service.getUserSettings("42") + } assertEquals(HttpStatus.UNAUTHORIZED, thrown.statusCode) } diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/xp/XpServiceTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/xp/XpServiceTests.kt index 5faff877..44817650 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/xp/XpServiceTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/xp/XpServiceTests.kt @@ -2,6 +2,11 @@ package edu.ntnu.idi.idatt.backend.xp import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom import edu.ntnu.idi.idatt.backend.game.gameProgress.application.PupilGameProgressService +import edu.ntnu.idi.idatt.backend.iam.application.CurrentUserResolver +import edu.ntnu.idi.idatt.backend.iam.domain.User +import edu.ntnu.idi.idatt.backend.iam.domain.UserRole +import edu.ntnu.idi.idatt.backend.iam.domain.UserStatus +import edu.ntnu.idi.idatt.backend.iam.security.CurrentUser import edu.ntnu.idi.idatt.backend.xp.application.XpService import edu.ntnu.idi.idatt.backend.xp.domain.PupilXpEvent import edu.ntnu.idi.idatt.backend.xp.domain.PupilXpEventId @@ -9,11 +14,6 @@ import edu.ntnu.idi.idatt.backend.xp.domain.XpEvent import edu.ntnu.idi.idatt.backend.xp.domain.XpEventCode import edu.ntnu.idi.idatt.backend.xp.infrastructure.PupilXpEventRepository import edu.ntnu.idi.idatt.backend.xp.infrastructure.XpEventRepository -import edu.ntnu.idi.idatt.backend.iam.application.CurrentUserResolver -import edu.ntnu.idi.idatt.backend.iam.domain.User -import edu.ntnu.idi.idatt.backend.iam.domain.UserRole -import edu.ntnu.idi.idatt.backend.iam.domain.UserStatus -import edu.ntnu.idi.idatt.backend.iam.security.CurrentUser import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertSame import org.junit.jupiter.api.Test @@ -27,12 +27,13 @@ class XpServiceTests { val pupilXpEventRepository = Mockito.mock(PupilXpEventRepository::class.java) val xpEventRepository = Mockito.mock(XpEventRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) - val service = XpService( - pupilXpEventRepository, - xpEventRepository, - pupilGameProgressService, - currentUserResolver, - ) + val service = + XpService( + pupilXpEventRepository, + xpEventRepository, + pupilGameProgressService, + currentUserResolver, + ) val currentUser = currentUser() val pupil = pupil() val xpEvent = xpEvent(id = 10L, xpScore = 75, repeatable = false) @@ -72,20 +73,22 @@ class XpServiceTests { val pupilXpEventRepository = Mockito.mock(PupilXpEventRepository::class.java) val xpEventRepository = Mockito.mock(XpEventRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) - val service = XpService( - pupilXpEventRepository, - xpEventRepository, - pupilGameProgressService, - currentUserResolver, - ) + val service = + XpService( + pupilXpEventRepository, + xpEventRepository, + pupilGameProgressService, + currentUserResolver, + ) val currentUser = currentUser() val pupil = pupil() - val existingAward = PupilXpEvent( - id = PupilXpEventId(pupilUserId = 1L, xpEventId = 10L), - pupil = pupil, - xpEvent = xpEvent(id = 10L, xpScore = 75, repeatable = false), - completionCount = 1, - ) + val existingAward = + PupilXpEvent( + id = PupilXpEventId(pupilUserId = 1L, xpEventId = 10L), + pupil = pupil, + xpEvent = xpEvent(id = 10L, xpScore = 75, repeatable = false), + completionCount = 1, + ) Mockito.`when`(currentUserResolver.requirePupil(currentUser.subject)).thenReturn(pupil) Mockito.`when`(xpEventRepository.findByCode(XpEventCode.STOP_COMPLETED)).thenReturn(existingAward.xpEvent) @@ -103,21 +106,23 @@ class XpServiceTests { val pupilXpEventRepository = Mockito.mock(PupilXpEventRepository::class.java) val xpEventRepository = Mockito.mock(XpEventRepository::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) - val service = XpService( - pupilXpEventRepository, - xpEventRepository, - pupilGameProgressService, - currentUserResolver, - ) + val service = + XpService( + pupilXpEventRepository, + xpEventRepository, + pupilGameProgressService, + currentUserResolver, + ) val currentUser = currentUser() val pupil = pupil() val xpEvent = xpEvent(id = 10L, xpScore = 80, repeatable = true) - val existingAward = PupilXpEvent( - id = PupilXpEventId(pupilUserId = 1L, xpEventId = 10L), - pupil = pupil, - xpEvent = xpEvent, - completionCount = 1, - ) + val existingAward = + PupilXpEvent( + id = PupilXpEventId(pupilUserId = 1L, xpEventId = 10L), + pupil = pupil, + xpEvent = xpEvent, + completionCount = 1, + ) Mockito.`when`(currentUserResolver.requirePupil(currentUser.subject)).thenReturn(pupil) Mockito.`when`(currentUserResolver.getClassroomByUser(currentUser)).thenReturn(classroom()) @@ -156,7 +161,11 @@ class XpServiceTests { title = "XP Classroom", ) - private fun xpEvent(id: Long, xpScore: Int, repeatable: Boolean): XpEvent = + private fun xpEvent( + id: Long, + xpScore: Int, + repeatable: Boolean, + ): XpEvent = XpEvent( id = id, code = XpEventCode.STOP_COMPLETED, @@ -164,5 +173,4 @@ class XpServiceTests { xpScore = xpScore, repeatable = repeatable, ) - } From 9ad552d978568df445e2dc75a74b2511f23bbcc1 Mon Sep 17 00:00:00 2001 From: Johannes Aamot-Skeidsvoll Date: Tue, 28 Apr 2026 10:29:38 +0200 Subject: [PATCH 06/10] Format after pull --- .../{upload => app/config}/WebMvcConfig.kt | 20 +- .../backend/bootstrap/MysteryBootstrap.kt | 75 +-- .../backend/iam/security/SecurityConfig.kt | 103 ++-- .../mystery/api/FeatureMysteryRequest.kt | 2 +- .../backend/mystery/api/MysteryApiMappings.kt | 2 +- .../backend/mystery/api/MysteryController.kt | 30 +- .../mystery/api/MysterySubmissionResponse.kt | 2 +- .../mystery/api/ReviewSubmissionRequest.kt | 3 +- .../mystery/api/SubmitMysteryRequest.kt | 4 +- .../mystery/api/TeacherMysteryController.kt | 55 ++- .../mystery/api/WeeklyMysteryResponse.kt | 2 +- .../mystery/application/MysteryCommands.kt | 2 +- .../mystery/application/MysteryService.kt | 94 ++-- .../mystery/domain/MysterySubmission.kt | 21 +- .../backend/mystery/domain/ReviewStatus.kt | 2 +- .../backend/mystery/domain/WeeklyMystery.kt | 7 +- .../MysterySubmissionRepository.kt | 8 +- .../infrastructure/WeeklyMysteryRepository.kt | 8 +- .../events/WeeklyMysteryApprovedEvent.kt | 2 +- .../FileStorageService.kt} | 39 +- .../MysteryControllerIntegrationTests.kt | 268 ++++++----- ...Servicetests.kt => MysteryServiceTests.kt} | 291 +++++------ ...eacherMysteryControllerIntegrationTests.kt | 452 ++++++++++-------- 23 files changed, 806 insertions(+), 686 deletions(-) rename src/main/kotlin/edu/ntnu/idi/idatt/backend/{upload => app/config}/WebMvcConfig.kt (62%) rename src/main/kotlin/edu/ntnu/idi/idatt/backend/upload/{FileUploadService.kt => application/FileStorageService.kt} (65%) rename src/test/kotlin/edu/ntnu/idi/idatt/backend/mystery/{MysteryServicetests.kt => MysteryServiceTests.kt} (76%) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/upload/WebMvcConfig.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/app/config/WebMvcConfig.kt similarity index 62% rename from src/main/kotlin/edu/ntnu/idi/idatt/backend/upload/WebMvcConfig.kt rename to src/main/kotlin/edu/ntnu/idi/idatt/backend/app/config/WebMvcConfig.kt index 95b84b6c..8d391603 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/upload/WebMvcConfig.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/app/config/WebMvcConfig.kt @@ -18,14 +18,18 @@ import java.nio.file.Paths */ @Configuration class WebMvcConfig( -@Value("\${app.uploads.base-dir:\${user.home}/nettdetektivene/uploads}") private val baseDirPath: String, + @Value($$"${app.uploads.base-dir:${user.home}/nettdetektivene/uploads}") private val baseDirPath: String, ) : WebMvcConfigurer { + override fun addResourceHandlers(registry: ResourceHandlerRegistry) { + val uploadPath = + Paths + .get(baseDirPath) + .toAbsolutePath() + .toUri() + .toString() -override fun addResourceHandlers(registry: ResourceHandlerRegistry) { -val uploadPath = Paths.get(baseDirPath).toAbsolutePath().toUri().toString() - -registry -.addResourceHandler("/uploads/**") -.addResourceLocations(uploadPath) + registry + .addResourceHandler("/uploads/**") + .addResourceLocations(uploadPath) + } } -} \ No newline at end of file diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/MysteryBootstrap.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/MysteryBootstrap.kt index f86d77b5..4eff3331 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/MysteryBootstrap.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/MysteryBootstrap.kt @@ -3,7 +3,6 @@ package edu.ntnu.idi.idatt.backend.bootstrap import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom import edu.ntnu.idi.idatt.backend.iam.domain.User import edu.ntnu.idi.idatt.backend.mystery.domain.MysterySubmission -import edu.ntnu.idi.idatt.backend.mystery.domain.ReviewStatus import edu.ntnu.idi.idatt.backend.mystery.domain.WeeklyMystery import edu.ntnu.idi.idatt.backend.mystery.infrastructure.MysterySubmissionRepository import edu.ntnu.idi.idatt.backend.mystery.infrastructure.WeeklyMysteryRepository @@ -28,41 +27,50 @@ class MysteryBootstrap( private val submissionRepository: MysterySubmissionRepository, private val weeklyMysteryRepository: WeeklyMysteryRepository, ) { - fun seedAllMysteries(classroom: Classroom, teacher: User, pupilAda: User, pupilNoah: User, pupilSofia: User) { + fun seedAllMysteries( + classroom: Classroom, + teacher: User, + pupilAda: User, + pupilNoah: User, + pupilSofia: User, + ) { if (submissionRepository.findAllByClassroomId(classroom.id).isNotEmpty()) return val thisWeek = currentWeekStart() val lastWeek = thisWeek.minusWeeks(1) // Ada — approved, featured this week - val adaSubmission = submissionRepository.save( - MysterySubmission( - classroom = classroom, - pupil = pupilAda, - description = "Jeg fant denne artikkelen på nettet som hevder at sjokolade kurerer forkjølelse. Ingen kilder er oppgitt og avsender er ukjent.", - attachmentUrl = null, - ).also { it.approve(teacher, "Flott funn! Du la godt merke til at det mangler kilder.") } - ) + val adaSubmission = + submissionRepository.save( + MysterySubmission( + classroom = classroom, + pupil = pupilAda, + description = "Jeg fant denne artikkelen på nettet som hevder at sjokolade kurerer forkjølelse. Ingen kilder er oppgitt og avsender er ukjent.", + attachmentUrl = null, + ).also { it.approve(teacher, "Flott funn! Du la godt merke til at det mangler kilder.") }, + ) // Noah — approved, featured this week - val noahSubmission = submissionRepository.save( - MysterySubmission( - classroom = classroom, - pupil = pupilNoah, - description = "Et innlegg på sosiale medier som sier at en kjendis har sagt noe de aldri har sagt. Bildet ser manipulert ut.", - attachmentUrl = null, - ).also { it.approve(teacher, "Godt observert! Bildesøk er et nyttig verktøy.") } - ) + val noahSubmission = + submissionRepository.save( + MysterySubmission( + classroom = classroom, + pupil = pupilNoah, + description = "Et innlegg på sosiale medier som sier at en kjendis har sagt noe de aldri har sagt. Bildet ser manipulert ut.", + attachmentUrl = null, + ).also { it.approve(teacher, "Godt observert! Bildesøk er et nyttig verktøy.") }, + ) // Sofia — approved, featured last week (tests week navigation) - val sofiaSubmission = submissionRepository.save( - MysterySubmission( - classroom = classroom, - pupil = pupilSofia, - description = "En epost som ser ut som den er fra banken, men avsenderadressen er veldig merkelig og lenken går til et ukjent nettsted.", - attachmentUrl = null, - ).also { it.approve(teacher, "Veldig bra! Dette er et typisk phishing-forsøk.") } - ) + val sofiaSubmission = + submissionRepository.save( + MysterySubmission( + classroom = classroom, + pupil = pupilSofia, + description = "En epost som ser ut som den er fra banken, men avsenderadressen er veldig merkelig og lenken går til et ukjent nettsted.", + attachmentUrl = null, + ).also { it.approve(teacher, "Veldig bra! Dette er et typisk phishing-forsøk.") }, + ) // Ada — pending (tests the 'mine' tab with pending status) submissionRepository.save( @@ -71,7 +79,7 @@ class MysteryBootstrap( pupil = pupilAda, description = "Jeg fant et innlegg som påstår at jordet er flat, med mange likes og delinger. Ingen vitenskapelige kilder.", attachmentUrl = null, - ) + ), ) // Noah — rejected (tests the 'mine' tab with rejected status) @@ -81,7 +89,7 @@ class MysteryBootstrap( pupil = pupilNoah, description = "En reklame som sier du har vunnet en premie.", attachmentUrl = null, - ).also { it.reject(teacher, "Dette er reklame, ikke fake news. Prøv å finne et bedre eksempel.") } + ).also { it.reject(teacher, "Dette er reklame, ikke fake news. Prøv å finne et bedre eksempel.") }, ) // Feature ada + noah this week, sofia last week @@ -91,7 +99,7 @@ class MysteryBootstrap( submission = adaSubmission, weekStart = thisWeek, featuredBy = teacher, - ) + ), ) weeklyMysteryRepository.save( WeeklyMystery( @@ -99,7 +107,7 @@ class MysteryBootstrap( submission = noahSubmission, weekStart = thisWeek, featuredBy = teacher, - ) + ), ) weeklyMysteryRepository.save( WeeklyMystery( @@ -107,10 +115,9 @@ class MysteryBootstrap( submission = sofiaSubmission, weekStart = lastWeek, featuredBy = teacher, - ) + ), ) } - private fun currentWeekStart(): LocalDate = - LocalDate.now().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) -} \ No newline at end of file + private fun currentWeekStart(): LocalDate = LocalDate.now().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) +} diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/security/SecurityConfig.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/security/SecurityConfig.kt index d2c3b31f..0d715e46 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/security/SecurityConfig.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/security/SecurityConfig.kt @@ -1,9 +1,9 @@ package edu.ntnu.idi.idatt.backend.iam.security import com.nimbusds.jose.jwk.source.ImmutableSecret +import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration -import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.http.HttpMethod import org.springframework.security.authentication.AbstractAuthenticationToken import org.springframework.security.authentication.UsernamePasswordAuthenticationToken @@ -51,8 +51,7 @@ class SecurityConfig( * @return configured CSRF token repository */ @Bean - fun csrfTokenRepository(): CsrfTokenRepository = - CookieCsrfTokenRepository.withHttpOnlyFalse() + fun csrfTokenRepository(): CsrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse() /** * Builds the application security filter chain. @@ -67,42 +66,45 @@ class SecurityConfig( fun securityFilterChain( http: HttpSecurity, csrfTokenRepository: CsrfTokenRepository, - ): SecurityFilterChain = http - .cors(Customizer.withDefaults()) - .csrf { - it.csrfTokenRepository(csrfTokenRepository) - .csrfTokenRequestHandler(SpaCsrfTokenRequestHandler()) - } - .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } - .authorizeHttpRequests { - it.requestMatchers( - "/v3/api-docs/**", - "/swagger-ui/**", - "/swagger-ui.html", - "/assets/**", - "/error", - ).permitAll() - .requestMatchers(HttpMethod.GET, "/api/v1/auth/csrf").permitAll() - // public uploads endpoint for now, since images are stored directly on the backend - // instead of a dedicated file storage system - .requestMatchers(HttpMethod.GET, "/uploads/**").permitAll() - .requestMatchers( - HttpMethod.POST, - "/api/v1/auth/teacher/register", - "/api/v1/auth/teacher/login", - "/api/v1/auth/teacher/refresh", - "/api/v1/auth/teacher/logout", - "/api/v1/auth/pupil/login", - "/api/v1/auth/pupil/refresh", - "/api/v1/auth/pupil/logout", - ).permitAll() - .anyRequest().authenticated() - } - .oauth2ResourceServer { - it.jwt { jwt -> jwt.jwtAuthenticationConverter(::toAuthentication) } - } - .addFilterAfter(CsrfCookieFilter(), CsrfFilter::class.java) - .build() + ): SecurityFilterChain = + http + .cors(Customizer.withDefaults()) + .csrf { + it + .csrfTokenRepository(csrfTokenRepository) + .csrfTokenRequestHandler(SpaCsrfTokenRequestHandler()) + }.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .authorizeHttpRequests { + it + .requestMatchers( + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html", + "/assets/**", + "/error", + ).permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/auth/csrf") + .permitAll() + // public uploads endpoint for now, since images are stored directly on the backend + // instead of a dedicated file storage system + .requestMatchers(HttpMethod.GET, "/uploads/**") + .permitAll() + .requestMatchers( + HttpMethod.POST, + "/api/v1/auth/teacher/register", + "/api/v1/auth/teacher/login", + "/api/v1/auth/teacher/refresh", + "/api/v1/auth/teacher/logout", + "/api/v1/auth/pupil/login", + "/api/v1/auth/pupil/refresh", + "/api/v1/auth/pupil/logout", + ).permitAll() + .anyRequest() + .authenticated() + }.oauth2ResourceServer { + it.jwt { jwt -> jwt.jwtAuthenticationConverter(::toAuthentication) } + }.addFilterAfter(CsrfCookieFilter(), CsrfFilter::class.java) + .build() /** * Creates the global CORS configuration source for browser clients. @@ -157,19 +159,26 @@ class SecurityConfig( @Bean fun jwtEncoder(): JwtEncoder = NimbusJwtEncoder(ImmutableSecret(secretKey())) - private fun secretKey(): SecretKeySpec = - SecretKeySpec(securityProperties.jwt.secret.toByteArray(StandardCharsets.UTF_8), "HmacSHA256") + private fun secretKey(): SecretKeySpec = SecretKeySpec(securityProperties.jwt.secret.toByteArray(StandardCharsets.UTF_8), "HmacSHA256") private fun toAuthentication(jwt: Jwt): AbstractAuthenticationToken { val authorities = jwt.getClaimAsStringList("authorities")?.toSet().orEmpty() val portalType = jwt.getClaimAsString("portal_type") ?: "UNKNOWN" - val principal = CurrentUser( - subject = jwt.subject, - portalType = portalType, - email = jwt.getClaimAsString("email"), - authorities = authorities, + val principal = + CurrentUser( + subject = jwt.subject, + portalType = portalType, + email = jwt.getClaimAsString("email"), + authorities = authorities, + ) + return UsernamePasswordAuthenticationToken( + principal, + jwt, + authorities.map { + org.springframework.security.core.authority + .SimpleGrantedAuthority(it) + }, ) - return UsernamePasswordAuthenticationToken(principal, jwt, authorities.map { org.springframework.security.core.authority.SimpleGrantedAuthority(it) }) } private fun audienceValidator(): OAuth2TokenValidator = diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/FeatureMysteryRequest.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/FeatureMysteryRequest.kt index 60198e56..d98f851e 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/FeatureMysteryRequest.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/FeatureMysteryRequest.kt @@ -10,4 +10,4 @@ import java.time.LocalDate data class FeatureMysteryRequest( val submissionId: Long, val weekStart: LocalDate, -) \ No newline at end of file +) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/MysteryApiMappings.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/MysteryApiMappings.kt index 4e495da5..9efab760 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/MysteryApiMappings.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/MysteryApiMappings.kt @@ -25,4 +25,4 @@ fun WeeklyMystery.toResponse(): WeeklyMysteryResponse = featuredByTeacherId = requireNotNull(featuredBy.id) { "Teacher id missing" }, featuredAt = featuredAt, submission = submission.toResponse(), - ) \ No newline at end of file + ) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/MysteryController.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/MysteryController.kt index 2b9fd7ad..fb9b7e19 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/MysteryController.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/MysteryController.kt @@ -93,15 +93,16 @@ class MysteryController( ): MysterySubmissionResponse { val pupil = currentUserResolver.requirePupil(currentUser.subject) val classroom = classroomUserResolver.requireClassroomMember(pupil, classroomId) - return mysteryService.submitExample( - SubmitMysteryCommand( - classroomId = classroomId, - classroom = classroom, - pupilUserId = requireNotNull(pupil.id) { "Pupil id missing" }, - description = request.description, - attachmentUrl = request.attachmentUrl, - ) - ).toResponse() + return mysteryService + .submitExample( + SubmitMysteryCommand( + classroomId = classroomId, + classroom = classroom, + pupilUserId = requireNotNull(pupil.id) { "Pupil id missing" }, + description = request.description, + attachmentUrl = request.attachmentUrl, + ), + ).toResponse() } /** @@ -142,9 +143,10 @@ class MysteryController( ): List { val pupil = currentUserResolver.requirePupil(currentUser.subject) classroomUserResolver.requireClassroomMember(pupil, classroomId) - return mysteryService.getMySubmissions( - classroomId = classroomId, - pupilUserId = requireNotNull(pupil.id) { "Pupil id missing" }, - ).map { it.toResponse() } + return mysteryService + .getMySubmissions( + classroomId = classroomId, + pupilUserId = requireNotNull(pupil.id) { "Pupil id missing" }, + ).map { it.toResponse() } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/MysterySubmissionResponse.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/MysterySubmissionResponse.kt index bca6cb11..e277f28e 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/MysterySubmissionResponse.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/MysterySubmissionResponse.kt @@ -25,4 +25,4 @@ data class MysterySubmissionResponse( val reviewedByTeacherId: Long?, val reviewedAt: java.time.Instant?, val teacherComment: String?, -) \ No newline at end of file +) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/ReviewSubmissionRequest.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/ReviewSubmissionRequest.kt index f89f6b11..21fc1328 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/ReviewSubmissionRequest.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/ReviewSubmissionRequest.kt @@ -1,6 +1,7 @@ package edu.ntnu.idi.idatt.backend.mystery.api import jakarta.validation.constraints.Size + /** * Teacher request to approve or reject a submission. * @@ -9,4 +10,4 @@ import jakarta.validation.constraints.Size data class ReviewSubmissionRequest( @field:Size(max = 1000) val comment: String?, -) \ No newline at end of file +) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/SubmitMysteryRequest.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/SubmitMysteryRequest.kt index 9d1bf59a..1fbf6fdb 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/SubmitMysteryRequest.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/SubmitMysteryRequest.kt @@ -1,6 +1,7 @@ package edu.ntnu.idi.idatt.backend.mystery.api import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.Size + /** * Pupil request to submit a new mystery example. * @@ -11,6 +12,5 @@ data class SubmitMysteryRequest( @field:NotBlank @field:Size(max = 2000) val description: String, - val attachmentUrl: String?, -) \ No newline at end of file +) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/TeacherMysteryController.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/TeacherMysteryController.kt index 76139cc3..a3ebc101 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/TeacherMysteryController.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/TeacherMysteryController.kt @@ -92,14 +92,15 @@ class TeacherMysteryController( ): MysterySubmissionResponse { val teacher = currentUserResolver.requireTeacherOrAdmin(currentUser.subject) classroomUserResolver.requireClassroomTeacherAccess(teacher, classroomId) - return mysteryService.approveSubmission( - ReviewSubmissionCommand( - submissionId = submissionId, - classroomId = classroomId, - teacherUserId = requireNotNull(teacher.id) { "Teacher id missing" }, - comment = request.comment, - ) - ).toResponse() + return mysteryService + .approveSubmission( + ReviewSubmissionCommand( + submissionId = submissionId, + classroomId = classroomId, + teacherUserId = requireNotNull(teacher.id) { "Teacher id missing" }, + comment = request.comment, + ), + ).toResponse() } /** @@ -120,14 +121,15 @@ class TeacherMysteryController( ): MysterySubmissionResponse { val teacher = currentUserResolver.requireTeacherOrAdmin(currentUser.subject) classroomUserResolver.requireClassroomTeacherAccess(teacher, classroomId) - return mysteryService.rejectSubmission( - ReviewSubmissionCommand( - submissionId = submissionId, - classroomId = classroomId, - teacherUserId = requireNotNull(teacher.id) { "Teacher id missing" }, - comment = request.comment, - ) - ).toResponse() + return mysteryService + .rejectSubmission( + ReviewSubmissionCommand( + submissionId = submissionId, + classroomId = classroomId, + teacherUserId = requireNotNull(teacher.id) { "Teacher id missing" }, + comment = request.comment, + ), + ).toResponse() } /** @@ -149,15 +151,16 @@ class TeacherMysteryController( ): WeeklyMysteryResponse { val teacher = currentUserResolver.requireTeacherOrAdmin(currentUser.subject) val classroom = classroomUserResolver.requireClassroomTeacherAccess(teacher, classroomId) - return mysteryService.featureSubmission( - FeatureMysteryCommand( - submissionId = request.submissionId, - classroomId = classroomId, - classroom = classroom, - teacherUserId = requireNotNull(teacher.id) { "Teacher id missing" }, - weekStart = request.weekStart, - ) - ).toResponse() + return mysteryService + .featureSubmission( + FeatureMysteryCommand( + submissionId = request.submissionId, + classroomId = classroomId, + classroom = classroom, + teacherUserId = requireNotNull(teacher.id) { "Teacher id missing" }, + weekStart = request.weekStart, + ), + ).toResponse() } /** @@ -180,7 +183,7 @@ class TeacherMysteryController( UnfeatureMysteryCommand( weeklyMysteryId = weeklyMysteryId, classroomId = classroomId, - ) + ), ) } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/WeeklyMysteryResponse.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/WeeklyMysteryResponse.kt index 6927c6c5..077b6848 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/WeeklyMysteryResponse.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/api/WeeklyMysteryResponse.kt @@ -18,4 +18,4 @@ data class WeeklyMysteryResponse( val featuredByTeacherId: Long, val featuredAt: java.time.Instant?, val submission: MysterySubmissionResponse, -) \ No newline at end of file +) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/application/MysteryCommands.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/application/MysteryCommands.kt index 072e425f..0006fea7 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/application/MysteryCommands.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/application/MysteryCommands.kt @@ -29,4 +29,4 @@ data class FeatureMysteryCommand( data class UnfeatureMysteryCommand( val weeklyMysteryId: Long, val classroomId: Long, -) \ No newline at end of file +) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/application/MysteryService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/application/MysteryService.kt index 783b2b0e..04c32368 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/application/MysteryService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/application/MysteryService.kt @@ -36,7 +36,6 @@ class MysteryService( private val userRepository: UserRepository, private val domainEventPublisher: DomainEventPublisher, ) { - /** * Creates a new mystery submission for a pupil and notifies teachers via a domain event. * @@ -46,24 +45,26 @@ class MysteryService( */ @Transactional fun submitExample(command: SubmitMysteryCommand): MysterySubmission { - val pupil = userRepository.findById(command.pupilUserId).orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil not found") - } - val submission = submissionRepository.save( - MysterySubmission( - classroom = command.classroom, - pupil = pupil, - description = command.description, - attachmentUrl = command.attachmentUrl, + val pupil = + userRepository.findById(command.pupilUserId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil not found") + } + val submission = + submissionRepository.save( + MysterySubmission( + classroom = command.classroom, + pupil = pupil, + description = command.description, + attachmentUrl = command.attachmentUrl, + ), ) - ) domainEventPublisher.publish( WeeklyMysterySubmittedEvent( pupilUserId = command.pupilUserId, classroomId = command.classroomId, submissionId = requireNotNull(submission.id) { "Submission id missing after save" }, - ) + ), ) return submission @@ -77,8 +78,10 @@ class MysteryService( * @return list of the pupil's submissions, in no guaranteed order */ @Transactional(readOnly = true) - fun getMySubmissions(classroomId: Long, pupilUserId: Long): List = - submissionRepository.findAllByClassroomIdAndPupilId(classroomId, pupilUserId) + fun getMySubmissions( + classroomId: Long, + pupilUserId: Long, + ): List = submissionRepository.findAllByClassroomIdAndPupilId(classroomId, pupilUserId) /** * Returns all mysteries featured for the current ISO week in a classroom. @@ -87,8 +90,7 @@ class MysteryService( * @return featured mysteries for this week, may be empty */ @Transactional(readOnly = true) - fun getCurrentWeekMysteries(classroomId: Long): List = - weeklyMysteryRepository.findAllByClassroomIdAndWeekStart(classroomId, currentWeekStart()) + fun getCurrentWeekMysteries(classroomId: Long): List = weeklyMysteryRepository.findAllByClassroomIdAndWeekStart(classroomId, currentWeekStart()) /** * Returns all featured mysteries for a classroom ordered by week descending. @@ -98,8 +100,7 @@ class MysteryService( * @return all weekly mysteries, newest week first */ @Transactional(readOnly = true) - fun getAllWeeklyMysteries(classroomId: Long): List = - weeklyMysteryRepository.findAllByClassroomIdOrderByWeekStartDesc(classroomId) + fun getAllWeeklyMysteries(classroomId: Long): List = weeklyMysteryRepository.findAllByClassroomIdOrderByWeekStartDesc(classroomId) /** * Returns all submissions for a classroom regardless of review status. @@ -108,8 +109,7 @@ class MysteryService( * @return all submissions for the classroom */ @Transactional(readOnly = true) - fun getAllSubmissions(classroomId: Long): List = - submissionRepository.findAllByClassroomId(classroomId) + fun getAllSubmissions(classroomId: Long): List = submissionRepository.findAllByClassroomId(classroomId) /** * Returns all pending submissions for a classroom. @@ -118,8 +118,7 @@ class MysteryService( * @return submissions awaiting teacher review */ @Transactional(readOnly = true) - fun getPendingSubmissions(classroomId: Long): List = - submissionRepository.findAllByClassroomIdAndReviewStatus(classroomId, ReviewStatus.PENDING) + fun getPendingSubmissions(classroomId: Long): List = submissionRepository.findAllByClassroomIdAndReviewStatus(classroomId, ReviewStatus.PENDING) /** * Approves a submission and publishes a [MysterySubmissionApprovedEvent] which triggers @@ -134,9 +133,10 @@ class MysteryService( @Transactional fun approveSubmission(command: ReviewSubmissionCommand): MysterySubmission { val submission = requireSubmissionInClassroom(command.submissionId, command.classroomId) - val teacher = userRepository.findById(command.teacherUserId).orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Teacher not found") - } + val teacher = + userRepository.findById(command.teacherUserId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Teacher not found") + } submission.approve(teacher, command.comment) val saved = submissionRepository.save(submission) @@ -146,7 +146,7 @@ class MysteryService( submissionId = requireNotNull(saved.id) { "Submission id missing after save" }, pupilUserId = requireNotNull(saved.pupil.id) { "Pupil id missing" }, classroomId = command.classroomId, - ) + ), ) return saved @@ -164,9 +164,10 @@ class MysteryService( @Transactional fun rejectSubmission(command: ReviewSubmissionCommand): MysterySubmission { val submission = requireSubmissionInClassroom(command.submissionId, command.classroomId) - val teacher = userRepository.findById(command.teacherUserId).orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Teacher not found") - } + val teacher = + userRepository.findById(command.teacherUserId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Teacher not found") + } submission.reject(teacher, command.comment) return submissionRepository.save(submission) @@ -187,9 +188,10 @@ class MysteryService( @Transactional fun featureSubmission(command: FeatureMysteryCommand): WeeklyMystery { val submission = requireSubmissionInClassroom(command.submissionId, command.classroomId) - val teacher = userRepository.findById(command.teacherUserId).orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Teacher not found") - } + val teacher = + userRepository.findById(command.teacherUserId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Teacher not found") + } if (!submission.isApproved) { throw ResponseStatusException( @@ -204,7 +206,7 @@ class MysteryService( submission = submission, weekStart = command.weekStart.toWeekStart(), featuredBy = teacher, - ) + ), ) } @@ -216,10 +218,11 @@ class MysteryService( */ @Transactional fun unfeatureSubmission(command: UnfeatureMysteryCommand) { - val deleted = weeklyMysteryRepository.deleteByIdAndClassroomId( - command.weeklyMysteryId, - command.classroomId, - ) + val deleted = + weeklyMysteryRepository.deleteByIdAndClassroomId( + command.weeklyMysteryId, + command.classroomId, + ) if (deleted == 0L) { throw ResponseStatusException(HttpStatus.NOT_FOUND, "Weekly mystery entry not found") } @@ -232,21 +235,20 @@ class MysteryService( * @return all weekly mysteries, newest week first */ @Transactional(readOnly = true) - fun getFeaturedMysteries(classroomId: Long): List = - weeklyMysteryRepository.findAllByClassroomIdOrderByWeekStartDesc(classroomId) - + fun getFeaturedMysteries(classroomId: Long): List = weeklyMysteryRepository.findAllByClassroomIdOrderByWeekStartDesc(classroomId) - private fun requireSubmissionInClassroom(submissionId: Long, classroomId: Long): MysterySubmission = + private fun requireSubmissionInClassroom( + submissionId: Long, + classroomId: Long, + ): MysterySubmission = submissionRepository.findByIdAndClassroomId(submissionId, classroomId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Submission not found") companion object { /** Returns the Monday of the ISO week containing [this] date. */ - fun LocalDate.toWeekStart(): LocalDate = - with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + fun LocalDate.toWeekStart(): LocalDate = with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) /** Returns the Monday of the current ISO week. */ - fun currentWeekStart(): LocalDate = - LocalDate.now().toWeekStart() + fun currentWeekStart(): LocalDate = LocalDate.now().toWeekStart() } -} \ No newline at end of file +} diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/domain/MysterySubmission.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/domain/MysterySubmission.kt index 639a44f9..2d871ecb 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/domain/MysterySubmission.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/domain/MysterySubmission.kt @@ -40,36 +40,27 @@ class MysterySubmission( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long = 0, - @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "classroom_id", nullable = false) val classroom: Classroom, - @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "pupil_user_id", nullable = false) val pupil: User, - @Column(name = "description", nullable = false, columnDefinition = "TEXT") val description: String, - @Column(name = "attachment_url", length = 512) val attachmentUrl: String? = null, - @CreationTimestamp @Column(name = "submitted_at", nullable = false, updatable = false) val submittedAt: Instant? = null, - @Enumerated(EnumType.STRING) @Column(name = "review_status", nullable = false) var reviewStatus: ReviewStatus = ReviewStatus.PENDING, - @ManyToOne(fetch = FetchType.LAZY, optional = true) @JoinColumn(name = "reviewed_by_teacher_id", nullable = true) var reviewedBy: User? = null, - @Column(name = "reviewed_at") var reviewedAt: Instant? = null, - @Column(name = "teacher_comment", columnDefinition = "TEXT") var teacherComment: String? = null, ) { @@ -80,7 +71,10 @@ class MysterySubmission( * @param comment Optional feedback to show the pupil. * @throws IllegalStateException if the submission has already been reviewed. */ - fun approve(teacher: User, comment: String? = null) { + fun approve( + teacher: User, + comment: String? = null, + ) { check(reviewStatus == ReviewStatus.PENDING) { "Cannot approve a submission that is already $reviewStatus" } @@ -97,7 +91,10 @@ class MysterySubmission( * @param comment Optional feedback to show the pupil. * @throws IllegalStateException if the submission has already been reviewed. */ - fun reject(teacher: User, comment: String? = null) { + fun reject( + teacher: User, + comment: String? = null, + ) { check(reviewStatus == ReviewStatus.PENDING) { "Cannot reject a submission that is already $reviewStatus" } @@ -108,4 +105,4 @@ class MysterySubmission( } val isApproved: Boolean get() = reviewStatus == ReviewStatus.APPROVED -} \ No newline at end of file +} diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/domain/ReviewStatus.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/domain/ReviewStatus.kt index 52e2d7cf..c127627e 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/domain/ReviewStatus.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/domain/ReviewStatus.kt @@ -4,4 +4,4 @@ enum class ReviewStatus { PENDING, APPROVED, REJECTED, -} \ No newline at end of file +} diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/domain/WeeklyMystery.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/domain/WeeklyMystery.kt index 739e7f3b..7d62ed94 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/domain/WeeklyMystery.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/domain/WeeklyMystery.kt @@ -37,23 +37,18 @@ class WeeklyMystery( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long = 0, - @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "classroom_id", nullable = false) val classroom: Classroom, - @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "submission_id", nullable = false) val submission: MysterySubmission, - @Column(name = "week_start", nullable = false) val weekStart: LocalDate, - @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "featured_by_teacher_id", nullable = false) val featuredBy: User, - @CreationTimestamp @Column(name = "featured_at", nullable = false, updatable = false) val featuredAt: Instant? = null, -) \ No newline at end of file +) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/infrastructure/MysterySubmissionRepository.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/infrastructure/MysterySubmissionRepository.kt index b991a701..b28ff5bd 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/infrastructure/MysterySubmissionRepository.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/infrastructure/MysterySubmissionRepository.kt @@ -7,7 +7,6 @@ import org.springframework.stereotype.Repository @Repository interface MysterySubmissionRepository : JpaRepository { - /** All submissions for a classroom, regardless of status. Used by the teacher review view. */ fun findAllByClassroomId(classroomId: Long): List @@ -27,5 +26,8 @@ interface MysterySubmissionRepository : JpaRepository { * Classroom-scoped fetch by id. Prevents a teacher from reviewing a submission * that belongs to a different classroom. */ - fun findByIdAndClassroomId(id: Long, classroomId: Long): MysterySubmission? -} \ No newline at end of file + fun findByIdAndClassroomId( + id: Long, + classroomId: Long, + ): MysterySubmission? +} diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/infrastructure/WeeklyMysteryRepository.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/infrastructure/WeeklyMysteryRepository.kt index 2e19d4e7..2b896af8 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/infrastructure/WeeklyMysteryRepository.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/mystery/infrastructure/WeeklyMysteryRepository.kt @@ -7,7 +7,6 @@ import java.time.LocalDate @Repository interface WeeklyMysteryRepository : JpaRepository { - /** All mysteries featured for a specific week. The primary pupil-facing query. */ fun findAllByClassroomIdAndWeekStart( classroomId: Long, @@ -25,5 +24,8 @@ interface WeeklyMysteryRepository : JpaRepository { ): Boolean /** Removes a specific featured entry so a teacher can un-feature a submission from a week. */ - fun deleteByIdAndClassroomId(id: Long, classroomId: Long): Long -} \ No newline at end of file + fun deleteByIdAndClassroomId( + id: Long, + classroomId: Long, + ): Long +} diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/shared/events/WeeklyMysteryApprovedEvent.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/shared/events/WeeklyMysteryApprovedEvent.kt index 98e56e4e..af5247dc 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/shared/events/WeeklyMysteryApprovedEvent.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/shared/events/WeeklyMysteryApprovedEvent.kt @@ -7,4 +7,4 @@ data class WeeklyMysteryApprovedEvent( val pupilUserId: Long, val classroomId: Long, override val occurredAt: Instant = Instant.now(), -) : DomainEvent \ No newline at end of file +) : DomainEvent diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/upload/FileUploadService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/upload/application/FileStorageService.kt similarity index 65% rename from src/main/kotlin/edu/ntnu/idi/idatt/backend/upload/FileUploadService.kt rename to src/main/kotlin/edu/ntnu/idi/idatt/backend/upload/application/FileStorageService.kt index 5cffd15e..7753311e 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/upload/FileUploadService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/upload/application/FileStorageService.kt @@ -23,16 +23,16 @@ import java.util.UUID @Service class FileStorageService( @Value("\${app.uploads.base-url}") private val baseUrl: String, - @Value("\${app.uploads.base-dir:\${user.home}/nettdetektivene/uploads}") private val baseDirPath: String, + @Value($$"${app.uploads.base-dir:${user.home}/nettdetektivene/uploads}") private val baseDirPath: String, ) { - private val baseStorageDir by lazy { Paths.get(baseDirPath).toAbsolutePath() } - private val allowedMediaTypes = mapOf( - MediaType.IMAGE_JPEG to "jpg", - MediaType.IMAGE_PNG to "png", - MediaType.IMAGE_GIF to "gif", - MediaType.parseMediaType("image/webp") to "webp", - ) + private val allowedMediaTypes = + mapOf( + MediaType.IMAGE_JPEG to "jpg", + MediaType.IMAGE_PNG to "png", + MediaType.IMAGE_GIF to "gif", + MediaType.parseMediaType("image/webp") to "webp", + ) /** * Validates and stores an uploaded file under [subdirectory]. @@ -42,15 +42,20 @@ class FileStorageService( * @return The absolute public URL the file can be accessed at. * @throws IllegalArgumentException if the file type is missing or not an allowed image type. */ - fun store(file: MultipartFile, subdirectory: String): String { - val contentType = file.contentType - ?.let { runCatching { MediaType.parseMediaType(it) }.getOrNull() } - ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Missing content type") + fun store( + file: MultipartFile, + subdirectory: String, + ): String { + val contentType = + file.contentType + ?.let { runCatching { MediaType.parseMediaType(it) }.getOrNull() } + ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Missing content type") - val extension = allowedMediaTypes.entries - .firstOrNull { (mediaType, _) -> mediaType.isCompatibleWith(contentType) } - ?.value - ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported file type: $contentType") + val extension = + allowedMediaTypes.entries + .firstOrNull { (mediaType, _) -> mediaType.isCompatibleWith(contentType) } + ?.value + ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported file type: $contentType") val targetDir = baseStorageDir.resolve(subdirectory).normalize() Files.createDirectories(targetDir) @@ -60,4 +65,4 @@ class FileStorageService( return "${baseUrl.trimEnd('/')}/uploads/$subdirectory/$filename" } -} \ No newline at end of file +} diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/mystery/MysteryControllerIntegrationTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/mystery/MysteryControllerIntegrationTests.kt index 2b2afc8c..9d2c61a6 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/mystery/MysteryControllerIntegrationTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/mystery/MysteryControllerIntegrationTests.kt @@ -10,7 +10,6 @@ import edu.ntnu.idi.idatt.backend.iam.domain.UserStatus import edu.ntnu.idi.idatt.backend.iam.infrastructure.RefreshTokenRepository import edu.ntnu.idi.idatt.backend.iam.infrastructure.UserRepository import edu.ntnu.idi.idatt.backend.mystery.domain.MysterySubmission -import edu.ntnu.idi.idatt.backend.mystery.domain.ReviewStatus import edu.ntnu.idi.idatt.backend.mystery.domain.WeeklyMystery import edu.ntnu.idi.idatt.backend.mystery.infrastructure.MysterySubmissionRepository import edu.ntnu.idi.idatt.backend.mystery.infrastructure.WeeklyMysteryRepository @@ -35,16 +34,24 @@ import java.time.LocalDate @SpringBootTest @AutoConfigureMockMvc class MysteryControllerIntegrationTests { - @Autowired private lateinit var mockMvc: MockMvc + @Autowired private lateinit var userRepository: UserRepository + @Autowired private lateinit var classroomRepository: ClassroomRepository + @Autowired private lateinit var submissionRepository: MysterySubmissionRepository + @Autowired private lateinit var weeklyMysteryRepository: WeeklyMysteryRepository + @Autowired private lateinit var refreshTokenRepository: RefreshTokenRepository + @Autowired private lateinit var userAvatarRepository: UserAvatarRepository + @Autowired private lateinit var pupilProfileRepository: PupilProfileRepository + @Autowired private lateinit var teacherProfileRepository: TeacherProfileRepository + @Autowired private lateinit var passwordEncoder: PasswordEncoder private val jsonMapper = JsonMapper.builder().build() @@ -66,15 +73,16 @@ class MysteryControllerIntegrationTests { teacher = userRepository.save(activeTeacher("teacher-mystery@test.com")) pupil = userRepository.save(activePupil("mystery-pupil")) - classroom = classroomRepository.save( - Classroom( - ownerTeacherId = requireNotNull(teacher.id), - title = "Mystery Class", - description = "desc", - joinCode = "MYST1", - status = ClassroomStatus.ACTIVE, + classroom = + classroomRepository.save( + Classroom( + ownerTeacherId = requireNotNull(teacher.id), + title = "Mystery Class", + description = "desc", + joinCode = "MYST1", + status = ClassroomStatus.ACTIVE, + ), ) - ) classroom.addTeacher(teacher) classroom.addPupilMembership(pupil) classroomRepository.save(classroom) @@ -82,11 +90,12 @@ class MysteryControllerIntegrationTests { @Test fun `GET weekly-current requires authentication`() { - mockMvc.get("/api/v1/classrooms/${classroom.id}/mystery/weekly/current") { - with(csrf()) - }.andExpect { - status { isUnauthorized() } - } + mockMvc + .get("/api/v1/classrooms/${classroom.id}/mystery/weekly/current") { + with(csrf()) + }.andExpect { + status { isUnauthorized() } + } } @Test @@ -98,28 +107,30 @@ class MysteryControllerIntegrationTests { submission = submission, weekStart = mondayOfCurrentWeek(), featuredBy = teacher, - ) + ), ) - mockMvc.get("/api/v1/classrooms/${classroom.id}/mystery/weekly/current") { - with(csrf()) - header(HttpHeaders.AUTHORIZATION, "Bearer ${pupilToken()}") - }.andExpect { - status { isOk() } - jsonPath("$.length()") { value(1) } - jsonPath("$[0].submission.reviewStatus") { value("APPROVED") } - } + mockMvc + .get("/api/v1/classrooms/${classroom.id}/mystery/weekly/current") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer ${pupilToken()}") + }.andExpect { + status { isOk() } + jsonPath("$.length()") { value(1) } + jsonPath("$[0].submission.reviewStatus") { value("APPROVED") } + } } @Test fun `GET weekly-current returns empty list when no mysteries featured this week`() { - mockMvc.get("/api/v1/classrooms/${classroom.id}/mystery/weekly/current") { - with(csrf()) - header(HttpHeaders.AUTHORIZATION, "Bearer ${pupilToken()}") - }.andExpect { - status { isOk() } - jsonPath("$.length()") { value(0) } - } + mockMvc + .get("/api/v1/classrooms/${classroom.id}/mystery/weekly/current") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer ${pupilToken()}") + }.andExpect { + status { isOk() } + jsonPath("$.length()") { value(0) } + } } @Test @@ -127,84 +138,90 @@ class MysteryControllerIntegrationTests { val submission1 = approvedSubmission() val submission2 = approvedSubmission() weeklyMysteryRepository.save( - WeeklyMystery(classroom = classroom, submission = submission1, weekStart = mondayOfCurrentWeek(), featuredBy = teacher) + WeeklyMystery(classroom = classroom, submission = submission1, weekStart = mondayOfCurrentWeek(), featuredBy = teacher), ) weeklyMysteryRepository.save( - WeeklyMystery(classroom = classroom, submission = submission2, weekStart = mondayOfCurrentWeek().minusWeeks(1), featuredBy = teacher) + WeeklyMystery(classroom = classroom, submission = submission2, weekStart = mondayOfCurrentWeek().minusWeeks(1), featuredBy = teacher), ) - mockMvc.get("/api/v1/classrooms/${classroom.id}/mystery/weekly") { - with(csrf()) - header(HttpHeaders.AUTHORIZATION, "Bearer ${pupilToken()}") - }.andExpect { - status { isOk() } - jsonPath("$.length()") { value(2) } - } + mockMvc + .get("/api/v1/classrooms/${classroom.id}/mystery/weekly") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer ${pupilToken()}") + }.andExpect { + status { isOk() } + jsonPath("$.length()") { value(2) } + } } @Test fun `POST submissions creates a pending submission`() { - mockMvc.post("/api/v1/classrooms/${classroom.id}/mystery/submissions") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer ${pupilToken()}") - content = """{"description":"I found a fake news article","attachmentUrl":null}""" - }.andExpect { - status { isCreated() } - jsonPath("$.reviewStatus") { value("PENDING") } - jsonPath("$.description") { value("I found a fake news article") } - jsonPath("$.pupilUserId") { value(pupil.id!!.toInt()) } - } + mockMvc + .post("/api/v1/classrooms/${classroom.id}/mystery/submissions") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer ${pupilToken()}") + content = """{"description":"I found a fake news article","attachmentUrl":null}""" + }.andExpect { + status { isCreated() } + jsonPath("$.reviewStatus") { value("PENDING") } + jsonPath("$.description") { value("I found a fake news article") } + jsonPath("$.pupilUserId") { value(pupil.id!!.toInt()) } + } assertEquals(1, submissionRepository.findAllByClassroomId(classroom.id).size) } @Test fun `POST submissions requires authentication`() { - mockMvc.post("/api/v1/classrooms/${classroom.id}/mystery/submissions") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"description":"test","attachmentUrl":null}""" - }.andExpect { - status { isUnauthorized() } - } + mockMvc + .post("/api/v1/classrooms/${classroom.id}/mystery/submissions") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"description":"test","attachmentUrl":null}""" + }.andExpect { + status { isUnauthorized() } + } } @Test fun `POST submissions rejects blank description`() { - mockMvc.post("/api/v1/classrooms/${classroom.id}/mystery/submissions") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer ${pupilToken()}") - content = """{"description":"","attachmentUrl":null}""" - }.andExpect { - status { isBadRequest() } - } + mockMvc + .post("/api/v1/classrooms/${classroom.id}/mystery/submissions") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer ${pupilToken()}") + content = """{"description":"","attachmentUrl":null}""" + }.andExpect { + status { isBadRequest() } + } } @Test fun `POST submissions rejects description exceeding 2000 characters`() { val tooLong = "a".repeat(2001) - mockMvc.post("/api/v1/classrooms/${classroom.id}/mystery/submissions") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer ${pupilToken()}") - content = """{"description":"$tooLong","attachmentUrl":null}""" - }.andExpect { - status { isBadRequest() } - } + mockMvc + .post("/api/v1/classrooms/${classroom.id}/mystery/submissions") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer ${pupilToken()}") + content = """{"description":"$tooLong","attachmentUrl":null}""" + }.andExpect { + status { isBadRequest() } + } } @Test fun `POST submissions is forbidden for teachers`() { - mockMvc.post("/api/v1/classrooms/${classroom.id}/mystery/submissions") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") - content = """{"description":"test","attachmentUrl":null}""" - }.andExpect { - status { isForbidden() } - } + mockMvc + .post("/api/v1/classrooms/${classroom.id}/mystery/submissions") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") + content = """{"description":"test","attachmentUrl":null}""" + }.andExpect { + status { isForbidden() } + } } @Test @@ -215,32 +232,34 @@ class MysteryControllerIntegrationTests { // own submission submissionRepository.save( - MysterySubmission(classroom = classroom, pupil = pupil, description = "mine") + MysterySubmission(classroom = classroom, pupil = pupil, description = "mine"), ) // other pupil's submission — should NOT appear submissionRepository.save( - MysterySubmission(classroom = classroom, pupil = otherPupil, description = "not mine") + MysterySubmission(classroom = classroom, pupil = otherPupil, description = "not mine"), ) - mockMvc.get("/api/v1/classrooms/${classroom.id}/mystery/submissions/my") { - with(csrf()) - header(HttpHeaders.AUTHORIZATION, "Bearer ${pupilToken()}") - }.andExpect { - status { isOk() } - jsonPath("$.length()") { value(1) } - jsonPath("$[0].description") { value("mine") } - } + mockMvc + .get("/api/v1/classrooms/${classroom.id}/mystery/submissions/my") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer ${pupilToken()}") + }.andExpect { + status { isOk() } + jsonPath("$.length()") { value(1) } + jsonPath("$[0].description") { value("mine") } + } } @Test fun `GET submissions-my returns empty list when pupil has no submissions`() { - mockMvc.get("/api/v1/classrooms/${classroom.id}/mystery/submissions/my") { - with(csrf()) - header(HttpHeaders.AUTHORIZATION, "Bearer ${pupilToken()}") - }.andExpect { - status { isOk() } - jsonPath("$.length()") { value(0) } - } + mockMvc + .get("/api/v1/classrooms/${classroom.id}/mystery/submissions/my") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer ${pupilToken()}") + }.andExpect { + status { isOk() } + jsonPath("$.length()") { value(0) } + } } private fun approvedSubmission(): MysterySubmission { @@ -250,29 +269,40 @@ class MysteryControllerIntegrationTests { } private fun mondayOfCurrentWeek(): LocalDate = - LocalDate.now().with(java.time.temporal.TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY)) + LocalDate.now().with( + java.time.temporal.TemporalAdjusters + .previousOrSame(java.time.DayOfWeek.MONDAY), + ) private fun pupilToken(): String = - jsonMapper.readTree( - mockMvc.post("/api/v1/auth/pupil/login") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"username":"mystery-pupil","password":"secret123"}""" - }.andExpect { - status { isOk() } - }.andReturn().response.contentAsString - ).get("accessToken").asText() + jsonMapper + .readTree( + mockMvc + .post("/api/v1/auth/pupil/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"username":"mystery-pupil","password":"secret123"}""" + }.andExpect { + status { isOk() } + }.andReturn() + .response.contentAsString, + ).get("accessToken") + .asText() private fun teacherToken(): String = - jsonMapper.readTree( - mockMvc.post("/api/v1/auth/teacher/login") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"email":"teacher-mystery@test.com","password":"secret123"}""" - }.andExpect { - status { isOk() } - }.andReturn().response.contentAsString - ).get("accessToken").asText() + jsonMapper + .readTree( + mockMvc + .post("/api/v1/auth/teacher/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"email":"teacher-mystery@test.com","password":"secret123"}""" + }.andExpect { + status { isOk() } + }.andReturn() + .response.contentAsString, + ).get("accessToken") + .asText() private fun activePupil(username: String): User = User( @@ -289,4 +319,4 @@ class MysteryControllerIntegrationTests { role = UserRole.TEACHER, status = UserStatus.ACTIVE, ) -} \ No newline at end of file +} diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/mystery/MysteryServicetests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/mystery/MysteryServiceTests.kt similarity index 76% rename from src/test/kotlin/edu/ntnu/idi/idatt/backend/mystery/MysteryServicetests.kt rename to src/test/kotlin/edu/ntnu/idi/idatt/backend/mystery/MysteryServiceTests.kt index dbc2df63..6c1c85cb 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/mystery/MysteryServicetests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/mystery/MysteryServiceTests.kt @@ -38,37 +38,39 @@ import java.time.LocalDate import java.util.Optional class MysteryServiceTests { - private lateinit var submissionRepository: MysterySubmissionRepository private lateinit var weeklyMysteryRepository: WeeklyMysteryRepository private lateinit var userRepository: UserRepository private lateinit var domainEventPublisher: DomainEventPublisher private lateinit var mysteryService: MysteryService - private val classroom = Classroom( - id = 1L, - ownerTeacherId = 10L, - title = "Nettdetektivene", - description = "desc", - joinCode = "ABCD1", - status = ClassroomStatus.ACTIVE, - ) - - private val teacher = User( - id = 10L, - email = "teacher@test.com", - passwordHash = "hash", - role = UserRole.TEACHER, - status = UserStatus.ACTIVE, - ) - - private val pupil = User( - id = 20L, - username = "ada", - passwordHash = "hash", - role = UserRole.PUPIL, - status = UserStatus.ACTIVE, - ) + private val classroom = + Classroom( + id = 1L, + ownerTeacherId = 10L, + title = "Nettdetektivene", + description = "desc", + joinCode = "ABCD1", + status = ClassroomStatus.ACTIVE, + ) + + private val teacher = + User( + id = 10L, + email = "teacher@test.com", + passwordHash = "hash", + role = UserRole.TEACHER, + status = UserStatus.ACTIVE, + ) + + private val pupil = + User( + id = 20L, + username = "ada", + passwordHash = "hash", + role = UserRole.PUPIL, + status = UserStatus.ACTIVE, + ) @BeforeEach fun setUp() { @@ -76,22 +78,24 @@ class MysteryServiceTests { weeklyMysteryRepository = mock(WeeklyMysteryRepository::class.java) userRepository = mock(UserRepository::class.java) domainEventPublisher = mock(DomainEventPublisher::class.java) - mysteryService = MysteryService( - submissionRepository, - weeklyMysteryRepository, - userRepository, - domainEventPublisher, - ) + mysteryService = + MysteryService( + submissionRepository, + weeklyMysteryRepository, + userRepository, + domainEventPublisher, + ) } @Test fun `submitExample saves submission and publishes WeeklyMysterySubmittedEvent`() { - val savedSubmission = MysterySubmission( - id = 99L, - classroom = classroom, - pupil = pupil, - description = "Fake news example", - ) + val savedSubmission = + MysterySubmission( + id = 99L, + classroom = classroom, + pupil = pupil, + description = "Fake news example", + ) `when`(userRepository.findById(pupil.id!!)).thenReturn(Optional.of(pupil)) `when`(submissionRepository.save(any(MysterySubmission::class.java))).thenReturn(savedSubmission) @@ -99,15 +103,16 @@ class MysteryServiceTests { `when`(domainEventPublisher.publish(any(DomainEvent::class.java))) .thenAnswer { capturedEvent = it.arguments[0] as DomainEvent } - val result = mysteryService.submitExample( - SubmitMysteryCommand( - classroomId = classroom.id, - classroom = classroom, - pupilUserId = pupil.id!!, - description = "Fake news example", - attachmentUrl = null, + val result = + mysteryService.submitExample( + SubmitMysteryCommand( + classroomId = classroom.id, + classroom = classroom, + pupilUserId = pupil.id!!, + description = "Fake news example", + attachmentUrl = null, + ), ) - ) assertEquals(99L, result.id) assertEquals(ReviewStatus.PENDING, result.reviewStatus) @@ -123,17 +128,18 @@ class MysteryServiceTests { fun `submitExample throws 404 when pupil not found`() { `when`(userRepository.findById(pupil.id!!)).thenReturn(Optional.empty()) - val ex = assertThrows { - mysteryService.submitExample( - SubmitMysteryCommand( - classroomId = classroom.id, - classroom = classroom, - pupilUserId = pupil.id!!, - description = "desc", - attachmentUrl = null, + val ex = + assertThrows { + mysteryService.submitExample( + SubmitMysteryCommand( + classroomId = classroom.id, + classroom = classroom, + pupilUserId = pupil.id!!, + description = "desc", + attachmentUrl = null, + ), ) - ) - } + } assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) verify(submissionRepository, never()).save(any(MysterySubmission::class.java)) @@ -142,10 +148,11 @@ class MysteryServiceTests { @Test fun `getMySubmissions delegates to repository`() { - val submissions = listOf( - MysterySubmission(id = 1L, classroom = classroom, pupil = pupil, description = "a"), - MysterySubmission(id = 2L, classroom = classroom, pupil = pupil, description = "b"), - ) + val submissions = + listOf( + MysterySubmission(id = 1L, classroom = classroom, pupil = pupil, description = "a"), + MysterySubmission(id = 2L, classroom = classroom, pupil = pupil, description = "b"), + ) `when`(submissionRepository.findAllByClassroomIdAndPupilId(classroom.id, pupil.id!!)) .thenReturn(submissions) @@ -185,9 +192,10 @@ class MysteryServiceTests { @Test fun `getAllSubmissions returns all classroom submissions`() { - val submissions = listOf( - MysterySubmission(id = 1L, classroom = classroom, pupil = pupil, description = "a"), - ) + val submissions = + listOf( + MysterySubmission(id = 1L, classroom = classroom, pupil = pupil, description = "a"), + ) `when`(submissionRepository.findAllByClassroomId(classroom.id)).thenReturn(submissions) val result = mysteryService.getAllSubmissions(classroom.id) @@ -216,14 +224,15 @@ class MysteryServiceTests { `when`(domainEventPublisher.publish(any(DomainEvent::class.java))) .thenAnswer { capturedEvent = it.arguments[0] as DomainEvent } - val result = mysteryService.approveSubmission( - ReviewSubmissionCommand( - submissionId = 5L, - classroomId = classroom.id, - teacherUserId = teacher.id!!, - comment = "Good find!", + val result = + mysteryService.approveSubmission( + ReviewSubmissionCommand( + submissionId = 5L, + classroomId = classroom.id, + teacherUserId = teacher.id!!, + comment = "Good find!", + ), ) - ) assertEquals(ReviewStatus.APPROVED, result.reviewStatus) assertEquals(teacher, result.reviewedBy) @@ -240,16 +249,17 @@ class MysteryServiceTests { fun `approveSubmission throws 404 when submission not found in classroom`() { `when`(submissionRepository.findByIdAndClassroomId(99L, classroom.id)).thenReturn(null) - val ex = assertThrows { - mysteryService.approveSubmission( - ReviewSubmissionCommand( - submissionId = 99L, - classroomId = classroom.id, - teacherUserId = teacher.id!!, - comment = null, + val ex = + assertThrows { + mysteryService.approveSubmission( + ReviewSubmissionCommand( + submissionId = 99L, + classroomId = classroom.id, + teacherUserId = teacher.id!!, + comment = null, + ), ) - ) - } + } assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) verify(domainEventPublisher, never()).publish(any(DomainEvent::class.java)) @@ -261,16 +271,17 @@ class MysteryServiceTests { `when`(submissionRepository.findByIdAndClassroomId(5L, classroom.id)).thenReturn(submission) `when`(userRepository.findById(teacher.id!!)).thenReturn(Optional.empty()) - val ex = assertThrows { - mysteryService.approveSubmission( - ReviewSubmissionCommand( - submissionId = 5L, - classroomId = classroom.id, - teacherUserId = teacher.id!!, - comment = null, + val ex = + assertThrows { + mysteryService.approveSubmission( + ReviewSubmissionCommand( + submissionId = 5L, + classroomId = classroom.id, + teacherUserId = teacher.id!!, + comment = null, + ), ) - ) - } + } assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) verify(domainEventPublisher, never()).publish(any(DomainEvent::class.java)) @@ -290,7 +301,7 @@ class MysteryServiceTests { classroomId = classroom.id, teacherUserId = teacher.id!!, comment = null, - ) + ), ) } } @@ -302,14 +313,15 @@ class MysteryServiceTests { `when`(userRepository.findById(teacher.id!!)).thenReturn(Optional.of(teacher)) `when`(submissionRepository.save(any(MysterySubmission::class.java))).thenReturn(submission) - val result = mysteryService.rejectSubmission( - ReviewSubmissionCommand( - submissionId = 6L, - classroomId = classroom.id, - teacherUserId = teacher.id!!, - comment = "Not a good example.", + val result = + mysteryService.rejectSubmission( + ReviewSubmissionCommand( + submissionId = 6L, + classroomId = classroom.id, + teacherUserId = teacher.id!!, + comment = "Not a good example.", + ), ) - ) assertEquals(ReviewStatus.REJECTED, result.reviewStatus) assertEquals("Not a good example.", result.teacherComment) @@ -319,16 +331,17 @@ class MysteryServiceTests { fun `rejectSubmission throws 404 when submission not found`() { `when`(submissionRepository.findByIdAndClassroomId(99L, classroom.id)).thenReturn(null) - val ex = assertThrows { - mysteryService.rejectSubmission( - ReviewSubmissionCommand( - submissionId = 99L, - classroomId = classroom.id, - teacherUserId = teacher.id!!, - comment = null, + val ex = + assertThrows { + mysteryService.rejectSubmission( + ReviewSubmissionCommand( + submissionId = 99L, + classroomId = classroom.id, + teacherUserId = teacher.id!!, + comment = null, + ), ) - ) - } + } assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) } @@ -347,7 +360,7 @@ class MysteryServiceTests { classroomId = classroom.id, teacherUserId = teacher.id!!, comment = null, - ) + ), ) } } @@ -363,15 +376,16 @@ class MysteryServiceTests { `when`(userRepository.findById(teacher.id!!)).thenReturn(Optional.of(teacher)) `when`(weeklyMysteryRepository.save(any(WeeklyMystery::class.java))).thenAnswer { it.arguments[0] } - val result = mysteryService.featureSubmission( - FeatureMysteryCommand( - submissionId = 7L, - classroomId = classroom.id, - classroom = classroom, - teacherUserId = teacher.id!!, - weekStart = wednesday, + val result = + mysteryService.featureSubmission( + FeatureMysteryCommand( + submissionId = 7L, + classroomId = classroom.id, + classroom = classroom, + teacherUserId = teacher.id!!, + weekStart = wednesday, + ), ) - ) assertEquals(expectedMonday, result.weekStart) assertEquals(teacher, result.featuredBy) @@ -385,17 +399,18 @@ class MysteryServiceTests { `when`(submissionRepository.findByIdAndClassroomId(8L, classroom.id)).thenReturn(submission) `when`(userRepository.findById(teacher.id!!)).thenReturn(Optional.of(teacher)) - val ex = assertThrows { - mysteryService.featureSubmission( - FeatureMysteryCommand( - submissionId = 8L, - classroomId = classroom.id, - classroom = classroom, - teacherUserId = teacher.id!!, - weekStart = LocalDate.now(), + val ex = + assertThrows { + mysteryService.featureSubmission( + FeatureMysteryCommand( + submissionId = 8L, + classroomId = classroom.id, + classroom = classroom, + teacherUserId = teacher.id!!, + weekStart = LocalDate.now(), + ), ) - ) - } + } assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) verify(weeklyMysteryRepository, never()).save(any(WeeklyMystery::class.java)) @@ -405,17 +420,18 @@ class MysteryServiceTests { fun `featureSubmission throws 404 when submission not found`() { `when`(submissionRepository.findByIdAndClassroomId(99L, classroom.id)).thenReturn(null) - val ex = assertThrows { - mysteryService.featureSubmission( - FeatureMysteryCommand( - submissionId = 99L, - classroomId = classroom.id, - classroom = classroom, - teacherUserId = teacher.id!!, - weekStart = LocalDate.now(), + val ex = + assertThrows { + mysteryService.featureSubmission( + FeatureMysteryCommand( + submissionId = 99L, + classroomId = classroom.id, + classroom = classroom, + teacherUserId = teacher.id!!, + weekStart = LocalDate.now(), + ), ) - ) - } + } assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) } @@ -433,9 +449,10 @@ class MysteryServiceTests { fun `unfeatureSubmission throws 404 when entry does not exist`() { `when`(weeklyMysteryRepository.deleteByIdAndClassroomId(99L, classroom.id)).thenReturn(0L) - val ex = assertThrows { - mysteryService.unfeatureSubmission(UnfeatureMysteryCommand(weeklyMysteryId = 99L, classroomId = classroom.id)) - } + val ex = + assertThrows { + mysteryService.unfeatureSubmission(UnfeatureMysteryCommand(weeklyMysteryId = 99L, classroomId = classroom.id)) + } assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) } @@ -466,4 +483,4 @@ class MysteryServiceTests { * causes an NPE before Mockito can intercept the call. */ private fun any(type: Class): T = org.mockito.Mockito.any(type) -} \ No newline at end of file +} diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/mystery/TeacherMysteryControllerIntegrationTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/mystery/TeacherMysteryControllerIntegrationTests.kt index 0dd151b6..b78f7813 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/mystery/TeacherMysteryControllerIntegrationTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/mystery/TeacherMysteryControllerIntegrationTests.kt @@ -39,16 +39,24 @@ import java.time.temporal.TemporalAdjusters @SpringBootTest @AutoConfigureMockMvc class TeacherMysteryControllerIntegrationTests { - @Autowired private lateinit var mockMvc: MockMvc + @Autowired private lateinit var userRepository: UserRepository + @Autowired private lateinit var classroomRepository: ClassroomRepository + @Autowired private lateinit var submissionRepository: MysterySubmissionRepository + @Autowired private lateinit var weeklyMysteryRepository: WeeklyMysteryRepository + @Autowired private lateinit var refreshTokenRepository: RefreshTokenRepository + @Autowired private lateinit var userAvatarRepository: UserAvatarRepository + @Autowired private lateinit var pupilProfileRepository: PupilProfileRepository + @Autowired private lateinit var teacherProfileRepository: TeacherProfileRepository + @Autowired private lateinit var passwordEncoder: PasswordEncoder private val jsonMapper = JsonMapper.builder().build() @@ -70,15 +78,16 @@ class TeacherMysteryControllerIntegrationTests { teacher = userRepository.save(activeTeacher("teacher-mgr@test.com")) pupil = userRepository.save(activePupil("mystery-kid")) - classroom = classroomRepository.save( - Classroom( - ownerTeacherId = requireNotNull(teacher.id), - title = "Teacher Mystery Class", - description = "desc", - joinCode = "TMYST", - status = ClassroomStatus.ACTIVE, + classroom = + classroomRepository.save( + Classroom( + ownerTeacherId = requireNotNull(teacher.id), + title = "Teacher Mystery Class", + description = "desc", + joinCode = "TMYST", + status = ClassroomStatus.ACTIVE, + ), ) - ) classroom.addTeacher(teacher) classroom.addPupilMembership(pupil) classroomRepository.save(classroom) @@ -89,69 +98,76 @@ class TeacherMysteryControllerIntegrationTests { submissionRepository.save(MysterySubmission(classroom = classroom, pupil = pupil, description = "a")) submissionRepository.save(MysterySubmission(classroom = classroom, pupil = pupil, description = "b")) - mockMvc.get("/api/v1/teacher/classrooms/${classroom.id}/mystery/submissions") { - with(csrf()) - header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") - }.andExpect { - status { isOk() } - jsonPath("$.length()") { value(2) } - } + mockMvc + .get("/api/v1/teacher/classrooms/${classroom.id}/mystery/submissions") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") + }.andExpect { + status { isOk() } + jsonPath("$.length()") { value(2) } + } } @Test fun `GET submissions requires authentication`() { - mockMvc.get("/api/v1/teacher/classrooms/${classroom.id}/mystery/submissions") { - with(csrf()) - }.andExpect { - status { isUnauthorized() } - } + mockMvc + .get("/api/v1/teacher/classrooms/${classroom.id}/mystery/submissions") { + with(csrf()) + }.andExpect { + status { isUnauthorized() } + } } @Test fun `GET submissions is forbidden for pupils`() { - mockMvc.get("/api/v1/teacher/classrooms/${classroom.id}/mystery/submissions") { - with(csrf()) - header(HttpHeaders.AUTHORIZATION, "Bearer ${pupilToken()}") - }.andExpect { - status { isForbidden() } - } + mockMvc + .get("/api/v1/teacher/classrooms/${classroom.id}/mystery/submissions") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer ${pupilToken()}") + }.andExpect { + status { isForbidden() } + } } @Test fun `GET submissions-pending returns only pending submissions`() { - val pending = submissionRepository.save( - MysterySubmission(classroom = classroom, pupil = pupil, description = "pending one") - ) + val pending = + submissionRepository.save( + MysterySubmission(classroom = classroom, pupil = pupil, description = "pending one"), + ) val approved = MysterySubmission(classroom = classroom, pupil = pupil, description = "approved one") approved.approve(teacher, null) submissionRepository.save(approved) - mockMvc.get("/api/v1/teacher/classrooms/${classroom.id}/mystery/submissions/pending") { - with(csrf()) - header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") - }.andExpect { - status { isOk() } - jsonPath("$.length()") { value(1) } - jsonPath("$[0].reviewStatus") { value("PENDING") } - } + mockMvc + .get("/api/v1/teacher/classrooms/${classroom.id}/mystery/submissions/pending") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") + }.andExpect { + status { isOk() } + jsonPath("$.length()") { value(1) } + jsonPath("$[0].reviewStatus") { value("PENDING") } + } } @Test fun `PATCH approve transitions submission to APPROVED`() { - val submission = submissionRepository.save( - MysterySubmission(classroom = classroom, pupil = pupil, description = "great example") - ) + val submission = + submissionRepository.save( + MysterySubmission(classroom = classroom, pupil = pupil, description = "great example"), + ) - mockMvc.patch("/api/v1/teacher/classrooms/${classroom.id}/mystery/submissions/${submission.id}/approve") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") - content = """{"comment":"Well spotted!"}""" - }.andExpect { - status { isOk() } - jsonPath("$.reviewStatus") { value("APPROVED") } - jsonPath("$.teacherComment") { value("Well spotted!") } - } + mockMvc + .patch("/api/v1/teacher/classrooms/${classroom.id}/mystery/submissions/${submission.id}/approve") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") + content = """{"comment":"Well spotted!"}""" + }.andExpect { + status { isOk() } + jsonPath("$.reviewStatus") { value("APPROVED") } + jsonPath("$.teacherComment") { value("Well spotted!") } + } val persisted = submissionRepository.findById(requireNotNull(submission.id)).orElseThrow() assertEquals(ReviewStatus.APPROVED, persisted.reviewStatus) @@ -159,30 +175,33 @@ class TeacherMysteryControllerIntegrationTests { @Test fun `PATCH approve returns 404 for a submission in another classroom`() { - val otherClassroom = classroomRepository.save( - Classroom( - ownerTeacherId = requireNotNull(teacher.id), - title = "Other", - description = "d", - joinCode = "OTHER", - status = ClassroomStatus.ACTIVE, + val otherClassroom = + classroomRepository.save( + Classroom( + ownerTeacherId = requireNotNull(teacher.id), + title = "Other", + description = "d", + joinCode = "OTHER", + status = ClassroomStatus.ACTIVE, + ), ) - ) otherClassroom.addTeacher(teacher) classroomRepository.save(otherClassroom) - val submission = submissionRepository.save( - MysterySubmission(classroom = otherClassroom, pupil = pupil, description = "belongs elsewhere") - ) + val submission = + submissionRepository.save( + MysterySubmission(classroom = otherClassroom, pupil = pupil, description = "belongs elsewhere"), + ) - mockMvc.patch("/api/v1/teacher/classrooms/${classroom.id}/mystery/submissions/${submission.id}/approve") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") - content = """{"comment":null}""" - }.andExpect { - status { isNotFound() } - } + mockMvc + .patch("/api/v1/teacher/classrooms/${classroom.id}/mystery/submissions/${submission.id}/approve") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") + content = """{"comment":null}""" + }.andExpect { + status { isNotFound() } + } } @Test @@ -194,39 +213,43 @@ class TeacherMysteryControllerIntegrationTests { // MysterySubmission.approve throws IllegalStateException for an already-reviewed submission. // MockMvc re-throws it as ServletException at the dispatcher level. // If the project adds a @ExceptionHandler mapping this to 409, switch to andExpect { status { isConflict() } }. - val ex = org.junit.jupiter.api.Assertions.assertThrows( - jakarta.servlet.ServletException::class.java - ) { - mockMvc.patch("/api/v1/teacher/classrooms/${classroom.id}/mystery/submissions/${saved.id}/approve") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") - content = """{"comment":null}""" - }.andReturn() - } + val ex = + org.junit.jupiter.api.Assertions.assertThrows( + jakarta.servlet.ServletException::class.java, + ) { + mockMvc + .patch("/api/v1/teacher/classrooms/${classroom.id}/mystery/submissions/${saved.id}/approve") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") + content = """{"comment":null}""" + }.andReturn() + } val cause = generateSequence(ex.cause) { it.cause } org.junit.jupiter.api.Assertions.assertTrue( cause.any { it is IllegalStateException }, - "Expected root cause to be IllegalStateException" + "Expected root cause to be IllegalStateException", ) } @Test fun `PATCH reject transitions submission to REJECTED`() { - val submission = submissionRepository.save( - MysterySubmission(classroom = classroom, pupil = pupil, description = "bad example") - ) + val submission = + submissionRepository.save( + MysterySubmission(classroom = classroom, pupil = pupil, description = "bad example"), + ) - mockMvc.patch("/api/v1/teacher/classrooms/${classroom.id}/mystery/submissions/${submission.id}/reject") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") - content = """{"comment":"This is just an ad."}""" - }.andExpect { - status { isOk() } - jsonPath("$.reviewStatus") { value("REJECTED") } - jsonPath("$.teacherComment") { value("This is just an ad.") } - } + mockMvc + .patch("/api/v1/teacher/classrooms/${classroom.id}/mystery/submissions/${submission.id}/reject") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") + content = """{"comment":"This is just an ad."}""" + }.andExpect { + status { isOk() } + jsonPath("$.reviewStatus") { value("REJECTED") } + jsonPath("$.teacherComment") { value("This is just an ad.") } + } val persisted = submissionRepository.findById(requireNotNull(submission.id)).orElseThrow() assertEquals(ReviewStatus.REJECTED, persisted.reviewStatus) @@ -234,31 +257,34 @@ class TeacherMysteryControllerIntegrationTests { @Test fun `PATCH reject returns 404 when submission not found`() { - mockMvc.patch("/api/v1/teacher/classrooms/${classroom.id}/mystery/submissions/99999/reject") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") - content = """{"comment":null}""" - }.andExpect { - status { isNotFound() } - } + mockMvc + .patch("/api/v1/teacher/classrooms/${classroom.id}/mystery/submissions/99999/reject") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") + content = """{"comment":null}""" + }.andExpect { + status { isNotFound() } + } } @Test fun `PATCH reject comment may be null`() { - val submission = submissionRepository.save( - MysterySubmission(classroom = classroom, pupil = pupil, description = "weak example") - ) + val submission = + submissionRepository.save( + MysterySubmission(classroom = classroom, pupil = pupil, description = "weak example"), + ) - mockMvc.patch("/api/v1/teacher/classrooms/${classroom.id}/mystery/submissions/${submission.id}/reject") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") - content = """{"comment":null}""" - }.andExpect { - status { isOk() } - jsonPath("$.reviewStatus") { value("REJECTED") } - } + mockMvc + .patch("/api/v1/teacher/classrooms/${classroom.id}/mystery/submissions/${submission.id}/reject") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") + content = """{"comment":null}""" + }.andExpect { + status { isOk() } + jsonPath("$.reviewStatus") { value("REJECTED") } + } } @Test @@ -266,34 +292,37 @@ class TeacherMysteryControllerIntegrationTests { val submission = approvedSubmission() val thisMonday = mondayOfCurrentWeek() - mockMvc.post("/api/v1/teacher/classrooms/${classroom.id}/mystery/weekly") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") - content = """{"submissionId":${submission.id},"weekStart":"$thisMonday"}""" - }.andExpect { - status { isCreated() } - jsonPath("$.weekStart") { value(thisMonday.toString()) } - jsonPath("$.submission.id") { value(submission.id!!.toInt()) } - } + mockMvc + .post("/api/v1/teacher/classrooms/${classroom.id}/mystery/weekly") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") + content = """{"submissionId":${submission.id},"weekStart":"$thisMonday"}""" + }.andExpect { + status { isCreated() } + jsonPath("$.weekStart") { value(thisMonday.toString()) } + jsonPath("$.submission.id") { value(submission.id!!.toInt()) } + } assertEquals(1, weeklyMysteryRepository.findAllByClassroomIdOrderByWeekStartDesc(classroom.id).size) } @Test fun `POST weekly returns 422 when submission is not approved`() { - val pending = submissionRepository.save( - MysterySubmission(classroom = classroom, pupil = pupil, description = "still pending") - ) + val pending = + submissionRepository.save( + MysterySubmission(classroom = classroom, pupil = pupil, description = "still pending"), + ) - mockMvc.post("/api/v1/teacher/classrooms/${classroom.id}/mystery/weekly") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") - content = """{"submissionId":${pending.id},"weekStart":"${mondayOfCurrentWeek()}"}""" - }.andExpect { - status { isUnprocessableEntity() } - } + mockMvc + .post("/api/v1/teacher/classrooms/${classroom.id}/mystery/weekly") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") + content = """{"submissionId":${pending.id},"weekStart":"${mondayOfCurrentWeek()}"}""" + }.andExpect { + status { isUnprocessableEntity() } + } } @Test @@ -302,69 +331,76 @@ class TeacherMysteryControllerIntegrationTests { val wednesday = mondayOfCurrentWeek().plusDays(2) val expectedMonday = mondayOfCurrentWeek() - mockMvc.post("/api/v1/teacher/classrooms/${classroom.id}/mystery/weekly") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") - content = """{"submissionId":${submission.id},"weekStart":"$wednesday"}""" - }.andExpect { - status { isCreated() } - jsonPath("$.weekStart") { value(expectedMonday.toString()) } - } + mockMvc + .post("/api/v1/teacher/classrooms/${classroom.id}/mystery/weekly") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") + content = """{"submissionId":${submission.id},"weekStart":"$wednesday"}""" + }.andExpect { + status { isCreated() } + jsonPath("$.weekStart") { value(expectedMonday.toString()) } + } } @Test fun `DELETE weekly removes the featured entry and returns 204`() { val submission = approvedSubmission() - val weekly = weeklyMysteryRepository.save( - WeeklyMystery(classroom = classroom, submission = submission, weekStart = mondayOfCurrentWeek(), featuredBy = teacher) - ) + val weekly = + weeklyMysteryRepository.save( + WeeklyMystery(classroom = classroom, submission = submission, weekStart = mondayOfCurrentWeek(), featuredBy = teacher), + ) - mockMvc.delete("/api/v1/teacher/classrooms/${classroom.id}/mystery/weekly/${weekly.id}") { - with(csrf()) - header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") - }.andExpect { - status { isNoContent() } - } + mockMvc + .delete("/api/v1/teacher/classrooms/${classroom.id}/mystery/weekly/${weekly.id}") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") + }.andExpect { + status { isNoContent() } + } assertEquals(0, weeklyMysteryRepository.findAllByClassroomIdOrderByWeekStartDesc(classroom.id).size) } @Test fun `DELETE weekly returns 404 for a non-existent entry`() { - mockMvc.delete("/api/v1/teacher/classrooms/${classroom.id}/mystery/weekly/99999") { - with(csrf()) - header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") - }.andExpect { - status { isNotFound() } - } + mockMvc + .delete("/api/v1/teacher/classrooms/${classroom.id}/mystery/weekly/99999") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") + }.andExpect { + status { isNotFound() } + } } @Test fun `DELETE weekly returns 404 when entry belongs to a different classroom`() { - val otherClassroom = classroomRepository.save( - Classroom( - ownerTeacherId = requireNotNull(teacher.id), - title = "Other", - description = "d", - joinCode = "OTH2", - status = ClassroomStatus.ACTIVE, + val otherClassroom = + classroomRepository.save( + Classroom( + ownerTeacherId = requireNotNull(teacher.id), + title = "Other", + description = "d", + joinCode = "OTH2", + status = ClassroomStatus.ACTIVE, + ), ) - ) otherClassroom.addTeacher(teacher) classroomRepository.save(otherClassroom) val submission = approvedSubmission() - val weekly = weeklyMysteryRepository.save( - WeeklyMystery(classroom = otherClassroom, submission = submission, weekStart = mondayOfCurrentWeek(), featuredBy = teacher) - ) + val weekly = + weeklyMysteryRepository.save( + WeeklyMystery(classroom = otherClassroom, submission = submission, weekStart = mondayOfCurrentWeek(), featuredBy = teacher), + ) - mockMvc.delete("/api/v1/teacher/classrooms/${classroom.id}/mystery/weekly/${weekly.id}") { - with(csrf()) - header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") - }.andExpect { - status { isNotFound() } - } + mockMvc + .delete("/api/v1/teacher/classrooms/${classroom.id}/mystery/weekly/${weekly.id}") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") + }.andExpect { + status { isNotFound() } + } } @Test @@ -374,13 +410,14 @@ class TeacherMysteryControllerIntegrationTests { weeklyMysteryRepository.save(WeeklyMystery(classroom = classroom, submission = sub1, weekStart = mondayOfCurrentWeek(), featuredBy = teacher)) weeklyMysteryRepository.save(WeeklyMystery(classroom = classroom, submission = sub2, weekStart = mondayOfCurrentWeek().minusWeeks(1), featuredBy = teacher)) - mockMvc.get("/api/v1/teacher/classrooms/${classroom.id}/mystery/weekly") { - with(csrf()) - header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") - }.andExpect { - status { isOk() } - jsonPath("$.length()") { value(2) } - } + mockMvc + .get("/api/v1/teacher/classrooms/${classroom.id}/mystery/weekly") { + with(csrf()) + header(HttpHeaders.AUTHORIZATION, "Bearer ${teacherToken()}") + }.andExpect { + status { isOk() } + jsonPath("$.length()") { value(2) } + } } private fun approvedSubmission(): MysterySubmission { @@ -389,30 +426,37 @@ class TeacherMysteryControllerIntegrationTests { return submissionRepository.save(sub) } - private fun mondayOfCurrentWeek(): LocalDate = - LocalDate.now().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + private fun mondayOfCurrentWeek(): LocalDate = LocalDate.now().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) private fun teacherToken(): String = - jsonMapper.readTree( - mockMvc.post("/api/v1/auth/teacher/login") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"email":"teacher-mgr@test.com","password":"secret123"}""" - }.andExpect { - status { isOk() } - }.andReturn().response.contentAsString - ).get("accessToken").asText() + jsonMapper + .readTree( + mockMvc + .post("/api/v1/auth/teacher/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"email":"teacher-mgr@test.com","password":"secret123"}""" + }.andExpect { + status { isOk() } + }.andReturn() + .response.contentAsString, + ).get("accessToken") + .asText() private fun pupilToken(): String = - jsonMapper.readTree( - mockMvc.post("/api/v1/auth/pupil/login") { - with(csrf()) - contentType = MediaType.APPLICATION_JSON - content = """{"username":"mystery-kid","password":"secret123"}""" - }.andExpect { - status { isOk() } - }.andReturn().response.contentAsString - ).get("accessToken").asText() + jsonMapper + .readTree( + mockMvc + .post("/api/v1/auth/pupil/login") { + with(csrf()) + contentType = MediaType.APPLICATION_JSON + content = """{"username":"mystery-kid","password":"secret123"}""" + }.andExpect { + status { isOk() } + }.andReturn() + .response.contentAsString, + ).get("accessToken") + .asText() private fun activePupil(username: String): User = User( @@ -429,4 +473,4 @@ class TeacherMysteryControllerIntegrationTests { role = UserRole.TEACHER, status = UserStatus.ACTIVE, ) -} \ No newline at end of file +} From 3850a7dc47d631701945aa5d1e5ed694a97fa9df Mon Sep 17 00:00:00 2001 From: Johannes Aamot-Skeidsvoll Date: Tue, 28 Apr 2026 10:37:36 +0200 Subject: [PATCH 07/10] Use actions/upload-artifact@v2 in CI --- .github/workflows/backend.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index c1f6d088..e0db5d73 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -66,7 +66,7 @@ jobs: - name: Upload backend coverage report if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v2 with: name: backend-jacoco-report path: build/reports/jacoco/test From 80629cf1104222fb08a50cc9a935c92c5cde55fc Mon Sep 17 00:00:00 2001 From: Johannes Aamot-Skeidsvoll Date: Tue, 28 Apr 2026 10:42:51 +0200 Subject: [PATCH 08/10] Update packages --- .../edu/ntnu/idi/idatt/backend/bootstrap/DevDataBootstrap.kt | 1 + .../bootstrap/tasks/DenSosialeMarkedsplassenTaskSeeds.kt | 2 +- .../idi/idatt/backend/bootstrap/tasks/FinalBossTaskSeeds.kt | 2 +- .../idi/idatt/backend/bootstrap/tasks/FotografenTaskSeeds.kt | 2 +- .../idatt/backend/bootstrap/tasks/MarkedsplassenTaskSeeds.kt | 2 +- .../idatt/backend/bootstrap/tasks/NyhetskvartaleTaskSeeds.kt | 2 +- .../idatt/backend/bootstrap/tasks/PassordbankenTaskSeeds.kt | 2 +- .../idi/idatt/backend/bootstrap/tasks/PostkontoretTaskSeeds.kt | 2 +- .../ntnu/idi/idatt/backend/bootstrap/tasks/TaskBootstrap.kt | 3 ++- .../ntnu/idi/idatt/backend/bootstrap/tasks/TaskSeedModels.kt | 2 +- 10 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/DevDataBootstrap.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/DevDataBootstrap.kt index 1f1c764c..37e4080a 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/DevDataBootstrap.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/DevDataBootstrap.kt @@ -1,5 +1,6 @@ package edu.ntnu.idi.idatt.backend.bootstrap +import edu.ntnu.idi.idatt.backend.bootstrap.tasks.TaskBootstrap import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom import edu.ntnu.idi.idatt.backend.classroom.domain.ClassroomPupilStatus import edu.ntnu.idi.idatt.backend.classroom.domain.ClassroomStatus diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/DenSosialeMarkedsplassenTaskSeeds.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/DenSosialeMarkedsplassenTaskSeeds.kt index c2bb7896..1feeb282 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/DenSosialeMarkedsplassenTaskSeeds.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/DenSosialeMarkedsplassenTaskSeeds.kt @@ -1,4 +1,4 @@ -package edu.ntnu.idi.idatt.backend.bootstrap +package edu.ntnu.idi.idatt.backend.bootstrap.tasks import edu.ntnu.idi.idatt.backend.game.tasks.domain.TaskType diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/FinalBossTaskSeeds.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/FinalBossTaskSeeds.kt index 3a5b2620..928f10cf 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/FinalBossTaskSeeds.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/FinalBossTaskSeeds.kt @@ -1,4 +1,4 @@ -package edu.ntnu.idi.idatt.backend.bootstrap +package edu.ntnu.idi.idatt.backend.bootstrap.tasks import edu.ntnu.idi.idatt.backend.game.tasks.domain.TaskType diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/FotografenTaskSeeds.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/FotografenTaskSeeds.kt index 47b4c062..862ebad2 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/FotografenTaskSeeds.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/FotografenTaskSeeds.kt @@ -1,4 +1,4 @@ -package edu.ntnu.idi.idatt.backend.bootstrap +package edu.ntnu.idi.idatt.backend.bootstrap.tasks import edu.ntnu.idi.idatt.backend.game.tasks.domain.TaskType diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/MarkedsplassenTaskSeeds.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/MarkedsplassenTaskSeeds.kt index 62c11227..5e8476e5 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/MarkedsplassenTaskSeeds.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/MarkedsplassenTaskSeeds.kt @@ -1,4 +1,4 @@ -package edu.ntnu.idi.idatt.backend.bootstrap +package edu.ntnu.idi.idatt.backend.bootstrap.tasks import edu.ntnu.idi.idatt.backend.game.tasks.domain.TaskType diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/NyhetskvartaleTaskSeeds.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/NyhetskvartaleTaskSeeds.kt index b5dae79c..a559c772 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/NyhetskvartaleTaskSeeds.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/NyhetskvartaleTaskSeeds.kt @@ -1,4 +1,4 @@ -package edu.ntnu.idi.idatt.backend.bootstrap +package edu.ntnu.idi.idatt.backend.bootstrap.tasks import edu.ntnu.idi.idatt.backend.game.tasks.domain.TaskType diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/PassordbankenTaskSeeds.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/PassordbankenTaskSeeds.kt index 609e20bb..861af101 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/PassordbankenTaskSeeds.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/PassordbankenTaskSeeds.kt @@ -1,4 +1,4 @@ -package edu.ntnu.idi.idatt.backend.bootstrap +package edu.ntnu.idi.idatt.backend.bootstrap.tasks import edu.ntnu.idi.idatt.backend.game.tasks.domain.TaskType diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/PostkontoretTaskSeeds.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/PostkontoretTaskSeeds.kt index 85f164f4..2decc0fe 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/PostkontoretTaskSeeds.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/PostkontoretTaskSeeds.kt @@ -1,4 +1,4 @@ -package edu.ntnu.idi.idatt.backend.bootstrap +package edu.ntnu.idi.idatt.backend.bootstrap.tasks import edu.ntnu.idi.idatt.backend.game.tasks.domain.TaskType diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/TaskBootstrap.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/TaskBootstrap.kt index ac2156e9..c2729082 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/TaskBootstrap.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/TaskBootstrap.kt @@ -1,4 +1,4 @@ -package edu.ntnu.idi.idatt.backend.bootstrap +package edu.ntnu.idi.idatt.backend.bootstrap.tasks import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom import edu.ntnu.idi.idatt.backend.game.mapstops.domain.MapStop @@ -17,6 +17,7 @@ import edu.ntnu.idi.idatt.backend.game.tasks.infrastructure.TaskRepository import edu.ntnu.idi.idatt.backend.iam.domain.User import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.stereotype.Component +import kotlin.collections.forEach @Component @ConditionalOnProperty(name = ["app.bootstrap.enabled"], havingValue = "true") diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/TaskSeedModels.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/TaskSeedModels.kt index 54e2063e..be9fb535 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/TaskSeedModels.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/bootstrap/tasks/TaskSeedModels.kt @@ -1,4 +1,4 @@ -package edu.ntnu.idi.idatt.backend.bootstrap +package edu.ntnu.idi.idatt.backend.bootstrap.tasks import edu.ntnu.idi.idatt.backend.game.tasks.domain.TaskPassingRule import edu.ntnu.idi.idatt.backend.game.tasks.domain.TaskType From 45df207acf0e9ed05bc24e4195ea0bcf6d15b385 Mon Sep 17 00:00:00 2001 From: Johannes Aamot-Skeidsvoll Date: Tue, 28 Apr 2026 10:45:32 +0200 Subject: [PATCH 09/10] Update upload artifact to v3.2.2 --- .github/workflows/backend.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index e0db5d73..c2dc271e 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -66,7 +66,7 @@ jobs: - name: Upload backend coverage report if: always() - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3.2.2 with: name: backend-jacoco-report path: build/reports/jacoco/test From 6e27b7cf77a37d4bf872bfcfd4a13f41050b4d46 Mon Sep 17 00:00:00 2001 From: Johannes Aamot-Skeidsvoll Date: Tue, 28 Apr 2026 10:47:26 +0200 Subject: [PATCH 10/10] Update upload artifact to v3 --- .github/workflows/backend.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index c2dc271e..35d76b97 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -66,7 +66,7 @@ jobs: - name: Upload backend coverage report if: always() - uses: actions/upload-artifact@v3.2.2 + uses: actions/upload-artifact@v3 with: name: backend-jacoco-report path: build/reports/jacoco/test