diff --git a/src/main/kotlin/fr/shikkanime/caches/CountryCodeLocalDateKeyCache.kt b/src/main/kotlin/fr/shikkanime/caches/CountryCodeLocalDateKeyCache.kt index 6f44b2ff..a640029c 100644 --- a/src/main/kotlin/fr/shikkanime/caches/CountryCodeLocalDateKeyCache.kt +++ b/src/main/kotlin/fr/shikkanime/caches/CountryCodeLocalDateKeyCache.kt @@ -2,8 +2,10 @@ package fr.shikkanime.caches import fr.shikkanime.entities.enums.CountryCode import java.time.LocalDate +import java.util.* data class CountryCodeLocalDateKeyCache( + val member: UUID?, val countryCode: CountryCode, val localDate: LocalDate ) \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/controllers/api/AnimeController.kt b/src/main/kotlin/fr/shikkanime/controllers/api/AnimeController.kt index c6885274..cb726409 100644 --- a/src/main/kotlin/fr/shikkanime/controllers/api/AnimeController.kt +++ b/src/main/kotlin/fr/shikkanime/controllers/api/AnimeController.kt @@ -160,4 +160,55 @@ class AnimeController : HasPageableRoute() { ) ) } + + @Path("/member-weekly") + @Get + @JWTAuthenticated + @OpenAPI( + "Get member weekly anime", + [ + OpenAPIResponse( + 200, + "Member weekly anime found", + Array::class, + ), + OpenAPIResponse( + 400, + "Invalid week format", + MessageDto::class + ), + OpenAPIResponse(401, "Unauthorized") + ], + security = true + ) + private fun getMemberWeekly( + @JWTUser uuid: UUID, + @QueryParam("country", description = "By default: FR", type = CountryCode::class) countryParam: String?, + @QueryParam("date", description = "By default: today", type = String::class) dateParam: String?, + ): Response { + val parsedDate = if (dateParam != null) { + val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + + try { + LocalDate.parse(dateParam, formatter) + } catch (e: Exception) { + return Response.badRequest( + MessageDto( + MessageDto.Type.ERROR, + "Invalid week format", + ) + ) + } + } else { + LocalDate.now() + } + + return Response.ok( + animeCacheService.getWeeklyAnimes( + uuid, + parsedDate!!.minusDays(parsedDate.dayOfWeek.value.toLong() - 1), + CountryCode.fromNullable(countryParam) ?: CountryCode.FR + ) + ) + } } \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/controllers/api/MemberController.kt b/src/main/kotlin/fr/shikkanime/controllers/api/MemberController.kt new file mode 100644 index 00000000..70adb797 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/controllers/api/MemberController.kt @@ -0,0 +1,138 @@ +package fr.shikkanime.controllers.api + +import com.google.inject.Inject +import fr.shikkanime.converters.AbstractConverter +import fr.shikkanime.dtos.AllFollowedEpisodeDto +import fr.shikkanime.dtos.GenericDto +import fr.shikkanime.dtos.MemberDto +import fr.shikkanime.services.MemberFollowAnimeService +import fr.shikkanime.services.MemberFollowEpisodeService +import fr.shikkanime.services.MemberService +import fr.shikkanime.utils.StringUtils +import fr.shikkanime.utils.routes.* +import fr.shikkanime.utils.routes.method.Delete +import fr.shikkanime.utils.routes.method.Post +import fr.shikkanime.utils.routes.method.Put +import fr.shikkanime.utils.routes.openapi.OpenAPI +import fr.shikkanime.utils.routes.openapi.OpenAPIResponse +import fr.shikkanime.utils.routes.param.BodyParam +import java.util.* + +@Controller("/api/v1/members") +class MemberController { + @Inject + private lateinit var memberService: MemberService + + @Inject + private lateinit var memberFollowAnimeService: MemberFollowAnimeService + + @Inject + private lateinit var memberFollowEpisodeService: MemberFollowEpisodeService + + @Path("/private-register") + @Post + @OpenAPI( + description = "Register a private member", + responses = [ + OpenAPIResponse(201, "Private member registered", Map::class), + ] + ) + private fun registerPrivateMember(): Response { + var identifier: String + + do { + identifier = StringUtils.generateRandomString(12) + } while (memberService.findPrivateMember(identifier) != null) + + memberService.savePrivateMember(identifier) + return Response.created(mapOf("identifier" to identifier)) + } + + @Path("/private-login") + @Post + @OpenAPI( + description = "Login a private member", + responses = [ + OpenAPIResponse(200, "Private member logged in"), + ] + ) + private fun loginPrivateMember(@BodyParam identifier: String): Response { + val privateMember = memberService.findPrivateMember(identifier) ?: return Response.notFound() + return Response.ok(AbstractConverter.convert(privateMember, MemberDto::class.java)) + } + + @Path("/animes") + @Put + @JWTAuthenticated + @OpenAPI( + description = "Follow an anime", + responses = [ + OpenAPIResponse(200, "Anime followed successfully"), + OpenAPIResponse(401, "Unauthorized") + ], + security = true + ) + private fun followAnime(@JWTUser uuidUser: UUID, @BodyParam anime: GenericDto): Response { + return memberFollowAnimeService.follow(uuidUser, anime) + } + + @Path("/animes") + @Delete + @JWTAuthenticated + @OpenAPI( + description = "Unfollow an anime", + responses = [ + OpenAPIResponse(200, "Anime unfollowed successfully"), + OpenAPIResponse(401, "Unauthorized") + ], + security = true + ) + private fun unfollowAnime(@JWTUser uuidUser: UUID, @BodyParam anime: GenericDto): Response { + return memberFollowAnimeService.unfollow(uuidUser, anime) + } + + @Path("/follow-all-episodes") + @Put + @JWTAuthenticated + @OpenAPI( + description = "Follow all episodes of an anime", + responses = [ + OpenAPIResponse(200, "Episodes followed successfully", AllFollowedEpisodeDto::class), + OpenAPIResponse(401, "Unauthorized") + ], + security = true + ) + private fun followAllEpisodes(@JWTUser uuidUser: UUID, @BodyParam anime: GenericDto): Response { + return memberFollowEpisodeService.followAll(uuidUser, anime) + } + + @Path("/episodes") + @Put + @JWTAuthenticated + @OpenAPI( + description = "Follow an episode", + responses = [ + OpenAPIResponse(200, "Episode followed successfully"), + OpenAPIResponse(401, "Unauthorized") + ], + security = true + ) + private fun followEpisode(@JWTUser uuidUser: UUID, @BodyParam episode: GenericDto): Response { + return memberFollowEpisodeService.follow(uuidUser, episode) + } + + @Path("/episodes") + @Delete + @JWTAuthenticated + @OpenAPI( + description = "Unfollow an episode", + responses = [ + OpenAPIResponse(200, "Episode unfollowed successfully"), + OpenAPIResponse(401, "Unauthorized") + ], + security = true + ) + private fun unfollowEpisode(@JWTUser uuidUser: UUID, @BodyParam episode: GenericDto): Response { + return memberFollowEpisodeService.unfollow(uuidUser, episode) + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/converters/member/MemberToMemberDtoConverter.kt b/src/main/kotlin/fr/shikkanime/converters/member/MemberToMemberDtoConverter.kt new file mode 100644 index 00000000..ca2b6a7c --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/converters/member/MemberToMemberDtoConverter.kt @@ -0,0 +1,34 @@ +package fr.shikkanime.converters.member + +import com.google.inject.Inject +import fr.shikkanime.converters.AbstractConverter +import fr.shikkanime.dtos.MemberDto +import fr.shikkanime.dtos.TokenDto +import fr.shikkanime.entities.Member +import fr.shikkanime.services.MemberFollowAnimeService +import fr.shikkanime.services.MemberFollowEpisodeService +import fr.shikkanime.utils.withUTCString + +class MemberToMemberDtoConverter : AbstractConverter() { + @Inject + private lateinit var memberFollowAnimeService: MemberFollowAnimeService + + @Inject + private lateinit var memberFollowEpisodeService: MemberFollowEpisodeService + + override fun convert(from: Member): MemberDto { + val tokenDto = convert(from, TokenDto::class.java) + val followedEpisodes = memberFollowEpisodeService.getAllFollowedEpisodes(from) + + return MemberDto( + uuid = from.uuid!!, + token = tokenDto.token!!, + creationDateTime = from.creationDateTime.withUTCString(), + lastUpdateDateTime = from.lastUpdateDateTime.withUTCString(), + isPrivate = from.isPrivate, + followedAnimes = memberFollowAnimeService.getAllFollowedAnimes(from).mapNotNull { it.uuid }, + followedEpisodes = followedEpisodes.mapNotNull { it.uuid }, + totalDuration = followedEpisodes.sumOf { it.duration } + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/converters/member/MemberToTokenDtoConverter.kt b/src/main/kotlin/fr/shikkanime/converters/member/MemberToTokenDtoConverter.kt index f31e6c78..640de1d9 100644 --- a/src/main/kotlin/fr/shikkanime/converters/member/MemberToTokenDtoConverter.kt +++ b/src/main/kotlin/fr/shikkanime/converters/member/MemberToTokenDtoConverter.kt @@ -14,6 +14,7 @@ class MemberToTokenDtoConverter : AbstractConverter() { .withAudience(Constant.jwtAudience) .withIssuer(Constant.jwtDomain) .withClaim("uuid", from.uuid.toString()) + .withClaim("isPrivate", from.isPrivate) .withClaim("username", from.username) .withClaim("creationDateTime", from.creationDateTime.toString()) .withClaim("roles", from.roles.map { it.name }) diff --git a/src/main/kotlin/fr/shikkanime/dtos/AllFollowedEpisodeDto.kt b/src/main/kotlin/fr/shikkanime/dtos/AllFollowedEpisodeDto.kt new file mode 100644 index 00000000..a70206cd --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/dtos/AllFollowedEpisodeDto.kt @@ -0,0 +1,8 @@ +package fr.shikkanime.dtos + +import java.util.* + +data class AllFollowedEpisodeDto( + val data: Set, + val duration: Long, +) diff --git a/src/main/kotlin/fr/shikkanime/dtos/GenericDto.kt b/src/main/kotlin/fr/shikkanime/dtos/GenericDto.kt new file mode 100644 index 00000000..19e4c9b2 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/dtos/GenericDto.kt @@ -0,0 +1,8 @@ +package fr.shikkanime.dtos + +import java.io.Serializable +import java.util.* + +data class GenericDto( + val uuid: UUID, +) : Serializable diff --git a/src/main/kotlin/fr/shikkanime/dtos/MemberDto.kt b/src/main/kotlin/fr/shikkanime/dtos/MemberDto.kt new file mode 100644 index 00000000..faa334f8 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/dtos/MemberDto.kt @@ -0,0 +1,14 @@ +package fr.shikkanime.dtos + +import java.util.* + +data class MemberDto( + val uuid: UUID, + val token: String, + val creationDateTime: String, + val lastUpdateDateTime: String, + val isPrivate: Boolean, + val followedAnimes: List, + val followedEpisodes: List, + val totalDuration: Long, +) diff --git a/src/main/kotlin/fr/shikkanime/entities/Member.kt b/src/main/kotlin/fr/shikkanime/entities/Member.kt index 08c035d7..4c79ad51 100644 --- a/src/main/kotlin/fr/shikkanime/entities/Member.kt +++ b/src/main/kotlin/fr/shikkanime/entities/Member.kt @@ -13,8 +13,12 @@ import java.util.* @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) class Member( override val uuid: UUID? = null, - @Column(name = "creation_date_time") + @Column(nullable = false, name = "creation_date_time") val creationDateTime: ZonedDateTime = ZonedDateTime.now(), + @Column(nullable = true, name = "last_update_date_time") + var lastUpdateDateTime: ZonedDateTime = ZonedDateTime.now(), + @Column(nullable = false, name = "is_private") + val isPrivate: Boolean = false, @Column(nullable = false, unique = true) val username: String? = null, @Column(nullable = false, name = "encrypted_password") diff --git a/src/main/kotlin/fr/shikkanime/entities/MemberFollowAnime.kt b/src/main/kotlin/fr/shikkanime/entities/MemberFollowAnime.kt new file mode 100644 index 00000000..6c3526f1 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/entities/MemberFollowAnime.kt @@ -0,0 +1,21 @@ +package fr.shikkanime.entities + +import jakarta.persistence.* +import org.hibernate.annotations.Cache +import org.hibernate.annotations.CacheConcurrencyStrategy +import java.time.ZonedDateTime +import java.util.* + +@Entity +@Table(name = "member_follow_anime") +@Cacheable +@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) +class MemberFollowAnime( + override val uuid: UUID? = null, + @Column(nullable = false, name = "follow_date_time") + val followDateTime: ZonedDateTime = ZonedDateTime.now(), + @ManyToOne(optional = false) + val member: Member? = null, + @ManyToOne(optional = false) + val anime: Anime? = null, +) : ShikkEntity(uuid) \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/entities/MemberFollowEpisode.kt b/src/main/kotlin/fr/shikkanime/entities/MemberFollowEpisode.kt new file mode 100644 index 00000000..8f8bf96d --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/entities/MemberFollowEpisode.kt @@ -0,0 +1,21 @@ +package fr.shikkanime.entities + +import jakarta.persistence.* +import org.hibernate.annotations.Cache +import org.hibernate.annotations.CacheConcurrencyStrategy +import java.time.ZonedDateTime +import java.util.* + +@Entity +@Table(name = "member_follow_episode") +@Cacheable +@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) +class MemberFollowEpisode( + override val uuid: UUID? = null, + @Column(nullable = false, name = "follow_date_time") + val followDateTime: ZonedDateTime = ZonedDateTime.now(), + @ManyToOne(optional = false) + val member: Member? = null, + @ManyToOne(optional = false) + val episode: EpisodeMapping? = null, +) : ShikkEntity(uuid) \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/modules/HTTP.kt b/src/main/kotlin/fr/shikkanime/modules/HTTP.kt index 28fb9525..553e95e4 100644 --- a/src/main/kotlin/fr/shikkanime/modules/HTTP.kt +++ b/src/main/kotlin/fr/shikkanime/modules/HTTP.kt @@ -1,8 +1,11 @@ package fr.shikkanime.modules import fr.shikkanime.utils.Constant +import fr.shikkanime.utils.LoggerFactory import freemarker.cache.ClassTemplateLoader import io.github.smiley4.ktorswaggerui.SwaggerUI +import io.github.smiley4.ktorswaggerui.data.AuthScheme +import io.github.smiley4.ktorswaggerui.data.AuthType import io.ktor.http.* import io.ktor.serialization.gson.* import io.ktor.server.application.* @@ -14,6 +17,9 @@ import io.ktor.server.plugins.cors.routing.* import io.ktor.server.plugins.statuspages.* import io.ktor.server.request.* import io.ktor.server.response.* +import java.util.logging.Level + +private val logger = LoggerFactory.getLogger("HTTP") fun Application.configureHTTP() { install(Compression) { @@ -34,6 +40,7 @@ fun Application.configureHTTP() { } install(StatusPages) { exception { call, cause -> + logger.log(Level.SEVERE, "Internal server error", cause) call.respondText(text = "500: $cause", status = HttpStatusCode.InternalServerError) } status(HttpStatusCode.NotFound) { call, _ -> @@ -53,6 +60,11 @@ fun Application.configureHTTP() { install(CachingHeaders) { } install(SwaggerUI) { + securityScheme("BearerAuth") { + type = AuthType.HTTP + scheme = AuthScheme.BEARER + bearerFormat = "jwt" + } swagger { swaggerUrl = "api/swagger" forwardRoot = false @@ -69,5 +81,5 @@ fun Application.configureHTTP() { } private fun isNotSiteRoute(path: String, errorCode: String): Boolean { - return path.contains(".") || path.startsWith("/$errorCode") || (path.startsWith("/api") && path.startsWith("/admin")) + return path.contains(".") || path.startsWith("/$errorCode") || path.startsWith("/api") || path.startsWith("/admin") } diff --git a/src/main/kotlin/fr/shikkanime/modules/Routing.kt b/src/main/kotlin/fr/shikkanime/modules/Routing.kt index f52f9d39..60d6e5e7 100644 --- a/src/main/kotlin/fr/shikkanime/modules/Routing.kt +++ b/src/main/kotlin/fr/shikkanime/modules/Routing.kt @@ -1,9 +1,6 @@ package fr.shikkanime.modules -import fr.shikkanime.dtos.AnimeDto -import fr.shikkanime.dtos.ConfigDto -import fr.shikkanime.dtos.EpisodeMappingDto -import fr.shikkanime.dtos.TokenDto +import fr.shikkanime.dtos.* import fr.shikkanime.dtos.enums.Status import fr.shikkanime.entities.enums.ConfigPropertyKey import fr.shikkanime.entities.enums.CountryCode @@ -242,6 +239,7 @@ private suspend fun handleBodyParam(kParameter: KParameter, call: ApplicationCal ConfigDto::class.java -> call.receive() AnimeDto::class.java -> call.receive() EpisodeMappingDto::class.java -> call.receive() + GenericDto::class.java -> call.receive() else -> call.receive() } } diff --git a/src/main/kotlin/fr/shikkanime/modules/Security.kt b/src/main/kotlin/fr/shikkanime/modules/Security.kt index 2353587e..084c5f7f 100644 --- a/src/main/kotlin/fr/shikkanime/modules/Security.kt +++ b/src/main/kotlin/fr/shikkanime/modules/Security.kt @@ -40,6 +40,7 @@ private fun setupJWTVerifier(): JWTVerifier = JWT .withAudience(Constant.jwtAudience) .withIssuer(Constant.jwtDomain) .withClaimPresence("uuid") + .withClaimPresence("isPrivate") .withClaimPresence("username") .withClaimPresence("creationDateTime") .withClaimPresence("roles") @@ -81,7 +82,8 @@ private fun validationSession(jwtVerifier: JWTVerifier, session: TokenDto): Toke val jwtPrincipal = jwtVerifier.verify(session.token) ?: return null val member = memberCacheService.find(UUID.fromString(jwtPrincipal.getClaim("uuid").asString())) ?: return null - return if (member.username == jwtPrincipal.getClaim("username").asString() && + return if (member.isPrivate == jwtPrincipal.getClaim("isPrivate").asBoolean() && + member.username == jwtPrincipal.getClaim("username").asString() && member.roles.toTypedArray().contentEquals(jwtPrincipal.getClaim("roles").asArray(Role::class.java)) && member.creationDateTime.toString() == jwtPrincipal.getClaim("creationDateTime").asString() && member.roles.any { it == Role.ADMIN } diff --git a/src/main/kotlin/fr/shikkanime/modules/SwaggerRouting.kt b/src/main/kotlin/fr/shikkanime/modules/SwaggerRouting.kt index 02d33fa9..2345db75 100644 --- a/src/main/kotlin/fr/shikkanime/modules/SwaggerRouting.kt +++ b/src/main/kotlin/fr/shikkanime/modules/SwaggerRouting.kt @@ -1,6 +1,7 @@ package fr.shikkanime.modules import fr.shikkanime.utils.routes.openapi.OpenAPI +import fr.shikkanime.utils.routes.param.BodyParam import fr.shikkanime.utils.routes.param.PathParam import fr.shikkanime.utils.routes.param.QueryParam import io.github.smiley4.ktorswaggerui.dsl.BodyTypeDescriptor @@ -23,8 +24,10 @@ fun swagger( } return { + securitySchemeName = if (openApi.security) "BearerAuth" else null tags = routeTags hidden = hiddenRoute || openApi.hidden + summary = openApi.description description = openApi.description swaggerRequest(method) swaggerResponse(openApi) @@ -33,7 +36,7 @@ fun swagger( private fun OpenApiRoute.swaggerRequest(method: KFunction<*>) { request { - method.parameters.filter { it.hasAnnotation() || it.hasAnnotation() } + method.parameters.filter { it.hasAnnotation() || it.hasAnnotation() || it.hasAnnotation() } .forEach { parameter -> val name = parameter.name!! val type = parameter.type.jvmErasure @@ -54,6 +57,10 @@ private fun OpenApiRoute.swaggerRequest(method: KFunction<*>) { required = true } } + + parameter.hasAnnotation() -> { + body(type) + } } } } diff --git a/src/main/kotlin/fr/shikkanime/repositories/EpisodeVariantRepository.kt b/src/main/kotlin/fr/shikkanime/repositories/EpisodeVariantRepository.kt index 2f86e15a..606f6b78 100644 --- a/src/main/kotlin/fr/shikkanime/repositories/EpisodeVariantRepository.kt +++ b/src/main/kotlin/fr/shikkanime/repositories/EpisodeVariantRepository.kt @@ -1,16 +1,22 @@ package fr.shikkanime.repositories import fr.shikkanime.entities.* +import com.google.inject.Inject import fr.shikkanime.entities.enums.CountryCode import fr.shikkanime.entities.enums.EpisodeType +import fr.shikkanime.services.MemberFollowAnimeService import jakarta.persistence.Tuple import java.time.ZonedDateTime import java.util.* class EpisodeVariantRepository : AbstractRepository() { + @Inject + private lateinit var memberFollowAnimeService: MemberFollowAnimeService + override fun getEntityClass() = EpisodeVariant::class.java fun findAllByDateRange( + member: Member?, countryCode: CountryCode, start: ZonedDateTime, end: ZonedDateTime, @@ -23,8 +29,16 @@ class EpisodeVariantRepository : AbstractRepository() { val countryPredicate = cb.equal(root[EpisodeVariant_.mapping][EpisodeMapping_.anime][Anime_.countryCode], countryCode) val datePredicate = cb.between(root[EpisodeVariant_.releaseDateTime], start, end) + val predicates = mutableListOf(countryPredicate, datePredicate) + + member?.let { + val animePredicate = root[EpisodeVariant_.mapping][EpisodeMapping_.anime].`in`( + memberFollowAnimeService.getAllFollowedAnimes(it) + ) + predicates.add(animePredicate) + } - query.where(cb.and(countryPredicate, datePredicate)) + query.where(cb.and(*predicates.toTypedArray())) createReadOnlyQuery(entityManager, query) .resultList diff --git a/src/main/kotlin/fr/shikkanime/repositories/MemberFollowAnimeRepository.kt b/src/main/kotlin/fr/shikkanime/repositories/MemberFollowAnimeRepository.kt new file mode 100644 index 00000000..50cdd5ff --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/repositories/MemberFollowAnimeRepository.kt @@ -0,0 +1,43 @@ +package fr.shikkanime.repositories + +import fr.shikkanime.entities.Anime +import fr.shikkanime.entities.Member +import fr.shikkanime.entities.MemberFollowAnime +import fr.shikkanime.entities.MemberFollowAnime_ + +class MemberFollowAnimeRepository : AbstractRepository() { + override fun getEntityClass() = MemberFollowAnime::class.java + + fun findByMemberAndAnime(member: Member, anime: Anime): MemberFollowAnime? { + return inTransaction { + val cb = it.criteriaBuilder + val query = cb.createQuery(getEntityClass()) + val root = query.from(getEntityClass()) + + query.where( + cb.equal(root[MemberFollowAnime_.member], member), + cb.equal(root[MemberFollowAnime_.anime], anime) + ) + + createReadOnlyQuery(it, query) + .resultList + .firstOrNull() + } + } + + fun getAllFollowedAnimes(member: Member): List { + return inTransaction { + val cb = it.criteriaBuilder + val query = cb.createQuery(Anime::class.java) + val root = query.from(getEntityClass()) + query.select(root[MemberFollowAnime_.anime]) + + query.where( + cb.equal(root[MemberFollowAnime_.member], member) + ) + + createReadOnlyQuery(it, query) + .resultList + } + } +} \ 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 new file mode 100644 index 00000000..d6fbc2a7 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/repositories/MemberFollowEpisodeRepository.kt @@ -0,0 +1,43 @@ +package fr.shikkanime.repositories + +import fr.shikkanime.entities.EpisodeMapping +import fr.shikkanime.entities.Member +import fr.shikkanime.entities.MemberFollowEpisode +import fr.shikkanime.entities.MemberFollowEpisode_ + +class MemberFollowEpisodeRepository : AbstractRepository() { + override fun getEntityClass() = MemberFollowEpisode::class.java + + fun findByMemberAndEpisode(member: Member, episode: EpisodeMapping): MemberFollowEpisode? { + return inTransaction { + val cb = it.criteriaBuilder + val query = cb.createQuery(getEntityClass()) + val root = query.from(getEntityClass()) + + query.where( + cb.equal(root[MemberFollowEpisode_.member], member), + cb.equal(root[MemberFollowEpisode_.episode], episode) + ) + + createReadOnlyQuery(it, query) + .resultList + .firstOrNull() + } + } + + fun getAllFollowedEpisodes(member: Member): List { + return inTransaction { + val cb = it.criteriaBuilder + val query = cb.createQuery(EpisodeMapping::class.java) + val root = query.from(getEntityClass()) + query.select(root[MemberFollowEpisode_.episode]) + + query.where( + cb.equal(root[MemberFollowEpisode_.member], member) + ) + + createReadOnlyQuery(it, query) + .resultList + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/repositories/MemberRepository.kt b/src/main/kotlin/fr/shikkanime/repositories/MemberRepository.kt index 75c51739..6e7ee6af 100644 --- a/src/main/kotlin/fr/shikkanime/repositories/MemberRepository.kt +++ b/src/main/kotlin/fr/shikkanime/repositories/MemberRepository.kt @@ -35,4 +35,21 @@ class MemberRepository : AbstractRepository() { .firstOrNull() } } + + fun findPrivateMember(identifier: String): Member? { + return inTransaction { + val cb = it.criteriaBuilder + val query = cb.createQuery(getEntityClass()) + val root = query.from(getEntityClass()) + + query.where( + cb.equal(root[Member_.username], identifier), + cb.isTrue(root[Member_.isPrivate]) + ) + + createReadOnlyQuery(it, query) + .resultList + .firstOrNull() + } + } } \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/services/AnimeService.kt b/src/main/kotlin/fr/shikkanime/services/AnimeService.kt index a619dbfa..795ffc4b 100644 --- a/src/main/kotlin/fr/shikkanime/services/AnimeService.kt +++ b/src/main/kotlin/fr/shikkanime/services/AnimeService.kt @@ -7,10 +7,7 @@ import fr.shikkanime.dtos.PlatformDto import fr.shikkanime.dtos.WeeklyAnimeDto import fr.shikkanime.dtos.WeeklyAnimesDto import fr.shikkanime.dtos.enums.Status -import fr.shikkanime.entities.Anime -import fr.shikkanime.entities.EpisodeVariant -import fr.shikkanime.entities.Simulcast -import fr.shikkanime.entities.SortParameter +import fr.shikkanime.entities.* import fr.shikkanime.entities.enums.CountryCode import fr.shikkanime.entities.enums.LangType import fr.shikkanime.repositories.AnimeRepository @@ -60,10 +57,11 @@ class AnimeService : AbstractService() { fun findBySlug(countryCode: CountryCode, slug: String) = animeRepository.findBySlug(countryCode, slug) - fun getWeeklyAnimes(startOfWeekDay: LocalDate, countryCode: CountryCode): List { + fun getWeeklyAnimes(member: Member?, startOfWeekDay: LocalDate, countryCode: CountryCode): List { val zoneId = ZoneId.of(countryCode.timezone) val list = episodeVariantService.findAllByDateRange( + member, countryCode, startOfWeekDay.minusDays(7).atStartOfDay(zoneId), startOfWeekDay.plusDays(7).atTime(23, 59, 59).atZone(zoneId) diff --git a/src/main/kotlin/fr/shikkanime/services/EpisodeVariantService.kt b/src/main/kotlin/fr/shikkanime/services/EpisodeVariantService.kt index ecfd1aff..84d2bb54 100644 --- a/src/main/kotlin/fr/shikkanime/services/EpisodeVariantService.kt +++ b/src/main/kotlin/fr/shikkanime/services/EpisodeVariantService.kt @@ -1,10 +1,7 @@ package fr.shikkanime.services import com.google.inject.Inject -import fr.shikkanime.entities.Anime -import fr.shikkanime.entities.EpisodeMapping -import fr.shikkanime.entities.EpisodeVariant -import fr.shikkanime.entities.Simulcast +import fr.shikkanime.entities.* import fr.shikkanime.entities.enums.ConfigPropertyKey import fr.shikkanime.entities.enums.CountryCode import fr.shikkanime.entities.enums.EpisodeType @@ -36,10 +33,11 @@ class EpisodeVariantService : AbstractService() { + @Inject + private lateinit var memberFollowAnimeRepository: MemberFollowAnimeRepository + + @Inject + private lateinit var memberService: MemberService + + @Inject + private lateinit var animeService: AnimeService + + override fun getRepository() = memberFollowAnimeRepository + + fun getAllFollowedAnimes(member: Member) = memberFollowAnimeRepository.getAllFollowedAnimes(member) + + fun follow(uuidUser: UUID, anime: GenericDto): Response { + val member = memberService.find(uuidUser) ?: return Response.notFound() + val element = animeService.find(anime.uuid) ?: return Response.notFound() + + if (memberFollowAnimeRepository.findByMemberAndAnime(member, element) != null) { + return Response.conflict() + } + + member.lastUpdateDateTime = ZonedDateTime.now() + memberService.update(member) + save(MemberFollowAnime(member = member, anime = element)) + MapCache.invalidate(MemberFollowAnime::class.java) + return Response.ok() + } + + fun unfollow(uuidUser: UUID, anime: GenericDto): Response { + val member = memberService.find(uuidUser) ?: return Response.notFound() + val element = animeService.find(anime.uuid) ?: return Response.notFound() + + val findByMemberAndAnime = memberFollowAnimeRepository.findByMemberAndAnime(member, element) + ?: return Response.conflict() + + member.lastUpdateDateTime = ZonedDateTime.now() + memberService.update(member) + memberFollowAnimeRepository.delete(findByMemberAndAnime) + MapCache.invalidate(MemberFollowAnime::class.java) + return Response.ok() + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/services/MemberFollowEpisodeService.kt b/src/main/kotlin/fr/shikkanime/services/MemberFollowEpisodeService.kt new file mode 100644 index 00000000..26c26ae7 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/services/MemberFollowEpisodeService.kt @@ -0,0 +1,79 @@ +package fr.shikkanime.services + +import com.google.inject.Inject +import fr.shikkanime.dtos.AllFollowedEpisodeDto +import fr.shikkanime.dtos.GenericDto +import fr.shikkanime.entities.Member +import fr.shikkanime.entities.MemberFollowEpisode +import fr.shikkanime.repositories.MemberFollowEpisodeRepository +import fr.shikkanime.utils.MapCache +import fr.shikkanime.utils.routes.Response +import java.time.ZonedDateTime +import java.util.* + +class MemberFollowEpisodeService : AbstractService() { + @Inject + private lateinit var memberFollowEpisodeRepository: MemberFollowEpisodeRepository + + @Inject + private lateinit var memberService: MemberService + + @Inject + private lateinit var episodeMappingService: EpisodeMappingService + + @Inject + private lateinit var animeService: AnimeService + + override fun getRepository() = memberFollowEpisodeRepository + + fun getAllFollowedEpisodes(member: Member) = memberFollowEpisodeRepository.getAllFollowedEpisodes(member) + + fun followAll(uuidUser: UUID, anime: GenericDto): Response { + val member = memberService.find(uuidUser) ?: return Response.notFound() + val elements = episodeMappingService.findAllByAnime(animeService.find(anime.uuid) ?: return Response.notFound()) + val list = mutableListOf() + + elements.forEach { element -> + if (memberFollowEpisodeRepository.findByMemberAndEpisode(member, element) != null) { + return@forEach + } + + list.add(save(MemberFollowEpisode(member = member, episode = element))) + } + + member.lastUpdateDateTime = ZonedDateTime.now() + memberService.update(member) + MapCache.invalidate(MemberFollowEpisode::class.java) + + return Response.ok(AllFollowedEpisodeDto(data = list.mapNotNull { it.episode?.uuid }.toSet(), duration = list.sumOf { it.episode!!.duration })) + } + + fun follow(uuidUser: UUID, episode: GenericDto): Response { + val member = memberService.find(uuidUser) ?: return Response.notFound() + val element = episodeMappingService.find(episode.uuid) ?: return Response.notFound() + + if (memberFollowEpisodeRepository.findByMemberAndEpisode(member, element) != null) { + return Response.conflict() + } + + member.lastUpdateDateTime = ZonedDateTime.now() + memberService.update(member) + save(MemberFollowEpisode(member = member, episode = element)) + MapCache.invalidate(MemberFollowEpisode::class.java) + return Response.ok() + } + + fun unfollow(uuidUser: UUID, episode: GenericDto): Response { + val member = memberService.find(uuidUser) ?: return Response.notFound() + val element = episodeMappingService.find(episode.uuid) ?: return Response.notFound() + + val findByMemberAndEpisode = memberFollowEpisodeRepository.findByMemberAndEpisode(member, element) + ?: return Response.conflict() + + member.lastUpdateDateTime = ZonedDateTime.now() + memberService.update(member) + memberFollowEpisodeRepository.delete(findByMemberAndEpisode) + MapCache.invalidate(MemberFollowEpisode::class.java) + return Response.ok() + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/services/MemberService.kt b/src/main/kotlin/fr/shikkanime/services/MemberService.kt index dc8d1615..44b0757e 100644 --- a/src/main/kotlin/fr/shikkanime/services/MemberService.kt +++ b/src/main/kotlin/fr/shikkanime/services/MemberService.kt @@ -8,7 +8,6 @@ import fr.shikkanime.utils.EncryptionManager import fr.shikkanime.utils.LoggerFactory import fr.shikkanime.utils.RandomManager - class MemberService : AbstractService() { private val logger = LoggerFactory.getLogger(javaClass) @@ -22,6 +21,9 @@ class MemberService : AbstractService() { fun findByUsernameAndPassword(username: String, password: String) = memberRepository.findByUsernameAndPassword(username, EncryptionManager.generate(password)) + fun findPrivateMember(identifier: String) = + memberRepository.findPrivateMember(EncryptionManager.toSHA512(identifier)) + fun initDefaultAdminUser(): String { val adminUsers = findAllByRoles(listOf(Role.ADMIN)) check(adminUsers.isEmpty()) { "Admin user already exists" } @@ -36,4 +38,13 @@ class MemberService : AbstractService() { ) return password } + + fun savePrivateMember(identifier: String) = + save( + Member( + isPrivate = true, + username = EncryptionManager.toSHA512(identifier), + encryptedPassword = byteArrayOf() + ) + ) } \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/services/caches/AnimeCacheService.kt b/src/main/kotlin/fr/shikkanime/services/caches/AnimeCacheService.kt index dd74b3f0..aa2b51fb 100644 --- a/src/main/kotlin/fr/shikkanime/services/caches/AnimeCacheService.kt +++ b/src/main/kotlin/fr/shikkanime/services/caches/AnimeCacheService.kt @@ -9,12 +9,10 @@ import fr.shikkanime.dtos.AnimeDto import fr.shikkanime.dtos.PageableDto import fr.shikkanime.dtos.WeeklyAnimesDto import fr.shikkanime.dtos.enums.Status -import fr.shikkanime.entities.Anime -import fr.shikkanime.entities.EpisodeMapping -import fr.shikkanime.entities.EpisodeVariant -import fr.shikkanime.entities.SortParameter +import fr.shikkanime.entities.* import fr.shikkanime.entities.enums.CountryCode import fr.shikkanime.services.AnimeService +import fr.shikkanime.services.MemberService import fr.shikkanime.services.SimulcastService import fr.shikkanime.utils.MapCache import java.time.LocalDate @@ -27,6 +25,9 @@ class AnimeCacheService : AbstractCacheService { @Inject private lateinit var simulcastService: SimulcastService + @Inject + private lateinit var memberService: MemberService + private val findAllByCache = MapCache>(classes = listOf(Anime::class.java)) { PageableDto.fromPageable( @@ -61,7 +62,18 @@ class AnimeCacheService : AbstractCacheService { EpisodeVariant::class.java ) ) { - animeService.getWeeklyAnimes(it.localDate, it.countryCode) + animeService.getWeeklyAnimes(null, it.localDate, it.countryCode) + } + + private val weeklyMemberCache = + MapCache>( + classes = listOf( + EpisodeMapping::class.java, + EpisodeVariant::class.java, + MemberFollowAnime::class.java + ) + ) { + animeService.getWeeklyAnimes(memberService.find(it.member), it.localDate, it.countryCode) } fun findAllBy( @@ -79,5 +91,8 @@ class AnimeCacheService : AbstractCacheService { fun findBySlug(countryCode: CountryCode, slug: String) = findBySlugCache[CountryCodeIdKeyCache(countryCode, slug)] fun getWeeklyAnimes(startOfWeekDay: LocalDate, countryCode: CountryCode) = - weeklyCache[CountryCodeLocalDateKeyCache(countryCode, startOfWeekDay)] + weeklyCache[CountryCodeLocalDateKeyCache(null, countryCode, startOfWeekDay)] + + fun getWeeklyAnimes(member: UUID, startOfWeekDay: LocalDate, countryCode: CountryCode) = + weeklyMemberCache[CountryCodeLocalDateKeyCache(member, countryCode, startOfWeekDay)] } \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/utils/StringUtils.kt b/src/main/kotlin/fr/shikkanime/utils/StringUtils.kt index 9f3d0c54..ccd5cb09 100644 --- a/src/main/kotlin/fr/shikkanime/utils/StringUtils.kt +++ b/src/main/kotlin/fr/shikkanime/utils/StringUtils.kt @@ -130,4 +130,11 @@ object StringUtils { episodeMapping.image == Constant.DEFAULT_IMAGE_PREVIEW ) Status.INVALID else Status.VALID } + + fun generateRandomString(length: Int): String { + val source = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + return (1..length) + .map { source.random() } + .joinToString("") + } } \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/openapi/OpenAPI.kt b/src/main/kotlin/fr/shikkanime/utils/routes/openapi/OpenAPI.kt index 22ba55eb..be655f8d 100644 --- a/src/main/kotlin/fr/shikkanime/utils/routes/openapi/OpenAPI.kt +++ b/src/main/kotlin/fr/shikkanime/utils/routes/openapi/OpenAPI.kt @@ -6,4 +6,5 @@ annotation class OpenAPI( val description: String = "", val responses: Array = [], val hidden: Boolean = false, + val security: Boolean = false ) diff --git a/src/main/resources/db/changelog/2024/05/02-changelog.xml b/src/main/resources/db/changelog/2024/05/02-changelog.xml new file mode 100644 index 00000000..5a6fcac2 --- /dev/null +++ b/src/main/resources/db/changelog/2024/05/02-changelog.xml @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/db/changelog/db.changelog-master.xml b/src/main/resources/db/changelog/db.changelog-master.xml index 335cca4f..e4641f16 100644 --- a/src/main/resources/db/changelog/db.changelog-master.xml +++ b/src/main/resources/db/changelog/db.changelog-master.xml @@ -49,4 +49,5 @@ + \ No newline at end of file diff --git a/src/test/kotlin/fr/shikkanime/controllers/api/AnimeControllerTest.kt b/src/test/kotlin/fr/shikkanime/controllers/api/AnimeControllerTest.kt new file mode 100644 index 00000000..886e95e9 --- /dev/null +++ b/src/test/kotlin/fr/shikkanime/controllers/api/AnimeControllerTest.kt @@ -0,0 +1,188 @@ +package fr.shikkanime.controllers.api + +import com.google.inject.Inject +import fr.shikkanime.dtos.GenericDto +import fr.shikkanime.dtos.MemberDto +import fr.shikkanime.dtos.WeeklyAnimesDto +import fr.shikkanime.entities.Anime +import fr.shikkanime.entities.EpisodeMapping +import fr.shikkanime.entities.EpisodeVariant +import fr.shikkanime.entities.enums.CountryCode +import fr.shikkanime.entities.enums.EpisodeType +import fr.shikkanime.entities.enums.Platform +import fr.shikkanime.module +import fr.shikkanime.services.* +import fr.shikkanime.utils.Constant +import fr.shikkanime.utils.ObjectParser +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.server.testing.* +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class AnimeControllerTest { + @Inject + private lateinit var memberService: MemberService + + @Inject + private lateinit var animeService: AnimeService + + @Inject + private lateinit var episodeMappingService: EpisodeMappingService + + @Inject + private lateinit var episodeVariantService: EpisodeVariantService + + @Inject + private lateinit var memberFollowAnimeService: MemberFollowAnimeService + + @Inject + private lateinit var memberFollowEpisodeService: MemberFollowEpisodeService + + @BeforeEach + fun setUp() { + Constant.injector.injectMembers(this) + } + + @AfterEach + fun tearDown() { + memberFollowEpisodeService.deleteAll() + memberFollowAnimeService.deleteAll() + memberService.deleteAll() + episodeVariantService.deleteAll() + episodeMappingService.deleteAll() + animeService.deleteAll() + } + + private suspend fun ApplicationTestBuilder.registerAndLogin(): Pair { + var identifier: String? + + client.post("/api/v1/members/private-register").apply { + assertEquals(HttpStatusCode.Created, status) + identifier = ObjectParser.fromJson(bodyAsText(), Map::class.java)["identifier"].toString() + val findPrivateMember = memberService.findPrivateMember(identifier!!) + assertNotNull(findPrivateMember) + assertTrue(findPrivateMember!!.isPrivate) + } + + client.post("/api/v1/members/private-login") { + setBody(identifier!!) + }.apply { + assertEquals(HttpStatusCode.OK, status) + val tokenDto = ObjectParser.fromJson(bodyAsText(), MemberDto::class.java) + assertTrue(tokenDto.token.isNotBlank()) + return identifier!! to tokenDto.token + } + } + + @Test + fun getWeekly() { + testApplication { + application { + module() + } + + val (identifier, token) = registerAndLogin() + + val anime1 = animeService.save( + Anime( + countryCode = CountryCode.FR, + name = "Test Anime 1", + image = "test.jpg", + slug = "test-anime-1", + banner = "test-banner.jpg", + description = "Test description", + ) + ) + + episodeMappingService.save( + EpisodeMapping( + anime = anime1, + episodeType = EpisodeType.EPISODE, + season = 1, + number = 1, + image = "test.jpg", + ) + ).apply { + episodeVariantService.save( + EpisodeVariant( + mapping = this, + platform = Platform.CRUN, + audioLocale = "ja-JP", + identifier = "test-episode-1", + url = "test.mp4", + uncensored = true + ) + ) + } + + val anime2 = animeService.save( + Anime( + countryCode = CountryCode.FR, + name = "Test Anime 2", + image = "test.jpg", + slug = "test-anime-2", + banner = "test-banner.jpg", + description = "Test description", + ) + ) + + episodeMappingService.save( + EpisodeMapping( + anime = anime2, + episodeType = EpisodeType.EPISODE, + season = 1, + number = 1, + image = "test.jpg", + ) + ).apply { + episodeVariantService.save( + EpisodeVariant( + mapping = this, + platform = Platform.CRUN, + audioLocale = "ja-JP", + identifier = "test-episode-2", + url = "test.mp4", + uncensored = true + ) + ) + } + + client.put("/api/v1/members/animes") { + header(HttpHeaders.Authorization, "Bearer $token") + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody(ObjectParser.toJson(GenericDto(anime1.uuid!!))) + }.apply { + assertEquals(HttpStatusCode.OK, status) + val findPrivateMember = memberService.findPrivateMember(identifier) + val followedAnimes = memberFollowAnimeService.getAllFollowedAnimes(findPrivateMember!!) + assertNotNull(findPrivateMember) + assertEquals(1, followedAnimes.size) + assertEquals(anime1.uuid, followedAnimes.first().uuid) + } + + client.get("/api/v1/animes/weekly") { + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + }.apply { + assertEquals(HttpStatusCode.OK, status) + val weeklyAnimesDto = + ObjectParser.fromJson(bodyAsText(), Array::class.java).flatMap { it.releases } + assertEquals(2, weeklyAnimesDto.size) + } + + client.get("/api/v1/animes/member-weekly") { + header(HttpHeaders.Authorization, "Bearer $token") + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + }.apply { + assertEquals(HttpStatusCode.OK, status) + val weeklyAnimesDto = + ObjectParser.fromJson(bodyAsText(), Array::class.java).flatMap { it.releases } + assertEquals(1, weeklyAnimesDto.size) + assertEquals(anime1.uuid, weeklyAnimesDto.first().anime.uuid) + } + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/fr/shikkanime/controllers/api/MemberControllerTest.kt b/src/test/kotlin/fr/shikkanime/controllers/api/MemberControllerTest.kt new file mode 100644 index 00000000..ec127063 --- /dev/null +++ b/src/test/kotlin/fr/shikkanime/controllers/api/MemberControllerTest.kt @@ -0,0 +1,386 @@ +package fr.shikkanime.controllers.api + +import com.google.inject.Inject +import fr.shikkanime.dtos.GenericDto +import fr.shikkanime.dtos.MemberDto +import fr.shikkanime.entities.Anime +import fr.shikkanime.entities.EpisodeMapping +import fr.shikkanime.entities.enums.CountryCode +import fr.shikkanime.entities.enums.EpisodeType +import fr.shikkanime.module +import fr.shikkanime.services.* +import fr.shikkanime.utils.Constant +import fr.shikkanime.utils.ObjectParser +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.server.testing.* +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.util.* + +class MemberControllerTest { + @Inject + private lateinit var memberService: MemberService + + @Inject + private lateinit var animeService: AnimeService + + @Inject + private lateinit var episodeMappingService: EpisodeMappingService + + @Inject + private lateinit var memberFollowAnimeService: MemberFollowAnimeService + + @Inject + private lateinit var memberFollowEpisodeService: MemberFollowEpisodeService + + @BeforeEach + fun setUp() { + Constant.injector.injectMembers(this) + } + + @AfterEach + fun tearDown() { + memberFollowEpisodeService.deleteAll() + memberFollowAnimeService.deleteAll() + memberService.deleteAll() + episodeMappingService.deleteAll() + animeService.deleteAll() + } + + @Test + fun registerPrivateMember() { + testApplication { + application { + module() + } + + client.post("/api/v1/members/private-register").apply { + assertEquals(HttpStatusCode.Created, status) + val identifier = ObjectParser.fromJson(bodyAsText(), Map::class.java)["identifier"].toString() + println(identifier) + val findPrivateMember = memberService.findPrivateMember(identifier) + assertNotNull(findPrivateMember) + assertTrue(findPrivateMember!!.isPrivate) + } + } + } + + private suspend fun ApplicationTestBuilder.registerAndLogin(): Pair { + var identifier: String? + + client.post("/api/v1/members/private-register").apply { + assertEquals(HttpStatusCode.Created, status) + identifier = ObjectParser.fromJson(bodyAsText(), Map::class.java)["identifier"].toString() + val findPrivateMember = memberService.findPrivateMember(identifier!!) + assertNotNull(findPrivateMember) + assertTrue(findPrivateMember!!.isPrivate) + } + + client.post("/api/v1/members/private-login") { + setBody(identifier!!) + }.apply { + assertEquals(HttpStatusCode.OK, status) + val tokenDto = ObjectParser.fromJson(bodyAsText(), MemberDto::class.java) + assertTrue(tokenDto.token.isNotBlank()) + return identifier!! to tokenDto.token + } + } + + @Test + fun loginPrivateMember() { + testApplication { + application { + module() + } + + registerAndLogin() + } + } + + @Test + fun `try to login with admin`() { + testApplication { + application { + module() + } + + client.post("/api/v1/members/private-login") { + setBody("admin") + }.apply { + assertEquals(HttpStatusCode.NotFound, status) + } + } + } + + @Test + fun followAnime() { + testApplication { + application { + module() + } + + val (identifier, token) = registerAndLogin() + + val anime = animeService.save( + Anime( + countryCode = CountryCode.FR, + name = "Test Anime", + image = "test.jpg", + slug = "test-anime", + banner = "test-banner.jpg", + description = "Test description", + ) + ) + + client.put("/api/v1/members/animes") { + header(HttpHeaders.Authorization, "Bearer $token") + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody(ObjectParser.toJson(GenericDto(anime.uuid!!))) + }.apply { + assertEquals(HttpStatusCode.OK, status) + val findPrivateMember = memberService.findPrivateMember(identifier) + val followedAnimes = memberFollowAnimeService.getAllFollowedAnimes(findPrivateMember!!) + assertNotNull(findPrivateMember) + assertEquals(1, followedAnimes.size) + assertEquals(anime.uuid, followedAnimes.first().uuid) + } + } + } + + @Test + fun `follow a random anime`() { + testApplication { + application { + module() + } + + val (_, token) = registerAndLogin() + + client.put("/api/v1/members/animes") { + header(HttpHeaders.Authorization, "Bearer $token") + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody(ObjectParser.toJson(GenericDto(UUID.randomUUID()))) + }.apply { + assertEquals(HttpStatusCode.NotFound, status) + } + } + } + + @Test + fun unfollowAnime() { + testApplication { + application { + module() + } + + val (identifier, token) = registerAndLogin() + + val anime = animeService.save( + Anime( + countryCode = CountryCode.FR, + name = "Test Anime", + image = "test.jpg", + slug = "test-anime", + banner = "test-banner.jpg", + description = "Test description", + ) + ) + + client.put("/api/v1/members/animes") { + header(HttpHeaders.Authorization, "Bearer $token") + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody(ObjectParser.toJson(GenericDto(anime.uuid!!))) + }.apply { + assertEquals(HttpStatusCode.OK, status) + val findPrivateMember = memberService.findPrivateMember(identifier) + val followedAnimes = memberFollowAnimeService.getAllFollowedAnimes(findPrivateMember!!) + assertNotNull(findPrivateMember) + assertEquals(1, followedAnimes.size) + assertEquals(anime.uuid, followedAnimes.first().uuid) + } + + client.delete("/api/v1/members/animes") { + header(HttpHeaders.Authorization, "Bearer $token") + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody(ObjectParser.toJson(GenericDto(anime.uuid!!))) + }.apply { + assertEquals(HttpStatusCode.OK, status) + val findPrivateMember = memberService.findPrivateMember(identifier) + val followedAnimes = memberFollowAnimeService.getAllFollowedAnimes(findPrivateMember!!) + assertNotNull(findPrivateMember) + assertEquals(0, followedAnimes.size) + } + } + } + + @Test + fun followEpisode() { + testApplication { + application { + module() + } + + val (identifier, token) = registerAndLogin() + + val anime = animeService.save( + Anime( + countryCode = CountryCode.FR, + name = "Test Anime", + image = "test.jpg", + slug = "test-anime", + banner = "test-banner.jpg", + description = "Test description", + ) + ) + + val episode = episodeMappingService.save( + EpisodeMapping( + anime = anime, + episodeType = EpisodeType.FILM, + season = 1, + number = 1, + image = "test.jpg", + ) + ) + + client.put("/api/v1/members/episodes") { + header(HttpHeaders.Authorization, "Bearer $token") + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody(ObjectParser.toJson(GenericDto(episode.uuid!!))) + }.apply { + assertEquals(HttpStatusCode.OK, status) + val findPrivateMember = memberService.findPrivateMember(identifier) + val followedEpisodes = memberFollowEpisodeService.getAllFollowedEpisodes(findPrivateMember!!) + assertNotNull(findPrivateMember) + assertEquals(1, followedEpisodes.size) + assertEquals(episode.uuid, followedEpisodes.first().uuid) + } + } + } + + @Test + fun `follow a random episode`() { + testApplication { + application { + module() + } + + val (_, token) = registerAndLogin() + + client.put("/api/v1/members/episodes") { + header(HttpHeaders.Authorization, "Bearer $token") + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody(ObjectParser.toJson(GenericDto(UUID.randomUUID()))) + }.apply { + assertEquals(HttpStatusCode.NotFound, status) + } + } + } + + @Test + fun unfollowEpisode() { + testApplication { + application { + module() + } + + val (identifier, token) = registerAndLogin() + + val anime = animeService.save( + Anime( + countryCode = CountryCode.FR, + name = "Test Anime", + image = "test.jpg", + slug = "test-anime", + banner = "test-banner.jpg", + description = "Test description", + ) + ) + + val episode = episodeMappingService.save( + EpisodeMapping( + anime = anime, + episodeType = EpisodeType.FILM, + season = 1, + number = 1, + image = "test.jpg", + ) + ) + + client.put("/api/v1/members/episodes") { + header(HttpHeaders.Authorization, "Bearer $token") + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody(ObjectParser.toJson(GenericDto(episode.uuid!!))) + }.apply { + assertEquals(HttpStatusCode.OK, status) + val findPrivateMember = memberService.findPrivateMember(identifier) + val followedEpisodes = memberFollowEpisodeService.getAllFollowedEpisodes(findPrivateMember!!) + assertNotNull(findPrivateMember) + assertEquals(1, followedEpisodes.size) + assertEquals(episode.uuid, followedEpisodes.first().uuid) + } + + client.delete("/api/v1/members/episodes") { + header(HttpHeaders.Authorization, "Bearer $token") + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody(ObjectParser.toJson(GenericDto(episode.uuid!!))) + }.apply { + assertEquals(HttpStatusCode.OK, status) + val findPrivateMember = memberService.findPrivateMember(identifier) + val followedEpisodes = memberFollowEpisodeService.getAllFollowedEpisodes(findPrivateMember!!) + assertNotNull(findPrivateMember) + assertEquals(0, followedEpisodes.size) + } + } + } + + @Test + fun followAllEpisodes() { + testApplication { + application { + module() + } + + val (identifier, token) = registerAndLogin() + + val anime = animeService.save( + Anime( + countryCode = CountryCode.FR, + name = "Test Anime", + image = "test.jpg", + slug = "test-anime", + banner = "test-banner.jpg", + description = "Test description", + ) + ) + + (1..12).forEach { + episodeMappingService.save( + EpisodeMapping( + anime = anime, + episodeType = EpisodeType.EPISODE, + season = 1, + number = it, + image = "test.jpg", + ) + ) + } + + client.put("/api/v1/members/follow-all-episodes") { + header(HttpHeaders.Authorization, "Bearer $token") + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody(ObjectParser.toJson(GenericDto(anime.uuid!!))) + }.apply { + assertEquals(HttpStatusCode.OK, status) + val findPrivateMember = memberService.findPrivateMember(identifier) + val followedEpisodes = memberFollowEpisodeService.getAllFollowedEpisodes(findPrivateMember!!) + assertNotNull(findPrivateMember) + assertEquals(12, followedEpisodes.size) + } + } + } +} \ No newline at end of file