From e363c39b159f31aa2677e800f5bb20a35957fc7d Mon Sep 17 00:00:00 2001 From: sindrelm Date: Thu, 30 Apr 2026 09:59:52 +0200 Subject: [PATCH 1/3] Add completed stop event to update pupil game progress Signed-off-by: sindrelm --- .../PupilGameProgressEventListener.kt | 55 +++++++++++++++++++ .../PupilGameProgressEventListenerTests.kt | 44 +++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 src/main/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/application/PupilGameProgressEventListener.kt create mode 100644 src/test/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/PupilGameProgressEventListenerTests.kt diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/application/PupilGameProgressEventListener.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/application/PupilGameProgressEventListener.kt new file mode 100644 index 0000000..766f1a8 --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/application/PupilGameProgressEventListener.kt @@ -0,0 +1,55 @@ +package edu.ntnu.idi.idatt.backend.game.gameProgress.application + +import edu.ntnu.idi.idatt.backend.game.gameProgress.infrastructure.PupilGameProgressRepository +import edu.ntnu.idi.idatt.backend.shared.events.MapStopCompletedEvent +import edu.ntnu.idi.idatt.backend.shared.events.PupilGameCompletedEvent +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.event.TransactionPhase +import org.springframework.transaction.event.TransactionalEventListener +import java.time.Instant + +/** + * Keeps classroom-scoped aggregate game progress in sync with committed gameplay events. + */ +@Component +class PupilGameProgressEventListener( + private val pupilGameProgressRepository: PupilGameProgressRepository, +) { + /** + * Increments the persisted completed-stop counter after a stop completion commits. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + fun onMapStopCompleted(event: MapStopCompletedEvent) { + val progress = + pupilGameProgressRepository.findByPupilIdAndClassroomId( + event.pupilUserId, + event.classroomId, + ) ?: return + + progress.completedStopsCount += 1 + progress.lastActivityAt = Instant.now() + + pupilGameProgressRepository.save(progress) + } + + /** + * Marks the final boss as completed after the game-completion event commits. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + fun onGameCompleted(event: PupilGameCompletedEvent) { + val progress = + pupilGameProgressRepository.findByPupilIdAndClassroomId( + event.pupilUserId, + event.classroomId, + ) ?: return + + progress.finalBossCompleted = true + progress.lastActivityAt = Instant.now() + + pupilGameProgressRepository.save(progress) + } +} diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/PupilGameProgressEventListenerTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/PupilGameProgressEventListenerTests.kt new file mode 100644 index 0000000..821ed01 --- /dev/null +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/PupilGameProgressEventListenerTests.kt @@ -0,0 +1,44 @@ +package edu.ntnu.idi.idatt.backend.game.gameProgress + +import edu.ntnu.idi.idatt.backend.game.gameProgress.application.PupilGameProgressEventListener +import edu.ntnu.idi.idatt.backend.game.gameProgress.domain.PupilGameProgress +import edu.ntnu.idi.idatt.backend.game.gameProgress.infrastructure.PupilGameProgressRepository +import edu.ntnu.idi.idatt.backend.shared.events.MapStopCompletedEvent +import edu.ntnu.idi.idatt.backend.shared.events.PupilGameCompletedEvent +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import java.time.Instant + +class PupilGameProgressEventListenerTests { + @Test + fun `completed stop increments aggregate stop counter`() { + val repository = Mockito.mock(PupilGameProgressRepository::class.java) + val listener = PupilGameProgressEventListener(repository) + val progress = PupilGameProgress(completedStopsCount = 2, lastActivityAt = Instant.EPOCH) + + Mockito.`when`(repository.findByPupilIdAndClassroomId(1L, 2L)).thenReturn(progress) + + listener.onMapStopCompleted(MapStopCompletedEvent(pupilUserId = 1L, classroomId = 2L, stopId = 3L)) + + assertEquals(3, progress.completedStopsCount) + assertTrue(progress.lastActivityAt.isAfter(Instant.EPOCH)) + Mockito.verify(repository).save(progress) + } + + @Test + fun `game completed marks final boss as completed`() { + val repository = Mockito.mock(PupilGameProgressRepository::class.java) + val listener = PupilGameProgressEventListener(repository) + val progress = PupilGameProgress(finalBossCompleted = false, lastActivityAt = Instant.EPOCH) + + Mockito.`when`(repository.findByPupilIdAndClassroomId(1L, 2L)).thenReturn(progress) + + listener.onGameCompleted(PupilGameCompletedEvent(pupilUserId = 1L, classroomId = 2L, stopId = 5L)) + + assertTrue(progress.finalBossCompleted) + assertTrue(progress.lastActivityAt.isAfter(Instant.EPOCH)) + Mockito.verify(repository).save(progress) + } +} From 73a5bca05592cc1aaef63dc092135b75e3a69a02 Mon Sep 17 00:00:00 2001 From: sindrelm Date: Thu, 30 Apr 2026 10:02:30 +0200 Subject: [PATCH 2/3] Fix formating Signed-off-by: sindrelm --- .../gameProgress/application/PupilGameProgressEventListener.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/application/PupilGameProgressEventListener.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/application/PupilGameProgressEventListener.kt index 766f1a8..a695985 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/application/PupilGameProgressEventListener.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/application/PupilGameProgressEventListener.kt @@ -4,8 +4,8 @@ import edu.ntnu.idi.idatt.backend.game.gameProgress.infrastructure.PupilGameProg import edu.ntnu.idi.idatt.backend.shared.events.MapStopCompletedEvent import edu.ntnu.idi.idatt.backend.shared.events.PupilGameCompletedEvent import org.springframework.stereotype.Component -import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.event.TransactionPhase import org.springframework.transaction.event.TransactionalEventListener import java.time.Instant From c1a8d311c3376d7844516661fb1135b6e1cbf626 Mon Sep 17 00:00:00 2001 From: sindrelm Date: Thu, 30 Apr 2026 10:06:12 +0200 Subject: [PATCH 3/3] Refactor eventListener to use service instead of repo Signed-off-by: sindrelm --- .../PupilGameProgressEventListener.kt | 36 ++++--------- .../application/PupilGameProgressService.kt | 33 ++++++++++++ .../PupilGameProgressEventListenerTests.kt | 32 ++++------- .../PupilGameProgressServiceTests.kt | 53 +++++++++++++++++++ 4 files changed, 104 insertions(+), 50 deletions(-) create mode 100644 src/test/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/PupilGameProgressServiceTests.kt diff --git a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/application/PupilGameProgressEventListener.kt b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/application/PupilGameProgressEventListener.kt index a695985..993cc96 100644 --- a/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/application/PupilGameProgressEventListener.kt +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/application/PupilGameProgressEventListener.kt @@ -1,55 +1,37 @@ package edu.ntnu.idi.idatt.backend.game.gameProgress.application -import edu.ntnu.idi.idatt.backend.game.gameProgress.infrastructure.PupilGameProgressRepository import edu.ntnu.idi.idatt.backend.shared.events.MapStopCompletedEvent import edu.ntnu.idi.idatt.backend.shared.events.PupilGameCompletedEvent import org.springframework.stereotype.Component -import org.springframework.transaction.annotation.Propagation -import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.event.TransactionPhase import org.springframework.transaction.event.TransactionalEventListener -import java.time.Instant /** * Keeps classroom-scoped aggregate game progress in sync with committed gameplay events. */ @Component class PupilGameProgressEventListener( - private val pupilGameProgressRepository: PupilGameProgressRepository, + private val pupilGameProgressService: PupilGameProgressService, ) { /** * Increments the persisted completed-stop counter after a stop completion commits. */ - @Transactional(propagation = Propagation.REQUIRES_NEW) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) fun onMapStopCompleted(event: MapStopCompletedEvent) { - val progress = - pupilGameProgressRepository.findByPupilIdAndClassroomId( - event.pupilUserId, - event.classroomId, - ) ?: return - - progress.completedStopsCount += 1 - progress.lastActivityAt = Instant.now() - - pupilGameProgressRepository.save(progress) + pupilGameProgressService.incrementCompletedStopsCount( + pupilId = event.pupilUserId, + classroomId = event.classroomId, + ) } /** * Marks the final boss as completed after the game-completion event commits. */ - @Transactional(propagation = Propagation.REQUIRES_NEW) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) fun onGameCompleted(event: PupilGameCompletedEvent) { - val progress = - pupilGameProgressRepository.findByPupilIdAndClassroomId( - event.pupilUserId, - event.classroomId, - ) ?: return - - progress.finalBossCompleted = true - progress.lastActivityAt = Instant.now() - - pupilGameProgressRepository.save(progress) + pupilGameProgressService.markFinalBossCompleted( + pupilId = event.pupilUserId, + classroomId = event.classroomId, + ) } } 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 fe83650..2fe39fb 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 @@ -10,6 +10,7 @@ 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 +import org.springframework.transaction.annotation.Propagation import org.springframework.transaction.annotation.Transactional import org.springframework.web.server.ResponseStatusException import java.time.Instant @@ -100,6 +101,38 @@ class PupilGameProgressService( classroomId: Long, ): PupilGameProgress? = pupilGameProgressRepository.findByPupilIdAndClassroomId(pupilId, classroomId) + /** + * Increments the completed-stop counter for one pupil in one classroom. + * + * If no progress row exists for the tuple, the update is skipped. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun incrementCompletedStopsCount( + pupilId: Long, + classroomId: Long, + ) { + val progress = pupilGameProgressRepository.findByPupilIdAndClassroomId(pupilId, classroomId) ?: return + progress.completedStopsCount += 1 + progress.lastActivityAt = Instant.now() + pupilGameProgressRepository.save(progress) + } + + /** + * Marks the classroom-scoped game as completed for one pupil. + * + * If no progress row exists for the tuple, the update is skipped. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun markFinalBossCompleted( + pupilId: Long, + classroomId: Long, + ) { + val progress = pupilGameProgressRepository.findByPupilIdAndClassroomId(pupilId, classroomId) ?: return + progress.finalBossCompleted = true + progress.lastActivityAt = Instant.now() + pupilGameProgressRepository.save(progress) + } + /** * Creates an empty classroom-scoped progress row for a pupil if it does not already exist. * diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/PupilGameProgressEventListenerTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/PupilGameProgressEventListenerTests.kt index 821ed01..44fb667 100644 --- a/src/test/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/PupilGameProgressEventListenerTests.kt +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/PupilGameProgressEventListenerTests.kt @@ -1,44 +1,30 @@ package edu.ntnu.idi.idatt.backend.game.gameProgress import edu.ntnu.idi.idatt.backend.game.gameProgress.application.PupilGameProgressEventListener -import edu.ntnu.idi.idatt.backend.game.gameProgress.domain.PupilGameProgress -import edu.ntnu.idi.idatt.backend.game.gameProgress.infrastructure.PupilGameProgressRepository +import edu.ntnu.idi.idatt.backend.game.gameProgress.application.PupilGameProgressService import edu.ntnu.idi.idatt.backend.shared.events.MapStopCompletedEvent import edu.ntnu.idi.idatt.backend.shared.events.PupilGameCompletedEvent -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.mockito.Mockito -import java.time.Instant class PupilGameProgressEventListenerTests { @Test - fun `completed stop increments aggregate stop counter`() { - val repository = Mockito.mock(PupilGameProgressRepository::class.java) - val listener = PupilGameProgressEventListener(repository) - val progress = PupilGameProgress(completedStopsCount = 2, lastActivityAt = Instant.EPOCH) - - Mockito.`when`(repository.findByPupilIdAndClassroomId(1L, 2L)).thenReturn(progress) + fun `completed stop delegates aggregate stop counter increment to service`() { + val service = Mockito.mock(PupilGameProgressService::class.java) + val listener = PupilGameProgressEventListener(service) listener.onMapStopCompleted(MapStopCompletedEvent(pupilUserId = 1L, classroomId = 2L, stopId = 3L)) - assertEquals(3, progress.completedStopsCount) - assertTrue(progress.lastActivityAt.isAfter(Instant.EPOCH)) - Mockito.verify(repository).save(progress) + Mockito.verify(service).incrementCompletedStopsCount(1L, 2L) } @Test - fun `game completed marks final boss as completed`() { - val repository = Mockito.mock(PupilGameProgressRepository::class.java) - val listener = PupilGameProgressEventListener(repository) - val progress = PupilGameProgress(finalBossCompleted = false, lastActivityAt = Instant.EPOCH) - - Mockito.`when`(repository.findByPupilIdAndClassroomId(1L, 2L)).thenReturn(progress) + fun `game completed delegates final boss completion to service`() { + val service = Mockito.mock(PupilGameProgressService::class.java) + val listener = PupilGameProgressEventListener(service) listener.onGameCompleted(PupilGameCompletedEvent(pupilUserId = 1L, classroomId = 2L, stopId = 5L)) - assertTrue(progress.finalBossCompleted) - assertTrue(progress.lastActivityAt.isAfter(Instant.EPOCH)) - Mockito.verify(repository).save(progress) + Mockito.verify(service).markFinalBossCompleted(1L, 2L) } } diff --git a/src/test/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/PupilGameProgressServiceTests.kt b/src/test/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/PupilGameProgressServiceTests.kt new file mode 100644 index 0000000..8b55bf6 --- /dev/null +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/PupilGameProgressServiceTests.kt @@ -0,0 +1,53 @@ +package edu.ntnu.idi.idatt.backend.game.gameProgress + +import edu.ntnu.idi.idatt.backend.classroom.application.ClassroomQueryService +import edu.ntnu.idi.idatt.backend.game.gameProgress.application.PupilGameProgressService +import edu.ntnu.idi.idatt.backend.game.gameProgress.domain.PupilGameProgress +import edu.ntnu.idi.idatt.backend.game.gameProgress.infrastructure.PupilGameProgressRepository +import edu.ntnu.idi.idatt.backend.iam.application.CurrentUserResolver +import edu.ntnu.idi.idatt.backend.iam.application.IamUserService +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import java.time.Instant + +class PupilGameProgressServiceTests { + @Test + fun `increment completed stops count updates aggregate`() { + val repository = Mockito.mock(PupilGameProgressRepository::class.java) + val service = service(repository) + val progress = PupilGameProgress(completedStopsCount = 2, lastActivityAt = Instant.EPOCH) + + Mockito.`when`(repository.findByPupilIdAndClassroomId(1L, 2L)).thenReturn(progress) + + service.incrementCompletedStopsCount(1L, 2L) + + assertEquals(3, progress.completedStopsCount) + assertTrue(progress.lastActivityAt.isAfter(Instant.EPOCH)) + Mockito.verify(repository).save(progress) + } + + @Test + fun `mark final boss completed updates aggregate`() { + val repository = Mockito.mock(PupilGameProgressRepository::class.java) + val service = service(repository) + val progress = PupilGameProgress(finalBossCompleted = false, lastActivityAt = Instant.EPOCH) + + Mockito.`when`(repository.findByPupilIdAndClassroomId(1L, 2L)).thenReturn(progress) + + service.markFinalBossCompleted(1L, 2L) + + assertTrue(progress.finalBossCompleted) + assertTrue(progress.lastActivityAt.isAfter(Instant.EPOCH)) + Mockito.verify(repository).save(progress) + } + + private fun service(repository: PupilGameProgressRepository): PupilGameProgressService = + PupilGameProgressService( + repository, + Mockito.mock(IamUserService::class.java), + Mockito.mock(ClassroomQueryService::class.java), + Mockito.mock(CurrentUserResolver::class.java), + ) +}