diff --git a/MovieApp/Data/Network/APIClient.swift b/MovieApp/Data/Network/APIClient.swift new file mode 100644 index 0000000..5666336 --- /dev/null +++ b/MovieApp/Data/Network/APIClient.swift @@ -0,0 +1,111 @@ +import Foundation + +// MARK: - APIClientProtocol Definition +protocol APIClientProtocol { + func request(urlRequest: URLRequest, completion: @escaping (Result) -> Void) + func buildRequest(path: String, method: String, params: [String: String]?) -> URLRequest? +} + +class APIClient: APIClientProtocol { // Made APIClient conform to the protocol + + // MARK: - Properties + + // !!!IMPORTANT!!!: This is not a secure way to store an API key. + // For a production app, this should be stored securely, + // e.g., in a configuration file not committed to version control, + // or retrieved from a server-side service. + static let apiKey = "124e713abb7dfb463142baac075d3e11" + + static let shared = APIClient() // Keep shared instance for default usage + private let session: URLSession + + // MARK: - Initialization + // Made init public to allow instantiation where needed, e.g. for the shared instance + // or if someone wants to create a specific session-configured client. + // For DI, we usually pass the protocol, so this direct instantiation is less common outside initial setup. + // Making it internal as 'shared' provides the default, and DI uses the protocol. + internal init(session: URLSession = .shared) { + self.session = session + } + + // MARK: - Request Method + + /// Performs a network request and decodes the response. + /// - Parameters: + /// - urlRequest: The URLRequest to be executed. + /// - completion: A closure to be called once the request is complete. + /// It returns a `Result` type with either the decoded data (`T`) + /// or an `APIError`. + func request(urlRequest: URLRequest, completion: @escaping (Result) -> Void) { + + session.dataTask(with: urlRequest) { data, response, error in + // Handle client-side errors (e.g., network connectivity issues) + if let error = error { + completion(.failure(.networkError(error))) + return + } + + // Ensure we have an HTTP response + guard let httpResponse = response as? HTTPURLResponse else { + completion(.failure(.invalidResponse(statusCode: 0))) // Or a more specific error + return + } + + // Check for successful HTTP status codes + guard (200...299).contains(httpResponse.statusCode) else { + completion(.failure(.invalidResponse(statusCode: httpResponse.statusCode))) + return + } + + // Ensure data is present + guard let data = data else { + // This case should ideally be covered by the HTTP status code check, + // but as a safeguard: + completion(.failure(.invalidResponse(statusCode: httpResponse.statusCode))) // Or a new error case e.g., .noData + return + } + + // Attempt to decode the JSON response + do { + let decoder = JSONDecoder() + // Convention for converting snake_case keys from API to camelCase + decoder.keyDecodingStrategy = .convertFromSnakeCase + let decodedObject = try decoder.decode(T.self, from: data) + completion(.success(decodedObject)) + } catch let decodingError { + completion(.failure(.decodingError(decodingError))) + } + }.resume() + } + + /// Helper to create a URLRequest for The Movie Database (TMDB) API. + /// - Parameters: + /// - path: The endpoint path (e.g., "/movie/popular"). + /// - method: The HTTP method (default is "GET"). + /// - params: Optional query parameters for the request. + /// - Returns: A URLRequest? or nil if URL construction fails. + func buildRequest(path: String, method: String = "GET", params: [String: String]? = nil) -> URLRequest? { + var components = URLComponents() + components.scheme = "https" + components.host = "api.themoviedb.org" + components.path = "/3" + path // API version 3 + + var queryItems = [URLQueryItem(name: "api_key", value: APIClient.apiKey)] + if let params = params { + queryItems.append(contentsOf: params.map { URLQueryItem(name: $0.key, value: $0.value) }) + } + components.queryItems = queryItems + + guard let url = components.url else { + return nil + } + + var request = URLRequest(url: url) + request.httpMethod = method + // Add any common headers here if needed, e.g.: + // request.setValue("application/json", forHTTPHeaderField: "Accept") + // request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + return request + } +} diff --git a/MovieApp/Data/Network/APIError.swift b/MovieApp/Data/Network/APIError.swift new file mode 100644 index 0000000..d1f2fef --- /dev/null +++ b/MovieApp/Data/Network/APIError.swift @@ -0,0 +1,23 @@ +import Foundation + +enum APIError: Error { + case networkError(Error) + case invalidResponse(statusCode: Int) + case decodingError(Error) + case invalidURL +} + +extension APIError: LocalizedError { + var errorDescription: String? { + switch self { + case .networkError(let error): + return "Network error: \(error.localizedDescription)" + case .invalidResponse(let statusCode): + return "Invalid response with status code: \(statusCode)" + case .decodingError(let error): + return "Decoding error: \(error.localizedDescription)" + case .invalidURL: + return "The provided URL was invalid." + } + } +} diff --git a/MovieApp/Data/Repositories/MovieRepository.swift b/MovieApp/Data/Repositories/MovieRepository.swift new file mode 100644 index 0000000..2465c3b --- /dev/null +++ b/MovieApp/Data/Repositories/MovieRepository.swift @@ -0,0 +1,162 @@ +import Foundation + +// APIClientProtocol (and APIClient) are expected to be in Data/Network +// MovieRepositoryProtocol is expected to be in Domain/Interfaces/Repositories +// PaginatedResponse, Movie, APIError are expected to be defined appropriately + +class MovieRepository: MovieRepositoryProtocol { + + private let apiClient: APIClientProtocol + private let userDefaults: UserDefaults + + // Cache Keys + private static let nowPlayingCacheKey = "nowPlayingMoviesCache" + private static let popularCacheKey = "popularMoviesCache" + private static let upcomingCacheKey = "upcomingMoviesCache" + private static func movieDetailCacheKey(id: Int) -> String { "movieDetailCache_\(id)" } + + init(apiClient: APIClientProtocol = APIClient.shared, userDefaults: UserDefaults = .standard) { + self.apiClient = apiClient + self.userDefaults = userDefaults + } + + // MARK: - Caching Helper Methods + private func cacheData(_ data: T, forKey key: String) { + do { + let encodedData = try JSONEncoder().encode(data) + userDefaults.set(encodedData, forKey: key) + } catch { + // Log error or handle it as appropriate for your app + print("Caching error: Failed to encode data for key \(key). Error: \(error)") + } + } + + private func loadData(forKey key: String) -> T? { + guard let savedData = userDefaults.data(forKey: key) else { + return nil + } + do { + let decodedData = try JSONDecoder().decode(T.self, from: savedData) + return decodedData + } catch { + print("Caching error: Failed to decode data for key \(key). Error: \(error)") + // Consider removing corrupted data: userDefaults.removeObject(forKey: key) + return nil + } + } + + // MARK: - Data Fetching Methods with Caching + + func getNowPlayingMovies(page: Int, completion: @escaping (Result, Error>) -> Void) { + let cacheKey = MovieRepository.nowPlayingCacheKey + let cachedResponse: PaginatedResponse? = loadData(forKey: cacheKey) + + let path = "/movie/now_playing" + let params = ["page": String(page)] + + guard let urlRequest = apiClient.buildRequest(path: path, params: params) else { + guard let urlRequest = apiClient.buildRequest(path: path, params: params) else { + guard let urlRequest = apiClient.buildRequest(path: path, params: params) else { + completion(.failure(APIError.invalidURL)) + return + } + + apiClient.request(urlRequest: urlRequest) { [weak self] (result: Result, APIError>) in + guard let self = self else { return } + switch result { + case .success(let networkResponse): + self.cacheData(networkResponse, forKey: cacheKey) + completion(.success(networkResponse)) + case .failure(let error): + if let cachedData = cachedResponse { + completion(.success(cachedData)) + } else { + completion(.failure(error as Error)) + } + } + } + } + + func getPopularMovies(page: Int, completion: @escaping (Result, Error>) -> Void) { + let cacheKey = MovieRepository.popularCacheKey + let cachedResponse: PaginatedResponse? = loadData(forKey: cacheKey) + + let path = "/movie/popular" + let params = ["page": String(page)] + + guard let urlRequest = apiClient.buildRequest(path: path, method: "GET", params: params) else { + completion(.failure(APIError.invalidURL)) + return + } + + apiClient.request(urlRequest: urlRequest) { [weak self] (result: Result, APIError>) in + guard let self = self else { return } + switch result { + case .success(let networkResponse): + self.cacheData(networkResponse, forKey: cacheKey) + completion(.success(networkResponse)) + case .failure(let error): + if let cachedData = cachedResponse { + completion(.success(cachedData)) + } else { + completion(.failure(error as Error)) + } + } + } + } + + func getUpcomingMovies(page: Int, completion: @escaping (Result, Error>) -> Void) { + let cacheKey = MovieRepository.upcomingCacheKey + let cachedResponse: PaginatedResponse? = loadData(forKey: cacheKey) + + let path = "/movie/upcoming" + let params = ["page": String(page)] + + guard let urlRequest = apiClient.buildRequest(path: path, method: "GET", params: params) else { + completion(.failure(APIError.invalidURL)) + return + } + + apiClient.request(urlRequest: urlRequest) { [weak self] (result: Result, APIError>) in + guard let self = self else { return } + switch result { + case .success(let networkResponse): + self.cacheData(networkResponse, forKey: cacheKey) + completion(.success(networkResponse)) + case .failure(let error): + if let cachedData = cachedResponse { + completion(.success(cachedData)) + } else { + completion(.failure(error as Error)) + } + } + } + } + + func getMovieDetails(id: Int, completion: @escaping (Result) -> Void) { + let cacheKey = MovieRepository.movieDetailCacheKey(id: id) + let cachedMovie: Movie? = loadData(forKey: cacheKey) + + let path = "/movie/\(id)" + + guard let urlRequest = apiClient.buildRequest(path: path, params: nil) else { + completion(.failure(APIError.invalidURL)) + return + } + + apiClient.request(urlRequest: urlRequest) { [weak self] (result: Result) in + guard let self = self else { return } + switch result { + case .success(let networkMovie): + self.cacheData(networkMovie, forKey: cacheKey) + completion(.success(networkMovie)) + case .failure(let error): + if let cachedData = cachedMovie { + completion(.success(cachedData)) + } else { + completion(.failure(error as Error)) + } + } + } + } +} diff --git a/MovieApp/Domain/Entities/Movie.swift b/MovieApp/Domain/Entities/Movie.swift new file mode 100644 index 0000000..3d050e7 --- /dev/null +++ b/MovieApp/Domain/Entities/Movie.swift @@ -0,0 +1,78 @@ +import Foundation + +// MARK: - Genre + +struct Genre: Decodable, Identifiable, Hashable { + let id: Int + let name: String +} + +// MARK: - Movie + +struct Movie: Decodable, Identifiable, Hashable { + let id: Int + let title: String + let releaseDate: String? + let posterPath: String? + let overview: String? + let genres: [Genre]? // This will be populated if fetching movie details + let runtime: Int? + let popularity: Double? + let voteAverage: Double? + let voteCount: Int? + + enum CodingKeys: String, CodingKey { + case id + case title + case releaseDate = "release_date" + case posterPath = "poster_path" + case overview + case genres + case runtime + case popularity + case voteAverage = "vote_average" + case voteCount = "vote_count" + } + + var posterURL: URL? { + guard let path = posterPath else { return nil } + return URL(string: "https://image.tmdb.org/t/p/w500\(path)") + } +} + +// MARK: - PaginatedResponse + +struct PaginatedResponse: Decodable { + let page: Int + let results: [T] + let totalPages: Int + let totalResults: Int + + enum CodingKeys: String, CodingKey { + case page + case results + case totalPages = "total_pages" + case totalResults = "total_results" + } +} + +// Example Usage (for compilation check, can be removed later) +/* +struct MovieService { + func fetchPopularMovies(completion: @escaping (Result, APIError>) -> Void) { + guard let urlRequest = APIClient.shared.buildRequest(path: "/movie/popular") else { + completion(.failure(.invalidURL)) + return + } + APIClient.shared.request(urlRequest: urlRequest, completion: completion) + } + + func fetchMovieDetails(movieId: Int, completion: @escaping (Result) -> Void) { + guard let urlRequest = APIClient.shared.buildRequest(path: "/movie/\(movieId)") else { + completion(.failure(.invalidURL)) + return + } + APIClient.shared.request(urlRequest: urlRequest, completion: completion) + } +} +*/ diff --git a/MovieApp/Domain/Interfaces/Repositories/MovieRepositoryProtocol.swift b/MovieApp/Domain/Interfaces/Repositories/MovieRepositoryProtocol.swift new file mode 100644 index 0000000..f02155f --- /dev/null +++ b/MovieApp/Domain/Interfaces/Repositories/MovieRepositoryProtocol.swift @@ -0,0 +1,30 @@ +import Foundation + +// Note: PaginatedResponse and Movie types are expected to be defined in Domain/Entities + +/// Protocol defining the contract for fetching movie data. +protocol MovieRepositoryProtocol { + /// Fetches a list of movies currently playing in theaters. + /// - Parameters: + /// - page: The page number to fetch. + /// - completion: A closure to be called with the result, either a `PaginatedResponse` or an `Error`. + func getNowPlayingMovies(page: Int, completion: @escaping (Result, Error>) -> Void) + + /// Fetches a list of popular movies. + /// - Parameters: + /// - page: The page number to fetch. + /// - completion: A closure to be called with the result, either a `PaginatedResponse` or an `Error`. + func getPopularMovies(page: Int, completion: @escaping (Result, Error>) -> Void) + + /// Fetches a list of upcoming movies. + /// - Parameters: + /// - page: The page number to fetch. + /// - completion: A closure to be called with the result, either a `PaginatedResponse` or an `Error`. + func getUpcomingMovies(page: Int, completion: @escaping (Result, Error>) -> Void) + + /// Fetches the details for a specific movie. + /// - Parameters: + /// - id: The ID of the movie to fetch details for. + /// - completion: A closure to be called with the result, either a `Movie` object or an `Error`. + func getMovieDetails(id: Int, completion: @escaping (Result) -> Void) +} diff --git a/MovieApp/Domain/UseCases/GetMovieDetailsUseCase.swift b/MovieApp/Domain/UseCases/GetMovieDetailsUseCase.swift new file mode 100644 index 0000000..eeaed96 --- /dev/null +++ b/MovieApp/Domain/UseCases/GetMovieDetailsUseCase.swift @@ -0,0 +1,16 @@ +import Foundation + +// Movie type is expected to be defined in Domain/Entities +// MovieRepositoryProtocol is expected to be defined in Domain/Interfaces/Repositories + +class GetMovieDetailsUseCase { + private let repository: MovieRepositoryProtocol + + init(repository: MovieRepositoryProtocol) { + self.repository = repository + } + + func execute(id: Int, completion: @escaping (Result) -> Void) { + repository.getMovieDetails(id: id, completion: completion) + } +} diff --git a/MovieApp/Domain/UseCases/GetNowPlayingMoviesUseCase.swift b/MovieApp/Domain/UseCases/GetNowPlayingMoviesUseCase.swift new file mode 100644 index 0000000..617b930 --- /dev/null +++ b/MovieApp/Domain/UseCases/GetNowPlayingMoviesUseCase.swift @@ -0,0 +1,16 @@ +import Foundation + +// PaginatedResponse and Movie types are expected to be defined in Domain/Entities +// MovieRepositoryProtocol is expected to be defined in Domain/Interfaces/Repositories + +class GetNowPlayingMoviesUseCase { + private let repository: MovieRepositoryProtocol + + init(repository: MovieRepositoryProtocol) { + self.repository = repository + } + + func execute(page: Int, completion: @escaping (Result, Error>) -> Void) { + repository.getNowPlayingMovies(page: page, completion: completion) + } +} diff --git a/MovieApp/Domain/UseCases/GetPopularMoviesUseCase.swift b/MovieApp/Domain/UseCases/GetPopularMoviesUseCase.swift new file mode 100644 index 0000000..e9f2857 --- /dev/null +++ b/MovieApp/Domain/UseCases/GetPopularMoviesUseCase.swift @@ -0,0 +1,16 @@ +import Foundation + +// PaginatedResponse and Movie types are expected to be defined in Domain/Entities +// MovieRepositoryProtocol is expected to be defined in Domain/Interfaces/Repositories + +class GetPopularMoviesUseCase { + private let repository: MovieRepositoryProtocol + + init(repository: MovieRepositoryProtocol) { + self.repository = repository + } + + func execute(page: Int, completion: @escaping (Result, Error>) -> Void) { + repository.getPopularMovies(page: page, completion: completion) + } +} diff --git a/MovieApp/Domain/UseCases/GetUpcomingMoviesUseCase.swift b/MovieApp/Domain/UseCases/GetUpcomingMoviesUseCase.swift new file mode 100644 index 0000000..56a9cd9 --- /dev/null +++ b/MovieApp/Domain/UseCases/GetUpcomingMoviesUseCase.swift @@ -0,0 +1,16 @@ +import Foundation + +// PaginatedResponse and Movie types are expected to be defined in Domain/Entities +// MovieRepositoryProtocol is expected to be defined in Domain/Interfaces/Repositories + +class GetUpcomingMoviesUseCase { + private let repository: MovieRepositoryProtocol + + init(repository: MovieRepositoryProtocol) { + self.repository = repository + } + + func execute(page: Int, completion: @escaping (Result, Error>) -> Void) { + repository.getUpcomingMovies(page: page, completion: completion) + } +} diff --git a/MovieApp/MovieApp/AppDelegate.swift b/MovieApp/MovieApp/AppDelegate.swift new file mode 100644 index 0000000..42a8a75 --- /dev/null +++ b/MovieApp/MovieApp/AppDelegate.swift @@ -0,0 +1,49 @@ +import UIKit +import SwiftUI // Required for UIHostingController and MainTabView + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + var window: UIWindow? + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + + // 1. Create the SwiftUI view that will be the root view + let mainTabView = MainTabView() + + // 2. Create a UIHostingController to host the SwiftUI view + let hostingController = UIHostingController(rootView: mainTabView) + + // 3. Create a new UIWindow + window = UIWindow(frame: UIScreen.main.bounds) + + // 4. Set the hosting controller as the root view controller of the window + window?.rootViewController = hostingController + + // 5. Make the window key and visible + window?.makeKeyAndVisible() + + return true + } + + // MARK: UISceneSession Lifecycle (If you were using Scenes) + // If your project supports scenes (iOS 13+), you might configure them here or in SceneDelegate. + // For this setup, we are directly setting the rootViewController on the window, + // which is simpler for projects not explicitly using SceneDelegate. + + /* + @available(iOS 13.0, *) + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + // Called when a new scene session is being created. + // Use this method to select a configuration to create the new scene with. + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + + @available(iOS 13.0, *) + func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { + // Called when the user discards a scene session. + // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. + // Use this method to release any resources that were specific to the discarded scenes, as they will not return. + } + */ +} diff --git a/MovieApp/MovieApp/Info.plist b/MovieApp/MovieApp/Info.plist new file mode 100644 index 0000000..d6ddf15 --- /dev/null +++ b/MovieApp/MovieApp/Info.plist @@ -0,0 +1,43 @@ + + + + + CFBundleDevelopmentRegion + + CFBundleExecutable + + CFBundleIdentifier + + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/MovieApp/MovieApp/ViewController.swift b/MovieApp/MovieApp/ViewController.swift new file mode 100644 index 0000000..e69de29 diff --git a/MovieApp/Presentation/ViewModels/MovieDetailViewModel.swift b/MovieApp/Presentation/ViewModels/MovieDetailViewModel.swift new file mode 100644 index 0000000..b98e62d --- /dev/null +++ b/MovieApp/Presentation/ViewModels/MovieDetailViewModel.swift @@ -0,0 +1,43 @@ +import SwiftUI // For ObservableObject and @Published +// Foundation is usually imported by SwiftUI. +// Movie entity, GetMovieDetailsUseCase, and MovieRepository will be needed. +// Assuming these are accessible via the project's module or appropriate imports. + +class MovieDetailViewModel: ObservableObject { + + // MARK: - Published Properties + @Published var movie: Movie? = nil + @Published var isLoading: Bool = false + @Published var errorMessage: String? = nil + + // MARK: - Use Case + private let getMovieDetailsUseCase: GetMovieDetailsUseCase + + // MARK: - Initializer + init( + // In a real app, this would likely be injected. + getMovieDetailsUseCase: GetMovieDetailsUseCase = GetMovieDetailsUseCase(repository: MovieRepository()) + ) { + self.getMovieDetailsUseCase = getMovieDetailsUseCase + } + + // MARK: - Fetch Method + func fetchMovieDetails(id: Int) { + self.isLoading = true + self.errorMessage = nil + self.movie = nil // Clear previous movie details if any + + getMovieDetailsUseCase.execute(id: id) { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + self.isLoading = false + switch result { + case .success(let movieDetail): + self.movie = movieDetail + case .failure(let error): + self.errorMessage = "Failed to load movie details: \(error.localizedDescription)" + } + } + } + } +} diff --git a/MovieApp/Presentation/ViewModels/MovieListViewModel.swift b/MovieApp/Presentation/ViewModels/MovieListViewModel.swift new file mode 100644 index 0000000..7d0dc59 --- /dev/null +++ b/MovieApp/Presentation/ViewModels/MovieListViewModel.swift @@ -0,0 +1,173 @@ +import SwiftUI // For ObservableObject and @Published +// Foundation is usually imported by SwiftUI, but good to be explicit if needed. +// Entities (Movie, PaginatedResponse), UseCases, and Repository will be needed. +// Assuming these are accessible via the project's module or appropriate imports. + +class MovieListViewModel: ObservableObject { + + // MARK: - Published Properties + @Published var nowPlayingMovies: [Movie] = [] + @Published var popularMovies: [Movie] = [] + @Published var upcomingMovies: [Movie] = [] + @Published var isLoading: Bool = false + @Published var errorMessage: String? = nil + + // MARK: - Use Cases + private let getNowPlayingMoviesUseCase: GetNowPlayingMoviesUseCase + private let getPopularMoviesUseCase: GetPopularMoviesUseCase + private let getUpcomingMoviesUseCase: GetUpcomingMoviesUseCase + + // MARK: - Initializer + init( + // In a real app, these would likely be injected via a Dependency Injection framework + // or passed in as protocols for better testability. + getNowPlayingMoviesUseCase: GetNowPlayingMoviesUseCase = GetNowPlayingMoviesUseCase(repository: MovieRepository()), + getPopularMoviesUseCase: GetPopularMoviesUseCase = GetPopularMoviesUseCase(repository: MovieRepository()), + getUpcomingMoviesUseCase: GetUpcomingMoviesUseCase = GetUpcomingMoviesUseCase(repository: MovieRepository()) + ) { + self.getNowPlayingMoviesUseCase = getNowPlayingMoviesUseCase + self.getPopularMoviesUseCase = getPopularMoviesUseCase + self.getUpcomingMoviesUseCase = getUpcomingMoviesUseCase + } + + // MARK: - Fetch Methods + + // Individual fetch methods now manage their own isLoading and errorMessage states + // when called directly. When called by fetchAllMovieLists, these will be + // overridden by the group's state management. + + func fetchNowPlayingMovies(page: Int = 1, completionGroup: DispatchGroup? = nil, errorContainer: ErrorContainer? = nil) { + if completionGroup == nil { // Not part of a group + self.isLoading = true + self.errorMessage = nil + } + completionGroup?.enter() + + getNowPlayingMoviesUseCase.execute(page: page) { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { completionGroup?.leave(); return } + + if completionGroup == nil { // Not part of a group, manage its own isLoading + self.isLoading = false + } + + switch result { + case .success(let response): + self.nowPlayingMovies = response.results + case .failure(let error): + if completionGroup != nil { + errorContainer?.setError(error, forSource: "Now Playing movies") + } else { + self.errorMessage = "Failed to load Now Playing movies: \(error.localizedDescription)" + } + } + completionGroup?.leave() + } + } + } + + func fetchPopularMovies(page: Int = 1, completionGroup: DispatchGroup? = nil, errorContainer: ErrorContainer? = nil) { + if completionGroup == nil { + self.isLoading = true + self.errorMessage = nil + } + completionGroup?.enter() + + getPopularMoviesUseCase.execute(page: page) { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { completionGroup?.leave(); return } + + if completionGroup == nil { + self.isLoading = false + } + + switch result { + case .success(let response): + self.popularMovies = response.results + case .failure(let error): + if completionGroup != nil { + errorContainer?.setError(error, forSource: "Popular movies") + } else { + self.errorMessage = "Failed to load Popular movies: \(error.localizedDescription)" + } + } + completionGroup?.leave() + } + } + } + + func fetchUpcomingMovies(page: Int = 1, completionGroup: DispatchGroup? = nil, errorContainer: ErrorContainer? = nil) { + if completionGroup == nil { + self.isLoading = true + self.errorMessage = nil + } + completionGroup?.enter() + + getUpcomingMoviesUseCase.execute(page: page) { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { completionGroup?.leave(); return } + + if completionGroup == nil { + self.isLoading = false + } + + switch result { + case .success(let response): + self.upcomingMovies = response.results + case .failure(let error): + if completionGroup != nil { + errorContainer?.setError(error, forSource: "Upcoming movies") + } else { + self.errorMessage = "Failed to load Upcoming movies: \(error.localizedDescription)" + } + } + completionGroup?.leave() + } + } + } + + // Helper class to manage first error in a group operation + fileprivate class ErrorContainer { + private var firstError: (source: String, error: Error)? + private let lock = NSLock() + + func setError(_ error: Error, forSource source: String) { + lock.lock() + defer { lock.unlock() } + if firstError == nil { + firstError = (source, error) + } + } + + func getErrorMessage() -> String? { + lock.lock() + defer { lock.unlock() } + if let errorInfo = firstError { + return "Failed to load \(errorInfo.source): \(errorInfo.error.localizedDescription)" + } + return nil + } + } + + // Convenience method to fetch all initial lists + func fetchAllMovieLists() { + self.isLoading = true + self.errorMessage = nil // Clear previous errors before starting a batch + + let group = DispatchGroup() + let errorContainer = ErrorContainer() // To capture the first error + + fetchNowPlayingMovies(page: 1, completionGroup: group, errorContainer: errorContainer) + fetchPopularMovies(page: 1, completionGroup: group, errorContainer: errorContainer) + fetchUpcomingMovies(page: 1, completionGroup: group, errorContainer: errorContainer) + + group.notify(queue: .main) { [weak self] in + guard let self = self else { return } + self.isLoading = false + // Set error message if any error occurred during the batch + if let groupError = errorContainer.getErrorMessage() { + self.errorMessage = groupError + } + } + } +} diff --git a/MovieApp/Presentation/Views/MainTabView.swift b/MovieApp/Presentation/Views/MainTabView.swift new file mode 100644 index 0000000..2dbcb12 --- /dev/null +++ b/MovieApp/Presentation/Views/MainTabView.swift @@ -0,0 +1,87 @@ +import SwiftUI + +struct MainTabView: View { + @StateObject var movieListViewModel = MovieListViewModel() + + var body: some View { + NavigationView { + VStack(spacing: 0) { // Use VStack to stack error message and TabView + // Display error message if it exists and not currently loading + if let errorMessage = movieListViewModel.errorMessage, !movieListViewModel.isLoading { + Text(errorMessage) + .foregroundColor(.red) + .padding() + .frame(maxWidth: .infinity) // Ensure it takes available width + .background(Color.gray.opacity(0.1)) // Optional: subtle background for the error + } + + // Display loading indicator if actively loading and no specific error shown yet + // This is a general loading indicator for the whole view model state. + // Individual lists might still be empty if their specific fetches are pending or failed. + if movieListViewModel.isLoading && movieListViewModel.errorMessage == nil { + ProgressView("Loading movies...") + .padding() + } + + TabView { + MovieListView(movies: movieListViewModel.nowPlayingMovies, title: "Now Playing") + .tabItem { + Label("Now Playing", systemImage: "play.circle.fill") + } + .tag(0) + + MovieListView(movies: movieListViewModel.popularMovies, title: "Popular") + .tabItem { + Label("Popular", systemImage: "star.circle.fill") + } + .tag(1) + + MovieListView(movies: movieListViewModel.upcomingMovies, title: "Upcoming") + .tabItem { + Label("Upcoming", systemImage: "calendar.circle.fill") + } + .tag(2) + } + // The navigationTitle for the TabView itself can be set on the TabView + // or on the views inside if they don't have their own. + // For this setup, MovieListView sets its own title, which is appropriate. + } + // .navigationTitle("Movie App") // Example if a root title was desired for the NavigationView + // .navigationBarTitleDisplayMode(.inline) + } + .onAppear { + // Clear any previous errors when the view appears or re-appears. + // This might be desirable if the user navigates away and back. + // movieListViewModel.errorMessage = nil + // Decided against clearing error on appear for now, as it might hide persistent errors + // until the next fetch attempt. The ViewModel should manage when to clear errors. + + movieListViewModel.fetchAllMovieLists() + } + } +} + +// Preview (Optional) +/* +struct MainTabView_Previews: PreviewProvider { + static var previews: some View { + // Example with an error + let viewModelWithError = MovieListViewModel() + viewModelWithError.errorMessage = "Network connection failed. Please try again later." + + // Example while loading + let viewModelLoading = MovieListViewModel() + viewModelLoading.isLoading = true + + return Group { + MainTabView() // Normal state + + MainTabView(movieListViewModel: viewModelWithError) + .previewDisplayName("With Error Message") + + MainTabView(movieListViewModel: viewModelLoading) + .previewDisplayName("Loading State") + } + } +} +*/ diff --git a/MovieApp/Presentation/Views/MovieDetailView.swift b/MovieApp/Presentation/Views/MovieDetailView.swift new file mode 100644 index 0000000..6cbbac1 --- /dev/null +++ b/MovieApp/Presentation/Views/MovieDetailView.swift @@ -0,0 +1,154 @@ +import SwiftUI + +struct MovieDetailView: View { + @StateObject var viewModel = MovieDetailViewModel() + let movieId: Int + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if viewModel.isLoading { + ProgressView("Loading details...") + .frame(maxWidth: .infinity, alignment: .center) + } else if let errorMessage = viewModel.errorMessage { + Text("Error: \(errorMessage)") + .foregroundColor(.red) + .frame(maxWidth: .infinity, alignment: .center) + } else if let movie = viewModel.movie { + moviePoster(for: movie) + movieTitle(for: movie) + movieOverview(for: movie) + movieInfoSection(for: movie) + movieStats(for: movie) + Spacer() // Push content to top + } else { + Text("Movie details not available.") + .frame(maxWidth: .infinity, alignment: .center) + } + } + .padding() + } + .onAppear { + viewModel.fetchMovieDetails(id: movieId) + } + .navigationTitle(viewModel.movie?.title ?? "Details") + .navigationBarTitleDisplayMode(.inline) + } + + // MARK: - Private View Builders + + @ViewBuilder + private func moviePoster(for movie: Movie) -> some View { + AsyncImage(url: movie.posterURL) { phase in + switch phase { + case .empty: + ProgressView().frame(height: 300) + case .success(let image): + image.resizable().aspectRatio(contentMode: .fit).cornerRadius(12) + case .failure: + Image(systemName: "photo.artframe").resizable().aspectRatio(contentMode: .fit).frame(height: 300).foregroundColor(.gray) + @unknown default: + EmptyView().frame(height: 300) + } + } + .frame(maxWidth: .infinity) + } + + @ViewBuilder + private func movieTitle(for movie: Movie) -> some View { + Text(movie.title) + .font(.largeTitle) + .fontWeight(.bold) + .padding(.top, 8) + } + + @ViewBuilder + private func movieOverview(for movie: Movie) -> some View { + if let overview = movie.overview, !overview.isEmpty { + Text("Overview") + .font(.title2) + .fontWeight(.semibold) + .padding(.top, 8) + Text(overview) + .font(.body) + } + } + + @ViewBuilder + private func movieInfoSection(for movie: Movie) -> some View { + // Release Date + if let releaseDate = movie.releaseDate, !releaseDate.isEmpty { + HStack { + Text("Release Date:").fontWeight(.semibold) + Text(releaseDate).foregroundColor(.gray) + } + .font(.subheadline) + .padding(.top, 2) + } + + // Runtime + if let runtime = movie.runtime, runtime > 0 { + HStack { + Text("Runtime:").fontWeight(.semibold) + Text(formattedRuntime(runtime)).foregroundColor(.gray) + } + .font(.subheadline) + .padding(.top, 2) + } + + // Genres + if let genres = movie.genres, !genres.isEmpty { + VStack(alignment: .leading) { + Text("Genres:").fontWeight(.semibold) + Text(genres.map { $0.name }.joined(separator: ", ")) + .foregroundColor(.gray) + } + .font(.subheadline) + .padding(.top, 2) + } + } + + @ViewBuilder + private func movieStats(for movie: Movie) -> some View { + VStack(alignment: .leading, spacing: 4) { + if let popularity = movie.popularity { + Text("Popularity: \(String(format: "%.1f", popularity))") + } + if let voteAverage = movie.voteAverage, let voteCount = movie.voteCount { + Text("Rating: \(String(format: "%.1f", voteAverage))/10 (\(voteCount) votes)") + } + } + .font(.caption) + .foregroundColor(.secondary) + .padding(.top, 8) + } + + private func formattedRuntime(_ minutes: Int) -> String { + let hours = minutes / 60 + let remainingMinutes = minutes % 60 + if hours > 0 && remainingMinutes > 0 { + return "\(hours)h \(remainingMinutes)m" + } else if hours > 0 { + return "\(hours)h" + } else { + return "\(remainingMinutes)m" + } + } +} + +// Preview (Optional - for development in Xcode) +struct MovieDetailView_Previews: PreviewProvider { + static var previews: some View { + let mockViewModel = MovieDetailViewModel() + // Simulate a loaded movie state for preview + let sampleGenres = [Genre(id: 28, name: "Action"), Genre(id: 12, name: "Adventure")] + let sampleMovie = Movie(id: 1, title: "Preview Blockbuster", releaseDate: "2023-01-01", posterPath: "/uS1AIL7I1Ycgs8PTfqUeN6jYNsQ.jpg", overview: "This is a very long and detailed overview of the movie to see how text wrapping and layout behaves within the ScrollView. It should provide enough content to test scrolling if necessary, especially on smaller devices. The plot involves exciting chases, dramatic confrontations, and a resolution that ties everything together neatly. More text just to make it longer and longer and longer.", genres: sampleGenres, runtime: 155, popularity: 250.5, voteAverage: 8.8, voteCount: 3500) + mockViewModel.movie = sampleMovie + // mockViewModel.isLoading = true // To test loading state + // mockViewModel.errorMessage = "Failed to load movie details. Please try again." // To test error state + + return NavigationView { + MovieDetailView(viewModel: mockViewModel, movieId: 1) + } + } +} diff --git a/MovieApp/Presentation/Views/MovieListView.swift b/MovieApp/Presentation/Views/MovieListView.swift new file mode 100644 index 0000000..f822bd6 --- /dev/null +++ b/MovieApp/Presentation/Views/MovieListView.swift @@ -0,0 +1,74 @@ +import SwiftUI + +struct MovieListView: View { + let movies: [Movie] + let title: String + + var body: some View { + List(movies) { movie in + NavigationLink(destination: MovieDetailView(movieId: movie.id)) { + HStack(alignment: .top, spacing: 12) { + AsyncImage(url: movie.posterURL) { phase in + switch phase { + case .empty: + ProgressView() // Placeholder while loading + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) // Fill the frame + case .failure: + Image(systemName: "photo.artframe") // System icon for failure + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.gray.opacity(0.7)) + @unknown default: + EmptyView() // Fallback for unknown states + } + } + .frame(width: 80, height: 120) // Fixed frame for the image + .background(Color.gray.opacity(0.1)) // Background for the frame + .clipped() // Clip the image to the frame + .cornerRadius(8) // Rounded corners for the image frame + + VStack(alignment: .leading, spacing: 5) { // Increased spacing slightly + Text(movie.title) + .font(.headline) + + Text(movie.releaseDate ?? "N/A") // Use "N/A" for nil releaseDate + .font(.subheadline) + .foregroundColor(.gray) + + if let overview = movie.overview, !overview.isEmpty { + Text(overview) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(3) // Limit overview to 3 lines + .padding(.top, 2) + } + } + } + .padding(.vertical, 6) // Slightly increased vertical padding for the row + } + } + .navigationTitle(title) + } +} + +// Preview (Optional - for development in Xcode) +struct MovieListView_Previews: PreviewProvider { + static var previews: some View { + // Sample data for preview + let sampleMovies = [ + Movie(id: 1, title: "Epic Adventure Movie", releaseDate: "2023-03-15", posterPath: "/uS1AIL7I1Ycgs8PTfqUeN6jYNsQ.jpg", overview: "A thrilling journey through mountains and valleys, full of action and suspense. The hero faces many challenges.", genres: [Genre(id: 28, name: "Action")], runtime: 145, popularity: 123.45, voteAverage: 8.1, voteCount: 2034), + Movie(id: 2, title: "Mystery of the Lost City", releaseDate: "2023-05-20", posterPath: "/sMP3VlYpccMPhVybmlLhY8PvK40.jpg", overview: "Detectives try to solve the disappearance of an ancient artifact from a legendary lost city. Twists and turns await.", genres: [Genre(id: 9648, name: "Mystery")], runtime: 110, popularity: 98.76, voteAverage: 7.5, voteCount: 980), + Movie(id: 3, title: "Comedy Nights", releaseDate: nil, posterPath: nil, overview: "A hilarious series of sketches and stand-up comedy that will leave you in stitches.", genres: [Genre(id: 35, name: "Comedy")], runtime: 90, popularity: 75.30, voteAverage: 6.9, voteCount: 450) + ] + + NavigationView { + MovieListView( + movies: sampleMovies, + title: "Popular Movies" + ) + } + } +} diff --git a/MovieAppTests/GetMovieDetailsUseCaseTests.swift b/MovieAppTests/GetMovieDetailsUseCaseTests.swift new file mode 100644 index 0000000..03e7d15 --- /dev/null +++ b/MovieAppTests/GetMovieDetailsUseCaseTests.swift @@ -0,0 +1,87 @@ +import XCTest +@testable import MovieApp + +class GetMovieDetailsUseCaseTests: XCTestCase { + + var mockRepository: MockMovieRepository! + var useCase: GetMovieDetailsUseCase! + + override func setUp() { + super.setUp() + mockRepository = MockMovieRepository() + useCase = GetMovieDetailsUseCase(repository: mockRepository) + } + + override func tearDown() { + mockRepository = nil + useCase = nil + super.tearDown() + } + + func testExecute_Success_ReturnsMovieDetail() { + // Arrange + let expectedMovieId = 123 + let expectedMovie = MockMovieRepository.createSampleMovie(id: expectedMovieId, title: "Detailed Movie") + mockRepository.getMovieDetailsResult = .success(expectedMovie) + + let expectation = self.expectation(description: "Completion handler called for movie details") + var actualResult: Result? + + // Act + useCase.execute(id: expectedMovieId) { result in + actualResult = result + expectation.fulfill() + } + + // Assert + waitForExpectations(timeout: 1.0) { error in + XCTAssertNil(error, "Expectation should not timeout") + + XCTAssertTrue(self.mockRepository.getMovieDetailsCalled, "getMovieDetails should be called") + XCTAssertEqual(self.mockRepository.getMovieDetailsLastIdFetched, expectedMovieId, "ID fetched should match expected ID") + + switch actualResult { + case .success(let movie): + XCTAssertEqual(movie.id, expectedMovieId) + XCTAssertEqual(movie.title, expectedMovie.title) + case .failure(let error): + XCTFail("Expected success but got failure: \(error.localizedDescription)") + case nil: + XCTFail("Result should not be nil") + } + } + } + + func testExecute_Failure_ReturnsError() { + // Arrange + let expectedMovieId = 456 + let expectedError = TestError.genericError + mockRepository.getMovieDetailsResult = .failure(expectedError) + + let expectation = self.expectation(description: "Completion handler called for movie details failure") + var actualResult: Result? + + // Act + useCase.execute(id: expectedMovieId) { result in + actualResult = result + expectation.fulfill() + } + + // Assert + waitForExpectations(timeout: 1.0) { error in + XCTAssertNil(error, "Expectation should not timeout") + + XCTAssertTrue(self.mockRepository.getMovieDetailsCalled, "getMovieDetails should be called") + XCTAssertEqual(self.mockRepository.getMovieDetailsLastIdFetched, expectedMovieId, "ID fetched should match expected ID") + + switch actualResult { + case .success: + XCTFail("Expected failure but got success") + case .failure(let receivedError): + XCTAssertEqual(receivedError as? TestError, expectedError, "Received error should match expected error") + case nil: + XCTFail("Result should not be nil") + } + } + } +} diff --git a/MovieAppTests/GetNowPlayingMoviesUseCaseTests.swift b/MovieAppTests/GetNowPlayingMoviesUseCaseTests.swift new file mode 100644 index 0000000..8147234 --- /dev/null +++ b/MovieAppTests/GetNowPlayingMoviesUseCaseTests.swift @@ -0,0 +1,90 @@ +import XCTest +@testable import MovieApp // To access GetNowPlayingMoviesUseCase, Movie, PaginatedResponse, etc. + +class GetNowPlayingMoviesUseCaseTests: XCTestCase { + + var mockRepository: MockMovieRepository! + var useCase: GetNowPlayingMoviesUseCase! + + override func setUp() { + super.setUp() + mockRepository = MockMovieRepository() + useCase = GetNowPlayingMoviesUseCase(repository: mockRepository) + } + + override func tearDown() { + mockRepository = nil + useCase = nil + super.tearDown() + } + + func testExecute_Success_ReturnsMovies() { + // Arrange + let expectedPage = 1 + let expectedMovies = [MockMovieRepository.createSampleMovie(id: 1, title: "Now Playing Movie 1")] + let paginatedResponse = MockMovieRepository.createSamplePaginatedResponse(page: expectedPage, movies: expectedMovies) + mockRepository.getNowPlayingMoviesResult = .success(paginatedResponse) + + let expectation = self.expectation(description: "Completion handler called") + var actualResult: Result, Error>? + + // Act + useCase.execute(page: expectedPage) { result in + actualResult = result + expectation.fulfill() + } + + // Assert + waitForExpectations(timeout: 1.0) { error in + XCTAssertNil(error, "Expectation should not timeout") + + XCTAssertTrue(self.mockRepository.getNowPlayingMoviesCalled, "getNowPlayingMovies should be called") + XCTAssertEqual(self.mockRepository.getNowPlayingMoviesLastPageFetched, expectedPage, "Page fetched should match expected page") + + switch actualResult { + case .success(let response): + XCTAssertEqual(response.page, expectedPage) + XCTAssertEqual(response.results.count, expectedMovies.count) + XCTAssertEqual(response.results.first?.id, expectedMovies.first?.id) + XCTAssertEqual(response.results.first?.title, expectedMovies.first?.title) + case .failure(let error): + XCTFail("Expected success but got failure: \(error.localizedDescription)") + case nil: + XCTFail("Result should not be nil") + } + } + } + + func testExecute_Failure_ReturnsError() { + // Arrange + let expectedPage = 1 + let expectedError = TestError.genericError + mockRepository.getNowPlayingMoviesResult = .failure(expectedError) + + let expectation = self.expectation(description: "Completion handler called") + var actualResult: Result, Error>? + + // Act + useCase.execute(page: expectedPage) { result in + actualResult = result + expectation.fulfill() + } + + // Assert + waitForExpectations(timeout: 1.0) { error in + XCTAssertNil(error, "Expectation should not timeout") + + XCTAssertTrue(self.mockRepository.getNowPlayingMoviesCalled, "getNowPlayingMovies should be called") + XCTAssertEqual(self.mockRepository.getNowPlayingMoviesLastPageFetched, expectedPage, "Page fetched should match expected page") + + switch actualResult { + case .success: + XCTFail("Expected failure but got success") + case .failure(let receivedError): + XCTAssertEqual(receivedError as? TestError, expectedError, "Received error should match expected error") + case nil: + XCTFail("Result should not be nil") + } + } + } +} diff --git a/MovieAppTests/GetPopularMoviesUseCaseTests.swift b/MovieAppTests/GetPopularMoviesUseCaseTests.swift new file mode 100644 index 0000000..ba3a1d3 --- /dev/null +++ b/MovieAppTests/GetPopularMoviesUseCaseTests.swift @@ -0,0 +1,89 @@ +import XCTest +@testable import MovieApp + +class GetPopularMoviesUseCaseTests: XCTestCase { + + var mockRepository: MockMovieRepository! + var useCase: GetPopularMoviesUseCase! + + override func setUp() { + super.setUp() + mockRepository = MockMovieRepository() + useCase = GetPopularMoviesUseCase(repository: mockRepository) + } + + override func tearDown() { + mockRepository = nil + useCase = nil + super.tearDown() + } + + func testExecute_Success_ReturnsMovies() { + // Arrange + let expectedPage = 2 // Using a different page for variety + let expectedMovies = [MockMovieRepository.createSampleMovie(id: 10, title: "Popular Movie 1")] + let paginatedResponse = MockMovieRepository.createSamplePaginatedResponse(page: expectedPage, movies: expectedMovies) + mockRepository.getPopularMoviesResult = .success(paginatedResponse) + + let expectation = self.expectation(description: "Completion handler called for popular movies") + var actualResult: Result, Error>? + + // Act + useCase.execute(page: expectedPage) { result in + actualResult = result + expectation.fulfill() + } + + // Assert + waitForExpectations(timeout: 1.0) { error in + XCTAssertNil(error, "Expectation should not timeout") + + XCTAssertTrue(self.mockRepository.getPopularMoviesCalled, "getPopularMovies should be called") + XCTAssertEqual(self.mockRepository.getPopularMoviesLastPageFetched, expectedPage, "Page fetched should match expected page") + + switch actualResult { + case .success(let response): + XCTAssertEqual(response.page, expectedPage) + XCTAssertEqual(response.results.count, expectedMovies.count) + XCTAssertEqual(response.results.first?.id, expectedMovies.first?.id) + case .failure(let error): + XCTFail("Expected success but got failure: \(error.localizedDescription)") + case nil: + XCTFail("Result should not be nil") + } + } + } + + func testExecute_Failure_ReturnsError() { + // Arrange + let expectedPage = 1 + let expectedError = TestError.genericError + mockRepository.getPopularMoviesResult = .failure(expectedError) + + let expectation = self.expectation(description: "Completion handler called for popular movies failure") + var actualResult: Result, Error>? + + // Act + useCase.execute(page: expectedPage) { result in + actualResult = result + expectation.fulfill() + } + + // Assert + waitForExpectations(timeout: 1.0) { error in + XCTAssertNil(error, "Expectation should not timeout") + + XCTAssertTrue(self.mockRepository.getPopularMoviesCalled, "getPopularMovies should be called") + XCTAssertEqual(self.mockRepository.getPopularMoviesLastPageFetched, expectedPage, "Page fetched should match expected page") + + switch actualResult { + case .success: + XCTFail("Expected failure but got success") + case .failure(let receivedError): + XCTAssertEqual(receivedError as? TestError, expectedError, "Received error should match expected error") + case nil: + XCTFail("Result should not be nil") + } + } + } +} diff --git a/MovieAppTests/GetUpcomingMoviesUseCaseTests.swift b/MovieAppTests/GetUpcomingMoviesUseCaseTests.swift new file mode 100644 index 0000000..b1c6d3c --- /dev/null +++ b/MovieAppTests/GetUpcomingMoviesUseCaseTests.swift @@ -0,0 +1,89 @@ +import XCTest +@testable import MovieApp + +class GetUpcomingMoviesUseCaseTests: XCTestCase { + + var mockRepository: MockMovieRepository! + var useCase: GetUpcomingMoviesUseCase! + + override func setUp() { + super.setUp() + mockRepository = MockMovieRepository() + useCase = GetUpcomingMoviesUseCase(repository: mockRepository) + } + + override func tearDown() { + mockRepository = nil + useCase = nil + super.tearDown() + } + + func testExecute_Success_ReturnsMovies() { + // Arrange + let expectedPage = 3 + let expectedMovies = [MockMovieRepository.createSampleMovie(id: 20, title: "Upcoming Movie 1")] + let paginatedResponse = MockMovieRepository.createSamplePaginatedResponse(page: expectedPage, movies: expectedMovies) + mockRepository.getUpcomingMoviesResult = .success(paginatedResponse) + + let expectation = self.expectation(description: "Completion handler called for upcoming movies") + var actualResult: Result, Error>? + + // Act + useCase.execute(page: expectedPage) { result in + actualResult = result + expectation.fulfill() + } + + // Assert + waitForExpectations(timeout: 1.0) { error in + XCTAssertNil(error, "Expectation should not timeout") + + XCTAssertTrue(self.mockRepository.getUpcomingMoviesCalled, "getUpcomingMovies should be called") + XCTAssertEqual(self.mockRepository.getUpcomingMoviesLastPageFetched, expectedPage, "Page fetched should match expected page") + + switch actualResult { + case .success(let response): + XCTAssertEqual(response.page, expectedPage) + XCTAssertEqual(response.results.count, expectedMovies.count) + XCTAssertEqual(response.results.first?.id, expectedMovies.first?.id) + case .failure(let error): + XCTFail("Expected success but got failure: \(error.localizedDescription)") + case nil: + XCTFail("Result should not be nil") + } + } + } + + func testExecute_Failure_ReturnsError() { + // Arrange + let expectedPage = 1 + let expectedError = TestError.genericError + mockRepository.getUpcomingMoviesResult = .failure(expectedError) + + let expectation = self.expectation(description: "Completion handler called for upcoming movies failure") + var actualResult: Result, Error>? + + // Act + useCase.execute(page: expectedPage) { result in + actualResult = result + expectation.fulfill() + } + + // Assert + waitForExpectations(timeout: 1.0) { error in + XCTAssertNil(error, "Expectation should not timeout") + + XCTAssertTrue(self.mockRepository.getUpcomingMoviesCalled, "getUpcomingMovies should be called") + XCTAssertEqual(self.mockRepository.getUpcomingMoviesLastPageFetched, expectedPage, "Page fetched should match expected page") + + switch actualResult { + case .success: + XCTFail("Expected failure but got success") + case .failure(let receivedError): + XCTAssertEqual(receivedError as? TestError, expectedError, "Received error should match expected error") + case nil: + XCTFail("Result should not be nil") + } + } + } +} diff --git a/MovieAppTests/MockAPIClient.swift b/MovieAppTests/MockAPIClient.swift new file mode 100644 index 0000000..b8df7b0 --- /dev/null +++ b/MovieAppTests/MockAPIClient.swift @@ -0,0 +1,73 @@ +import Foundation +@testable import MovieApp // To access APIClientProtocol, APIError, PaginatedResponse, Movie + +class MockAPIClient: APIClientProtocol { + + // MARK: - Properties for buildRequest + var buildRequestCalled: Bool = false + var buildRequestPathArg: String? + var buildRequestMethodArg: String? + var buildRequestParamsArg: [String: String]? + var buildRequestReturnValue: URLRequest? = URLRequest(url: URL(string: "https://dummy.url/path")!) // Default dummy request + + // MARK: - Properties for request + var requestCalled: Bool = false + var requestURLRequestArg: URLRequest? + + // We need separate result properties for each type of expected Decodable, + // as the generic T cannot be directly stored with a specific type. + var paginatedMovieResponseResult: Result, APIError>? + var movieDetailResult: Result? + + // MARK: - Method Implementations + + func buildRequest(path: String, method: String, params: [String: String]?) -> URLRequest? { + buildRequestCalled = true + buildRequestPathArg = path + buildRequestMethodArg = method + buildRequestParamsArg = params + return buildRequestReturnValue + } + + func request(urlRequest: URLRequest, completion: @escaping (Result) -> Void) { + requestCalled = true + requestURLRequestArg = urlRequest + + // Determine which mock result to use based on T + if T.self == PaginatedResponse.self, let result = paginatedMovieResponseResult as? Result { + completion(result) + } else if T.self == Movie.self, let result = movieDetailResult as? Result { + completion(result) + } else { + // Fallback or fatal error if the mock result for type T is not set + // This indicates a test setup issue. + fatalError("MockAPIClient.request called with type \(T.self) but no mock result was set for this type.") + } + } + + // MARK: - Helper to reset state + func reset() { + buildRequestCalled = false + buildRequestPathArg = nil + buildRequestMethodArg = nil + buildRequestParamsArg = nil + buildRequestReturnValue = URLRequest(url: URL(string: "https://dummy.url/path")!) // Reset to default + + requestCalled = false + requestURLRequestArg = nil + + paginatedMovieResponseResult = nil + movieDetailResult = nil + } + + // MARK: - Sample Data (Convenience for tests, can be shared or moved) + // Using sample data from MockMovieRepository to avoid duplication for now. + // In a larger project, these might live in a shared test utilities file. + static func createSampleMovie(id: Int, title: String = "Sample Movie") -> Movie { + return MockMovieRepository.createSampleMovie(id: id, title: title) + } + + static func createSamplePaginatedResponse(page: Int = 1, movies: [Movie]? = nil) -> PaginatedResponse { + return MockMovieRepository.createSamplePaginatedResponse(page: page, movies: movies) + } +} diff --git a/MovieAppTests/MockMovieRepository.swift b/MovieAppTests/MockMovieRepository.swift new file mode 100644 index 0000000..dec1113 --- /dev/null +++ b/MovieAppTests/MockMovieRepository.swift @@ -0,0 +1,115 @@ +import Foundation +// We need to import the main module to access MovieRepositoryProtocol, PaginatedResponse, Movie, APIError +// The actual name of the module is 'MovieApp' based on the project structure. +// If tests are part of the same module, this import might not be strictly needed for types, +// but it's good practice for clarity and when dealing with access control (e.g., public vs internal). +@testable import MovieApp + +// Dummy Error for testing +enum TestError: Error, Equatable { + case genericError +} + +class MockMovieRepository: MovieRepositoryProtocol { + + // MARK: - Properties for getNowPlayingMovies + var getNowPlayingMoviesResult: Result, Error>? + var getNowPlayingMoviesCalled: Bool = false + var getNowPlayingMoviesLastPageFetched: Int? + + // MARK: - Properties for getPopularMovies + var getPopularMoviesResult: Result, Error>? + var getPopularMoviesCalled: Bool = false + var getPopularMoviesLastPageFetched: Int? + + // MARK: - Properties for getUpcomingMovies + var getUpcomingMoviesResult: Result, Error>? + var getUpcomingMoviesCalled: Bool = false + var getUpcomingMoviesLastPageFetched: Int? + + // MARK: - Properties for getMovieDetails + var getMovieDetailsResult: Result? + var getMovieDetailsCalled: Bool = false + var getMovieDetailsLastIdFetched: Int? + + // MARK: - Method Implementations + + func getNowPlayingMovies(page: Int, completion: @escaping (Result, Error>) -> Void) { + getNowPlayingMoviesCalled = true + getNowPlayingMoviesLastPageFetched = page + if let result = getNowPlayingMoviesResult { + completion(result) + } else { + // Default behavior or fatal error if not set, to ensure tests configure it. + fatalError("getNowPlayingMoviesResult was not set in MockMovieRepository") + } + } + + func getPopularMovies(page: Int, completion: @escaping (Result, Error>) -> Void) { + getPopularMoviesCalled = true + getPopularMoviesLastPageFetched = page + if let result = getPopularMoviesResult { + completion(result) + } else { + fatalError("getPopularMoviesResult was not set in MockMovieRepository") + } + } + + func getUpcomingMovies(page: Int, completion: @escaping (Result, Error>) -> Void) { + getUpcomingMoviesCalled = true + getUpcomingMoviesLastPageFetched = page + if let result = getUpcomingMoviesResult { + completion(result) + } else { + fatalError("getUpcomingMoviesResult was not set in MockMovieRepository") + } + } + + func getMovieDetails(id: Int, completion: @escaping (Result) -> Void) { + getMovieDetailsCalled = true + getMovieDetailsLastIdFetched = id + if let result = getMovieDetailsResult { + completion(result) + } else { + fatalError("getMovieDetailsResult was not set in MockMovieRepository") + } + } + + // MARK: - Helper to reset state + func reset() { + getNowPlayingMoviesResult = nil + getNowPlayingMoviesCalled = false + getNowPlayingMoviesLastPageFetched = nil + + getPopularMoviesResult = nil + getPopularMoviesCalled = false + getPopularMoviesLastPageFetched = nil + + getUpcomingMoviesResult = nil + getUpcomingMoviesCalled = false + getUpcomingMoviesLastPageFetched = nil + + getMovieDetailsResult = nil + getMovieDetailsCalled = false + getMovieDetailsLastIdFetched = nil + } + + // MARK: - Sample Data (Convenient for tests) + + static func createSampleMovie(id: Int, title: String = "Sample Movie") -> Movie { + return Movie(id: id, title: title, releaseDate: "2023-01-01", posterPath: "/sample.jpg", overview: "This is a sample overview.", genres: [Genre(id: 1, name: "Action")], runtime: 120, popularity: 8.0, voteAverage: 7.5, voteCount: 100) + } + + static func createSamplePaginatedResponse(page: Int = 1, totalPages: Int = 5, totalResults: Int = 100, movies: [Movie]? = nil) -> PaginatedResponse { + let sampleMovies = movies ?? [ + createSampleMovie(id: 1, title: "Movie 1 in Page \(page)"), + createSampleMovie(id: 2, title: "Movie 2 in Page \(page)") + ] + return PaginatedResponse( + page: page, + results: sampleMovies, + totalPages: totalPages, + totalResults: totalResults + ) + } +} diff --git a/MovieAppTests/MovieDetailViewModelTests.swift b/MovieAppTests/MovieDetailViewModelTests.swift new file mode 100644 index 0000000..c1b7f26 --- /dev/null +++ b/MovieAppTests/MovieDetailViewModelTests.swift @@ -0,0 +1,128 @@ +import XCTest +import Combine +@testable import MovieApp + +class MovieDetailViewModelTests: XCTestCase { + + var mockRepository: MockMovieRepository! + var getMovieDetailsUseCase: GetMovieDetailsUseCase! + var viewModel: MovieDetailViewModel! + + private var cancellables: Set = [] + + override func setUp() { + super.setUp() + mockRepository = MockMovieRepository() + // As with MovieListViewModelTests, we inject the use case with the mock repository + getMovieDetailsUseCase = GetMovieDetailsUseCase(repository: mockRepository) + viewModel = MovieDetailViewModel(getMovieDetailsUseCase: getMovieDetailsUseCase) + } + + override func tearDown() { + mockRepository = nil + viewModel = nil + getMovieDetailsUseCase = nil + cancellables.forEach { $0.cancel() } + cancellables.removeAll() + super.tearDown() + } + + // Helper to create expectations for @Published properties + private func expectPublished( + _ publishedValue: Published.Publisher, // Note: T? because movie is optional + equals expected: T?, + description: String, + timeout: TimeInterval = 1.0 + ) -> XCTestExpectation { + let expectation = self.expectation(description: description) + var actualValue: T?? // Double optional to distinguish initial nil from set nil + + publishedValue + .dropFirst() // Ignore the initial value + .first() + .sink { value in + actualValue = value + if actualValue == expected { // This works even if both are nil + expectation.fulfill() + } else if let actual = actualValue, let exp = expected, actual == exp { // For non-nil comparison + expectation.fulfill() + } + } + .store(in: &cancellables) + + return expectation + } + + private func expectPublishedNonNil( + _ publishedValue: Published.Publisher, + description: String, + timeout: TimeInterval = 1.0 + ) -> XCTestExpectation { + let expectation = self.expectation(description: description) + + publishedValue + .dropFirst() + .first(where: { $0 != nil }) + .sink { value in + if value != nil { + expectation.fulfill() + } + } + .store(in: &cancellables) + + return expectation + } + + + func testFetchMovieDetails_Success() { + // Arrange + let movieId = 123 + let expectedMovie = MockMovieRepository.createSampleMovie(id: movieId, title: "Detailed Movie") + mockRepository.getMovieDetailsResult = .success(expectedMovie) + + let movieExpectation = expectPublished(viewModel.$movie, equals: expectedMovie, description: "Movie details updated") + let loadingExpectation = self.expectation(description: "isLoading goes true then false") + loadingExpectation.expectedFulfillmentCount = 2 // true, then false + + viewModel.$isLoading.sink { isLoading in + loadingExpectation.fulfill() + }.store(in: &cancellables) + + + // Act + viewModel.fetchMovieDetails(id: movieId) + + // Assert + wait(for: [movieExpectation, loadingExpectation], timeout: 2.0) + XCTAssertTrue(mockRepository.getMovieDetailsCalled) + XCTAssertEqual(mockRepository.getMovieDetailsLastIdFetched, movieId) + XCTAssertEqual(viewModel.movie?.id, expectedMovie.id) + XCTAssertEqual(viewModel.movie?.title, expectedMovie.title) + XCTAssertNil(viewModel.errorMessage) + } + + func testFetchMovieDetails_Failure() { + // Arrange + let movieId = 456 + let expectedError = TestError.genericError + mockRepository.getMovieDetailsResult = .failure(expectedError) + + let errorExpectation = expectPublishedNonNil(viewModel.$errorMessage, description: "Error message should be set on failure") + let loadingExpectation = self.expectation(description: "isLoading goes true then false on failure") + loadingExpectation.expectedFulfillmentCount = 2 // true, then false + + viewModel.$isLoading.sink { isLoading in + loadingExpectation.fulfill() + }.store(in: &cancellables) + + // Act + viewModel.fetchMovieDetails(id: movieId) + + // Assert + wait(for: [errorExpectation, loadingExpectation], timeout: 2.0) + XCTAssertTrue(mockRepository.getMovieDetailsCalled) + XCTAssertEqual(mockRepository.getMovieDetailsLastIdFetched, movieId) + XCTAssertNotNil(viewModel.errorMessage) + XCTAssertNil(viewModel.movie) + } +} diff --git a/MovieAppTests/MovieListViewModelTests.swift b/MovieAppTests/MovieListViewModelTests.swift new file mode 100644 index 0000000..f195adf --- /dev/null +++ b/MovieAppTests/MovieListViewModelTests.swift @@ -0,0 +1,256 @@ +import XCTest +import Combine // For testing @Published properties if needed, though direct checks are also fine. +@testable import MovieApp + +class MovieListViewModelTests: XCTestCase { + + var mockRepository: MockMovieRepository! + // Use cases will be initialized by the ViewModel itself using this mockRepository + var getNowPlayingMoviesUseCase: GetNowPlayingMoviesUseCase! + var getPopularMoviesUseCase: GetPopularMoviesUseCase! + var getUpcomingMoviesUseCase: GetUpcomingMoviesUseCase! + + var viewModel: MovieListViewModel! + + private var cancellables: Set = [] + + override func setUp() { + super.setUp() + mockRepository = MockMovieRepository() + // The ViewModel initializes its own use cases, but they will use the repository we pass. + // For more control, we could inject mock use cases, but the current ViewModel design + // instantiates them directly. We will create a view model and then assign its use cases the mock repo. + // This is a bit of a workaround for the current ViewModel init. + // A better approach would be to allow injecting use cases directly into the ViewModel. + + // Create actual use cases but with the mock repository + getNowPlayingMoviesUseCase = GetNowPlayingMoviesUseCase(repository: mockRepository) + getPopularMoviesUseCase = GetPopularMoviesUseCase(repository: mockRepository) + getUpcomingMoviesUseCase = GetUpcomingMoviesUseCase(repository: mockRepository) + + viewModel = MovieListViewModel( + getNowPlayingMoviesUseCase: getNowPlayingMoviesUseCase, + getPopularMoviesUseCase: getPopularMoviesUseCase, + getUpcomingMoviesUseCase: getUpcomingMoviesUseCase + ) + } + + override func tearDown() { + mockRepository = nil + viewModel = nil + getNowPlayingMoviesUseCase = nil + getPopularMoviesUseCase = nil + getUpcomingMoviesUseCase = nil + cancellables.forEach { $0.cancel() } + cancellables.removeAll() + super.tearDown() + } + + // Helper to create expectations for @Published properties + private func expectPublished( + _ publishedValue: Published.Publisher, + equals expected: T, + description: String, + timeout: TimeInterval = 1.0 + ) -> XCTestExpectation { + let expectation = self.expectation(description: description) + var actualValue: T? + + publishedValue + .dropFirst() // Ignore the initial value + .first() // We are interested in the first change after the action + .sink { value in + actualValue = value + if actualValue == expected { + expectation.fulfill() + } + } + .store(in: &cancellables) + + return expectation + } + + private func expectPublishedNonNil( + _ publishedValue: Published.Publisher, + description: String, + timeout: TimeInterval = 1.0 + ) -> XCTestExpectation { + let expectation = self.expectation(description: description) + + publishedValue + .dropFirst() + .first(where: { $0 != nil }) // Fulfill when value is not nil + .sink { value in + if value != nil { + expectation.fulfill() + } + } + .store(in: &cancellables) + + return expectation + } + + + // MARK: - Now Playing Movies Tests + + func testFetchNowPlayingMovies_Success() { + // Arrange + let movies = [MockMovieRepository.createSampleMovie(id: 1, title: "Now Playing 1")] + let response = MockMovieRepository.createSamplePaginatedResponse(movies: movies) + mockRepository.getNowPlayingMoviesResult = .success(response) + + let moviesExpectation = expectPublished(viewModel.$nowPlayingMovies, equals: movies, description: "Now playing movies updated") + let loadingExpectation = expectPublished(viewModel.$isLoading, equals: false, description: "isLoading should be false after fetch") + loadingExpectation.expectedFulfillmentCount = 2 // true then false + + // Act + viewModel.fetchNowPlayingMovies() + + // Assert + wait(for: [moviesExpectation, loadingExpectation], timeout: 2.0) + XCTAssertTrue(mockRepository.getNowPlayingMoviesCalled) + XCTAssertEqual(viewModel.nowPlayingMovies.count, 1) + XCTAssertEqual(viewModel.nowPlayingMovies.first?.title, "Now Playing 1") + XCTAssertNil(viewModel.errorMessage) + } + + func testFetchNowPlayingMovies_Failure() { + // Arrange + let expectedError = TestError.genericError + mockRepository.getNowPlayingMoviesResult = .failure(expectedError) + + let errorExpectation = expectPublishedNonNil(viewModel.$errorMessage, description: "Error message should be set") + let loadingExpectation = expectPublished(viewModel.$isLoading, equals: false, description: "isLoading should be false after fetch attempt") + loadingExpectation.expectedFulfillmentCount = 2 + + + // Act + viewModel.fetchNowPlayingMovies() + + // Assert + wait(for: [errorExpectation, loadingExpectation], timeout: 2.0) + XCTAssertTrue(mockRepository.getNowPlayingMoviesCalled) + XCTAssertNotNil(viewModel.errorMessage) + XCTAssertTrue(viewModel.nowPlayingMovies.isEmpty) + } + + // MARK: - Popular Movies Tests + + func testFetchPopularMovies_Success() { + // Arrange + let movies = [MockMovieRepository.createSampleMovie(id: 2, title: "Popular 1")] + let response = MockMovieRepository.createSamplePaginatedResponse(movies: movies) + mockRepository.getPopularMoviesResult = .success(response) + + let moviesExpectation = expectPublished(viewModel.$popularMovies, equals: movies, description: "Popular movies updated") + let loadingExpectation = expectPublished(viewModel.$isLoading, equals: false, description: "isLoading should be false") + loadingExpectation.expectedFulfillmentCount = 2 + + + // Act + viewModel.fetchPopularMovies() + + // Assert + wait(for: [moviesExpectation, loadingExpectation], timeout: 2.0) + XCTAssertTrue(mockRepository.getPopularMoviesCalled) + XCTAssertEqual(viewModel.popularMovies.first?.title, "Popular 1") + XCTAssertNil(viewModel.errorMessage) + } + + func testFetchPopularMovies_Failure() { + // Arrange + let expectedError = TestError.genericError + mockRepository.getPopularMoviesResult = .failure(expectedError) + + let errorExpectation = expectPublishedNonNil(viewModel.$errorMessage, description: "Error message should be set for popular") + let loadingExpectation = expectPublished(viewModel.$isLoading, equals: false, description: "isLoading should be false after fetch attempt for popular") + loadingExpectation.expectedFulfillmentCount = 2 + + // Act + viewModel.fetchPopularMovies() + + // Assert + wait(for: [errorExpectation, loadingExpectation], timeout: 2.0) + XCTAssertTrue(mockRepository.getPopularMoviesCalled) + XCTAssertNotNil(viewModel.errorMessage) + XCTAssertTrue(viewModel.popularMovies.isEmpty) + } + + // MARK: - Upcoming Movies Tests + + func testFetchUpcomingMovies_Success() { + // Arrange + let movies = [MockMovieRepository.createSampleMovie(id: 3, title: "Upcoming 1")] + let response = MockMovieRepository.createSamplePaginatedResponse(movies: movies) + mockRepository.getUpcomingMoviesResult = .success(response) + + let moviesExpectation = expectPublished(viewModel.$upcomingMovies, equals: movies, description: "Upcoming movies updated") + let loadingExpectation = expectPublished(viewModel.$isLoading, equals: false, description: "isLoading should be false") + loadingExpectation.expectedFulfillmentCount = 2 + + // Act + viewModel.fetchUpcomingMovies() + + // Assert + wait(for: [moviesExpectation, loadingExpectation], timeout: 2.0) + XCTAssertTrue(mockRepository.getUpcomingMoviesCalled) + XCTAssertEqual(viewModel.upcomingMovies.first?.title, "Upcoming 1") + XCTAssertNil(viewModel.errorMessage) + } + + func testFetchUpcomingMovies_Failure() { + // Arrange + let expectedError = TestError.genericError + mockRepository.getUpcomingMoviesResult = .failure(expectedError) + + let errorExpectation = expectPublishedNonNil(viewModel.$errorMessage, description: "Error message should be set for upcoming") + let loadingExpectation = expectPublished(viewModel.$isLoading, equals: false, description: "isLoading should be false after fetch attempt for upcoming") + loadingExpectation.expectedFulfillmentCount = 2 + + // Act + viewModel.fetchUpcomingMovies() + + // Assert + wait(for: [errorExpectation, loadingExpectation], timeout: 2.0) + XCTAssertTrue(mockRepository.getUpcomingMoviesCalled) + XCTAssertNotNil(viewModel.errorMessage) + XCTAssertTrue(viewModel.upcomingMovies.isEmpty) + } + + // MARK: - Fetch All Lists + + func testFetchAllMovieLists_Success() { + // Arrange + let nowPlaying = [MockMovieRepository.createSampleMovie(id: 1, title: "NP")] + let popular = [MockMovieRepository.createSampleMovie(id: 2, title: "Pop")] + let upcoming = [MockMovieRepository.createSampleMovie(id: 3, title: "Up")] + + mockRepository.getNowPlayingMoviesResult = .success(MockMovieRepository.createSamplePaginatedResponse(movies: nowPlaying)) + mockRepository.getPopularMoviesResult = .success(MockMovieRepository.createSamplePaginatedResponse(movies: popular)) + mockRepository.getUpcomingMoviesResult = .success(MockMovieRepository.createSamplePaginatedResponse(movies: upcoming)) + + let npExpectation = expectPublished(viewModel.$nowPlayingMovies, equals: nowPlaying, description: "Now Playing movies loaded by fetchAll") + let popExpectation = expectPublished(viewModel.$popularMovies, equals: popular, description: "Popular movies loaded by fetchAll") + let upExpectation = expectPublished(viewModel.$upcomingMovies, equals: upcoming, description: "Upcoming movies loaded by fetchAll") + // isLoading will be set to true 3 times and false 3 times. We only care about the final false. + // A more robust way might be to use a dispatch group or combine multiple expectations. + let loadingExp = expectation(description: "isLoading eventually false after all fetches") + + viewModel.$isLoading + .filter { !$0 } // looking for false + .dropFirst(2) // Expect it to go true->false, true->false, true->false. So drop first 2 false. + .first() + .sink { _ in loadingExp.fulfill() } + .store(in: &cancellables) + + // Act + viewModel.fetchAllMovieLists() + + // Assert + wait(for: [npExpectation, popExpectation, upExpectation, loadingExp], timeout: 5.0) // Increased timeout for multiple async calls + XCTAssertTrue(mockRepository.getNowPlayingMoviesCalled) + XCTAssertTrue(mockRepository.getPopularMoviesCalled) + XCTAssertTrue(mockRepository.getUpcomingMoviesCalled) + XCTAssertNil(viewModel.errorMessage) + } +} diff --git a/MovieAppTests/MovieRepositoryTests.swift b/MovieAppTests/MovieRepositoryTests.swift new file mode 100644 index 0000000..cba6472 --- /dev/null +++ b/MovieAppTests/MovieRepositoryTests.swift @@ -0,0 +1,315 @@ +import XCTest +@testable import MovieApp + +class MovieRepositoryTests: XCTestCase { + + var mockAPIClient: MockAPIClient! + var movieRepository: MovieRepository! + var testUserDefaults: UserDefaults! + + // Cache Keys (mirrored for direct cache inspection in tests) + private static let nowPlayingCacheKey = "nowPlayingMoviesCache" + private static let popularCacheKey = "popularMoviesCache" + private static let upcomingCacheKey = "upcomingMoviesCache" + private static func movieDetailCacheKey(id: Int) -> String { "movieDetailCache_\(id)" } + + + override func setUpWithError() throws { + try super.setUpWithError() + mockAPIClient = MockAPIClient() + // Use a specific suite for testing to avoid interfering with app's UserDefaults + let suiteName = #file + testUserDefaults = UserDefaults(suiteName: suiteName) + movieRepository = MovieRepository(apiClient: mockAPIClient, userDefaults: testUserDefaults) + } + + override func tearDownWithError() throws { + mockAPIClient.reset() + mockAPIClient = nil + movieRepository = nil + // Clear UserDefaults for the test suite + let suiteName = #file + testUserDefaults.removePersistentDomain(forName: suiteName) + testUserDefaults = nil + try super.tearDownWithError() + } + + // MARK: - Test Data Helpers (from MockAPIClient to avoid direct dependency on MockMovieRepository here) + private func sampleMovie(id: Int, title: String = "Sample Movie") -> Movie { + return MockAPIClient.createSampleMovie(id: id, title: title) + } + + private func samplePaginatedResponse(page: Int, movies: [Movie]) -> PaginatedResponse { + return MockAPIClient.createSamplePaginatedResponse(page: page, movies: movies) + } + + // Helper to directly cache data for testing fallback scenarios + private func cacheDirectly(_ data: T, forKey key: String) { + do { + let encodedData = try JSONEncoder().encode(data) + testUserDefaults.set(encodedData, forKey: key) + } catch { + XCTFail("Test setup error: Failed to cache data directly. Error: \(error)") + } + } + + private func loadDirectlyFromCache(forKey key: String) -> T? { + guard let data = testUserDefaults.data(forKey: key) else { return nil } + return try? JSONDecoder().decode(T.self, from: data) + } + + // MARK: - getNowPlayingMovies Tests (Original tests + Caching tests) + + func testGetNowPlayingMovies_Success_Original() { // Renamed original test + let expectedPage = 1 + let networkResponse = samplePaginatedResponse(page: expectedPage, movies: [sampleMovie(id: 1)]) + mockAPIClient.paginatedMovieResponseResult = .success(networkResponse) + mockAPIClient.buildRequestReturnValue = URLRequest(url: URL(string: "https://dummy.url/movie/now_playing?page=1")!) + + let expectation = self.expectation(description: "getNowPlayingMovies success original") + var actualResult: Result, Error>? + movieRepository.getNowPlayingMovies(page: expectedPage) { result in actualResult = result; expectation.fulfill() } + waitForExpectations(timeout: 1.0) + + XCTAssertTrue(mockAPIClient.buildRequestCalled) + XCTAssertTrue(mockAPIClient.requestCalled) + guard case .success(let response) = actualResult else { XCTFail("Expected success"); return } + XCTAssertEqual(response.results.first?.id, 1) + } + + func testGetNowPlayingMovies_APIFailure_Original() { // Renamed + let expectedError = APIError.networkError(NSError(domain: "Test", code: 123, userInfo: nil)) + mockAPIClient.paginatedMovieResponseResult = .failure(expectedError) + mockAPIClient.buildRequestReturnValue = URLRequest(url: URL(string: "https://dummy.url/movie/now_playing?page=1")!) + + let expectation = self.expectation(description: "getNowPlayingMovies API failure original") + var actualResult: Result, Error>? + movieRepository.getNowPlayingMovies(page: 1) { result in actualResult = result; expectation.fulfill() } + waitForExpectations(timeout: 1.0) + + XCTAssertTrue(mockAPIClient.requestCalled) + guard case .failure(let error) = actualResult else { XCTFail("Expected failure"); return } + XCTAssertEqual(error as? APIError, expectedError) + } + + func testGetNowPlayingMovies_BuildRequestFailure_Original() { // Renamed + mockAPIClient.buildRequestReturnValue = nil + let expectation = self.expectation(description: "getNowPlayingMovies buildRequest failure original") + var actualResult: Result, Error>? + movieRepository.getNowPlayingMovies(page: 1) { result in actualResult = result; expectation.fulfill() } + waitForExpectations(timeout: 1.0) + + XCTAssertFalse(mockAPIClient.requestCalled) + guard case .failure(let error) = actualResult else { XCTFail("Expected failure"); return } + XCTAssertEqual(error as? APIError, APIError.invalidURL) + } + + // Caching Tests for getNowPlayingMovies + func test_getNowPlayingMovies_success_servesNetworkDataAndCachesIt() { + let networkData = samplePaginatedResponse(page: 1, movies: [sampleMovie(id: 10, title: "Network Now Playing")]) + mockAPIClient.paginatedMovieResponseResult = .success(networkData) + mockAPIClient.buildRequestReturnValue = URLRequest(url: URL(string: "https://dummy.url/now_playing")!) + + let expectation = self.expectation(description: "Network success and caches data") + movieRepository.getNowPlayingMovies(page: 1) { result in + guard case .success(let response) = result else { XCTFail("Expected success"); expectation.fulfill(); return } + XCTAssertEqual(response.results.first?.title, "Network Now Playing") + + // Verify cache + let cached: PaginatedResponse? = self.loadDirectlyFromCache(forKey: MovieRepositoryTests.nowPlayingCacheKey) + XCTAssertNotNil(cached) + XCTAssertEqual(cached?.results.first?.title, "Network Now Playing") + expectation.fulfill() + } + waitForExpectations(timeout: 1.0) + } + + func test_getNowPlayingMovies_networkFailure_servesCachedDataIfExists() { + let cachedData = samplePaginatedResponse(page: 1, movies: [sampleMovie(id: 20, title: "Cached Now Playing")]) + cacheDirectly(cachedData, forKey: MovieRepositoryTests.nowPlayingCacheKey) + + mockAPIClient.paginatedMovieResponseResult = .failure(APIError.networkError(NSError(domain: "Test", code: 0))) + mockAPIClient.buildRequestReturnValue = URLRequest(url: URL(string: "https://dummy.url/now_playing")!) + + let expectation = self.expectation(description: "Network failure serves cached data") + movieRepository.getNowPlayingMovies(page: 1) { result in + guard case .success(let response) = result else { XCTFail("Expected success from cache"); expectation.fulfill(); return } + XCTAssertEqual(response.results.first?.title, "Cached Now Playing") + expectation.fulfill() + } + waitForExpectations(timeout: 1.0) + } + + func test_getNowPlayingMovies_networkFailure_noCache_returnsError() { + mockAPIClient.paginatedMovieResponseResult = .failure(APIError.invalidResponse(statusCode: 500)) + mockAPIClient.buildRequestReturnValue = URLRequest(url: URL(string: "https://dummy.url/now_playing")!) + + let expectation = self.expectation(description: "Network failure no cache returns error") + movieRepository.getNowPlayingMovies(page: 1) { result in + guard case .failure(let error) = result else { XCTFail("Expected failure"); expectation.fulfill(); return } + XCTAssertEqual(error as? APIError, APIError.invalidResponse(statusCode: 500)) + expectation.fulfill() + } + waitForExpectations(timeout: 1.0) + } + + func test_getNowPlayingMovies_success_overwritesOldCache() { + let oldCachedData = samplePaginatedResponse(page: 1, movies: [sampleMovie(id: 30, title: "Old Now Playing")]) + cacheDirectly(oldCachedData, forKey: MovieRepositoryTests.nowPlayingCacheKey) + + let newNetworkData = samplePaginatedResponse(page: 1, movies: [sampleMovie(id: 40, title: "New Now Playing")]) + mockAPIClient.paginatedMovieResponseResult = .success(newNetworkData) + mockAPIClient.buildRequestReturnValue = URLRequest(url: URL(string: "https://dummy.url/now_playing")!) + + let expectation = self.expectation(description: "Network success overwrites old cache") + movieRepository.getNowPlayingMovies(page: 1) { result in + guard case .success(let response) = result else { XCTFail("Expected success"); expectation.fulfill(); return } + XCTAssertEqual(response.results.first?.title, "New Now Playing") + + let currentCached: PaginatedResponse? = self.loadDirectlyFromCache(forKey: MovieRepositoryTests.nowPlayingCacheKey) + XCTAssertEqual(currentCached?.results.first?.title, "New Now Playing") + expectation.fulfill() + } + waitForExpectations(timeout: 1.0) + } + + // MARK: - getPopularMovies Tests (Caching tests - assuming original tests are similar and can be omitted for brevity here if needed) + + func test_getPopularMovies_success_servesNetworkDataAndCachesIt() { + let networkData = samplePaginatedResponse(page: 1, movies: [sampleMovie(id: 11, title: "Network Popular")]) + mockAPIClient.paginatedMovieResponseResult = .success(networkData) + mockAPIClient.buildRequestReturnValue = URLRequest(url: URL(string: "https://dummy.url/popular")!) + + let expectation = self.expectation(description: "Popular: Network success and caches data") + movieRepository.getPopularMovies(page: 1) { result in + guard case .success(let response) = result else { XCTFail("Expected success"); expectation.fulfill(); return } + XCTAssertEqual(response.results.first?.title, "Network Popular") + let cached: PaginatedResponse? = self.loadDirectlyFromCache(forKey: MovieRepositoryTests.popularCacheKey) + XCTAssertEqual(cached?.results.first?.title, "Network Popular") + expectation.fulfill() + } + waitForExpectations(timeout: 1.0) + } + + func test_getPopularMovies_networkFailure_servesCachedDataIfExists() { + let cachedData = samplePaginatedResponse(page: 1, movies: [sampleMovie(id: 21, title: "Cached Popular")]) + cacheDirectly(cachedData, forKey: MovieRepositoryTests.popularCacheKey) + + mockAPIClient.paginatedMovieResponseResult = .failure(APIError.networkError(NSError(domain: "Test", code: 0))) + mockAPIClient.buildRequestReturnValue = URLRequest(url: URL(string: "https://dummy.url/popular")!) + + let expectation = self.expectation(description: "Popular: Network failure serves cached data") + movieRepository.getPopularMovies(page: 1) { result in + guard case .success(let response) = result else { XCTFail("Expected success from cache"); expectation.fulfill(); return } + XCTAssertEqual(response.results.first?.title, "Cached Popular") + expectation.fulfill() + } + waitForExpectations(timeout: 1.0) + } + + // MARK: - getUpcomingMovies Tests (Caching tests) + + func test_getUpcomingMovies_success_servesNetworkDataAndCachesIt() { + let networkData = samplePaginatedResponse(page: 1, movies: [sampleMovie(id: 12, title: "Network Upcoming")]) + mockAPIClient.paginatedMovieResponseResult = .success(networkData) + mockAPIClient.buildRequestReturnValue = URLRequest(url: URL(string: "https://dummy.url/upcoming")!) + + let expectation = self.expectation(description: "Upcoming: Network success and caches data") + movieRepository.getUpcomingMovies(page: 1) { result in + guard case .success(let response) = result else { XCTFail("Expected success"); expectation.fulfill(); return } + XCTAssertEqual(response.results.first?.title, "Network Upcoming") + let cached: PaginatedResponse? = self.loadDirectlyFromCache(forKey: MovieRepositoryTests.upcomingCacheKey) + XCTAssertEqual(cached?.results.first?.title, "Network Upcoming") + expectation.fulfill() + } + waitForExpectations(timeout: 1.0) + } + + func test_getUpcomingMovies_networkFailure_servesCachedDataIfExists() { + let cachedData = samplePaginatedResponse(page: 1, movies: [sampleMovie(id: 22, title: "Cached Upcoming")]) + cacheDirectly(cachedData, forKey: MovieRepositoryTests.upcomingCacheKey) + + mockAPIClient.paginatedMovieResponseResult = .failure(APIError.networkError(NSError(domain: "Test", code: 0))) + mockAPIClient.buildRequestReturnValue = URLRequest(url: URL(string: "https://dummy.url/upcoming")!) + + let expectation = self.expectation(description: "Upcoming: Network failure serves cached data") + movieRepository.getUpcomingMovies(page: 1) { result in + guard case .success(let response) = result else { XCTFail("Expected success from cache"); expectation.fulfill(); return } + XCTAssertEqual(response.results.first?.title, "Cached Upcoming") + expectation.fulfill() + } + waitForExpectations(timeout: 1.0) + } + + // MARK: - getMovieDetails Tests (Caching tests) + + func test_getMovieDetails_success_servesNetworkDataAndCachesIt() { + let movieId = 101 + let networkMovie = sampleMovie(id: movieId, title: "Network Detail Movie") + mockAPIClient.movieDetailResult = .success(networkMovie) + mockAPIClient.buildRequestReturnValue = URLRequest(url: URL(string: "https://dummy.url/movie/\(movieId)")!) + + let expectation = self.expectation(description: "Detail: Network success and caches data") + movieRepository.getMovieDetails(id: movieId) { result in + guard case .success(let movie) = result else { XCTFail("Expected success"); expectation.fulfill(); return } + XCTAssertEqual(movie.title, "Network Detail Movie") + + let cached: Movie? = self.loadDirectlyFromCache(forKey: MovieRepositoryTests.movieDetailCacheKey(id: movieId)) + XCTAssertEqual(cached?.title, "Network Detail Movie") + expectation.fulfill() + } + waitForExpectations(timeout: 1.0) + } + + func test_getMovieDetails_networkFailure_servesCachedDataIfExists() { + let movieId = 102 + let cachedMovie = sampleMovie(id: movieId, title: "Cached Detail Movie") + cacheDirectly(cachedMovie, forKey: MovieRepositoryTests.movieDetailCacheKey(id: movieId)) + + mockAPIClient.movieDetailResult = .failure(APIError.networkError(NSError(domain: "Test", code: 0))) + mockAPIClient.buildRequestReturnValue = URLRequest(url: URL(string: "https://dummy.url/movie/\(movieId)")!) + + let expectation = self.expectation(description: "Detail: Network failure serves cached data") + movieRepository.getMovieDetails(id: movieId) { result in + guard case .success(let movie) = result else { XCTFail("Expected success from cache"); expectation.fulfill(); return } + XCTAssertEqual(movie.title, "Cached Detail Movie") + expectation.fulfill() + } + waitForExpectations(timeout: 1.0) + } + + func test_getMovieDetails_networkFailure_noCache_returnsError() { + let movieId = 103 + mockAPIClient.movieDetailResult = .failure(APIError.invalidResponse(statusCode: 404)) + mockAPIClient.buildRequestReturnValue = URLRequest(url: URL(string: "https://dummy.url/movie/\(movieId)")!) + + let expectation = self.expectation(description: "Detail: Network failure no cache returns error") + movieRepository.getMovieDetails(id: movieId) { result in + guard case .failure(let error) = result else { XCTFail("Expected failure"); expectation.fulfill(); return } + XCTAssertEqual(error as? APIError, APIError.invalidResponse(statusCode: 404)) + expectation.fulfill() + } + waitForExpectations(timeout: 1.0) + } + + func test_getMovieDetails_success_overwritesOldCache() { + let movieId = 104 + let oldCachedMovie = sampleMovie(id: movieId, title: "Old Detail Movie") + cacheDirectly(oldCachedMovie, forKey: MovieRepositoryTests.movieDetailCacheKey(id: movieId)) + + let newNetworkMovie = sampleMovie(id: movieId, title: "New Detail Movie") + mockAPIClient.movieDetailResult = .success(newNetworkMovie) + mockAPIClient.buildRequestReturnValue = URLRequest(url: URL(string: "https://dummy.url/movie/\(movieId)")!) + + let expectation = self.expectation(description: "Detail: Network success overwrites old cache") + movieRepository.getMovieDetails(id: movieId) { result in + guard case .success(let movie) = result else { XCTFail("Expected success"); expectation.fulfill(); return } + XCTAssertEqual(movie.title, "New Detail Movie") + + let currentCached: Movie? = self.loadDirectlyFromCache(forKey: MovieRepositoryTests.movieDetailCacheKey(id: movieId)) + XCTAssertEqual(currentCached?.title, "New Detail Movie") + expectation.fulfill() + } + waitForExpectations(timeout: 1.0) + } +}