refactor: обновить use case слои и интеграцию Jellyfin #58

Merged
skettiks merged 1 commits from fix-develop-problems into develop 2026-05-22 13:56:26 +00:00
67 changed files with 1144 additions and 995 deletions
Showing only changes of commit ef995422dd - Show all commits
+1
View File
@@ -33,6 +33,7 @@ dependencies {
implementation(libs.spring.boot.starter.web)
implementation(libs.spring.boot.starter.actuator)
implementation(libs.spring.boot.starter.aop)
implementation(libs.spring.boot.starter.security)
implementation(libs.spring.boot.starter.cache)
implementation(libs.spring.boot.starter.data.jdbc)
+1
View File
@@ -17,6 +17,7 @@ mockk = "1.13.13"
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-aop = { module = "org.springframework.boot:spring-boot-starter-aop" }
spring-boot-starter-security = { module = "org.springframework.boot:spring-boot-starter-security" }
spring-boot-starter-cache = { module = "org.springframework.boot:spring-boot-starter-cache" }
spring-boot-starter-data-jdbc = { module = "org.springframework.boot:spring-boot-starter-data-jdbc" }
@@ -2,6 +2,9 @@ package com.project.movienight.adapters.jellyfin
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.project.movienight.application.ports.output.JellyfinCatalogPort
import com.project.movienight.application.ports.output.JellyfinLibraryItemSnapshot
import com.project.movienight.application.ports.output.JellyfinRemoteUser
import com.project.movienight.config.JellyfinIntegrationProperties
import com.project.movienight.domain.model.ContentType
import org.springframework.stereotype.Service
@@ -11,39 +14,18 @@ import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.time.Duration
data class JellyfinRemoteUser(
val id: String,
val name: String,
)
data class JellyfinLibraryItemSnapshot(
val jellyfinItemId: String,
val title: String,
val description: String,
val contentType: ContentType,
val releaseYear: Int?,
val genres: List<String>,
val cast: List<String>,
val directors: List<String>,
val platformRating: Double?,
val imdbRating: Double?,
val externalUrl: String?,
val jellyfinLibraryId: String?,
val isPlayed: Boolean,
)
@Service
class JellyfinApiClient(
private val properties: JellyfinIntegrationProperties,
private val objectMapper: ObjectMapper,
) {
) : JellyfinCatalogPort {
private val httpClient: HttpClient =
HttpClient
.newBuilder()
.connectTimeout(Duration.ofMillis(properties.requestTimeoutMs))
.build()
fun fetchUsers(): List<JellyfinRemoteUser> =
override fun fetchUsers(): List<JellyfinRemoteUser> =
request("Users")
.asItems()
.mapNotNull { node ->
@@ -51,7 +33,7 @@ class JellyfinApiClient(
JellyfinRemoteUser(id = id, name = node.fieldText("Name") ?: id)
}
fun fetchLibraryItems(userId: String): List<JellyfinLibraryItemSnapshot> =
override fun fetchLibraryItems(userId: String): List<JellyfinLibraryItemSnapshot> =
@Suppress("MaxLineLength")
request(
"Users/$userId/Items?Recursive=true&IncludeItemTypes=Movie,Series,Episode&Fields=Genres,People,ProviderIds,Overview,ProductionYear,CommunityRating,OfficialRating,ParentId,UserData",
@@ -1,5 +1,6 @@
package com.project.movienight.adapters.metrics
import com.project.movienight.application.ports.output.BusinessMetricsPort
import com.project.movienight.domain.model.JellyfinSyncSummary
import com.project.movienight.domain.model.RecommendationEventType
import io.micrometer.core.instrument.Counter
@@ -11,8 +12,12 @@ import java.util.concurrent.atomic.AtomicInteger
@Service
class BusinessMetricsService(
private val meterRegistry: MeterRegistry,
) {
) : BusinessMetricsPort {
private val recommendationRequests: Counter = meterRegistry.counter("business_recommendation_requests_total")
private val filmsCreated: Counter = meterRegistry.counter("business_films_created_total")
private val filmsEdited: Counter = meterRegistry.counter("business_films_edited_total")
private val filmsDeleted: Counter = meterRegistry.counter("business_films_deleted_total")
private val filmsBlocked: Counter = meterRegistry.counter("business_films_blocked_total")
private val ratingsSubmitted: Counter = meterRegistry.counter("business_ratings_submitted_total")
private val libraryEvents: Counter = meterRegistry.counter("business_library_events_total")
private val jellyfinSyncRuns: Counter = meterRegistry.counter("business_jellyfin_sync_runs_total")
@@ -33,11 +38,27 @@ class BusinessMetricsService(
private val backendWriteFailures: Counter = meterRegistry.counter("business_jellyfin_backend_write_failures_total")
fun recordRecommendationRequest() {
override fun recordFilmCreated() {
filmsCreated.increment()
}
override fun recordFilmEdited() {
filmsEdited.increment()
}
override fun recordFilmDeleted() {
filmsDeleted.increment()
}
override fun recordFilmBlocked() {
filmsBlocked.increment()
}
override fun recordRecommendationRequest() {
recommendationRequests.increment()
}
fun recordRecommendationWeightsUpdated(eventType: RecommendationEventType) {
override fun recordRecommendationWeightsUpdated(eventType: RecommendationEventType) {
Counter
.builder("recommendation_weights_updated_total")
.tag("eventType", eventType.name)
@@ -45,15 +66,15 @@ class BusinessMetricsService(
.increment()
}
fun recordRatingSubmitted() {
override fun recordRatingSubmitted() {
ratingsSubmitted.increment()
}
fun recordLibraryEvent() {
override fun recordLibraryEvent() {
libraryEvents.increment()
}
fun recordJellyfinSync(summary: JellyfinSyncSummary) {
override fun recordJellyfinSync(summary: JellyfinSyncSummary) {
jellyfinSyncRuns.increment()
jellyfinSyncedUsers.increment(summary.syncedUsers.toDouble())
jellyfinSkippedUsers.increment(summary.skippedUsers.toDouble())
@@ -61,15 +82,15 @@ class BusinessMetricsService(
jellyfinSyncDuration.record(summary.durationMs, java.util.concurrent.TimeUnit.MILLISECONDS)
}
fun recordJellyfinSyncFailure() {
override fun recordJellyfinSyncFailure() {
jellyfinSyncFailures.increment()
}
fun recordJellyfinUnmappedUser() {
override fun recordJellyfinUnmappedUser() {
jellyfinUnmappedUsersGaugeValue.incrementAndGet()
}
fun recordBackendWriteFailure() {
override fun recordBackendWriteFailure() {
backendWriteFailures.increment()
}
}
@@ -20,7 +20,6 @@ fun UserEntity.toDomain(): User =
id = id,
name = name,
email = email,
library = null,
preferences = null,
jellyfinUserId = jellyfinUserId,
)
@@ -1,18 +1,18 @@
package com.project.movienight.adapters.persistence.jdbc
import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
import com.project.movienight.domain.model.FilmLibrary
import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort
import com.project.movienight.domain.model.FilmLibraryEntry
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Repository
import java.sql.ResultSet
import java.util.UUID
@Repository
class FilmLibraryRepository(
class FilmLibraryEntryRepository(
private val jdbc: JdbcTemplate,
) : FilmLibraryRepositoryPort {
private val filmLibraryRowMapper = { rs: ResultSet, _: Int ->
FilmLibrary(
) : FilmLibraryEntryRepositoryPort {
private val rowMapper = { rs: ResultSet, _: Int ->
FilmLibraryEntry(
id = UUID.fromString(rs.getString("id")),
userId = UUID.fromString(rs.getString("user_id")),
filmId = UUID.fromString(rs.getString("film_id")),
@@ -22,7 +22,7 @@ class FilmLibraryRepository(
)
}
override fun save(filmLibrary: FilmLibrary): FilmLibrary {
override fun save(entry: FilmLibraryEntry): FilmLibraryEntry {
val updatedRows =
jdbc.update(
"""
@@ -30,12 +30,12 @@ class FilmLibraryRepository(
SET user_id = ?, film_id = ?, comment = ?, is_viewed = ?, watched_at = ?
WHERE id = ?
""".trimIndent(),
filmLibrary.userId,
filmLibrary.filmId,
filmLibrary.comment,
filmLibrary.isViewed,
filmLibrary.watchedAt,
filmLibrary.id,
entry.userId,
entry.filmId,
entry.comment,
entry.isViewed,
entry.watchedAt,
entry.id,
)
if (updatedRows == 0) {
jdbc.update(
@@ -43,48 +43,51 @@ class FilmLibraryRepository(
INSERT INTO favorites (id, user_id, film_id, comment, is_viewed, watched_at)
VALUES (?, ?, ?, ?, ?, ?)
""".trimIndent(),
filmLibrary.id,
filmLibrary.userId,
filmLibrary.filmId,
filmLibrary.comment,
filmLibrary.isViewed,
filmLibrary.watchedAt,
entry.id,
entry.userId,
entry.filmId,
entry.comment,
entry.isViewed,
entry.watchedAt,
)
}
return filmLibrary
return entry
}
override fun findById(id: UUID): FilmLibrary? {
val entries =
jdbc.query(
override fun findById(id: UUID): FilmLibraryEntry? =
jdbc
.query(
"SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites WHERE id = ?",
filmLibraryRowMapper,
rowMapper,
id,
).firstOrNull()
override fun findByUserId(userId: UUID): List<FilmLibraryEntry> =
jdbc.query(
"SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites WHERE user_id = ?",
rowMapper,
userId,
)
return entries.firstOrNull()
}
override fun findByUserIdAndFilmId(
userId: UUID,
filmId: UUID,
): FilmLibrary? {
val entries =
jdbc.query(
): FilmLibraryEntry? =
jdbc
.query(
"""
SELECT id, user_id, film_id, comment, is_viewed, watched_at
FROM favorites WHERE user_id = ? AND film_id = ?
""".trimIndent(),
filmLibraryRowMapper,
rowMapper,
userId,
filmId,
)
return entries.firstOrNull()
}
).firstOrNull()
override fun findAll(): List<FilmLibrary> =
override fun findAll(): List<FilmLibraryEntry> =
jdbc.query(
"SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites",
filmLibraryRowMapper,
rowMapper,
)
override fun deleteById(id: UUID) {
@@ -159,32 +159,6 @@ class FilmRepository(
return films.firstOrNull()
}
override fun findByJellyfinLibraryId(jellyfinLibraryId: String): Film? {
val films =
jdbc.query(
"""
SELECT id,
title,
description,
content_type,
release_year,
genres,
cast_members,
directors,
imdb_rating,
platform_rating,
external_url,
jellyfin_item_id,
jellyfin_library_id
FROM films
WHERE jellyfin_library_id = ?
""".trimIndent(),
filmRowMapper,
jellyfinLibraryId,
)
return films.firstOrNull()
}
override fun findAll(): List<Film> =
jdbc.query(
"""
@@ -1,5 +1,7 @@
package com.project.movienight.adapters.persistence.jdbc
import com.project.movienight.application.ports.output.JellyfinEventRecord
import com.project.movienight.application.ports.output.JellyfinEventStorePort
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
import org.springframework.stereotype.Repository
@@ -7,16 +9,8 @@ import org.springframework.stereotype.Repository
@Repository
class JellyfinEventRepository(
private val jdbc: NamedParameterJdbcTemplate,
) {
fun save(
eventId: String,
serverId: String?,
eventType: String,
occurredAt: java.time.OffsetDateTime?,
jellyfinUserId: String?,
jellyfinItemId: String?,
payload: String?,
): Int {
) : JellyfinEventStorePort {
override fun save(event: JellyfinEventRecord): Boolean {
val sql =
"""
INSERT INTO jellyfin_events(event_id, server_id, event_type, occurred_at, jellyfin_user_id, jellyfin_item_id, payload)
@@ -26,20 +20,14 @@ class JellyfinEventRepository(
val params =
MapSqlParameterSource()
.addValue("eventId", eventId)
.addValue("serverId", serverId)
.addValue("eventType", eventType)
.addValue("occurredAt", occurredAt)
.addValue("jellyfinUserId", jellyfinUserId)
.addValue("jellyfinItemId", jellyfinItemId)
.addValue("payload", payload)
.addValue("eventId", event.eventId)
.addValue("serverId", event.serverId)
.addValue("eventType", event.eventType)
.addValue("occurredAt", event.occurredAt)
.addValue("jellyfinUserId", event.jellyfinUserId)
.addValue("jellyfinItemId", event.jellyfinItemId)
.addValue("payload", event.payload)
return jdbc.update(sql, params)
}
fun delete(eventId: String) {
val sql = "DELETE FROM jellyfin_events WHERE event_id = :eventId"
val params = MapSqlParameterSource().addValue("eventId", eventId)
jdbc.update(sql, params)
return jdbc.update(sql, params) == 1
}
}
@@ -4,6 +4,7 @@ import com.project.movienight.adapters.persistence.entity.UserEntity
import com.project.movienight.adapters.persistence.entity.toDomain
import com.project.movienight.adapters.persistence.entity.toEntity
import com.project.movienight.application.ports.output.UserRepositoryPort
import com.project.movienight.domain.exception.EntityNotFoundException
import com.project.movienight.domain.model.AuthProvider
import com.project.movienight.domain.model.User
import org.springframework.jdbc.core.JdbcTemplate
@@ -32,11 +33,9 @@ class UserRepository(
val entity =
if (existingUser != null) {
val existingEntity = existingUser.toEntity()
user.toEntity(
provider = existingEntity.provider?.let { AuthProvider.valueOf(it) },
providerId = existingEntity.providerId,
createdAt = existingEntity.createdAt,
provider = findProviderById(user.id),
providerId = findProviderIdById(user.id),
)
} else {
user.toEntity()
@@ -75,6 +74,70 @@ class UserRepository(
return user
}
override fun createOAuthUser(
user: User,
provider: AuthProvider,
providerId: String,
): User {
val entity = user.toEntity(provider = provider, providerId = providerId)
val updatedRows =
jdbc.update(
"""
UPDATE users
SET name = ?, email = ?, provider = ?, provider_id = ?, jellyfin_user_id = ?
WHERE id = ?
""".trimIndent(),
entity.name,
entity.email,
entity.provider,
entity.providerId,
entity.jellyfinUserId,
entity.id,
)
if (updatedRows == 0) {
jdbc.update(
"""
INSERT INTO users (id, name, email, provider, provider_id, jellyfin_user_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""".trimIndent(),
entity.id,
entity.name,
entity.email,
entity.provider,
entity.providerId,
entity.jellyfinUserId,
entity.createdAt,
)
}
return findById(user.id) ?: user
}
override fun linkOAuthAccount(
userId: UUID,
provider: AuthProvider,
providerId: String,
): User {
val updatedRows =
jdbc.update(
"""
UPDATE users
SET provider = ?, provider_id = ?
WHERE id = ?
""".trimIndent(),
provider.name,
providerId,
userId,
)
if (updatedRows == 0) {
throw EntityNotFoundException(entity = "User", id = userId.toString())
}
return findById(userId) ?: throw EntityNotFoundException(entity = "User", id = userId.toString())
}
override fun findById(id: UUID): User? {
val entities =
jdbc.query(
@@ -88,13 +151,31 @@ class UserRepository(
override fun findByEmail(email: String): User? {
val entities =
jdbc.query(
"SELECT id, name, email, provider, provider_id, created_at FROM users WHERE email = ?",
"""
SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at
FROM users
WHERE email = ?
""".trimIndent(),
userEntityRowMapper,
email,
)
return entities.firstOrNull()?.toDomain()
}
override fun findByJellyfinUserId(jellyfinUserId: String): User? {
val entities =
jdbc.query(
"""
SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at
FROM users
WHERE jellyfin_user_id = ?
""".trimIndent(),
userEntityRowMapper,
jellyfinUserId,
)
return entities.firstOrNull()?.toDomain()
}
override fun findAll(): List<User> =
jdbc
.query(
@@ -122,4 +203,21 @@ class UserRepository(
)
return entities.firstOrNull()?.toDomain()
}
private fun findProviderById(id: UUID): AuthProvider? =
jdbc
.query(
"SELECT provider FROM users WHERE id = ?",
{ rs: ResultSet, _: Int -> rs.getString("provider") },
id,
).firstOrNull()
?.let { AuthProvider.valueOf(it) }
private fun findProviderIdById(id: UUID): String? =
jdbc
.query(
"SELECT provider_id FROM users WHERE id = ?",
{ rs: ResultSet, _: Int -> rs.getString("provider_id") },
id,
).firstOrNull()
}
@@ -1,7 +1,5 @@
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
@@ -59,12 +57,11 @@ class CustomOAuth2UserService(
if (userByEmail != null) {
log.debug("Linking OAuth2 account to existing user: {}", userInfo.getEmail())
val entity =
userByEmail.toEntity(
userRepository.linkOAuthAccount(
userId = userByEmail.id,
provider = provider,
providerId = userInfo.getProviderId(),
)
userRepository.save(entity.toDomain())
} else {
log.debug("Creating new user for provider: {}", userInfo.getProvider())
val newUser =
@@ -72,14 +69,12 @@ class CustomOAuth2UserService(
id = idGenerator.generateId(),
name = userInfo.getName(),
email = userInfo.getEmail(),
library = null,
)
val entity =
newUser.toEntity(
userRepository.createOAuthUser(
user = newUser,
provider = provider,
providerId = userInfo.getProviderId(),
)
userRepository.save(entity.toDomain())
}
}
}
@@ -23,6 +23,11 @@ class SecurityConfiguration(
auth
.requestMatchers("/", "/login/**", "/oauth2/**", "/h2-console/**", "/actuator/health")
.permitAll()
.requestMatchers(
"/api/integrations/jellyfin/events",
"/api/integrations/jellyfin/sync",
"/api/integrations/jellyfin/sync-state",
).permitAll()
.requestMatchers("/api/users/me")
.authenticated()
.requestMatchers("/api/**")
@@ -6,9 +6,12 @@ import com.project.movienight.domain.exception.EntityNotFoundException
import org.slf4j.LoggerFactory
import org.slf4j.MDC
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.server.ResponseStatusException
@RestControllerAdvice
class ApiExceptionHandler {
@@ -50,6 +53,44 @@ class ApiExceptionHandler {
)
}
@ExceptionHandler(MethodArgumentNotValidException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
fun handleValidationException(exception: MethodArgumentNotValidException): ErrorResponse {
val traceId = currentTraceId()
val details =
exception
.bindingResult
.fieldErrors
.joinToString("; ") { error -> "${error.field}: ${error.defaultMessage}" }
.ifBlank { "Invalid request" }
log.warn("Validation error: traceId='{}', message='{}'", traceId, details)
return ErrorResponse(
message = details,
traceId = traceId,
)
}
@ExceptionHandler(ResponseStatusException::class)
fun handleResponseStatusException(exception: ResponseStatusException): ResponseEntity<ErrorResponse> {
val traceId = currentTraceId()
log.warn(
"HTTP error: traceId='{}', status='{}', message='{}'",
traceId,
exception.statusCode,
exception.reason,
)
return ResponseEntity
.status(exception.statusCode)
.body(
ErrorResponse(
message = exception.reason ?: exception.message,
traceId = traceId,
),
)
}
@ExceptionHandler(Exception::class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
fun handleUnexpectedException(exception: Exception): ErrorResponse {
@@ -0,0 +1,10 @@
package com.project.movienight.adapters.web
import com.project.movienight.domain.exception.DomainException
import com.project.movienight.domain.model.ContentType
fun parseContentType(value: String): ContentType =
runCatching { ContentType.valueOf(value.uppercase()) }
.getOrElse { throw DomainException("Unsupported content type: $value") }
fun parseOptionalContentType(value: String?): ContentType? = value?.let { parseContentType(it) }
@@ -4,13 +4,9 @@ import com.project.movienight.adapters.web.dto.request.CreateFilmRequest
import com.project.movienight.adapters.web.dto.request.EditFilmRequest
import com.project.movienight.adapters.web.dto.response.FilmResponse
import com.project.movienight.application.ports.input.CreateFilmCommand
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.input.FilmUseCase
import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.DeleteMapping
@@ -25,33 +21,22 @@ import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
private fun String.toContentTypeOrFilm(): com.project.movienight.domain.model.ContentType =
runCatching {
com.project.movienight.domain.model.ContentType
.valueOf(this)
}.getOrDefault(com.project.movienight.domain.model.ContentType.FILM)
@RestController
@RequestMapping("/api/films")
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,
private val filmUseCase: FilmUseCase,
) {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun create(
@RequestBody request: CreateFilmRequest,
@Valid @RequestBody request: CreateFilmRequest,
): FilmResponse =
FilmResponse.fromDomain(
createFilmUseCase.create(
filmUseCase.create(
CreateFilmCommand(
title = request.title,
description = request.description,
contentType = request.contentType.toContentTypeOrFilm(),
contentType = parseContentType(request.contentType),
releaseYear = request.releaseYear,
genres = request.genres,
cast = request.cast,
@@ -68,16 +53,16 @@ class FilmController(
@PatchMapping("/{id}")
fun edit(
@PathVariable id: UUID,
@RequestBody request: EditFilmRequest,
@Valid @RequestBody request: EditFilmRequest,
): FilmResponse =
FilmResponse.fromDomain(
editFilmUseCase.edit(
filmUseCase.edit(
id = id,
command =
EditFilmCommand(
title = request.title,
description = request.description,
contentType = request.contentType.toContentTypeOrFilm(),
contentType = parseContentType(request.contentType),
releaseYear = request.releaseYear,
genres = request.genres,
cast = request.cast,
@@ -95,21 +80,21 @@ class FilmController(
@ResponseStatus(HttpStatus.NO_CONTENT)
fun delete(
@PathVariable id: UUID,
) = deleteFilmUseCase.delete(id)
) = filmUseCase.delete(id)
@GetMapping("/{id}")
fun getById(
@PathVariable id: UUID,
): FilmResponse = FilmResponse.fromDomain(getFilmByIdUseCase.getById(id))
): FilmResponse = FilmResponse.fromDomain(filmUseCase.getById(id))
@GetMapping
fun getAll(): List<FilmResponse> = getAllFilmsUseCase.getAll().map { FilmResponse.fromDomain(it) }
fun getAll(): List<FilmResponse> = filmUseCase.getAll().map { FilmResponse.fromDomain(it) }
@GetMapping("/search")
fun searchByTitle(
@RequestParam title: String,
): ResponseEntity<FilmResponse> {
val film = searchFilmByTitleUseCase.searchByTitle(title)
val film = filmUseCase.searchByTitle(title)
return if (film != null) {
ResponseEntity.ok(FilmResponse.fromDomain(film))
} else {
@@ -1,28 +1,16 @@
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.FilmLibraryEntryResponse
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.ListFilmLibraryEntriesUseCase
import com.project.movienight.application.ports.input.FilmLibraryUseCase
import com.project.movienight.application.ports.input.MarkFilmViewedCommand
import com.project.movienight.application.ports.input.MarkFilmViewedUseCase
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
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.ResponseStatus
import org.springframework.web.bind.annotation.RestController
@@ -31,52 +19,29 @@ import java.util.UUID
@RestController
@RequestMapping("/api/users/{userId}/library")
class FilmLibraryController(
private val createFilmLibraryUseCase: CreateFilmLibraryUseCase,
private val addFilmToLibraryUseCase: AddFilmToLibraryUseCase,
private val markFilmViewedUseCase: MarkFilmViewedUseCase,
private val removeFilmFromLibraryUseCase: RemoveFilmFromLibraryUseCase,
private val getFilmLibraryUseCase: GetFilmLibraryUseCase,
private val getAllFilmsUseCase: GetAllFilmsUseCase,
private val listFilmLibraryEntriesUseCase: ListFilmLibraryEntriesUseCase,
private val filmLibraryUseCase: FilmLibraryUseCase,
) {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun create(
@PathVariable userId: UUID,
@RequestBody request: CreateFilmLibraryRequest,
): FilmLibraryResponse =
FilmLibraryResponse.fromDomain(
createFilmLibraryUseCase.create(
CreateFilmLibraryCommand(
userId = userId,
name = request.name,
),
),
)
@GetMapping
fun get(
@PathVariable userId: UUID,
): FilmLibraryResponse =
FilmLibraryResponse.fromDomain(
getFilmLibraryUseCase.getLibrary(
GetFilmLibraryQuery(userId = userId),
),
)
@GetMapping("/entries")
fun list(
@PathVariable userId: UUID,
): List<FilmLibraryResponse> = listFilmLibraryEntriesUseCase.list(userId).map { FilmLibraryResponse.fromDomain(it) }
): List<FilmLibraryEntryResponse> =
filmLibraryUseCase
.list(userId)
.map { entry -> FilmLibraryEntryResponse.fromDomain(entry) }
@GetMapping("/entries")
fun listEntries(
@PathVariable userId: UUID,
): List<FilmLibraryEntryResponse> = list(userId)
@PostMapping("/films/{filmId}")
@ResponseStatus(HttpStatus.CREATED)
fun addFilm(
@PathVariable userId: UUID,
@PathVariable filmId: UUID,
): FilmLibraryResponse =
FilmLibraryResponse.fromDomain(
addFilmToLibraryUseCase.addFilm(
): FilmLibraryEntryResponse =
FilmLibraryEntryResponse.fromDomain(
filmLibraryUseCase.addFilm(
AddFilmToLibraryCommand(
userId = userId,
filmId = filmId,
@@ -88,9 +53,9 @@ class FilmLibraryController(
fun markViewed(
@PathVariable userId: UUID,
@PathVariable filmId: UUID,
): FilmLibraryResponse =
FilmLibraryResponse.fromDomain(
markFilmViewedUseCase.markViewed(
): FilmLibraryEntryResponse =
FilmLibraryEntryResponse.fromDomain(
filmLibraryUseCase.markViewed(
MarkFilmViewedCommand(
userId = userId,
filmId = filmId,
@@ -104,7 +69,7 @@ class FilmLibraryController(
@PathVariable userId: UUID,
@PathVariable filmId: UUID,
) {
removeFilmFromLibraryUseCase.removeFilm(
filmLibraryUseCase.removeFilm(
RemoveFilmFromLibraryCommand(
userId = userId,
filmId = filmId,
@@ -115,27 +80,5 @@ 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) }
}
): List<FilmResponse> = filmLibraryUseCase.listAvailableFilms(userId).map { FilmResponse.fromDomain(it) }
}
@@ -2,9 +2,9 @@ package com.project.movienight.adapters.web
import com.project.movienight.adapters.web.dto.request.RateFilmRequest
import com.project.movienight.adapters.web.dto.response.FilmRatingResponse
import com.project.movienight.application.ports.input.GetFilmRatingsUseCase
import com.project.movienight.application.ports.input.FilmRatingUseCase
import com.project.movienight.application.ports.input.RateFilmCommand
import com.project.movienight.application.ports.input.RateFilmUseCase
import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
@@ -18,18 +18,17 @@ import java.util.UUID
@RestController
@RequestMapping("/api/users/{userId}/ratings")
class FilmRatingController(
private val rateFilmUseCase: RateFilmUseCase,
private val getFilmRatingsUseCase: GetFilmRatingsUseCase,
private val filmRatingUseCase: FilmRatingUseCase,
) {
@PostMapping("/films/{filmId}")
@ResponseStatus(HttpStatus.CREATED)
fun rate(
@PathVariable userId: UUID,
@PathVariable filmId: UUID,
@RequestBody request: RateFilmRequest,
@Valid @RequestBody request: RateFilmRequest,
): FilmRatingResponse =
FilmRatingResponse.fromDomain(
rateFilmUseCase.rate(
filmRatingUseCase.rate(
RateFilmCommand(
userId = userId,
filmId = filmId,
@@ -42,5 +41,5 @@ class FilmRatingController(
@GetMapping
fun list(
@PathVariable userId: UUID,
): List<FilmRatingResponse> = getFilmRatingsUseCase.getRatings(userId).map { FilmRatingResponse.fromDomain(it) }
): List<FilmRatingResponse> = filmRatingUseCase.getRatings(userId).map { FilmRatingResponse.fromDomain(it) }
}
@@ -1,8 +1,10 @@
package com.project.movienight.adapters.web
import com.project.movienight.adapters.web.dto.request.JellyfinEventRequest
import com.project.movienight.application.services.JellyfinEventService
import com.project.movienight.application.ports.input.HandleJellyfinEventCommand
import com.project.movienight.application.ports.input.JellyfinEventUseCase
import com.project.movienight.config.JellyfinIntegrationProperties
import jakarta.validation.Valid
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.PostMapping
@@ -16,7 +18,7 @@ import org.springframework.web.server.ResponseStatusException
@RestController
@RequestMapping("/api/integrations/jellyfin")
class JellyfinEventsController(
private val jellyfinEventService: JellyfinEventService,
private val jellyfinEventUseCase: JellyfinEventUseCase,
private val properties: JellyfinIntegrationProperties,
) {
private val log = LoggerFactory.getLogger(JellyfinEventsController::class.java)
@@ -25,7 +27,7 @@ class JellyfinEventsController(
@ResponseStatus(HttpStatus.OK)
fun receiveEvent(
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
@RequestBody request: JellyfinEventRequest,
@Valid @RequestBody request: JellyfinEventRequest,
) {
if (!properties.enabled) {
throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Jellyfin integration is disabled")
@@ -43,7 +45,8 @@ class JellyfinEventsController(
request.jellyfinUserId,
request.itemId,
)
jellyfinEventService.handleEvent(
jellyfinEventUseCase.handle(
HandleJellyfinEventCommand(
eventId = request.eventId,
serverId = null,
eventType = request.eventType,
@@ -51,6 +54,7 @@ class JellyfinEventsController(
jellyfinUserId = request.jellyfinUserId,
itemId = request.itemId,
payload = request.payload,
),
)
}
}
@@ -1,6 +1,6 @@
package com.project.movienight.adapters.web
import com.project.movienight.application.services.JellyfinSyncService
import com.project.movienight.application.ports.input.JellyfinSyncUseCase
import com.project.movienight.domain.model.JellyfinSyncState
import com.project.movienight.domain.model.JellyfinSyncSummary
import org.springframework.web.bind.annotation.GetMapping
@@ -11,11 +11,11 @@ import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/integrations/jellyfin")
class JellyfinSyncController(
private val jellyfinSyncService: JellyfinSyncService,
private val jellyfinSyncUseCase: JellyfinSyncUseCase,
) {
@PostMapping("/sync")
fun syncNow(): JellyfinSyncSummary = jellyfinSyncService.syncNow()
fun syncNow(): JellyfinSyncSummary = jellyfinSyncUseCase.syncNow()
@GetMapping("/sync-state")
fun syncState(): List<JellyfinSyncState> = jellyfinSyncService.getSyncStates()
fun syncState(): List<JellyfinSyncState> = jellyfinSyncUseCase.getSyncStates()
}
@@ -9,7 +9,6 @@ import com.project.movienight.application.ports.input.RecommendationQuery
import com.project.movienight.application.ports.input.RejectRecommendationCommand
import com.project.movienight.application.ports.input.RejectRecommendationUseCase
import com.project.movienight.config.JellyfinIntegrationProperties
import com.project.movienight.domain.model.ContentType
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
@@ -40,7 +39,7 @@ class RecommendationController(
.recommend(
RecommendationQuery(
userId = userId,
contentType = contentType?.let { runCatching { ContentType.valueOf(it.uppercase()) }.getOrNull() },
contentType = parseOptionalContentType(contentType),
mood = mood,
libraryOnly = libraryOnly,
limit = limit,
@@ -1,16 +1,15 @@
package com.project.movienight.adapters.web
import com.project.movienight.adapters.security.UserPrincipal
import com.project.movienight.adapters.web.dto.request.CreateUserRequest
import com.project.movienight.adapters.web.dto.request.EditUserRequest
import com.project.movienight.adapters.web.dto.response.UserResponse
import com.project.movienight.application.ports.input.CreateUserCommand
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.input.UserUseCase
import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PatchMapping
@@ -25,19 +24,15 @@ import java.util.UUID
@RestController
@RequestMapping("/api/users")
class UserController(
private val createUserUseCase: CreateUserUseCase,
private val editUserUseCase: EditUserUseCase,
private val deleteUserUseCase: DeleteUserUseCase,
private val getUserByIdUseCase: GetUserByIdUseCase,
private val getAllUsersUseCase: GetAllUsersUseCase,
private val userUseCase: UserUseCase,
) {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun create(
@RequestBody request: CreateUserRequest,
@Valid @RequestBody request: CreateUserRequest,
): UserResponse =
UserResponse.fromDomain(
createUserUseCase.create(
userUseCase.create(
CreateUserCommand(
name = request.name,
email = request.email,
@@ -46,20 +41,25 @@ class UserController(
)
@GetMapping
fun getAll(): List<UserResponse> = getAllUsersUseCase.getAll().map { UserResponse.fromDomain(it) }
fun getAll(): List<UserResponse> = userUseCase.getAll().map { UserResponse.fromDomain(it) }
@GetMapping("/me")
fun getMe(
@AuthenticationPrincipal principal: UserPrincipal,
): UserResponse = UserResponse.fromDomain(userUseCase.getById(principal.getId()))
@GetMapping("/{id}")
fun getById(
@PathVariable id: UUID,
): UserResponse = UserResponse.fromDomain(getUserByIdUseCase.getById(id))
): UserResponse = UserResponse.fromDomain(userUseCase.getById(id))
@PatchMapping("/{id}")
fun edit(
@PathVariable id: UUID,
@RequestBody request: EditUserRequest,
@Valid @RequestBody request: EditUserRequest,
): UserResponse =
UserResponse.fromDomain(
editUserUseCase.edit(
userUseCase.edit(
id = id,
command =
EditUserCommand(
@@ -73,5 +73,5 @@ class UserController(
@ResponseStatus(HttpStatus.NO_CONTENT)
fun delete(
@PathVariable id: UUID,
) = deleteUserUseCase.delete(id)
) = userUseCase.delete(id)
}
@@ -2,10 +2,9 @@ package com.project.movienight.adapters.web
import com.project.movienight.adapters.web.dto.request.UpsertUserPreferencesRequest
import com.project.movienight.adapters.web.dto.response.UserPreferencesResponse
import com.project.movienight.application.ports.input.GetUserPreferencesUseCase
import com.project.movienight.application.ports.input.UpsertUserPreferencesCommand
import com.project.movienight.application.ports.input.UpsertUserPreferencesUseCase
import com.project.movienight.domain.model.ContentType
import com.project.movienight.application.ports.input.UserPreferencesUseCase
import jakarta.validation.Valid
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PutMapping
@@ -17,16 +16,15 @@ import java.util.UUID
@RestController
@RequestMapping("/api/users/{userId}/preferences")
class UserPreferencesController(
private val upsertUserPreferencesUseCase: UpsertUserPreferencesUseCase,
private val getUserPreferencesUseCase: GetUserPreferencesUseCase,
private val userPreferencesUseCase: UserPreferencesUseCase,
) {
@PutMapping
fun upsert(
@PathVariable userId: UUID,
@RequestBody request: UpsertUserPreferencesRequest,
@Valid @RequestBody request: UpsertUserPreferencesRequest,
): UserPreferencesResponse =
UserPreferencesResponse.fromDomain(
upsertUserPreferencesUseCase.upsert(
userPreferencesUseCase.upsert(
UpsertUserPreferencesCommand(
userId = userId,
weightedGenres = request.weightedGenres,
@@ -35,13 +33,7 @@ class UserPreferencesController(
castAndDirectors = request.castAndDirectors,
moods = request.moods,
contentTypes =
request.contentTypes.mapNotNull {
runCatching {
ContentType.valueOf(
it,
)
}.getOrNull()
},
request.contentTypes.map { parseContentType(it) },
),
),
)
@@ -49,5 +41,5 @@ class UserPreferencesController(
@GetMapping
fun get(
@PathVariable userId: UUID,
): UserPreferencesResponse? = getUserPreferencesUseCase.get(userId)?.let { UserPreferencesResponse.fromDomain(it) }
): UserPreferencesResponse? = userPreferencesUseCase.get(userId)?.let { UserPreferencesResponse.fromDomain(it) }
}
@@ -1,5 +0,0 @@
package com.project.movienight.adapters.web.dto.request
data class CreateFilmLibraryRequest(
val name: String = "My films",
)
@@ -1,14 +1,29 @@
package com.project.movienight.adapters.web.dto.request
import jakarta.validation.constraints.Max
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
data class CreateFilmRequest(
@field:NotBlank
@field:Size(max = 255)
val title: String,
@field:NotBlank
val description: String,
@field:NotBlank
val contentType: String = "FILM",
@field:Min(1888)
@field:Max(3000)
val releaseYear: Int? = null,
val genres: List<String> = emptyList(),
val cast: List<String> = emptyList(),
val directors: List<String> = emptyList(),
@field:Min(0)
@field:Max(10)
val imdbRating: Double? = null,
@field:Min(0)
@field:Max(10)
val platformRating: Double? = null,
val externalUrl: String? = null,
val jellyfinItemId: String? = null,
@@ -1,6 +1,15 @@
package com.project.movienight.adapters.web.dto.request
import jakarta.validation.constraints.Email
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
data class CreateUserRequest(
@field:NotBlank
@field:Size(max = 255)
val name: String,
@field:Email
@field:NotBlank
@field:Size(max = 320)
val email: String,
)
@@ -1,14 +1,29 @@
package com.project.movienight.adapters.web.dto.request
import jakarta.validation.constraints.Max
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
data class EditFilmRequest(
@field:NotBlank
@field:Size(max = 255)
val title: String,
@field:NotBlank
val description: String,
@field:NotBlank
val contentType: String = "FILM",
@field:Min(1888)
@field:Max(3000)
val releaseYear: Int? = null,
val genres: List<String> = emptyList(),
val cast: List<String> = emptyList(),
val directors: List<String> = emptyList(),
@field:Min(0)
@field:Max(10)
val imdbRating: Double? = null,
@field:Min(0)
@field:Max(10)
val platformRating: Double? = null,
val externalUrl: String? = null,
val jellyfinItemId: String? = null,
@@ -1,6 +1,12 @@
package com.project.movienight.adapters.web.dto.request
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
data class EditUserRequest(
@field:NotBlank
@field:Size(max = 255)
val name: String,
@field:Size(max = 255)
val jellyfinUserId: String? = null,
)
@@ -1,18 +1,23 @@
package com.project.movienight.adapters.web.dto.request
import com.fasterxml.jackson.annotation.JsonProperty
import jakarta.validation.constraints.NotBlank
import java.time.OffsetDateTime
data class JellyfinEventRequest(
@JsonProperty("event_id")
@field:NotBlank
val eventId: String,
@JsonProperty("event_type")
@field:NotBlank
val eventType: String,
@JsonProperty("occurred_at")
val occurredAt: OffsetDateTime,
@JsonProperty("jellyfin_user_id")
@field:NotBlank
val jellyfinUserId: String,
@JsonProperty("item_id")
@field:NotBlank
val itemId: String,
@JsonProperty("payload_version")
val payloadVersion: Int = 1,
@@ -1,6 +1,13 @@
package com.project.movienight.adapters.web.dto.request
import jakarta.validation.constraints.Max
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.Size
data class RateFilmRequest(
@field:Min(1)
@field:Max(10)
val score: Int,
@field:Size(max = 2048)
val note: String? = null,
)
@@ -0,0 +1,26 @@
package com.project.movienight.adapters.web.dto.response
import com.project.movienight.domain.model.FilmLibraryEntry
import java.time.LocalDateTime
import java.util.UUID
data class FilmLibraryEntryResponse(
val id: UUID,
val userId: UUID,
val filmId: UUID,
val comment: String?,
val isViewed: Boolean,
val watchedAt: LocalDateTime?,
) {
companion object {
fun fromDomain(entry: FilmLibraryEntry): FilmLibraryEntryResponse =
FilmLibraryEntryResponse(
id = entry.id,
userId = entry.userId,
filmId = entry.filmId,
comment = entry.comment,
isViewed = entry.isViewed,
watchedAt = entry.watchedAt,
)
}
}
@@ -1,25 +0,0 @@
package com.project.movienight.adapters.web.dto.response
import com.project.movienight.domain.model.FilmLibrary
import java.util.UUID
data class FilmLibraryResponse(
val id: UUID,
val userId: UUID,
val filmId: UUID,
val comment: String?,
val isViewed: Boolean,
val watchedAt: java.time.LocalDateTime?,
) {
companion object {
fun fromDomain(filmLibrary: FilmLibrary): FilmLibraryResponse =
FilmLibraryResponse(
id = filmLibrary.id,
userId = filmLibrary.userId,
filmId = filmLibrary.filmId,
comment = filmLibrary.comment,
isViewed = filmLibrary.isViewed,
watchedAt = filmLibrary.watchedAt,
)
}
}
@@ -1,20 +1,20 @@
package com.project.movienight.application.ports.input
import com.project.movienight.domain.model.FilmLibrary
import com.project.movienight.domain.model.Film
import com.project.movienight.domain.model.FilmLibraryEntry
import java.time.LocalDateTime
import java.util.UUID
interface CreateFilmLibraryUseCase {
fun create(command: CreateFilmLibraryCommand): FilmLibrary
}
interface FilmLibraryUseCase {
fun addFilm(command: AddFilmToLibraryCommand): FilmLibraryEntry
data class CreateFilmLibraryCommand(
val userId: UUID,
val name: String = "Мои фильмы",
)
fun markViewed(command: MarkFilmViewedCommand): FilmLibraryEntry
interface AddFilmToLibraryUseCase {
fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary
fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibraryEntry
fun list(userId: UUID): List<FilmLibraryEntry>
fun listAvailableFilms(userId: UUID): List<Film>
}
data class AddFilmToLibraryCommand(
@@ -22,34 +22,14 @@ data class AddFilmToLibraryCommand(
val filmId: UUID,
)
interface MarkFilmViewedUseCase {
fun markViewed(command: MarkFilmViewedCommand): FilmLibrary
}
data class MarkFilmViewedCommand(
val userId: UUID,
val filmId: UUID,
val watchedAt: LocalDateTime? = null,
)
interface RemoveFilmFromLibraryUseCase {
fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary
}
data class RemoveFilmFromLibraryCommand(
val userId: UUID,
val filmId: UUID,
val libraryId: UUID? = null,
val entryId: UUID? = null,
)
interface GetFilmLibraryUseCase {
fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary
}
data class GetFilmLibraryQuery(
val userId: UUID,
)
interface ListFilmLibraryEntriesUseCase {
fun list(userId: UUID): List<FilmLibrary>
}
@@ -3,8 +3,10 @@ package com.project.movienight.application.ports.input
import com.project.movienight.domain.model.FilmRating
import java.util.UUID
interface RateFilmUseCase {
interface FilmRatingUseCase {
fun rate(command: RateFilmCommand): FilmRating
fun getRatings(userId: UUID): List<FilmRating>
}
data class RateFilmCommand(
@@ -13,7 +15,3 @@ data class RateFilmCommand(
val score: Int,
val note: String? = null,
)
interface GetFilmRatingsUseCase {
fun getRatings(userId: UUID): List<FilmRating>
}
@@ -4,8 +4,21 @@ import com.project.movienight.domain.model.ContentType
import com.project.movienight.domain.model.Film
import java.util.UUID
interface CreateFilmUseCase {
interface FilmUseCase {
fun create(command: CreateFilmCommand): Film
fun edit(
id: UUID,
command: EditFilmCommand,
): Film
fun delete(id: UUID)
fun getById(id: UUID): Film
fun getAll(): List<Film>
fun searchByTitle(title: String): Film?
}
data class CreateFilmCommand(
@@ -23,13 +36,6 @@ data class CreateFilmCommand(
val jellyfinLibraryId: String? = null,
)
interface EditFilmUseCase {
fun edit(
id: UUID,
command: EditFilmCommand,
): Film
}
data class EditFilmCommand(
val title: String,
val description: String,
@@ -44,19 +50,3 @@ data class EditFilmCommand(
val jellyfinItemId: String? = null,
val jellyfinLibraryId: String? = null,
)
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?
}
@@ -0,0 +1,25 @@
package com.project.movienight.application.ports.input
import com.project.movienight.domain.model.JellyfinSyncState
import com.project.movienight.domain.model.JellyfinSyncSummary
import java.time.OffsetDateTime
interface JellyfinEventUseCase {
fun handle(command: HandleJellyfinEventCommand)
}
data class HandleJellyfinEventCommand(
val eventId: String,
val serverId: String?,
val eventType: String,
val occurredAt: OffsetDateTime,
val jellyfinUserId: String,
val itemId: String,
val payload: Map<String, Any>?,
)
interface JellyfinSyncUseCase {
fun syncNow(): JellyfinSyncSummary
fun getSyncStates(): List<JellyfinSyncState>
}
@@ -4,8 +4,10 @@ import com.project.movienight.domain.model.ContentType
import com.project.movienight.domain.model.UserPreferences
import java.util.UUID
interface UpsertUserPreferencesUseCase {
interface UserPreferencesUseCase {
fun upsert(command: UpsertUserPreferencesCommand): UserPreferences
fun get(userId: UUID): UserPreferences?
}
data class UpsertUserPreferencesCommand(
@@ -17,7 +19,3 @@ data class UpsertUserPreferencesCommand(
val moods: List<String> = emptyList(),
val contentTypes: List<ContentType> = emptyList(),
)
interface GetUserPreferencesUseCase {
fun get(userId: UUID): UserPreferences?
}
@@ -3,8 +3,19 @@ package com.project.movienight.application.ports.input
import com.project.movienight.domain.model.User
import java.util.UUID
interface CreateUserUseCase {
interface UserUseCase {
fun create(command: CreateUserCommand): User
fun edit(
id: UUID,
command: EditUserCommand,
): User
fun delete(id: UUID)
fun getById(id: UUID): User
fun getAll(): List<User>
}
data class CreateUserCommand(
@@ -12,26 +23,7 @@ data class CreateUserCommand(
val email: String,
)
interface EditUserUseCase {
fun edit(
id: UUID,
command: EditUserCommand,
): User
}
data class EditUserCommand(
val name: String,
val jellyfinUserId: String? = null,
)
interface DeleteUserUseCase {
fun delete(id: UUID)
}
interface GetUserByIdUseCase {
fun getById(id: UUID): User
}
interface GetAllUsersUseCase {
fun getAll(): List<User>
}
@@ -0,0 +1,30 @@
package com.project.movienight.application.ports.output
import com.project.movienight.domain.model.JellyfinSyncSummary
import com.project.movienight.domain.model.RecommendationEventType
interface BusinessMetricsPort {
fun recordFilmCreated()
fun recordFilmEdited()
fun recordFilmDeleted()
fun recordFilmBlocked()
fun recordRecommendationRequest()
fun recordRecommendationWeightsUpdated(eventType: RecommendationEventType)
fun recordRatingSubmitted()
fun recordLibraryEvent()
fun recordJellyfinSync(summary: JellyfinSyncSummary)
fun recordJellyfinSyncFailure()
fun recordJellyfinUnmappedUser()
fun recordBackendWriteFailure()
}
@@ -0,0 +1,21 @@
package com.project.movienight.application.ports.output
import com.project.movienight.domain.model.FilmLibraryEntry
import java.util.UUID
interface FilmLibraryEntryRepositoryPort {
fun save(entry: FilmLibraryEntry): FilmLibraryEntry
fun findById(id: UUID): FilmLibraryEntry?
fun findByUserId(userId: UUID): List<FilmLibraryEntry>
fun findByUserIdAndFilmId(
userId: UUID,
filmId: UUID,
): FilmLibraryEntry?
fun findAll(): List<FilmLibraryEntry>
fun deleteById(id: UUID)
}
@@ -1,19 +0,0 @@
package com.project.movienight.application.ports.output
import com.project.movienight.domain.model.FilmLibrary
import java.util.UUID
interface FilmLibraryRepositoryPort {
fun save(filmLibrary: FilmLibrary): FilmLibrary
fun findById(id: UUID): FilmLibrary?
fun findByUserIdAndFilmId(
userId: UUID,
filmId: UUID,
): FilmLibrary?
fun findAll(): List<FilmLibrary>
fun deleteById(id: UUID)
}
@@ -10,8 +10,6 @@ interface FilmRepositoryPort {
fun findByJellyfinItemId(jellyfinItemId: String): Film?
fun findByJellyfinLibraryId(jellyfinLibraryId: String): Film?
fun findAll(): List<Film>
fun findByTitle(title: String): Film?
@@ -0,0 +1,30 @@
package com.project.movienight.application.ports.output
import com.project.movienight.domain.model.ContentType
interface JellyfinCatalogPort {
fun fetchUsers(): List<JellyfinRemoteUser>
fun fetchLibraryItems(userId: String): List<JellyfinLibraryItemSnapshot>
}
data class JellyfinRemoteUser(
val id: String,
val name: String,
)
data class JellyfinLibraryItemSnapshot(
val jellyfinItemId: String,
val title: String,
val description: String,
val contentType: ContentType,
val releaseYear: Int?,
val genres: List<String>,
val cast: List<String>,
val directors: List<String>,
val platformRating: Double?,
val imdbRating: Double?,
val externalUrl: String?,
val jellyfinLibraryId: String?,
val isPlayed: Boolean,
)
@@ -0,0 +1,17 @@
package com.project.movienight.application.ports.output
import java.time.OffsetDateTime
interface JellyfinEventStorePort {
fun save(event: JellyfinEventRecord): Boolean
}
data class JellyfinEventRecord(
val eventId: String,
val serverId: String?,
val eventType: String,
val occurredAt: OffsetDateTime?,
val jellyfinUserId: String?,
val jellyfinItemId: String?,
val payload: String?,
)
@@ -7,10 +7,24 @@ import java.util.UUID
interface UserRepositoryPort {
fun save(user: User): User
fun createOAuthUser(
user: User,
provider: AuthProvider,
providerId: String,
): User
fun linkOAuthAccount(
userId: UUID,
provider: AuthProvider,
providerId: String,
): User
fun findById(id: UUID): User?
fun findByEmail(email: String): User?
fun findByJellyfinUserId(jellyfinUserId: String): User?
fun findAll(): List<User>
fun deleteById(id: UUID)
@@ -1,46 +1,34 @@
package com.project.movienight.application.services
import com.project.movienight.adapters.metrics.BusinessMetricsService
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.GetFilmLibraryQuery
import com.project.movienight.application.ports.input.GetFilmLibraryUseCase
import com.project.movienight.application.ports.input.ListFilmLibraryEntriesUseCase
import com.project.movienight.application.ports.input.FilmLibraryUseCase
import com.project.movienight.application.ports.input.MarkFilmViewedCommand
import com.project.movienight.application.ports.input.MarkFilmViewedUseCase
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase
import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
import com.project.movienight.application.ports.output.BusinessMetricsPort
import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort
import com.project.movienight.application.ports.output.FilmRepositoryPort
import com.project.movienight.application.ports.output.IdGenerator
import com.project.movienight.domain.exception.DomainException
import com.project.movienight.domain.exception.EntityNotFoundException
import com.project.movienight.domain.model.FilmLibrary
import com.project.movienight.domain.model.Film
import com.project.movienight.domain.model.FilmLibraryEntry
import org.springframework.stereotype.Service
import java.util.UUID
@Service
class FilmLibraryService(
private val filmLibraryRepository: FilmLibraryRepositoryPort,
private val filmLibraryEntryRepository: FilmLibraryEntryRepositoryPort,
private val filmRepository: FilmRepositoryPort,
private val idGenerator: IdGenerator,
private val businessMetricsService: BusinessMetricsService,
) : CreateFilmLibraryUseCase,
AddFilmToLibraryUseCase,
MarkFilmViewedUseCase,
RemoveFilmFromLibraryUseCase,
GetFilmLibraryUseCase,
ListFilmLibraryEntriesUseCase {
override fun create(command: CreateFilmLibraryCommand): FilmLibrary {
findByUserId(command.userId)?.let { return it }
throw EntityNotFoundException(entity = "Film library", id = command.userId.toString())
}
private val businessMetricsService: BusinessMetricsPort,
) : FilmLibraryUseCase {
override fun addFilm(command: AddFilmToLibraryCommand): FilmLibraryEntry {
ensureFilmExists(command.filmId)
override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary {
val existingEntry = findByUserAndFilmId(command.userId, command.filmId)
val existingEntry = filmLibraryEntryRepository.findByUserIdAndFilmId(command.userId, command.filmId)
if (existingEntry != null) {
val saved =
filmLibraryRepository.save(
filmLibraryEntryRepository.save(
existingEntry.copy(
isViewed = false,
watchedAt = null,
@@ -51,8 +39,8 @@ class FilmLibraryService(
}
val saved =
filmLibraryRepository.save(
FilmLibrary(
filmLibraryEntryRepository.save(
FilmLibraryEntry(
id = idGenerator.generateId(),
userId = command.userId,
filmId = command.filmId,
@@ -65,33 +53,35 @@ class FilmLibraryService(
return saved
}
override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary {
val existingLibrary =
if (command.libraryId != null) {
filmLibraryRepository.findById(command.libraryId)
?: throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString())
override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibraryEntry {
val existingEntry =
if (command.entryId != null) {
filmLibraryEntryRepository.findById(command.entryId)
?: throw EntityNotFoundException(entity = "Film library entry", id = command.entryId.toString())
} else {
findByUserAndFilmId(command.userId, command.filmId)
?: throw EntityNotFoundException(entity = "Film library", id = command.filmId.toString())
filmLibraryEntryRepository.findByUserIdAndFilmId(command.userId, command.filmId)
?: throw EntityNotFoundException(entity = "Film library entry", id = command.filmId.toString())
}
if (existingLibrary.userId != command.userId || existingLibrary.filmId != command.filmId) {
if (existingEntry.userId != command.userId || existingEntry.filmId != command.filmId) {
throw DomainException("Film with id ${command.filmId} not found in user's library")
}
filmLibraryRepository.deleteById(existingLibrary.id)
filmLibraryEntryRepository.deleteById(existingEntry.id)
businessMetricsService.recordLibraryEvent()
return existingLibrary
return existingEntry
}
override fun markViewed(command: MarkFilmViewedCommand): FilmLibrary {
val existingEntry = findByUserAndFilmId(command.userId, command.filmId)
override fun markViewed(command: MarkFilmViewedCommand): FilmLibraryEntry {
ensureFilmExists(command.filmId)
val existingEntry = filmLibraryEntryRepository.findByUserIdAndFilmId(command.userId, command.filmId)
val watchedAt = command.watchedAt ?: java.time.LocalDateTime.now()
val saved =
if (existingEntry == null) {
filmLibraryRepository.save(
FilmLibrary(
filmLibraryEntryRepository.save(
FilmLibraryEntry(
id = idGenerator.generateId(),
userId = command.userId,
filmId = command.filmId,
@@ -101,7 +91,7 @@ class FilmLibraryService(
),
)
} else {
filmLibraryRepository.save(
filmLibraryEntryRepository.save(
existingEntry.copy(
isViewed = true,
watchedAt = watchedAt,
@@ -112,17 +102,14 @@ class FilmLibraryService(
return saved
}
override fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary =
findByUserId(query.userId)
?: throw EntityNotFoundException(entity = "Film library", id = query.userId.toString())
override fun list(userId: UUID): List<FilmLibraryEntry> = filmLibraryEntryRepository.findByUserId(userId)
override fun list(userId: UUID): List<FilmLibrary> = filmLibraryRepository.findAll().filter { it.userId == userId }
override fun listAvailableFilms(userId: UUID): List<Film> {
val libraryFilmIds = list(userId).map { it.filmId }.toSet()
return filmRepository.findAll().filter { it.id !in libraryFilmIds }
}
private fun findByUserId(userId: UUID): FilmLibrary? =
filmLibraryRepository.findAll().firstOrNull { it.userId == userId }
private fun findByUserAndFilmId(
userId: UUID,
filmId: UUID,
): FilmLibrary? = filmLibraryRepository.findAll().firstOrNull { it.userId == userId && it.filmId == filmId }
private fun ensureFilmExists(filmId: UUID) {
filmRepository.findById(filmId) ?: throw EntityNotFoundException(entity = "Film", id = filmId.toString())
}
}
@@ -1,9 +1,8 @@
package com.project.movienight.application.services
import com.project.movienight.adapters.metrics.BusinessMetricsService
import com.project.movienight.application.ports.input.GetFilmRatingsUseCase
import com.project.movienight.application.ports.input.FilmRatingUseCase
import com.project.movienight.application.ports.input.RateFilmCommand
import com.project.movienight.application.ports.input.RateFilmUseCase
import com.project.movienight.application.ports.output.BusinessMetricsPort
import com.project.movienight.application.ports.output.FilmRatingRepositoryPort
import com.project.movienight.application.ports.output.FilmRepositoryPort
import com.project.movienight.application.ports.output.IdGenerator
@@ -19,9 +18,8 @@ class FilmRatingService(
private val filmRepository: FilmRepositoryPort,
private val filmRatingRepository: FilmRatingRepositoryPort,
private val idGenerator: IdGenerator,
private val businessMetricsService: BusinessMetricsService,
) : RateFilmUseCase,
GetFilmRatingsUseCase {
private val businessMetricsService: BusinessMetricsPort,
) : FilmRatingUseCase {
override fun rate(command: RateFilmCommand): FilmRating {
if (command.score !in 1..10) {
throw DomainException("Film rating score must be between 1 and 10")
@@ -1,22 +1,16 @@
package com.project.movienight.application.services
import com.project.movienight.application.ports.input.CreateFilmCommand
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.input.FilmUseCase
import com.project.movienight.application.ports.output.BusinessMetricsPort
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 io.micrometer.core.annotation.Timed
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.util.UUID
@@ -26,19 +20,15 @@ class FilmService(
private val filmRepository: FilmRepositoryPort,
private val idGenerator: IdGenerator,
private val filmConfig: FilmServiceProperties,
private val meterRegistry: MeterRegistry,
) : CreateFilmUseCase,
EditFilmUseCase,
DeleteFilmUseCase,
GetFilmByIdUseCase,
GetAllFilmsUseCase,
SearchFilmByTitleUseCase {
private val businessMetricsService: BusinessMetricsPort,
) : FilmUseCase {
private val log = LoggerFactory.getLogger(javaClass)
@Timed(
value = "business_films_create_duration_seconds",
description = "Film creation duration",
)
override fun create(command: CreateFilmCommand): Film {
val sample = Timer.start(meterRegistry)
try {
log.debug(
"Create film request received: title='{}', descriptionLength={}",
command.title,
@@ -47,12 +37,12 @@ class FilmService(
if (filmConfig.isBlocked(command.title)) {
log.debug("Create film blocked by title policy: title='{}'", command.title)
filmBlockedCounter.increment()
businessMetricsService.recordFilmBlocked()
throw BlockedValueException(target = "Film", field = "title")
}
if (filmConfig.isBlocked(command.description)) {
log.debug("Create film blocked by description policy")
filmBlockedCounter.increment()
businessMetricsService.recordFilmBlocked()
throw BlockedValueException(target = "Film", field = "description")
}
@@ -74,41 +64,37 @@ class FilmService(
)
val saved = filmRepository.save(film)
filmCreatedCounter.increment()
businessMetricsService.recordFilmCreated()
return saved
} finally {
sample.stop(createFilmTimer)
}
}
@Timed(
value = "business_films_edit_duration_seconds",
description = "Film edit duration",
)
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()
businessMetricsService.recordFilmBlocked()
throw BlockedValueException(target = "Film", field = "title")
}
if (filmConfig.isBlocked(command.description)) {
log.debug("Edit film blocked by description policy")
filmBlockedCounter.increment()
businessMetricsService.recordFilmBlocked()
throw BlockedValueException(target = "Film", field = "description")
}
var film = filmRepository.findById(id)
val film =
filmRepository.findById(id)
?: throw EntityNotFoundException(entity = "Film", id = id.toString())
if (film == null) {
log.debug("Film not found for edit: id='{}'", id)
throw EntityNotFoundException(entity = "Film", id = id.toString())
}
film =
val saved =
filmRepository.save(
film.copy(
title = command.title,
description = command.description,
@@ -122,20 +108,17 @@ class FilmService(
externalUrl = command.externalUrl,
jellyfinItemId = command.jellyfinItemId,
jellyfinLibraryId = command.jellyfinLibraryId,
),
)
val saved = filmRepository.save(film)
filmEditedCounter.increment()
businessMetricsService.recordFilmEdited()
return saved
} finally {
sample.stop(editFilmTimer)
}
}
@Timed(
value = "business_films_delete_duration_seconds",
description = "Film deletion duration",
)
override fun delete(id: UUID) {
val sample = Timer.start(meterRegistry)
try {
log.debug("Delete film with id: {}", id)
val film = filmRepository.findById(id)
@@ -146,13 +129,9 @@ class FilmService(
}
filmRepository.deleteById(id)
filmDeletedCounter.increment()
businessMetricsService.recordFilmDeleted()
log.info("Film deleted: id='{}'", id)
} finally {
sample.stop(deleteFilmTimer)
}
}
override fun getById(id: UUID): Film =
@@ -161,46 +140,4 @@ class FilmService(
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)
}
@@ -1,79 +1,73 @@
package com.project.movienight.application.services
import com.fasterxml.jackson.databind.ObjectMapper
import com.project.movienight.adapters.metrics.BusinessMetricsService
import com.project.movienight.adapters.persistence.jdbc.JellyfinEventRepository
import com.project.movienight.application.ports.input.FilmLibraryUseCase
import com.project.movienight.application.ports.input.HandleJellyfinEventCommand
import com.project.movienight.application.ports.input.JellyfinEventUseCase
import com.project.movienight.application.ports.input.MarkFilmViewedCommand
import com.project.movienight.application.ports.input.MarkFilmViewedUseCase
import com.project.movienight.application.ports.output.BusinessMetricsPort
import com.project.movienight.application.ports.output.FilmRepositoryPort
import com.project.movienight.application.ports.output.JellyfinEventRecord
import com.project.movienight.application.ports.output.JellyfinEventStorePort
import com.project.movienight.application.ports.output.UserRepositoryPort
import org.springframework.stereotype.Service
import java.time.OffsetDateTime
import org.springframework.transaction.annotation.Transactional
@Service
class JellyfinEventService(
private val jellyfinEventRepository: JellyfinEventRepository,
private val jellyfinEventStore: JellyfinEventStorePort,
private val userRepository: UserRepositoryPort,
private val filmRepository: FilmRepositoryPort,
private val markFilmViewedUseCase: MarkFilmViewedUseCase,
private val filmLibraryUseCase: FilmLibraryUseCase,
private val objectMapper: ObjectMapper,
private val businessMetricsService: BusinessMetricsService,
) {
private val businessMetricsService: BusinessMetricsPort,
) : JellyfinEventUseCase {
private val playbackEventTypes = setOf("playback.ended", "playback.stopped", "playback.completed")
fun handleEvent(
eventId: String,
serverId: String?,
eventType: String,
occurredAt: OffsetDateTime,
jellyfinUserId: String,
itemId: String,
payload: Map<String, Any>?,
) {
val payloadJson = payload?.let { objectMapper.writeValueAsString(it) }
@Transactional
override fun handle(command: HandleJellyfinEventCommand) {
val payloadJson = command.payload?.let { objectMapper.writeValueAsString(it) }
val inserted =
jellyfinEventRepository.save(
eventId = eventId,
serverId = serverId,
eventType = eventType,
occurredAt = occurredAt,
jellyfinUserId = jellyfinUserId,
jellyfinItemId = itemId,
jellyfinEventStore.save(
JellyfinEventRecord(
eventId = command.eventId,
serverId = command.serverId,
eventType = command.eventType,
occurredAt = command.occurredAt,
jellyfinUserId = command.jellyfinUserId,
jellyfinItemId = command.itemId,
payload = payloadJson,
),
)
if (inserted != 1) {
if (!inserted) {
return
}
try {
if (playbackEventTypes.contains(eventType)) {
val localUser = userRepository.findAll().firstOrNull { it.jellyfinUserId == jellyfinUserId }
if (playbackEventTypes.contains(command.eventType)) {
val localUser = userRepository.findByJellyfinUserId(command.jellyfinUserId)
if (localUser == null) {
jellyfinEventRepository.delete(eventId)
businessMetricsService.recordJellyfinUnmappedUser()
return
}
val film = filmRepository.findByJellyfinItemId(itemId)
val film = filmRepository.findByJellyfinItemId(command.itemId)
if (film == null) {
jellyfinEventRepository.delete(eventId)
businessMetricsService.recordBackendWriteFailure()
return
}
markFilmViewedUseCase.markViewed(
filmLibraryUseCase.markViewed(
MarkFilmViewedCommand(
userId = localUser.id,
filmId = film.id,
watchedAt = occurredAt.toLocalDateTime(),
watchedAt = command.occurredAt.toLocalDateTime(),
),
)
businessMetricsService.recordLibraryEvent()
}
} catch (
@Suppress("TooGenericExceptionCaught") ex: RuntimeException,
) {
jellyfinEventRepository.delete(eventId)
businessMetricsService.recordBackendWriteFailure()
throw ex
}
@@ -1,18 +1,17 @@
package com.project.movienight.application.services
import com.project.movienight.adapters.jellyfin.JellyfinApiClient
import com.project.movienight.adapters.jellyfin.JellyfinLibraryItemSnapshot
import com.project.movienight.adapters.jellyfin.JellyfinRemoteUser
import com.project.movienight.adapters.metrics.BusinessMetricsService
import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
import com.project.movienight.application.ports.input.JellyfinSyncUseCase
import com.project.movienight.application.ports.output.BusinessMetricsPort
import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort
import com.project.movienight.application.ports.output.FilmRepositoryPort
import com.project.movienight.application.ports.output.IdGenerator
import com.project.movienight.application.ports.output.JellyfinCatalogPort
import com.project.movienight.application.ports.output.JellyfinLibraryItemSnapshot
import com.project.movienight.application.ports.output.JellyfinSyncStateRepositoryPort
import com.project.movienight.application.ports.output.UserRepositoryPort
import com.project.movienight.config.JellyfinIntegrationProperties
import com.project.movienight.domain.model.ContentType
import com.project.movienight.domain.model.Film
import com.project.movienight.domain.model.FilmLibrary
import com.project.movienight.domain.model.FilmLibraryEntry
import com.project.movienight.domain.model.JellyfinSyncState
import com.project.movienight.domain.model.JellyfinSyncSummary
import org.springframework.scheduling.annotation.Scheduled
@@ -20,18 +19,19 @@ import org.springframework.stereotype.Service
import java.time.Duration
import java.time.Instant
import java.time.LocalDateTime
import java.util.UUID
@Service
class JellyfinSyncService(
private val properties: JellyfinIntegrationProperties,
private val jellyfinApiClient: JellyfinApiClient,
private val jellyfinCatalog: JellyfinCatalogPort,
private val userRepository: UserRepositoryPort,
private val filmRepository: FilmRepositoryPort,
private val filmLibraryRepository: FilmLibraryRepositoryPort,
private val filmLibraryEntryRepository: FilmLibraryEntryRepositoryPort,
private val syncStateRepository: JellyfinSyncStateRepositoryPort,
private val idGenerator: IdGenerator,
private val businessMetricsService: BusinessMetricsService,
) {
private val businessMetricsService: BusinessMetricsPort,
) : JellyfinSyncUseCase {
@Scheduled(fixedDelayString = "\${integrations.jellyfin.sync-interval-ms:1800000}")
fun scheduledSync() {
if (properties.enabled) {
@@ -39,13 +39,26 @@ class JellyfinSyncService(
}
}
fun syncNow(): JellyfinSyncSummary {
override fun syncNow(): JellyfinSyncSummary {
if (!properties.enabled || properties.baseUrl.isBlank() || properties.apiKey.isBlank()) {
return JellyfinSyncSummary(syncedUsers = 0, skippedUsers = 0, syncedItems = 0, durationMs = 0)
}
return try {
runSync()
} catch (
@Suppress("TooGenericExceptionCaught") ex: RuntimeException,
) {
businessMetricsService.recordJellyfinSyncFailure()
throw ex
}
}
override fun getSyncStates(): List<JellyfinSyncState> = syncStateRepository.findAll()
private fun runSync(): JellyfinSyncSummary {
val startedAt = Instant.now()
val remoteUsers = jellyfinApiClient.fetchUsers()
val remoteUsers = jellyfinCatalog.fetchUsers()
val localUsersByJellyfinId =
userRepository
.findAll()
@@ -64,7 +77,7 @@ class JellyfinSyncService(
return@forEach
}
val items = jellyfinApiClient.fetchLibraryItems(remoteUser.id)
val items = jellyfinCatalog.fetchLibraryItems(remoteUser.id)
items.forEach { item ->
syncItem(localUser.id, item)
syncedItems += 1
@@ -94,12 +107,22 @@ class JellyfinSyncService(
return summary
}
fun getSyncStates(): List<JellyfinSyncState> = syncStateRepository.findAll()
private fun syncItem(
userId: java.util.UUID,
userId: UUID,
item: JellyfinLibraryItemSnapshot,
) {
val savedFilm = upsertFilm(item)
if (item.isPlayed) {
markFilmViewed(
userId = userId,
filmId = savedFilm.id,
watchedAt = LocalDateTime.now(),
)
}
}
private fun upsertFilm(item: JellyfinLibraryItemSnapshot): Film {
val film =
filmRepository.findByJellyfinItemId(item.jellyfinItemId)?.copy(
title = item.title,
@@ -130,24 +153,27 @@ class JellyfinSyncService(
jellyfinLibraryId = item.jellyfinLibraryId,
)
val savedFilm = filmRepository.save(film)
return filmRepository.save(film)
}
if (item.isPlayed) {
val watchedAt = LocalDateTime.now()
val existingEntry = filmLibraryRepository.findByUserIdAndFilmId(userId, savedFilm.id)
filmLibraryRepository.save(
private fun markFilmViewed(
userId: UUID,
filmId: UUID,
watchedAt: LocalDateTime,
) {
val existingEntry = filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId)
filmLibraryEntryRepository.save(
existingEntry?.copy(
isViewed = true,
watchedAt = watchedAt,
) ?: FilmLibrary(
) ?: FilmLibraryEntry(
id = idGenerator.generateId(),
userId = userId,
filmId = savedFilm.id,
filmId = filmId,
comment = null,
isViewed = true,
watchedAt = watchedAt,
),
)
}
}
}
@@ -3,7 +3,7 @@ package com.project.movienight.application.services
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingCommand
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingUseCase
import com.project.movienight.application.ports.input.RecommendationOnboardingResult
import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort
import com.project.movienight.application.ports.output.FilmRatingRepositoryPort
import com.project.movienight.application.ports.output.FilmRepositoryPort
import com.project.movienight.application.ports.output.IdGenerator
@@ -11,7 +11,7 @@ import com.project.movienight.application.ports.output.UserPreferencesRepository
import com.project.movienight.application.ports.output.UserRecommendationWeightsRepositoryPort
import com.project.movienight.application.ports.output.UserRepositoryPort
import com.project.movienight.domain.exception.EntityNotFoundException
import com.project.movienight.domain.model.FilmLibrary
import com.project.movienight.domain.model.FilmLibraryEntry
import com.project.movienight.domain.model.FilmRating
import com.project.movienight.domain.model.UserPreferences
import com.project.movienight.domain.model.UserRecommendationWeights
@@ -25,7 +25,7 @@ class RecommendationOnboardingService(
private val filmRepository: FilmRepositoryPort,
private val userPreferencesRepository: UserPreferencesRepositoryPort,
private val filmRatingRepository: FilmRatingRepositoryPort,
private val filmLibraryRepository: FilmLibraryRepositoryPort,
private val filmLibraryEntryRepository: FilmLibraryEntryRepositoryPort,
private val userRecommendationWeightsRepository: UserRecommendationWeightsRepositoryPort,
private val idGenerator: IdGenerator,
) : CompleteRecommendationOnboardingUseCase {
@@ -125,12 +125,12 @@ class RecommendationOnboardingService(
userId: UUID,
filmId: UUID,
isViewed: Boolean,
): FilmLibrary {
): FilmLibraryEntry {
val watchedAt = LocalDateTime.now().takeIf { isViewed }
val existing = filmLibraryRepository.findByUserIdAndFilmId(userId, filmId)
return filmLibraryRepository.save(
val existing = filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId)
return filmLibraryEntryRepository.save(
existing?.copy(isViewed = isViewed, watchedAt = watchedAt)
?: FilmLibrary(
?: FilmLibraryEntry(
id = idGenerator.generateId(),
userId = userId,
filmId = filmId,
@@ -1,13 +1,13 @@
package com.project.movienight.application.services
import com.project.movienight.adapters.metrics.BusinessMetricsService
import com.project.movienight.application.ports.input.AcceptRecommendationCommand
import com.project.movienight.application.ports.input.AcceptRecommendationUseCase
import com.project.movienight.application.ports.input.GetRecommendationsUseCase
import com.project.movienight.application.ports.input.RecommendationQuery
import com.project.movienight.application.ports.input.RejectRecommendationCommand
import com.project.movienight.application.ports.input.RejectRecommendationUseCase
import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
import com.project.movienight.application.ports.output.BusinessMetricsPort
import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort
import com.project.movienight.application.ports.output.FilmRatingRepositoryPort
import com.project.movienight.application.ports.output.FilmRepositoryPort
import com.project.movienight.application.ports.output.IdGenerator
@@ -17,7 +17,7 @@ import com.project.movienight.application.ports.output.UserRecommendationWeights
import com.project.movienight.application.ports.output.UserRepositoryPort
import com.project.movienight.domain.exception.EntityNotFoundException
import com.project.movienight.domain.model.Film
import com.project.movienight.domain.model.FilmLibrary
import com.project.movienight.domain.model.FilmLibraryEntry
import com.project.movienight.domain.model.FilmRating
import com.project.movienight.domain.model.RecommendationEvent
import com.project.movienight.domain.model.RecommendationEventType
@@ -34,14 +34,14 @@ import kotlin.math.sqrt
@Service
class RecommendationService(
private val filmRepository: FilmRepositoryPort,
private val filmLibraryRepository: FilmLibraryRepositoryPort,
private val filmLibraryEntryRepository: FilmLibraryEntryRepositoryPort,
private val filmRatingRepository: FilmRatingRepositoryPort,
private val userPreferencesRepository: UserPreferencesRepositoryPort,
private val userRepository: UserRepositoryPort,
private val recommendationEventRepository: RecommendationEventRepositoryPort,
private val userRecommendationWeightsRepository: UserRecommendationWeightsRepositoryPort,
private val idGenerator: IdGenerator,
private val businessMetricsService: BusinessMetricsService,
private val businessMetricsService: BusinessMetricsPort,
) : GetRecommendationsUseCase,
AcceptRecommendationUseCase,
RejectRecommendationUseCase {
@@ -54,7 +54,7 @@ class RecommendationService(
val preferences = userPreferencesRepository.findByUserId(query.userId)
val ratings = filmRatingRepository.findByUserId(query.userId)
val libraryEntries = filmLibraryRepository.findAll().filter { it.userId == query.userId }
val libraryEntries = filmLibraryEntryRepository.findByUserId(query.userId)
val libraryFilmIds = libraryEntries.map { it.filmId }.toSet()
val watchedFilmIds = libraryEntries.filter { it.isViewed }.map { it.filmId }.toSet()
val films = filmRepository.findAll()
@@ -278,7 +278,7 @@ class RecommendationService(
private fun buildUserProfile(
preferences: UserPreferences?,
ratings: List<FilmRating>,
libraryEntries: List<FilmLibrary>,
libraryEntries: List<FilmLibraryEntry>,
filmsById: Map<UUID, Film>,
weights: UserRecommendationWeights,
): SparseVector {
@@ -1,8 +1,7 @@
package com.project.movienight.application.services
import com.project.movienight.application.ports.input.GetUserPreferencesUseCase
import com.project.movienight.application.ports.input.UpsertUserPreferencesCommand
import com.project.movienight.application.ports.input.UpsertUserPreferencesUseCase
import com.project.movienight.application.ports.input.UserPreferencesUseCase
import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort
import com.project.movienight.domain.model.UserPreferences
import org.springframework.stereotype.Service
@@ -10,8 +9,7 @@ import org.springframework.stereotype.Service
@Service
class UserPreferencesService(
private val userPreferencesRepository: UserPreferencesRepositoryPort,
) : UpsertUserPreferencesUseCase,
GetUserPreferencesUseCase {
) : UserPreferencesUseCase {
override fun upsert(command: UpsertUserPreferencesCommand): UserPreferences =
userPreferencesRepository.save(
UserPreferences(
@@ -1,12 +1,8 @@
package com.project.movienight.application.services
import com.project.movienight.application.ports.input.CreateUserCommand
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.input.UserUseCase
import com.project.movienight.application.ports.output.IdGenerator
import com.project.movienight.application.ports.output.UserRepositoryPort
import com.project.movienight.config.UserServiceProperties
@@ -21,11 +17,7 @@ class UserService(
private val userRepository: UserRepositoryPort,
private val idGenerator: IdGenerator,
private val userConfig: UserServiceProperties,
) : CreateUserUseCase,
EditUserUseCase,
DeleteUserUseCase,
GetUserByIdUseCase,
GetAllUsersUseCase {
) : UserUseCase {
override fun create(command: CreateUserCommand): User {
if (userConfig.isBlocked(command.name)) {
throw BlockedValueException(target = "User", field = "name")
@@ -36,7 +28,6 @@ class UserService(
id = idGenerator.generateId(),
name = command.name,
email = command.email,
library = null,
jellyfinUserId = null,
)
return userRepository.save(user)
@@ -6,7 +6,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties
data class JellyfinIntegrationProperties(
val enabled: Boolean = false,
val baseUrl: String = "",
val webUrl: String = "",
val webUrl: String = baseUrl,
val apiKey: String = "",
val syncIntervalMs: Long = 1_800_000,
val requestTimeoutMs: Long = 20_000,
@@ -0,0 +1,12 @@
package com.project.movienight.config
import io.micrometer.core.aop.TimedAspect
import io.micrometer.core.instrument.MeterRegistry
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class MetricsConfiguration {
@Bean
fun timedAspect(meterRegistry: MeterRegistry): TimedAspect = TimedAspect(meterRegistry)
}
@@ -3,7 +3,7 @@ package com.project.movienight.domain.model
import java.time.LocalDateTime
import java.util.UUID
data class FilmLibrary(
data class FilmLibraryEntry(
val id: UUID,
val userId: UUID,
val filmId: UUID,
@@ -6,7 +6,6 @@ data class User(
val id: UUID,
val name: String,
val email: String,
val library: FilmLibrary?,
val preferences: UserPreferences? = null,
val jellyfinUserId: String? = null,
)
+59
View File
@@ -6,12 +6,26 @@ erDiagram
UUID id PK
VARCHAR name
VARCHAR email
VARCHAR provider
VARCHAR provider_id
VARCHAR jellyfin_user_id
TIMESTAMP created_at
}
films {
UUID id PK
VARCHAR title
TEXT description
VARCHAR content_type
INT release_year
TEXT genres
TEXT cast_members
TEXT directors
DOUBLE imdb_rating
DOUBLE platform_rating
TEXT external_url
VARCHAR jellyfin_item_id
VARCHAR jellyfin_library_id
}
favorites {
@@ -20,8 +34,53 @@ erDiagram
UUID film_id FK
VARCHAR comment
BOOLEAN is_viewed
TIMESTAMP watched_at
}
user_preferences {
UUID user_id PK,FK
TEXT weighted_genres
TEXT plot_types
TEXT eras
TEXT cast_and_directors
TEXT moods
TEXT content_types
}
film_ratings {
UUID id PK
UUID user_id FK
UUID film_id FK
INT score
VARCHAR note
TIMESTAMP created_at
TIMESTAMP updated_at
}
jellyfin_events {
VARCHAR event_id PK
VARCHAR server_id
VARCHAR event_type
TIMESTAMP occurred_at
VARCHAR jellyfin_user_id
VARCHAR jellyfin_item_id
JSON payload
TIMESTAMP created_at
}
jellyfin_sync_state {
UUID user_id PK,FK
TIMESTAMP last_synced_at
TIMESTAMP last_successful_sync_at
TEXT last_error
INT synced_item_count
TIMESTAMP updated_at
}
users ||--o{ favorites : has
films ||--o{ favorites : linked
users ||--o{ film_ratings : rates
films ||--o{ film_ratings : rated
users ||--|| user_preferences : configures
users ||--|| jellyfin_sync_state : syncs
```
@@ -0,0 +1,11 @@
DROP TABLE IF EXISTS public.ratings;
ALTER TABLE public.users
DROP COLUMN IF EXISTS jellyfin_id;
ALTER TABLE public.films
DROP COLUMN IF EXISTS jellyfin_id;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_jellyfin_user_id ON public.users(jellyfin_user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_films_jellyfin_item_id ON public.films(jellyfin_item_id);
CREATE INDEX IF NOT EXISTS idx_films_jellyfin_library_id ON public.films(jellyfin_library_id);
@@ -0,0 +1,17 @@
package com.project.movienight
import org.junit.jupiter.api.Test
import kotlin.test.assertNotNull
class ClassLoaderTest {
@Test
fun `can load OAuth2ClientProperties class`() {
val clazz =
Class.forName(
"org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties",
)
assertNotNull(clazz)
println("Successfully loaded: ${clazz.name}")
println("ClassLoader: ${clazz.classLoader}")
}
}
@@ -26,7 +26,6 @@ class UserEntityMappingTest {
assertEquals(entity.id, user.id)
assertEquals(entity.name, user.name)
assertEquals(entity.email, user.email)
assertNull(user.library)
assertNull(user.jellyfinUserId)
}
@@ -37,7 +36,6 @@ class UserEntityMappingTest {
id = UUID.randomUUID(),
name = "Jane",
email = "jane@mail.com",
library = null,
)
val entity = user.toEntity(AuthProvider.YANDEX, "yandex456")
@@ -56,7 +54,6 @@ class UserEntityMappingTest {
id = UUID.randomUUID(),
name = "Bob",
email = "bob@mail.com",
library = null,
)
val entity = user.toEntity()
@@ -72,7 +69,6 @@ class UserEntityMappingTest {
id = UUID.randomUUID(),
name = "Alice",
email = "alice@email.com",
library = null,
)
val mapped = original.toEntity().toDomain()
@@ -1,7 +1,7 @@
package com.project.movienight.adapters.persistence.jdbc
import com.project.movienight.domain.model.Film
import com.project.movienight.domain.model.FilmLibrary
import com.project.movienight.domain.model.FilmLibraryEntry
import com.project.movienight.domain.model.User
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
@@ -19,9 +19,9 @@ import java.util.UUID
@SpringBootTest
@ActiveProfiles("test")
class FilmLibraryRepositoryIntegrationTest {
class FilmLibraryEntryRepositoryIntegrationTest {
@Autowired
private lateinit var filmLibraryRepository: FilmLibraryRepository
private lateinit var filmLibraryEntryRepository: FilmLibraryEntryRepository
@Autowired
private lateinit var userRepository: UserRepository
@@ -62,7 +62,7 @@ class FilmLibraryRepositoryIntegrationTest {
@Test
fun `should save new film library entry and return saved entry`() {
val entry =
FilmLibrary(
FilmLibraryEntry(
id = UUID.randomUUID(),
userId = testUser.id,
filmId = testFilm.id,
@@ -70,7 +70,7 @@ class FilmLibraryRepositoryIntegrationTest {
isViewed = false,
)
val savedEntry = filmLibraryRepository.save(entry)
val savedEntry = filmLibraryEntryRepository.save(entry)
assertNotNull(savedEntry)
assertEquals(entry.id, savedEntry.id)
@@ -83,17 +83,17 @@ class FilmLibraryRepositoryIntegrationTest {
@Test
fun `should update existing film library entry`() {
val entryId = UUID.randomUUID()
val originalEntry = FilmLibrary(entryId, testUser.id, testFilm.id, "Хочу посмотреть", false)
filmLibraryRepository.save(originalEntry)
val originalEntry = FilmLibraryEntry(entryId, testUser.id, testFilm.id, "Хочу посмотреть", false)
filmLibraryEntryRepository.save(originalEntry)
val updatedEntry = FilmLibrary(entryId, testUser.id, testFilm.id, "Уже посмотрел, потрясающе!", true)
val result = filmLibraryRepository.save(updatedEntry)
val updatedEntry = FilmLibraryEntry(entryId, testUser.id, testFilm.id, "Уже посмотрел, потрясающе!", true)
val result = filmLibraryEntryRepository.save(updatedEntry)
assertEquals(entryId, result.id)
assertEquals("Уже посмотрел, потрясающе!", result.comment)
assertTrue(result.isViewed)
val foundEntry = filmLibraryRepository.findById(entryId)
val foundEntry = filmLibraryEntryRepository.findById(entryId)
assertNotNull(foundEntry)
assertEquals("Уже посмотрел, потрясающе!", foundEntry?.comment)
assertTrue(foundEntry?.isViewed ?: false)
@@ -101,10 +101,10 @@ class FilmLibraryRepositoryIntegrationTest {
@Test
fun `should find film library entry by id`() {
val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Обязательно посмотреть", false)
filmLibraryRepository.save(entry)
val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Обязательно посмотреть", false)
filmLibraryEntryRepository.save(entry)
val foundEntry = filmLibraryRepository.findById(entry.id)
val foundEntry = filmLibraryEntryRepository.findById(entry.id)
assertNotNull(foundEntry)
assertEquals(entry.id, foundEntry?.id)
@@ -118,22 +118,22 @@ class FilmLibraryRepositoryIntegrationTest {
fun `should return null when film library entry not found by id`() {
val nonExistentId = UUID.randomUUID()
val foundEntry = filmLibraryRepository.findById(nonExistentId)
val foundEntry = filmLibraryEntryRepository.findById(nonExistentId)
assertNull(foundEntry)
}
@Test
fun `should find all film library entries`() {
val entry1 = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Комментарий 1", false)
val entry2 = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Комментарий 2", true)
val entry3 = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, null, false)
val entry1 = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Комментарий 1", false)
val entry2 = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Комментарий 2", true)
val entry3 = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, null, false)
filmLibraryRepository.save(entry1)
filmLibraryRepository.save(entry2)
filmLibraryRepository.save(entry3)
filmLibraryEntryRepository.save(entry1)
filmLibraryEntryRepository.save(entry2)
filmLibraryEntryRepository.save(entry3)
val allEntries = filmLibraryRepository.findAll()
val allEntries = filmLibraryEntryRepository.findAll()
assertEquals(3, allEntries.size)
assertTrue(allEntries.any { it.id == entry1.id })
@@ -143,19 +143,19 @@ class FilmLibraryRepositoryIntegrationTest {
@Test
fun `should return empty list when no film library entries exist`() {
val allEntries = filmLibraryRepository.findAll()
val allEntries = filmLibraryEntryRepository.findAll()
assertTrue(allEntries.isEmpty())
}
@Test
fun `should delete film library entry by id`() {
val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Для удаления", false)
filmLibraryRepository.save(entry)
val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Для удаления", false)
filmLibraryEntryRepository.save(entry)
filmLibraryRepository.deleteById(entry.id)
filmLibraryEntryRepository.deleteById(entry.id)
val foundEntry = filmLibraryRepository.findById(entry.id)
val foundEntry = filmLibraryEntryRepository.findById(entry.id)
assertNull(foundEntry)
}
@@ -163,14 +163,14 @@ class FilmLibraryRepositoryIntegrationTest {
fun `should not throw exception when deleting non-existent entry`() {
val nonExistentId = UUID.randomUUID()
filmLibraryRepository.deleteById(nonExistentId)
filmLibraryEntryRepository.deleteById(nonExistentId)
}
@Test
fun `should save entry with null comment`() {
val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, null, false)
val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, null, false)
val savedEntry = filmLibraryRepository.save(entry)
val savedEntry = filmLibraryEntryRepository.save(entry)
assertNotNull(savedEntry)
assertNull(savedEntry.comment)
@@ -178,9 +178,9 @@ class FilmLibraryRepositoryIntegrationTest {
@Test
fun `should save entry with isViewed true`() {
val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Посмотрел", true)
val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Посмотрел", true)
val savedEntry = filmLibraryRepository.save(entry)
val savedEntry = filmLibraryEntryRepository.save(entry)
assertNotNull(savedEntry)
assertTrue(savedEntry.isViewed)
@@ -188,9 +188,9 @@ class FilmLibraryRepositoryIntegrationTest {
@Test
fun `should save entry with isViewed false`() {
val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Еще не смотрел", false)
val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Еще не смотрел", false)
val savedEntry = filmLibraryRepository.save(entry)
val savedEntry = filmLibraryEntryRepository.save(entry)
assertNotNull(savedEntry)
assertFalse(savedEntry.isViewed)
@@ -198,23 +198,23 @@ class FilmLibraryRepositoryIntegrationTest {
@Test
fun `should cascade delete entries when user is deleted`() {
val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Любимый фильм пользователя", false)
filmLibraryRepository.save(entry)
val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Любимый фильм пользователя", false)
filmLibraryEntryRepository.save(entry)
userRepository.deleteById(testUser.id)
val foundEntry = filmLibraryRepository.findById(entry.id)
val foundEntry = filmLibraryEntryRepository.findById(entry.id)
assertNull(foundEntry)
}
@Test
fun `should cascade delete entries when film is deleted`() {
val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Запись о фильме", false)
filmLibraryRepository.save(entry)
val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Запись о фильме", false)
filmLibraryEntryRepository.save(entry)
filmRepository.deleteById(testFilm.id)
val foundEntry = filmLibraryRepository.findById(entry.id)
val foundEntry = filmLibraryEntryRepository.findById(entry.id)
assertNull(foundEntry)
}
}
@@ -1,5 +1,6 @@
package com.project.movienight.adapters.persistence.jdbc
import com.project.movienight.domain.model.AuthProvider
import com.project.movienight.domain.model.User
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
@@ -46,7 +47,6 @@ class UserRepositoryIntegrationTest {
id = UUID.randomUUID(),
name = "John Doe",
email = "john@example.com",
library = null,
)
val savedUser = userRepository.save(user)
@@ -61,10 +61,10 @@ class UserRepositoryIntegrationTest {
fun `should update existing user`() {
// given
val userId = UUID.randomUUID()
val originalUser = User(userId, "John Doe", "john@example.com", null)
val originalUser = User(userId, "John Doe", "john@example.com")
userRepository.save(originalUser)
val updatedUser = User(userId, "Jane Doe", "jane@example.com", null)
val updatedUser = User(userId, "Jane Doe", "jane@example.com")
val result = userRepository.save(updatedUser)
assertEquals(userId, result.id)
@@ -80,7 +80,7 @@ class UserRepositoryIntegrationTest {
@Test
fun `should find user by id`() {
// given
val user = User(UUID.randomUUID(), "John Doe", "john@example.com", null)
val user = User(UUID.randomUUID(), "John Doe", "john@example.com")
userRepository.save(user)
// when
@@ -108,9 +108,9 @@ class UserRepositoryIntegrationTest {
@Test
fun `should find all users`() {
// given
val user1 = User(UUID.randomUUID(), "John Doe", "john@example.com", null)
val user2 = User(UUID.randomUUID(), "Jane Smith", "jane@example.com", null)
val user3 = User(UUID.randomUUID(), "Bob Johnson", "bob@example.com", null)
val user1 = User(UUID.randomUUID(), "John Doe", "john@example.com")
val user2 = User(UUID.randomUUID(), "Jane Smith", "jane@example.com")
val user3 = User(UUID.randomUUID(), "Bob Johnson", "bob@example.com")
userRepository.save(user1)
userRepository.save(user2)
@@ -138,7 +138,7 @@ class UserRepositoryIntegrationTest {
@Test
fun `should delete user by id`() {
// given
val user = User(UUID.randomUUID(), "John Doe", "john@example.com", null)
val user = User(UUID.randomUUID(), "John Doe", "john@example.com")
userRepository.save(user)
// when
@@ -157,4 +157,41 @@ class UserRepositoryIntegrationTest {
// when & then (no exception should be thrown)
userRepository.deleteById(nonExistentId)
}
@Test
fun `should create OAuth user with provider identity`() {
val user = User(UUID.randomUUID(), "OAuth User", "oauth@example.com")
val savedUser = userRepository.createOAuthUser(user, AuthProvider.GOOGLE, "google-123")
assertEquals(user.id, savedUser.id)
assertEquals(user.email, savedUser.email)
val foundByProvider = userRepository.findByProviderAndProviderId(AuthProvider.GOOGLE, "google-123")
assertNotNull(foundByProvider)
assertEquals(user.id, foundByProvider?.id)
}
@Test
fun `should link OAuth account to existing user`() {
val user = userRepository.save(User(UUID.randomUUID(), "Link User", "link@example.com"))
val linkedUser = userRepository.linkOAuthAccount(user.id, AuthProvider.YANDEX, "yandex-456")
assertEquals(user.id, linkedUser.id)
val foundByProvider = userRepository.findByProviderAndProviderId(AuthProvider.YANDEX, "yandex-456")
assertNotNull(foundByProvider)
assertEquals(user.id, foundByProvider?.id)
}
@Test
fun `find by email should include jellyfin user id`() {
val user = userRepository.save(User(UUID.randomUUID(), "Jellyfin User", "jellyfin@example.com"))
jdbcTemplate.update("UPDATE users SET jellyfin_user_id = ? WHERE id = ?", "jellyfin-789", user.id)
val foundUser = userRepository.findByEmail(user.email)
assertNotNull(foundUser)
assertEquals("jellyfin-789", foundUser?.jellyfinUserId)
}
}
@@ -1,11 +1,6 @@
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.application.ports.input.FilmUseCase
import com.project.movienight.domain.model.Film
import io.mockk.every
import io.mockk.mockk
@@ -19,20 +14,15 @@ import java.util.UUID
class FilmControllerSearchTest {
private lateinit var mockMvc: MockMvc
private lateinit var searchFilmByTitleUseCase: SearchFilmByTitleUseCase
private lateinit var filmUseCase: FilmUseCase
@BeforeEach
fun setup() {
searchFilmByTitleUseCase = mockk()
filmUseCase = mockk()
val controller =
FilmController(
createFilmUseCase = mockk<CreateFilmUseCase>(),
editFilmUseCase = mockk<EditFilmUseCase>(),
deleteFilmUseCase = mockk<DeleteFilmUseCase>(),
getFilmByIdUseCase = mockk<GetFilmByIdUseCase>(),
getAllFilmsUseCase = mockk<GetAllFilmsUseCase>(),
searchFilmByTitleUseCase = searchFilmByTitleUseCase,
filmUseCase = filmUseCase,
)
mockMvc = MockMvcBuilders.standaloneSetup(controller).build()
@@ -43,7 +33,7 @@ class FilmControllerSearchTest {
val title = "Inception"
val film = Film(id = UUID.randomUUID(), title = title, description = "A dream heist")
every { searchFilmByTitleUseCase.searchByTitle(title) } returns film
every { filmUseCase.searchByTitle(title) } returns film
mockMvc
.get("/api/films/search") {
@@ -55,14 +45,14 @@ class FilmControllerSearchTest {
jsonPath("$.description") { value("A dream heist") }
}
verify(exactly = 1) { searchFilmByTitleUseCase.searchByTitle(title) }
verify(exactly = 1) { filmUseCase.searchByTitle(title) }
}
@Test
fun `search returns 404 when title is not found`() {
val title = "Unknown Title"
every { searchFilmByTitleUseCase.searchByTitle(title) } returns null
every { filmUseCase.searchByTitle(title) } returns null
mockMvc
.get("/api/films/search") {
@@ -72,6 +62,6 @@ class FilmControllerSearchTest {
content { string("") }
}
verify(exactly = 1) { searchFilmByTitleUseCase.searchByTitle(title) }
verify(exactly = 1) { filmUseCase.searchByTitle(title) }
}
}
@@ -1,15 +1,15 @@
package com.project.movienight.application.services
import com.project.movienight.adapters.metrics.BusinessMetricsService
import com.project.movienight.application.ports.input.AddFilmToLibraryCommand
import com.project.movienight.application.ports.input.CreateFilmLibraryCommand
import com.project.movienight.application.ports.input.GetFilmLibraryQuery
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand
import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
import com.project.movienight.application.ports.output.BusinessMetricsPort
import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort
import com.project.movienight.application.ports.output.FilmRepositoryPort
import com.project.movienight.application.ports.output.IdGenerator
import com.project.movienight.domain.exception.DomainException
import com.project.movienight.domain.exception.EntityNotFoundException
import com.project.movienight.domain.model.FilmLibrary
import com.project.movienight.domain.model.Film
import com.project.movienight.domain.model.FilmLibraryEntry
import io.mockk.every
import io.mockk.justRun
import io.mockk.mockk
@@ -22,83 +22,52 @@ import org.junit.jupiter.api.assertThrows
import java.util.UUID
class FilmLibraryServiceTest {
private lateinit var filmLibraryRepository: FilmLibraryRepositoryPort
private lateinit var filmLibraryEntryRepository: FilmLibraryEntryRepositoryPort
private lateinit var filmRepository: FilmRepositoryPort
private lateinit var idGenerator: IdGenerator
private lateinit var businessMetricsService: BusinessMetricsService
private lateinit var businessMetricsService: BusinessMetricsPort
private lateinit var filmLibraryService: FilmLibraryService
@BeforeEach
fun setup() {
filmLibraryRepository = mockk()
filmLibraryEntryRepository = mockk()
filmRepository = mockk()
idGenerator = mockk()
businessMetricsService = mockk(relaxed = true)
filmLibraryService = FilmLibraryService(filmLibraryRepository, idGenerator, businessMetricsService)
}
@Test
fun `should throw EntityNotFoundException when creating library for user with no entries`() {
val userId = UUID.randomUUID()
val command = CreateFilmLibraryCommand(userId = userId, name = "My Films")
every { filmLibraryRepository.findAll() } returns emptyList()
assertThrows<EntityNotFoundException> {
filmLibraryService.create(command)
}
verify(exactly = 1) { filmLibraryRepository.findAll() }
verify(exactly = 0) { idGenerator.generateId() }
verify(exactly = 0) { filmLibraryRepository.save(any()) }
}
@Test
fun `should return existing library when user already has one`() {
val userId = UUID.randomUUID()
val existingLibrary =
FilmLibrary(
id = UUID.randomUUID(),
userId = userId,
filmId = UUID.randomUUID(),
comment = "Existing Library",
isViewed = false,
filmLibraryService =
FilmLibraryService(
filmLibraryEntryRepository,
filmRepository,
idGenerator,
businessMetricsService,
)
val command = CreateFilmLibraryCommand(userId = userId, name = "New Library")
every { filmLibraryRepository.findAll() } returns listOf(existingLibrary)
val result = filmLibraryService.create(command)
assertEquals(existingLibrary, result)
verify(exactly = 1) { filmLibraryRepository.findAll() }
verify(exactly = 0) { idGenerator.generateId() }
verify(exactly = 0) { filmLibraryRepository.save(any()) }
}
@Test
fun `should add film to new library when user has no library`() {
fun `should add film as new library entry`() {
val userId = UUID.randomUUID()
val filmId = UUID.randomUUID()
val libraryId = UUID.randomUUID()
val entryId = UUID.randomUUID()
val command = AddFilmToLibraryCommand(userId = userId, filmId = filmId)
val expectedLibrary =
FilmLibrary(
id = libraryId,
val expectedEntry =
FilmLibraryEntry(
id = entryId,
userId = userId,
filmId = filmId,
comment = null,
isViewed = false,
)
every { filmLibraryRepository.findAll() } returns emptyList()
every { idGenerator.generateId() } returns libraryId
every { filmRepository.findById(filmId) } returns Film(filmId, "Film", "Description")
every { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } returns null
every { idGenerator.generateId() } returns entryId
every {
filmLibraryRepository.save(
filmLibraryEntryRepository.save(
match {
it.userId == userId && it.filmId == filmId && it.comment == null && it.isViewed == false
},
)
} returns expectedLibrary
} returns expectedEntry
val result = filmLibraryService.addFilm(command)
@@ -106,62 +75,46 @@ class FilmLibraryServiceTest {
assertEquals(filmId, result.filmId)
assertEquals(userId, result.userId)
verify(exactly = 1) { filmLibraryRepository.findAll() }
verify(exactly = 1) { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) }
verify(exactly = 1) { idGenerator.generateId() }
verify(exactly = 1) { filmLibraryRepository.save(any()) }
verify(exactly = 1) { filmLibraryEntryRepository.save(any()) }
}
@Test
fun `should add film as a new library entry when another film already exists`() {
fun `should reset viewed state when adding existing entry`() {
val userId = UUID.randomUUID()
val oldFilmId = UUID.randomUUID()
val newFilmId = UUID.randomUUID()
val existingLibrary =
FilmLibrary(
val filmId = UUID.randomUUID()
val existingEntry =
FilmLibraryEntry(
id = UUID.randomUUID(),
userId = userId,
filmId = oldFilmId,
filmId = filmId,
comment = "My Library",
isViewed = true,
)
val command = AddFilmToLibraryCommand(userId = userId, filmId = newFilmId)
val createdLibrary =
FilmLibrary(
id = UUID.randomUUID(),
userId = userId,
filmId = newFilmId,
comment = null,
isViewed = false,
)
val updatedEntry = existingEntry.copy(isViewed = false, watchedAt = null)
every { filmLibraryRepository.findAll() } returns listOf(existingLibrary)
every { idGenerator.generateId() } returns createdLibrary.id
every {
filmLibraryRepository.save(
match {
it.id == createdLibrary.id && it.userId == userId && it.filmId == newFilmId && it.isViewed == false
},
)
} returns createdLibrary
every { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } returns existingEntry
every { filmRepository.findById(filmId) } returns Film(filmId, "Film", "Description")
every { filmLibraryEntryRepository.save(updatedEntry) } returns updatedEntry
val result = filmLibraryService.addFilm(command)
val result = filmLibraryService.addFilm(AddFilmToLibraryCommand(userId = userId, filmId = filmId))
assertEquals(newFilmId, result.filmId)
assertEquals(false, result.isViewed)
assertEquals(updatedEntry, result)
verify(exactly = 1) { filmLibraryRepository.findAll() }
verify(exactly = 1) { idGenerator.generateId() }
verify(exactly = 1) { filmLibraryRepository.save(any()) }
verify(exactly = 1) { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) }
verify(exactly = 0) { idGenerator.generateId() }
verify(exactly = 1) { filmLibraryEntryRepository.save(updatedEntry) }
}
@Test
fun `should remove film from library successfully`() {
val userId = UUID.randomUUID()
val filmId = UUID.randomUUID()
val libraryId = UUID.randomUUID()
val existingLibrary =
FilmLibrary(
id = libraryId,
val entryId = UUID.randomUUID()
val existingEntry =
FilmLibraryEntry(
id = entryId,
userId = userId,
filmId = filmId,
comment = "My Library",
@@ -169,122 +122,70 @@ class FilmLibraryServiceTest {
)
val command = RemoveFilmFromLibraryCommand(userId = userId, filmId = filmId)
every { filmLibraryRepository.findAll() } returns listOf(existingLibrary)
justRun { filmLibraryRepository.deleteById(libraryId) }
every { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } returns existingEntry
justRun { filmLibraryEntryRepository.deleteById(entryId) }
val result = filmLibraryService.removeFilm(command)
assertEquals(existingLibrary, result)
assertEquals(existingEntry, result)
verify(exactly = 1) { filmLibraryRepository.findAll() }
verify(exactly = 1) { filmLibraryRepository.deleteById(libraryId) }
verify(exactly = 1) { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) }
verify(exactly = 1) { filmLibraryEntryRepository.deleteById(entryId) }
}
@Test
fun `should throw EntityNotFoundException when removing film from non-existent library`() {
fun `should throw EntityNotFoundException when removing non-existent entry`() {
val userId = UUID.randomUUID()
val filmId = UUID.randomUUID()
val command = RemoveFilmFromLibraryCommand(userId = userId, filmId = filmId)
every { filmLibraryRepository.findAll() } returns emptyList()
every { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } returns null
assertThrows<EntityNotFoundException> {
filmLibraryService.removeFilm(command)
}
verify(exactly = 1) { filmLibraryRepository.findAll() }
verify(exactly = 0) { filmLibraryRepository.deleteById(any()) }
verify(exactly = 1) { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) }
verify(exactly = 0) { filmLibraryEntryRepository.deleteById(any()) }
}
@Test
fun `should throw DomainException when removing film that is not in library`() {
val userId = UUID.randomUUID()
val libraryFilmId = UUID.randomUUID()
val differentFilmId = UUID.randomUUID()
val existingLibrary =
FilmLibrary(
id = UUID.randomUUID(),
userId = userId,
filmId = libraryFilmId,
comment = "My Library",
isViewed = false,
)
val command = RemoveFilmFromLibraryCommand(userId = userId, filmId = differentFilmId)
every { filmLibraryRepository.findAll() } returns listOf(existingLibrary)
assertThrows<DomainException> {
filmLibraryService.removeFilm(command)
}
verify(exactly = 1) { filmLibraryRepository.findAll() }
verify(exactly = 0) { filmLibraryRepository.deleteById(any()) }
}
@Test
fun `should throw EntityNotFoundException when libraryId does not match`() {
fun `should throw DomainException when entry id belongs to another film`() {
val userId = UUID.randomUUID()
val filmId = UUID.randomUUID()
val actualLibraryId = UUID.randomUUID()
val wrongLibraryId = UUID.randomUUID()
val existingLibrary =
FilmLibrary(
id = actualLibraryId,
userId = userId,
filmId = filmId,
comment = "My Library",
isViewed = false,
)
val command =
RemoveFilmFromLibraryCommand(
userId = userId,
filmId = filmId,
libraryId = wrongLibraryId,
)
every { filmLibraryRepository.findById(wrongLibraryId) } returns null
assertThrows<EntityNotFoundException> {
filmLibraryService.removeFilm(command)
}
verify(exactly = 1) { filmLibraryRepository.findById(wrongLibraryId) }
verify(exactly = 0) { filmLibraryRepository.deleteById(any()) }
}
@Test
fun `should get library successfully`() {
val userId = UUID.randomUUID()
val existingLibrary =
FilmLibrary(
id = UUID.randomUUID(),
val entryId = UUID.randomUUID()
val existingEntry =
FilmLibraryEntry(
id = entryId,
userId = userId,
filmId = UUID.randomUUID(),
comment = "My Library",
isViewed = false,
)
val query = GetFilmLibraryQuery(userId = userId)
val command = RemoveFilmFromLibraryCommand(userId = userId, filmId = filmId, entryId = entryId)
every { filmLibraryRepository.findAll() } returns listOf(existingLibrary)
every { filmLibraryEntryRepository.findById(entryId) } returns existingEntry
val result = filmLibraryService.getLibrary(query)
assertThrows<DomainException> {
filmLibraryService.removeFilm(command)
}
assertEquals(existingLibrary, result)
verify(exactly = 1) { filmLibraryRepository.findAll() }
verify(exactly = 1) { filmLibraryEntryRepository.findById(entryId) }
verify(exactly = 0) { filmLibraryEntryRepository.deleteById(any()) }
}
@Test
fun `should throw EntityNotFoundException when getting non-existent library`() {
fun `should list entries by user`() {
val userId = UUID.randomUUID()
val query = GetFilmLibraryQuery(userId = userId)
val entries =
listOf(
FilmLibraryEntry(UUID.randomUUID(), userId, UUID.randomUUID(), null, false),
)
every { filmLibraryRepository.findAll() } returns emptyList()
every { filmLibraryEntryRepository.findByUserId(userId) } returns entries
assertThrows<EntityNotFoundException> {
filmLibraryService.getLibrary(query)
}
assertEquals(entries, filmLibraryService.list(userId))
verify(exactly = 1) { filmLibraryRepository.findAll() }
verify(exactly = 1) { filmLibraryEntryRepository.findByUserId(userId) }
}
}
@@ -2,13 +2,13 @@ package com.project.movienight.application.services
import com.project.movienight.application.ports.input.CreateFilmCommand
import com.project.movienight.application.ports.input.EditFilmCommand
import com.project.movienight.application.ports.output.BusinessMetricsPort
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.simple.SimpleMeterRegistry
import io.mockk.every
import io.mockk.justRun
import io.mockk.mockk
@@ -24,7 +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 businessMetricsService: BusinessMetricsPort
private lateinit var filmService: FilmService
@BeforeEach
@@ -32,8 +32,8 @@ class FilmServiceTest {
filmRepository = mockk()
idGenerator = mockk()
filmConfig = mockk()
meterRegistry = SimpleMeterRegistry()
filmService = FilmService(filmRepository, idGenerator, filmConfig, meterRegistry)
businessMetricsService = mockk(relaxed = true)
filmService = FilmService(filmRepository, idGenerator, filmConfig, businessMetricsService)
}
@Test
@@ -37,7 +37,7 @@ class UserServiceTest {
fun `should create user successfully`() {
val command = CreateUserCommand(name = "John Doe", email = "john@example.com")
val userId = UUID.randomUUID()
val expectedUser = User(id = userId, name = "John Doe", email = "john@example.com", library = null)
val expectedUser = User(id = userId, name = "John Doe", email = "john@example.com")
every { userConfig.isBlocked("John Doe") } returns false
every { idGenerator.generateId() } returns userId
@@ -74,8 +74,8 @@ class UserServiceTest {
fun `should edit user successfully`() {
val userId = UUID.randomUUID()
val command = EditUserCommand(name = "Jane Doe")
val existingUser = User(id = userId, name = "John Doe", email = "john@example.com", library = null)
val updatedUser = User(id = userId, name = "Jane Doe", email = "john@example.com", library = null)
val existingUser = User(id = userId, name = "John Doe", email = "john@example.com")
val updatedUser = User(id = userId, name = "Jane Doe", email = "john@example.com")
every { userConfig.isBlocked("Jane Doe") } returns false
every { userRepository.findById(userId) } returns existingUser
@@ -128,7 +128,7 @@ class UserServiceTest {
@Test
fun `should delete user successfully`() {
val userId = UUID.randomUUID()
val existingUser = User(id = userId, name = "John Doe", email = "john@example.com", library = null)
val existingUser = User(id = userId, name = "John Doe", email = "john@example.com")
every { userRepository.findById(userId) } returns existingUser
justRun { userRepository.deleteById(userId) }
@@ -0,0 +1,29 @@
package com.project.movienight.config
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Primary
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.web.SecurityFilterChain
@TestConfiguration
@EnableWebSecurity
class TestSecurityConfiguration {
@Bean
@Primary
fun testSecurityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.authorizeHttpRequests { auth ->
auth.anyRequest().permitAll()
}.csrf { csrf ->
csrf.disable()
}.headers { headers ->
headers.frameOptions { frameOptions ->
frameOptions.sameOrigin()
}
}
return http.build()
}
}