From 7bc2df20ca49bd572decfad92a9b8a381c38ada9 Mon Sep 17 00:00:00 2001 From: Johannes Aamot-Skeidsvoll Date: Wed, 29 Apr 2026 12:51:30 +0200 Subject: [PATCH 1/8] Add domain query services --- .../classroom/application/ClassroomDetail.kt | 8 +++ .../application/ClassroomQueryService.kt | 54 ++++++++++++++ .../tasks/application/TaskQueryService.kt | 25 +++++++ .../backend/iam/application/IamUserService.kt | 51 ++++++++++++++ .../UserAvatarProvisioningService.kt | 16 +++++ .../profile/application/UserProfileService.kt | 70 +++++++++++++++++++ 6 files changed, 224 insertions(+) create mode 100644 src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/ClassroomDetail.kt create mode 100644 src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/ClassroomQueryService.kt create mode 100644 src/main/kotlin/edu/ntnu/idi/idatt/backend/game/tasks/application/TaskQueryService.kt create mode 100644 src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/application/IamUserService.kt create mode 100644 src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UserAvatarProvisioningService.kt create mode 100644 src/main/kotlin/edu/ntnu/idi/idatt/backend/users/profile/application/UserProfileService.kt diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/ClassroomDetail.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/ClassroomDetail.kt new file mode 100644 index 00000000..73a3e7b3 --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/ClassroomDetail.kt @@ -0,0 +1,8 @@ +package edu.ntnu.idi.idatt.backend.classroom.application + +import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom + +data class ClassroomDetail( + val classroom: Classroom, + val pupilDisplayNames: Map, +) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/ClassroomQueryService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/ClassroomQueryService.kt new file mode 100644 index 00000000..5cbbba6b --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/ClassroomQueryService.kt @@ -0,0 +1,54 @@ +package edu.ntnu.idi.idatt.backend.classroom.application + +import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom +import edu.ntnu.idi.idatt.backend.classroom.infrastructure.ClassroomRepository +import org.springframework.stereotype.Service + +/** + * Read-oriented classroom operations exposed to other domains. + */ +@Service +class ClassroomQueryService( + private val classroomRepository: ClassroomRepository, +) { + fun findById(classroomId: Long): Classroom? = classroomRepository.findById(classroomId).orElse(null) + + fun findByJoinCode(joinCode: String): Classroom? = classroomRepository.findByJoinCode(joinCode) + + fun findFirstOwnedByTeacher(teacherUserId: Long): Classroom? = + classroomRepository.findFirstByOwnerTeacherIdOrderByIdAsc(teacherUserId) + + fun findOwnedByTeacher(teacherUserId: Long): List = + classroomRepository.findAllByOwnerTeacherIdOrderByTitleAscIdAsc(teacherUserId) + + fun findAll(): List = classroomRepository.findAll() + + fun findAllByUserId(userId: Long): List = classroomRepository.findAllByUserId(userId) + + fun existsById(classroomId: Long): Boolean = classroomRepository.existsById(classroomId) + + fun findTeacherRecipientIds(classroomId: Long): Set? = + findById(classroomId)?.let { classroom -> + buildSet { + add(classroom.ownerTeacherId) + classroom.teacherMemberships + .mapNotNull { membership -> membership.teacher?.id } + .forEach(::add) + } + } + + fun findAverageTotalScores(): List = + classroomRepository.findAllWithAverageTotalScore().map { score -> + ClassroomAverageScore( + classroomId = score.classroomId, + classroomTitle = score.classroomTitle, + averageTotalScore = score.averageTotalScore, + ) + } +} + +data class ClassroomAverageScore( + val classroomId: Long, + val classroomTitle: String, + val averageTotalScore: Double, +) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/tasks/application/TaskQueryService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/tasks/application/TaskQueryService.kt new file mode 100644 index 00000000..181a963d --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/tasks/application/TaskQueryService.kt @@ -0,0 +1,25 @@ +package edu.ntnu.idi.idatt.backend.game.tasks.application + +import edu.ntnu.idi.idatt.backend.game.tasks.infrastructure.TaskRepository +import org.springframework.stereotype.Service + +/** + * Read-oriented task operations exposed to other domains. + */ +@Service +class TaskQueryService( + private val taskRepository: TaskRepository, +) { + fun countTasksInClassroom(classroomId: Long?): Int = + classroomId + ?.let { taskRepository.countByClassroomId(it).toInt() } + ?: 0 + + fun findPublishedStopMaxScores(classroomId: Long): Map = + taskRepository + .findAllByClassroomIdOrderByStopIdAscDifficultyLevelAsc(classroomId) + .asSequence() + .filter { it.isPublished } + .groupBy { it.stopId } + .mapValues { (_, tasks) -> tasks.sumOf { it.maxScore } } +} diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/application/IamUserService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/application/IamUserService.kt new file mode 100644 index 00000000..1959a5af --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/application/IamUserService.kt @@ -0,0 +1,51 @@ +package edu.ntnu.idi.idatt.backend.iam.application + +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.springframework.stereotype.Service + +/** + * Application-facing user lookup and persistence operations owned by IAM. + */ +@Service +class IamUserService( + private val userRepository: UserRepository, +) { + fun findById(userId: Long): User? = userRepository.findById(userId).orElse(null) + + fun findAllById(userIds: Iterable): List = userRepository.findAllById(userIds).toList() + + fun findByEmail(email: String): User? = userRepository.findByEmailIgnoreCase(email) + + fun findByUsername(username: String): User? = userRepository.findByUsernameIgnoreCase(username) + + fun findTeachersByEmails(emails: List): List = userRepository.findAllByEmailIgnoreCaseIn(emails) + + fun createPupilUser( + username: String, + passwordHash: String, + ): User = + userRepository.saveAndFlush( + User( + username = username, + passwordHash = passwordHash, + role = UserRole.PUPIL, + status = UserStatus.ACTIVE, + ), + ) + + fun createTeacherUser( + email: String, + passwordHash: String, + ): User = + userRepository.save( + User( + email = email, + passwordHash = passwordHash, + role = UserRole.TEACHER, + status = UserStatus.ACTIVE, + ), + ) +} diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UserAvatarProvisioningService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UserAvatarProvisioningService.kt new file mode 100644 index 00000000..ba6daf09 --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UserAvatarProvisioningService.kt @@ -0,0 +1,16 @@ +package edu.ntnu.idi.idatt.backend.users.avatar.application + +import edu.ntnu.idi.idatt.backend.iam.domain.User +import edu.ntnu.idi.idatt.backend.users.avatar.domain.UserAvatar +import edu.ntnu.idi.idatt.backend.users.avatar.infrastructure.UserAvatarRepository +import org.springframework.stereotype.Service + +/** + * Owns avatar setup operations used when pupil accounts are provisioned. + */ +@Service +class UserAvatarProvisioningService( + private val userAvatarRepository: UserAvatarRepository, +) { + fun createDefaultAvatar(user: User): UserAvatar = userAvatarRepository.save(UserAvatar.defaultsFor(user)) +} diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/profile/application/UserProfileService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/profile/application/UserProfileService.kt new file mode 100644 index 00000000..374164c8 --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/profile/application/UserProfileService.kt @@ -0,0 +1,70 @@ +package edu.ntnu.idi.idatt.backend.users.profile.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.users.profile.domain.PupilProfile +import edu.ntnu.idi.idatt.backend.users.profile.domain.TeacherProfile +import edu.ntnu.idi.idatt.backend.users.profile.infrastructure.PupilProfileRepository +import edu.ntnu.idi.idatt.backend.users.profile.infrastructure.TeacherProfileRepository +import org.springframework.stereotype.Service + +/** + * Owns profile persistence operations needed by other application features. + */ +@Service +class UserProfileService( + private val pupilProfileRepository: PupilProfileRepository, + private val teacherProfileRepository: TeacherProfileRepository, +) { + fun createPupilProfile( + user: User, + displayName: String, + ): PupilProfile = + pupilProfileRepository.save( + PupilProfile( + user = user, + displayName = displayName, + ), + ) + + fun createTeacherProfile( + user: User, + teacherName: String, + schoolName: String, + ): TeacherProfile = + teacherProfileRepository.save( + TeacherProfile( + user = user, + teacherName = teacherName, + schoolName = schoolName, + ), + ) + + fun findPupilProfile(userId: Long): PupilProfile? = pupilProfileRepository.findById(userId).orElse(null) + + fun findTeacherProfile(userId: Long): TeacherProfile? = teacherProfileRepository.findById(userId).orElse(null) + + fun findPupilDisplayNames(userIds: Collection): Map = + if (userIds.isEmpty()) { + emptyMap() + } else { + pupilProfileRepository + .findAllByUserIdIn(userIds) + .associate { profile -> requireNotNull(profile.userId) to profile.displayName } + } + + fun updateCurrentClassroom( + pupilUserId: Long, + classroom: Classroom, + ): PupilProfile { + val profile = + pupilProfileRepository + .findById(pupilUserId) + .orElse(null) + ?: throw NoSuchElementException("Pupil profile was not found") + profile.currentClassroom = classroom + return pupilProfileRepository.save(profile) + } + + fun savePupilProfile(profile: PupilProfile): PupilProfile = pupilProfileRepository.save(profile) +} From 5b8d80b11509d70f948e67341702e9ee552f439b Mon Sep 17 00:00:00 2001 From: Johannes Aamot-Skeidsvoll Date: Wed, 29 Apr 2026 12:52:22 +0200 Subject: [PATCH 2/8] Route IAM through domain services --- .../iam/application/ClassroomUserResolver.kt | 27 ++---- .../iam/application/CurrentUserResolver.kt | 36 +++---- .../application/PupilProvisioningService.kt | 41 +++----- .../application/TeacherRegistrationService.kt | 35 ++----- .../application/CurrentUserProfileService.kt | 37 +++---- .../idi/idatt/backend/iam/AuthServiceTests.kt | 10 +- .../backend/iam/CurrentUserResolverTests.kt | 29 ++++-- .../iam/PupilProvisioningServiceTests.kt | 16 +-- .../iam/TeacherRegistrationServiceTests.kt | 15 +-- .../profile/CurrentUserProfileServiceTests.kt | 97 +++++-------------- 10 files changed, 128 insertions(+), 215 deletions(-) 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 e43cc441..5683411f 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 @@ -1,7 +1,7 @@ package edu.ntnu.idi.idatt.backend.iam.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.classroom.application.ClassroomQueryService import edu.ntnu.idi.idatt.backend.iam.domain.User import org.springframework.http.HttpStatus import org.springframework.stereotype.Service @@ -13,11 +13,11 @@ import org.springframework.web.server.ResponseStatusException * Complements [CurrentUserResolver] by adding classroom ownership and membership * guards on top of the base user resolution. * - * @property classroomRepository repository used to load classroom records + * @property classroomQueryService service used to load classroom records */ @Service class ClassroomUserResolver( - private val classroomRepository: ClassroomRepository, + private val classroomQueryService: ClassroomQueryService, ) { /** * Verifies that [user] is the owner of the given classroom. @@ -33,11 +33,8 @@ class ClassroomUserResolver( classroomId: Long, ): Classroom { val classroom = - classroomRepository - .findById(classroomId) - .orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") - } + classroomQueryService.findById(classroomId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") if (classroom.ownerTeacherId != user.id) { throw ResponseStatusException(HttpStatus.FORBIDDEN, "Only the classroom owner is allowed") } @@ -58,11 +55,8 @@ class ClassroomUserResolver( classroomId: Long, ): Classroom { val classroom = - classroomRepository - .findById(classroomId) - .orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") - } + classroomQueryService.findById(classroomId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") val userId = requireNotNull(user.id) @@ -93,11 +87,8 @@ class ClassroomUserResolver( classroomId: Long, ): Classroom { val classroom = - classroomRepository - .findById(classroomId) - .orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") - } + classroomQueryService.findById(classroomId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") val userId = requireNotNull(user.id) 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 1f32b567..3034456c 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 @@ -1,12 +1,11 @@ package edu.ntnu.idi.idatt.backend.iam.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.classroom.application.ClassroomQueryService 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.security.CurrentUser -import edu.ntnu.idi.idatt.backend.users.profile.infrastructure.PupilProfileRepository +import edu.ntnu.idi.idatt.backend.users.profile.application.UserProfileService import org.springframework.http.HttpStatus import org.springframework.stereotype.Service import org.springframework.web.server.ResponseStatusException @@ -14,13 +13,13 @@ import org.springframework.web.server.ResponseStatusException /** * Resolves the authenticated user from the JWT subject claim and enforces common role guards. * - * @property userRepository repository used to load the authenticated user + * @property iamUserService service used to load the authenticated user */ @Service class CurrentUserResolver( - private val userRepository: UserRepository, - private val pupilProfileRepository: PupilProfileRepository, - private val classroomRepository: ClassroomRepository, + private val iamUserService: IamUserService, + private val userProfileService: UserProfileService, + private val classroomQueryService: ClassroomQueryService, ) { /** * Resolves the authenticated user from the JWT subject claim. @@ -32,11 +31,8 @@ class CurrentUserResolver( 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") - } + return iamUserService.findById(userId) + ?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authenticated user was not found") } /** @@ -82,23 +78,17 @@ class CurrentUserResolver( return when (user.role) { UserRole.PUPIL -> { val pupilProfile = - pupilProfileRepository - .findById(userId) - .orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil profile was not found") - } + userProfileService.findPupilProfile(userId) + ?: throw 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") - } + classroomQueryService.findById(classroomId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom was not found") } UserRole.TEACHER -> - classroomRepository.findFirstByOwnerTeacherIdOrderByIdAsc(userId) + classroomQueryService.findFirstOwnedByTeacher(userId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Teacher does not own any classroom") UserRole.ADMIN -> throw ResponseStatusException( 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 8de35012..cfd19605 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 @@ -1,13 +1,8 @@ package edu.ntnu.idi.idatt.backend.iam.application 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.users.avatar.domain.UserAvatar -import edu.ntnu.idi.idatt.backend.users.avatar.infrastructure.UserAvatarRepository -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.users.avatar.application.UserAvatarProvisioningService +import edu.ntnu.idi.idatt.backend.users.profile.application.UserProfileService import org.springframework.dao.DataIntegrityViolationException import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.stereotype.Service @@ -18,14 +13,14 @@ import org.springframework.transaction.support.TransactionTemplate /** * Ensures a pupil account exists for a submitted login identifier before authentication continues. * - * @property userRepository repository used to look up and persist pupil users + * @property iamUserService service used to look up and persist pupil users * @property passwordEncoder password encoder used to hash the first submitted pupil password */ @Service class PupilProvisioningService( - private val userRepository: UserRepository, - private val pupilProfileRepository: PupilProfileRepository, - private val userAvatarRepository: UserAvatarRepository, + private val iamUserService: IamUserService, + private val userProfileService: UserProfileService, + private val userAvatarProvisioningService: UserAvatarProvisioningService, private val passwordEncoder: PasswordEncoder, transactionManager: PlatformTransactionManager, ) { @@ -46,12 +41,12 @@ class PupilProvisioningService( */ fun ensurePupilUserExists(command: PupilLoginCommand): User { val normalizedUsername = normalizeUsername(command.username) - userRepository.findByUsernameIgnoreCase(normalizedUsername)?.let { return it } + iamUserService.findByUsername(normalizedUsername)?.let { return it } return try { createPupilUser(normalizedUsername, command.password) } catch (exception: DataIntegrityViolationException) { - userRepository.findByUsernameIgnoreCase(normalizedUsername) ?: throw exception + iamUserService.findByUsername(normalizedUsername) ?: throw exception } } @@ -84,23 +79,9 @@ class PupilProvisioningService( ): User = requireNotNull( createTransaction.execute { - val savedUser = - userRepository.saveAndFlush( - User( - username = username, - passwordHash = encodedPassword(rawPassword), - role = UserRole.PUPIL, - status = UserStatus.ACTIVE, - ), - ) - - pupilProfileRepository.save( - PupilProfile( - user = savedUser, - displayName = username, - ), - ) - userAvatarRepository.save(UserAvatar.defaultsFor(savedUser)) + val savedUser = iamUserService.createPupilUser(username, encodedPassword(rawPassword)) + userProfileService.createPupilProfile(savedUser, username) + userAvatarProvisioningService.createDefaultAvatar(savedUser) savedUser }, 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 6ca73a78..b5c1ba31 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 @@ -1,11 +1,6 @@ package edu.ntnu.idi.idatt.backend.iam.application -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.users.profile.domain.TeacherProfile -import edu.ntnu.idi.idatt.backend.users.profile.infrastructure.TeacherProfileRepository +import edu.ntnu.idi.idatt.backend.users.profile.application.UserProfileService import org.springframework.dao.DataIntegrityViolationException import org.springframework.http.HttpStatus import org.springframework.security.crypto.password.PasswordEncoder @@ -16,14 +11,14 @@ import org.springframework.web.server.ResponseStatusException /** * Creates teacher accounts together with their teacher profile data. * - * @property userRepository repository used to persist teacher user accounts - * @property teacherProfileRepository repository used to persist teacher profile rows + * @property iamUserService service used to persist teacher user accounts + * @property userProfileService service used to persist teacher profile rows * @property passwordEncoder password encoder used to hash teacher passwords */ @Service class TeacherRegistrationService( - private val userRepository: UserRepository, - private val teacherProfileRepository: TeacherProfileRepository, + private val iamUserService: IamUserService, + private val userProfileService: UserProfileService, private val passwordEncoder: PasswordEncoder, ) { /** @@ -38,27 +33,13 @@ class TeacherRegistrationService( val normalizedEmail = command.email.trim() val sanitizedFullName = sanitizeProfileText("fullName", command.fullName) val sanitizedSchoolName = sanitizeProfileText("schoolName", command.schoolName) - if (userRepository.findByEmailIgnoreCase(normalizedEmail) != null) { + if (iamUserService.findByEmail(normalizedEmail) != null) { throw ResponseStatusException(HttpStatus.CONFLICT, "Email is already registered") } try { - val savedUser = - userRepository.save( - User( - email = normalizedEmail, - passwordHash = encodePassword(command.password), - role = UserRole.TEACHER, - status = UserStatus.ACTIVE, - ), - ) - teacherProfileRepository.save( - TeacherProfile( - user = savedUser, - teacherName = sanitizedFullName, - schoolName = sanitizedSchoolName, - ), - ) + val savedUser = iamUserService.createTeacherUser(normalizedEmail, encodePassword(command.password)) + userProfileService.createTeacherProfile(savedUser, sanitizedFullName, sanitizedSchoolName) return RegisteredTeacher( id = savedUser.id ?: throw IllegalStateException("Saved teacher user is missing an id"), 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 b14618d8..8cb7c049 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 @@ -5,8 +5,6 @@ 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.UserRole 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.users.profile.infrastructure.TeacherProfileRepository import org.springframework.http.HttpStatus import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -16,16 +14,14 @@ import org.springframework.web.server.ResponseStatusException * Handles lookup of the authenticated user's profile data. * * @property currentUserResolver resolver to authenticate users - * @property pupilProfileRepository repository used to load pupil profile data + * @property userProfileService service used to load profile data * @property pupilGameProgressService service used to load classroom-scoped game progress - * @property teacherProfileRepository repository used to load the teacher profile data */ @Service class CurrentUserProfileService( private val currentUserResolver: CurrentUserResolver, - private val pupilProfileRepository: PupilProfileRepository, + private val userProfileService: UserProfileService, private val pupilGameProgressService: PupilGameProgressService, - private val teacherProfileRepository: TeacherProfileRepository, ) { /** * Returns the current authenticated user's profile. @@ -40,9 +36,9 @@ class CurrentUserProfileService( val pupilProfile = if (user.role == UserRole.PUPIL) { - pupilProfileRepository - .findById(userId) - .map { + userProfileService + .findPupilProfile(userId) + ?.let { val progress = getCurrentClassroomProgress(userId, it.currentClassroom?.id) PupilProfileDetails( @@ -52,25 +48,23 @@ class CurrentUserProfileService( currentLevel = progress?.currentLevel ?: 1, hasCompletedOnboarding = it.hasCompletedOnboarding, ) - }.orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil profile was not found") } + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil profile was not found") } else { null } val teacherProfile = if (user.role == UserRole.TEACHER) { - teacherProfileRepository - .findById(userId) - .map { + userProfileService + .findTeacherProfile(userId) + ?.let { TeacherProfileDetails( teacherName = it.teacherName, schoolName = it.schoolName, ) - }.orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Teacher profile was not found") } + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Teacher profile was not found") } else { null } @@ -102,7 +96,7 @@ class CurrentUserProfileService( val pupilProfile = findPupilProfile(userId) val progress = getCurrentClassroomProgress(userId, pupilProfile.currentClassroom?.id) pupilProfile.displayName = command.displayName.trim() - val savedProfile = pupilProfileRepository.save(pupilProfile) + val savedProfile = userProfileService.savePupilProfile(pupilProfile) return PupilProfileDetails( displayName = savedProfile.displayName, @@ -126,7 +120,7 @@ class CurrentUserProfileService( val pupilProfile = findPupilProfile(userId) val progress = getCurrentClassroomProgress(userId, pupilProfile.currentClassroom?.id) pupilProfile.hasCompletedOnboarding = true - val savedProfile = pupilProfileRepository.save(pupilProfile) + val savedProfile = userProfileService.savePupilProfile(pupilProfile) return PupilProfileDetails( displayName = savedProfile.displayName, @@ -144,11 +138,8 @@ class CurrentUserProfileService( * @return persisted pupil profile row */ private fun findPupilProfile(userId: Long): PupilProfile = - pupilProfileRepository - .findById(userId) - .orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil profile was not found") - } + userProfileService.findPupilProfile(userId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil profile was not found") /** * Loads the classroom-scoped progress row for the supplied pupil if a current classroom is set. 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 865b3914..0e8c59f6 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,6 +1,7 @@ package edu.ntnu.idi.idatt.backend.iam import edu.ntnu.idi.idatt.backend.iam.application.AuthService +import edu.ntnu.idi.idatt.backend.iam.application.IamUserService 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 @@ -11,8 +12,11 @@ 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.SecurityConfig +import edu.ntnu.idi.idatt.backend.users.avatar.application.UserAvatarProvisioningService import edu.ntnu.idi.idatt.backend.users.avatar.infrastructure.UserAvatarRepository +import edu.ntnu.idi.idatt.backend.users.profile.application.UserProfileService 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.assertThrows import org.junit.jupiter.api.Test @@ -35,9 +39,9 @@ class AuthServiceTests { val passwordEncoder = Mockito.mock(PasswordEncoder::class.java) val pupilProvisioningService = PupilProvisioningService( - userRepository, - pupilProfileRepository, - userAvatarRepository, + IamUserService(userRepository), + UserProfileService(pupilProfileRepository, Mockito.mock(TeacherProfileRepository::class.java)), + UserAvatarProvisioningService(userAvatarRepository), passwordEncoder, transactionManager(), ) 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 e43a7a2a..d1052631 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 @@ -1,15 +1,19 @@ package edu.ntnu.idi.idatt.backend.iam import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom +import edu.ntnu.idi.idatt.backend.classroom.application.ClassroomQueryService import edu.ntnu.idi.idatt.backend.classroom.infrastructure.ClassroomRepository import edu.ntnu.idi.idatt.backend.iam.application.CurrentUserResolver +import edu.ntnu.idi.idatt.backend.iam.application.IamUserService 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.iam.security.CurrentUser import edu.ntnu.idi.idatt.backend.users.profile.domain.PupilProfile +import edu.ntnu.idi.idatt.backend.users.profile.application.UserProfileService 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.assertSame import org.junit.jupiter.api.Test @@ -24,7 +28,7 @@ class CurrentUserResolverTests { val userRepository = Mockito.mock(UserRepository::class.java) val pupilProfileRepository = Mockito.mock(PupilProfileRepository::class.java) val classroomRepository = Mockito.mock(ClassroomRepository::class.java) - val resolver = CurrentUserResolver(userRepository, pupilProfileRepository, classroomRepository) + val resolver = resolver(userRepository, pupilProfileRepository, classroomRepository) val user = user(id = 42L, role = UserRole.PUPIL) Mockito.`when`(userRepository.findById(42L)).thenReturn(Optional.of(user)) @@ -39,7 +43,7 @@ class CurrentUserResolverTests { val userRepository = Mockito.mock(UserRepository::class.java) val pupilProfileRepository = Mockito.mock(PupilProfileRepository::class.java) val classroomRepository = Mockito.mock(ClassroomRepository::class.java) - val resolver = CurrentUserResolver(userRepository, pupilProfileRepository, classroomRepository) + val resolver = resolver(userRepository, pupilProfileRepository, classroomRepository) val exception = org.junit.jupiter.api.assertThrows { @@ -54,7 +58,7 @@ class CurrentUserResolverTests { val userRepository = Mockito.mock(UserRepository::class.java) val pupilProfileRepository = Mockito.mock(PupilProfileRepository::class.java) val classroomRepository = Mockito.mock(ClassroomRepository::class.java) - val resolver = CurrentUserResolver(userRepository, pupilProfileRepository, classroomRepository) + val resolver = resolver(userRepository, pupilProfileRepository, classroomRepository) Mockito.`when`(userRepository.findById(42L)).thenReturn(Optional.empty()) @@ -71,7 +75,7 @@ class CurrentUserResolverTests { val userRepository = Mockito.mock(UserRepository::class.java) val pupilProfileRepository = Mockito.mock(PupilProfileRepository::class.java) val classroomRepository = Mockito.mock(ClassroomRepository::class.java) - val resolver = CurrentUserResolver(userRepository, pupilProfileRepository, classroomRepository) + val resolver = resolver(userRepository, pupilProfileRepository, classroomRepository) val user = user(id = 7L, role = UserRole.TEACHER) Mockito.`when`(userRepository.findById(7L)).thenReturn(Optional.of(user)) @@ -89,7 +93,7 @@ class CurrentUserResolverTests { val userRepository = Mockito.mock(UserRepository::class.java) val pupilProfileRepository = Mockito.mock(PupilProfileRepository::class.java) val classroomRepository = Mockito.mock(ClassroomRepository::class.java) - val resolver = CurrentUserResolver(userRepository, pupilProfileRepository, classroomRepository) + val resolver = resolver(userRepository, pupilProfileRepository, classroomRepository) val user = user(id = 9L, role = UserRole.PUPIL) Mockito.`when`(userRepository.findById(9L)).thenReturn(Optional.of(user)) @@ -107,7 +111,7 @@ class CurrentUserResolverTests { val userRepository = Mockito.mock(UserRepository::class.java) val pupilProfileRepository = Mockito.mock(PupilProfileRepository::class.java) val classroomRepository = Mockito.mock(ClassroomRepository::class.java) - val resolver = CurrentUserResolver(userRepository, pupilProfileRepository, classroomRepository) + val resolver = resolver(userRepository, pupilProfileRepository, classroomRepository) val user = user(id = 11L, role = UserRole.PUPIL) val classroom = Classroom(id = 101L, ownerTeacherId = 7L, title = "Class A") val pupilProfile = @@ -132,7 +136,7 @@ class CurrentUserResolverTests { val userRepository = Mockito.mock(UserRepository::class.java) val pupilProfileRepository = Mockito.mock(PupilProfileRepository::class.java) val classroomRepository = Mockito.mock(ClassroomRepository::class.java) - val resolver = CurrentUserResolver(userRepository, pupilProfileRepository, classroomRepository) + val resolver = resolver(userRepository, pupilProfileRepository, classroomRepository) val user = user(id = 12L, role = UserRole.TEACHER) val classroom = Classroom(id = 202L, ownerTeacherId = 12L, title = "Class B") @@ -156,6 +160,17 @@ class CurrentUserResolverTests { status = UserStatus.ACTIVE, ) + private fun resolver( + userRepository: UserRepository, + pupilProfileRepository: PupilProfileRepository, + classroomRepository: ClassroomRepository, + ): CurrentUserResolver = + CurrentUserResolver( + IamUserService(userRepository), + UserProfileService(pupilProfileRepository, Mockito.mock(TeacherProfileRepository::class.java)), + ClassroomQueryService(classroomRepository), + ) + private fun currentUser( subject: String, portalType: String, 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 6fb0329d..e89e197e 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 @@ -1,15 +1,19 @@ package edu.ntnu.idi.idatt.backend.iam +import edu.ntnu.idi.idatt.backend.iam.application.IamUserService 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 import edu.ntnu.idi.idatt.backend.iam.infrastructure.UserRepository +import edu.ntnu.idi.idatt.backend.users.avatar.application.UserAvatarProvisioningService 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.application.UserProfileService 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.users.profile.infrastructure.TeacherProfileRepository import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertSame import org.junit.jupiter.api.Test @@ -29,9 +33,9 @@ class PupilProvisioningServiceTests { val passwordEncoder = Mockito.mock(PasswordEncoder::class.java) val provisioningService = PupilProvisioningService( - userRepository, - pupilProfileRepository, - userAvatarRepository, + IamUserService(userRepository), + UserProfileService(pupilProfileRepository, Mockito.mock(TeacherProfileRepository::class.java)), + UserAvatarProvisioningService(userAvatarRepository), passwordEncoder, transactionManager(), ) @@ -71,9 +75,9 @@ class PupilProvisioningServiceTests { val passwordEncoder = Mockito.mock(PasswordEncoder::class.java) val provisioningService = PupilProvisioningService( - userRepository, - pupilProfileRepository, - userAvatarRepository, + IamUserService(userRepository), + UserProfileService(pupilProfileRepository, Mockito.mock(TeacherProfileRepository::class.java)), + UserAvatarProvisioningService(userAvatarRepository), passwordEncoder, transactionManager(), ) 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 d48dac64..3eaa37c5 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 @@ -1,12 +1,15 @@ package edu.ntnu.idi.idatt.backend.iam +import edu.ntnu.idi.idatt.backend.iam.application.IamUserService import edu.ntnu.idi.idatt.backend.iam.application.TeacherRegistrationCommand import edu.ntnu.idi.idatt.backend.iam.application.TeacherRegistrationService 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.users.profile.application.UserProfileService import edu.ntnu.idi.idatt.backend.users.profile.domain.TeacherProfile +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.assertThrows @@ -25,8 +28,8 @@ class TeacherRegistrationServiceTests { val passwordEncoder = Mockito.mock(PasswordEncoder::class.java) val registrationService = TeacherRegistrationService( - userRepository, - teacherProfileRepository, + IamUserService(userRepository), + UserProfileService(Mockito.mock(PupilProfileRepository::class.java), teacherProfileRepository), passwordEncoder, ) @@ -63,8 +66,8 @@ class TeacherRegistrationServiceTests { val passwordEncoder = Mockito.mock(PasswordEncoder::class.java) val registrationService = TeacherRegistrationService( - userRepository, - teacherProfileRepository, + IamUserService(userRepository), + UserProfileService(Mockito.mock(PupilProfileRepository::class.java), teacherProfileRepository), passwordEncoder, ) @@ -100,8 +103,8 @@ class TeacherRegistrationServiceTests { val passwordEncoder = Mockito.mock(PasswordEncoder::class.java) val registrationService = TeacherRegistrationService( - userRepository, - teacherProfileRepository, + IamUserService(userRepository), + UserProfileService(Mockito.mock(PupilProfileRepository::class.java), teacherProfileRepository), passwordEncoder, ) 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 a43eb161..30345bd1 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 @@ -9,6 +9,7 @@ import edu.ntnu.idi.idatt.backend.iam.domain.UserRole import edu.ntnu.idi.idatt.backend.iam.domain.UserStatus import edu.ntnu.idi.idatt.backend.users.profile.application.CurrentUserProfileService import edu.ntnu.idi.idatt.backend.users.profile.application.UpdateCurrentUserDisplayNameCommand +import edu.ntnu.idi.idatt.backend.users.profile.application.UserProfileService 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 @@ -30,12 +31,7 @@ class CurrentUserProfileServiceTests { val teacherProfileRepository = Mockito.mock(TeacherProfileRepository::class.java) val classroom = Classroom(id = 101L, ownerTeacherId = 7L, title = "Class A") val service = - CurrentUserProfileService( - currentUserResolver, - pupilProfileRepository, - pupilGameProgressService, - teacherProfileRepository, - ) + currentUserProfileService(currentUserResolver, pupilProfileRepository, pupilGameProgressService, teacherProfileRepository) val user = pupilUser(42L) val pupilProfile = PupilProfile( @@ -73,12 +69,7 @@ class CurrentUserProfileServiceTests { val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val teacherProfileRepository = Mockito.mock(TeacherProfileRepository::class.java) val service = - CurrentUserProfileService( - currentUserResolver, - pupilProfileRepository, - pupilGameProgressService, - teacherProfileRepository, - ) + currentUserProfileService(currentUserResolver, pupilProfileRepository, pupilGameProgressService, teacherProfileRepository) val user = pupilUser(42L) val pupilProfile = PupilProfile( @@ -107,12 +98,7 @@ class CurrentUserProfileServiceTests { val teacherProfileRepository = Mockito.mock(TeacherProfileRepository::class.java) val classroom = Classroom(id = 101L, ownerTeacherId = 7L, title = "Class A") val service = - CurrentUserProfileService( - currentUserResolver, - pupilProfileRepository, - pupilGameProgressService, - teacherProfileRepository, - ) + currentUserProfileService(currentUserResolver, pupilProfileRepository, pupilGameProgressService, teacherProfileRepository) val user = pupilUser(42L) val pupilProfile = PupilProfile( @@ -140,12 +126,7 @@ class CurrentUserProfileServiceTests { val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val teacherProfileRepository = Mockito.mock(TeacherProfileRepository::class.java) val service = - CurrentUserProfileService( - currentUserResolver, - pupilProfileRepository, - pupilGameProgressService, - teacherProfileRepository, - ) + currentUserProfileService(currentUserResolver, pupilProfileRepository, pupilGameProgressService, teacherProfileRepository) val user = teacherUser(7L) val teacherProfile = TeacherProfile( @@ -177,12 +158,7 @@ class CurrentUserProfileServiceTests { val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val teacherProfileRepository = Mockito.mock(TeacherProfileRepository::class.java) val service = - CurrentUserProfileService( - currentUserResolver, - pupilProfileRepository, - pupilGameProgressService, - teacherProfileRepository, - ) + currentUserProfileService(currentUserResolver, pupilProfileRepository, pupilGameProgressService, teacherProfileRepository) val exception = ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid authenticated user subject") Mockito.`when`(currentUserResolver.requireUser("not-a-number")).thenThrow(exception) @@ -202,12 +178,7 @@ class CurrentUserProfileServiceTests { val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val teacherProfileRepository = Mockito.mock(TeacherProfileRepository::class.java) val service = - CurrentUserProfileService( - currentUserResolver, - pupilProfileRepository, - pupilGameProgressService, - teacherProfileRepository, - ) + currentUserProfileService(currentUserResolver, pupilProfileRepository, pupilGameProgressService, teacherProfileRepository) val exception = ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authenticated user was not found") Mockito.`when`(currentUserResolver.requireUser("42")).thenThrow(exception) @@ -227,12 +198,7 @@ class CurrentUserProfileServiceTests { val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val teacherProfileRepository = Mockito.mock(TeacherProfileRepository::class.java) val service = - CurrentUserProfileService( - currentUserResolver, - pupilProfileRepository, - pupilGameProgressService, - teacherProfileRepository, - ) + currentUserProfileService(currentUserResolver, pupilProfileRepository, pupilGameProgressService, teacherProfileRepository) val user = pupilUser(42L) Mockito.`when`(currentUserResolver.requireUser("42")).thenReturn(user) @@ -253,12 +219,7 @@ class CurrentUserProfileServiceTests { val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val teacherProfileRepository = Mockito.mock(TeacherProfileRepository::class.java) val service = - CurrentUserProfileService( - currentUserResolver, - pupilProfileRepository, - pupilGameProgressService, - teacherProfileRepository, - ) + currentUserProfileService(currentUserResolver, pupilProfileRepository, pupilGameProgressService, teacherProfileRepository) val user = teacherUser(7L) Mockito.`when`(currentUserResolver.requireUser("7")).thenReturn(user) @@ -280,12 +241,7 @@ class CurrentUserProfileServiceTests { val teacherProfileRepository = Mockito.mock(TeacherProfileRepository::class.java) val classroom = Classroom(id = 101L, ownerTeacherId = 7L, title = "Class A") val service = - CurrentUserProfileService( - currentUserResolver, - pupilProfileRepository, - pupilGameProgressService, - teacherProfileRepository, - ) + currentUserProfileService(currentUserResolver, pupilProfileRepository, pupilGameProgressService, teacherProfileRepository) val user = pupilUser(42L) val pupilProfile = PupilProfile( @@ -327,12 +283,7 @@ class CurrentUserProfileServiceTests { val teacherProfileRepository = Mockito.mock(TeacherProfileRepository::class.java) val classroom = Classroom(id = 101L, ownerTeacherId = 7L, title = "Class A") val service = - CurrentUserProfileService( - currentUserResolver, - pupilProfileRepository, - pupilGameProgressService, - teacherProfileRepository, - ) + currentUserProfileService(currentUserResolver, pupilProfileRepository, pupilGameProgressService, teacherProfileRepository) val user = pupilUser(42L) val pupilProfile = PupilProfile( @@ -369,12 +320,7 @@ class CurrentUserProfileServiceTests { val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val teacherProfileRepository = Mockito.mock(TeacherProfileRepository::class.java) val service = - CurrentUserProfileService( - currentUserResolver, - pupilProfileRepository, - pupilGameProgressService, - teacherProfileRepository, - ) + currentUserProfileService(currentUserResolver, pupilProfileRepository, pupilGameProgressService, teacherProfileRepository) val exception = ResponseStatusException(HttpStatus.FORBIDDEN, "Only pupils are allowed") Mockito.`when`(currentUserResolver.requirePupil("7")).thenThrow(exception) @@ -397,12 +343,7 @@ class CurrentUserProfileServiceTests { val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val teacherProfileRepository = Mockito.mock(TeacherProfileRepository::class.java) val service = - CurrentUserProfileService( - currentUserResolver, - pupilProfileRepository, - pupilGameProgressService, - teacherProfileRepository, - ) + currentUserProfileService(currentUserResolver, pupilProfileRepository, pupilGameProgressService, teacherProfileRepository) val user = pupilUser(42L) Mockito.`when`(currentUserResolver.requirePupil("42")).thenReturn(user) @@ -436,4 +377,16 @@ class CurrentUserProfileServiceTests { role = UserRole.TEACHER, status = UserStatus.ACTIVE, ) + + private fun currentUserProfileService( + currentUserResolver: CurrentUserResolver, + pupilProfileRepository: PupilProfileRepository, + pupilGameProgressService: PupilGameProgressService, + teacherProfileRepository: TeacherProfileRepository, + ): CurrentUserProfileService = + CurrentUserProfileService( + currentUserResolver, + UserProfileService(pupilProfileRepository, teacherProfileRepository), + pupilGameProgressService, + ) } From cd11b2a16363071692d9ffa745a62da10ce36836 Mon Sep 17 00:00:00 2001 From: Johannes Aamot-Skeidsvoll Date: Wed, 29 Apr 2026 12:52:47 +0200 Subject: [PATCH 3/8] Move classroom game lookups behind services --- .../classroom/api/ClassroomController.kt | 12 +----- .../classroom/application/ClassroomService.kt | 39 ++++++++++--------- .../application/GameAccessContextResolver.kt | 11 ++---- .../application/PupilGameProgressService.kt | 22 +++++------ .../mapstops/application/MapStopService.kt | 18 +++++---- .../game/tasks/application/TaskService.kt | 18 +++------ .../application/LeaderboardService.kt | 8 ++-- .../medals/application/MedalEventListener.kt | 2 +- .../backend/classroom/ClassroomServiceTest.kt | 11 ++++-- .../game/mapstops/MapStopServiceTests.kt | 3 +- .../backend/medals/MedalEventListenerTests.kt | 5 ++- 11 files changed, 71 insertions(+), 78 deletions(-) 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 1706643b..ebd71973 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 @@ -2,7 +2,6 @@ package edu.ntnu.idi.idatt.backend.classroom.api import edu.ntnu.idi.idatt.backend.classroom.application.ClassroomService 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.DeleteMapping @@ -21,7 +20,6 @@ import org.springframework.web.bind.annotation.RestController @RequestMapping("/api/v1/classrooms") class ClassroomController( private val classroomService: ClassroomService, - private val pupilProfileRepository: PupilProfileRepository, ) { @GetMapping("/owned") fun listOwnedClassrooms( @@ -96,14 +94,8 @@ class ClassroomController( @PathVariable id: Long, @AuthenticationPrincipal currentUser: CurrentUser, ): 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 } - - classroom.toDetailResponse(pupilDisplayNames) + classroomService.getClassroomById(id, currentUser.subject).let { detail -> + detail.classroom.toDetailResponse(detail.pupilDisplayNames) } /** 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 adbbe6fb..1dd935b9 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,13 +5,13 @@ 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.game.tasks.application.TaskQueryService 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.application.IamUserService import edu.ntnu.idi.idatt.backend.iam.domain.UserRole -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 edu.ntnu.idi.idatt.backend.users.profile.application.UserProfileService import org.springframework.http.HttpStatus import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -24,21 +24,21 @@ import java.util.UUID * * @property classroomRepository repository for classroom access. * @property currentUserResolver shared resolver for the authenticated user and role guard. - * @property userRepository repository for user access. + * @property iamUserService service for user access. * @property classroomUserResolver resolver for classroom access control. * @property pupilGameProgressService service for classroom-scoped game progress entries. - * @property taskRepository repository used to count classroom tasks. + * @property taskQueryService service used to count classroom tasks. * @property domainEventPublisher publisher for application events. */ @Service class ClassroomService( private val classroomRepository: ClassroomRepository, private val currentUserResolver: CurrentUserResolver, - private val userRepository: UserRepository, + private val iamUserService: IamUserService, private val classroomUserResolver: ClassroomUserResolver, private val pupilGameProgressService: PupilGameProgressService, - private val taskRepository: TaskRepository, - private val pupilProfileRepository: PupilProfileRepository, + private val taskQueryService: TaskQueryService, + private val userProfileService: UserProfileService, private val domainEventPublisher: DomainEventPublisher, ) { /** @@ -129,12 +129,11 @@ class ClassroomService( } classroom.addPupilMembership(user) - val profile = - pupilProfileRepository - .findById(userId) - .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Profile not found") } - profile.currentClassroom = classroom - pupilProfileRepository.save(profile) + try { + userProfileService.updateCurrentClassroom(userId, classroom) + } catch (_: NoSuchElementException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, "Profile not found") + } classroomRepository.save(classroom) pupilGameProgressService.createPupilGameProgressEntry( pupilId = userId, @@ -232,7 +231,7 @@ class ClassroomService( val emails = command.teacherEmails.map { it.trim().lowercase(Locale.ROOT) } - val teachers = userRepository.findAllByEmailIgnoreCaseIn(emails) + val teachers = iamUserService.findTeachersByEmails(emails) if (teachers.size != emails.size) { val foundEmails = teachers.mapNotNull { it.email?.lowercase(Locale.ROOT) } @@ -261,7 +260,7 @@ class ClassroomService( fun getClassroomById( classroomId: Long, authenticatedSubject: String, - ): Classroom { + ): ClassroomDetail { val user = currentUserResolver.requireUser(authenticatedSubject) classroomUserResolver.requireClassroomMember(user, classroomId) @@ -269,7 +268,11 @@ class ClassroomService( classroomRepository.findById(classroomId).orElse(null) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") - return classroom + val pupilIds = classroom.pupilMemberships.mapNotNull { membership -> membership.pupil?.id } + return ClassroomDetail( + classroom = classroom, + pupilDisplayNames = userProfileService.findPupilDisplayNames(pupilIds), + ) } /** @@ -343,6 +346,6 @@ class ClassroomService( pupilCount = classroom.pupilMemberships.count { it.status == ClassroomPupilStatus.ACTIVE }, pendingPupilCount = classroom.pupilMemberships.count { it.status == ClassroomPupilStatus.PENDING }, teacherCount = 1, - taskCount = taskRepository.countByClassroomId(classroom.id).toInt(), + taskCount = taskQueryService.countTasksInClassroom(classroom.id), ) } 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 30c6192d..25eb0adb 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 @@ -1,7 +1,7 @@ package edu.ntnu.idi.idatt.backend.game.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.classroom.application.ClassroomQueryService 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 @@ -24,7 +24,7 @@ enum class GameReadMode { @Service class GameAccessContextResolver( private val currentUserResolver: CurrentUserResolver, - private val classroomRepository: ClassroomRepository, + private val classroomQueryService: ClassroomQueryService, ) { /** * Resolves the caller's effective read capabilities for one classroom. @@ -40,11 +40,8 @@ class GameAccessContextResolver( ): GameAccessContext { val user = currentUserResolver.requireUser(currentUser.subject) val classroom = - classroomRepository - .findById(classroomId) - .orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") - } + classroomQueryService.findById(classroomId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") val userId = requireNotNull(user.id) { "Authenticated user is missing an id" } if (readMode == GameReadMode.TEACHER_PLAYTEST) { 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 04f5f316..fe83650f 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,12 +1,12 @@ package edu.ntnu.idi.idatt.backend.game.gameProgress.application -import edu.ntnu.idi.idatt.backend.classroom.infrastructure.ClassroomRepository +import edu.ntnu.idi.idatt.backend.classroom.application.ClassroomQueryService 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 import edu.ntnu.idi.idatt.backend.iam.application.CurrentUserResolver -import edu.ntnu.idi.idatt.backend.iam.infrastructure.UserRepository +import edu.ntnu.idi.idatt.backend.iam.application.IamUserService import edu.ntnu.idi.idatt.backend.iam.security.CurrentUser import org.springframework.http.HttpStatus import org.springframework.stereotype.Service @@ -22,15 +22,15 @@ import java.time.Instant * flows, and profile/notebook lookups that need progress-derived values. * * @property pupilGameProgressRepository repository for persisted pupil game-progress rows - * @property userRepository repository used to resolve pupils when creating new progress rows - * @property classroomRepository repository used to resolve classrooms when creating new progress rows + * @property iamUserService service used to resolve pupils when creating new progress rows + * @property classroomQueryService service used to resolve classrooms when creating new progress rows * @property currentUserResolver resolver used to derive the active pupil and classroom from the authenticated user */ @Service class PupilGameProgressService( private val pupilGameProgressRepository: PupilGameProgressRepository, - private val userRepository: UserRepository, - private val classroomRepository: ClassroomRepository, + private val iamUserService: IamUserService, + private val classroomQueryService: ClassroomQueryService, private val currentUserResolver: CurrentUserResolver, ) { companion object { @@ -117,13 +117,11 @@ class PupilGameProgressService( } val pupil = - userRepository.findById(pupilId).orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil not found") - } + iamUserService.findById(pupilId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil not found") val classroom = - classroomRepository.findById(classroomId).orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") - } + classroomQueryService.findById(classroomId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") pupilGameProgressRepository.save( PupilGameProgress( diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/application/MapStopService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/application/MapStopService.kt index 124adec9..44610405 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/application/MapStopService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/application/MapStopService.kt @@ -6,7 +6,7 @@ import edu.ntnu.idi.idatt.backend.game.mapstops.domain.MapStop 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.game.tasks.application.TaskQueryService import edu.ntnu.idi.idatt.backend.iam.security.CurrentUser import org.springframework.http.HttpStatus import org.springframework.stereotype.Service @@ -23,7 +23,7 @@ class MapStopService( private val mapStopsRepository: MapStopsRepository, private val pupilStopProgressRepository: PupilStopProgressRepository, private val gameAccessContextResolver: GameAccessContextResolver, - private val taskRepository: TaskRepository, + private val taskQueryService: TaskQueryService, ) { /** * Returns all active map stops sorted by display order. @@ -45,6 +45,13 @@ class MapStopService( ?.takeIf { it.isActive == true } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "No map stop found for slug: $slug") + fun getMapStopById(stopId: Long): MapStop = + mapStopsRepository + .findById(stopId) + .orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Map stop not found") + } + /** * Returns all active map stops enriched with the pupil's progress, stars, and lock state, * sorted by display order. @@ -133,12 +140,7 @@ 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) - .asSequence() - .filter { it.isPublished } - .groupBy { it.stopId } - .mapValues { (_, tasks) -> tasks.sumOf { it.maxScore } } + taskQueryService.findPublishedStopMaxScores(classroomId) private fun computeStars( bestScore: Int, 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 454bab5a..17fec06b 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 @@ -1,7 +1,7 @@ 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.classroom.application.ClassroomQueryService 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 @@ -35,7 +35,7 @@ import org.springframework.web.server.ResponseStatusException @Service class TaskService( private val taskRepository: TaskRepository, - private val classroomRepository: ClassroomRepository, + private val classroomQueryService: ClassroomQueryService, private val currentUserResolver: CurrentUserResolver, private val pupilStopProgressService: PupilStopProgressService, private val gameAccessContextResolver: GameAccessContextResolver, @@ -316,11 +316,8 @@ class TaskService( 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") - } + classroomQueryService.findById(classroomId) + ?: throw 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") } @@ -334,11 +331,8 @@ class TaskService( 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") - } + classroomQueryService.findById(classroomId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") val hasAccess = when (user.role) { 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 e990555f..71b07009 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 @@ -1,6 +1,6 @@ package edu.ntnu.idi.idatt.backend.leaderboard.application -import edu.ntnu.idi.idatt.backend.classroom.infrastructure.ClassroomRepository +import edu.ntnu.idi.idatt.backend.classroom.application.ClassroomQueryService import edu.ntnu.idi.idatt.backend.iam.application.CurrentUserResolver import edu.ntnu.idi.idatt.backend.iam.security.CurrentUser import org.springframework.stereotype.Service @@ -14,7 +14,7 @@ import org.springframework.transaction.annotation.Transactional */ @Service class LeaderboardService( - private val classroomRepository: ClassroomRepository, + private val classroomQueryService: ClassroomQueryService, private val currentUserResolver: CurrentUserResolver, ) { /** @@ -29,8 +29,8 @@ class LeaderboardService( fun getLeaderboardList(currentUser: CurrentUser): List { currentUserResolver.requireUser(currentUser.subject) - return classroomRepository - .findAllWithAverageTotalScore() + return classroomQueryService + .findAverageTotalScores() .mapIndexed { index, classroom -> LeaderboardEntry( rank = index + 1, diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/medals/application/MedalEventListener.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/medals/application/MedalEventListener.kt index 009c106b..fcb588c8 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/medals/application/MedalEventListener.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/medals/application/MedalEventListener.kt @@ -36,7 +36,7 @@ class MedalEventListener( fun onMapStopCompleted(event: MapStopCompletedEvent) { val authenticatedSubject = event.pupilUserId.toString() val pupil = currentUserResolver.requireUser(authenticatedSubject) - val classroom = classroomService.getClassroomById(event.classroomId, authenticatedSubject) + val classroom = classroomService.getClassroomById(event.classroomId, authenticatedSubject).classroom medalService.awardStopMedal( pupil = pupil, 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 f2913e7c..d9f4c815 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,20 @@ 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.application.TaskQueryService 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.application.IamUserService 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.application.UserProfileService 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.users.profile.infrastructure.TeacherProfileRepository import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach @@ -55,16 +59,17 @@ class ClassroomServiceTest { pupilGameProgressService = mock(PupilGameProgressService::class.java) taskRepository = mock(TaskRepository::class.java) pupilProfileRepository = mock(PupilProfileRepository::class.java) + `when`(pupilProfileRepository.save(any(PupilProfile::class.java))).thenAnswer { it.arguments[0] } eventPublisher = mock(DomainEventPublisher::class.java) classroomService = ClassroomService( classroomRepository, currentUserResolver, - userRepository, + IamUserService(userRepository), classroomUserResolver, pupilGameProgressService, - taskRepository, - pupilProfileRepository, + TaskQueryService(taskRepository), + UserProfileService(pupilProfileRepository, mock(TeacherProfileRepository::class.java)), eventPublisher, ) } 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 ab5479e9..267a00f0 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 @@ -14,6 +14,7 @@ import edu.ntnu.idi.idatt.backend.game.mapstops.infrastructure.PupilStopProgress import edu.ntnu.idi.idatt.backend.game.tasks.domain.Task 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.application.TaskQueryService 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 @@ -40,7 +41,7 @@ class MapStopServiceTests { val progressRepository = Mockito.mock(PupilStopProgressRepository::class.java) val resolver = Mockito.mock(GameAccessContextResolver::class.java) val taskRepository = Mockito.mock(TaskRepository::class.java) - val service = MapStopService(mapStopsRepository, progressRepository, resolver, taskRepository) + val service = MapStopService(mapStopsRepository, progressRepository, resolver, TaskQueryService(taskRepository)) return TestContext(mapStopsRepository, progressRepository, resolver, taskRepository, service) } diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/medals/MedalEventListenerTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/medals/MedalEventListenerTests.kt index d9ebcb2a..88f27c8b 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/medals/MedalEventListenerTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/medals/MedalEventListenerTests.kt @@ -1,5 +1,6 @@ package edu.ntnu.idi.idatt.backend.medals +import edu.ntnu.idi.idatt.backend.classroom.application.ClassroomDetail import edu.ntnu.idi.idatt.backend.classroom.application.ClassroomService import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom import edu.ntnu.idi.idatt.backend.iam.application.CurrentUserResolver @@ -23,7 +24,7 @@ class MedalEventListenerTests { val classroom = classroom() Mockito.`when`(currentUserResolver.requireUser("1")).thenReturn(pupil) - Mockito.`when`(classroomService.getClassroomById(2L, "1")).thenReturn(classroom) + Mockito.`when`(classroomService.getClassroomById(2L, "1")).thenReturn(ClassroomDetail(classroom, emptyMap())) listener.onMapStopCompleted(MapStopCompletedEvent(pupilUserId = 1L, classroomId = 2L, stopId = 3L)) @@ -40,7 +41,7 @@ class MedalEventListenerTests { val classroom = classroom() Mockito.`when`(currentUserResolver.requireUser("1")).thenReturn(pupil) - Mockito.`when`(classroomService.getClassroomById(2L, "1")).thenReturn(classroom) + Mockito.`when`(classroomService.getClassroomById(2L, "1")).thenReturn(ClassroomDetail(classroom, emptyMap())) listener.onMapStopCompleted(MapStopCompletedEvent(pupilUserId = 1L, classroomId = 2L, stopId = 3L)) Mockito.verify(currentUserResolver).requireUser("1") From 11a25a789a68c66362f575fc78b558e50ce11e6b Mon Sep 17 00:00:00 2001 From: Johannes Aamot-Skeidsvoll Date: Wed, 29 Apr 2026 12:53:07 +0200 Subject: [PATCH 4/8] Use services for notification content lookups --- .../mystery/application/MysteryService.kt | 26 ++++----- .../notebook/application/NotebookService.kt | 13 ++--- .../api/NotificationPresentationResolver.kt | 29 ++++------ .../ClassroomNotificationRecipientResolver.kt | 19 ++---- .../NotificationEventContextResolver.kt | 39 +++++-------- .../backend/mystery/MysteryServiceTests.kt | 3 +- .../backend/notebook/NotebookServiceTests.kt | 58 +++++++++---------- 7 files changed, 76 insertions(+), 111 deletions(-) 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 14508890..543f0da5 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 @@ -1,6 +1,6 @@ package edu.ntnu.idi.idatt.backend.mystery.application -import edu.ntnu.idi.idatt.backend.iam.infrastructure.UserRepository +import edu.ntnu.idi.idatt.backend.iam.application.IamUserService import edu.ntnu.idi.idatt.backend.moderation.application.ClassroomModerationService import edu.ntnu.idi.idatt.backend.mystery.domain.MysterySubmission import edu.ntnu.idi.idatt.backend.mystery.domain.ReviewStatus @@ -27,14 +27,14 @@ import java.time.temporal.TemporalAdjusters * * @property submissionRepository Repository for mystery submissions. * @property weeklyMysteryRepository Repository for weekly featured mysteries. - * @property userRepository Repository for loading user entities by id. + * @property iamUserService service for loading user entities by id. * @property domainEventPublisher Publisher for domain events. */ @Service class MysteryService( private val submissionRepository: MysterySubmissionRepository, private val weeklyMysteryRepository: WeeklyMysteryRepository, - private val userRepository: UserRepository, + private val iamUserService: IamUserService, private val domainEventPublisher: DomainEventPublisher, private val classroomModerationService: ClassroomModerationService, ) { @@ -50,9 +50,8 @@ class MysteryService( classroomModerationService.assertNoBannedWords(command.classroomId, command.description) val pupil = - userRepository.findById(command.pupilUserId).orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil not found") - } + iamUserService.findById(command.pupilUserId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil not found") val submission = submissionRepository.save( MysterySubmission( @@ -138,9 +137,8 @@ class MysteryService( 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") - } + iamUserService.findById(command.teacherUserId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Teacher not found") submission.approve(teacher, command.comment) val saved = submissionRepository.save(submission) @@ -169,9 +167,8 @@ class MysteryService( 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") - } + iamUserService.findById(command.teacherUserId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Teacher not found") submission.reject(teacher, command.comment) return submissionRepository.save(submission) @@ -193,9 +190,8 @@ class MysteryService( 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") - } + iamUserService.findById(command.teacherUserId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Teacher not found") if (!submission.isApproved) { throw ResponseStatusException( 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 ebecdb89..ddeeb38d 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 @@ -1,7 +1,7 @@ package edu.ntnu.idi.idatt.backend.notebook.application import edu.ntnu.idi.idatt.backend.game.gameProgress.application.PupilGameProgressService -import edu.ntnu.idi.idatt.backend.game.mapstops.infrastructure.MapStopsRepository +import edu.ntnu.idi.idatt.backend.game.mapstops.application.MapStopService 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.security.CurrentUser @@ -21,7 +21,7 @@ import org.springframework.web.server.ResponseStatusException * * @property notebookEntryRepository repository used to load and persist notebook entries * @property currentUserResolver resolver used to enforce that only pupil users access notebook entries - * @property mapStopsRepository repository used to validate referenced map stops during creation + * @property mapStopService service used to validate referenced map stops during creation * @property pupilGameProgressService service used to resolve notebook visibility from pupil progress * @property xpService service used to award XP for notebook activity */ @@ -30,7 +30,7 @@ class NotebookService( private val notebookEntryRepository: NotebookEntryRepository, private val currentUserResolver: CurrentUserResolver, private val classroomUserResolver: ClassroomUserResolver, - private val mapStopsRepository: MapStopsRepository, + private val mapStopService: MapStopService, private val pupilGameProgressService: PupilGameProgressService, private val xpService: XpService, private val classroomModerationService: ClassroomModerationService, @@ -107,12 +107,7 @@ class NotebookService( classroomModerationService.assertNoBannedWords(command.classroomId, command.title, command.content) - val stop = - mapStopsRepository - .findById(command.stopId) - .orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Map stop not found") - } + val stop = mapStopService.getMapStopById(command.stopId) xpService.awardXpEvent(command.currentUser, XpEventCode.NOTEBOOK_ENTRY_CREATED) 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 e61ff96d..2dc31058 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 @@ -2,10 +2,10 @@ package edu.ntnu.idi.idatt.backend.notification.api import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.json.JsonMapper -import edu.ntnu.idi.idatt.backend.classroom.infrastructure.ClassroomRepository -import edu.ntnu.idi.idatt.backend.iam.infrastructure.UserRepository +import edu.ntnu.idi.idatt.backend.classroom.application.ClassroomQueryService +import edu.ntnu.idi.idatt.backend.iam.application.IamUserService import edu.ntnu.idi.idatt.backend.notification.domain.TeacherNotification -import edu.ntnu.idi.idatt.backend.users.profile.infrastructure.PupilProfileRepository +import edu.ntnu.idi.idatt.backend.users.profile.application.UserProfileService import org.springframework.stereotype.Component /** @@ -22,9 +22,9 @@ data class NotificationPresentationData( */ @Component class NotificationPresentationResolver( - private val classroomRepository: ClassroomRepository, - private val pupilProfileRepository: PupilProfileRepository, - private val userRepository: UserRepository, + private val classroomQueryService: ClassroomQueryService, + private val userProfileService: UserProfileService, + private val iamUserService: IamUserService, ) { private val objectMapper = JsonMapper.builder().build() @@ -38,26 +38,21 @@ class NotificationPresentationResolver( } val classroomTitles = - classroomRepository - .findAllById(notifications.map(TeacherNotification::classroomId).distinct()) + notifications + .map(TeacherNotification::classroomId) + .distinct() + .mapNotNull(classroomQueryService::findById) .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 = userProfileService.findPupilDisplayNames(pupilUserIds) val unresolvedPupilIds = pupilUserIds.filterNot(pupilDisplayNames::containsKey) val pupilUsernames = if (unresolvedPupilIds.isEmpty()) { emptyMap() } else { - userRepository + iamUserService .findAllById(unresolvedPupilIds) .associate { user -> requireNotNull(user.id) to user.username } } 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 7f1964f2..1b31996a 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 @@ -1,6 +1,6 @@ package edu.ntnu.idi.idatt.backend.notification.application -import edu.ntnu.idi.idatt.backend.classroom.infrastructure.ClassroomRepository +import edu.ntnu.idi.idatt.backend.classroom.application.ClassroomQueryService import org.springframework.http.HttpStatus import org.springframework.stereotype.Component import org.springframework.web.server.ResponseStatusException @@ -10,7 +10,7 @@ import org.springframework.web.server.ResponseStatusException */ @Component class ClassroomNotificationRecipientResolver( - private val classroomRepository: ClassroomRepository, + private val classroomQueryService: ClassroomQueryService, ) { /** * Resolves all teacher recipients that administer the given classroom. @@ -20,18 +20,7 @@ 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") - } - - return buildSet { - add(classroom.ownerTeacherId) - classroom.teacherMemberships - .mapNotNull { membership -> membership.teacher?.id } - .forEach(::add) - } + return classroomQueryService.findTeacherRecipientIds(classroomId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") } } 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 ad6af95b..c6de5d0b 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 @@ -1,10 +1,10 @@ package edu.ntnu.idi.idatt.backend.notification.application import com.fasterxml.jackson.databind.json.JsonMapper -import edu.ntnu.idi.idatt.backend.classroom.infrastructure.ClassroomRepository -import edu.ntnu.idi.idatt.backend.game.mapstops.infrastructure.MapStopsRepository -import edu.ntnu.idi.idatt.backend.iam.infrastructure.UserRepository -import edu.ntnu.idi.idatt.backend.users.profile.infrastructure.PupilProfileRepository +import edu.ntnu.idi.idatt.backend.classroom.application.ClassroomQueryService +import edu.ntnu.idi.idatt.backend.game.mapstops.application.MapStopService +import edu.ntnu.idi.idatt.backend.iam.application.IamUserService +import edu.ntnu.idi.idatt.backend.users.profile.application.UserProfileService import org.springframework.http.HttpStatus import org.springframework.stereotype.Component import org.springframework.web.server.ResponseStatusException @@ -27,10 +27,10 @@ data class StopNotificationContext( */ @Component class NotificationEventContextResolver( - private val classroomRepository: ClassroomRepository, - private val pupilProfileRepository: PupilProfileRepository, - private val userRepository: UserRepository, - private val mapStopsRepository: MapStopsRepository, + private val classroomQueryService: ClassroomQueryService, + private val userProfileService: UserProfileService, + private val iamUserService: IamUserService, + private val mapStopService: MapStopService, ) { private val objectMapper = JsonMapper.builder().build() @@ -49,24 +49,13 @@ class NotificationEventContextResolver( stopId: Long, ): StopNotificationContext { val classroom = - classroomRepository - .findById(classroomId) - .orElseThrow { - ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") - } - val pupilProfile = pupilProfileRepository.findById(pupilUserId).orElse(null) + classroomQueryService.findById(classroomId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") + val pupilProfile = userProfileService.findPupilProfile(pupilUserId) 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") - } + iamUserService.findById(pupilUserId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil not found") + val stop = mapStopService.getMapStopById(stopId) return StopNotificationContext( classroomTitle = classroom.title, 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 index 7ec83358..06cfe2cc 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 @@ -2,6 +2,7 @@ package edu.ntnu.idi.idatt.backend.mystery import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom import edu.ntnu.idi.idatt.backend.classroom.domain.ClassroomStatus +import edu.ntnu.idi.idatt.backend.iam.application.IamUserService 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 @@ -85,7 +86,7 @@ class MysteryServiceTests { MysteryService( submissionRepository, weeklyMysteryRepository, - userRepository, + IamUserService(userRepository), domainEventPublisher, classroomModerationService, ) 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 d20ed81c..8ed76011 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 @@ -3,7 +3,7 @@ package edu.ntnu.idi.idatt.backend.notebook 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.game.mapstops.application.MapStopService 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 @@ -35,7 +35,7 @@ class NotebookServiceTests { val currentUserResolver = Mockito.mock(CurrentUserResolver::class.java) val classroomUserResolver = Mockito.mock(ClassroomUserResolver::class.java) val notebookEntryRepository = Mockito.mock(NotebookEntryRepository::class.java) - val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) + val mapStopService = Mockito.mock(MapStopService::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) val classroomModerationService = Mockito.mock(ClassroomModerationService::class.java) @@ -44,7 +44,7 @@ class NotebookServiceTests { notebookEntryRepository, currentUserResolver, classroomUserResolver, - mapStopsRepository, + mapStopService, pupilGameProgressService, xpService, classroomModerationService, @@ -69,7 +69,7 @@ class NotebookServiceTests { val currentUserResolver = Mockito.mock(CurrentUserResolver::class.java) val classroomUserResolver = Mockito.mock(ClassroomUserResolver::class.java) val notebookEntryRepository = Mockito.mock(NotebookEntryRepository::class.java) - val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) + val mapStopService = Mockito.mock(MapStopService::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) val classroomModerationService = Mockito.mock(ClassroomModerationService::class.java) @@ -78,7 +78,7 @@ class NotebookServiceTests { notebookEntryRepository, currentUserResolver, classroomUserResolver, - mapStopsRepository, + mapStopService, pupilGameProgressService, xpService, classroomModerationService, @@ -104,7 +104,7 @@ class NotebookServiceTests { val currentUserResolver = Mockito.mock(CurrentUserResolver::class.java) val classroomUserResolver = Mockito.mock(ClassroomUserResolver::class.java) val notebookEntryRepository = Mockito.mock(NotebookEntryRepository::class.java) - val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) + val mapStopService = Mockito.mock(MapStopService::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) val classroomModerationService = Mockito.mock(ClassroomModerationService::class.java) @@ -113,7 +113,7 @@ class NotebookServiceTests { notebookEntryRepository, currentUserResolver, classroomUserResolver, - mapStopsRepository, + mapStopService, pupilGameProgressService, xpService, classroomModerationService, @@ -142,7 +142,7 @@ class NotebookServiceTests { val currentUserResolver = Mockito.mock(CurrentUserResolver::class.java) val classroomUserResolver = Mockito.mock(ClassroomUserResolver::class.java) val notebookEntryRepository = Mockito.mock(NotebookEntryRepository::class.java) - val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) + val mapStopService = Mockito.mock(MapStopService::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) val classroomModerationService = Mockito.mock(ClassroomModerationService::class.java) @@ -151,7 +151,7 @@ class NotebookServiceTests { notebookEntryRepository, currentUserResolver, classroomUserResolver, - mapStopsRepository, + mapStopService, pupilGameProgressService, xpService, classroomModerationService, @@ -172,7 +172,7 @@ class NotebookServiceTests { 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`(mapStopService.getMapStopById(9L)).thenReturn(stop) Mockito .`when`(notebookEntryRepository.save(Mockito.any(NotebookEntry::class.java))) .thenAnswer { it.arguments[0] as NotebookEntry } @@ -196,7 +196,7 @@ class NotebookServiceTests { val currentUserResolver = Mockito.mock(CurrentUserResolver::class.java) val classroomUserResolver = Mockito.mock(ClassroomUserResolver::class.java) val notebookEntryRepository = Mockito.mock(NotebookEntryRepository::class.java) - val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) + val mapStopService = Mockito.mock(MapStopService::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) val classroomModerationService = Mockito.mock(ClassroomModerationService::class.java) @@ -205,7 +205,7 @@ class NotebookServiceTests { notebookEntryRepository, currentUserResolver, classroomUserResolver, - mapStopsRepository, + mapStopService, pupilGameProgressService, xpService, classroomModerationService, @@ -239,7 +239,7 @@ class NotebookServiceTests { val currentUserResolver = Mockito.mock(CurrentUserResolver::class.java) val classroomUserResolver = Mockito.mock(ClassroomUserResolver::class.java) val notebookEntryRepository = Mockito.mock(NotebookEntryRepository::class.java) - val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) + val mapStopService = Mockito.mock(MapStopService::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) val classroomModerationService = Mockito.mock(ClassroomModerationService::class.java) @@ -248,7 +248,7 @@ class NotebookServiceTests { notebookEntryRepository, currentUserResolver, classroomUserResolver, - mapStopsRepository, + mapStopService, pupilGameProgressService, xpService, classroomModerationService, @@ -282,7 +282,7 @@ class NotebookServiceTests { val currentUserResolver = Mockito.mock(CurrentUserResolver::class.java) val classroomUserResolver = Mockito.mock(ClassroomUserResolver::class.java) val notebookEntryRepository = Mockito.mock(NotebookEntryRepository::class.java) - val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) + val mapStopService = Mockito.mock(MapStopService::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) val classroomModerationService = Mockito.mock(ClassroomModerationService::class.java) @@ -291,7 +291,7 @@ class NotebookServiceTests { notebookEntryRepository, currentUserResolver, classroomUserResolver, - mapStopsRepository, + mapStopService, pupilGameProgressService, xpService, classroomModerationService, @@ -311,7 +311,7 @@ class NotebookServiceTests { Mockito.`when`(currentUserResolver.requirePupil("42")).thenReturn(pupil) Mockito.`when`(currentUserResolver.getClassroomByUser(currentUser)).thenReturn(classroom) - Mockito.`when`(mapStopsRepository.findById(9L)).thenReturn(Optional.empty()) + Mockito.`when`(mapStopService.getMapStopById(9L)).thenThrow(ResponseStatusException(HttpStatus.NOT_FOUND, "Map stop not found")) val exception = assertThrows { @@ -326,7 +326,7 @@ class NotebookServiceTests { val currentUserResolver = Mockito.mock(CurrentUserResolver::class.java) val classroomUserResolver = Mockito.mock(ClassroomUserResolver::class.java) val notebookEntryRepository = Mockito.mock(NotebookEntryRepository::class.java) - val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) + val mapStopService = Mockito.mock(MapStopService::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) val classroomModerationService = Mockito.mock(ClassroomModerationService::class.java) @@ -335,7 +335,7 @@ class NotebookServiceTests { notebookEntryRepository, currentUserResolver, classroomUserResolver, - mapStopsRepository, + mapStopService, pupilGameProgressService, xpService, classroomModerationService, @@ -371,7 +371,7 @@ class NotebookServiceTests { val currentUserResolver = Mockito.mock(CurrentUserResolver::class.java) val classroomUserResolver = Mockito.mock(ClassroomUserResolver::class.java) val notebookEntryRepository = Mockito.mock(NotebookEntryRepository::class.java) - val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) + val mapStopService = Mockito.mock(MapStopService::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) val classroomModerationService = Mockito.mock(ClassroomModerationService::class.java) @@ -380,7 +380,7 @@ class NotebookServiceTests { notebookEntryRepository, currentUserResolver, classroomUserResolver, - mapStopsRepository, + mapStopService, pupilGameProgressService, xpService, classroomModerationService, @@ -412,7 +412,7 @@ class NotebookServiceTests { val currentUserResolver = Mockito.mock(CurrentUserResolver::class.java) val classroomUserResolver = Mockito.mock(ClassroomUserResolver::class.java) val notebookEntryRepository = Mockito.mock(NotebookEntryRepository::class.java) - val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) + val mapStopService = Mockito.mock(MapStopService::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) val classroomModerationService = Mockito.mock(ClassroomModerationService::class.java) @@ -421,7 +421,7 @@ class NotebookServiceTests { notebookEntryRepository, currentUserResolver, classroomUserResolver, - mapStopsRepository, + mapStopService, pupilGameProgressService, xpService, classroomModerationService, @@ -448,7 +448,7 @@ class NotebookServiceTests { val currentUserResolver = Mockito.mock(CurrentUserResolver::class.java) val classroomUserResolver = Mockito.mock(ClassroomUserResolver::class.java) val notebookEntryRepository = Mockito.mock(NotebookEntryRepository::class.java) - val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) + val mapStopService = Mockito.mock(MapStopService::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) val classroomModerationService = Mockito.mock(ClassroomModerationService::class.java) @@ -457,7 +457,7 @@ class NotebookServiceTests { notebookEntryRepository, currentUserResolver, classroomUserResolver, - mapStopsRepository, + mapStopService, pupilGameProgressService, xpService, classroomModerationService, @@ -487,7 +487,7 @@ class NotebookServiceTests { val currentUserResolver = Mockito.mock(CurrentUserResolver::class.java) val classroomUserResolver = Mockito.mock(ClassroomUserResolver::class.java) val notebookEntryRepository = Mockito.mock(NotebookEntryRepository::class.java) - val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) + val mapStopService = Mockito.mock(MapStopService::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) val classroomModerationService = Mockito.mock(ClassroomModerationService::class.java) @@ -496,7 +496,7 @@ class NotebookServiceTests { notebookEntryRepository, currentUserResolver, classroomUserResolver, - mapStopsRepository, + mapStopService, pupilGameProgressService, xpService, classroomModerationService, @@ -521,7 +521,7 @@ class NotebookServiceTests { val currentUserResolver = Mockito.mock(CurrentUserResolver::class.java) val classroomUserResolver = Mockito.mock(ClassroomUserResolver::class.java) val notebookEntryRepository = Mockito.mock(NotebookEntryRepository::class.java) - val mapStopsRepository = Mockito.mock(MapStopsRepository::class.java) + val mapStopService = Mockito.mock(MapStopService::class.java) val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) val classroomModerationService = Mockito.mock(ClassroomModerationService::class.java) @@ -530,7 +530,7 @@ class NotebookServiceTests { notebookEntryRepository, currentUserResolver, classroomUserResolver, - mapStopsRepository, + mapStopService, pupilGameProgressService, xpService, classroomModerationService, From bc056f8fdb0c8f06421ae750cff1ea66620a180c Mon Sep 17 00:00:00 2001 From: Johannes Aamot-Skeidsvoll Date: Wed, 29 Apr 2026 12:55:04 +0200 Subject: [PATCH 5/8] Add repository boundary test --- .../backend/ArchitectureBoundaryTests.kt | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 src/test/kotlin/edu/ntnu/idi/idatt/backend/ArchitectureBoundaryTests.kt diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/ArchitectureBoundaryTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/ArchitectureBoundaryTests.kt new file mode 100644 index 00000000..a411602a --- /dev/null +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/ArchitectureBoundaryTests.kt @@ -0,0 +1,54 @@ +package edu.ntnu.idi.idatt.backend + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.isRegularFile +import kotlin.io.path.readText + +class ArchitectureBoundaryTests { + @Test + fun `runtime source does not import other domains repositories`() { + val sourceRoot = Path.of("src/main/kotlin") + val repositoryImport = + Regex("""import edu\.ntnu\.idi\.idatt\.backend\.([A-Za-z0-9_.]+)\.infrastructure\.[A-Za-z0-9_]+Repository""") + val violations = mutableListOf() + + Files + .walk(sourceRoot) + .use { paths -> + paths + .filter { path -> path.isRegularFile() && path.toString().endsWith(".kt") } + .filter { path -> !path.toString().contains("/bootstrap/") } + .forEach { path -> + val source = path.readText() + val sourceDomain = domainPrefix(path, sourceRoot) + repositoryImport.findAll(source).forEach { match -> + val repositoryDomain = match.groupValues[1] + if (sourceDomain != repositoryDomain) { + violations += "${sourceRoot.relativize(path)} imports $repositoryDomain repository" + } + } + } + } + + assertEquals(emptyList(), violations.sorted()) + } + + private fun domainPrefix( + path: Path, + sourceRoot: Path, + ): String { + val relative = sourceRoot.relativize(path).toString().removeSuffix(".kt") + val packagePath = relative.substringBeforeLast('/').replace('/', '.') + val backendPrefix = "edu.ntnu.idi.idatt.backend." + return packagePath + .removePrefix(backendPrefix) + .substringBefore(".api") + .substringBefore(".application") + .substringBefore(".domain") + .substringBefore(".infrastructure") + .substringBefore(".security") + } +} From 96c7dcea2ff42b335c06071880759050c2f28568 Mon Sep 17 00:00:00 2001 From: Johannes Aamot-Skeidsvoll Date: Wed, 29 Apr 2026 13:00:57 +0200 Subject: [PATCH 6/8] Remove unused methods --- .../classroom/application/ClassroomQueryService.kt | 9 --------- .../ntnu/idi/idatt/backend/ArchitectureBoundaryTests.kt | 2 +- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/ClassroomQueryService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/ClassroomQueryService.kt index 5cbbba6b..b4f33675 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/ClassroomQueryService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/ClassroomQueryService.kt @@ -13,20 +13,11 @@ class ClassroomQueryService( ) { fun findById(classroomId: Long): Classroom? = classroomRepository.findById(classroomId).orElse(null) - fun findByJoinCode(joinCode: String): Classroom? = classroomRepository.findByJoinCode(joinCode) - fun findFirstOwnedByTeacher(teacherUserId: Long): Classroom? = classroomRepository.findFirstByOwnerTeacherIdOrderByIdAsc(teacherUserId) - fun findOwnedByTeacher(teacherUserId: Long): List = - classroomRepository.findAllByOwnerTeacherIdOrderByTitleAscIdAsc(teacherUserId) - fun findAll(): List = classroomRepository.findAll() - fun findAllByUserId(userId: Long): List = classroomRepository.findAllByUserId(userId) - - fun existsById(classroomId: Long): Boolean = classroomRepository.existsById(classroomId) - fun findTeacherRecipientIds(classroomId: Long): Set? = findById(classroomId)?.let { classroom -> buildSet { diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/ArchitectureBoundaryTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/ArchitectureBoundaryTests.kt index a411602a..3635a421 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/ArchitectureBoundaryTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/ArchitectureBoundaryTests.kt @@ -20,7 +20,7 @@ class ArchitectureBoundaryTests { .use { paths -> paths .filter { path -> path.isRegularFile() && path.toString().endsWith(".kt") } - .filter { path -> !path.toString().contains("/bootstrap/") } + .filter { path -> !path.toString().contains("/bootstrap/") } // Bootstrap may wire seed data directly. .forEach { path -> val source = path.readText() val sourceDomain = domainPrefix(path, sourceRoot) From 99046ddbfd43182fdacb0c45932a368ef7800aa2 Mon Sep 17 00:00:00 2001 From: Johannes Aamot-Skeidsvoll Date: Wed, 29 Apr 2026 13:02:41 +0200 Subject: [PATCH 7/8] Format --- .../backend/classroom/application/ClassroomQueryService.kt | 3 +-- .../backend/game/application/GameAccessContextResolver.kt | 2 +- .../backend/game/mapstops/application/MapStopService.kt | 3 +-- .../idi/idatt/backend/game/tasks/application/TaskService.kt | 2 +- .../idatt/backend/iam/application/ClassroomUserResolver.kt | 2 +- .../idi/idatt/backend/iam/application/CurrentUserResolver.kt | 2 +- .../application/ClassroomNotificationRecipientResolver.kt | 5 ++--- .../idi/idatt/backend/game/mapstops/MapStopServiceTests.kt | 2 +- .../ntnu/idi/idatt/backend/iam/CurrentUserResolverTests.kt | 4 ++-- .../ntnu/idi/idatt/backend/notebook/NotebookServiceTests.kt | 2 +- 10 files changed, 12 insertions(+), 15 deletions(-) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/ClassroomQueryService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/ClassroomQueryService.kt index b4f33675..fcf77605 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/ClassroomQueryService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/ClassroomQueryService.kt @@ -13,8 +13,7 @@ class ClassroomQueryService( ) { fun findById(classroomId: Long): Classroom? = classroomRepository.findById(classroomId).orElse(null) - fun findFirstOwnedByTeacher(teacherUserId: Long): Classroom? = - classroomRepository.findFirstByOwnerTeacherIdOrderByIdAsc(teacherUserId) + fun findFirstOwnedByTeacher(teacherUserId: Long): Classroom? = classroomRepository.findFirstByOwnerTeacherIdOrderByIdAsc(teacherUserId) fun findAll(): List = classroomRepository.findAll() 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 25eb0adb..354a35c0 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 @@ -1,7 +1,7 @@ package edu.ntnu.idi.idatt.backend.game.application -import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom import edu.ntnu.idi.idatt.backend.classroom.application.ClassroomQueryService +import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom 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 diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/application/MapStopService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/application/MapStopService.kt index 44610405..67a451b2 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/application/MapStopService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/application/MapStopService.kt @@ -139,8 +139,7 @@ class MapStopService( /** * Loads the total max score for all published tasks at each stop in a classroom. */ - private fun loadPublishedStopMaxScores(classroomId: Long): Map = - taskQueryService.findPublishedStopMaxScores(classroomId) + private fun loadPublishedStopMaxScores(classroomId: Long): Map = taskQueryService.findPublishedStopMaxScores(classroomId) private fun computeStars( bestScore: Int, 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 17fec06b..db16535b 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 @@ -1,7 +1,7 @@ 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.application.ClassroomQueryService +import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom 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 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 5683411f..81a451eb 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 @@ -1,7 +1,7 @@ package edu.ntnu.idi.idatt.backend.iam.application -import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom import edu.ntnu.idi.idatt.backend.classroom.application.ClassroomQueryService +import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom import edu.ntnu.idi.idatt.backend.iam.domain.User import org.springframework.http.HttpStatus import org.springframework.stereotype.Service 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 3034456c..0489e52d 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 @@ -1,7 +1,7 @@ package edu.ntnu.idi.idatt.backend.iam.application -import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom import edu.ntnu.idi.idatt.backend.classroom.application.ClassroomQueryService +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.security.CurrentUser 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 1b31996a..6c526e7f 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 @@ -19,8 +19,7 @@ class ClassroomNotificationRecipientResolver( * @return deduplicated teacher user ids for the classroom. * @throws ResponseStatusException if the classroom does not exist. */ - fun resolveTeacherRecipientIds(classroomId: Long): Set { - return classroomQueryService.findTeacherRecipientIds(classroomId) + fun resolveTeacherRecipientIds(classroomId: Long): Set = + classroomQueryService.findTeacherRecipientIds(classroomId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found") - } } 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 267a00f0..dbeb395c 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 @@ -11,10 +11,10 @@ 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.game.tasks.application.TaskQueryService import edu.ntnu.idi.idatt.backend.game.tasks.domain.Task 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.application.TaskQueryService 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 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 d1052631..49816a46 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 @@ -1,7 +1,7 @@ package edu.ntnu.idi.idatt.backend.iam -import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom import edu.ntnu.idi.idatt.backend.classroom.application.ClassroomQueryService +import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom import edu.ntnu.idi.idatt.backend.classroom.infrastructure.ClassroomRepository import edu.ntnu.idi.idatt.backend.iam.application.CurrentUserResolver import edu.ntnu.idi.idatt.backend.iam.application.IamUserService @@ -10,8 +10,8 @@ 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.iam.security.CurrentUser -import edu.ntnu.idi.idatt.backend.users.profile.domain.PupilProfile import edu.ntnu.idi.idatt.backend.users.profile.application.UserProfileService +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.users.profile.infrastructure.TeacherProfileRepository import org.junit.jupiter.api.Assertions.assertEquals 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 8ed76011..11be795e 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 @@ -2,8 +2,8 @@ package edu.ntnu.idi.idatt.backend.notebook 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.application.MapStopService +import edu.ntnu.idi.idatt.backend.game.mapstops.domain.MapStop 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 From 70baa0c3af564be9664d18616f82cad4e4d51540 Mon Sep 17 00:00:00 2001 From: Johannes Aamot-Skeidsvoll Date: Wed, 29 Apr 2026 13:07:21 +0200 Subject: [PATCH 8/8] Document repository boundary services --- .../classroom/api/ClassroomController.kt | 18 ++++++- .../classroom/application/ClassroomDetail.kt | 6 +++ .../application/ClassroomQueryService.kt | 39 +++++++++++++++ .../mapstops/application/MapStopService.kt | 7 +++ .../tasks/application/TaskQueryService.kt | 14 ++++++ .../backend/iam/application/IamUserService.kt | 46 +++++++++++++++++ .../application/LeaderboardService.kt | 8 +++ .../api/NotificationPresentationResolver.kt | 16 ++++++ .../UserAvatarProvisioningService.kt | 8 +++ .../profile/application/UserProfileService.kt | 50 +++++++++++++++++++ .../backend/ArchitectureBoundaryTests.kt | 15 ++++++ 11 files changed, 225 insertions(+), 2 deletions(-) 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 ebd71973..ee2edb42 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 @@ -21,6 +21,12 @@ import org.springframework.web.bind.annotation.RestController class ClassroomController( private val classroomService: ClassroomService, ) { + /** + * Lists classrooms owned by the authenticated teacher or all classrooms for admins. + * + * @param currentUser authenticated teacher or admin principal. + * @return classroom ownership summaries for the caller. + */ @GetMapping("/owned") fun listOwnedClassrooms( @AuthenticationPrincipal currentUser: CurrentUser, @@ -29,6 +35,13 @@ class ClassroomController( .listOwnedClassrooms(currentUser.subject) .map { it.toOwnedClassroomResponse() } + /** + * Retrieves one classroom ownership summary for a teacher or admin. + * + * @param currentUser authenticated teacher or admin principal. + * @param classroomId classroom id to retrieve. + * @return classroom ownership summary. + */ @GetMapping("/owned/{classroomId}") fun getOwnedClassroom( @AuthenticationPrincipal currentUser: CurrentUser, @@ -103,7 +116,8 @@ class ClassroomController( * * @param id the unique id of the classroom to be updated. * @param request the update classroom request containing the updated classroom data. - * @param currentUser the updated classroom. + * @param currentUser the authenticated user extracted from the security context. + * @return updated classroom. */ @PutMapping("/{id}") fun updateClassroom( @@ -121,7 +135,7 @@ class ClassroomController( * * @param classroomId the id of the classroom the pupil should be removed from. * @param pupilId the id of the student being removed. - * @param currentUser the authenticates user extracted from the security context. + * @param currentUser the authenticated user extracted from the security context. */ @DeleteMapping("/{classroomId}/pupils/{pupilId}") fun removePupil( diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/ClassroomDetail.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/ClassroomDetail.kt index 73a3e7b3..b61adf49 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/ClassroomDetail.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/ClassroomDetail.kt @@ -2,6 +2,12 @@ package edu.ntnu.idi.idatt.backend.classroom.application import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom +/** + * Classroom detail model enriched with presentation data owned by other domains. + * + * @property classroom classroom entity after access checks have passed. + * @property pupilDisplayNames display names keyed by pupil user id. + */ data class ClassroomDetail( val classroom: Classroom, val pupilDisplayNames: Map, diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/ClassroomQueryService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/ClassroomQueryService.kt index fcf77605..c0ad0488 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/ClassroomQueryService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/classroom/application/ClassroomQueryService.kt @@ -6,17 +6,44 @@ import org.springframework.stereotype.Service /** * Read-oriented classroom operations exposed to other domains. + * + * @property classroomRepository repository that owns classroom persistence. */ @Service class ClassroomQueryService( private val classroomRepository: ClassroomRepository, ) { + /** + * Finds one classroom by persistent id. + * + * @param classroomId classroom id to look up. + * @return matching classroom, or `null` when no classroom exists. + */ fun findById(classroomId: Long): Classroom? = classroomRepository.findById(classroomId).orElse(null) + /** + * Finds the first classroom owned by the teacher, ordered by classroom id. + * + * @param teacherUserId teacher user id that owns the classroom. + * @return first owned classroom, or `null` when the teacher owns none. + */ fun findFirstOwnedByTeacher(teacherUserId: Long): Classroom? = classroomRepository.findFirstByOwnerTeacherIdOrderByIdAsc(teacherUserId) + /** + * Returns every classroom in persistence order. + * + * @return all classrooms. + */ fun findAll(): List = classroomRepository.findAll() + /** + * Resolves the teacher recipients for one classroom notification. + * + * The returned ids include the owner and any teacher memberships, with duplicates removed. + * + * @param classroomId classroom whose teachers should receive a notification. + * @return teacher user ids, or `null` when the classroom does not exist. + */ fun findTeacherRecipientIds(classroomId: Long): Set? = findById(classroomId)?.let { classroom -> buildSet { @@ -27,6 +54,11 @@ class ClassroomQueryService( } } + /** + * Returns classroom average score projections for leaderboard rendering. + * + * @return classroom score projections ordered by the repository query. + */ fun findAverageTotalScores(): List = classroomRepository.findAllWithAverageTotalScore().map { score -> ClassroomAverageScore( @@ -37,6 +69,13 @@ class ClassroomQueryService( } } +/** + * Classroom aggregate score projection used outside the classroom domain. + * + * @property classroomId classroom id. + * @property classroomTitle display title for the classroom. + * @property averageTotalScore average total score across pupils in the classroom. + */ data class ClassroomAverageScore( val classroomId: Long, val classroomTitle: String, diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/application/MapStopService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/application/MapStopService.kt index 67a451b2..e82022ee 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/application/MapStopService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/mapstops/application/MapStopService.kt @@ -45,6 +45,13 @@ class MapStopService( ?.takeIf { it.isActive == true } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "No map stop found for slug: $slug") + /** + * Returns a map stop by persistent id. + * + * @param stopId map stop id to look up. + * @return matching map stop. + * @throws ResponseStatusException 404 if no map stop exists with the id. + */ fun getMapStopById(stopId: Long): MapStop = mapStopsRepository .findById(stopId) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/tasks/application/TaskQueryService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/tasks/application/TaskQueryService.kt index 181a963d..94d5b32b 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/tasks/application/TaskQueryService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/tasks/application/TaskQueryService.kt @@ -5,16 +5,30 @@ import org.springframework.stereotype.Service /** * Read-oriented task operations exposed to other domains. + * + * @property taskRepository repository that owns task persistence. */ @Service class TaskQueryService( private val taskRepository: TaskRepository, ) { + /** + * Counts all tasks in one classroom. + * + * @param classroomId classroom id to count tasks for, or `null` when the classroom has not been persisted. + * @return number of tasks in the classroom, or `0` when no classroom id is available. + */ fun countTasksInClassroom(classroomId: Long?): Int = classroomId ?.let { taskRepository.countByClassroomId(it).toInt() } ?: 0 + /** + * Sums published task max scores per stop for one classroom. + * + * @param classroomId classroom whose published tasks should be aggregated. + * @return total published max score keyed by stop id. + */ fun findPublishedStopMaxScores(classroomId: Long): Map = taskRepository .findAllByClassroomIdOrderByStopIdAscDifficultyLevelAsc(classroomId) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/application/IamUserService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/application/IamUserService.kt index 1959a5af..1c131de4 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/application/IamUserService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/iam/application/IamUserService.kt @@ -8,21 +8,60 @@ import org.springframework.stereotype.Service /** * Application-facing user lookup and persistence operations owned by IAM. + * + * @property userRepository repository that owns user persistence. */ @Service class IamUserService( private val userRepository: UserRepository, ) { + /** + * Finds one user by persistent id. + * + * @param userId user id to look up. + * @return matching user, or `null` when no user exists. + */ fun findById(userId: Long): User? = userRepository.findById(userId).orElse(null) + /** + * Finds all users with ids in the supplied iterable. + * + * @param userIds user ids to load. + * @return persisted users matching the supplied ids. + */ fun findAllById(userIds: Iterable): List = userRepository.findAllById(userIds).toList() + /** + * Finds a teacher or admin user by email, ignoring case. + * + * @param email email address to look up. + * @return matching user, or `null` when no user has the email. + */ fun findByEmail(email: String): User? = userRepository.findByEmailIgnoreCase(email) + /** + * Finds a pupil user by username, ignoring case. + * + * @param username username to look up. + * @return matching user, or `null` when no user has the username. + */ fun findByUsername(username: String): User? = userRepository.findByUsernameIgnoreCase(username) + /** + * Finds users for the supplied teacher email list. + * + * @param emails normalized teacher email addresses. + * @return users matching the submitted emails. + */ fun findTeachersByEmails(emails: List): List = userRepository.findAllByEmailIgnoreCaseIn(emails) + /** + * Creates a new active pupil user. + * + * @param username normalized pupil username. + * @param passwordHash encoded pupil password. + * @return persisted pupil user. + */ fun createPupilUser( username: String, passwordHash: String, @@ -36,6 +75,13 @@ class IamUserService( ), ) + /** + * Creates a new active teacher user. + * + * @param email normalized teacher email. + * @param passwordHash encoded teacher password. + * @return persisted teacher user. + */ fun createTeacherUser( email: String, passwordHash: String, 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 71b07009..b0d8e7f2 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 @@ -42,6 +42,14 @@ class LeaderboardService( } } +/** + * Classroom leaderboard row returned by the leaderboard application service. + * + * @property rank one-based rank in the leaderboard. + * @property classroomId classroom id. + * @property classroomTitle display title for the classroom. + * @property averageTotalScore average classroom score used for ranking. + */ data class LeaderboardEntry( val rank: Int, val classroomId: Long, 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 2dc31058..6b3b772d 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 @@ -10,6 +10,10 @@ import org.springframework.stereotype.Component /** * Teacher-facing display metadata attached to one notification response. + * + * @property classroomTitle classroom title shown in the notification UI. + * @property pupilDisplayName pupil display name shown in the notification UI, when relevant. + * @property stopTitle map stop title shown in the notification UI, when relevant. */ data class NotificationPresentationData( val classroomTitle: String, @@ -28,10 +32,22 @@ class NotificationPresentationResolver( ) { private val objectMapper = JsonMapper.builder().build() + /** + * Resolves display metadata for one notification. + * + * @param notification notification to enrich. + * @return presentation data for the notification. + */ fun resolve(notification: TeacherNotification): NotificationPresentationData = resolve(listOf(notification))[requireNotNull(notification.id) { "Notification is missing an id" }] ?: fallback(notification) + /** + * Resolves display metadata for a batch of notifications. + * + * @param notifications notifications to enrich. + * @return presentation data keyed by notification id. + */ fun resolve(notifications: List): Map { if (notifications.isEmpty()) { return emptyMap() diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UserAvatarProvisioningService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UserAvatarProvisioningService.kt index ba6daf09..b7d32a4d 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UserAvatarProvisioningService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UserAvatarProvisioningService.kt @@ -7,10 +7,18 @@ import org.springframework.stereotype.Service /** * Owns avatar setup operations used when pupil accounts are provisioned. + * + * @property userAvatarRepository repository that owns current avatar persistence. */ @Service class UserAvatarProvisioningService( private val userAvatarRepository: UserAvatarRepository, ) { + /** + * Creates the default avatar row for a newly provisioned pupil. + * + * @param user owning pupil user. + * @return persisted default avatar. + */ fun createDefaultAvatar(user: User): UserAvatar = userAvatarRepository.save(UserAvatar.defaultsFor(user)) } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/profile/application/UserProfileService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/profile/application/UserProfileService.kt index 374164c8..ce60ed59 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/profile/application/UserProfileService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/profile/application/UserProfileService.kt @@ -10,12 +10,22 @@ import org.springframework.stereotype.Service /** * Owns profile persistence operations needed by other application features. + * + * @property pupilProfileRepository repository that owns pupil profile persistence. + * @property teacherProfileRepository repository that owns teacher profile persistence. */ @Service class UserProfileService( private val pupilProfileRepository: PupilProfileRepository, private val teacherProfileRepository: TeacherProfileRepository, ) { + /** + * Creates a pupil profile for a newly provisioned pupil user. + * + * @param user owning pupil user. + * @param displayName pupil display name. + * @return persisted pupil profile. + */ fun createPupilProfile( user: User, displayName: String, @@ -27,6 +37,14 @@ class UserProfileService( ), ) + /** + * Creates a teacher profile for a newly registered teacher user. + * + * @param user owning teacher user. + * @param teacherName teacher display name. + * @param schoolName school name associated with the teacher. + * @return persisted teacher profile. + */ fun createTeacherProfile( user: User, teacherName: String, @@ -40,10 +58,28 @@ class UserProfileService( ), ) + /** + * Finds a pupil profile by owning user id. + * + * @param userId pupil user id. + * @return matching pupil profile, or `null` when none exists. + */ fun findPupilProfile(userId: Long): PupilProfile? = pupilProfileRepository.findById(userId).orElse(null) + /** + * Finds a teacher profile by owning user id. + * + * @param userId teacher user id. + * @return matching teacher profile, or `null` when none exists. + */ fun findTeacherProfile(userId: Long): TeacherProfile? = teacherProfileRepository.findById(userId).orElse(null) + /** + * Finds pupil display names for a set of user ids. + * + * @param userIds pupil user ids to resolve. + * @return display names keyed by pupil user id. + */ fun findPupilDisplayNames(userIds: Collection): Map = if (userIds.isEmpty()) { emptyMap() @@ -53,6 +89,14 @@ class UserProfileService( .associate { profile -> requireNotNull(profile.userId) to profile.displayName } } + /** + * Updates a pupil profile's current classroom. + * + * @param pupilUserId pupil user id that owns the profile. + * @param classroom classroom to set as the current classroom. + * @return persisted pupil profile. + * @throws NoSuchElementException when the pupil profile does not exist. + */ fun updateCurrentClassroom( pupilUserId: Long, classroom: Classroom, @@ -66,5 +110,11 @@ class UserProfileService( return pupilProfileRepository.save(profile) } + /** + * Persists changes to an existing pupil profile. + * + * @param profile mutable pupil profile to save. + * @return persisted pupil profile. + */ fun savePupilProfile(profile: PupilProfile): PupilProfile = pupilProfileRepository.save(profile) } diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/ArchitectureBoundaryTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/ArchitectureBoundaryTests.kt index 3635a421..53c24628 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/ArchitectureBoundaryTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/ArchitectureBoundaryTests.kt @@ -7,7 +7,15 @@ import java.nio.file.Path import kotlin.io.path.isRegularFile import kotlin.io.path.readText +/** + * Architecture regression tests for source-level module boundaries. + */ class ArchitectureBoundaryTests { + /** + * Verifies that runtime source code only imports repositories from its own domain. + * + * Bootstrap is excluded because it deliberately composes seed data across domains. + */ @Test fun `runtime source does not import other domains repositories`() { val sourceRoot = Path.of("src/main/kotlin") @@ -36,6 +44,13 @@ class ArchitectureBoundaryTests { assertEquals(emptyList(), violations.sorted()) } + /** + * Extracts the backend domain prefix from a Kotlin source path. + * + * @param path source file path. + * @param sourceRoot root path used to relativize the source file. + * @return domain prefix before the architectural layer segment. + */ private fun domainPrefix( path: Path, sourceRoot: Path,