diff --git a/scripts/generate_library.py b/scripts/generate_library.py new file mode 100644 index 0000000..1f84920 --- /dev/null +++ b/scripts/generate_library.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 + +import os +import sys +import urllib.request +import argparse +import logging +import re +import random + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler(sys.stdout) + ] +) +logger = logging.getLogger(__name__) + +DEFAULT_DATASET_URL = "https://raw.githubusercontent.com/sidooms/MovieTweetings/master/latest/movies.dat" +DEFAULT_OUTPUT_DIR = "./Jellyfin_Movies" +DEFAULT_COUNT = 1000 + +def download_dataset(url): + logger.info(f"Downloading dataset from {url}...") + try: + req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) + with urllib.request.urlopen(req) as response: + data = response.read().decode('utf-8') + logger.info("Dataset downloaded successfully.") + return data.splitlines() + except Exception as e: + logger.error(f"Failed to download dataset: {e}") + sys.exit(1) + +def parse_movies(lines): + """ + Parse the movies.dat file. + Format: IMDbID::Title (Year)::Genres + Example: 0000008::Edison Kinetoscopic Record of a Sneeze (1894)::Documentary|Short + """ + movies = [] + # Regex to extract Title and Year from "Title (Year)" + title_year_pattern = re.compile(r'(.*)\s+\((\d{4})\)$') + + for line in lines: + line = line.strip() + if not line: + continue + + parts = line.split('::') + if len(parts) >= 2: + imdb_id_raw = parts[0] + title_year_raw = parts[1] + + # Format IMDb ID to ttXXXXXXX + if imdb_id_raw.isdigit(): + imdb_id = f"tt{imdb_id_raw.zfill(7)}" + else: + continue + + match = title_year_pattern.match(title_year_raw) + if match: + title = match.group(1).strip() + year = match.group(2) + + # Clean title for filesystem (remove invalid characters) + safe_title = re.sub(r'[\\/*?:"<>|]', "", title) + safe_title = safe_title.strip() + + if safe_title: + movies.append({ + 'imdb_id': imdb_id, + 'title': safe_title, + 'year': year + }) + + logger.info(f"Parsed {len(movies)} valid movies from dataset.") + return movies + +def create_dummy_video(filepath): + """Create a minimal valid dummy video file (mp4).""" + try: + mp4_header = b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom\x00\x00\x00\x00moov\x00\x00\x00\x08mvhd" + with open(filepath, 'wb') as f: + f.write(mp4_header) + return True + except Exception as e: + logger.error(f"Failed to create dummy video {filepath}: {e}") + return False + +def generate_library(movies, output_dir, count): + """Generate the folder structure and dummy files.""" + if not os.path.exists(output_dir): + os.makedirs(output_dir) + logger.info(f"Created output directory: {output_dir}") + + generated_imdb_ids = set() + created_count = 0 + skipped_count = 0 + failed_count = 0 + + logger.info(f"Starting generation of up to {count} movies...") + + # Shuffle to get diverse movies + random.shuffle(movies) + + for movie in movies: + if created_count >= count: + break + + if movie['imdb_id'] in generated_imdb_ids: + skipped_count += 1 + continue + + # Jellyfin naming convention: Movie Name (year) [imdbid-tt1234567] + folder_name = f"{movie['title']} ({movie['year']}) [imdbid-{movie['imdb_id']}]" + folder_path = os.path.join(output_dir, folder_name) + + file_name = f"{folder_name}.mp4" + file_path = os.path.join(folder_path, file_name) + + if os.path.exists(file_path): + skipped_count += 1 + generated_imdb_ids.add(movie['imdb_id']) + continue + + try: + os.makedirs(folder_path, exist_ok=True) + if create_dummy_video(file_path): + created_count += 1 + generated_imdb_ids.add(movie['imdb_id']) + else: + failed_count += 1 + except Exception as e: + logger.error(f"Error processing {folder_name}: {e}") + failed_count += 1 + + logger.info("--- Generation Summary ---") + logger.info(f"Target count: {count}") + logger.info(f"Successfully created: {created_count}") + logger.info(f"Skipped (already exists or duplicate): {skipped_count}") + logger.info(f"Failed: {failed_count}") + + return created_count > 0 + +def main(): + parser = argparse.ArgumentParser(description="Generate a dummy Jellyfin movie library.") + parser.add_argument("--output-dir", type=str, default=DEFAULT_OUTPUT_DIR, + help=f"Directory to create the library in (default: {DEFAULT_OUTPUT_DIR})") + parser.add_argument("--count", type=int, default=DEFAULT_COUNT, + help=f"Number of movies to generate (default: {DEFAULT_COUNT})") + parser.add_argument("--dataset-url", type=str, default=DEFAULT_DATASET_URL, + help="URL to the movies.dat file") + + args = parser.parse_args() + + lines = download_dataset(args.dataset_url) + if not lines: + logger.error("No dataset lines to process.") + sys.exit(1) + + movies = parse_movies(lines) + + if not movies: + logger.error("No movies parsed from the dataset.") + sys.exit(1) + + if len(movies) < args.count: + logger.warning(f"Requested {args.count} movies, but only {len(movies)} available.") + args.count = len(movies) + + success = generate_library(movies, args.output_dir, args.count) + + if success: + logger.info(f"Library generation complete. You can now mount '{os.path.abspath(args.output_dir)}' into Jellyfin.") + else: + logger.error("Library generation failed.") + sys.exit(1) + +if __name__ == "__main__": + main() 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..1a107da 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.JellyfinLibraryItemSnapshot +import com.project.movienight.application.ports.output.JellyfinRemoteUser +import com.project.movienight.application.ports.output.JellyfinSyncSourcePort 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, -) { +) : JellyfinSyncSourcePort { 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..d06d815 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 @@ -33,7 +34,7 @@ class BusinessMetricsService( private val backendWriteFailures: Counter = meterRegistry.counter("business_jellyfin_backend_write_failures_total") - fun recordRecommendationRequest() { + override fun recordRecommendationRequest() { recommendationRequests.increment() } @@ -49,11 +50,11 @@ class BusinessMetricsService( 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 +62,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/jdbc/JellyfinEventRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt index 153eba7..80334de 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,6 @@ package com.project.movienight.adapters.persistence.jdbc +import com.project.movienight.application.ports.output.JellyfinEventRepositoryPort import org.springframework.jdbc.core.namedparam.MapSqlParameterSource import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate import org.springframework.stereotype.Repository @@ -7,8 +8,8 @@ import org.springframework.stereotype.Repository @Repository class JellyfinEventRepository( private val jdbc: NamedParameterJdbcTemplate, -) { - fun save( +) : JellyfinEventRepositoryPort { + override fun save( eventId: String, serverId: String?, eventType: String, @@ -37,7 +38,7 @@ class JellyfinEventRepository( return jdbc.update(sql, params) } - fun delete(eventId: String) { + override fun delete(eventId: String) { val sql = "DELETE FROM jellyfin_events WHERE event_id = :eventId" val params = MapSqlParameterSource().addValue("eventId", eventId) jdbc.update(sql, params) 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..34766f7 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/BusinessMetricsPort.kt @@ -0,0 +1,19 @@ +package com.project.movienight.application.ports.output + +import com.project.movienight.domain.model.JellyfinSyncSummary + +interface BusinessMetricsPort { + fun recordRecommendationRequest() + + fun recordRatingSubmitted() + + fun recordLibraryEvent() + + fun recordJellyfinSync(summary: JellyfinSyncSummary) + + fun recordJellyfinSyncFailure() + + fun recordJellyfinUnmappedUser() + + fun recordBackendWriteFailure() +} \ No newline at end of file diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinEventRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinEventRepositoryPort.kt new file mode 100644 index 0000000..eb084f3 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinEventRepositoryPort.kt @@ -0,0 +1,17 @@ +package com.project.movienight.application.ports.output + +import java.time.OffsetDateTime + +interface JellyfinEventRepositoryPort { + fun save( + eventId: String, + serverId: String?, + eventType: String, + occurredAt: OffsetDateTime?, + jellyfinUserId: String?, + jellyfinItemId: String?, + payload: String?, + ): Int + + fun delete(eventId: String) +} \ No newline at end of file diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinSyncSourcePort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinSyncSourcePort.kt new file mode 100644 index 0000000..4113a7d --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinSyncSourcePort.kt @@ -0,0 +1,30 @@ +package com.project.movienight.application.ports.output + +import com.project.movienight.domain.model.ContentType + +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, +) + +interface JellyfinSyncSourcePort { + fun fetchUsers(): List + + fun fetchLibraryItems(userId: String): List +} \ No newline at end of file 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 381f3bf..af4ed13 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt @@ -1,6 +1,5 @@ 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 @@ -12,6 +11,7 @@ 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.BusinessMetricsPort import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.domain.exception.DomainException @@ -25,7 +25,7 @@ import java.util.UUID class FilmLibraryService( private val filmLibraryRepository: FilmLibraryRepositoryPort, private val idGenerator: IdGenerator, - private val businessMetricsService: BusinessMetricsService, + private val businessMetricsService: BusinessMetricsPort, ) : CreateFilmLibraryUseCase, AddFilmToLibraryUseCase, MarkFilmViewedUseCase, 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..54f7d72 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,9 @@ 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.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,7 +19,7 @@ class FilmRatingService( private val filmRepository: FilmRepositoryPort, private val filmRatingRepository: FilmRatingRepositoryPort, private val idGenerator: IdGenerator, - private val businessMetricsService: BusinessMetricsService, + private val businessMetricsService: BusinessMetricsPort, ) : RateFilmUseCase, GetFilmRatingsUseCase { override fun rate(command: RateFilmCommand): FilmRating { 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..d4fe6a0 100644 --- a/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt @@ -1,10 +1,10 @@ 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.MarkFilmViewedCommand import com.project.movienight.application.ports.input.MarkFilmViewedUseCase +import com.project.movienight.application.ports.output.BusinessMetricsPort +import com.project.movienight.application.ports.output.JellyfinEventRepositoryPort import com.project.movienight.application.ports.output.FilmRepositoryPort import com.project.movienight.application.ports.output.UserRepositoryPort import org.springframework.stereotype.Service @@ -12,12 +12,12 @@ import java.time.OffsetDateTime @Service class JellyfinEventService( - private val jellyfinEventRepository: JellyfinEventRepository, + private val jellyfinEventRepository: JellyfinEventRepositoryPort, private val userRepository: UserRepositoryPort, private val filmRepository: FilmRepositoryPort, private val markFilmViewedUseCase: MarkFilmViewedUseCase, private val objectMapper: ObjectMapper, - private val businessMetricsService: BusinessMetricsService, + private val businessMetricsService: BusinessMetricsPort, ) { private val playbackEventTypes = setOf("playback.ended", "playback.stopped", "playback.completed") 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..a6776d1 100644 --- a/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt @@ -1,13 +1,13 @@ 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.BusinessMetricsPort import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort import com.project.movienight.application.ports.output.FilmRepositoryPort import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.application.ports.output.JellyfinSyncStateRepositoryPort +import com.project.movienight.application.ports.output.JellyfinLibraryItemSnapshot +import com.project.movienight.application.ports.output.JellyfinRemoteUser +import com.project.movienight.application.ports.output.JellyfinSyncSourcePort import com.project.movienight.application.ports.output.UserRepositoryPort import com.project.movienight.config.JellyfinIntegrationProperties import com.project.movienight.domain.model.ContentType @@ -24,13 +24,13 @@ import java.time.LocalDateTime @Service class JellyfinSyncService( private val properties: JellyfinIntegrationProperties, - private val jellyfinApiClient: JellyfinApiClient, + private val jellyfinApiClient: JellyfinSyncSourcePort, private val userRepository: UserRepositoryPort, private val filmRepository: FilmRepositoryPort, private val filmLibraryRepository: FilmLibraryRepositoryPort, private val syncStateRepository: JellyfinSyncStateRepositoryPort, private val idGenerator: IdGenerator, - private val businessMetricsService: BusinessMetricsService, + private val businessMetricsService: BusinessMetricsPort, ) { @Scheduled(fixedDelayString = "\${integrations.jellyfin.sync-interval-ms:1800000}") fun scheduledSync() { diff --git a/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt b/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt deleted file mode 100644 index 142754a..0000000 --- a/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.project.movienight.domain.model - -import java.util.UUID - -data class RecommendationContext( - val userId: UUID, - val contentType: ContentType? = null, - val mood: String? = null, - val libraryOnly: Boolean = false, - val limit: Int = 10, -) - -data class RecommendationResult( - val film: Film, - val score: Double, - val reasons: List, -) diff --git a/src/main/kotlin/com/project/movienight/domain/model/RecommendationResult.kt b/src/main/kotlin/com/project/movienight/domain/model/RecommendationResult.kt new file mode 100644 index 0000000..5b5b0a4 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/RecommendationResult.kt @@ -0,0 +1,7 @@ +package com.project.movienight.domain.model + +data class RecommendationResult( + val film: Film, + val score: Double, + val reasons: List, +) \ No newline at end of file diff --git a/src/test/kotlin/com/project/movienight/adapters/web/RecommendationControllerTest.kt b/src/test/kotlin/com/project/movienight/adapters/web/RecommendationControllerTest.kt new file mode 100644 index 0000000..75e222e --- /dev/null +++ b/src/test/kotlin/com/project/movienight/adapters/web/RecommendationControllerTest.kt @@ -0,0 +1,26 @@ +package com.project.movienight.adapters.web + +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import java.util.UUID + +@SpringBootTest +@AutoConfigureMockMvc(addFilters = false) +class RecommendationControllerTest { + @Autowired + private lateinit var mockMvc: MockMvc + + @Test + fun `invalid content type should return bad request`() { + mockMvc + .get("/api/users/${UUID.randomUUID()}/recommendations") { + param("contentType", "INVALID") + }.andExpect { + status { isBadRequest() } + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/project/movienight/application/services/RecommendationServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/RecommendationServiceTest.kt new file mode 100644 index 0000000..ea8e84a --- /dev/null +++ b/src/test/kotlin/com/project/movienight/application/services/RecommendationServiceTest.kt @@ -0,0 +1,56 @@ +package com.project.movienight.application.services + +import com.project.movienight.application.ports.input.RecommendationQuery +import com.project.movienight.application.ports.output.BusinessMetricsPort +import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort +import com.project.movienight.application.ports.output.FilmRatingRepositoryPort +import com.project.movienight.application.ports.output.FilmRepositoryPort +import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort +import com.project.movienight.domain.model.Film +import com.project.movienight.domain.model.FilmRating +import com.project.movienight.domain.model.UserPreferences +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.util.UUID + +class RecommendationServiceTest { + private val filmRepository: FilmRepositoryPort = mockk() + private val filmLibraryRepository: FilmLibraryRepositoryPort = mockk() + private val filmRatingRepository: FilmRatingRepositoryPort = mockk() + private val userPreferencesRepository: UserPreferencesRepositoryPort = mockk() + private val businessMetricsPort: BusinessMetricsPort = mockk(relaxed = true) + + private val recommendationService = + RecommendationService( + filmRepository = filmRepository, + filmLibraryRepository = filmLibraryRepository, + filmRatingRepository = filmRatingRepository, + userPreferencesRepository = userPreferencesRepository, + businessMetricsService = businessMetricsPort, + ) + + @Test + fun `should rank highly rated films above poorly rated films`() { + val userId = UUID.randomUUID() + val lowRatedFilmId = UUID.randomUUID() + val highRatedFilmId = UUID.randomUUID() + + val lowRatedFilm = Film(id = lowRatedFilmId, title = "Low", description = "Low") + val highRatedFilm = Film(id = highRatedFilmId, title = "High", description = "High") + + every { userPreferencesRepository.findByUserId(userId) } returns UserPreferences(userId = userId) + every { filmRatingRepository.findByUserId(userId) } returns + listOf( + FilmRating(id = UUID.randomUUID(), userId = userId, filmId = lowRatedFilmId, score = 1), + FilmRating(id = UUID.randomUUID(), userId = userId, filmId = highRatedFilmId, score = 10), + ) + every { filmLibraryRepository.findAll() } returns emptyList() + every { filmRepository.findAll() } returns listOf(lowRatedFilm, highRatedFilm) + + val result = recommendationService.recommend(RecommendationQuery(userId = userId)) + + assertEquals(highRatedFilmId, result.first().film.id) + } +} \ No newline at end of file