diff --git a/DM.MovieApi.IntegrationTests/ApiResponseUtil.cs b/DM.MovieApi.IntegrationTests/ApiResponseUtil.cs index 5f480c7..c66863b 100644 --- a/DM.MovieApi.IntegrationTests/ApiResponseUtil.cs +++ b/DM.MovieApi.IntegrationTests/ApiResponseUtil.cs @@ -17,6 +17,7 @@ internal static class ApiResponseUtil { internal const int TestInitThrottle = 375; internal const int PagingThrottle = 225; + private static readonly DateTime MinDate = new( 1900, 1, 1 ); /// /// Slows down the starting of tests to keep themoviedb.org api from denying the request @@ -29,12 +30,14 @@ public static void ThrottleTests() public static void AssertErrorIsNull( ApiResponseBase response ) { - Console.WriteLine( response.CommandText ); + Log( response.CommandText ); Assert.IsNull( response.Error, response.Error?.ToString() ?? "Makes Compiler Happy" ); } public static void AssertImagePath( string path ) { + if( path == null ) return; + Assert.IsTrue( path.StartsWith( "/" ), $"Actual: {path}" ); Assert.IsTrue( @@ -42,22 +45,35 @@ public static void AssertImagePath( string path ) $"Actual: {path}" ); } - public static async Task AssertCanPageSearchResponse( TSearch search, int minimumPageCount, int minimumTotalResultsCount, - Func>> apiSearch, Func keySelector ) + public static void AssertNoSearchResults( ApiSearchResponse response ) + { + AssertErrorIsNull( response ); + + Assert.AreEqual( 0, response.Results.Count, $"Actual: {response}" ); + Assert.AreEqual( 1, response.PageNumber, $"Actual: {response}" ); + Assert.AreEqual( 0, response.TotalPages, $"Actual: {response}" ); + Assert.AreEqual( 0, response.TotalResults, $"Actual: {response}" ); + } + + public static async Task AssertCanPageSearchResponse( + TSearch search, + int minimumPageCount, + Func>> apiSearch, + Func keySelector ) { if( minimumPageCount < 2 ) { Assert.Fail( "minimumPageCount must be greater than 1." ); } - var allFound = new List(); + var ids = new HashSet(); + var dups = new List(); + int totalResults = 0; int pageNumber = 1; - var priorResults = new Dictionary(); - do { - System.Diagnostics.Trace.WriteLine( $"search: {search} | page: {pageNumber}", "ApiResponseUti.AssertCanPageSearchResponse" ); + Log( $"search: {search} | page: {pageNumber}", "AssertCanPage" ); ApiSearchResponse response = await apiSearch( search, pageNumber ); AssertErrorIsNull( response ); @@ -66,41 +82,28 @@ public static async Task AssertCanPageSearchResponse( TSearch search if( typeof( T ) == typeof( Movie ) ) { - AssertMovieStructure( ( IEnumerable )response.Results ); + AssertMovieStructure( (IEnumerable)response.Results ); } else if( typeof( T ) == typeof( PersonInfo ) ) { - AssertPersonInfoStructure( ( IEnumerable )response.Results ); + AssertPersonInfoStructure( (IEnumerable)response.Results ); } if( keySelector == null ) { - allFound.AddRange( response.Results ); + totalResults += response.Results.Count; } else { - var current = new List(); foreach( T res in response.Results ) { + totalResults++; int key = keySelector( res ); - - if( priorResults.TryAdd( key, 1 ) ) - { - current.Add( res ); - continue; - } - - System.Diagnostics.Trace.WriteLine( $"dup on page {response.PageNumber}: {res}" ); - - if( ++priorResults[key] > 2 ) + if( ids.Add( key ) == false ) { - Assert.Fail( "Every now and then themoviedb.org API returns a duplicate from a prior page. " + - "But this time it exceeded our tolerance of one dup.\r\n" + - $"dup: {res}" ); + dups.Add( res.ToString() ); } } - - allFound.AddRange( current ); } Assert.AreEqual( pageNumber, response.PageNumber ); @@ -108,16 +111,17 @@ public static async Task AssertCanPageSearchResponse( TSearch search Assert.IsTrue( response.TotalPages >= minimumPageCount, $"Expected minimum of {minimumPageCount} TotalPages. Actual TotalPages: {response.TotalPages}" ); - pageNumber++; - // keeps the system from being throttled System.Threading.Thread.Sleep( PagingThrottle ); - } while( pageNumber <= minimumPageCount ); + } while( ++pageNumber <= minimumPageCount ); // will be 1 greater than minimumPageCount in the last loop - Assert.AreEqual( minimumPageCount + 1, pageNumber ); + Assert.AreEqual( minimumPageCount, --pageNumber ); - Assert.IsTrue( allFound.Count >= minimumTotalResultsCount, $"Actual found count: {allFound.Count} | Expected min count: {minimumTotalResultsCount}" ); + // 20 results per page + int minCount = pageNumber * 20; + Assert.IsTrue( totalResults >= minCount, + $"Actual results: {totalResults} | Expected min: {minCount}" ); if( keySelector == null ) { @@ -126,16 +130,21 @@ public static async Task AssertCanPageSearchResponse( TSearch search return; } - List> groupById = allFound - .ToLookup( keySelector ) - .ToList(); - - List dups = groupById - .Where( x => x.Skip( 1 ).Any() ) - .Select( x => $"({x.Count()}) {string.Join( " | ", x.Select( y => y.ToString() ) )}" ) - .ToList(); + // api tends to return duplicate results when paging + // shouldn't be more than 2 or 3 per page; at 20 per page, + // that's approximately 4-6; let's target 20% + int min = (int)(totalResults * 0.8); + var d = dups + .GroupBy( x => x ) + .Select( x => $"{x.Key} (x {x.Count()})" ); + Log( $"Results: {totalResults}, Dups: {dups.Count}\r\n{string.Join( "\r\n", d )}" ); - Assert.AreEqual( 0, dups.Count, "Duplicates: " + Environment.NewLine + string.Join( Environment.NewLine, dups ) ); + if( min >= ids.Count ) + { + Assert.Fail( "Every now and then themoviedb.org API returns a duplicate from a prior page. " + + "But this time it exceeded our tolerance of 20% dups.\r\n" + + $"Actual: {ids.Count} vs {min}" ); + } } private static void AssertPersonInfoStructure( IEnumerable people ) @@ -232,9 +241,15 @@ public static void AssertMovieInformationStructure( IEnumerable movie public static void AssertMovieInformationStructure( MovieInfo movie ) { - Assert.IsFalse( string.IsNullOrWhiteSpace( movie.Title ) ); - Assert.IsTrue( movie.Id > 0 ); + Assert.IsFalse( string.IsNullOrWhiteSpace( movie.Title ), $"Actual: {movie}" ); + Assert.IsFalse( string.IsNullOrWhiteSpace( movie.OriginalTitle ), $"Actual {movie}" ); + // movie.Overview is sometimes empty + + Assert.IsTrue( movie.Id > 1 ); + Assert.IsTrue( movie.ReleaseDate > MinDate, $"Actual: {movie.ReleaseDate} | {movie}" ); + AssertImagePath( movie.BackdropPath ); + AssertImagePath( movie.PosterPath ); AssertGenres( movie.GenreIds, movie.Genres ); } @@ -333,10 +348,10 @@ private static void AssertTvShowGuestStarsStructure( GuestStars guestStars ) Assert.IsFalse( string.IsNullOrWhiteSpace( guestStars.OriginalName ) ); Assert.IsTrue( guestStars.Popularity > 0 ); - if( guestStars.ProfilePath != null ) - { - AssertImagePath( guestStars.ProfilePath ); - } + AssertImagePath( guestStars.ProfilePath ); } + + private static void Log( string msg, string category = null ) + => System.Diagnostics.Trace.WriteLine( msg, category ); } } diff --git a/DM.MovieApi.IntegrationTests/MovieDb/Companies/ApiCompanyRequestTests.cs b/DM.MovieApi.IntegrationTests/MovieDb/Companies/ApiCompanyRequestTests.cs index c72cbc7..78edc3a 100644 --- a/DM.MovieApi.IntegrationTests/MovieDb/Companies/ApiCompanyRequestTests.cs +++ b/DM.MovieApi.IntegrationTests/MovieDb/Companies/ApiCompanyRequestTests.cs @@ -68,9 +68,8 @@ public async Task GetMoviesAsync_CanPageResults() { const int companyId = 3; const int minimumPageCount = 5; - const int minimumTotalResultsCount = 95; - await ApiResponseUtil.AssertCanPageSearchResponse( companyId, minimumPageCount, minimumTotalResultsCount, + await ApiResponseUtil.AssertCanPageSearchResponse( companyId, minimumPageCount, ( id, pageNumber ) => _api.GetMoviesAsync( id, pageNumber ), x => x.Id ); } } diff --git a/DM.MovieApi.IntegrationTests/MovieDb/Discover/ApiDiscoverRequestTests.cs b/DM.MovieApi.IntegrationTests/MovieDb/Discover/ApiDiscoverRequestTests.cs new file mode 100644 index 0000000..9db14ab --- /dev/null +++ b/DM.MovieApi.IntegrationTests/MovieDb/Discover/ApiDiscoverRequestTests.cs @@ -0,0 +1,146 @@ +using System.Linq; +using System.Threading.Tasks; +using DM.MovieApi.ApiResponse; +using DM.MovieApi.MovieDb.Discover; +using DM.MovieApi.MovieDb.Movies; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DM.MovieApi.IntegrationTests.MovieDb.Discover +{ + [TestClass] + public class ApiDiscoverRequestTests + { + private IApiDiscoverRequest _api; + + [TestInitialize] + public void TestInit() + { + ApiResponseUtil.ThrottleTests(); + + _api = MovieDbFactory.Create().Value; + + Assert.IsInstanceOfType( _api, typeof( ApiDiscoverRequest ) ); + } + + [TestMethod] + public async Task DiscoverMovies_WithCrew() + { + int directorId = 66212; + + IDiscoverMovieParameterBuilder builder = CreateBuilder(); + builder.WithCrew( directorId ); + + ApiSearchResponse response = await _api.DiscoverMoviesAsync( builder ); + + ApiResponseUtil.AssertErrorIsNull( response ); + ApiResponseUtil.AssertMovieInformationStructure( response.Results ); + } + + [TestMethod] + public async Task DiscoverMovies_WithCrew_HasNoResult_InvalidPersonId() + { + int personId = 0; + + IDiscoverMovieParameterBuilder builder = CreateBuilder(); + builder.WithCrew( personId ); + + ApiSearchResponse response = await _api.DiscoverMoviesAsync( builder ); + + ApiResponseUtil.AssertNoSearchResults( response ); + } + + [TestMethod] + public async Task DiscoverMovies_WithCast() + { + int actorId = 66462; + + IDiscoverMovieParameterBuilder builder = CreateBuilder(); + builder.WithCast( actorId ); + + ApiSearchResponse response = await _api.DiscoverMoviesAsync( builder ); + + ApiResponseUtil.AssertErrorIsNull( response ); + ApiResponseUtil.AssertMovieInformationStructure( response.Results ); + } + + [TestMethod] + public async Task DiscoverMovies_WithCast_HasNoResult_InvalidPersonId() + { + int personId = 0; + + IDiscoverMovieParameterBuilder builder = CreateBuilder(); + builder.WithCast( personId ); + + ApiSearchResponse response = await _api.DiscoverMoviesAsync( builder ); + + ApiResponseUtil.AssertNoSearchResults( response ); + } + + [TestMethod] + public async Task DiscoverMovies_WithGenre() + { + int genreId = 28; + + IDiscoverMovieParameterBuilder builder = CreateBuilder(); + builder.WithGenre( genreId ); + + ApiSearchResponse response = await _api.DiscoverMoviesAsync( builder ); + + ApiResponseUtil.AssertErrorIsNull( response ); + ApiResponseUtil.AssertMovieInformationStructure( response.Results ); + + Assert.IsTrue( response.Results + .All( r => r.Genres.Any( g => g.Id == genreId ) ), "No results with genre" ); + } + + [TestMethod] + public async Task DiscoverMovies_ExcludeGenre() + { + int genreId = 28; + + IDiscoverMovieParameterBuilder builder = CreateBuilder(); + builder.ExcludeGenre( genreId ); + + ApiSearchResponse response = await _api.DiscoverMoviesAsync( builder ); + + ApiResponseUtil.AssertErrorIsNull( response ); + ApiResponseUtil.AssertMovieInformationStructure( response.Results ); + + Assert.IsTrue( response.Results + .All( r => r.Genres.All( g => g.Id != genreId ) ), "Genre found in results" ); + } + + [TestMethod] + public async Task DiscoverMovies_WithOriginalLanguage_InFinnish() + { + int directorId = 66212; + string originalLanguage = "fi"; + + IDiscoverMovieParameterBuilder builder = CreateBuilder(); + builder.WithOriginalLanguage( originalLanguage ).WithCrew( directorId ); + + ApiSearchResponse response = await _api.DiscoverMoviesAsync( builder ); + + ApiResponseUtil.AssertErrorIsNull( response ); + ApiResponseUtil.AssertMovieInformationStructure( response.Results ); + } + + [TestMethod] + public async Task DiscoverMovies_WithOriginalLanguage_InGerman() + { + int directorId = 66212; + string originalLanguage = "de"; + + IDiscoverMovieParameterBuilder builder = CreateBuilder(); + builder.WithOriginalLanguage( originalLanguage ).WithCrew( directorId ); + + ApiSearchResponse response = await _api.DiscoverMoviesAsync( builder ); + + ApiResponseUtil.AssertErrorIsNull( response ); + ApiResponseUtil.AssertMovieInformationStructure( response.Results ); + } + + private IDiscoverMovieParameterBuilder CreateBuilder() + => new DiscoverMovieParameterBuilder(); + } +} diff --git a/DM.MovieApi.IntegrationTests/MovieDb/Genres/ApiGenreRequestTests.cs b/DM.MovieApi.IntegrationTests/MovieDb/Genres/ApiGenreRequestTests.cs index fd8cfb0..d406f2b 100644 --- a/DM.MovieApi.IntegrationTests/MovieDb/Genres/ApiGenreRequestTests.cs +++ b/DM.MovieApi.IntegrationTests/MovieDb/Genres/ApiGenreRequestTests.cs @@ -31,7 +31,7 @@ public async Task FindById_Foreign_Genre_NoLongerExists() const int id = 10769; const string name = "Foreign"; - CollectionAssert.DoesNotContain( _api.AllGenres.ToList(), new Genre( id, name ) ); + CollectionAssert.DoesNotContain( _api.AllGenres.ToArray(), new Genre( id, name ) ); // FindById will add to AllGenres when it does not exist ApiQueryResponse response = await _api.FindByIdAsync( id ); @@ -41,7 +41,7 @@ public async Task FindById_Foreign_Genre_NoLongerExists() // the genre should not have been added to AllGenres since TMDB no longer recognizes // the foreign genre category. - CollectionAssert.DoesNotContain( _api.AllGenres.ToList(), new Genre( id, name ) ); + CollectionAssert.DoesNotContain( _api.AllGenres.ToArray(), new Genre( id, name ) ); } [TestMethod] @@ -78,7 +78,7 @@ public async Task GetAllAsync_Returns_Known_Genres() IReadOnlyList knownGenres = GenreFactory.GetAll(); - CollectionAssert.AreEquivalent( knownGenres.ToList(), response.Item.ToList() ); + CollectionAssert.AreEquivalent( knownGenres.ToArray(), response.Item.ToArray() ); } [TestMethod] @@ -118,7 +118,7 @@ public async Task GetMoviesAsync_IsSubset_OfGetAll() Assert.IsTrue( all.Item.Count > movies.Item.Count ); - CollectionAssert.IsSubsetOf( movies.Item.ToList(), all.Item.ToList() ); + CollectionAssert.IsSubsetOf( movies.Item.ToArray(), all.Item.ToArray() ); } [TestMethod] @@ -134,7 +134,7 @@ public async Task GetTelevisionAsync_IsSubset_OfGetAll() Assert.IsTrue( all.Item.Count > tv.Item.Count ); - CollectionAssert.IsSubsetOf( tv.Item.ToList(), all.Item.ToList() ); + CollectionAssert.IsSubsetOf( tv.Item.ToArray(), all.Item.ToArray() ); } [TestMethod] @@ -152,7 +152,7 @@ public async Task FindMoviesByIdAsync_Returns_ValidResult() foreach( MovieInfo info in response.Results ) { - CollectionAssert.IsSubsetOf( expectedGenres, info.Genres.ToList() ); + CollectionAssert.IsSubsetOf( expectedGenres, info.Genres.ToArray() ); } } @@ -161,10 +161,9 @@ public async Task FindMoviesByIdAsync_CanPageResults() { int genreId = GenreFactory.Comedy().Id; // Comedy has upwards of 2k pages. - const int minimumPageCount = 5; - const int minimumTotalResultsCount = 100; // 20 results per page x 5 pages = 100 + const int minimumPageCount = 10; - await ApiResponseUtil.AssertCanPageSearchResponse( genreId, minimumPageCount, minimumTotalResultsCount, + await ApiResponseUtil.AssertCanPageSearchResponse( genreId, minimumPageCount, ( id, page ) => _api.FindMoviesByIdAsync( id, page ), x => x.Id ); } } diff --git a/DM.MovieApi.IntegrationTests/MovieDb/Movies/ApiMovieRequestTests.cs b/DM.MovieApi.IntegrationTests/MovieDb/Movies/ApiMovieRequestTests.cs index 19e0a7e..1731d93 100644 --- a/DM.MovieApi.IntegrationTests/MovieDb/Movies/ApiMovieRequestTests.cs +++ b/DM.MovieApi.IntegrationTests/MovieDb/Movies/ApiMovieRequestTests.cs @@ -108,7 +108,7 @@ private void AssertRunLolaRun( ApiSearchResponse response, string exp Assert.AreEqual( new DateTime( 1998, 03, 03 ), movie.ReleaseDate ); - var expectedGenres = new List + var expectedGenres = new[] { GenreFactory.Action(), GenreFactory.Drama(), @@ -122,9 +122,8 @@ public async Task SearchByTitleAsync_CanPageResults() { const string query = "Harry"; const int minimumPageCount = 8; - const int minimumTotalResultsCount = 150; - await ApiResponseUtil.AssertCanPageSearchResponse( query, minimumPageCount, minimumTotalResultsCount, + await ApiResponseUtil.AssertCanPageSearchResponse( query, minimumPageCount, ( search, pageNumber ) => _api.SearchByTitleAsync( search, pageNumber ), x => x.Id ); } @@ -202,7 +201,7 @@ public async Task FindByIdAsync_StarWarsTheForceAwakens_Returns_AllValues() ApiResponseUtil.AssertImagePath( movie.MovieCollectionInfo.PosterPath ); // Genres - var expectedGenres = new List + var expectedGenres = new[] { GenreFactory.Action(), GenreFactory.Adventure(), @@ -213,15 +212,15 @@ public async Task FindByIdAsync_StarWarsTheForceAwakens_Returns_AllValues() "actual:\r\n" + string.Join( "\r\n", movie.Genres ) ); // Keywords - var expectedKeywords = new List + var expectedKeywords = new Keyword[] { new(803, "android"), new(1612, "spacecraft"), new(161176, "space opera") }; CollectionAssert.AreEquivalent( expectedKeywords, movie.Keywords.ToArray(), - $"\r\nactual:\r\n\t{string.Join( "\r\n\t", expectedKeywords )}" + - $"\r\nactual:\r\n\t{string.Join( "\r\n\t", movie.Keywords )}" ); + $"\r\nactual:\r\n\t{string.Join( "\r\n\t", expectedKeywords.Select( x => x.ToString() ) )}" + + $"\r\nactual:\r\n\t{string.Join( "\r\n\t", movie.Keywords.Select( x => x.ToString() ) )}" ); } [TestMethod] @@ -279,9 +278,8 @@ public async Task GetNowPlayingAsync_CanPageResults() { // Now Playing typically has 25+ pages. const int minimumPageCount = 5; - const int minimumTotalResultsCount = 100; // 20 results per page x 5 pages = 100 - await ApiResponseUtil.AssertCanPageSearchResponse( "unused", minimumPageCount, minimumTotalResultsCount, + await ApiResponseUtil.AssertCanPageSearchResponse( "unused", minimumPageCount, ( _, page ) => _api.GetNowPlayingAsync( page ), x => x.Id ); } @@ -299,11 +297,10 @@ public async Task GetUpcomingAsync_Returns_ValidResults() public async Task GetUpcomingAsync_CanPageResults() { // Now Playing typically has 5+ pages. - // - intentionally setting minimumTotalResultsCount at 50; sometimes upcoming movies are scarce. + // note: sometimes upcoming movies are scarce and may occasionally fail. const int minimumPageCount = 3; - const int minimumTotalResultsCount = 50; // 20 results per page x 3 pages = 60 - await ApiResponseUtil.AssertCanPageSearchResponse( "unused", minimumPageCount, minimumTotalResultsCount, + await ApiResponseUtil.AssertCanPageSearchResponse( "unused", minimumPageCount, ( _, page ) => _api.GetUpcomingAsync( page ), x => x.Id ); } @@ -323,9 +320,8 @@ public async Task GetTopRatedAsync_Returns_ValidResults() public async Task GetTopRatedAsync_CanPageResults() { const int minimumPageCount = 2; - const int minimumTotalResultsCount = 40; - await ApiResponseUtil.AssertCanPageSearchResponse( "unused", minimumPageCount, minimumTotalResultsCount, + await ApiResponseUtil.AssertCanPageSearchResponse( "unused", minimumPageCount, ( _, page ) => _api.GetTopRatedAsync( page ), x => x.Id ); } @@ -345,9 +341,8 @@ public async Task GetPopularAsync_Returns_ValidResults() public async Task GetPopularAsync_CanPageResults() { const int minimumPageCount = 2; - const int minimumTotalResultsCount = 40; - await ApiResponseUtil.AssertCanPageSearchResponse( "unused", minimumPageCount, minimumTotalResultsCount, + await ApiResponseUtil.AssertCanPageSearchResponse( "unused", minimumPageCount, ( _, page ) => _api.GetPopularAsync( page ), x => x.Id ); } } diff --git a/DM.MovieApi.IntegrationTests/MovieDb/Movies/ApiMovieRequestTests_GetCredits.cs b/DM.MovieApi.IntegrationTests/MovieDb/Movies/ApiMovieRequestTests_GetCredits.cs index 4fc4ec4..f9d11f0 100644 --- a/DM.MovieApi.IntegrationTests/MovieDb/Movies/ApiMovieRequestTests_GetCredits.cs +++ b/DM.MovieApi.IntegrationTests/MovieDb/Movies/ApiMovieRequestTests_GetCredits.cs @@ -1,4 +1,4 @@ -using System.Linq; +using System.Linq; using System.Threading.Tasks; using DM.MovieApi.ApiResponse; using DM.MovieApi.MovieDb.Movies; diff --git a/DM.MovieApi.IntegrationTests/MovieDb/Movies/ApiMovieRequestTests_GetRecommendations.cs b/DM.MovieApi.IntegrationTests/MovieDb/Movies/ApiMovieRequestTests_GetRecommendations.cs new file mode 100644 index 0000000..f995741 --- /dev/null +++ b/DM.MovieApi.IntegrationTests/MovieDb/Movies/ApiMovieRequestTests_GetRecommendations.cs @@ -0,0 +1,45 @@ +using System.Threading.Tasks; +using DM.MovieApi.ApiResponse; +using DM.MovieApi.MovieDb.Movies; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DM.MovieApi.IntegrationTests.MovieDb.Movies +{ + [TestClass] + public class GetRecommendationsTests + { + private IApiMovieRequest _api; + + [TestInitialize] + public void TestInit() + { + ApiResponseUtil.ThrottleTests(); + + _api = MovieDbFactory.Create().Value; + } + + [TestMethod] + public async Task GetRecommendationsAsync_Returns_ValidResults() + { + const int movieIdRunLolaRun = 104; + + ApiSearchResponse response = await _api.GetRecommendationsAsync( movieIdRunLolaRun ); + + ApiResponseUtil.AssertErrorIsNull( response ); + ApiResponseUtil.AssertMovieInformationStructure( response.Results ); + + Assert.IsTrue( response.TotalPages > 1 ); + Assert.IsTrue( response.TotalResults > 20 ); + Assert.AreEqual( 1, response.PageNumber ); + } + + [TestMethod] + public async Task GetRecommendationsAsync_HasError_InvalidMovieId() + { + const int movieId = 1; + + ApiSearchResponse response = await _api.GetRecommendationsAsync( movieId ); + Assert.IsNotNull( response.Error ); + } + } +} diff --git a/DM.MovieApi.IntegrationTests/MovieDb/Movies/ApiMovieRequestTests_GetSimilar.cs b/DM.MovieApi.IntegrationTests/MovieDb/Movies/ApiMovieRequestTests_GetSimilar.cs new file mode 100644 index 0000000..dd9e7cb --- /dev/null +++ b/DM.MovieApi.IntegrationTests/MovieDb/Movies/ApiMovieRequestTests_GetSimilar.cs @@ -0,0 +1,98 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using DM.MovieApi.ApiResponse; +using DM.MovieApi.MovieDb.Movies; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DM.MovieApi.IntegrationTests.MovieDb.Movies +{ + [TestClass] + public class GetSimilarTests + { + private IApiMovieRequest _api; + + [TestInitialize] + public void TestInit() + { + ApiResponseUtil.ThrottleTests(); + + _api = MovieDbFactory.Create().Value; + } + + [TestMethod] + public async Task GetSimilarAsync_Returns_ValidResults() + { + const int movieIdRunLolaRun = 104; + + ApiSearchResponse response = await _api.GetSimilarAsync( movieIdRunLolaRun ); + + ApiResponseUtil.AssertErrorIsNull( response ); + ApiResponseUtil.AssertMovieInformationStructure( response.Results ); + + // get similar will return the max number of results + Assert.AreEqual( 20, response.Results.Count ); + Assert.AreEqual( 500, response.TotalPages ); + Assert.AreEqual( 10000, response.TotalResults ); + Assert.AreEqual( 1, response.PageNumber ); + } + + [TestMethod] + public async Task GetSimilarAsync_CanPage() + { + const int movieIdRunLolaRun = 104; + + // todo: move to ApiResponseUtil and refactor w/ existing AssertCanPageSearchResponse + // * each component may be able to become re-usable methods + + int num = 0; + var ids = new HashSet(); + var dups = new List(); + + for( int i = 1; i <= 10; i++ ) + { + int pageNumber = i; + ApiSearchResponse response = await _api.GetSimilarAsync( movieIdRunLolaRun, pageNumber ); + + ApiResponseUtil.AssertErrorIsNull( response ); + ApiResponseUtil.AssertMovieInformationStructure( response.Results ); + + // get similar will return the max number of results + Assert.AreEqual( 20, response.Results.Count ); + Assert.AreEqual( 500, response.TotalPages ); + Assert.AreEqual( 10000, response.TotalResults ); + Assert.AreEqual( pageNumber, response.PageNumber ); + + foreach( MovieInfo m in response.Results ) + { + num++; + if( ids.Add( m.Id ) == false ) + { + dups.Add( m.ToString() ); + } + } + } + + // api tends to return duplicate results when paging + // shouldn't be more than 2 or 3 per page; at 20 per page, + // that's approximately 4-6; let's target 25% + int min = (int)(num * 0.75); + var d = dups + .GroupBy( x => x ) + .Select( x => $"{x.Key} (x {x.Count()})" ); + System.Diagnostics.Trace.WriteLine( $"Results: {num}, Dups: {dups.Count}" + + $"\r\n{string.Join( "\r\n", d )}" ); + + Assert.IsTrue( ids.Count >= min, $"Total: {num}.\r\nUnique: {ids.Count}, Dup Threshold {min}" ); + } + + [TestMethod] + public async Task GetSimilarAsync_HasError_InvalidMovieId() + { + const int movieId = 1; + + ApiSearchResponse response = await _api.GetSimilarAsync( movieId ); + Assert.IsNotNull( response.Error ); + } + } +} diff --git a/DM.MovieApi.IntegrationTests/MovieDb/People/ApiPeopleRequestTests.cs b/DM.MovieApi.IntegrationTests/MovieDb/People/ApiPeopleRequestTests.cs index 3041b6a..4632a53 100644 --- a/DM.MovieApi.IntegrationTests/MovieDb/People/ApiPeopleRequestTests.cs +++ b/DM.MovieApi.IntegrationTests/MovieDb/People/ApiPeopleRequestTests.cs @@ -310,7 +310,7 @@ public async Task SearchByNameAsync_Milla_Jovovich_Returns_SingleResult_WithExpe { "The Fifth Element", "Resident Evil", - "Zoolander" + "Resident Evil: Apocalypse" }; foreach( string role in roles ) @@ -326,9 +326,8 @@ public async Task SearchByNameAsync_CanPageResults() { const string query = "Cox"; const int minimumPageCount = 15; - const int minimumTotalResultsCount = 300; - await ApiResponseUtil.AssertCanPageSearchResponse( query, minimumPageCount, minimumTotalResultsCount, + await ApiResponseUtil.AssertCanPageSearchResponse( query, minimumPageCount, ( search, pageNumber ) => _api.SearchByNameAsync( search, pageNumber ), null ); } } diff --git a/DM.MovieApi.IntegrationTests/MovieDb/TV/ApiTVShowRequestTests.cs b/DM.MovieApi.IntegrationTests/MovieDb/TV/ApiTVShowRequestTests.cs index f192756..4a5f395 100644 --- a/DM.MovieApi.IntegrationTests/MovieDb/TV/ApiTVShowRequestTests.cs +++ b/DM.MovieApi.IntegrationTests/MovieDb/TV/ApiTVShowRequestTests.cs @@ -100,9 +100,8 @@ public async Task SearchByNameAsync_CanPageResults() { const string query = "full"; const int minimumPageCount = 4; - const int minimumTotalResultsCount = 80; - await ApiResponseUtil.AssertCanPageSearchResponse( query, minimumPageCount, minimumTotalResultsCount, + await ApiResponseUtil.AssertCanPageSearchResponse( query, minimumPageCount, ( search, pageNumber ) => _api.SearchByNameAsync( search, pageNumber ), x => x.Id ); } @@ -224,9 +223,8 @@ public async Task GetTopRatedAsync_Returns_ValidResult() public async Task GetTopRatedAsync_CanPageResults() { const int minimumPageCount = 5; - const int minimumTotalResultsCount = 100; - await ApiResponseUtil.AssertCanPageSearchResponse( "none", minimumPageCount, minimumTotalResultsCount, + await ApiResponseUtil.AssertCanPageSearchResponse( "none", minimumPageCount, ( _, page ) => _api.GetTopRatedAsync( page ), x => x.Id ); } @@ -246,9 +244,8 @@ public async Task GetPopularAsync_Returns_ValidResult() public async Task GetPopularAsync_CanPageResults() { const int minimumPageCount = 5; - const int minimumTotalResultsCount = 100; - await ApiResponseUtil.AssertCanPageSearchResponse( "none", minimumPageCount, minimumTotalResultsCount, + await ApiResponseUtil.AssertCanPageSearchResponse( "none", minimumPageCount, ( _, page ) => _api.GetPopularAsync( page ), x => x.Id ); } diff --git a/DM.MovieApi.IntegrationTests/MovieDbFactoryTests.cs b/DM.MovieApi.IntegrationTests/MovieDbFactoryTests.cs index 27a4f67..22f4314 100644 --- a/DM.MovieApi.IntegrationTests/MovieDbFactoryTests.cs +++ b/DM.MovieApi.IntegrationTests/MovieDbFactoryTests.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; @@ -88,13 +87,13 @@ public void Create_Returns_Lazy_IApiMovieRequest() [TestMethod] public void GetAllApiRequests_CanCreate_IMovieApi() { - List dbApi = typeof( IMovieDbApi ) + PropertyInfo[] dbApi = typeof( IMovieDbApi ) .GetProperties() .Where( x => typeof( IApiRequest ).IsAssignableFrom( x.PropertyType ) ) .Distinct() - .ToList(); + .ToArray(); - Assert.AreEqual( 8, dbApi.Count ); + Assert.AreEqual( 9, dbApi.Length ); IMovieDbApi api; diff --git a/DM.MovieApi/ApiRequest/ApiRequestBase.cs b/DM.MovieApi/ApiRequest/ApiRequestBase.cs index 19ea1c8..1d4e6d0 100644 --- a/DM.MovieApi/ApiRequest/ApiRequestBase.cs +++ b/DM.MovieApi/ApiRequest/ApiRequestBase.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net; @@ -14,15 +14,7 @@ internal abstract class ApiRequestBase { private readonly IApiSettings _settings; - protected ApiRequestBase( IApiSettings settings ) - { - _settings = settings; - } - - public async Task> QueryAsync( string command ) - => await QueryAsync( command, new Dictionary() ); - - public async Task> QueryAsync( string command, IDictionary parameters ) + static ApiRequestBase() { var settings = new JsonSerializerSettings { @@ -31,17 +23,25 @@ public async Task> QueryAsync( string command, IDictionar }; settings.Converters.Add( new IsoDateTimeConverterEx() ); - return await QueryAsync( command, parameters, Deserializer ); - - T Deserializer( string json ) - => JsonConvert.DeserializeObject( json, settings ); + JsonConvert.DefaultSettings = () => settings; } + protected ApiRequestBase( IApiSettings settings ) + => _settings = settings; + + public async Task> QueryAsync( string command ) + => await QueryAsync( command, new Dictionary(), null ); + + public async Task> QueryAsync( string command, IDictionary parameters ) + => await QueryAsync( command, parameters, null ); + public async Task> QueryAsync( string command, Func deserializer ) => await QueryAsync( command, new Dictionary(), deserializer ); public async Task> QueryAsync( string command, IDictionary parameters, Func deserializer ) { + deserializer ??= JsonConvert.DeserializeObject; + using HttpClient client = CreateClient(); string cmd = CreateCommand( command, parameters ); @@ -118,7 +118,7 @@ public async Task> SearchAsync( string command, int page return error; } - var result = JsonConvert.DeserializeObject>( json, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore } ); + var result = JsonConvert.DeserializeObject>( json ); // ReSharper disable PossibleNullReferenceException result.CommandText = response.RequestMessage.RequestUri.ToString(); diff --git a/DM.MovieApi/ApiRequest/IsoDateTimeConverterEx.cs b/DM.MovieApi/ApiRequest/IsoDateTimeConverterEx.cs index a56ea14..805ba5f 100644 --- a/DM.MovieApi/ApiRequest/IsoDateTimeConverterEx.cs +++ b/DM.MovieApi/ApiRequest/IsoDateTimeConverterEx.cs @@ -37,7 +37,7 @@ public override object ReadJson( JsonReader reader, Type objectType, object exis return new DateTime( year, 1, 1 ); } - return default( DateTime ); + return DateTime.UnixEpoch; } } @@ -50,7 +50,7 @@ private void ConditionalTraceReaderValue( JsonReader reader ) val = ""; } - Debug.WriteLine( $"IsoDateTimeConverterEx.JsonReader.Value: {val}" ); + Trace.WriteLine( val, "IsoDateTimeConverterEx" ); } } } diff --git a/DM.MovieApi/IMovieDbApi.cs b/DM.MovieApi/IMovieDbApi.cs index bf525b4..5b1d0bc 100644 --- a/DM.MovieApi/IMovieDbApi.cs +++ b/DM.MovieApi/IMovieDbApi.cs @@ -1,6 +1,7 @@ -using DM.MovieApi.MovieDb.Certifications; +using DM.MovieApi.MovieDb.Certifications; using DM.MovieApi.MovieDb.Companies; using DM.MovieApi.MovieDb.Configuration; +using DM.MovieApi.MovieDb.Discover; using DM.MovieApi.MovieDb.Genres; using DM.MovieApi.MovieDb.IndustryProfessions; using DM.MovieApi.MovieDb.Movies; @@ -54,5 +55,10 @@ public interface IMovieDbApi /// Provides access for retrieving information about People. /// IApiPeopleRequest People { get; } + + /// + /// Provides access for discovering movies based on a filtered set of parameters. + /// + IApiDiscoverRequest Discover { get; } } } diff --git a/DM.MovieApi/MovieDb/Discover/ApiDiscoverRequest.cs b/DM.MovieApi/MovieDb/Discover/ApiDiscoverRequest.cs new file mode 100644 index 0000000..0fb32c3 --- /dev/null +++ b/DM.MovieApi/MovieDb/Discover/ApiDiscoverRequest.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using DM.MovieApi.ApiRequest; +using DM.MovieApi.ApiResponse; +using DM.MovieApi.MovieDb.Genres; +using DM.MovieApi.MovieDb.Movies; +using DM.MovieApi.Shims; + +namespace DM.MovieApi.MovieDb.Discover +{ + internal class ApiDiscoverRequest : ApiRequestBase, IApiDiscoverRequest + { + private readonly IApiGenreRequest _genreApi; + + [ImportingConstructor] + public ApiDiscoverRequest( IApiSettings settings, IApiGenreRequest genreApi ) + : base( settings ) + { + _genreApi = genreApi; + } + + public async Task> DiscoverMoviesAsync( IDiscoverMovieParameterBuilder builder, int pageNumber = 1, string language = "en" ) + { + Dictionary param = builder.Build(); + param.Add( "language", language ); + + const string command = "discover/movie"; + + ApiSearchResponse response = await base.SearchAsync( command, pageNumber, param ); + + if( response.Error != null ) + { + return response; + } + + response.Results.PopulateGenres( _genreApi ); + + return response; + } + } +} diff --git a/DM.MovieApi/MovieDb/Discover/DiscoverMovieParameterBuilder.cs b/DM.MovieApi/MovieDb/Discover/DiscoverMovieParameterBuilder.cs new file mode 100644 index 0000000..4fc44b2 --- /dev/null +++ b/DM.MovieApi/MovieDb/Discover/DiscoverMovieParameterBuilder.cs @@ -0,0 +1,74 @@ +using System.Collections.Generic; + +namespace DM.MovieApi.MovieDb.Discover +{ + public class DiscoverMovieParameterBuilder : IDiscoverMovieParameterBuilder + { + private readonly Dictionary> _param; + + public DiscoverMovieParameterBuilder() + { + _param = new Dictionary>(); + } + + public Dictionary Build() + { + var param = new Dictionary(); + + foreach( var kvp in _param ) + { + param.Add( kvp.Key, string.Join( ",", kvp.Value ) ); + } + + return param; + } + + public IDiscoverMovieParameterBuilder WithCast( int personId ) + { + AddParamType( "with_cast" ); + + _param["with_cast"].Add( personId.ToString() ); + + return this; + } + + public IDiscoverMovieParameterBuilder WithCrew( int personId ) + { + AddParamType( "with_crew" ); + + _param["with_crew"].Add( personId.ToString() ); + + return this; + } + + public IDiscoverMovieParameterBuilder WithGenre( int genreId ) + { + AddParamType( "with_genres" ); + + _param["with_genres"].Add( genreId.ToString() ); + + return this; + } + + public IDiscoverMovieParameterBuilder WithOriginalLanguage( string language ) + { + AddParamType( "original_language" ); + + _param["original_language"].Add( language ); + + return this; + } + + public IDiscoverMovieParameterBuilder ExcludeGenre( int genreId ) + { + AddParamType( "without_genres" ); + + _param["without_genres"].Add( genreId.ToString() ); + + return this; + } + + private void AddParamType( string name ) + => _param.TryAdd( name, new List() ); + } +} diff --git a/DM.MovieApi/MovieDb/Discover/IApiDiscoverRequest.cs b/DM.MovieApi/MovieDb/Discover/IApiDiscoverRequest.cs new file mode 100644 index 0000000..29540b5 --- /dev/null +++ b/DM.MovieApi/MovieDb/Discover/IApiDiscoverRequest.cs @@ -0,0 +1,21 @@ +using System.Threading.Tasks; +using DM.MovieApi.ApiRequest; +using DM.MovieApi.ApiResponse; +using DM.MovieApi.MovieDb.Movies; + +namespace DM.MovieApi.MovieDb.Discover +{ + /// + /// Interface for discovering movies based on the filter provided by the parameter builder. + /// + public interface IApiDiscoverRequest : IApiRequest + { + /// + /// Allows for the discovery of movies by various types of data provided to the parameter. + /// + /// Provides a method of adding several types of parameters to filter the query. + /// Default is page 1. The page number to retrieve; the will contain the current page returned and the total number of pages available. + /// Default is English. The ISO 639-1 language code to retrieve the result from. + Task> DiscoverMoviesAsync( IDiscoverMovieParameterBuilder builder, int pageNumber = 1, string language = "en" ); + } +} diff --git a/DM.MovieApi/MovieDb/Discover/IDiscoverMovieParameterBuilder.cs b/DM.MovieApi/MovieDb/Discover/IDiscoverMovieParameterBuilder.cs new file mode 100644 index 0000000..c3efc9e --- /dev/null +++ b/DM.MovieApi/MovieDb/Discover/IDiscoverMovieParameterBuilder.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; + +namespace DM.MovieApi.MovieDb.Discover +{ + public interface IDiscoverMovieParameterBuilder + { + /// + /// Builds all parameters for use with an . + /// Typically called by the internal engine. + /// + Dictionary Build(); + + /// + /// Add for each language version to be returned in the query. May be invoked more than once. + /// + IDiscoverMovieParameterBuilder WithOriginalLanguage( string language ); + + /// + /// Add for each crew member to be returned in the query. May be invoked more than once. + /// + IDiscoverMovieParameterBuilder WithCrew( int personId ); + + /// + /// Add for each cast member to be returned in the query. May be invoked more than once. + /// + IDiscoverMovieParameterBuilder WithCast( int personId ); + + /// + /// Add for each genre to be included in the query. May be invoked more than once. + /// + IDiscoverMovieParameterBuilder WithGenre( int genre ); + + /// + /// Add for each genre to be excluded from the query. May be invoked more than once. + /// + IDiscoverMovieParameterBuilder ExcludeGenre( int genre ); + } +} diff --git a/DM.MovieApi/MovieDb/Movies/ApiMovieRequest.cs b/DM.MovieApi/MovieDb/Movies/ApiMovieRequest.cs index e7a1d60..ce758a5 100644 --- a/DM.MovieApi/MovieDb/Movies/ApiMovieRequest.cs +++ b/DM.MovieApi/MovieDb/Movies/ApiMovieRequest.cs @@ -156,5 +156,45 @@ public async Task> GetCreditsAsync( int movieId, s return response; } + + public async Task> GetRecommendationsAsync( int movieId, int pageNumber = 1, string language = "en" ) + { + var param = new Dictionary + { + {"language", language}, + }; + + string command = $"movie/{movieId}/recommendations"; + ApiSearchResponse response = await base.SearchAsync( command, pageNumber, param ); + + if( response.Error != null ) + { + return response; + } + + response.Results.PopulateGenres( _genreApi ); + + return response; + } + + public async Task> GetSimilarAsync( int movieId, int pageNumber = 1, string language = "en" ) + { + var param = new Dictionary + { + {"language", language}, + }; + + string command = $"movie/{movieId}/similar"; + ApiSearchResponse response = await base.SearchAsync( command, pageNumber, param ); + + if( response.Error != null ) + { + return response; + } + + response.Results.PopulateGenres( _genreApi ); + + return response; + } } } diff --git a/DM.MovieApi/MovieDb/Movies/IApiMovieRequest.cs b/DM.MovieApi/MovieDb/Movies/IApiMovieRequest.cs index f919d5a..ea19faf 100644 --- a/DM.MovieApi/MovieDb/Movies/IApiMovieRequest.cs +++ b/DM.MovieApi/MovieDb/Movies/IApiMovieRequest.cs @@ -1,4 +1,4 @@ -using System.Threading.Tasks; +using System.Threading.Tasks; using DM.MovieApi.ApiRequest; using DM.MovieApi.ApiResponse; @@ -64,5 +64,22 @@ public interface IApiMovieRequest : IApiRequest /// The movie Id is typically found from a more generic Movie query. /// Default is English. The ISO 639-1 language code to retrieve the result from. Task> GetCreditsAsync( int movieId, string language = "en" ); + + /// + /// Get a list of recommended movies for a movie. + /// + /// The movie Id is typically found from a more generic Movie query. + /// Default is page 1. The page number to retrieve; the will contain the current page returned and the total number of pages available. + /// Default is English. The ISO 639-1 language code to retrieve the result from. + Task> GetRecommendationsAsync( int movieId, int pageNumber = 1, string language = "en" ); + + /// + /// Get a list of similar movies. This is not the same as the "Recommendation" system you see on the website. + /// These items are assembled by looking at keywords and genres. + /// + /// The movie Id is typically found from a more generic Movie query. + /// Default is page 1. The page number to retrieve; the will contain the current page returned and the total number of pages available. + /// Default is English. The ISO 639-1 language code to retrieve the result from. + Task> GetSimilarAsync( int movieId, int pageNumber = 1, string language = "en" ); } } diff --git a/DM.MovieApi/MovieDb/Movies/MovieInfo.cs b/DM.MovieApi/MovieDb/Movies/MovieInfo.cs index bca1e1f..31e6d93 100644 --- a/DM.MovieApi/MovieDb/Movies/MovieInfo.cs +++ b/DM.MovieApi/MovieDb/Movies/MovieInfo.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Runtime.Serialization; using DM.MovieApi.MovieDb.Genres; @@ -53,6 +53,7 @@ public MovieInfo() { GenreIds = Array.Empty(); Genres = Array.Empty(); + ReleaseDate = DateTime.UnixEpoch; } public override string ToString()