Merge branch 'develop' into feat/extend-data-structures-48

This commit is contained in:
ITQ
2026-05-20 03:30:08 +03:00
committed by GitHub
31 changed files with 1230 additions and 45 deletions
+2 -5
View File
@@ -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)
} }
+2 -1
View File
@@ -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>
}
@@ -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()
} }
+35
View File
@@ -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())
}
}