From 4571f09ce3bfc3f2f342140faf18d8c3ce63dad8 Mon Sep 17 00:00:00 2001 From: Erican Date: Sat, 26 Sep 2026 19:48:07 +0200 Subject: [PATCH] List the metadata sources the backend can use, with how to show them GET /sources lists the sources that are configured and whose API key works, each with its display name and the address templates of its movie and TV show pages. Every source has a health check that tests the key (TVDB asks for its languages, TMDB for its configuration) and describes the source; the results are remembered for five minutes if healthy and half a minute if not, so a fixed key shows up soon without asking the sources on every request. Nothing that could carry the key is logged. Co-Authored-By: Claude Sonnet 5 --- bruno/Sources/List.yml | 15 ++ bruno/Sources/folder.yml | 4 + .../Controllers/Sources/SourcesController.cs | 36 +++++ .../Mediatracker.Metadata.Tmdb/ITmdbClient.cs | 5 + .../ServiceCollectionExtensions.cs | 6 + .../TmdbClientAdapter.cs | 5 + .../TmdbSourceHealthCheck.cs | 44 +++++ .../ServiceCollectionExtensions.cs | 7 + .../TvdbSourceHealthCheck.cs | 45 ++++++ .../IMetadataSourceAvailabilityService.cs | 13 ++ .../IMetadataSourceHealthCheck.cs | 19 +++ .../MetadataSourceAvailabilityService.cs | 103 ++++++++++++ .../MetadataSourceDto.cs | 28 ++++ .../ServiceCollectionExtensions.cs | 1 + .../Sources/SourcesControllerTests.cs | 56 +++++++ .../MetadataSourceAvailabilityServiceTests.cs | 153 ++++++++++++++++++ .../ServiceCollectionExtensionsTests.cs | 16 ++ .../TmdbSourceHealthCheckTests.cs | 78 +++++++++ .../ServiceCollectionExtensionsTests.cs | 32 ++++ .../TvdbSourceHealthCheckTests.cs | 78 +++++++++ 20 files changed, 744 insertions(+) create mode 100644 bruno/Sources/List.yml create mode 100644 bruno/Sources/folder.yml create mode 100644 dotnet/src/Mediatracker.Api/Controllers/Sources/SourcesController.cs create mode 100644 dotnet/src/Mediatracker.Metadata.Tmdb/TmdbSourceHealthCheck.cs create mode 100644 dotnet/src/Mediatracker.Metadata.Tvdb/TvdbSourceHealthCheck.cs create mode 100644 dotnet/src/Mediatracker.Metadata/IMetadataSourceAvailabilityService.cs create mode 100644 dotnet/src/Mediatracker.Metadata/IMetadataSourceHealthCheck.cs create mode 100644 dotnet/src/Mediatracker.Metadata/MetadataSourceAvailabilityService.cs create mode 100644 dotnet/src/Mediatracker.Metadata/MetadataSourceDto.cs create mode 100644 dotnet/tests/Mediatracker.Api.Tests/Controllers/Sources/SourcesControllerTests.cs create mode 100644 dotnet/tests/Mediatracker.Metadata.Tests/MetadataSourceAvailabilityServiceTests.cs create mode 100644 dotnet/tests/Mediatracker.Metadata.Tmdb.Tests/TmdbSourceHealthCheckTests.cs create mode 100644 dotnet/tests/Mediatracker.Metadata.Tvdb.Tests/ServiceCollectionExtensionsTests.cs create mode 100644 dotnet/tests/Mediatracker.Metadata.Tvdb.Tests/TvdbSourceHealthCheckTests.cs diff --git a/bruno/Sources/List.yml b/bruno/Sources/List.yml new file mode 100644 index 00000000..6604ab79 --- /dev/null +++ b/bruno/Sources/List.yml @@ -0,0 +1,15 @@ +info: + name: List + type: http + seq: 1 + +http: + method: GET + url: "{{baseUrl}}/sources" + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/Sources/folder.yml b/bruno/Sources/folder.yml new file mode 100644 index 00000000..ba28705f --- /dev/null +++ b/bruno/Sources/folder.yml @@ -0,0 +1,4 @@ +info: + name: Sources + type: folder + seq: 5 diff --git a/dotnet/src/Mediatracker.Api/Controllers/Sources/SourcesController.cs b/dotnet/src/Mediatracker.Api/Controllers/Sources/SourcesController.cs new file mode 100644 index 00000000..2acadc01 --- /dev/null +++ b/dotnet/src/Mediatracker.Api/Controllers/Sources/SourcesController.cs @@ -0,0 +1,36 @@ +using Mediatracker.Metadata; +using Microsoft.AspNetCore.Mvc; + +namespace Mediatracker.Api.Controllers.Sources; + +/// +/// The metadata sources that can be used. +/// +[ApiController] +[Route("sources")] +public class SourcesController : ControllerBase +{ + private readonly IMetadataSourceAvailabilityService _availabilityService; + + /// + /// Creates the controller with the service that knows which sources are available. + /// + public SourcesController(IMetadataSourceAvailabilityService availabilityService) + => _availabilityService = availabilityService; + + /// + /// Lists the metadata sources that are configured and whose configuration works (an API key that the source + /// accepts), each with its name and the addresses of its pages. The list is empty if there is none, which is an + /// answer, not an error: nothing can be searched or + /// shown then, and a client says so. + /// + [HttpGet] + public async Task>> List( + CancellationToken cancellationToken = default + ) + { + var sources = await _availabilityService.GetAvailableAsync(cancellationToken); + + return Ok(sources); + } +} diff --git a/dotnet/src/Mediatracker.Metadata.Tmdb/ITmdbClient.cs b/dotnet/src/Mediatracker.Metadata.Tmdb/ITmdbClient.cs index 26303302..5c677eff 100644 --- a/dotnet/src/Mediatracker.Metadata.Tmdb/ITmdbClient.cs +++ b/dotnet/src/Mediatracker.Metadata.Tmdb/ITmdbClient.cs @@ -49,4 +49,9 @@ public interface ITmdbClient string language, CancellationToken cancellationToken = default ); + + /// + /// Gets TMDB's configuration, which TMDB only answers for a valid API key. + /// + Task GetConfigurationAsync(CancellationToken cancellationToken = default); } diff --git a/dotnet/src/Mediatracker.Metadata.Tmdb/ServiceCollectionExtensions.cs b/dotnet/src/Mediatracker.Metadata.Tmdb/ServiceCollectionExtensions.cs index e912a8c4..cbc49126 100644 --- a/dotnet/src/Mediatracker.Metadata.Tmdb/ServiceCollectionExtensions.cs +++ b/dotnet/src/Mediatracker.Metadata.Tmdb/ServiceCollectionExtensions.cs @@ -33,6 +33,12 @@ public static class ServiceCollectionExtensions ); services.AddSingleton(); + + services.AddScoped(provider => new TmdbSourceHealthCheck( + provider.GetRequiredService(), configuration + ) + ); + services.AddScoped(); services.AddScoped(); diff --git a/dotnet/src/Mediatracker.Metadata.Tmdb/TmdbClientAdapter.cs b/dotnet/src/Mediatracker.Metadata.Tmdb/TmdbClientAdapter.cs index 5f5e8f31..0a8235a3 100644 --- a/dotnet/src/Mediatracker.Metadata.Tmdb/TmdbClientAdapter.cs +++ b/dotnet/src/Mediatracker.Metadata.Tmdb/TmdbClientAdapter.cs @@ -53,4 +53,9 @@ public class TmdbClientAdapter : ITmdbClient => _client.Value.GetTvSeasonAsync( tvShowId, seasonNumber, language: language, cancellationToken: cancellationToken ); + + /// + public Task GetConfigurationAsync(CancellationToken cancellationToken = default) + // TMDbLib's call takes no cancellation token, so the wait for it is what gives way to one. + => _client.Value.GetConfigAsync().WaitAsync(cancellationToken); } diff --git a/dotnet/src/Mediatracker.Metadata.Tmdb/TmdbSourceHealthCheck.cs b/dotnet/src/Mediatracker.Metadata.Tmdb/TmdbSourceHealthCheck.cs new file mode 100644 index 00000000..a6884ddd --- /dev/null +++ b/dotnet/src/Mediatracker.Metadata.Tmdb/TmdbSourceHealthCheck.cs @@ -0,0 +1,44 @@ +using Microsoft.Extensions.Configuration; + +namespace Mediatracker.Metadata.Tmdb; + +/// +/// Checks TMDB: that an API key is configured and that TMDB accepts it, which asking for its configuration, +/// a request TMDB only answers for a valid key, finds out. +/// +public class TmdbSourceHealthCheck : IMetadataSourceHealthCheck +{ + private readonly ITmdbClient _client; + private readonly IConfiguration _configuration; + + /// + /// Creates the check with the TMDB client and the configuration the key is read from. + /// + public TmdbSourceHealthCheck(ITmdbClient client, IConfiguration configuration) + { + _client = client; + _configuration = configuration; + } + + /// + public MetadataSourceDto Info { get; } = new() + { + Source = MetadataSource.Tmdb, + DisplayName = "TMDB", + MovieUrlTemplate = "https://www.themoviedb.org/movie/{id}", + TvShowUrlTemplate = "https://www.themoviedb.org/tv/{id}", + }; + + /// + public async Task CheckAsync(CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(_configuration["TmdbConfiguration:ApiKey"])) + { + return false; + } + + await _client.GetConfigurationAsync(cancellationToken); + + return true; + } +} diff --git a/dotnet/src/Mediatracker.Metadata.Tvdb/ServiceCollectionExtensions.cs b/dotnet/src/Mediatracker.Metadata.Tvdb/ServiceCollectionExtensions.cs index 5c3cf14d..277609bd 100644 --- a/dotnet/src/Mediatracker.Metadata.Tvdb/ServiceCollectionExtensions.cs +++ b/dotnet/src/Mediatracker.Metadata.Tvdb/ServiceCollectionExtensions.cs @@ -4,6 +4,7 @@ using Mediatracker.Metadata.Tvdb.TvShows; using Mediatracker.Metadata.TvShows; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Tvdb.Clients; namespace Mediatracker.Metadata.Tvdb; @@ -21,6 +22,12 @@ public static class ServiceCollectionExtensions ) { services.AddTvdbClient(configuration); + + services.AddScoped(provider => new TvdbSourceHealthCheck( + provider.GetRequiredService(), configuration + ) + ); + services.AddScoped(); services.AddScoped(); diff --git a/dotnet/src/Mediatracker.Metadata.Tvdb/TvdbSourceHealthCheck.cs b/dotnet/src/Mediatracker.Metadata.Tvdb/TvdbSourceHealthCheck.cs new file mode 100644 index 00000000..44db1282 --- /dev/null +++ b/dotnet/src/Mediatracker.Metadata.Tvdb/TvdbSourceHealthCheck.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.Configuration; +using Tvdb.Clients; + +namespace Mediatracker.Metadata.Tvdb; + +/// +/// Checks TheTVDB: that an API key is configured and that TVDB accepts it, which asking for the languages, a +/// request only a logged-in client can make, finds out. +/// +public class TvdbSourceHealthCheck : IMetadataSourceHealthCheck +{ + private readonly IConfiguration _configuration; + private readonly ILanguagesClient _languagesClient; + + /// + /// Creates the check with the TVDB client and the configuration the key is read from. + /// + public TvdbSourceHealthCheck(ILanguagesClient languagesClient, IConfiguration configuration) + { + _languagesClient = languagesClient; + _configuration = configuration; + } + + /// + public MetadataSourceDto Info { get; } = new() + { + Source = MetadataSource.Tvdb, + DisplayName = "TVDB", + MovieUrlTemplate = "https://www.thetvdb.com/dereferrer/movie/{id}", + TvShowUrlTemplate = "https://www.thetvdb.com/dereferrer/series/{id}", + }; + + /// + public async Task CheckAsync(CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(_configuration["TvdbConfiguration:ApiKey"])) + { + return false; + } + + await _languagesClient.LanguagesAsync(cancellationToken); + + return true; + } +} diff --git a/dotnet/src/Mediatracker.Metadata/IMetadataSourceAvailabilityService.cs b/dotnet/src/Mediatracker.Metadata/IMetadataSourceAvailabilityService.cs new file mode 100644 index 00000000..9546cc7b --- /dev/null +++ b/dotnet/src/Mediatracker.Metadata/IMetadataSourceAvailabilityService.cs @@ -0,0 +1,13 @@ +namespace Mediatracker.Metadata; + +/// +/// Knows which metadata sources can be used right now. +/// +public interface IMetadataSourceAvailabilityService +{ + /// + /// The sources that are configured and whose configuration works, as far as the last check found out. Checks + /// are remembered for a while, so this doesn't ask the sources on every call. + /// + Task> GetAvailableAsync(CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Mediatracker.Metadata/IMetadataSourceHealthCheck.cs b/dotnet/src/Mediatracker.Metadata/IMetadataSourceHealthCheck.cs new file mode 100644 index 00000000..96512dca --- /dev/null +++ b/dotnet/src/Mediatracker.Metadata/IMetadataSourceHealthCheck.cs @@ -0,0 +1,19 @@ +namespace Mediatracker.Metadata; + +/// +/// Describes one and tells whether it can be used: it is configured (an API key) and +/// the key works. +/// +public interface IMetadataSourceHealthCheck +{ + /// + /// The source this check is about, with what a client shows of it. + /// + MetadataSourceDto Info { get; } + + /// + /// Whether the source is configured and answers a request made with its configuration. A source that isn't + /// configured is unhealthy without a request. A failing request may throw; the caller counts that as unhealthy. + /// + Task CheckAsync(CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Mediatracker.Metadata/MetadataSourceAvailabilityService.cs b/dotnet/src/Mediatracker.Metadata/MetadataSourceAvailabilityService.cs new file mode 100644 index 00000000..f2767b26 --- /dev/null +++ b/dotnet/src/Mediatracker.Metadata/MetadataSourceAvailabilityService.cs @@ -0,0 +1,103 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Mediatracker.Metadata; + +/// +/// An that runs every registered +/// and remembers what it found: a healthy source for a few minutes, a +/// failing one only briefly, so a fixed key shows up soon without asking a source on every request. +/// +public class MetadataSourceAvailabilityService : IMetadataSourceAvailabilityService +{ + private static readonly TimeSpan HealthyFor = TimeSpan.FromMinutes(5); + private static readonly TimeSpan UnhealthyFor = TimeSpan.FromSeconds(30); + private static readonly TimeSpan CheckTimeout = TimeSpan.FromSeconds(10); + private readonly SemaphoreSlim _lock = new(1, 1); + private readonly ILogger _logger; + + private readonly Dictionary _results = new(); + private readonly IServiceScopeFactory _scopeFactory; + private readonly TimeProvider _timeProvider; + + /// + /// Creates the service; the checks are resolved from a scope of their own, since they use scoped clients. + /// + public MetadataSourceAvailabilityService( + IServiceScopeFactory scopeFactory, + TimeProvider timeProvider, + ILogger logger + ) + { + _scopeFactory = scopeFactory; + _timeProvider = timeProvider; + _logger = logger; + } + + /// + public async Task> GetAvailableAsync(CancellationToken cancellationToken = default) + { + // One request checks, the others wait for that instead of asking the sources all at once. + await _lock.WaitAsync(cancellationToken); + + try + { + await using var scope = _scopeFactory.CreateAsyncScope(); + var checks = scope.ServiceProvider.GetServices().ToList(); + var now = _timeProvider.GetUtcNow(); + + var stale = checks.Where(check => !_results.TryGetValue(check.Info.Source, out var result) + || now - result.CheckedAt >= (result.IsHealthy + ? HealthyFor + : UnhealthyFor) + ) + .ToList(); + + var outcomes = await Task.WhenAll(stale.Select(check => RunAsync(check, cancellationToken))); + + foreach (var (check, isHealthy) in stale.Zip(outcomes)) + { + _results[check.Info.Source] = (isHealthy, now); + } + + return checks.Where(check => _results[check.Info.Source].IsHealthy) + .Select(check => check.Info) + .DistinctBy(info => info.Source) + .ToList(); + } + finally + { + _lock.Release(); + } + } + + private async Task RunAsync(IMetadataSourceHealthCheck check, CancellationToken cancellationToken) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(CheckTimeout); + + try + { + var isHealthy = await check.CheckAsync(timeout.Token); + + if (!isHealthy) + { + _logger.LogWarning( + "Metadata source {Source} is not available: not configured or its key is refused", check.Info.Source + ); + } + + return isHealthy; + } + catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested) + { + // Not `{Exception}` in the message: what a client throws can carry the request URL, and with it the key. + _logger.LogWarning( + "Metadata source {Source} is not available: the check failed with {ExceptionType}", check.Info.Source, + ex.GetType().Name + ); + + return false; + } + } +} diff --git a/dotnet/src/Mediatracker.Metadata/MetadataSourceDto.cs b/dotnet/src/Mediatracker.Metadata/MetadataSourceDto.cs new file mode 100644 index 00000000..b8a036c9 --- /dev/null +++ b/dotnet/src/Mediatracker.Metadata/MetadataSourceDto.cs @@ -0,0 +1,28 @@ +namespace Mediatracker.Metadata; + +/// +/// A metadata source that can be used, with what a client needs to show it, so a client has neither the name nor +/// the addresses of a source built in. +/// +public class MetadataSourceDto +{ + /// + /// The source. + /// + public required MetadataSource Source { get; init; } + + /// + /// The name to show, e.g. TMDB. + /// + public required string DisplayName { get; init; } + + /// + /// The address of a movie's page at the source, with {id} where the movie's id at the source goes. + /// + public required string MovieUrlTemplate { get; init; } + + /// + /// The address of a TV show's page at the source, with {id} where the show's id at the source goes. + /// + public required string TvShowUrlTemplate { get; init; } +} diff --git a/dotnet/src/Mediatracker.Metadata/ServiceCollectionExtensions.cs b/dotnet/src/Mediatracker.Metadata/ServiceCollectionExtensions.cs index 69e938aa..8c8ef778 100644 --- a/dotnet/src/Mediatracker.Metadata/ServiceCollectionExtensions.cs +++ b/dotnet/src/Mediatracker.Metadata/ServiceCollectionExtensions.cs @@ -18,6 +18,7 @@ public static class ServiceCollectionExtensions { services.AddDbContext(options => options.UseNpgsql(postgresConnectionString)); services.AddSingleton(TimeProvider.System); + services.AddSingleton(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/dotnet/tests/Mediatracker.Api.Tests/Controllers/Sources/SourcesControllerTests.cs b/dotnet/tests/Mediatracker.Api.Tests/Controllers/Sources/SourcesControllerTests.cs new file mode 100644 index 00000000..7ea4e4b7 --- /dev/null +++ b/dotnet/tests/Mediatracker.Api.Tests/Controllers/Sources/SourcesControllerTests.cs @@ -0,0 +1,56 @@ +using Mediatracker.Api.Controllers.Sources; +using Mediatracker.Metadata; +using Microsoft.AspNetCore.Mvc; +using NSubstitute; + +namespace Mediatracker.Api.Tests.Controllers.Sources; + +public class SourcesControllerTests +{ + private readonly IMetadataSourceAvailabilityService _availabilityService + = Substitute.For(); + + private static MetadataSourceDto Describe(MetadataSource source) + => new() + { + Source = source, + DisplayName = source.ToString(), + MovieUrlTemplate = $"https://{source}.test/movie/{{id}}", + TvShowUrlTemplate = $"https://{source}.test/tv/{{id}}", + }; + + [Fact] + public async Task List_returns_the_sources_that_are_available() + { + // Arrange + _availabilityService.GetAvailableAsync(Arg.Any()) + .Returns([Describe(MetadataSource.Tmdb), Describe(MetadataSource.Tvdb),]); + + var sut = new SourcesController(_availabilityService); + + // Act + var result = await sut.List(); + + // Assert + var ok = Assert.IsType(result.Result); + var sources = Assert.IsAssignableFrom>(ok.Value); + + Assert.Equal([MetadataSource.Tmdb, MetadataSource.Tvdb,], sources.Select(source => source.Source)); + } + + [Fact] + public async Task List_answers_with_an_empty_list_and_no_error_when_no_source_is_available() + { + // Arrange + _availabilityService.GetAvailableAsync(Arg.Any()).Returns([]); + var sut = new SourcesController(_availabilityService); + + // Act + var result = await sut.List(); + + // Assert + var ok = Assert.IsType(result.Result); + + Assert.Empty(Assert.IsAssignableFrom>(ok.Value)); + } +} diff --git a/dotnet/tests/Mediatracker.Metadata.Tests/MetadataSourceAvailabilityServiceTests.cs b/dotnet/tests/Mediatracker.Metadata.Tests/MetadataSourceAvailabilityServiceTests.cs new file mode 100644 index 00000000..fdd469df --- /dev/null +++ b/dotnet/tests/Mediatracker.Metadata.Tests/MetadataSourceAvailabilityServiceTests.cs @@ -0,0 +1,153 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using NSubstitute.ExceptionExtensions; + +namespace Mediatracker.Metadata.Tests; + +public class MetadataSourceAvailabilityServiceTests +{ + private readonly FakeTimeProvider _timeProvider = new(DateTimeOffset.Parse("2026-09-26T12:00:00Z")); + private readonly IMetadataSourceHealthCheck _tmdbCheck = Substitute.For(); + private readonly IMetadataSourceHealthCheck _tvdbCheck = Substitute.For(); + + private static MetadataSourceDto Describe(MetadataSource source) + => new() + { + Source = source, + DisplayName = source.ToString().ToUpperInvariant(), + MovieUrlTemplate = $"https://{source}.test/movie/{{id}}", + TvShowUrlTemplate = $"https://{source}.test/tv/{{id}}", + }; + + private MetadataSourceAvailabilityService CreateSut() + { + _tmdbCheck.Info.Returns(Describe(MetadataSource.Tmdb)); + _tvdbCheck.Info.Returns(Describe(MetadataSource.Tvdb)); + + var services = new ServiceCollection().AddSingleton(_tmdbCheck).AddSingleton(_tvdbCheck).BuildServiceProvider(); + + return new MetadataSourceAvailabilityService( + services.GetRequiredService(), _timeProvider, + NullLogger.Instance + ); + } + + [Fact] + public async Task GetAvailableAsync_lists_only_the_sources_whose_check_passes() + { + // Arrange + _tmdbCheck.CheckAsync(Arg.Any()).Returns(true); + _tvdbCheck.CheckAsync(Arg.Any()).Returns(false); + var sut = CreateSut(); + + // Act + var sources = await sut.GetAvailableAsync(); + + // Assert + Assert.Equal([MetadataSource.Tmdb,], sources.Select(source => source.Source)); + } + + [Fact] + public async Task GetAvailableAsync_lists_nothing_when_no_check_passes() + { + // Arrange + _tmdbCheck.CheckAsync(Arg.Any()).Returns(false); + _tvdbCheck.CheckAsync(Arg.Any()).Returns(false); + var sut = CreateSut(); + + // Act + var sources = await sut.GetAvailableAsync(); + + // Assert + Assert.Empty(sources); + } + + [Fact] + public async Task GetAvailableAsync_counts_a_check_that_throws_as_failed_and_still_lists_the_others() + { + // Arrange + _tmdbCheck.CheckAsync(Arg.Any()).ThrowsAsync(new HttpRequestException()); + _tvdbCheck.CheckAsync(Arg.Any()).Returns(true); + var sut = CreateSut(); + + // Act + var sources = await sut.GetAvailableAsync(); + + // Assert + Assert.Equal([MetadataSource.Tvdb,], sources.Select(source => source.Source)); + } + + [Fact] + public async Task GetAvailableAsync_keeps_a_healthy_result_for_a_while_without_checking_again() + { + // Arrange + _tmdbCheck.CheckAsync(Arg.Any()).Returns(true); + _tvdbCheck.CheckAsync(Arg.Any()).Returns(true); + var sut = CreateSut(); + + // Act + await sut.GetAvailableAsync(); + _timeProvider.Advance(TimeSpan.FromMinutes(4)); + await sut.GetAvailableAsync(); + + // Assert + await _tmdbCheck.Received(1).CheckAsync(Arg.Any()); + } + + [Fact] + public async Task GetAvailableAsync_checks_a_healthy_source_again_once_the_result_is_stale() + { + // Arrange + _tmdbCheck.CheckAsync(Arg.Any()).Returns(true); + _tvdbCheck.CheckAsync(Arg.Any()).Returns(true); + var sut = CreateSut(); + + // Act + await sut.GetAvailableAsync(); + _timeProvider.Advance(TimeSpan.FromMinutes(6)); + await sut.GetAvailableAsync(); + + // Assert + await _tmdbCheck.Received(2).CheckAsync(Arg.Any()); + } + + [Fact] + public async Task GetAvailableAsync_checks_a_failed_source_again_sooner_so_a_fixed_key_shows_up_quickly() + { + // Arrange + _tmdbCheck.CheckAsync(Arg.Any()).Returns(false, true); + _tvdbCheck.CheckAsync(Arg.Any()).Returns(true); + var sut = CreateSut(); + + // Act + var first = await sut.GetAvailableAsync(); + _timeProvider.Advance(TimeSpan.FromSeconds(31)); + var second = await sut.GetAvailableAsync(); + + // Assert + Assert.Equal([MetadataSource.Tvdb,], first.Select(source => source.Source)); + Assert.Equivalent(new[] { MetadataSource.Tmdb, MetadataSource.Tvdb, }, second.Select(source => source.Source)); + await _tvdbCheck.Received(1).CheckAsync(Arg.Any()); + } + + [Fact] + public async Task GetAvailableAsync_returns_what_each_source_says_about_itself() + { + // Arrange + _tmdbCheck.CheckAsync(Arg.Any()).Returns(true); + _tvdbCheck.CheckAsync(Arg.Any()).Returns(false); + var sut = CreateSut(); + + // Act + var sources = await sut.GetAvailableAsync(); + + // Assert + var source = Assert.Single(sources); + + Assert.Equal("TMDB", source.DisplayName); + Assert.Equal("https://Tmdb.test/movie/{id}", source.MovieUrlTemplate); + Assert.Equal("https://Tmdb.test/tv/{id}", source.TvShowUrlTemplate); + } +} diff --git a/dotnet/tests/Mediatracker.Metadata.Tmdb.Tests/ServiceCollectionExtensionsTests.cs b/dotnet/tests/Mediatracker.Metadata.Tmdb.Tests/ServiceCollectionExtensionsTests.cs index 5c45baa4..a05ce8da 100644 --- a/dotnet/tests/Mediatracker.Metadata.Tmdb.Tests/ServiceCollectionExtensionsTests.cs +++ b/dotnet/tests/Mediatracker.Metadata.Tmdb.Tests/ServiceCollectionExtensionsTests.cs @@ -28,4 +28,20 @@ public class ServiceCollectionExtensionsTests // Assert Assert.Null(exception); } + + [Fact] + public void AddTmdbMetadataProvider_registers_a_health_check_that_can_be_resolved_without_configuration() + { + // Arrange + var configuration = new ConfigurationBuilder().Build(); + var services = new ServiceCollection(); + services.AddTmdbMetadataProvider(configuration); + var provider = services.BuildServiceProvider(); + + // Act + var checks = provider.GetServices().ToList(); + + // Assert + Assert.Equal([MetadataSource.Tmdb,], checks.Select(check => check.Info.Source)); + } } diff --git a/dotnet/tests/Mediatracker.Metadata.Tmdb.Tests/TmdbSourceHealthCheckTests.cs b/dotnet/tests/Mediatracker.Metadata.Tmdb.Tests/TmdbSourceHealthCheckTests.cs new file mode 100644 index 00000000..05e956c4 --- /dev/null +++ b/dotnet/tests/Mediatracker.Metadata.Tmdb.Tests/TmdbSourceHealthCheckTests.cs @@ -0,0 +1,78 @@ +using Microsoft.Extensions.Configuration; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using TMDbLib.Objects.General; + +namespace Mediatracker.Metadata.Tmdb.Tests; + +public class TmdbSourceHealthCheckTests +{ + private readonly ITmdbClient _client = Substitute.For(); + + private TmdbSourceHealthCheck CreateSut(string? apiKey) + { + var configuration = new ConfigurationBuilder().AddInMemoryCollection( + apiKey is null + ? [] + : [new KeyValuePair("TmdbConfiguration:ApiKey", apiKey),] + ) + .Build(); + + return new TmdbSourceHealthCheck(_client, configuration); + } + + [Fact] + public void Info_describes_TMDB_with_the_addresses_of_its_pages() + { + // Act + var info = CreateSut("key").Info; + + // Assert + Assert.Equal(MetadataSource.Tmdb, info.Source); + Assert.Equal("TMDB", info.DisplayName); + Assert.Equal("https://www.themoviedb.org/movie/{id}", info.MovieUrlTemplate); + Assert.Equal("https://www.themoviedb.org/tv/{id}", info.TvShowUrlTemplate); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task CheckAsync_is_unhealthy_without_asking_TMDB_when_no_API_key_is_configured(string? apiKey) + { + // Arrange + var sut = CreateSut(apiKey); + + // Act + var isHealthy = await sut.CheckAsync(); + + // Assert + Assert.False(isHealthy); + await _client.DidNotReceiveWithAnyArgs().GetConfigurationAsync(); + } + + [Fact] + public async Task CheckAsync_is_healthy_when_TMDB_answers_a_request_made_with_the_key() + { + // Arrange + _client.GetConfigurationAsync(Arg.Any()).Returns(new TMDbConfig()); + var sut = CreateSut("key"); + + // Act + var isHealthy = await sut.CheckAsync(); + + // Assert + Assert.True(isHealthy); + } + + [Fact] + public async Task CheckAsync_lets_the_failure_through_when_TMDB_refuses_the_key() + { + // Arrange + _client.GetConfigurationAsync(Arg.Any()).ThrowsAsync(new HttpRequestException()); + var sut = CreateSut("wrong-key"); + + // Act & Assert + await Assert.ThrowsAsync(() => sut.CheckAsync()); + } +} diff --git a/dotnet/tests/Mediatracker.Metadata.Tvdb.Tests/ServiceCollectionExtensionsTests.cs b/dotnet/tests/Mediatracker.Metadata.Tvdb.Tests/ServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..4865eeb7 --- /dev/null +++ b/dotnet/tests/Mediatracker.Metadata.Tvdb.Tests/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Mediatracker.Metadata.Tvdb.Tests; + +public class ServiceCollectionExtensionsTests +{ + [Fact] + public void AddTvdbMetadataProvider_registers_a_health_check_that_can_be_resolved_while_the_API_key_is_still_empty() + { + // Arrange + // As appsettings.json has it: the section is there, the key still empty. + var configuration = new ConfigurationBuilder().AddInMemoryCollection( + [ + new KeyValuePair("TvdbConfiguration:BaseUrl", "https://api4.thetvdb.com/v4"), + new KeyValuePair("TvdbConfiguration:ApiKey", ""), + new KeyValuePair("TvdbConfiguration:Pin", ""), + ] + ) + .Build(); + + var services = new ServiceCollection(); + services.AddTvdbMetadataProvider(configuration); + var provider = services.BuildServiceProvider(); + + // Act + var checks = provider.GetServices().ToList(); + + // Assert + Assert.Equal([MetadataSource.Tvdb,], checks.Select(check => check.Info.Source)); + } +} diff --git a/dotnet/tests/Mediatracker.Metadata.Tvdb.Tests/TvdbSourceHealthCheckTests.cs b/dotnet/tests/Mediatracker.Metadata.Tvdb.Tests/TvdbSourceHealthCheckTests.cs new file mode 100644 index 00000000..2888cadc --- /dev/null +++ b/dotnet/tests/Mediatracker.Metadata.Tvdb.Tests/TvdbSourceHealthCheckTests.cs @@ -0,0 +1,78 @@ +using Microsoft.Extensions.Configuration; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Tvdb.Clients; + +namespace Mediatracker.Metadata.Tvdb.Tests; + +public class TvdbSourceHealthCheckTests +{ + private readonly ILanguagesClient _languagesClient = Substitute.For(); + + private TvdbSourceHealthCheck CreateSut(string? apiKey) + { + var configuration = new ConfigurationBuilder().AddInMemoryCollection( + apiKey is null + ? [] + : [new KeyValuePair("TvdbConfiguration:ApiKey", apiKey),] + ) + .Build(); + + return new TvdbSourceHealthCheck(_languagesClient, configuration); + } + + [Fact] + public void Info_describes_TVDB_with_the_addresses_of_its_pages() + { + // Act + var info = CreateSut("key").Info; + + // Assert + Assert.Equal(MetadataSource.Tvdb, info.Source); + Assert.Equal("TVDB", info.DisplayName); + Assert.Equal("https://www.thetvdb.com/dereferrer/movie/{id}", info.MovieUrlTemplate); + Assert.Equal("https://www.thetvdb.com/dereferrer/series/{id}", info.TvShowUrlTemplate); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task CheckAsync_is_unhealthy_without_asking_TVDB_when_no_API_key_is_configured(string? apiKey) + { + // Arrange + var sut = CreateSut(apiKey); + + // Act + var isHealthy = await sut.CheckAsync(); + + // Assert + Assert.False(isHealthy); + await _languagesClient.DidNotReceiveWithAnyArgs().LanguagesAsync(); + } + + [Fact] + public async Task CheckAsync_is_healthy_when_TVDB_answers_a_request_made_with_the_key() + { + // Arrange + var sut = CreateSut("key"); + + // Act + var isHealthy = await sut.CheckAsync(); + + // Assert + Assert.True(isHealthy); + await _languagesClient.Received(1).LanguagesAsync(Arg.Any()); + } + + [Fact] + public async Task CheckAsync_lets_the_failure_through_when_TVDB_refuses_the_key() + { + // Arrange + _languagesClient.LanguagesAsync(Arg.Any()).ThrowsAsync(new HttpRequestException()); + var sut = CreateSut("wrong-key"); + + // Act & Assert + await Assert.ThrowsAsync(() => sut.CheckAsync()); + } +} -- knotmirror 0.0.1