From 01a10bb6766b6c073c7146e1aae65dfe2ce79454 Mon Sep 17 00:00:00 2001 From: Ziedelth Date: Thu, 28 May 2026 13:45:49 +0200 Subject: [PATCH] feat: add batch filtering for followed episodes and API endpoint - Introduced `filterFollowedEpisodeUUIDs` method in `MemberFollowEpisodeService` and repository. - Added `/me/watched-episodes/batch` POST endpoint in `MemberController` for batch processing of followed episodes. - Implemented validation for empty and oversized episode lists. - Provided comprehensive test cases for the new functionality. --- .../controllers/api/MemberController.kt | 14 +++ .../MemberFollowEpisodeRepository.kt | 20 +++++ .../services/MemberFollowEpisodeService.kt | 2 + .../controllers/api/MemberControllerTest.kt | 85 +++++++++++++++++++ 4 files changed, 121 insertions(+) diff --git a/src/main/kotlin/fr/shikkanime/controllers/api/MemberController.kt b/src/main/kotlin/fr/shikkanime/controllers/api/MemberController.kt index bc71c0fd..b4b9054d 100644 --- a/src/main/kotlin/fr/shikkanime/controllers/api/MemberController.kt +++ b/src/main/kotlin/fr/shikkanime/controllers/api/MemberController.kt @@ -166,4 +166,18 @@ class MemberController : HasPageableRoute() { memberService.delete(member) return Response.noContent() } + + @Path("/me/watched-episodes/batch") + @Post + @JWTAuthenticated + private suspend fun filterFollowedEpisodeUUIDs( + @JWTUser memberUuid: UUID, + @BodyParam episodes: Array + ): Response { + if (episodes.isEmpty()) + return Response.badRequest(MessageDto.error("No episodes provided")) + if (episodes.size > 50) + return Response.badRequest(MessageDto.error("Too many episodes provided")) + return Response.ok(memberFollowEpisodeService.filterFollowedEpisodeUUIDs(memberUuid, episodes.toList())) + } } \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/repositories/MemberFollowEpisodeRepository.kt b/src/main/kotlin/fr/shikkanime/repositories/MemberFollowEpisodeRepository.kt index 103f9a9b..1d9640f9 100644 --- a/src/main/kotlin/fr/shikkanime/repositories/MemberFollowEpisodeRepository.kt +++ b/src/main/kotlin/fr/shikkanime/repositories/MemberFollowEpisodeRepository.kt @@ -61,6 +61,26 @@ class MemberFollowEpisodeRepository : AbstractRepository() } } + suspend fun filterFollowedEpisodeUUIDs(memberUuid: UUID, list: List): List { + return dispatch { + val cb = it.criteriaBuilder + val query = cb.createQuery(UUID::class.java) + val root = query.from(entityClass) + query.distinct(true) + .select(root[MemberFollowEpisode_.episode][EpisodeMapping_.uuid]) + + query.where( + cb.and( + cb.equal(root[MemberFollowEpisode_.member][Member_.uuid], memberUuid), + root[MemberFollowEpisode_.episode][EpisodeMapping_.uuid].`in`(list) + ) + ) + + createReadOnlyQuery(it, query) + .resultList + } + } + suspend fun findAllByEpisode(episode: EpisodeMapping): List { return dispatch { val cb = it.criteriaBuilder diff --git a/src/main/kotlin/fr/shikkanime/services/MemberFollowEpisodeService.kt b/src/main/kotlin/fr/shikkanime/services/MemberFollowEpisodeService.kt index 3ce14819..cd218aea 100644 --- a/src/main/kotlin/fr/shikkanime/services/MemberFollowEpisodeService.kt +++ b/src/main/kotlin/fr/shikkanime/services/MemberFollowEpisodeService.kt @@ -22,6 +22,8 @@ class MemberFollowEpisodeService : AbstractService) = repository.filterFollowedEpisodeUUIDs(memberUuid, list) + suspend fun findAllByEpisode(episodeMapping: EpisodeMapping) = repository.findAllByEpisode(episodeMapping) diff --git a/src/test/kotlin/fr/shikkanime/controllers/api/MemberControllerTest.kt b/src/test/kotlin/fr/shikkanime/controllers/api/MemberControllerTest.kt index 7a462bc4..3977c85d 100644 --- a/src/test/kotlin/fr/shikkanime/controllers/api/MemberControllerTest.kt +++ b/src/test/kotlin/fr/shikkanime/controllers/api/MemberControllerTest.kt @@ -595,4 +595,89 @@ class MemberControllerTest : AbstractControllerTest() { } } } + + @Test + fun batchFollowWatchedEpisodesEmptyList() { + testApplication { + application { + module() + } + + val (_, token) = registerAndLogin() + + client.post("/api/v1/members/me/watched-episodes/batch") { + header(HttpHeaders.Authorization, "Bearer $token") + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody("[]") + }.apply { + assertEquals(HttpStatusCode.BadRequest, status) + val responseMap = ObjectParser.fromJson(bodyAsText(), Map::class.java) + assertEquals("No episodes provided", responseMap["message"]) + } + } + } + + @Test + fun batchFollowWatchedEpisodesTooMany() { + testApplication { + application { + module() + } + + val (_, token) = registerAndLogin() + val largeList = (1..51).map { UUID.randomUUID() } + + client.post("/api/v1/members/me/watched-episodes/batch") { + header(HttpHeaders.Authorization, "Bearer $token") + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody(ObjectParser.toJson(largeList)) + }.apply { + assertEquals(HttpStatusCode.BadRequest, status) + val responseMap = ObjectParser.fromJson(bodyAsText(), Map::class.java) + assertEquals("Too many episodes provided", responseMap["message"]) + } + } + } + + @Test + fun batchFollowWatchedEpisodesSuccess() { + testApplication { + application { + module() + } + + val (_, token) = registerAndLogin() + val anime = animeService.findAll().first() + val allEpisodes = episodeMappingService.findAllByAnime(anime) + + val episodeFollowed1 = allEpisodes[0] + val episodeFollowed2 = allEpisodes[1] + val episodeNotFollowed = allEpisodes[2] + + client.put("/api/v1/members/episodes") { + header(HttpHeaders.Authorization, "Bearer $token") + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody(ObjectParser.toJson(GenericDto(episodeFollowed1.uuid!!))) + } + client.put("/api/v1/members/episodes") { + header(HttpHeaders.Authorization, "Bearer $token") + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody(ObjectParser.toJson(GenericDto(episodeFollowed2.uuid!!))) + } + + val requestList = listOf(episodeFollowed1.uuid!!, episodeFollowed2.uuid!!, episodeNotFollowed.uuid!!) + + client.post("/api/v1/members/me/watched-episodes/batch") { + header(HttpHeaders.Authorization, "Bearer $token") + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody(ObjectParser.toJson(requestList)) + }.apply { + assertEquals(HttpStatusCode.OK, status) + val responseList = ObjectParser.fromJson(bodyAsText(), List::class.java) + assertEquals(2, responseList.size) + assertTrue(responseList.contains(episodeFollowed1.uuid.toString())) + assertTrue(responseList.contains(episodeFollowed2.uuid.toString())) + } + } + } } \ No newline at end of file