using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using PLib.Infrastructure.Metadata; using PLib.Infrastructure.Storage; using Shouldly; namespace PLib.Tests.Metadata; /// /// A picture host that will not answer must cost a few attempts, not one per candidate. /// public sealed class RemoteImageCacheTests : IDisposable { private readonly TempPaths _paths = new(); private readonly CountingHandler _handler = new(); public void Dispose() { _paths.Dispose(); _handler.Dispose(); } [Fact] public async Task A_host_that_keeps_failing_is_left_alone_after_a_few_tries() { var cache = Create(); var problems = new List(); for (var attempt = 0; attempt < 6; attempt++) { var image = await cache.GetOrCreateAsync($"https://cdn.example/{attempt}.jpg", Token); image.Path.ShouldBeNull(); if (image.Problem is { } problem) { problems.Add(problem); } } // A library-wide run produces a cover per candidate, all from the same host. Without a // cut-off, a host that answers its headers and then stalls holds a connection open for // the full timeout on every single one of them. _handler.Requests.ShouldBe(3); // Said once, at the moment the host is given up on. Every miss would put the same line // beside every candidate; never saying it leaves rows of empty squares unexplained. problems.ShouldHaveSingleItem().ShouldContain("cdn.example"); } [Fact] public async Task A_different_host_is_judged_on_its_own_behaviour() { var cache = Create(); for (var attempt = 0; attempt < 4; attempt++) { await cache.GetOrCreateAsync($"https://broken.example/{attempt}.jpg", Token); } await cache.GetOrCreateAsync("https://other.example/a.jpg", Token); // Three to the broken host, then it is skipped; the fourth request is the other host. _handler.Requests.ShouldBe(4); } [Fact] public async Task An_address_that_is_not_a_web_address_is_never_fetched() { var cache = Create(); // The URL comes from somebody else's server, so file:// would turn "fetch a picture" // into "read a path of the server's choosing". (await cache.GetOrCreateAsync(@"file:///C:/Windows/win.ini", Token)).Path.ShouldBeNull(); (await cache.GetOrCreateAsync("не адрес", Token)).Path.ShouldBeNull(); _handler.Requests.ShouldBe(0); } private static CancellationToken Token => TestContext.Current.CancellationToken; private HttpRemoteImageCache Create() { var factory = Substitute.For(); factory.CreateClient(Arg.Any()).Returns(_ => new HttpClient(_handler, disposeHandler: false)); return new HttpRemoteImageCache(factory, _paths, NullLogger.Instance); } /// Fails every request at once, and counts how many it was asked to make. private sealed class CountingHandler : HttpMessageHandler { private int _requests; public int Requests => _requests; protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { Interlocked.Increment(ref _requests); throw new HttpRequestException("no route to host"); } } private sealed class TempPaths : IAppPaths, IDisposable { public TempPaths() { DataDirectory = Path.Combine(Path.GetTempPath(), $"plib-images-{Guid.CreateVersion7()}"); ThumbnailDirectory = Path.Combine(DataDirectory, "thumbnails"); PreviewDirectory = Path.Combine(DataDirectory, "previews"); RemoteImageDirectory = Path.Combine(DataDirectory, "images"); DatabaseFile = Path.Combine(DataDirectory, "library.db"); Directory.CreateDirectory(RemoteImageDirectory); } public string DataDirectory { get; } public string ThumbnailDirectory { get; } public string PreviewDirectory { get; } public string RemoteImageDirectory { get; } public string DatabaseFile { get; } public void Dispose() { if (Directory.Exists(DataDirectory)) { Directory.Delete(DataDirectory, recursive: true); } } } }