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 {
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)
}
+2 -1
View File
@@ -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>
}
@@ -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,14 +26,33 @@ class FilmService(
private val filmRepository: FilmRepositoryPort,
private val idGenerator: IdGenerator,
private val filmConfig: FilmServiceProperties,
private val meterRegistry: MeterRegistry,
) : CreateFilmUseCase,
EditFilmUseCase,
DeleteFilmUseCase {
DeleteFilmUseCase,
GetFilmByIdUseCase,
GetAllFilmsUseCase,
SearchFilmByTitleUseCase {
private val log = LoggerFactory.getLogger(javaClass)
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,
)
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")
}
@@ -36,30 +62,129 @@ class FilmService(
title = command.title,
description = command.description,
)
return filmRepository.save(film)
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 {
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")
}
var film = filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString())
val film = filmRepository.findById(id)
film = film.copy(title = command.title, description = command.description)
if (film == null) {
log.debug("Film not found for edit: id='{}'", id)
throw EntityNotFoundException(entity = "Film", id = id.toString())
}
return filmRepository.save(film)
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)
}
}
override fun delete(id: UUID) {
filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString())
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())
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()
}
+35
View File
@@ -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())
}
}