Merge branch 'develop' into feat/extend-data-structures-48
This commit is contained in:
+2
-5
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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(
|
||||
"""
|
||||
|
||||
@@ -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<User> =
|
||||
jdbc
|
||||
.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.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,
|
||||
)
|
||||
|
||||
@@ -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<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.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<FilmResponse> {
|
||||
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<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.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<UserResponse> = 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,
|
||||
|
||||
@@ -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<Film>
|
||||
}
|
||||
|
||||
interface SearchFilmByTitleUseCase {
|
||||
fun searchByTitle(title: String): Film?
|
||||
}
|
||||
|
||||
@@ -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<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 findByTitle(title: String): Film?
|
||||
|
||||
fun deleteById(id: UUID)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ interface UserRepositoryPort {
|
||||
|
||||
fun findById(id: UUID): User?
|
||||
|
||||
fun findByEmail(email: String): User?
|
||||
|
||||
fun findAll(): List<User>
|
||||
|
||||
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.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<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.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<User> = userRepository.findAll()
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.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
|
||||
|
||||
@@ -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