Merge branch 'develop' into feat/extend-data-structures-48
This commit is contained in:
+2
-5
@@ -30,11 +30,10 @@ java {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(platform(libs.sentry.bom))
|
implementation(platform(libs.sentry.bom))
|
||||||
implementation(platform(libs.spring.grpc.bom))
|
|
||||||
|
|
||||||
implementation(libs.spring.boot.starter.web)
|
implementation(libs.spring.boot.starter.web)
|
||||||
implementation(libs.spring.boot.starter.actuator)
|
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.cache)
|
||||||
implementation(libs.spring.boot.starter.data.jdbc)
|
implementation(libs.spring.boot.starter.data.jdbc)
|
||||||
implementation(libs.spring.boot.starter.validation)
|
implementation(libs.spring.boot.starter.validation)
|
||||||
@@ -46,8 +45,7 @@ dependencies {
|
|||||||
implementation(libs.opentelemetry.exporter.otlp)
|
implementation(libs.opentelemetry.exporter.otlp)
|
||||||
implementation(libs.sentry.spring.boot.starter)
|
implementation(libs.sentry.spring.boot.starter)
|
||||||
|
|
||||||
implementation(libs.spring.grpc.starter)
|
implementation(libs.spring.boot.starter.oauth2.client)
|
||||||
implementation(libs.grpc.services)
|
|
||||||
|
|
||||||
runtimeOnly(libs.micrometer.registry.prometheus)
|
runtimeOnly(libs.micrometer.registry.prometheus)
|
||||||
runtimeOnly(libs.h2)
|
runtimeOnly(libs.h2)
|
||||||
@@ -57,7 +55,6 @@ dependencies {
|
|||||||
|
|
||||||
testImplementation(libs.spring.boot.starter.test)
|
testImplementation(libs.spring.boot.starter.test)
|
||||||
testImplementation(libs.kotlin.test.junit5)
|
testImplementation(libs.kotlin.test.junit5)
|
||||||
testImplementation(libs.spring.grpc.test)
|
|
||||||
testImplementation(libs.mockk)
|
testImplementation(libs.mockk)
|
||||||
testRuntimeOnly(libs.junit.platform.launcher)
|
testRuntimeOnly(libs.junit.platform.launcher)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ spring-grpc = "1.0.1"
|
|||||||
protoc = "3.25.1"
|
protoc = "3.25.1"
|
||||||
grpc-java = "1.60.0"
|
grpc-java = "1.60.0"
|
||||||
springdoc = "2.8.6"
|
springdoc = "2.8.6"
|
||||||
mockk = "1.13.12"
|
mockk = "1.13.13"
|
||||||
|
|
||||||
[libraries]
|
[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-web = { module = "org.springframework.boot:spring-boot-starter-web" }
|
||||||
spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator" }
|
spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator" }
|
||||||
spring-boot-starter-security = { module = "org.springframework.boot:spring-boot-starter-security" }
|
spring-boot-starter-security = { module = "org.springframework.boot:spring-boot-starter-security" }
|
||||||
|
|||||||
@@ -206,6 +206,16 @@ class FilmRepository(
|
|||||||
filmRowMapper,
|
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) {
|
override fun deleteById(id: UUID) {
|
||||||
jdbc.update(
|
jdbc.update(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -28,7 +28,20 @@ class UserRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun save(user: User): User {
|
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 =
|
val updatedRows =
|
||||||
jdbc.update(
|
jdbc.update(
|
||||||
"""
|
"""
|
||||||
@@ -43,6 +56,7 @@ class UserRepository(
|
|||||||
entity.jellyfinUserId,
|
entity.jellyfinUserId,
|
||||||
entity.id,
|
entity.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (updatedRows == 0) {
|
if (updatedRows == 0) {
|
||||||
jdbc.update(
|
jdbc.update(
|
||||||
"""
|
"""
|
||||||
@@ -71,6 +85,16 @@ class UserRepository(
|
|||||||
return entities.firstOrNull()?.toDomain()
|
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<User> =
|
override fun findAll(): List<User> =
|
||||||
jdbc
|
jdbc
|
||||||
.query(
|
.query(
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, Any>,
|
||||||
|
) : 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<String, Any> = attributes
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, Any>? = null,
|
||||||
|
) : OAuth2User,
|
||||||
|
UserDetails {
|
||||||
|
fun getId(): UUID = user.id
|
||||||
|
|
||||||
|
override fun getName(): String = user.name
|
||||||
|
|
||||||
|
override fun getAttributes(): Map<String, Any> = attributes ?: emptyMap()
|
||||||
|
|
||||||
|
override fun getAuthorities(): Collection<GrantedAuthority> =
|
||||||
|
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<String, Any>? = null,
|
||||||
|
): UserPrincipal = UserPrincipal(user, attributes)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, Any>,
|
||||||
|
) : 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<String, Any> = attributes
|
||||||
|
}
|
||||||
@@ -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<String, Any>,
|
||||||
|
) : 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<String, Any> = attributes
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ package com.project.movienight.adapters.web
|
|||||||
import com.project.movienight.domain.exception.BlockedValueException
|
import com.project.movienight.domain.exception.BlockedValueException
|
||||||
import com.project.movienight.domain.exception.DomainException
|
import com.project.movienight.domain.exception.DomainException
|
||||||
import com.project.movienight.domain.exception.EntityNotFoundException
|
import com.project.movienight.domain.exception.EntityNotFoundException
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.slf4j.MDC
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler
|
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||||
import org.springframework.web.bind.annotation.ResponseStatus
|
import org.springframework.web.bind.annotation.ResponseStatus
|
||||||
@@ -10,22 +12,60 @@ import org.springframework.web.bind.annotation.RestControllerAdvice
|
|||||||
|
|
||||||
@RestControllerAdvice
|
@RestControllerAdvice
|
||||||
class ApiExceptionHandler {
|
class ApiExceptionHandler {
|
||||||
|
private val log = LoggerFactory.getLogger(javaClass)
|
||||||
|
|
||||||
@ExceptionHandler(EntityNotFoundException::class)
|
@ExceptionHandler(EntityNotFoundException::class)
|
||||||
@ResponseStatus(HttpStatus.NOT_FOUND)
|
@ResponseStatus(HttpStatus.NOT_FOUND)
|
||||||
fun handleNotFound(exception: EntityNotFoundException): ErrorResponse =
|
fun handleNotFound(exception: EntityNotFoundException): ErrorResponse {
|
||||||
ErrorResponse(message = exception.message ?: "Entity not found")
|
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)
|
@ExceptionHandler(BlockedValueException::class)
|
||||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||||
fun handleBlockedValue(exception: BlockedValueException): ErrorResponse =
|
fun handleBlockedValue(exception: BlockedValueException): ErrorResponse {
|
||||||
ErrorResponse(message = exception.message ?: "Blocked value")
|
val traceId = currentTraceId()
|
||||||
|
log.warn("Blocked value: traceId='{}', message='{}'", traceId, exception.message)
|
||||||
|
|
||||||
|
return ErrorResponse(
|
||||||
|
message = exception.message ?: "Blocked value",
|
||||||
|
traceId = traceId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@ExceptionHandler(DomainException::class)
|
@ExceptionHandler(DomainException::class)
|
||||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||||
fun handleDomainException(exception: DomainException): ErrorResponse =
|
fun handleDomainException(exception: DomainException): ErrorResponse {
|
||||||
ErrorResponse(message = exception.message ?: "Domain error")
|
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(
|
data class ErrorResponse(
|
||||||
val message: String,
|
val message: String,
|
||||||
|
val traceId: String,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.DeleteFilmUseCase
|
||||||
import com.project.movienight.application.ports.input.EditFilmCommand
|
import com.project.movienight.application.ports.input.EditFilmCommand
|
||||||
import com.project.movienight.application.ports.input.EditFilmUseCase
|
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.HttpStatus
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping
|
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.PatchMapping
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
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.ResponseStatus
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -25,6 +31,9 @@ class FilmController(
|
|||||||
private val createFilmUseCase: CreateFilmUseCase,
|
private val createFilmUseCase: CreateFilmUseCase,
|
||||||
private val editFilmUseCase: EditFilmUseCase,
|
private val editFilmUseCase: EditFilmUseCase,
|
||||||
private val deleteFilmUseCase: DeleteFilmUseCase,
|
private val deleteFilmUseCase: DeleteFilmUseCase,
|
||||||
|
private val getFilmByIdUseCase: GetFilmByIdUseCase,
|
||||||
|
private val getAllFilmsUseCase: GetAllFilmsUseCase,
|
||||||
|
private val searchFilmByTitleUseCase: SearchFilmByTitleUseCase,
|
||||||
) {
|
) {
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
@@ -61,4 +70,24 @@ class FilmController(
|
|||||||
fun delete(
|
fun delete(
|
||||||
@PathVariable id: UUID,
|
@PathVariable id: UUID,
|
||||||
) = deleteFilmUseCase.delete(id)
|
) = deleteFilmUseCase.delete(id)
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
fun getById(
|
||||||
|
@PathVariable id: UUID,
|
||||||
|
): FilmResponse = FilmResponse.fromDomain(getFilmByIdUseCase.getById(id))
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
fun getAll(): List<FilmResponse> = getAllFilmsUseCase.getAll().map { FilmResponse.fromDomain(it) }
|
||||||
|
|
||||||
|
@GetMapping("/search")
|
||||||
|
fun searchByTitle(
|
||||||
|
@RequestParam title: String,
|
||||||
|
): ResponseEntity<FilmResponse> {
|
||||||
|
val film = searchFilmByTitleUseCase.searchByTitle(title)
|
||||||
|
return if (film != null) {
|
||||||
|
ResponseEntity.ok(FilmResponse.fromDomain(film))
|
||||||
|
} else {
|
||||||
|
ResponseEntity.notFound().build()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.request.CreateFilmLibraryRequest
|
||||||
import com.project.movienight.adapters.web.dto.response.FilmLibraryResponse
|
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.AddFilmToLibraryCommand
|
||||||
import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase
|
import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase
|
||||||
import com.project.movienight.application.ports.input.CreateFilmLibraryCommand
|
import com.project.movienight.application.ports.input.CreateFilmLibraryCommand
|
||||||
import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase
|
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.GetFilmLibraryQuery
|
||||||
import com.project.movienight.application.ports.input.GetFilmLibraryUseCase
|
import com.project.movienight.application.ports.input.GetFilmLibraryUseCase
|
||||||
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand
|
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand
|
||||||
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase
|
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase
|
||||||
|
import com.project.movienight.domain.exception.EntityNotFoundException
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
@@ -28,6 +32,8 @@ class FilmLibraryController(
|
|||||||
private val addFilmToLibraryUseCase: AddFilmToLibraryUseCase,
|
private val addFilmToLibraryUseCase: AddFilmToLibraryUseCase,
|
||||||
private val removeFilmFromLibraryUseCase: RemoveFilmFromLibraryUseCase,
|
private val removeFilmFromLibraryUseCase: RemoveFilmFromLibraryUseCase,
|
||||||
private val getFilmLibraryUseCase: GetFilmLibraryUseCase,
|
private val getFilmLibraryUseCase: GetFilmLibraryUseCase,
|
||||||
|
private val getFilmByIdUseCase: GetFilmByIdUseCase,
|
||||||
|
private val getAllFilmsUseCase: GetAllFilmsUseCase,
|
||||||
) {
|
) {
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
@@ -54,6 +60,19 @@ class FilmLibraryController(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@GetMapping("/films")
|
||||||
|
fun getAllFilmsInLibrary(
|
||||||
|
@PathVariable userId: UUID,
|
||||||
|
): List<FilmResponse> {
|
||||||
|
val library =
|
||||||
|
getFilmLibraryUseCase.getLibrary(
|
||||||
|
GetFilmLibraryQuery(userId = userId),
|
||||||
|
)
|
||||||
|
|
||||||
|
val film = getFilmByIdUseCase.getById(library.filmId)
|
||||||
|
return listOf(FilmResponse.fromDomain(film))
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/films/{filmId}")
|
@PostMapping("/films/{filmId}")
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
fun addFilm(
|
fun addFilm(
|
||||||
@@ -82,4 +101,31 @@ class FilmLibraryController(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/available-films")
|
||||||
|
fun getAvailableFilms(
|
||||||
|
@PathVariable userId: UUID,
|
||||||
|
): List<FilmResponse> {
|
||||||
|
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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.DeleteUserUseCase
|
||||||
import com.project.movienight.application.ports.input.EditUserCommand
|
import com.project.movienight.application.ports.input.EditUserCommand
|
||||||
import com.project.movienight.application.ports.input.EditUserUseCase
|
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.http.HttpStatus
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping
|
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.PatchMapping
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
@@ -25,6 +28,8 @@ class UserController(
|
|||||||
private val createUserUseCase: CreateUserUseCase,
|
private val createUserUseCase: CreateUserUseCase,
|
||||||
private val editUserUseCase: EditUserUseCase,
|
private val editUserUseCase: EditUserUseCase,
|
||||||
private val deleteUserUseCase: DeleteUserUseCase,
|
private val deleteUserUseCase: DeleteUserUseCase,
|
||||||
|
private val getUserByIdUseCase: GetUserByIdUseCase,
|
||||||
|
private val getAllUsersUseCase: GetAllUsersUseCase,
|
||||||
) {
|
) {
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
@@ -40,6 +45,14 @@ class UserController(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
fun getAll(): List<UserResponse> = getAllUsersUseCase.getAll().map { UserResponse.fromDomain(it) }
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
fun getById(
|
||||||
|
@PathVariable id: UUID,
|
||||||
|
): UserResponse = UserResponse.fromDomain(getUserByIdUseCase.getById(id))
|
||||||
|
|
||||||
@PatchMapping("/{id}")
|
@PatchMapping("/{id}")
|
||||||
fun edit(
|
fun edit(
|
||||||
@PathVariable id: UUID,
|
@PathVariable id: UUID,
|
||||||
|
|||||||
@@ -27,3 +27,15 @@ data class EditFilmCommand(
|
|||||||
interface DeleteFilmUseCase {
|
interface DeleteFilmUseCase {
|
||||||
fun delete(id: UUID)
|
fun delete(id: UUID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GetFilmByIdUseCase {
|
||||||
|
fun getById(id: UUID): Film
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetAllFilmsUseCase {
|
||||||
|
fun getAll(): List<Film>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchFilmByTitleUseCase {
|
||||||
|
fun searchByTitle(title: String): Film?
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,3 +26,11 @@ data class EditUserCommand(
|
|||||||
interface DeleteUserUseCase {
|
interface DeleteUserUseCase {
|
||||||
fun delete(id: UUID)
|
fun delete(id: UUID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GetUserByIdUseCase {
|
||||||
|
fun getById(id: UUID): User
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetAllUsersUseCase {
|
||||||
|
fun getAll(): List<User>
|
||||||
|
}
|
||||||
|
|||||||
+13
@@ -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<String, Any>
|
||||||
|
}
|
||||||
@@ -10,5 +10,7 @@ interface FilmRepositoryPort {
|
|||||||
|
|
||||||
fun findAll(): List<Film>
|
fun findAll(): List<Film>
|
||||||
|
|
||||||
|
fun findByTitle(title: String): Film?
|
||||||
|
|
||||||
fun deleteById(id: UUID)
|
fun deleteById(id: UUID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ interface UserRepositoryPort {
|
|||||||
|
|
||||||
fun findById(id: UUID): User?
|
fun findById(id: UUID): User?
|
||||||
|
|
||||||
|
fun findByEmail(email: String): User?
|
||||||
|
|
||||||
fun findAll(): List<User>
|
fun findAll(): List<User>
|
||||||
|
|
||||||
fun deleteById(id: UUID)
|
fun deleteById(id: UUID)
|
||||||
|
|||||||
@@ -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.DeleteFilmUseCase
|
||||||
import com.project.movienight.application.ports.input.EditFilmCommand
|
import com.project.movienight.application.ports.input.EditFilmCommand
|
||||||
import com.project.movienight.application.ports.input.EditFilmUseCase
|
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.FilmRepositoryPort
|
||||||
import com.project.movienight.application.ports.output.IdGenerator
|
import com.project.movienight.application.ports.output.IdGenerator
|
||||||
import com.project.movienight.config.FilmServiceProperties
|
import com.project.movienight.config.FilmServiceProperties
|
||||||
import com.project.movienight.domain.exception.BlockedValueException
|
import com.project.movienight.domain.exception.BlockedValueException
|
||||||
import com.project.movienight.domain.exception.EntityNotFoundException
|
import com.project.movienight.domain.exception.EntityNotFoundException
|
||||||
import com.project.movienight.domain.model.Film
|
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 org.springframework.stereotype.Service
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -19,47 +26,165 @@ class FilmService(
|
|||||||
private val filmRepository: FilmRepositoryPort,
|
private val filmRepository: FilmRepositoryPort,
|
||||||
private val idGenerator: IdGenerator,
|
private val idGenerator: IdGenerator,
|
||||||
private val filmConfig: FilmServiceProperties,
|
private val filmConfig: FilmServiceProperties,
|
||||||
|
private val meterRegistry: MeterRegistry,
|
||||||
) : CreateFilmUseCase,
|
) : CreateFilmUseCase,
|
||||||
EditFilmUseCase,
|
EditFilmUseCase,
|
||||||
DeleteFilmUseCase {
|
DeleteFilmUseCase,
|
||||||
override fun create(command: CreateFilmCommand): Film {
|
GetFilmByIdUseCase,
|
||||||
if (filmConfig.isBlocked(command.title)) {
|
GetAllFilmsUseCase,
|
||||||
throw BlockedValueException(target = "Film", field = "title")
|
SearchFilmByTitleUseCase {
|
||||||
}
|
private val log = LoggerFactory.getLogger(javaClass)
|
||||||
if (filmConfig.isBlocked(command.description)) {
|
|
||||||
throw BlockedValueException(target = "Film", field = "description")
|
|
||||||
}
|
|
||||||
|
|
||||||
val film =
|
override fun create(command: CreateFilmCommand): Film {
|
||||||
Film(
|
val sample = Timer.start(meterRegistry)
|
||||||
id = idGenerator.generateId(),
|
|
||||||
title = command.title,
|
try {
|
||||||
description = command.description,
|
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(
|
override fun edit(
|
||||||
id: UUID,
|
id: UUID,
|
||||||
command: EditFilmCommand,
|
command: EditFilmCommand,
|
||||||
): Film {
|
): Film {
|
||||||
if (filmConfig.isBlocked(command.title)) {
|
val sample = Timer.start(meterRegistry)
|
||||||
throw BlockedValueException(target = "Film", field = "title")
|
|
||||||
|
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) {
|
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.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString())
|
||||||
|
|
||||||
filmRepository.deleteById(id)
|
override fun getAll(): List<Film> = 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.DeleteUserUseCase
|
||||||
import com.project.movienight.application.ports.input.EditUserCommand
|
import com.project.movienight.application.ports.input.EditUserCommand
|
||||||
import com.project.movienight.application.ports.input.EditUserUseCase
|
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.IdGenerator
|
||||||
import com.project.movienight.application.ports.output.UserRepositoryPort
|
import com.project.movienight.application.ports.output.UserRepositoryPort
|
||||||
import com.project.movienight.config.UserServiceProperties
|
import com.project.movienight.config.UserServiceProperties
|
||||||
@@ -21,7 +23,9 @@ class UserService(
|
|||||||
private val userConfig: UserServiceProperties,
|
private val userConfig: UserServiceProperties,
|
||||||
) : CreateUserUseCase,
|
) : CreateUserUseCase,
|
||||||
EditUserUseCase,
|
EditUserUseCase,
|
||||||
DeleteUserUseCase {
|
DeleteUserUseCase,
|
||||||
|
GetUserByIdUseCase,
|
||||||
|
GetAllUsersUseCase {
|
||||||
override fun create(command: CreateUserCommand): User {
|
override fun create(command: CreateUserCommand): User {
|
||||||
if (userConfig.isBlocked(command.name)) {
|
if (userConfig.isBlocked(command.name)) {
|
||||||
throw BlockedValueException(target = "User", field = "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())
|
var user = userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString())
|
||||||
|
|
||||||
user = user.copy(name = command.name)
|
user = user.copy(name = command.name)
|
||||||
|
|
||||||
return userRepository.save(user)
|
return userRepository.save(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun delete(id: UUID) {
|
override fun delete(id: UUID) {
|
||||||
userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString())
|
userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString())
|
||||||
|
|
||||||
userRepository.deleteById(id)
|
userRepository.deleteById(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getById(id: UUID): User =
|
||||||
|
userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString())
|
||||||
|
|
||||||
|
override fun getAll(): List<User> = userRepository.findAll()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,38 @@ spring:
|
|||||||
console:
|
console:
|
||||||
enabled: ${SPRING_H2_CONSOLE_ENABLED:true}
|
enabled: ${SPRING_H2_CONSOLE_ENABLED:true}
|
||||||
path: /h2-console
|
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:
|
server:
|
||||||
shutdown: graceful
|
shutdown: graceful
|
||||||
@@ -74,3 +106,6 @@ services:
|
|||||||
- censored
|
- censored
|
||||||
- epstein
|
- epstein
|
||||||
- python
|
- python
|
||||||
|
logging:
|
||||||
|
pattern:
|
||||||
|
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%X{traceId}] %logger{36} - %msg%n"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ CREATE TABLE IF NOT EXISTS public.users (
|
|||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
name VARCHAR(255) NOT NULL,
|
name VARCHAR(255) NOT NULL,
|
||||||
email VARCHAR(320) NOT NULL UNIQUE,
|
email VARCHAR(320) NOT NULL UNIQUE,
|
||||||
|
password VARCHAR(255),
|
||||||
provider VARCHAR(64),
|
provider VARCHAR(64),
|
||||||
provider_id VARCHAR(255),
|
provider_id VARCHAR(255),
|
||||||
jellyfin_user_id VARCHAR(255),
|
jellyfin_user_id VARCHAR(255),
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_provider_provider_id
|
||||||
|
ON users(provider, provider_id);
|
||||||
@@ -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<CreateFilmUseCase>(),
|
||||||
|
editFilmUseCase = mockk<EditFilmUseCase>(),
|
||||||
|
deleteFilmUseCase = mockk<DeleteFilmUseCase>(),
|
||||||
|
getFilmByIdUseCase = mockk<GetFilmByIdUseCase>(),
|
||||||
|
getAllFilmsUseCase = mockk<GetAllFilmsUseCase>(),
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import com.project.movienight.config.FilmServiceProperties
|
|||||||
import com.project.movienight.domain.exception.BlockedValueException
|
import com.project.movienight.domain.exception.BlockedValueException
|
||||||
import com.project.movienight.domain.exception.EntityNotFoundException
|
import com.project.movienight.domain.exception.EntityNotFoundException
|
||||||
import com.project.movienight.domain.model.Film
|
import com.project.movienight.domain.model.Film
|
||||||
|
import io.micrometer.core.instrument.simple.SimpleMeterRegistry
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.justRun
|
import io.mockk.justRun
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
@@ -23,6 +24,7 @@ class FilmServiceTest {
|
|||||||
private lateinit var filmRepository: FilmRepositoryPort
|
private lateinit var filmRepository: FilmRepositoryPort
|
||||||
private lateinit var idGenerator: IdGenerator
|
private lateinit var idGenerator: IdGenerator
|
||||||
private lateinit var filmConfig: FilmServiceProperties
|
private lateinit var filmConfig: FilmServiceProperties
|
||||||
|
private lateinit var meterRegistry: SimpleMeterRegistry
|
||||||
private lateinit var filmService: FilmService
|
private lateinit var filmService: FilmService
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
@@ -30,7 +32,8 @@ class FilmServiceTest {
|
|||||||
filmRepository = mockk()
|
filmRepository = mockk()
|
||||||
idGenerator = mockk()
|
idGenerator = mockk()
|
||||||
filmConfig = mockk()
|
filmConfig = mockk()
|
||||||
filmService = FilmService(filmRepository, idGenerator, filmConfig)
|
meterRegistry = SimpleMeterRegistry()
|
||||||
|
filmService = FilmService(filmRepository, idGenerator, filmConfig, meterRegistry)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user