diff --git a/build.gradle.kts b/build.gradle.kts index 72e9333..c0f9dfd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -30,11 +30,10 @@ java { dependencies { implementation(platform(libs.sentry.bom)) - implementation(platform(libs.spring.grpc.bom)) implementation(libs.spring.boot.starter.web) implementation(libs.spring.boot.starter.actuator) -// implementation(libs.spring.boot.starter.security) + implementation(libs.spring.boot.starter.security) implementation(libs.spring.boot.starter.cache) implementation(libs.spring.boot.starter.data.jdbc) implementation(libs.spring.boot.starter.validation) @@ -46,8 +45,7 @@ dependencies { implementation(libs.opentelemetry.exporter.otlp) implementation(libs.sentry.spring.boot.starter) - implementation(libs.spring.grpc.starter) - implementation(libs.grpc.services) + implementation(libs.spring.boot.starter.oauth2.client) runtimeOnly(libs.micrometer.registry.prometheus) runtimeOnly(libs.h2) @@ -57,7 +55,6 @@ dependencies { testImplementation(libs.spring.boot.starter.test) testImplementation(libs.kotlin.test.junit5) - testImplementation(libs.spring.grpc.test) testImplementation(libs.mockk) testRuntimeOnly(libs.junit.platform.launcher) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7314bb3..72e81e2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,9 +11,10 @@ spring-grpc = "1.0.1" protoc = "3.25.1" grpc-java = "1.60.0" springdoc = "2.8.6" -mockk = "1.13.12" +mockk = "1.13.13" [libraries] +spring-boot-starter-oauth2-client = { module = "org.springframework.boot:spring-boot-starter-oauth2-client" } spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web" } spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator" } spring-boot-starter-security = { module = "org.springframework.boot:spring-boot-starter-security" } diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt index 93f580a..a661396 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt @@ -206,6 +206,16 @@ class FilmRepository( filmRowMapper, ) + override fun findByTitle(title: String): Film? { + val films = + jdbc.query( + "SELECT id, title, description FROM films WHERE title = ? ORDER BY id LIMIT 1", + filmRowMapper, + title, + ) + return films.firstOrNull() + } + override fun deleteById(id: UUID) { jdbc.update( """ diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt index f6e261a..ae34f89 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt @@ -28,7 +28,20 @@ class UserRepository( } override fun save(user: User): User { - val entity = user.toEntity() + val existingUser = findById(user.id) + + val entity = + if (existingUser != null) { + val existingEntity = existingUser.toEntity() + user.toEntity( + provider = existingEntity.provider?.let { AuthProvider.valueOf(it) }, + providerId = existingEntity.providerId, + createdAt = existingEntity.createdAt, + ) + } else { + user.toEntity() + } + val updatedRows = jdbc.update( """ @@ -43,6 +56,7 @@ class UserRepository( entity.jellyfinUserId, entity.id, ) + if (updatedRows == 0) { jdbc.update( """ @@ -71,6 +85,16 @@ class UserRepository( return entities.firstOrNull()?.toDomain() } + override fun findByEmail(email: String): User? { + val entities = + jdbc.query( + "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE email = ?", + userEntityRowMapper, + email, + ) + return entities.firstOrNull()?.toDomain() + } + override fun findAll(): List = jdbc .query( diff --git a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt new file mode 100644 index 0000000..b64ea71 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt @@ -0,0 +1,86 @@ +package com.project.movienight.adapters.security + +import com.project.movienight.adapters.persistence.entity.toDomain +import com.project.movienight.adapters.persistence.entity.toEntity +import com.project.movienight.application.ports.input.security.OAuth2UserInfo +import com.project.movienight.application.ports.output.IdGenerator +import com.project.movienight.application.ports.output.UserRepositoryPort +import com.project.movienight.domain.model.AuthProvider +import com.project.movienight.domain.model.User +import org.slf4j.LoggerFactory +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest +import org.springframework.security.oauth2.core.OAuth2AuthenticationException +import org.springframework.security.oauth2.core.user.OAuth2User +import org.springframework.stereotype.Service + +@Service +class CustomOAuth2UserService( + private val userRepository: UserRepositoryPort, + private val idGenerator: IdGenerator, +) : DefaultOAuth2UserService() { + companion object { + private val log = LoggerFactory.getLogger(CustomOAuth2UserService::class.java) + } + + override fun loadUser(userRequest: OAuth2UserRequest): OAuth2User { + val oAuth2User = super.loadUser(userRequest) + val registrationId = userRequest.clientRegistration.registrationId + + log.debug("Processing OAuth2 login for provider: {}", registrationId) + + return try { + val userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(registrationId, oAuth2User) + val user = findOrCreateUser(userInfo) + UserPrincipal.create(user, oAuth2User.attributes) + } catch (e: IllegalArgumentException) { + log.error("OAuth2 authentication failed: ${e.message}", e) + throw OAuth2AuthenticationException("Failed to process OAuth2 user data") + } catch (e: OAuth2AuthenticationException) { + log.error("OAuth2 authentication failed: ${e.message}", e) + throw e + } + } + + private fun findOrCreateUser(userInfo: OAuth2UserInfo): User { + val provider = AuthProvider.valueOf(userInfo.getProvider().uppercase()) + + val existingUser = + userRepository.findByProviderAndProviderId( + provider, + userInfo.getProviderId(), + ) + + return if (existingUser != null) { + log.debug("User found by provider: {}", userInfo.getProvider()) + existingUser + } else { + val userByEmail = userRepository.findByEmail(userInfo.getEmail()) + + if (userByEmail != null) { + log.debug("Linking OAuth2 account to existing user: {}", userInfo.getEmail()) + val entity = + userByEmail.toEntity( + provider = provider, + providerId = userInfo.getProviderId(), + ) + userRepository.save(entity.toDomain()) + } else { + log.debug("Creating new user for provider: {}", userInfo.getProvider()) + val newUser = + User( + id = idGenerator.generateId(), + name = userInfo.getName(), + email = userInfo.getEmail(), + library = null, + ) + val entity = + newUser.toEntity( + provider = provider, + providerId = userInfo.getProviderId(), + ) + userRepository.save(entity.toDomain()) + } + } + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt new file mode 100644 index 0000000..123f9a0 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt @@ -0,0 +1,17 @@ +package com.project.movienight.adapters.security + +import com.project.movienight.application.ports.input.security.OAuth2UserInfo + +class GoogleOAuth2UserInfo( + private val attributes: Map, +) : OAuth2UserInfo { + override fun getProviderId(): String = attributes["sub"] as String + + override fun getEmail(): String = attributes["email"] as String + + override fun getName(): String = attributes["name"] as String + + override fun getProvider(): String = "google" + + override fun getAttributes(): Map = attributes +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt new file mode 100644 index 0000000..1faf660 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt @@ -0,0 +1,21 @@ +package com.project.movienight.adapters.security + +import com.project.movienight.application.ports.input.security.OAuth2UserInfo +import org.springframework.security.oauth2.core.OAuth2AuthenticationException +import org.springframework.security.oauth2.core.user.OAuth2User + +object OAuth2UserInfoFactory { + fun getOAuth2UserInfo( + registrationId: String, + user: OAuth2User, + ): OAuth2UserInfo { + val attributes = user.attributes + + return when (registrationId.lowercase()) { + "google" -> GoogleOAuth2UserInfo(attributes) + "yandex" -> YandexOAuth2UserInfo(attributes) + "vk" -> VkOAuth2UserInfo(attributes) + else -> throw OAuth2AuthenticationException("Unknown provider: $registrationId") + } + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt new file mode 100644 index 0000000..0bcb1b3 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt @@ -0,0 +1,42 @@ +package com.project.movienight.adapters.security + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.web.SecurityFilterChain + +@Configuration +@EnableWebSecurity +class SecurityConfiguration( + private val customOAuth2UserService: CustomOAuth2UserService, +) { + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http + .oauth2Login { oauth2 -> + oauth2 + .userInfoEndpoint { userInfo -> + userInfo.userService(customOAuth2UserService) + }.defaultSuccessUrl("/api/users/me", true) + }.authorizeHttpRequests { auth -> + auth + .requestMatchers("/", "/login/**", "/oauth2/**", "/h2-console/**", "/actuator/health") + .permitAll() + .requestMatchers("/api/users/me") + .authenticated() + .requestMatchers("/api/**") + .authenticated() + .anyRequest() + .authenticated() + }.headers { headers -> + headers.frameOptions { frameOptions -> + frameOptions.sameOrigin() + } + }.csrf { csrf -> + csrf.disable() + } + + return http.build() + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt new file mode 100644 index 0000000..dd8eb93 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt @@ -0,0 +1,44 @@ +package com.project.movienight.adapters.security + +import com.project.movienight.domain.model.User +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.oauth2.core.user.OAuth2User +import java.util.UUID + +class UserPrincipal( + private val user: User, + private val attributes: Map? = null, +) : OAuth2User, + UserDetails { + fun getId(): UUID = user.id + + override fun getName(): String = user.name + + override fun getAttributes(): Map = attributes ?: emptyMap() + + override fun getAuthorities(): Collection = + listOf( + SimpleGrantedAuthority("ROLE_USER"), + ) + + override fun getPassword(): String = "" + + override fun getUsername(): String = user.email + + override fun isAccountNonExpired(): Boolean = true + + override fun isAccountNonLocked(): Boolean = true + + override fun isCredentialsNonExpired(): Boolean = true + + override fun isEnabled(): Boolean = true + + companion object { + fun create( + user: User, + attributes: Map? = null, + ): UserPrincipal = UserPrincipal(user, attributes) + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt new file mode 100644 index 0000000..9b55f35 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt @@ -0,0 +1,28 @@ +package com.project.movienight.adapters.security + +import com.project.movienight.application.ports.input.security.OAuth2UserInfo + +class VkOAuth2UserInfo( + private val attributes: Map, +) : OAuth2UserInfo { + override fun getProviderId(): String = + (attributes["response"] as? List<*>) + ?.firstOrNull() + ?.let { it as? Map<*, *> } + ?.get("id") + ?.toString() ?: "" + + override fun getEmail(): String = attributes["email"]?.toString() ?: "" + + override fun getName(): String { + val response = attributes["response"] as? List<*> + val first = response?.firstOrNull() as? Map<*, *> + val firstName = first?.get("first_name")?.toString() ?: "" + val lastName = first?.get("last_name")?.toString() ?: "" + return "$firstName $lastName".trim() + } + + override fun getProvider(): String = "vk" + + override fun getAttributes(): Map = attributes +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt new file mode 100644 index 0000000..dc71df9 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt @@ -0,0 +1,22 @@ +package com.project.movienight.adapters.security + +import com.project.movienight.application.ports.input.security.OAuth2UserInfo + +class YandexOAuth2UserInfo( + private val attributes: Map, +) : OAuth2UserInfo { + override fun getProviderId(): String = attributes["id"]?.toString() ?: "" + + override fun getEmail(): String = + (attributes["emails"] as? List<*>) + ?.firstOrNull() + ?.let { it as? Map<*, *> } + ?.get("value") + ?.toString() ?: "" + + override fun getName(): String = attributes["display_name"]?.toString() ?: "" + + override fun getProvider(): String = "yandex" + + override fun getAttributes(): Map = attributes +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt b/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt index 27a236d..40b3362 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt @@ -3,6 +3,8 @@ package com.project.movienight.adapters.web import com.project.movienight.domain.exception.BlockedValueException import com.project.movienight.domain.exception.DomainException import com.project.movienight.domain.exception.EntityNotFoundException +import org.slf4j.LoggerFactory +import org.slf4j.MDC import org.springframework.http.HttpStatus import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.ResponseStatus @@ -10,22 +12,60 @@ import org.springframework.web.bind.annotation.RestControllerAdvice @RestControllerAdvice class ApiExceptionHandler { + private val log = LoggerFactory.getLogger(javaClass) + @ExceptionHandler(EntityNotFoundException::class) @ResponseStatus(HttpStatus.NOT_FOUND) - fun handleNotFound(exception: EntityNotFoundException): ErrorResponse = - ErrorResponse(message = exception.message ?: "Entity not found") + fun handleNotFound(exception: EntityNotFoundException): ErrorResponse { + val traceId = currentTraceId() + log.warn("Entity not found: traceId='{}', message='{}'", traceId, exception.message) + + return ErrorResponse( + message = exception.message ?: "Entity not found", + traceId = traceId, + ) + } @ExceptionHandler(BlockedValueException::class) @ResponseStatus(HttpStatus.BAD_REQUEST) - fun handleBlockedValue(exception: BlockedValueException): ErrorResponse = - ErrorResponse(message = exception.message ?: "Blocked value") + fun handleBlockedValue(exception: BlockedValueException): ErrorResponse { + val traceId = currentTraceId() + log.warn("Blocked value: traceId='{}', message='{}'", traceId, exception.message) + + return ErrorResponse( + message = exception.message ?: "Blocked value", + traceId = traceId, + ) + } @ExceptionHandler(DomainException::class) @ResponseStatus(HttpStatus.BAD_REQUEST) - fun handleDomainException(exception: DomainException): ErrorResponse = - ErrorResponse(message = exception.message ?: "Domain error") + fun handleDomainException(exception: DomainException): ErrorResponse { + val traceId = currentTraceId() + log.warn("Domain error: traceId='{}', message='{}'", traceId, exception.message) + + return ErrorResponse( + message = exception.message ?: "Domain error", + traceId = traceId, + ) + } + + @ExceptionHandler(Exception::class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + fun handleUnexpectedException(exception: Exception): ErrorResponse { + val traceId = currentTraceId() + log.error("Unexpected error: traceId='{}'", traceId, exception) + + return ErrorResponse( + message = "Internal server error", + traceId = traceId, + ) + } + + private fun currentTraceId(): String = MDC.get("traceId") ?: "unknown" } data class ErrorResponse( val message: String, + val traceId: String, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt index d4d52ef..3699e33 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt @@ -8,13 +8,19 @@ import com.project.movienight.application.ports.input.CreateFilmUseCase import com.project.movienight.application.ports.input.DeleteFilmUseCase import com.project.movienight.application.ports.input.EditFilmCommand import com.project.movienight.application.ports.input.EditFilmUseCase +import com.project.movienight.application.ports.input.GetAllFilmsUseCase +import com.project.movienight.application.ports.input.GetFilmByIdUseCase +import com.project.movienight.application.ports.input.SearchFilmByTitleUseCase import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController import java.util.UUID @@ -25,6 +31,9 @@ class FilmController( private val createFilmUseCase: CreateFilmUseCase, private val editFilmUseCase: EditFilmUseCase, private val deleteFilmUseCase: DeleteFilmUseCase, + private val getFilmByIdUseCase: GetFilmByIdUseCase, + private val getAllFilmsUseCase: GetAllFilmsUseCase, + private val searchFilmByTitleUseCase: SearchFilmByTitleUseCase, ) { @PostMapping @ResponseStatus(HttpStatus.CREATED) @@ -61,4 +70,24 @@ class FilmController( fun delete( @PathVariable id: UUID, ) = deleteFilmUseCase.delete(id) + + @GetMapping("/{id}") + fun getById( + @PathVariable id: UUID, + ): FilmResponse = FilmResponse.fromDomain(getFilmByIdUseCase.getById(id)) + + @GetMapping + fun getAll(): List = getAllFilmsUseCase.getAll().map { FilmResponse.fromDomain(it) } + + @GetMapping("/search") + fun searchByTitle( + @RequestParam title: String, + ): ResponseEntity { + val film = searchFilmByTitleUseCase.searchByTitle(title) + return if (film != null) { + ResponseEntity.ok(FilmResponse.fromDomain(film)) + } else { + ResponseEntity.notFound().build() + } + } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt index 74b04bc..c2ed037 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -2,14 +2,18 @@ package com.project.movienight.adapters.web import com.project.movienight.adapters.web.dto.request.CreateFilmLibraryRequest import com.project.movienight.adapters.web.dto.response.FilmLibraryResponse +import com.project.movienight.adapters.web.dto.response.FilmResponse import com.project.movienight.application.ports.input.AddFilmToLibraryCommand import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase import com.project.movienight.application.ports.input.CreateFilmLibraryCommand import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase +import com.project.movienight.application.ports.input.GetAllFilmsUseCase +import com.project.movienight.application.ports.input.GetFilmByIdUseCase import com.project.movienight.application.ports.input.GetFilmLibraryQuery import com.project.movienight.application.ports.input.GetFilmLibraryUseCase import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase +import com.project.movienight.domain.exception.EntityNotFoundException import org.springframework.http.HttpStatus import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping @@ -28,6 +32,8 @@ class FilmLibraryController( private val addFilmToLibraryUseCase: AddFilmToLibraryUseCase, private val removeFilmFromLibraryUseCase: RemoveFilmFromLibraryUseCase, private val getFilmLibraryUseCase: GetFilmLibraryUseCase, + private val getFilmByIdUseCase: GetFilmByIdUseCase, + private val getAllFilmsUseCase: GetAllFilmsUseCase, ) { @PostMapping @ResponseStatus(HttpStatus.CREATED) @@ -54,6 +60,19 @@ class FilmLibraryController( ), ) + @GetMapping("/films") + fun getAllFilmsInLibrary( + @PathVariable userId: UUID, + ): List { + val library = + getFilmLibraryUseCase.getLibrary( + GetFilmLibraryQuery(userId = userId), + ) + + val film = getFilmByIdUseCase.getById(library.filmId) + return listOf(FilmResponse.fromDomain(film)) + } + @PostMapping("/films/{filmId}") @ResponseStatus(HttpStatus.CREATED) fun addFilm( @@ -82,4 +101,31 @@ class FilmLibraryController( ), ) } + + @GetMapping("/available-films") + fun getAvailableFilms( + @PathVariable userId: UUID, + ): List { + val userLibrary = + runCatching { + getFilmLibraryUseCase.getLibrary( + GetFilmLibraryQuery(userId = userId), + ) + }.onFailure { exception -> + if (exception !is EntityNotFoundException) { + throw exception + } + }.getOrNull() + + val allFilms = getAllFilmsUseCase.getAll() + + val availableFilms = + if (userLibrary != null) { + allFilms.filter { it.id != userLibrary.filmId } + } else { + allFilms + } + + return availableFilms.map { FilmResponse.fromDomain(it) } + } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/TraceIdFilter.kt b/src/main/kotlin/com/project/movienight/adapters/web/TraceIdFilter.kt new file mode 100644 index 0000000..596cb47 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/TraceIdFilter.kt @@ -0,0 +1,27 @@ +package com.project.movienight.adapters.web + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.MDC +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter +import java.util.UUID + +@Component +class TraceIdFilter : OncePerRequestFilter() { + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + val traceId = UUID.randomUUID().toString() + MDC.put("traceId", traceId) + + try { + filterChain.doFilter(request, response) + } finally { + MDC.remove("traceId") + } + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt index f270736..bccf5bc 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt @@ -8,8 +8,11 @@ import com.project.movienight.application.ports.input.CreateUserUseCase import com.project.movienight.application.ports.input.DeleteUserUseCase import com.project.movienight.application.ports.input.EditUserCommand import com.project.movienight.application.ports.input.EditUserUseCase +import com.project.movienight.application.ports.input.GetAllUsersUseCase +import com.project.movienight.application.ports.input.GetUserByIdUseCase import org.springframework.http.HttpStatus import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping @@ -25,6 +28,8 @@ class UserController( private val createUserUseCase: CreateUserUseCase, private val editUserUseCase: EditUserUseCase, private val deleteUserUseCase: DeleteUserUseCase, + private val getUserByIdUseCase: GetUserByIdUseCase, + private val getAllUsersUseCase: GetAllUsersUseCase, ) { @PostMapping @ResponseStatus(HttpStatus.CREATED) @@ -40,6 +45,14 @@ class UserController( ), ) + @GetMapping + fun getAll(): List = getAllUsersUseCase.getAll().map { UserResponse.fromDomain(it) } + + @GetMapping("/{id}") + fun getById( + @PathVariable id: UUID, + ): UserResponse = UserResponse.fromDomain(getUserByIdUseCase.getById(id)) + @PatchMapping("/{id}") fun edit( @PathVariable id: UUID, diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt index 3622878..db3f4b0 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt @@ -27,3 +27,15 @@ data class EditFilmCommand( interface DeleteFilmUseCase { fun delete(id: UUID) } + +interface GetFilmByIdUseCase { + fun getById(id: UUID): Film +} + +interface GetAllFilmsUseCase { + fun getAll(): List +} + +interface SearchFilmByTitleUseCase { + fun searchByTitle(title: String): Film? +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt index c889946..b417525 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt @@ -26,3 +26,11 @@ data class EditUserCommand( interface DeleteUserUseCase { fun delete(id: UUID) } + +interface GetUserByIdUseCase { + fun getById(id: UUID): User +} + +interface GetAllUsersUseCase { + fun getAll(): List +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt new file mode 100644 index 0000000..e45db2b --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt @@ -0,0 +1,13 @@ +package com.project.movienight.application.ports.input.security + +interface OAuth2UserInfo { + fun getProviderId(): String + + fun getEmail(): String + + fun getName(): String + + fun getProvider(): String + + fun getAttributes(): Map +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt index 91d45b6..c0d3938 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt @@ -10,5 +10,7 @@ interface FilmRepositoryPort { fun findAll(): List + fun findByTitle(title: String): Film? + fun deleteById(id: UUID) } diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt index e3c902c..dd69728 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt @@ -9,6 +9,8 @@ interface UserRepositoryPort { fun findById(id: UUID): User? + fun findByEmail(email: String): User? + fun findAll(): List fun deleteById(id: UUID) diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index 4166760..bbd32b1 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -5,12 +5,19 @@ import com.project.movienight.application.ports.input.CreateFilmUseCase import com.project.movienight.application.ports.input.DeleteFilmUseCase import com.project.movienight.application.ports.input.EditFilmCommand import com.project.movienight.application.ports.input.EditFilmUseCase +import com.project.movienight.application.ports.input.GetAllFilmsUseCase +import com.project.movienight.application.ports.input.GetFilmByIdUseCase +import com.project.movienight.application.ports.input.SearchFilmByTitleUseCase import com.project.movienight.application.ports.output.FilmRepositoryPort import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.config.FilmServiceProperties import com.project.movienight.domain.exception.BlockedValueException import com.project.movienight.domain.exception.EntityNotFoundException import com.project.movienight.domain.model.Film +import io.micrometer.core.instrument.Counter +import io.micrometer.core.instrument.MeterRegistry +import io.micrometer.core.instrument.Timer +import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.util.UUID @@ -19,47 +26,165 @@ class FilmService( private val filmRepository: FilmRepositoryPort, private val idGenerator: IdGenerator, private val filmConfig: FilmServiceProperties, + private val meterRegistry: MeterRegistry, ) : CreateFilmUseCase, EditFilmUseCase, - DeleteFilmUseCase { - override fun create(command: CreateFilmCommand): Film { - if (filmConfig.isBlocked(command.title)) { - throw BlockedValueException(target = "Film", field = "title") - } - if (filmConfig.isBlocked(command.description)) { - throw BlockedValueException(target = "Film", field = "description") - } + DeleteFilmUseCase, + GetFilmByIdUseCase, + GetAllFilmsUseCase, + SearchFilmByTitleUseCase { + private val log = LoggerFactory.getLogger(javaClass) - val film = - Film( - id = idGenerator.generateId(), - title = command.title, - description = command.description, + override fun create(command: CreateFilmCommand): Film { + val sample = Timer.start(meterRegistry) + + try { + log.debug( + "Create film request received: title='{}', descriptionLength={}", + command.title, + command.description.length, ) - return filmRepository.save(film) + + if (filmConfig.isBlocked(command.title)) { + log.debug("Create film blocked by title policy: title='{}'", command.title) + filmBlockedCounter.increment() + throw BlockedValueException(target = "Film", field = "title") + } + if (filmConfig.isBlocked(command.description)) { + log.debug("Create film blocked by description policy") + filmBlockedCounter.increment() + throw BlockedValueException(target = "Film", field = "description") + } + + val film = + Film( + id = idGenerator.generateId(), + title = command.title, + description = command.description, + ) + val saved = filmRepository.save(film) + + filmCreatedCounter.increment() + + log.info("Film created: id='{}', title='{}'", saved.id, saved.title) + return saved + } finally { + sample.stop(createFilmTimer) + } } override fun edit( id: UUID, command: EditFilmCommand, ): Film { - if (filmConfig.isBlocked(command.title)) { - throw BlockedValueException(target = "Film", field = "title") + val sample = Timer.start(meterRegistry) + + try { + log.debug("Edit film with id: {}", id) + + if (filmConfig.isBlocked(command.title)) { + log.debug("Edit film blocked by title policy: title='{}'", command.title) + filmBlockedCounter.increment() + throw BlockedValueException(target = "Film", field = "title") + } + if (filmConfig.isBlocked(command.description)) { + log.debug("Edit film blocked by description policy") + filmBlockedCounter.increment() + throw BlockedValueException(target = "Film", field = "description") + } + + val film = filmRepository.findById(id) + + if (film == null) { + log.debug("Film not found for edit: id='{}'", id) + throw EntityNotFoundException(entity = "Film", id = id.toString()) + } + + val updatedFilm = + film.copy( + title = command.title, + description = command.description, + ) + val saved = filmRepository.save(updatedFilm) + + filmEditedCounter.increment() + + log.info("Film edited: id='{}'", saved.id) + return saved + } finally { + sample.stop(editFilmTimer) } - if (filmConfig.isBlocked(command.description)) { - throw BlockedValueException(target = "Film", field = "description") - } - - var film = filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) - - film = film.copy(title = command.title, description = command.description) - - return filmRepository.save(film) } override fun delete(id: UUID) { + val sample = Timer.start(meterRegistry) + + try { + log.debug("Delete film with id: {}", id) + + val film = filmRepository.findById(id) + + if (film == null) { + log.debug("Film not found for delete: id='{}'", id) + throw EntityNotFoundException(entity = "Film", id = id.toString()) + } + + filmRepository.deleteById(id) + + filmDeletedCounter.increment() + + log.info("Film deleted: id='{}'", id) + } finally { + sample.stop(deleteFilmTimer) + } + } + + override fun getById(id: UUID): Film = filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) - filmRepository.deleteById(id) - } + override fun getAll(): List = filmRepository.findAll() + + override fun searchByTitle(title: String): Film? = filmRepository.findByTitle(title) + + private val filmCreatedCounter = + Counter + .builder("film_created_total") + .description("Total number of created films") + .register(meterRegistry) + + private val filmEditedCounter = + Counter + .builder("film_edited_total") + .description("Total number of successfully edited films") + .register(meterRegistry) + + private val filmDeletedCounter = + Counter + .builder("film_deleted_total") + .description("Total number of successfully deleted films") + .register(meterRegistry) + + private val filmBlockedCounter = + Counter + .builder("films.blocked") + .description("Total blocked film operations") + .register(meterRegistry) + + private val createFilmTimer = + Timer + .builder("films.create.duration") + .description("Film creation duration") + .register(meterRegistry) + + private val editFilmTimer = + Timer + .builder("films.edit.duration") + .description("Film edit duration") + .register(meterRegistry) + + private val deleteFilmTimer = + Timer + .builder("films.delete.duration") + .description("Film deletion duration") + .register(meterRegistry) } diff --git a/src/main/kotlin/com/project/movienight/application/services/UserService.kt b/src/main/kotlin/com/project/movienight/application/services/UserService.kt index be32d6a..da2a84b 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt @@ -5,6 +5,8 @@ import com.project.movienight.application.ports.input.CreateUserUseCase import com.project.movienight.application.ports.input.DeleteUserUseCase import com.project.movienight.application.ports.input.EditUserCommand import com.project.movienight.application.ports.input.EditUserUseCase +import com.project.movienight.application.ports.input.GetAllUsersUseCase +import com.project.movienight.application.ports.input.GetUserByIdUseCase import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.application.ports.output.UserRepositoryPort import com.project.movienight.config.UserServiceProperties @@ -21,7 +23,9 @@ class UserService( private val userConfig: UserServiceProperties, ) : CreateUserUseCase, EditUserUseCase, - DeleteUserUseCase { + DeleteUserUseCase, + GetUserByIdUseCase, + GetAllUsersUseCase { override fun create(command: CreateUserCommand): User { if (userConfig.isBlocked(command.name)) { throw BlockedValueException(target = "User", field = "name") @@ -46,15 +50,17 @@ class UserService( } var user = userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) - user = user.copy(name = command.name) - return userRepository.save(user) } override fun delete(id: UUID) { userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) - userRepository.deleteById(id) } + + override fun getById(id: UUID): User = + userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) + + override fun getAll(): List = userRepository.findAll() } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index f36c77d..284e69a 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -25,6 +25,38 @@ spring: console: enabled: ${SPRING_H2_CONSOLE_ENABLED:true} path: /h2-console + security: + oauth2: + client: + registration: + google: + client-id: ${OAUTH2_GOOGLE_CLIENT_ID:test-client-id} + client-secret: ${OAUTH2_GOOGLE_CLIENT_SECRET:test-secret} + scope: email,profile + yandex: + client-id: ${OAUTH2_YANDEX_CLIENT_ID:test-client-id} + client-secret: ${OAUTH2_YANDEX_CLIENT_SECRET:test-secret} + authorization-grant-type: authorization_code + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + scope: login:email,login:avatar + vk: + client-id: ${OAUTH2_VK_CLIENT_ID:test-client-id} + client-secret: ${OAUTH2_VK_CLIENT_SECRET:test-secret} + authorization-grant-type: authorization_code + client-authentication-method: client_secret_post + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + scope: email + provider: + yandex: + authorization-uri: https://oauth.yandex.ru/authorize + token-uri: https://oauth.yandex.ru/token + user-info-uri: https://login.yandex.ru/info + user-name-attribute: id + vk: + authorization-uri: https://oauth.vk.com/authorize + token-uri: https://oauth.vk.com/access_token + user-info-uri: https://api.vk.com/method/users.get?v=5.131&fields=photo_200 + user-name-attribute: response server: shutdown: graceful @@ -74,3 +106,6 @@ services: - censored - epstein - python +logging: + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%X{traceId}] %logger{36} - %msg%n" diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql index e98c8ff..1dcc5c9 100644 --- a/src/main/resources/db/migration/V1__init.sql +++ b/src/main/resources/db/migration/V1__init.sql @@ -2,6 +2,7 @@ CREATE TABLE IF NOT EXISTS public.users ( id UUID PRIMARY KEY, name VARCHAR(255) NOT NULL, email VARCHAR(320) NOT NULL UNIQUE, + password VARCHAR(255), provider VARCHAR(64), provider_id VARCHAR(255), jellyfin_user_id VARCHAR(255), diff --git a/src/main/resources/db/migration/V2__add_oauth2_index.sql b/src/main/resources/db/migration/V2__add_oauth2_index.sql new file mode 100644 index 0000000..b92102c --- /dev/null +++ b/src/main/resources/db/migration/V2__add_oauth2_index.sql @@ -0,0 +1,2 @@ +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_provider_provider_id +ON users(provider, provider_id); diff --git a/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt b/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt new file mode 100644 index 0000000..033cccd --- /dev/null +++ b/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt @@ -0,0 +1,77 @@ +package com.project.movienight.adapters.web + +import com.project.movienight.application.ports.input.CreateFilmUseCase +import com.project.movienight.application.ports.input.DeleteFilmUseCase +import com.project.movienight.application.ports.input.EditFilmUseCase +import com.project.movienight.application.ports.input.GetAllFilmsUseCase +import com.project.movienight.application.ports.input.GetFilmByIdUseCase +import com.project.movienight.application.ports.input.SearchFilmByTitleUseCase +import com.project.movienight.domain.model.Film +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import java.util.UUID + +class FilmControllerSearchTest { + private lateinit var mockMvc: MockMvc + private lateinit var searchFilmByTitleUseCase: SearchFilmByTitleUseCase + + @BeforeEach + fun setup() { + searchFilmByTitleUseCase = mockk() + + val controller = + FilmController( + createFilmUseCase = mockk(), + editFilmUseCase = mockk(), + deleteFilmUseCase = mockk(), + getFilmByIdUseCase = mockk(), + getAllFilmsUseCase = mockk(), + searchFilmByTitleUseCase = searchFilmByTitleUseCase, + ) + + mockMvc = MockMvcBuilders.standaloneSetup(controller).build() + } + + @Test + fun `search returns film when title exists`() { + val title = "Inception" + val film = Film(id = UUID.randomUUID(), title = title, description = "A dream heist") + + every { searchFilmByTitleUseCase.searchByTitle(title) } returns film + + mockMvc + .get("/api/films/search") { + param("title", title) + }.andExpect { + status { isOk() } + jsonPath("$.id") { value(film.id.toString()) } + jsonPath("$.title") { value(title) } + jsonPath("$.description") { value("A dream heist") } + } + + verify(exactly = 1) { searchFilmByTitleUseCase.searchByTitle(title) } + } + + @Test + fun `search returns 404 when title is not found`() { + val title = "Unknown Title" + + every { searchFilmByTitleUseCase.searchByTitle(title) } returns null + + mockMvc + .get("/api/films/search") { + param("title", title) + }.andExpect { + status { isNotFound() } + content { string("") } + } + + verify(exactly = 1) { searchFilmByTitleUseCase.searchByTitle(title) } + } +} diff --git a/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt index fcc5613..e0f96be 100644 --- a/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt +++ b/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt @@ -8,6 +8,7 @@ import com.project.movienight.config.FilmServiceProperties import com.project.movienight.domain.exception.BlockedValueException import com.project.movienight.domain.exception.EntityNotFoundException import com.project.movienight.domain.model.Film +import io.micrometer.core.instrument.simple.SimpleMeterRegistry import io.mockk.every import io.mockk.justRun import io.mockk.mockk @@ -23,6 +24,7 @@ class FilmServiceTest { private lateinit var filmRepository: FilmRepositoryPort private lateinit var idGenerator: IdGenerator private lateinit var filmConfig: FilmServiceProperties + private lateinit var meterRegistry: SimpleMeterRegistry private lateinit var filmService: FilmService @BeforeEach @@ -30,7 +32,8 @@ class FilmServiceTest { filmRepository = mockk() idGenerator = mockk() filmConfig = mockk() - filmService = FilmService(filmRepository, idGenerator, filmConfig) + meterRegistry = SimpleMeterRegistry() + filmService = FilmService(filmRepository, idGenerator, filmConfig, meterRegistry) } @Test diff --git a/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt b/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt new file mode 100644 index 0000000..c91e49e --- /dev/null +++ b/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt @@ -0,0 +1,140 @@ +package com.project.movienight.controllers + +import com.fasterxml.jackson.databind.ObjectMapper +import com.project.movienight.adapters.web.dto.request.CreateFilmRequest +import com.project.movienight.adapters.web.dto.request.EditFilmRequest +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + +@SpringBootTest +@AutoConfigureMockMvc(addFilters = false) +class FilmControllerTest { + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var objectMapper: ObjectMapper + + @Test + fun `create film should return 201 CREATED`() { + val request = + CreateFilmRequest( + title = "The Matrix", + description = "A computer hacker learns about the true nature of reality", + ) + + mockMvc + .perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)), + ).andExpect(status().isCreated) + .andExpect(jsonPath("$.title").value("The Matrix")) + .andExpect(jsonPath("$.description").value("A computer hacker learns about the true nature of reality")) + .andExpect(jsonPath("$.id").exists()) + } + + @Test + fun `edit film should return updated film`() { + val createRequest = + CreateFilmRequest( + title = "Old Title", + description = "Old Description", + ) + + val response = + mockMvc + .perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createRequest)), + ).andReturn() + + val filmId = objectMapper.readTree(response.response.contentAsString).get("id").asText() + + val editRequest = + EditFilmRequest( + title = "New Title", + description = "New Description", + ) + + mockMvc + .perform( + patch("/api/films/$filmId") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(editRequest)), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.title").value("New Title")) + .andExpect(jsonPath("$.description").value("New Description")) + } + + @Test + fun `search film by title should return film`() { + val request = + CreateFilmRequest( + title = "Inception", + description = "Dream within a dream", + ) + + mockMvc.perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)), + ) + + mockMvc + .perform( + get("/api/films/search") + .param("title", "Inception"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.title").value("Inception")) + .andExpect(jsonPath("$.description").value("Dream within a dream")) + } + + @Test + fun `search film by non-existent title should return 404`() { + mockMvc + .perform( + get("/api/films/search") + .param("title", "NonExistentFilm12345"), + ).andExpect(status().isNotFound) + } + + @Test + fun `delete film should return 204 NO CONTENT`() { + val request = + CreateFilmRequest( + title = "Film To Delete", + description = "This film will be deleted", + ) + + val response = + mockMvc + .perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)), + ).andReturn() + + val filmId = objectMapper.readTree(response.response.contentAsString).get("id").asText() + + mockMvc + .perform(delete("/api/films/$filmId")) + .andExpect(status().isNoContent()) + + mockMvc + .perform( + get("/api/films/search").param("title", "Film To Delete"), + ).andExpect(status().isNotFound) + } +} diff --git a/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt b/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt new file mode 100644 index 0000000..16d835d --- /dev/null +++ b/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt @@ -0,0 +1,204 @@ +package com.project.movienight.controllers + +import com.fasterxml.jackson.databind.ObjectMapper +import com.project.movienight.adapters.web.dto.request.CreateFilmRequest +import com.project.movienight.adapters.web.dto.request.CreateUserRequest +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional + +@SpringBootTest +@AutoConfigureMockMvc(addFilters = false) +@Transactional +class FilmLibraryControllerTest { + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var objectMapper: ObjectMapper + + @Test + fun `add film to library should work`() { + val userRequest = + CreateUserRequest( + name = "Film Adder", + email = "adder@example.com", + ) + val userResponse = + mockMvc + .perform( + post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(userRequest)), + ).andReturn() + val userId = objectMapper.readTree(userResponse.response.contentAsString).get("id").asText() + + val filmRequest = + CreateFilmRequest( + title = "Library Film", + description = "Film description", + ) + val filmResponse = + mockMvc + .perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(filmRequest)), + ).andReturn() + val filmId = objectMapper.readTree(filmResponse.response.contentAsString).get("id").asText() + + mockMvc + .perform( + post("/api/users/$userId/library/films/$filmId"), + ).andExpect(status().isCreated()) + } + + @Test + fun `remove film from library should return 204`() { + val userRequest = + CreateUserRequest( + name = "Remove Film", + email = "remove@example.com", + ) + val userResponse = + mockMvc + .perform( + post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(userRequest)), + ).andReturn() + val userId = objectMapper.readTree(userResponse.response.contentAsString).get("id").asText() + + val filmRequest = + CreateFilmRequest( + title = "Film To Remove", + description = "Will be removed", + ) + val filmResponse = + mockMvc + .perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(filmRequest)), + ).andReturn() + val filmId = objectMapper.readTree(filmResponse.response.contentAsString).get("id").asText() + + mockMvc.perform(post("/api/users/$userId/library/films/$filmId")) + mockMvc + .perform(delete("/api/users/$userId/library/films/$filmId")) + .andExpect(status().isNoContent()) + } + + @Test + fun `get available films should exclude film in user's library`() { + val userRequest = + CreateUserRequest( + name = "Available Films User", + email = "availablefilms@example.com", + ) + val userResponse = + mockMvc + .perform( + post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(userRequest)), + ).andReturn() + val userId = objectMapper.readTree(userResponse.response.contentAsString).get("id").asText() + + val film1Request = + CreateFilmRequest( + title = "Film In Library", + description = "This will be in the library", + ) + val film1Response = + mockMvc + .perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(film1Request)), + ).andReturn() + val film1Id = objectMapper.readTree(film1Response.response.contentAsString).get("id").asText() + + val film2Request = + CreateFilmRequest( + title = "Film Not In Library", + description = "This will not be in the library", + ) + val film2Response = + mockMvc + .perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(film2Request)), + ).andReturn() + val film2Id = objectMapper.readTree(film2Response.response.contentAsString).get("id").asText() + + mockMvc.perform(post("/api/users/$userId/library/films/$film1Id")) + + val result = + mockMvc + .perform( + get("/api/users/$userId/library/available-films"), + ).andExpect(status().isOk) + .andReturn() + + val responseBody = result.response.contentAsString + val films = objectMapper.readTree(responseBody) + val returnedIds = films.toList().map { it.get("id").asText() } + assert(!returnedIds.contains(film1Id)) { "Film in library should not appear in available films" } + assert(returnedIds.contains(film2Id)) { "Film not in library should appear in available films" } + } + + @Test + fun `get available films for user without library returns all films`() { + val userRequest = + CreateUserRequest( + name = "No Library User", + email = "nolibrary@example.com", + ) + val userResponse = + mockMvc + .perform( + post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(userRequest)), + ).andReturn() + val userId = objectMapper.readTree(userResponse.response.contentAsString).get("id").asText() + + val filmRequest = + CreateFilmRequest( + title = "Available Film", + description = "Should appear in available films", + ) + val filmResponse = + mockMvc + .perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(filmRequest)), + ).andReturn() + val filmId = objectMapper.readTree(filmResponse.response.contentAsString).get("id").asText() + + val result = + mockMvc + .perform( + get("/api/users/$userId/library/available-films"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$[*].id").isArray) + .andReturn() + + val responseBody = result.response.contentAsString + val films = objectMapper.readTree(responseBody) + val returnedIds = films.toList().map { it.get("id").asText() } + assert(returnedIds.contains(filmId)) { "Film should appear in available films when user has no library" } + } +} diff --git a/src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt b/src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt new file mode 100644 index 0000000..342fda9 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt @@ -0,0 +1,108 @@ +package com.project.movienight.controllers + +import com.fasterxml.jackson.databind.ObjectMapper +import com.project.movienight.adapters.web.dto.request.CreateUserRequest +import com.project.movienight.adapters.web.dto.request.EditUserRequest +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional + +@SpringBootTest +@AutoConfigureMockMvc(addFilters = false) +@Transactional +class UserControllerTest { + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var objectMapper: ObjectMapper + + @Test + fun `create user should return 201 CREATED`() { + val request = + CreateUserRequest( + name = "John Doe", + email = "john@example.com", + ) + + mockMvc + .perform( + post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)), + ).andExpect(status().isCreated) + .andExpect(jsonPath("$.name").value("John Doe")) + .andExpect(jsonPath("$.email").value("john@example.com")) + .andExpect(jsonPath("$.id").exists()) + } + + @Test + fun `edit user should return updated user`() { + val createRequest = + CreateUserRequest( + name = "Old Name", + email = "edit@example.com", + ) + + val response = + mockMvc + .perform( + post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createRequest)), + ).andReturn() + + val userId = objectMapper.readTree(response.response.contentAsString).get("id").asText() + + val editRequest = EditUserRequest(name = "New Name") + + mockMvc + .perform( + patch("/api/users/$userId") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(editRequest)), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.name").value("New Name")) + .andExpect(jsonPath("$.email").value("edit@example.com")) + } + + @Test + fun `delete user should return 204 NO CONTENT`() { + val request = + CreateUserRequest( + name = "User To Delete", + email = "delete@example.com", + ) + + val response = + mockMvc + .perform( + post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)), + ).andReturn() + + val userId = objectMapper.readTree(response.response.contentAsString).get("id").asText() + + mockMvc + .perform(delete("/api/users/$userId")) + .andExpect(status().isNoContent()) + } + + @Test + fun `delete non-existent user should return 404`() { + val nonExistentId = "123e4567-e89b-12d3-a456-426614174000" + mockMvc + .perform(delete("/api/users/$nonExistentId")) + .andExpect(status().isNotFound()) + } +}