Files
PLib/tests/PLib.Tests/Metadata/RemoteImageCacheTests.cs
T

138 lines
4.5 KiB
C#

using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using PLib.Infrastructure.Metadata;
using PLib.Infrastructure.Storage;
using Shouldly;
namespace PLib.Tests.Metadata;
/// <summary>
/// A picture host that will not answer must cost a few attempts, not one per candidate.
/// </summary>
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<string>();
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<IHttpClientFactory>();
factory.CreateClient(Arg.Any<string>()).Returns(_ => new HttpClient(_handler, disposeHandler: false));
return new HttpRemoteImageCache(factory, _paths, NullLogger<HttpRemoteImageCache>.Instance);
}
/// <summary>Fails every request at once, and counts how many it was asked to make.</summary>
private sealed class CountingHandler : HttpMessageHandler
{
private int _requests;
public int Requests => _requests;
protected override Task<HttpResponseMessage> 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);
}
}
}
}