using System.Net; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using TheLivingWorld.Core.Geo; using TheLivingWorld.Osm; using TheLivingWorld.Osm.Overpass; namespace TheLivingWorld.Tests; public class OverpassClientTests : IDisposable { private static readonly GeoBounds Bounds = GeoBounds.FromCenter(new GeoPoint(31.8966010, -100.4858591), 1000); private readonly string _cacheDirectory = Path.Combine(Path.GetTempPath(), $"tlw-osm-cache-{Guid.NewGuid():n}"); [Fact] public async Task Caches_the_response_and_serves_it_again_without_a_second_request() { var handler = new StubHandler((_, _) => Json("""{"elements":[]}""")); var client = CreateClient(handler); var first = await client.FetchAsync(Bounds, forceRefresh: false); var second = await client.FetchAsync(Bounds, forceRefresh: false); Assert.Equal(first, second); Assert.Equal(1, handler.Requests); Assert.Equal("""{"elements":[]}""", await File.ReadAllTextAsync(first)); } [Fact] public async Task Refetches_when_asked_to_refresh() { var handler = new StubHandler((_, _) => Json("""{"elements":[]}""")); var client = CreateClient(handler); await client.FetchAsync(Bounds, forceRefresh: false); await client.FetchAsync(Bounds, forceRefresh: true); Assert.Equal(2, handler.Requests); } [Fact] public async Task Falls_back_to_the_next_mirror_after_a_retryable_failure() { var handler = new StubHandler((request, _) => request.RequestUri!.Host == "first.example" ? new HttpResponseMessage(HttpStatusCode.TooManyRequests) { Content = new StringContent("busy") } : Json("""{"elements":[]}""")); var client = CreateClient(handler, "https://first.example/api", "https://second.example/api"); var path = await client.FetchAsync(Bounds, forceRefresh: false); Assert.True(File.Exists(path)); Assert.Equal(2, handler.Requests); } [Fact] public async Task Rejects_an_html_error_page_served_under_a_200() { // An overloaded Overpass instance answers "the server is probably too busy" as HTML with status 200. var handler = new StubHandler((_, _) => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("too busy", System.Text.Encoding.UTF8, "text/html"), }); var client = CreateClient(handler); var failure = await Assert.ThrowsAsync(() => client.FetchAsync(Bounds, forceRefresh: false)); Assert.Contains("text/html", failure.Message); Assert.False(Directory.EnumerateFiles(_cacheDirectory, "*.json").Any()); } [Fact] public async Task Reports_the_last_error_when_every_mirror_fails() { var handler = new StubHandler((_, _) => new HttpResponseMessage(HttpStatusCode.BadGateway) { Content = new StringContent("

gateway is down

"), }); var client = CreateClient(handler); var failure = await Assert.ThrowsAsync(() => client.FetchAsync(Bounds, forceRefresh: false)); Assert.Contains("gateway is down", failure.Message); } private static HttpResponseMessage Json(string body) => new(HttpStatusCode.OK) { Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json"), }; private OverpassClient CreateClient(StubHandler handler, params string[] endpoints) { var options = new OsmOptions { CacheDirectory = _cacheDirectory, MaxAttemptsPerEndpoint = 1, RequestTimeoutSeconds = 5, }; if (endpoints.Length > 0) options.Endpoints = endpoints; else options.Endpoints = ["https://only.example/api"]; return new OverpassClient( new HttpClient(handler), Options.Create(options), NullLogger.Instance); } public void Dispose() { if (Directory.Exists(_cacheDirectory)) Directory.Delete(_cacheDirectory, recursive: true); GC.SuppressFinalize(this); } private sealed class StubHandler(Func respond) : HttpMessageHandler { public int Requests { get; private set; } protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { Requests++; return Task.FromResult(respond(request, cancellationToken)); } } }