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..993cc96 --- /dev/null +++ b/src/main/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/application/PupilGameProgressEventListener.kt @@ -0,0 +1,37 @@ +package edu.ntnu.idi.idatt.backend.game.gameProgress.application + +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.event.TransactionPhase +import org.springframework.transaction.event.TransactionalEventListener + +/** + * Keeps classroom-scoped aggregate game progress in sync with committed gameplay events. + */ +@Component +class PupilGameProgressEventListener( + private val pupilGameProgressService: PupilGameProgressService, +) { + /** + * Increments the persisted completed-stop counter after a stop completion commits. + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + fun onMapStopCompleted(event: MapStopCompletedEvent) { + pupilGameProgressService.incrementCompletedStopsCount( + pupilId = event.pupilUserId, + classroomId = event.classroomId, + ) + } + + /** + * Marks the final boss as completed after the game-completion event commits. + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + fun onGameCompleted(event: PupilGameCompletedEvent) { + 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 new file mode 100644 index 0000000..44fb667 --- /dev/null +++ b/src/test/kotlin/edu/ntnu/idi/idatt/backend/game/gameProgress/PupilGameProgressEventListenerTests.kt @@ -0,0 +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.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.Test +import org.mockito.Mockito + +class PupilGameProgressEventListenerTests { + @Test + 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)) + + Mockito.verify(service).incrementCompletedStopsCount(1L, 2L) + } + + @Test + 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)) + + 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), + ) +}