From ef995422dda6c232049dc5aabc2347830ba7957a Mon Sep 17 00:00:00 2001 From: skettiks Date: Fri, 22 May 2026 14:43:26 +0300 Subject: [PATCH] =?UTF-8?q?refactor:=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20use=20case=20=D1=81=D0=BB=D0=BE=D0=B8=20?= =?UTF-8?q?=D0=B8=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8E=20Jellyfin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 1 + gradle/libs.versions.toml | 1 + .../adapters/jellyfin/JellyfinApiClient.kt | 30 +-- .../metrics/BusinessMetricsService.kt | 39 ++- .../adapters/persistence/entity/UserEntity.kt | 1 - ...itory.kt => FilmLibraryEntryRepository.kt} | 75 +++--- .../persistence/jdbc/FilmRepository.kt | 26 -- .../jdbc/JellyfinEventRepository.kt | 36 +-- .../persistence/jdbc/UserRepository.kt | 108 +++++++- .../security/CustomOAuth2UserService.kt | 25 +- .../security/SecurityConfiguration.kt | 5 + .../adapters/web/ApiExceptionHandler.kt | 41 +++ .../adapters/web/ContentTypeParser.kt | 10 + .../movienight/adapters/web/FilmController.kt | 41 +-- .../adapters/web/FilmLibraryController.kt | 97 ++----- .../adapters/web/FilmRatingController.kt | 13 +- .../adapters/web/JellyfinEventsController.kt | 26 +- .../adapters/web/JellyfinSyncController.kt | 8 +- .../adapters/web/RecommendationController.kt | 3 +- .../movienight/adapters/web/UserController.kt | 34 +-- .../adapters/web/UserPreferencesController.kt | 22 +- .../dto/request/CreateFilmLibraryRequest.kt | 5 - .../web/dto/request/CreateFilmRequest.kt | 15 ++ .../web/dto/request/CreateUserRequest.kt | 9 + .../web/dto/request/EditFilmRequest.kt | 15 ++ .../web/dto/request/EditUserRequest.kt | 6 + .../web/dto/request/JellyfinEventRequest.kt | 5 + .../web/dto/request/RateFilmRequest.kt | 7 + .../dto/response/FilmLibraryEntryResponse.kt | 26 ++ .../web/dto/response/FilmLibraryResponse.kt | 25 -- .../ports/input/FilmLibraryUseCase.kt | 42 +-- .../ports/input/FilmRatingUseCase.kt | 8 +- .../application/ports/input/FilmUseCase.kt | 38 +-- .../ports/input/JellyfinUseCase.kt | 25 ++ .../ports/input/UserPreferencesUseCase.kt | 8 +- .../application/ports/input/UserUseCase.kt | 32 +-- .../ports/output/BusinessMetricsPort.kt | 30 +++ .../output/FilmLibraryEntryRepositoryPort.kt | 21 ++ .../ports/output/FilmLibraryRepositoryPort.kt | 19 -- .../ports/output/FilmRepositoryPort.kt | 2 - .../ports/output/JellyfinCatalogPort.kt | 30 +++ .../ports/output/JellyfinEventStorePort.kt | 17 ++ .../ports/output/UserRepositoryPort.kt | 14 + .../services/FilmLibraryService.kt | 95 +++---- .../application/services/FilmRatingService.kt | 10 +- .../application/services/FilmService.kt | 225 ++++++---------- .../services/JellyfinEventService.kt | 66 +++-- .../services/JellyfinSyncService.kt | 96 ++++--- .../RecommendationOnboardingService.kt | 14 +- .../services/RecommendationService.kt | 14 +- .../services/UserPreferencesService.kt | 6 +- .../application/services/UserService.kt | 13 +- .../config/JellyfinIntegrationProperties.kt | 2 +- .../movienight/config/MetricsConfiguration.kt | 12 + .../{FilmLibrary.kt => FilmLibraryEntry.kt} | 2 +- .../project/movienight/domain/model/User.kt | 1 - src/main/resources/db/ER.md | 59 +++++ .../migration/V9__cleanup_legacy_schema.sql | 11 + .../com/project/movienight/ClassLoaderTest.kt | 17 ++ .../entity/UserEntityMappingTest.kt | 4 - ...mLibraryEntryRepositoryIntegrationTest.kt} | 78 +++--- .../jdbc/UserRepositoryIntegrationTest.kt | 53 +++- .../adapters/web/FilmControllerSearchTest.kt | 26 +- .../services/FilmLibraryServiceTest.kt | 249 ++++++------------ .../application/services/FilmServiceTest.kt | 8 +- .../application/services/UserServiceTest.kt | 8 +- .../config/TestSecurityConfiguration.kt | 29 ++ 67 files changed, 1144 insertions(+), 995 deletions(-) rename src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/{FilmLibraryRepository.kt => FilmLibraryEntryRepository.kt} (60%) create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/ContentTypeParser.kt delete mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmLibraryRequest.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryEntryResponse.kt delete mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/BusinessMetricsPort.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryEntryRepositoryPort.kt delete mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/JellyfinCatalogPort.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/JellyfinEventStorePort.kt create mode 100644 src/main/kotlin/com/project/movienight/config/MetricsConfiguration.kt rename src/main/kotlin/com/project/movienight/domain/model/{FilmLibrary.kt => FilmLibraryEntry.kt} (89%) create mode 100644 src/main/resources/db/migration/V9__cleanup_legacy_schema.sql create mode 100644 src/test/kotlin/com/project/movienight/ClassLoaderTest.kt rename src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/{FilmLibraryRepositoryIntegrationTest.kt => FilmLibraryEntryRepositoryIntegrationTest.kt} (61%) create mode 100644 src/test/kotlin/com/project/movienight/config/TestSecurityConfiguration.kt diff --git a/build.gradle.kts b/build.gradle.kts index c0f9dfd..37564ab 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 72e81e2..8dbb373 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt b/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt index 71670be..2aea71c 100644 --- a/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt +++ b/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt @@ -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, - val cast: List, - val directors: List, - 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 = + override fun fetchUsers(): List = request("Users") .asItems() .mapNotNull { node -> @@ -51,7 +33,7 @@ class JellyfinApiClient( JellyfinRemoteUser(id = id, name = node.fieldText("Name") ?: id) } - fun fetchLibraryItems(userId: String): List = + override fun fetchLibraryItems(userId: String): List = @Suppress("MaxLineLength") request( "Users/$userId/Items?Recursive=true&IncludeItemTypes=Movie,Series,Episode&Fields=Genres,People,ProviderIds,Overview,ProductionYear,CommunityRating,OfficialRating,ParentId,UserData", diff --git a/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt b/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt index b3912f1..973071c 100644 --- a/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt +++ b/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt @@ -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() } } diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt index 58e2c3c..2a9c0c6 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt @@ -20,7 +20,6 @@ fun UserEntity.toDomain(): User = id = id, name = name, email = email, - library = null, preferences = null, jellyfinUserId = jellyfinUserId, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryEntryRepository.kt similarity index 60% rename from src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt rename to src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryEntryRepository.kt index 9fa474d..beaf6cb 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryEntryRepository.kt @@ -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, - ) - return entries.firstOrNull() - } + ).firstOrNull() + + override fun findByUserId(userId: UUID): List = + jdbc.query( + "SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites WHERE user_id = ?", + rowMapper, + userId, + ) 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 = + override fun findAll(): List = jdbc.query( "SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites", - filmLibraryRowMapper, + rowMapper, ) override fun deleteById(id: UUID) { diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt index b1d4ab2..cdfc6b9 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt @@ -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 = jdbc.query( """ diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt index 153eba7..a3ec81f 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt @@ -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 } } diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt index ae34f89..02949ed 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt @@ -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 = 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() } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt index b64ea71..7b90df8 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt @@ -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( - provider = provider, - providerId = userInfo.getProviderId(), - ) - userRepository.save(entity.toDomain()) + userRepository.linkOAuthAccount( + userId = userByEmail.id, + provider = provider, + providerId = userInfo.getProviderId(), + ) } 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( - provider = provider, - providerId = userInfo.getProviderId(), - ) - userRepository.save(entity.toDomain()) + userRepository.createOAuthUser( + user = newUser, + provider = provider, + providerId = userInfo.getProviderId(), + ) } } } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt index 0bcb1b3..92fb02c 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt @@ -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/**") diff --git a/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt b/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt index 40b3362..29131e3 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt @@ -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 { + 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 { diff --git a/src/main/kotlin/com/project/movienight/adapters/web/ContentTypeParser.kt b/src/main/kotlin/com/project/movienight/adapters/web/ContentTypeParser.kt new file mode 100644 index 0000000..cf8ed0b --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/ContentTypeParser.kt @@ -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) } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt index 9bec40b..1a35dea 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt @@ -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 = getAllFilmsUseCase.getAll().map { FilmResponse.fromDomain(it) } + fun getAll(): List = filmUseCase.getAll().map { FilmResponse.fromDomain(it) } @GetMapping("/search") fun searchByTitle( @RequestParam title: String, ): ResponseEntity { - val film = searchFilmByTitleUseCase.searchByTitle(title) + val film = filmUseCase.searchByTitle(title) return if (film != null) { ResponseEntity.ok(FilmResponse.fromDomain(film)) } else { diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt index 81115cd..e955445 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -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 = listFilmLibraryEntriesUseCase.list(userId).map { FilmLibraryResponse.fromDomain(it) } + ): List = + filmLibraryUseCase + .list(userId) + .map { entry -> FilmLibraryEntryResponse.fromDomain(entry) } + + @GetMapping("/entries") + fun listEntries( + @PathVariable userId: UUID, + ): List = 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 { - 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 = filmLibraryUseCase.listAvailableFilms(userId).map { FilmResponse.fromDomain(it) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt index fec4889..b86b5f1 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt @@ -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 = getFilmRatingsUseCase.getRatings(userId).map { FilmRatingResponse.fromDomain(it) } + ): List = filmRatingUseCase.getRatings(userId).map { FilmRatingResponse.fromDomain(it) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt index 81ee52c..0d7a294 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt @@ -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,14 +45,16 @@ class JellyfinEventsController( request.jellyfinUserId, request.itemId, ) - jellyfinEventService.handleEvent( - eventId = request.eventId, - serverId = null, - eventType = request.eventType, - occurredAt = request.occurredAt, - jellyfinUserId = request.jellyfinUserId, - itemId = request.itemId, - payload = request.payload, + jellyfinEventUseCase.handle( + HandleJellyfinEventCommand( + eventId = request.eventId, + serverId = null, + eventType = request.eventType, + occurredAt = request.occurredAt, + jellyfinUserId = request.jellyfinUserId, + itemId = request.itemId, + payload = request.payload, + ), ) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt index 74aac90..aa107b7 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt @@ -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 = jellyfinSyncService.getSyncStates() + fun syncState(): List = jellyfinSyncUseCase.getSyncStates() } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt index 6d5bdd9..cd37beb 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt @@ -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, diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt index e06954c..ee74235 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt @@ -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 = getAllUsersUseCase.getAll().map { UserResponse.fromDomain(it) } + fun getAll(): List = 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) } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt index 276565b..074fb15 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt @@ -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) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmLibraryRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmLibraryRequest.kt deleted file mode 100644 index dc6cb11..0000000 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmLibraryRequest.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.project.movienight.adapters.web.dto.request - -data class CreateFilmLibraryRequest( - val name: String = "My films", -) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt index 82f7348..a07f049 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt @@ -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 = emptyList(), val cast: List = emptyList(), val directors: List = 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, diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateUserRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateUserRequest.kt index 73dcb0f..94308b2 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateUserRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateUserRequest.kt @@ -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, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt index 60eddce..20d88e5 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt @@ -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 = emptyList(), val cast: List = emptyList(), val directors: List = 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, diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt index 358e0e4..b459396 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt @@ -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, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt index 68abfe8..973f7b8 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt @@ -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, diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt index 1f44e39..c2a8077 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt @@ -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, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryEntryResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryEntryResponse.kt new file mode 100644 index 0000000..2a6d670 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryEntryResponse.kt @@ -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, + ) + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt deleted file mode 100644 index 90a339d..0000000 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt +++ /dev/null @@ -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, - ) - } -} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt index 3100547..39d9688 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt @@ -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 + + fun listAvailableFilms(userId: UUID): List } 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 -} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt index 37c8226..5a411a1 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt @@ -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 } data class RateFilmCommand( @@ -13,7 +15,3 @@ data class RateFilmCommand( val score: Int, val note: String? = null, ) - -interface GetFilmRatingsUseCase { - fun getRatings(userId: UUID): List -} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt index 27098c7..c88bc2d 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt @@ -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 + + 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 -} - -interface SearchFilmByTitleUseCase { - fun searchByTitle(title: String): Film? -} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt new file mode 100644 index 0000000..b28e68d --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt @@ -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?, +) + +interface JellyfinSyncUseCase { + fun syncNow(): JellyfinSyncSummary + + fun getSyncStates(): List +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt index b44820c..fcf539c 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt @@ -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 = emptyList(), val contentTypes: List = emptyList(), ) - -interface GetUserPreferencesUseCase { - fun get(userId: UUID): UserPreferences? -} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt index b066a4f..2cef36c 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt @@ -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 } 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 -} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/BusinessMetricsPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/BusinessMetricsPort.kt new file mode 100644 index 0000000..516fafd --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/BusinessMetricsPort.kt @@ -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() +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryEntryRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryEntryRepositoryPort.kt new file mode 100644 index 0000000..825bb06 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryEntryRepositoryPort.kt @@ -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 + + fun findByUserIdAndFilmId( + userId: UUID, + filmId: UUID, + ): FilmLibraryEntry? + + fun findAll(): List + + fun deleteById(id: UUID) +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt deleted file mode 100644 index 933f45c..0000000 --- a/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt +++ /dev/null @@ -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 - - fun deleteById(id: UUID) -} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt index d18b2e6..1883fe9 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt @@ -10,8 +10,6 @@ interface FilmRepositoryPort { fun findByJellyfinItemId(jellyfinItemId: String): Film? - fun findByJellyfinLibraryId(jellyfinLibraryId: String): Film? - fun findAll(): List fun findByTitle(title: String): Film? diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinCatalogPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinCatalogPort.kt new file mode 100644 index 0000000..0e48699 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinCatalogPort.kt @@ -0,0 +1,30 @@ +package com.project.movienight.application.ports.output + +import com.project.movienight.domain.model.ContentType + +interface JellyfinCatalogPort { + fun fetchUsers(): List + + fun fetchLibraryItems(userId: String): List +} + +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, + val cast: List, + val directors: List, + val platformRating: Double?, + val imdbRating: Double?, + val externalUrl: String?, + val jellyfinLibraryId: String?, + val isPlayed: Boolean, +) diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinEventStorePort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinEventStorePort.kt new file mode 100644 index 0000000..b7b7751 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinEventStorePort.kt @@ -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?, +) diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt index dd69728..980ec30 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt @@ -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 fun deleteById(id: UUID) diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt index 2924922..c78336e 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt @@ -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 = filmLibraryEntryRepository.findByUserId(userId) - override fun list(userId: UUID): List = filmLibraryRepository.findAll().filter { it.userId == userId } + override fun listAvailableFilms(userId: UUID): List { + 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()) + } } diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt index 738bada..0653171 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt @@ -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") diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index d69a6c8..30715f2 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -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,89 +20,81 @@ 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) + log.debug( + "Create film request received: title='{}', descriptionLength={}", + command.title, + command.description.length, + ) - try { - log.debug( - "Create film request received: title='{}', descriptionLength={}", - command.title, - command.description.length, + if (filmConfig.isBlocked(command.title)) { + log.debug("Create film blocked by title policy: title='{}'", command.title) + businessMetricsService.recordFilmBlocked() + throw BlockedValueException(target = "Film", field = "title") + } + if (filmConfig.isBlocked(command.description)) { + log.debug("Create film blocked by description policy") + businessMetricsService.recordFilmBlocked() + throw BlockedValueException(target = "Film", field = "description") + } + + val film = + Film( + id = idGenerator.generateId(), + title = command.title, + description = command.description, + contentType = command.contentType, + releaseYear = command.releaseYear, + genres = command.genres, + cast = command.cast, + directors = command.directors, + imdbRating = command.imdbRating, + platformRating = command.platformRating, + externalUrl = command.externalUrl, + jellyfinItemId = command.jellyfinItemId, + jellyfinLibraryId = command.jellyfinLibraryId, ) - if (filmConfig.isBlocked(command.title)) { - log.debug("Create film blocked by title policy: title='{}'", command.title) - filmBlockedCounter.increment() - throw BlockedValueException(target = "Film", field = "title") - } - if (filmConfig.isBlocked(command.description)) { - log.debug("Create film blocked by description policy") - filmBlockedCounter.increment() - throw BlockedValueException(target = "Film", field = "description") - } - - val film = - Film( - id = idGenerator.generateId(), - title = command.title, - description = command.description, - contentType = command.contentType, - releaseYear = command.releaseYear, - genres = command.genres, - cast = command.cast, - directors = command.directors, - imdbRating = command.imdbRating, - platformRating = command.platformRating, - externalUrl = command.externalUrl, - jellyfinItemId = command.jellyfinItemId, - jellyfinLibraryId = command.jellyfinLibraryId, - ) - - val saved = filmRepository.save(film) - filmCreatedCounter.increment() - return saved - } finally { - sample.stop(createFilmTimer) - } + val saved = filmRepository.save(film) + businessMetricsService.recordFilmCreated() + return saved } + @Timed( + value = "business_films_edit_duration_seconds", + description = "Film edit duration", + ) override fun edit( id: UUID, command: EditFilmCommand, ): Film { - val sample = Timer.start(meterRegistry) + log.debug("Edit film with id: {}", id) - try { - log.debug("Edit film with id: {}", id) + if (filmConfig.isBlocked(command.title)) { + log.debug("Edit film blocked by title policy: title='{}'", command.title) + businessMetricsService.recordFilmBlocked() + throw BlockedValueException(target = "Film", field = "title") + } + if (filmConfig.isBlocked(command.description)) { + log.debug("Edit film blocked by description policy") + businessMetricsService.recordFilmBlocked() + throw BlockedValueException(target = "Film", field = "description") + } - if (filmConfig.isBlocked(command.title)) { - log.debug("Edit film blocked by title policy: title='{}'", command.title) - filmBlockedCounter.increment() - throw BlockedValueException(target = "Film", field = "title") - } - if (filmConfig.isBlocked(command.description)) { - log.debug("Edit film blocked by description policy") - filmBlockedCounter.increment() - throw BlockedValueException(target = "Film", field = "description") - } + val film = + filmRepository.findById(id) + ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) - var film = filmRepository.findById(id) - - 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,37 +108,30 @@ class FilmService( externalUrl = command.externalUrl, jellyfinItemId = command.jellyfinItemId, jellyfinLibraryId = command.jellyfinLibraryId, - ) - - val saved = filmRepository.save(film) - filmEditedCounter.increment() - return saved - } finally { - sample.stop(editFilmTimer) - } + ), + ) + businessMetricsService.recordFilmEdited() + return saved } + @Timed( + value = "business_films_delete_duration_seconds", + description = "Film deletion duration", + ) override fun delete(id: UUID) { - val sample = Timer.start(meterRegistry) + log.debug("Delete film with id: {}", id) - try { - log.debug("Delete film with id: {}", id) + val film = filmRepository.findById(id) - val film = filmRepository.findById(id) - - if (film == null) { - log.debug("Film not found for delete: id='{}'", id) - throw EntityNotFoundException(entity = "Film", id = id.toString()) - } - - filmRepository.deleteById(id) - - filmDeletedCounter.increment() - - log.info("Film deleted: id='{}'", id) - } finally { - sample.stop(deleteFilmTimer) + if (film == null) { + log.debug("Film not found for delete: id='{}'", id) + throw EntityNotFoundException(entity = "Film", id = id.toString()) } + + filmRepository.deleteById(id) + businessMetricsService.recordFilmDeleted() + + log.info("Film deleted: id='{}'", id) } override fun getById(id: UUID): Film = @@ -161,46 +140,4 @@ class FilmService( override fun getAll(): List = 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) } diff --git a/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt index 64be033..b35c94c 100644 --- a/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt @@ -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?, - ) { - 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, - payload = payloadJson, + 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 } diff --git a/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt b/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt index 46faa88..cc852d4 100644 --- a/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt @@ -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 = 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 = 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( - existingEntry?.copy( - isViewed = true, - watchedAt = watchedAt, - ) ?: FilmLibrary( - id = idGenerator.generateId(), - userId = userId, - filmId = savedFilm.id, - comment = null, - isViewed = true, - watchedAt = watchedAt, - ), - ) - } + private fun markFilmViewed( + userId: UUID, + filmId: UUID, + watchedAt: LocalDateTime, + ) { + val existingEntry = filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) + filmLibraryEntryRepository.save( + existingEntry?.copy( + isViewed = true, + watchedAt = watchedAt, + ) ?: FilmLibraryEntry( + id = idGenerator.generateId(), + userId = userId, + filmId = filmId, + comment = null, + isViewed = true, + watchedAt = watchedAt, + ), + ) } } diff --git a/src/main/kotlin/com/project/movienight/application/services/RecommendationOnboardingService.kt b/src/main/kotlin/com/project/movienight/application/services/RecommendationOnboardingService.kt index 8f9fe04..ee02a92 100644 --- a/src/main/kotlin/com/project/movienight/application/services/RecommendationOnboardingService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/RecommendationOnboardingService.kt @@ -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, diff --git a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt index e8a7722..da8dd01 100644 --- a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt @@ -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, - libraryEntries: List, + libraryEntries: List, filmsById: Map, weights: UserRecommendationWeights, ): SparseVector { diff --git a/src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt b/src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt index de388ce..02fb178 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt @@ -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( diff --git a/src/main/kotlin/com/project/movienight/application/services/UserService.kt b/src/main/kotlin/com/project/movienight/application/services/UserService.kt index 684da5f..dc0e1e0 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt @@ -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) diff --git a/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt b/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt index a4dc555..efb1f22 100644 --- a/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt +++ b/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt @@ -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, diff --git a/src/main/kotlin/com/project/movienight/config/MetricsConfiguration.kt b/src/main/kotlin/com/project/movienight/config/MetricsConfiguration.kt new file mode 100644 index 0000000..3971bc3 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/config/MetricsConfiguration.kt @@ -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) +} diff --git a/src/main/kotlin/com/project/movienight/domain/model/FilmLibrary.kt b/src/main/kotlin/com/project/movienight/domain/model/FilmLibraryEntry.kt similarity index 89% rename from src/main/kotlin/com/project/movienight/domain/model/FilmLibrary.kt rename to src/main/kotlin/com/project/movienight/domain/model/FilmLibraryEntry.kt index 8d7861c..cc81c1a 100644 --- a/src/main/kotlin/com/project/movienight/domain/model/FilmLibrary.kt +++ b/src/main/kotlin/com/project/movienight/domain/model/FilmLibraryEntry.kt @@ -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, diff --git a/src/main/kotlin/com/project/movienight/domain/model/User.kt b/src/main/kotlin/com/project/movienight/domain/model/User.kt index 236a698..10bc566 100644 --- a/src/main/kotlin/com/project/movienight/domain/model/User.kt +++ b/src/main/kotlin/com/project/movienight/domain/model/User.kt @@ -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, ) diff --git a/src/main/resources/db/ER.md b/src/main/resources/db/ER.md index 4fecb18..5c0a829 100644 --- a/src/main/resources/db/ER.md +++ b/src/main/resources/db/ER.md @@ -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 ``` diff --git a/src/main/resources/db/migration/V9__cleanup_legacy_schema.sql b/src/main/resources/db/migration/V9__cleanup_legacy_schema.sql new file mode 100644 index 0000000..9415580 --- /dev/null +++ b/src/main/resources/db/migration/V9__cleanup_legacy_schema.sql @@ -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); diff --git a/src/test/kotlin/com/project/movienight/ClassLoaderTest.kt b/src/test/kotlin/com/project/movienight/ClassLoaderTest.kt new file mode 100644 index 0000000..a963e59 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/ClassLoaderTest.kt @@ -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}") + } +} diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt index 4e65569..859d9a4 100644 --- a/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt @@ -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() diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepositoryIntegrationTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryEntryRepositoryIntegrationTest.kt similarity index 61% rename from src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepositoryIntegrationTest.kt rename to src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryEntryRepositoryIntegrationTest.kt index d880f02..fa8c616 100644 --- a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepositoryIntegrationTest.kt +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryEntryRepositoryIntegrationTest.kt @@ -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) } } diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt index 02b588a..7d0c2bd 100644 --- a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt @@ -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) + } } diff --git a/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt b/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt index 033cccd..04a794d 100644 --- a/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt +++ b/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt @@ -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(), - editFilmUseCase = mockk(), - deleteFilmUseCase = mockk(), - getFilmByIdUseCase = mockk(), - getAllFilmsUseCase = mockk(), - 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) } } } diff --git a/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt index 7145507..beb7ec6 100644 --- a/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt +++ b/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt @@ -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 { - 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 { 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 { - 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 { - 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 { + 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 { - filmLibraryService.getLibrary(query) - } + assertEquals(entries, filmLibraryService.list(userId)) - verify(exactly = 1) { filmLibraryRepository.findAll() } + verify(exactly = 1) { filmLibraryEntryRepository.findByUserId(userId) } } } diff --git a/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt index e0f96be..5c48c2c 100644 --- a/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt +++ b/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt @@ -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 diff --git a/src/test/kotlin/com/project/movienight/application/services/UserServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/UserServiceTest.kt index c54a909..ce94f2e 100644 --- a/src/test/kotlin/com/project/movienight/application/services/UserServiceTest.kt +++ b/src/test/kotlin/com/project/movienight/application/services/UserServiceTest.kt @@ -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) } diff --git a/src/test/kotlin/com/project/movienight/config/TestSecurityConfiguration.kt b/src/test/kotlin/com/project/movienight/config/TestSecurityConfiguration.kt new file mode 100644 index 0000000..34ae9c7 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/config/TestSecurityConfiguration.kt @@ -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() + } +}