From 1047672bfdba1e4699e628b854b4ea47afa4c988 Mon Sep 17 00:00:00 2001 From: sindrelm Date: Mon, 27 Apr 2026 09:50:24 +0200 Subject: [PATCH 01/11] Add user_avatar and avatar_asset table Signed-off-by: sindrelm --- .../avatar/api/UpdateUserAvatarRequest.kt | 18 ++--- .../users/avatar/api/UserAvatarApiMappings.kt | 21 ++---- .../users/avatar/api/UserAvatarController.kt | 4 +- .../users/avatar/api/UserAvatarResponse.kt | 12 ++-- .../application/UpdateUserAvatarCommand.kt | 14 ++-- .../avatar/application/UserAvatarService.kt | 50 ++++++++++++-- .../users/avatar/domain/AvatarAsset.kt | 33 ++++++++++ .../users/avatar/domain/AvatarAssetType.kt | 10 +++ .../backend/users/avatar/domain/UserAvatar.kt | 50 +++++++------- .../infrastructure/AvatarAssetRepository.kt | 13 ++++ ...tars_into_user_avatar_and_avatar_asset.sql | 65 +++++++++++++++++++ .../iam/AuthControllerIntegrationTests.kt | 7 +- .../UserAvatarControllerIntegrationTests.kt | 24 +++---- .../users/avatar/UserAvatarServiceTests.kt | 38 ++++++++--- 14 files changed, 261 insertions(+), 98 deletions(-) create mode 100644 src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/domain/AvatarAsset.kt create mode 100644 src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/domain/AvatarAssetType.kt create mode 100644 src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/infrastructure/AvatarAssetRepository.kt create mode 100644 src/main/resources/db/migration/V22__split_avatars_into_user_avatar_and_avatar_asset.sql diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UpdateUserAvatarRequest.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UpdateUserAvatarRequest.kt index 39de74d8..58e2f01c 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UpdateUserAvatarRequest.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UpdateUserAvatarRequest.kt @@ -6,18 +6,18 @@ import jakarta.validation.constraints.Size /** * Request payload for updating the current pupil's avatar. * - * @property bodyType selected body style key - * @property skinTone selected skin tone key - * @property outfitStyle selected outfit style key + * @property hatId selected hat asset name + * @property outfitId selected outfit asset name + * @property skinColorId selected skin color asset name */ class UpdateUserAvatarRequest( @field:NotBlank - @field:Size(max = 50) - val bodyType: String, + @field:Size(max = 100) + val hatId: String, @field:NotBlank - @field:Size(max = 50) - val skinTone: String, + @field:Size(max = 100) + val outfitId: String, @field:NotBlank - @field:Size(max = 50) - val outfitStyle: String, + @field:Size(max = 100) + val skinColorId: String, ) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarApiMappings.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarApiMappings.kt index 19d4e5e6..e700aa17 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarApiMappings.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarApiMappings.kt @@ -1,19 +1,6 @@ package edu.ntnu.idi.idatt.backend.users.avatar.api import edu.ntnu.idi.idatt.backend.users.avatar.application.UpdateUserAvatarCommand -import edu.ntnu.idi.idatt.backend.users.avatar.domain.UserAvatar - -/** - * Converts the persisted avatar entity into the public API response payload. - * - * @return response exposed by the avatar API - */ -fun UserAvatar.toUserAvatarResponse(): UserAvatarResponse = - UserAvatarResponse( - bodyType = bodyType, - skinTone = skinTone, - outfitStyle = outfitStyle, - ) /** * Converts the update request payload into an application command. @@ -22,7 +9,7 @@ fun UserAvatar.toUserAvatarResponse(): UserAvatarResponse = */ fun UpdateUserAvatarRequest.toCommand(): UpdateUserAvatarCommand = UpdateUserAvatarCommand( - bodyType = bodyType, - skinTone = skinTone, - outfitStyle = outfitStyle - ) \ No newline at end of file + hatId = hatId, + outfitId = outfitId, + skinColorId = skinColorId + ) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarController.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarController.kt index 52bcaf23..e9075177 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarController.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarController.kt @@ -29,7 +29,7 @@ class UserAvatarController( @GetMapping fun getUserAvatar( @AuthenticationPrincipal currentUser: CurrentUser, - ): UserAvatarResponse = userAvatarService.getUserAvatar(currentUser.subject).toUserAvatarResponse() + ): UserAvatarResponse = userAvatarService.getUserAvatar(currentUser.subject) /** * Updates the current authenticated pupil's avatar with the submitted values. @@ -42,5 +42,5 @@ class UserAvatarController( fun updateUserAvatar( @AuthenticationPrincipal currentUser: CurrentUser, @Valid @RequestBody request: UpdateUserAvatarRequest, - ): UserAvatarResponse = userAvatarService.updateUserAvatar(currentUser.subject, request.toCommand()).toUserAvatarResponse() + ): UserAvatarResponse = userAvatarService.updateUserAvatar(currentUser.subject, request.toCommand()) } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarResponse.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarResponse.kt index 93a190e4..42675719 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarResponse.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarResponse.kt @@ -3,12 +3,12 @@ package edu.ntnu.idi.idatt.backend.users.avatar.api /** * Response payload for the authenticated pupil's avatar. * - * @property bodyType selected body style key - * @property skinTone selected skin tone key - * @property outfitStyle selected outfit style key + * @property hatId selected hat asset name + * @property outfitId selected outfit asset name + * @property skinColorId selected skin color asset name */ data class UserAvatarResponse( - val bodyType: String, - val skinTone: String, - val outfitStyle: String, + val hatId: String, + val outfitId: String, + val skinColorId: String, ) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UpdateUserAvatarCommand.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UpdateUserAvatarCommand.kt index 5ebb0f79..afd059f2 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UpdateUserAvatarCommand.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UpdateUserAvatarCommand.kt @@ -3,12 +3,12 @@ package edu.ntnu.idi.idatt.backend.users.avatar.application /** * Full avatar update by pupil. * - * @property bodyType the selected body type - * @property skinTone the selected skin tone - * @property outfitStyle the selected outfit style + * @property hatId the selected hat asset name + * @property outfitId the selected outfit asset name + * @property skinColorId the selected skin color asset name */ class UpdateUserAvatarCommand( - val bodyType: String, - val skinTone: String, - val outfitStyle: String, -) \ No newline at end of file + val hatId: String, + val outfitId: String, + val skinColorId: String, +) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UserAvatarService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UserAvatarService.kt index cbeb19d5..2e3a58ff 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UserAvatarService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UserAvatarService.kt @@ -3,7 +3,10 @@ package edu.ntnu.idi.idatt.backend.users.avatar.application 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.security.CurrentUser +import edu.ntnu.idi.idatt.backend.users.avatar.api.UserAvatarResponse +import edu.ntnu.idi.idatt.backend.users.avatar.domain.AvatarAssetType import edu.ntnu.idi.idatt.backend.users.avatar.domain.UserAvatar +import edu.ntnu.idi.idatt.backend.users.avatar.infrastructure.AvatarAssetRepository import edu.ntnu.idi.idatt.backend.users.avatar.infrastructure.UserAvatarRepository import edu.ntnu.idi.idatt.backend.xp.application.XpService import edu.ntnu.idi.idatt.backend.xp.domain.XpEventCode @@ -22,6 +25,7 @@ import org.springframework.web.server.ResponseStatusException class UserAvatarService( private val currentUserResolver: CurrentUserResolver, private val userAvatarRepository: UserAvatarRepository, + private val avatarAssetRepository: AvatarAssetRepository, private val xpService: XpService, ) { /** @@ -31,14 +35,16 @@ class UserAvatarService( * @return current pupil avatar */ @Transactional(readOnly = true) - fun getUserAvatar(authenticatedSubject: String): UserAvatar { + fun getUserAvatar(authenticatedSubject: String): UserAvatarResponse { val user = currentUserResolver.requirePupil(authenticatedSubject) val userId = requireNotNull(user.id) { "Authenticated user is missing an id" } - return userAvatarRepository.findById(userId) + val userAvatar = userAvatarRepository.findById(userId) .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil avatar was not found") } + + return toResponse(userAvatar) } /** @@ -52,14 +58,14 @@ class UserAvatarService( fun updateUserAvatar( authenticatedSubject: String, command: UpdateUserAvatarCommand, - ): UserAvatar { + ): UserAvatarResponse { val user = currentUserResolver.requirePupil(authenticatedSubject) val userAvatar = findUserAvatar(user) userAvatar.update( - bodyType = command.bodyType, - skinTone = command.skinTone, - outfitStyle = command.outfitStyle, + hatId = resolveAssetId(command.hatId, AvatarAssetType.HAT), + outfitId = resolveAssetId(command.outfitId, AvatarAssetType.OUTFIT), + skinColorId = resolveAssetId(command.skinColorId, AvatarAssetType.SKIN_COLOR), ) xpService.awardXpEvent( @@ -72,7 +78,7 @@ class UserAvatarService( XpEventCode.UPDATE_AVATAR, ) - return userAvatarRepository.save(userAvatar) + return toResponse(userAvatarRepository.save(userAvatar)) } private fun findUserAvatar(user: User): UserAvatar { @@ -82,4 +88,34 @@ class UserAvatarService( ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil avatar was not found") } } + + private fun toResponse(userAvatar: UserAvatar): UserAvatarResponse = + UserAvatarResponse( + hatId = resolveAssetName(userAvatar.hatId, AvatarAssetType.HAT), + outfitId = resolveAssetName(userAvatar.outfitId, AvatarAssetType.OUTFIT), + skinColorId = resolveAssetName(userAvatar.skinColorId, AvatarAssetType.SKIN_COLOR), + ) + + private fun resolveAssetId(name: String, type: AvatarAssetType): Long = + avatarAssetRepository.findByTypeAndName(type, name) + .map { requireNotNull(it.id) { "Avatar asset is missing an id" } } + .orElseThrow { + ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown avatar asset '$name' for type $type") + } + + private fun resolveAssetName(id: Long, type: AvatarAssetType): String { + val asset = avatarAssetRepository.findById(id) + .orElseThrow { + ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Avatar asset $id was not found") + } + + if (asset.type != type) { + throw ResponseStatusException( + HttpStatus.INTERNAL_SERVER_ERROR, + "Avatar asset $id had unexpected type ${asset.type}", + ) + } + + return asset.name + } } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/domain/AvatarAsset.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/domain/AvatarAsset.kt new file mode 100644 index 00000000..830b7530 --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/domain/AvatarAsset.kt @@ -0,0 +1,33 @@ +package edu.ntnu.idi.idatt.backend.users.avatar.domain + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table + +/** + * Unlockable avatar asset that can be equipped by pupils. + * + * @property id persistent asset id + * @property name asset key or display-safe identifier + * @property type asset category + * @property unlocksAtLevel minimum level required to unlock the asset + */ +@Entity +@Table(name = "avatar_asset") +class AvatarAsset( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null, + @Column(name = "name", nullable = false, length = 100) + var name: String, + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false, length = 20) + var type: AvatarAssetType, + @Column(name = "unlocks_at_level", nullable = false) + var unlocksAtLevel: Int = 0, +) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/domain/AvatarAssetType.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/domain/AvatarAssetType.kt new file mode 100644 index 00000000..6e11f100 --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/domain/AvatarAssetType.kt @@ -0,0 +1,10 @@ +package edu.ntnu.idi.idatt.backend.users.avatar.domain + +/** + * Supported avatar asset categories. + */ +enum class AvatarAssetType { + HAT, + OUTFIT, + SKIN_COLOR, +} diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/domain/UserAvatar.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/domain/UserAvatar.kt index 352fca71..3736398b 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/domain/UserAvatar.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/domain/UserAvatar.kt @@ -15,12 +15,12 @@ import jakarta.persistence.Table * * @property userId shared primary key matching the owning pupil user id * @property user owning pupil user account - * @property bodyType selected body style key - * @property skinTone selected skin tone key - * @property outfitStyle selected outfit style key + * @property hatId selected hat asset id + * @property outfitId selected outfit asset id + * @property skinColorId selected skin color asset id */ @Entity -@Table(name = "avatars") +@Table(name = "user_avatar") class UserAvatar( @Id @Column(name = "user_id") @@ -29,34 +29,34 @@ class UserAvatar( @OneToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "user_id", nullable = false) var user: User, - @Column(name = "body_type", nullable = false, length = 50) - var bodyType: String = DEFAULT_BODY_TYPE, - @Column(name = "skin_tone", nullable = false) - var skinTone: String = DEFAULT_SKIN_TONE, - @Column(name = "outfit_style", nullable = false, length = 50) - var outfitStyle: String = DEFAULT_OUTFIT_STYLE, + @Column(name = "hat_id", nullable = false) + var hatId: Long = DEFAULT_HAT_ID, + @Column(name = "outfit_id", nullable = false) + var outfitId: Long = DEFAULT_OUTFIT_ID, + @Column(name = "skin_color_id", nullable = false) + var skinColorId: Long = DEFAULT_SKIN_COLOR_ID, ) { /** * Applies a full avatar update to the entity * - * @param skinTone what skin tone is chosen - * @param bodyType what body type is chosen - * @param outfitStyle what outfit style is chosen + * @param hatId selected hat asset id + * @param outfitId selected outfit asset id + * @param skinColorId selected skin color asset id */ fun update( - bodyType: String, - skinTone: String, - outfitStyle: String, + hatId: Long, + outfitId: Long, + skinColorId: Long, ) { - this.bodyType = bodyType - this.skinTone = skinTone - this.outfitStyle = outfitStyle + this.hatId = hatId + this.outfitId = outfitId + this.skinColorId = skinColorId } companion object { - const val DEFAULT_BODY_TYPE = "detective-default" - const val DEFAULT_SKIN_TONE = "light" - const val DEFAULT_OUTFIT_STYLE = "starter" + const val DEFAULT_HAT_ID = 1L + const val DEFAULT_OUTFIT_ID = 2L + const val DEFAULT_SKIN_COLOR_ID = 3L /** * Builds the default avatar for a newly created pupil. @@ -67,9 +67,9 @@ class UserAvatar( fun defaultsFor(user: User): UserAvatar = UserAvatar( user = user, - bodyType = DEFAULT_BODY_TYPE, - skinTone = DEFAULT_SKIN_TONE, - outfitStyle = DEFAULT_OUTFIT_STYLE, + hatId = DEFAULT_HAT_ID, + outfitId = DEFAULT_OUTFIT_ID, + skinColorId = DEFAULT_SKIN_COLOR_ID, ) } } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/infrastructure/AvatarAssetRepository.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/infrastructure/AvatarAssetRepository.kt new file mode 100644 index 00000000..2598c2e7 --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/infrastructure/AvatarAssetRepository.kt @@ -0,0 +1,13 @@ +package edu.ntnu.idi.idatt.backend.users.avatar.infrastructure + +import edu.ntnu.idi.idatt.backend.users.avatar.domain.AvatarAsset +import edu.ntnu.idi.idatt.backend.users.avatar.domain.AvatarAssetType +import org.springframework.data.jpa.repository.JpaRepository +import java.util.Optional + +/** + * Repository for avatar asset persistence. + */ +interface AvatarAssetRepository : JpaRepository { + fun findByTypeAndName(type: AvatarAssetType, name: String): Optional +} diff --git a/src/main/resources/db/migration/V22__split_avatars_into_user_avatar_and_avatar_asset.sql b/src/main/resources/db/migration/V22__split_avatars_into_user_avatar_and_avatar_asset.sql new file mode 100644 index 00000000..b60ab6e0 --- /dev/null +++ b/src/main/resources/db/migration/V22__split_avatars_into_user_avatar_and_avatar_asset.sql @@ -0,0 +1,65 @@ +CREATE TABLE avatar_asset ( + id BIGINT NOT NULL AUTO_INCREMENT, + name VARCHAR(100) NOT NULL, + type VARCHAR(20) NOT NULL, + unlocks_at_level INT NOT NULL DEFAULT 0, + CONSTRAINT pk_avatar_asset PRIMARY KEY (id), + CONSTRAINT uq_avatar_asset_type_name UNIQUE (type, name), + CONSTRAINT chk_avatar_asset_type CHECK (type IN ('HAT', 'OUTFIT', 'SKIN_COLOR')) +); + +INSERT INTO avatar_asset (id, name, type, unlocks_at_level) +VALUES + (1, 'detective-default', 'HAT', 0), + (2, 'starter', 'OUTFIT', 0), + (3, 'light', 'SKIN_COLOR', 0); + +INSERT INTO avatar_asset (name, type, unlocks_at_level) +SELECT DISTINCT body_type, 'HAT', 0 +FROM avatars +WHERE body_type IS NOT NULL + AND body_type <> 'detective-default'; + +INSERT INTO avatar_asset (name, type, unlocks_at_level) +SELECT DISTINCT outfit_style, 'OUTFIT', 0 +FROM avatars +WHERE outfit_style IS NOT NULL + AND outfit_style <> 'starter'; + +INSERT INTO avatar_asset (name, type, unlocks_at_level) +SELECT DISTINCT skin_tone, 'SKIN_COLOR', 0 +FROM avatars +WHERE skin_tone IS NOT NULL + AND skin_tone <> 'light'; + +CREATE TABLE user_avatar ( + user_id BIGINT NOT NULL, + hat_id BIGINT NOT NULL, + outfit_id BIGINT NOT NULL, + skin_color_id BIGINT NOT NULL, + CONSTRAINT pk_user_avatar PRIMARY KEY (user_id), + CONSTRAINT fk_user_avatar_user + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT fk_user_avatar_hat + FOREIGN KEY (hat_id) REFERENCES avatar_asset (id), + CONSTRAINT fk_user_avatar_outfit + FOREIGN KEY (outfit_id) REFERENCES avatar_asset (id), + CONSTRAINT fk_user_avatar_skin_color + FOREIGN KEY (skin_color_id) REFERENCES avatar_asset (id) +); + +INSERT INTO user_avatar (user_id, hat_id, outfit_id, skin_color_id) +SELECT + legacy.user_id, + hat_asset.id, + outfit_asset.id, + skin_asset.id +FROM avatars legacy +JOIN avatar_asset hat_asset + ON hat_asset.type = 'HAT' AND hat_asset.name = legacy.body_type +JOIN avatar_asset outfit_asset + ON outfit_asset.type = 'OUTFIT' AND outfit_asset.name = legacy.outfit_style +JOIN avatar_asset skin_asset + ON skin_asset.type = 'SKIN_COLOR' AND skin_asset.name = legacy.skin_tone; + +DROP TABLE avatars; diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/AuthControllerIntegrationTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/AuthControllerIntegrationTests.kt index 92dea24c..113adfed 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/AuthControllerIntegrationTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/iam/AuthControllerIntegrationTests.kt @@ -6,6 +6,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.iam.infrastructure.RefreshTokenRepository 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.infrastructure.TeacherProfileRepository import jakarta.servlet.http.Cookie @@ -235,9 +236,9 @@ class AuthControllerIntegrationTests { assertEquals(64, persistedToken.tokenHash.length) val persistedAvatar = userAvatarRepository.findById(requireNotNull(persistedUser.id)) .orElseThrow { AssertionError("Expected default avatar row for pupil") } - assertEquals("detective-default", persistedAvatar.bodyType) - assertEquals("light", persistedAvatar.skinTone) - assertEquals("starter", persistedAvatar.outfitStyle) + assertEquals(UserAvatar.DEFAULT_HAT_ID, persistedAvatar.hatId) + assertEquals(UserAvatar.DEFAULT_OUTFIT_ID, persistedAvatar.outfitId) + assertEquals(UserAvatar.DEFAULT_SKIN_COLOR_ID, persistedAvatar.skinColorId) } @Test diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarControllerIntegrationTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarControllerIntegrationTests.kt index de710375..1771bc49 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarControllerIntegrationTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarControllerIntegrationTests.kt @@ -67,9 +67,9 @@ class UserAvatarControllerIntegrationTests { userAvatarRepository.save( UserAvatar( user = pupil, - bodyType = "detective-default", - skinTone = "light", - outfitStyle = "starter", + hatId = 1L, + outfitId = 2L, + skinColorId = 3L, ), ) val accessToken = pupilAccessToken("ada", "secret123") @@ -82,9 +82,9 @@ class UserAvatarControllerIntegrationTests { }.andReturn().response.contentAsString, ) - assertEquals("detective-default", body.get("bodyType").asText()) - assertEquals("light", body.get("skinTone").asText()) - assertEquals("starter", body.get("outfitStyle").asText()) + assertEquals("detective-default", body.get("hatId").asText()) + assertEquals("starter", body.get("outfitId").asText()) + assertEquals("light", body.get("skinColorId").asText()) } @Test @@ -124,9 +124,9 @@ class UserAvatarControllerIntegrationTests { val persistedAvatar = userAvatarRepository.findById(requireNotNull(persistedUser.id)) .orElseThrow { AssertionError("Expected default avatar row for pupil") } - assertEquals(UserAvatar.DEFAULT_BODY_TYPE, persistedAvatar.bodyType) - assertEquals(UserAvatar.DEFAULT_SKIN_TONE, persistedAvatar.skinTone) - assertEquals(UserAvatar.DEFAULT_OUTFIT_STYLE, persistedAvatar.outfitStyle) + assertEquals(UserAvatar.DEFAULT_HAT_ID, persistedAvatar.hatId) + assertEquals(UserAvatar.DEFAULT_OUTFIT_ID, persistedAvatar.outfitId) + assertEquals(UserAvatar.DEFAULT_SKIN_COLOR_ID, persistedAvatar.skinColorId) val avatarBody = readJson( mockMvc.get("/api/v1/users/me/avatar") { @@ -136,9 +136,9 @@ class UserAvatarControllerIntegrationTests { }.andReturn().response.contentAsString, ) - assertEquals(UserAvatar.DEFAULT_BODY_TYPE, avatarBody.get("bodyType").asText()) - assertEquals(UserAvatar.DEFAULT_SKIN_TONE, avatarBody.get("skinTone").asText()) - assertEquals(UserAvatar.DEFAULT_OUTFIT_STYLE, avatarBody.get("outfitStyle").asText()) + assertEquals("detective-default", avatarBody.get("hatId").asText()) + assertEquals("starter", avatarBody.get("outfitId").asText()) + assertEquals("light", avatarBody.get("skinColorId").asText()) } private fun readJson(content: String): JsonNode = jsonMapper.readTree(content) diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarServiceTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarServiceTests.kt index 68a2a4b3..29c82287 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarServiceTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarServiceTests.kt @@ -4,12 +4,14 @@ import edu.ntnu.idi.idatt.backend.iam.application.CurrentUserResolver import edu.ntnu.idi.idatt.backend.iam.domain.User import edu.ntnu.idi.idatt.backend.iam.domain.UserRole import edu.ntnu.idi.idatt.backend.iam.domain.UserStatus +import edu.ntnu.idi.idatt.backend.users.avatar.domain.AvatarAsset +import edu.ntnu.idi.idatt.backend.users.avatar.domain.AvatarAssetType import edu.ntnu.idi.idatt.backend.xp.application.XpService import edu.ntnu.idi.idatt.backend.users.avatar.application.UserAvatarService import edu.ntnu.idi.idatt.backend.users.avatar.domain.UserAvatar +import edu.ntnu.idi.idatt.backend.users.avatar.infrastructure.AvatarAssetRepository import edu.ntnu.idi.idatt.backend.users.avatar.infrastructure.UserAvatarRepository import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertSame import org.junit.jupiter.api.Test import org.mockito.Mockito import org.springframework.http.HttpStatus @@ -21,32 +23,45 @@ class UserAvatarServiceTests { fun `get user avatar returns persisted avatar for authenticated pupil`() { val currentUserResolver = Mockito.mock(CurrentUserResolver::class.java) val userAvatarRepository = Mockito.mock(UserAvatarRepository::class.java) + val avatarAssetRepository = Mockito.mock(AvatarAssetRepository::class.java) val xpService = Mockito.mock(XpService::class.java) - val service = UserAvatarService(currentUserResolver, userAvatarRepository, xpService) + val service = UserAvatarService(currentUserResolver, userAvatarRepository, avatarAssetRepository, xpService) val user = pupilUser(42L) val avatar = UserAvatar( user = user, - bodyType = "detective-default", - skinTone = "light", - outfitStyle = "starter", + hatId = 1L, + outfitId = 2L, + skinColorId = 3L, ).apply { userId = 42L } Mockito.`when`(currentUserResolver.requirePupil("42")).thenReturn(user) Mockito.`when`(userAvatarRepository.findById(42L)).thenReturn(Optional.of(avatar)) + Mockito.`when`(avatarAssetRepository.findById(1L)).thenReturn( + Optional.of(AvatarAsset(id = 1L, name = "detective-default", type = AvatarAssetType.HAT)), + ) + Mockito.`when`(avatarAssetRepository.findById(2L)).thenReturn( + Optional.of(AvatarAsset(id = 2L, name = "starter", type = AvatarAssetType.OUTFIT)), + ) + Mockito.`when`(avatarAssetRepository.findById(3L)).thenReturn( + Optional.of(AvatarAsset(id = 3L, name = "light", type = AvatarAssetType.SKIN_COLOR)), + ) val resolvedAvatar = service.getUserAvatar("42") - assertSame(avatar, resolvedAvatar) + assertEquals("detective-default", resolvedAvatar.hatId) + assertEquals("starter", resolvedAvatar.outfitId) + assertEquals("light", resolvedAvatar.skinColorId) } @Test fun `get user avatar rejects malformed authenticated subject`() { val currentUserResolver = Mockito.mock(CurrentUserResolver::class.java) val userAvatarRepository = Mockito.mock(UserAvatarRepository::class.java) + val avatarAssetRepository = Mockito.mock(AvatarAssetRepository::class.java) val xpService = Mockito.mock(XpService::class.java) - val service = UserAvatarService(currentUserResolver, userAvatarRepository, xpService) + val service = UserAvatarService(currentUserResolver, userAvatarRepository, avatarAssetRepository, xpService) val exception = ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid authenticated user subject") Mockito.`when`(currentUserResolver.requirePupil("not-a-number")).thenThrow(exception) @@ -62,8 +77,9 @@ class UserAvatarServiceTests { fun `get user avatar rejects unknown authenticated user`() { val currentUserResolver = Mockito.mock(CurrentUserResolver::class.java) val userAvatarRepository = Mockito.mock(UserAvatarRepository::class.java) + val avatarAssetRepository = Mockito.mock(AvatarAssetRepository::class.java) val xpService = Mockito.mock(XpService::class.java) - val service = UserAvatarService(currentUserResolver, userAvatarRepository, xpService) + val service = UserAvatarService(currentUserResolver, userAvatarRepository, avatarAssetRepository, xpService) val exception = ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authenticated user was not found") Mockito.`when`(currentUserResolver.requirePupil("42")).thenThrow(exception) @@ -79,8 +95,9 @@ class UserAvatarServiceTests { fun `get user avatar rejects teacher user`() { val currentUserResolver = Mockito.mock(CurrentUserResolver::class.java) val userAvatarRepository = Mockito.mock(UserAvatarRepository::class.java) + val avatarAssetRepository = Mockito.mock(AvatarAssetRepository::class.java) val xpService = Mockito.mock(XpService::class.java) - val service = UserAvatarService(currentUserResolver, userAvatarRepository, xpService) + val service = UserAvatarService(currentUserResolver, userAvatarRepository, avatarAssetRepository, xpService) val exception = ResponseStatusException(HttpStatus.FORBIDDEN, "Only pupils are allowed") Mockito.`when`(currentUserResolver.requirePupil("7")).thenThrow(exception) @@ -96,8 +113,9 @@ class UserAvatarServiceTests { fun `get user avatar rejects pupil without avatar row`() { val currentUserResolver = Mockito.mock(CurrentUserResolver::class.java) val userAvatarRepository = Mockito.mock(UserAvatarRepository::class.java) + val avatarAssetRepository = Mockito.mock(AvatarAssetRepository::class.java) val xpService = Mockito.mock(XpService::class.java) - val service = UserAvatarService(currentUserResolver, userAvatarRepository, xpService) + val service = UserAvatarService(currentUserResolver, userAvatarRepository, avatarAssetRepository, xpService) val user = pupilUser(42L) Mockito.`when`(currentUserResolver.requirePupil("42")).thenReturn(user) From 7843da8c8e1f535e27cef2a74b1f493536aee072 Mon Sep 17 00:00:00 2001 From: sindrelm Date: Mon, 27 Apr 2026 11:08:05 +0200 Subject: [PATCH 02/11] update migration Signed-off-by: sindrelm --- ...plit_avatars_into_user_avatar_and_avatar_asset.sql | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/main/resources/db/migration/V22__split_avatars_into_user_avatar_and_avatar_asset.sql b/src/main/resources/db/migration/V22__split_avatars_into_user_avatar_and_avatar_asset.sql index b60ab6e0..5c2d063d 100644 --- a/src/main/resources/db/migration/V22__split_avatars_into_user_avatar_and_avatar_asset.sql +++ b/src/main/resources/db/migration/V22__split_avatars_into_user_avatar_and_avatar_asset.sql @@ -12,25 +12,28 @@ INSERT INTO avatar_asset (id, name, type, unlocks_at_level) VALUES (1, 'detective-default', 'HAT', 0), (2, 'starter', 'OUTFIT', 0), - (3, 'light', 'SKIN_COLOR', 0); + (3, 'light', 'SKIN_COLOR', 0), + (4, 'wizard-hat', 'HAT', 0), + (5, 'standard2', 'OUTFIT', 0), + (6, 'dark', 'SKIN_COLOR', 0); INSERT INTO avatar_asset (name, type, unlocks_at_level) SELECT DISTINCT body_type, 'HAT', 0 FROM avatars WHERE body_type IS NOT NULL - AND body_type <> 'detective-default'; + AND body_type NOT IN ('detective-default', 'wizard-hat'); INSERT INTO avatar_asset (name, type, unlocks_at_level) SELECT DISTINCT outfit_style, 'OUTFIT', 0 FROM avatars WHERE outfit_style IS NOT NULL - AND outfit_style <> 'starter'; + AND outfit_style NOT IN ('starter', 'standard2'); INSERT INTO avatar_asset (name, type, unlocks_at_level) SELECT DISTINCT skin_tone, 'SKIN_COLOR', 0 FROM avatars WHERE skin_tone IS NOT NULL - AND skin_tone <> 'light'; + AND skin_tone NOT IN ('light', 'dark'); CREATE TABLE user_avatar ( user_id BIGINT NOT NULL, From ec97747db45be1276592ece2644ad5dc32e18f21 Mon Sep 17 00:00:00 2001 From: sindrelm Date: Mon, 27 Apr 2026 11:08:47 +0200 Subject: [PATCH 03/11] update migration Signed-off-by: sindrelm --- .../V22__split_avatars_into_user_avatar_and_avatar_asset.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/db/migration/V22__split_avatars_into_user_avatar_and_avatar_asset.sql b/src/main/resources/db/migration/V22__split_avatars_into_user_avatar_and_avatar_asset.sql index 5c2d063d..6140a9fb 100644 --- a/src/main/resources/db/migration/V22__split_avatars_into_user_avatar_and_avatar_asset.sql +++ b/src/main/resources/db/migration/V22__split_avatars_into_user_avatar_and_avatar_asset.sql @@ -13,7 +13,7 @@ VALUES (1, 'detective-default', 'HAT', 0), (2, 'starter', 'OUTFIT', 0), (3, 'light', 'SKIN_COLOR', 0), - (4, 'wizard-hat', 'HAT', 0), + (4, 'wizard-hat', 'HAT', 3), (5, 'standard2', 'OUTFIT', 0), (6, 'dark', 'SKIN_COLOR', 0); From 236571ef610b10bbb2e34b7f8e4b213d59722d12 Mon Sep 17 00:00:00 2001 From: sindrelm Date: Mon, 27 Apr 2026 11:25:36 +0200 Subject: [PATCH 04/11] Refactor avatar service to take currentUser args Signed-off-by: sindrelm --- .../users/avatar/api/UserAvatarController.kt | 4 ++-- .../avatar/application/UserAvatarService.kt | 21 +++++++------------ .../users/avatar/UserAvatarServiceTests.kt | 19 ++++++++++++----- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarController.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarController.kt index e9075177..1d5b1d9e 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarController.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarController.kt @@ -29,7 +29,7 @@ class UserAvatarController( @GetMapping fun getUserAvatar( @AuthenticationPrincipal currentUser: CurrentUser, - ): UserAvatarResponse = userAvatarService.getUserAvatar(currentUser.subject) + ): UserAvatarResponse = userAvatarService.getUserAvatar(currentUser) /** * Updates the current authenticated pupil's avatar with the submitted values. @@ -42,5 +42,5 @@ class UserAvatarController( fun updateUserAvatar( @AuthenticationPrincipal currentUser: CurrentUser, @Valid @RequestBody request: UpdateUserAvatarRequest, - ): UserAvatarResponse = userAvatarService.updateUserAvatar(currentUser.subject, request.toCommand()) + ): UserAvatarResponse = userAvatarService.updateUserAvatar(currentUser, request.toCommand()) } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UserAvatarService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UserAvatarService.kt index 5d897848..0c75386a 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UserAvatarService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UserAvatarService.kt @@ -27,17 +27,16 @@ class UserAvatarService( private val userAvatarRepository: UserAvatarRepository, private val avatarAssetRepository: AvatarAssetRepository, private val xpService: XpService, - private val ) { /** * Returns the current authenticated pupil's avatar. * - * @param authenticatedSubject JWT subject containing the persisted user id + * @param currentUser authenticated principal resolved from the JWT access token * @return current pupil avatar */ @Transactional(readOnly = true) - fun getUserAvatar(authenticatedSubject: String): UserAvatarResponse { - val user = currentUserResolver.requirePupil(authenticatedSubject) + fun getUserAvatar(currentUser: CurrentUser): UserAvatarResponse { + val user = currentUserResolver.requirePupil(currentUser.subject) val userId = requireNotNull(user.id) { "Authenticated user is missing an id" } val userAvatar = userAvatarRepository.findById(userId) @@ -51,19 +50,18 @@ class UserAvatarService( /** * Updates the current authenticated pupil's avatar with the submitted values. * - * @param authenticatedSubject JWT subject containing the persisted user id + * @param currentUser authenticated principal resolved from the JWT access token * @param command full avatar payload to persist * @return updated pupil avatar */ @Transactional fun updateUserAvatar( - authenticatedSubject: String, + currentUser: CurrentUser, command: UpdateUserAvatarCommand, ): UserAvatarResponse { - val user = currentUserResolver.requirePupil(authenticatedSubject) + val user = currentUserResolver.requirePupil(currentUser.subject) val userAvatar = findUserAvatar(user) - userAvatar.update( hatId = resolveAssetId(command.hatId, AvatarAssetType.HAT), outfitId = resolveAssetId(command.outfitId, AvatarAssetType.OUTFIT), @@ -71,12 +69,7 @@ class UserAvatarService( ) xpService.awardXpEvent( - CurrentUser( - subject = authenticatedSubject, - portalType = "pupil", - email = null, - authorities = setOf("ROLE_PUPIL"), - ), + currentUser, XpEventCode.UPDATE_AVATAR, ) diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarServiceTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarServiceTests.kt index 29c82287..93910ace 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarServiceTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarServiceTests.kt @@ -4,6 +4,7 @@ import edu.ntnu.idi.idatt.backend.iam.application.CurrentUserResolver import edu.ntnu.idi.idatt.backend.iam.domain.User import edu.ntnu.idi.idatt.backend.iam.domain.UserRole import edu.ntnu.idi.idatt.backend.iam.domain.UserStatus +import edu.ntnu.idi.idatt.backend.iam.security.CurrentUser import edu.ntnu.idi.idatt.backend.users.avatar.domain.AvatarAsset import edu.ntnu.idi.idatt.backend.users.avatar.domain.AvatarAssetType import edu.ntnu.idi.idatt.backend.xp.application.XpService @@ -48,7 +49,7 @@ class UserAvatarServiceTests { Optional.of(AvatarAsset(id = 3L, name = "light", type = AvatarAssetType.SKIN_COLOR)), ) - val resolvedAvatar = service.getUserAvatar("42") + val resolvedAvatar = service.getUserAvatar(currentPupilUser("42")) assertEquals("detective-default", resolvedAvatar.hatId) assertEquals("starter", resolvedAvatar.outfitId) @@ -67,7 +68,7 @@ class UserAvatarServiceTests { Mockito.`when`(currentUserResolver.requirePupil("not-a-number")).thenThrow(exception) val thrown = org.junit.jupiter.api.assertThrows { - service.getUserAvatar("not-a-number") + service.getUserAvatar(currentPupilUser("not-a-number")) } assertEquals(HttpStatus.UNAUTHORIZED, thrown.statusCode) @@ -85,7 +86,7 @@ class UserAvatarServiceTests { Mockito.`when`(currentUserResolver.requirePupil("42")).thenThrow(exception) val thrown = org.junit.jupiter.api.assertThrows { - service.getUserAvatar("42") + service.getUserAvatar(currentPupilUser("42")) } assertEquals(HttpStatus.UNAUTHORIZED, thrown.statusCode) @@ -103,7 +104,7 @@ class UserAvatarServiceTests { Mockito.`when`(currentUserResolver.requirePupil("7")).thenThrow(exception) val thrown = org.junit.jupiter.api.assertThrows { - service.getUserAvatar("7") + service.getUserAvatar(currentPupilUser("7")) } assertEquals(HttpStatus.FORBIDDEN, thrown.statusCode) @@ -122,12 +123,20 @@ class UserAvatarServiceTests { Mockito.`when`(userAvatarRepository.findById(42L)).thenReturn(Optional.empty()) val exception = org.junit.jupiter.api.assertThrows { - service.getUserAvatar("42") + service.getUserAvatar(currentPupilUser("42")) } assertEquals(HttpStatus.NOT_FOUND, exception.statusCode) } + private fun currentPupilUser(subject: String) = + CurrentUser( + subject = subject, + portalType = "pupil", + email = null, + authorities = setOf("ROLE_PUPIL"), + ) + private fun pupilUser(id: Long) = User( id = id, From 740a776036bcf0846414c490f70f9c66cf6bea2b Mon Sep 17 00:00:00 2001 From: sindrelm Date: Mon, 27 Apr 2026 11:32:13 +0200 Subject: [PATCH 05/11] Add getPupilCurrentLevel method Signed-off-by: sindrelm --- .../application/PupilGameProgressService.kt | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) 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 0349c946..e072745e 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 @@ -125,6 +125,27 @@ class PupilGameProgressService( ) } + /** + * Returns the current authenticated pupil's classroom-scoped level. + * + * The lookup is resolved from the authenticated pupil and that pupil's + * active classroom. If no progress row exists yet for that classroom, the + * service falls back to level `1`. + * + * @param currentUser authenticated pupil whose level should be resolved + * @return persisted classroom-scoped level, or `1` when no progress row exists yet + */ + @Transactional(readOnly = true) + fun getPupilCurrentLevel(currentUser: CurrentUser): Int { + val pupil = currentUserResolver.requirePupil(currentUser.subject) + val pupilId = requireNotNull(pupil.id) { "Authenticated pupil is missing an id" } + + val classroom = currentUserResolver.getClassroomByUser(currentUser) + val classroomId = requireNotNull(classroom.id) { "Active classroom is missing an id" } + + return pupilGameProgressRepository.findByPupilIdAndClassroomId(pupilId, classroomId)?.currentLevel ?: 1 + } + /** * Maps accumulated XP to a 1-based level. * From 4d5b6cb80735c1925f0919da7a86d59dbc53abf4 Mon Sep 17 00:00:00 2001 From: sindrelm Date: Mon, 27 Apr 2026 12:44:07 +0200 Subject: [PATCH 06/11] Make it so that one cannot use assets wich arent unlocked Signed-off-by: sindrelm --- .../avatar/application/UserAvatarService.kt | 44 ++++++++++++++++--- .../infrastructure/UserAvatarRepository.kt | 2 +- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UserAvatarService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UserAvatarService.kt index 0c75386a..ee903798 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UserAvatarService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UserAvatarService.kt @@ -8,6 +8,7 @@ import edu.ntnu.idi.idatt.backend.users.avatar.domain.AvatarAssetType import edu.ntnu.idi.idatt.backend.users.avatar.domain.UserAvatar import edu.ntnu.idi.idatt.backend.users.avatar.infrastructure.AvatarAssetRepository import edu.ntnu.idi.idatt.backend.users.avatar.infrastructure.UserAvatarRepository +import edu.ntnu.idi.idatt.backend.game.gameProgress.application.PupilGameProgressService import edu.ntnu.idi.idatt.backend.xp.application.XpService import edu.ntnu.idi.idatt.backend.xp.domain.XpEventCode import org.springframework.http.HttpStatus @@ -16,16 +17,18 @@ import org.springframework.transaction.annotation.Transactional import org.springframework.web.server.ResponseStatusException /** - * Handles lookup of the authenticated pupil's current avatar. + * Handles lookup and updates for the authenticated pupil's current avatar. * - * @property userRepository repository used to resolve the authenticated user * @property userAvatarRepository repository used to load the persisted avatar row + * @property avatarAssetRepository repository used to resolve avatar asset names and unlock rules + * @property pupilGameProgressService service used to resolve the pupil's current classroom-scoped level */ @Service class UserAvatarService( private val currentUserResolver: CurrentUserResolver, private val userAvatarRepository: UserAvatarRepository, private val avatarAssetRepository: AvatarAssetRepository, + private val pupilGameProgressService: PupilGameProgressService, private val xpService: XpService, ) { /** @@ -51,8 +54,10 @@ class UserAvatarService( * Updates the current authenticated pupil's avatar with the submitted values. * * @param currentUser authenticated principal resolved from the JWT access token - * @param command full avatar payload to persist + * @param command full avatar payload to persist, expressed as avatar asset names * @return updated pupil avatar + * @throws ResponseStatusException when an asset name is unknown or the pupil has not unlocked + * the requested asset for their current level */ @Transactional fun updateUserAvatar( @@ -62,10 +67,19 @@ class UserAvatarService( val user = currentUserResolver.requirePupil(currentUser.subject) val userAvatar = findUserAvatar(user) + val hatId = resolveAssetId(command.hatId, AvatarAssetType.HAT) + val outfitId = resolveAssetId(command.outfitId, AvatarAssetType.OUTFIT) + val skinColorId = resolveAssetId(command.skinColorId, AvatarAssetType.SKIN_COLOR) + + val currentLevel = pupilGameProgressService.getPupilCurrentLevel(currentUser) + assertAssetIsUnlocked(hatId, currentLevel) + assertAssetIsUnlocked(outfitId, currentLevel) + assertAssetIsUnlocked(skinColorId, currentLevel) + userAvatar.update( - hatId = resolveAssetId(command.hatId, AvatarAssetType.HAT), - outfitId = resolveAssetId(command.outfitId, AvatarAssetType.OUTFIT), - skinColorId = resolveAssetId(command.skinColorId, AvatarAssetType.SKIN_COLOR), + hatId = hatId, + outfitId = outfitId, + skinColorId = skinColorId, ) xpService.awardXpEvent( @@ -76,6 +90,24 @@ class UserAvatarService( return toResponse(userAvatarRepository.save(userAvatar)) } + /** + * Verifies that the requested avatar asset is unlocked for the supplied pupil level. + * + * @param assetId persistent avatar asset id to verify + * @param currentLevel pupil's current classroom-scoped level + * @throws ResponseStatusException when the asset does not exist or is still locked + */ + private fun assertAssetIsUnlocked(assetId: Long, currentLevel: Int) { + val asset = avatarAssetRepository.findById(assetId) + .orElseThrow { + ResponseStatusException(HttpStatus.BAD_REQUEST, "Avatar asset $assetId was not found") + } + + if (asset.unlocksAtLevel > currentLevel) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Current level is not high enough to use the avatar asset") + } + } + private fun findUserAvatar(user: User): UserAvatar { val userId = requireNotNull(user.id) { "Authenticated user is missing an id" } return userAvatarRepository.findById(userId) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/infrastructure/UserAvatarRepository.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/infrastructure/UserAvatarRepository.kt index ae7cfcfc..82f7d8c6 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/infrastructure/UserAvatarRepository.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/infrastructure/UserAvatarRepository.kt @@ -6,4 +6,4 @@ import org.springframework.data.jpa.repository.JpaRepository /** * Repository for current pupil avatar persistence. */ -interface UserAvatarRepository : JpaRepository +interface UserAvatarRepository : JpaRepository \ No newline at end of file From 4a9d957ce44d5814175fab55b5ef8a9814b8aad7 Mon Sep 17 00:00:00 2001 From: sindrelm Date: Mon, 27 Apr 2026 12:54:17 +0200 Subject: [PATCH 07/11] Add endpoint for fetching assets Signed-off-by: sindrelm --- .../application/PupilGameProgressService.kt | 13 +++-- .../users/avatar/api/UserAvatarApiMappings.kt | 14 +++++ .../avatar/api/UserAvatarAssetsResponse.kt | 29 ++++++++++ .../users/avatar/api/UserAvatarController.kt | 11 ++++ .../avatar/application/UserAvatarService.kt | 29 ++++++++++ .../infrastructure/AvatarAssetRepository.kt | 2 + .../UserAvatarControllerIntegrationTests.kt | 53 +++++++++++++++++++ 7 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarAssetsResponse.kt 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 e072745e..0881d792 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 @@ -129,8 +129,9 @@ class PupilGameProgressService( * Returns the current authenticated pupil's classroom-scoped level. * * The lookup is resolved from the authenticated pupil and that pupil's - * active classroom. If no progress row exists yet for that classroom, the - * service falls back to level `1`. + * active classroom. If the pupil has no classroom context yet or no + * progress row exists for the active classroom, the service falls back to + * level `1`. * * @param currentUser authenticated pupil whose level should be resolved * @return persisted classroom-scoped level, or `1` when no progress row exists yet @@ -140,8 +141,12 @@ class PupilGameProgressService( val pupil = currentUserResolver.requirePupil(currentUser.subject) val pupilId = requireNotNull(pupil.id) { "Authenticated pupil is missing an id" } - val classroom = currentUserResolver.getClassroomByUser(currentUser) - val classroomId = requireNotNull(classroom.id) { "Active classroom is missing an id" } + val classroomId = try { + val classroom = currentUserResolver.getClassroomByUser(currentUser) + requireNotNull(classroom.id) { "Active classroom is missing an id" } + } catch (_: ResponseStatusException) { + return 1 + } return pupilGameProgressRepository.findByPupilIdAndClassroomId(pupilId, classroomId)?.currentLevel ?: 1 } diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarApiMappings.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarApiMappings.kt index e700aa17..48ef0e48 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarApiMappings.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarApiMappings.kt @@ -1,6 +1,7 @@ package edu.ntnu.idi.idatt.backend.users.avatar.api import edu.ntnu.idi.idatt.backend.users.avatar.application.UpdateUserAvatarCommand +import edu.ntnu.idi.idatt.backend.users.avatar.domain.AvatarAsset /** * Converts the update request payload into an application command. @@ -13,3 +14,16 @@ fun UpdateUserAvatarRequest.toCommand(): UpdateUserAvatarCommand = outfitId = outfitId, skinColorId = skinColorId ) + +/** + * Converts one persisted avatar asset into a frontend-ready option response. + * + * @param currentLevel pupil level used to derive whether the asset is unlocked + * @return response exposed by the avatar assets API + */ +fun AvatarAsset.toAvatarAssetOptionResponse(currentLevel: Int): AvatarAssetOptionResponse = + AvatarAssetOptionResponse( + id = name, + unlocksAtLevel = unlocksAtLevel, + unlocked = unlocksAtLevel <= currentLevel, + ) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarAssetsResponse.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarAssetsResponse.kt new file mode 100644 index 00000000..50b96958 --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarAssetsResponse.kt @@ -0,0 +1,29 @@ +package edu.ntnu.idi.idatt.backend.users.avatar.api + +/** + * Response payload describing all avatar asset options available to the authenticated pupil. + * + * @property currentLevel pupil's current classroom-scoped level, or the default starting level + * @property hats available hat assets + * @property outfits available outfit assets + * @property skinColors available skin color assets + */ +data class UserAvatarAssetsResponse( + val currentLevel: Int, + val hats: List, + val outfits: List, + val skinColors: List, +) + +/** + * Response payload for one selectable avatar asset option. + * + * @property id stable asset name used by the avatar API + * @property unlocksAtLevel minimum level required to equip the asset + * @property unlocked whether the authenticated pupil can currently equip the asset + */ +data class AvatarAssetOptionResponse( + val id: String, + val unlocksAtLevel: Int, + val unlocked: Boolean, +) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarController.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarController.kt index 1d5b1d9e..cc9858eb 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarController.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/api/UserAvatarController.kt @@ -31,6 +31,17 @@ class UserAvatarController( @AuthenticationPrincipal currentUser: CurrentUser, ): UserAvatarResponse = userAvatarService.getUserAvatar(currentUser) + /** + * Returns all avatar assets available to the current authenticated pupil. + * + * @param currentUser authenticated principal resolved from the JWT access token + * @return grouped avatar asset catalog including unlock state for the pupil + */ + @GetMapping("/assets") + fun getUserAvatarAssets( + @AuthenticationPrincipal currentUser: CurrentUser, + ): UserAvatarAssetsResponse = userAvatarService.getUserAvatarAssets(currentUser) + /** * Updates the current authenticated pupil's avatar with the submitted values. * diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UserAvatarService.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UserAvatarService.kt index ee903798..ac94270c 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UserAvatarService.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/application/UserAvatarService.kt @@ -3,7 +3,9 @@ package edu.ntnu.idi.idatt.backend.users.avatar.application 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.security.CurrentUser +import edu.ntnu.idi.idatt.backend.users.avatar.api.UserAvatarAssetsResponse import edu.ntnu.idi.idatt.backend.users.avatar.api.UserAvatarResponse +import edu.ntnu.idi.idatt.backend.users.avatar.api.toAvatarAssetOptionResponse import edu.ntnu.idi.idatt.backend.users.avatar.domain.AvatarAssetType import edu.ntnu.idi.idatt.backend.users.avatar.domain.UserAvatar import edu.ntnu.idi.idatt.backend.users.avatar.infrastructure.AvatarAssetRepository @@ -50,6 +52,33 @@ class UserAvatarService( return toResponse(userAvatar) } + /** + * Returns all selectable avatar assets for the authenticated pupil together with + * their unlock requirements and current availability. + * + * @param currentUser authenticated principal resolved from the JWT access token + * @return grouped avatar asset catalog for the current pupil + */ + @Transactional(readOnly = true) + fun getUserAvatarAssets(currentUser: CurrentUser): UserAvatarAssetsResponse { + currentUserResolver.requirePupil(currentUser.subject) + val currentLevel = pupilGameProgressService.getPupilCurrentLevel(currentUser) + val assets = avatarAssetRepository.findAllByOrderByTypeAscUnlocksAtLevelAscNameAsc() + + return UserAvatarAssetsResponse( + currentLevel = currentLevel, + hats = assets + .filter { it.type == AvatarAssetType.HAT } + .map { it.toAvatarAssetOptionResponse(currentLevel) }, + outfits = assets + .filter { it.type == AvatarAssetType.OUTFIT } + .map { it.toAvatarAssetOptionResponse(currentLevel) }, + skinColors = assets + .filter { it.type == AvatarAssetType.SKIN_COLOR } + .map { it.toAvatarAssetOptionResponse(currentLevel) }, + ) + } + /** * Updates the current authenticated pupil's avatar with the submitted values. * diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/infrastructure/AvatarAssetRepository.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/infrastructure/AvatarAssetRepository.kt index 2598c2e7..d412ec8b 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/infrastructure/AvatarAssetRepository.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/infrastructure/AvatarAssetRepository.kt @@ -10,4 +10,6 @@ import java.util.Optional */ interface AvatarAssetRepository : JpaRepository { fun findByTypeAndName(type: AvatarAssetType, name: String): Optional + + fun findAllByOrderByTypeAscUnlocksAtLevelAscNameAsc(): List } diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarControllerIntegrationTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarControllerIntegrationTests.kt index 1771bc49..6de01774 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarControllerIntegrationTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarControllerIntegrationTests.kt @@ -7,7 +7,9 @@ import edu.ntnu.idi.idatt.backend.iam.domain.UserRole import edu.ntnu.idi.idatt.backend.iam.domain.UserStatus import edu.ntnu.idi.idatt.backend.iam.infrastructure.RefreshTokenRepository import edu.ntnu.idi.idatt.backend.iam.infrastructure.UserRepository +import edu.ntnu.idi.idatt.backend.users.avatar.domain.AvatarAssetType import edu.ntnu.idi.idatt.backend.users.avatar.domain.UserAvatar +import edu.ntnu.idi.idatt.backend.users.avatar.infrastructure.AvatarAssetRepository import edu.ntnu.idi.idatt.backend.users.avatar.infrastructure.UserAvatarRepository import edu.ntnu.idi.idatt.backend.users.profile.infrastructure.PupilProfileRepository import edu.ntnu.idi.idatt.backend.users.profile.infrastructure.TeacherProfileRepository @@ -38,6 +40,9 @@ class UserAvatarControllerIntegrationTests { @Autowired private lateinit var userAvatarRepository: UserAvatarRepository + @Autowired + private lateinit var avatarAssetRepository: AvatarAssetRepository + @Autowired private lateinit var pupilProfileRepository: PupilProfileRepository @@ -59,6 +64,7 @@ class UserAvatarControllerIntegrationTests { pupilProfileRepository.deleteAll() teacherProfileRepository.deleteAll() userRepository.deleteAll() + resetAvatarAssetUnlockLevels() } @Test @@ -141,8 +147,55 @@ class UserAvatarControllerIntegrationTests { assertEquals("light", avatarBody.get("skinColorId").asText()) } + @Test + fun `authenticated pupil gets avatar assets with unlock state`() { + val wizardHat = avatarAssetRepository.findByTypeAndName(AvatarAssetType.HAT, "wizard-hat") + .orElseThrow { AssertionError("Expected wizard-hat avatar asset") } + wizardHat.unlocksAtLevel = 5 + avatarAssetRepository.save(wizardHat) + + val pupil = userRepository.save(activePupil("asset-pupil")) + userAvatarRepository.save(UserAvatar.defaultsFor(pupil)) + val accessToken = pupilAccessToken("asset-pupil", "secret123") + + val body = readJson( + mockMvc.get("/api/v1/users/me/avatar/assets") { + header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken") + }.andExpect { + status { isOk() } + }.andReturn().response.contentAsString, + ) + + assertEquals(1, body.get("currentLevel").asInt()) + assertEquals(true, body.get("hats").isArray) + assertEquals(true, body.get("outfits").isArray) + assertEquals(true, body.get("skinColors").isArray) + + val wizardHatNode = body.get("hats").firstOrNull { it.get("id").asText() == "wizard-hat" } + ?: throw AssertionError("Expected wizard-hat option in hats response") + + assertEquals(5, wizardHatNode.get("unlocksAtLevel").asInt()) + assertEquals(false, wizardHatNode.get("unlocked").asBoolean()) + } + private fun readJson(content: String): JsonNode = jsonMapper.readTree(content) + private fun resetAvatarAssetUnlockLevels() { + listOf( + AvatarAssetType.HAT to "detective-default", + AvatarAssetType.HAT to "wizard-hat", + AvatarAssetType.OUTFIT to "starter", + AvatarAssetType.OUTFIT to "standard2", + AvatarAssetType.SKIN_COLOR to "light", + AvatarAssetType.SKIN_COLOR to "dark", + ).forEach { (type, name) -> + val asset = avatarAssetRepository.findByTypeAndName(type, name) + .orElseThrow { AssertionError("Expected avatar asset $name for type $type") } + asset.unlocksAtLevel = 0 + avatarAssetRepository.save(asset) + } + } + private fun pupilAccessToken(username: String, password: String): String = readJson( mockMvc.post("/api/v1/auth/pupil/login") { From 6bccf92d19b01d5c0d014e247f6420719ce1e99e Mon Sep 17 00:00:00 2001 From: sindrelm Date: Mon, 27 Apr 2026 13:16:36 +0200 Subject: [PATCH 08/11] Add eilif asset Signed-off-by: sindrelm --- .../V22__split_avatars_into_user_avatar_and_avatar_asset.sql | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/resources/db/migration/V22__split_avatars_into_user_avatar_and_avatar_asset.sql b/src/main/resources/db/migration/V22__split_avatars_into_user_avatar_and_avatar_asset.sql index 6140a9fb..ad67c55a 100644 --- a/src/main/resources/db/migration/V22__split_avatars_into_user_avatar_and_avatar_asset.sql +++ b/src/main/resources/db/migration/V22__split_avatars_into_user_avatar_and_avatar_asset.sql @@ -15,7 +15,8 @@ VALUES (3, 'light', 'SKIN_COLOR', 0), (4, 'wizard-hat', 'HAT', 3), (5, 'standard2', 'OUTFIT', 0), - (6, 'dark', 'SKIN_COLOR', 0); + (6, 'dark', 'SKIN_COLOR', 0), + (6, 'eilif', 'SKIN_COLOR', 4); INSERT INTO avatar_asset (name, type, unlocks_at_level) SELECT DISTINCT body_type, 'HAT', 0 From e0319174f246517a68e1c7593c5a1e1c96c486be Mon Sep 17 00:00:00 2001 From: sindrelm Date: Mon, 27 Apr 2026 13:33:04 +0200 Subject: [PATCH 09/11] Add eilif to db Signed-off-by: sindrelm --- .../V22__split_avatars_into_user_avatar_and_avatar_asset.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/db/migration/V22__split_avatars_into_user_avatar_and_avatar_asset.sql b/src/main/resources/db/migration/V22__split_avatars_into_user_avatar_and_avatar_asset.sql index ad67c55a..b3b9e949 100644 --- a/src/main/resources/db/migration/V22__split_avatars_into_user_avatar_and_avatar_asset.sql +++ b/src/main/resources/db/migration/V22__split_avatars_into_user_avatar_and_avatar_asset.sql @@ -16,7 +16,7 @@ VALUES (4, 'wizard-hat', 'HAT', 3), (5, 'standard2', 'OUTFIT', 0), (6, 'dark', 'SKIN_COLOR', 0), - (6, 'eilif', 'SKIN_COLOR', 4); + (7, 'eilif', 'SKIN_COLOR', 4); INSERT INTO avatar_asset (name, type, unlocks_at_level) SELECT DISTINCT body_type, 'HAT', 0 From 1af247cfab5a8f077345648cc9015492218c3efa Mon Sep 17 00:00:00 2001 From: sindrelm Date: Mon, 27 Apr 2026 13:42:36 +0200 Subject: [PATCH 10/11] Fix tests Signed-off-by: sindrelm --- .../users/avatar/UserAvatarServiceTests.kt | 46 +++++++++++++++++-- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarServiceTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarServiceTests.kt index 93910ace..b6967226 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarServiceTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/users/avatar/UserAvatarServiceTests.kt @@ -5,6 +5,7 @@ import edu.ntnu.idi.idatt.backend.iam.domain.User import edu.ntnu.idi.idatt.backend.iam.domain.UserRole import edu.ntnu.idi.idatt.backend.iam.domain.UserStatus import edu.ntnu.idi.idatt.backend.iam.security.CurrentUser +import edu.ntnu.idi.idatt.backend.game.gameProgress.application.PupilGameProgressService import edu.ntnu.idi.idatt.backend.users.avatar.domain.AvatarAsset import edu.ntnu.idi.idatt.backend.users.avatar.domain.AvatarAssetType import edu.ntnu.idi.idatt.backend.xp.application.XpService @@ -25,8 +26,15 @@ class UserAvatarServiceTests { val currentUserResolver = Mockito.mock(CurrentUserResolver::class.java) val userAvatarRepository = Mockito.mock(UserAvatarRepository::class.java) val avatarAssetRepository = Mockito.mock(AvatarAssetRepository::class.java) + val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) - val service = UserAvatarService(currentUserResolver, userAvatarRepository, avatarAssetRepository, xpService) + val service = UserAvatarService( + currentUserResolver, + userAvatarRepository, + avatarAssetRepository, + pupilGameProgressService, + xpService, + ) val user = pupilUser(42L) val avatar = UserAvatar( user = user, @@ -61,8 +69,15 @@ class UserAvatarServiceTests { val currentUserResolver = Mockito.mock(CurrentUserResolver::class.java) val userAvatarRepository = Mockito.mock(UserAvatarRepository::class.java) val avatarAssetRepository = Mockito.mock(AvatarAssetRepository::class.java) + val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) - val service = UserAvatarService(currentUserResolver, userAvatarRepository, avatarAssetRepository, xpService) + val service = UserAvatarService( + currentUserResolver, + userAvatarRepository, + avatarAssetRepository, + pupilGameProgressService, + xpService, + ) val exception = ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid authenticated user subject") Mockito.`when`(currentUserResolver.requirePupil("not-a-number")).thenThrow(exception) @@ -79,8 +94,15 @@ class UserAvatarServiceTests { val currentUserResolver = Mockito.mock(CurrentUserResolver::class.java) val userAvatarRepository = Mockito.mock(UserAvatarRepository::class.java) val avatarAssetRepository = Mockito.mock(AvatarAssetRepository::class.java) + val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) - val service = UserAvatarService(currentUserResolver, userAvatarRepository, avatarAssetRepository, xpService) + val service = UserAvatarService( + currentUserResolver, + userAvatarRepository, + avatarAssetRepository, + pupilGameProgressService, + xpService, + ) val exception = ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authenticated user was not found") Mockito.`when`(currentUserResolver.requirePupil("42")).thenThrow(exception) @@ -97,8 +119,15 @@ class UserAvatarServiceTests { val currentUserResolver = Mockito.mock(CurrentUserResolver::class.java) val userAvatarRepository = Mockito.mock(UserAvatarRepository::class.java) val avatarAssetRepository = Mockito.mock(AvatarAssetRepository::class.java) + val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) - val service = UserAvatarService(currentUserResolver, userAvatarRepository, avatarAssetRepository, xpService) + val service = UserAvatarService( + currentUserResolver, + userAvatarRepository, + avatarAssetRepository, + pupilGameProgressService, + xpService, + ) val exception = ResponseStatusException(HttpStatus.FORBIDDEN, "Only pupils are allowed") Mockito.`when`(currentUserResolver.requirePupil("7")).thenThrow(exception) @@ -115,8 +144,15 @@ class UserAvatarServiceTests { val currentUserResolver = Mockito.mock(CurrentUserResolver::class.java) val userAvatarRepository = Mockito.mock(UserAvatarRepository::class.java) val avatarAssetRepository = Mockito.mock(AvatarAssetRepository::class.java) + val pupilGameProgressService = Mockito.mock(PupilGameProgressService::class.java) val xpService = Mockito.mock(XpService::class.java) - val service = UserAvatarService(currentUserResolver, userAvatarRepository, avatarAssetRepository, xpService) + val service = UserAvatarService( + currentUserResolver, + userAvatarRepository, + avatarAssetRepository, + pupilGameProgressService, + xpService, + ) val user = pupilUser(42L) Mockito.`when`(currentUserResolver.requirePupil("42")).thenReturn(user) From f49269f54d5215aeed65a1e16c4ca7970223b484 Mon Sep 17 00:00:00 2001 From: Johannes Aamot-Skeidsvoll Date: Mon, 27 Apr 2026 14:08:09 +0200 Subject: [PATCH 11/11] Update migration version to avoid collision --- ...l => V23__split_avatars_into_user_avatar_and_avatar_asset.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/main/resources/db/migration/{V22__split_avatars_into_user_avatar_and_avatar_asset.sql => V23__split_avatars_into_user_avatar_and_avatar_asset.sql} (100%) diff --git a/src/main/resources/db/migration/V22__split_avatars_into_user_avatar_and_avatar_asset.sql b/src/main/resources/db/migration/V23__split_avatars_into_user_avatar_and_avatar_asset.sql similarity index 100% rename from src/main/resources/db/migration/V22__split_avatars_into_user_avatar_and_avatar_asset.sql rename to src/main/resources/db/migration/V23__split_avatars_into_user_avatar_and_avatar_asset.sql