using System.IO.Compression;
using System.Net;
using System.Text;
using System.Text.Json;
using XFEEDToolBox.Core.Galaxy;
using XFEExtension.NetCore.XUnit.Attributes;
namespace XFEEDToolBox.Test;
[TestFixture]
public class GalacticPlaceSearchTests
{
private const string Result="""{"results":[{"name":"Station","system_name":"Sol","system_x":0,"system_y":0,"system_z":0}]}""";
private sealed class Handler(Func<HttpRequestMessage,CancellationToken,Task<HttpResponseMessage>> send):HttpMessageHandler
{
public int Calls;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,CancellationToken token){Calls++;return send(request,token);}
}
private static HttpResponseMessage CompressedResult()
{
using var bytes=new MemoryStream();using(var zip=new GZipStream(bytes,CompressionLevel.Fastest,true))zip.Write(Encoding.UTF8.GetBytes(Result));
var response=new HttpResponseMessage(HttpStatusCode.OK){Content=new ByteArrayContent(bytes.ToArray())};response.Content.Headers.ContentEncoding.Add("gzip");return response;
}
[Test] public async Task CompressedStationSearchReusesResultsButKeepsLocationAndPadFiltersSeparate()
{
var handler=new Handler(async(request,token)=>
{
Check.That(request.Headers.AcceptEncoding.Any(e=>e.Value=="gzip"),"Large inventories must use compression.");
using var body=JsonDocument.Parse(await request.Content!.ReadAsStringAsync(token));
Check.That(body.RootElement.GetProperty("size").GetInt32()<=5,"Nearest-place lookup must not download whole pages of module inventories.");
return CompressedResult();
});
using var http=new HttpClient(handler);var service=new GalacticPlaces(http);
var first=await service.SearchAsync(GalacticPlaceKind.MaterialTrader,"Raw",new(0,0,0));
Check.Equal("Station",first.Single().Name);Check.Equal(1,handler.Calls);
var cached=await service.SearchAsync(GalacticPlaceKind.MaterialTrader,"Raw",new(0,0,0));Check.Equal(1,handler.Calls);Check.That(ReferenceEquals(first,cached));
await service.SearchAsync(GalacticPlaceKind.MaterialTrader,"Raw",new(1,0,0));
await service.SearchAsync(GalacticPlaceKind.MaterialTrader,"Raw",new(1,0,0),largePad:true);Check.Equal(3,handler.Calls);
using var stop=new CancellationTokenSource();stop.Cancel();
try{await service.SearchAsync(GalacticPlaceKind.MaterialTrader,"Raw",new(0,0,0),token:stop.Token);throw new InvalidOperationException("Cached queries must respect cancellation.");}
catch(OperationCanceledException){Check.Equal(3,handler.Calls);}
}
[Test] public async Task TemporaryServiceFailureRetriesOnceAndPermanentFailureReturnsImmediately()
{
var calls=0;var handler=new Handler((_,_)=>Task.FromResult(++calls==1?new HttpResponseMessage(HttpStatusCode.ServiceUnavailable):CompressedResult()));
using var http=new HttpClient(handler);var service=new GalacticPlaces(http);
Check.Equal(1,(await service.SearchAsync(GalacticPlaceKind.HighTech,"",new(0,0,0))).Count);Check.Equal(2,handler.Calls);
var bad=new Handler((_,_)=>Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest)));using var badHttp=new HttpClient(bad);
try{await new GalacticPlaces(badHttp).SearchAsync(GalacticPlaceKind.HighTech,"",new(0,0,0));throw new InvalidOperationException("Expected HTTP failure.");}
catch(HttpRequestException){Check.Equal(1,bad.Calls);}
}
private sealed class WaitingBody:Stream
{
public override bool CanRead=>true;public override bool CanSeek=>false;public override bool CanWrite=>false;
public override long Length=>throw new NotSupportedException();public override long Position{get=>throw new NotSupportedException();set=>throw new NotSupportedException();}
public override async ValueTask<int> ReadAsync(Memory<byte> buffer,CancellationToken token=default){await Task.Delay(Timeout.Infinite,token);return 0;}
public override int Read(byte[] b,int o,int c)=>throw new NotSupportedException();public override void Flush(){}public override long Seek(long o,SeekOrigin s)=>throw new NotSupportedException();public override void SetLength(long l)=>throw new NotSupportedException();public override void Write(byte[] b,int o,int c)=>throw new NotSupportedException();
}
[Test] public async Task UserCancellationInterruptsTheResponseBodyAndDoesNotRetryOrPopulateTheCache()
{
var handler=new Handler((_,_)=>Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK){Content=new StreamContent(new WaitingBody())}));
using var http=new HttpClient(handler);var service=new GalacticPlaces(http);using var stop=new CancellationTokenSource(TimeSpan.FromMilliseconds(40));
var at=System.Diagnostics.Stopwatch.StartNew();
try{await service.SearchAsync(GalacticPlaceKind.MaterialTrader,"Encoded",new(0,0,0),token:stop.Token);throw new InvalidOperationException("Expected cancellation.");}
catch(OperationCanceledException){Check.That(at.Elapsed<TimeSpan.FromSeconds(1));Check.Equal(1,handler.Calls);}
}
}
using System.IO.Compression;
using System.Net;
using System.Text;
using System.Text.Json;
using XFEEDToolBox.Core.Galaxy;
using XFEExtension.NetCore.XUnit.Attributes;
namespace XFEEDToolBox.Test;
[TestFixture]
public class GalacticPlaceSearchTests
{
private const string Result="""{"results":[{"name":"Station","system_name":"Sol","system_x":0,"system_y":0,"system_z":0}]}""";
private sealed class Handler(Func<HttpRequestMessage,CancellationToken,Task<HttpResponseMessage>> send):HttpMessageHandler
{
public int Calls;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,CancellationToken token){Calls++;return send(request,token);}
}
private static HttpResponseMessage CompressedResult()
{
using var bytes=new MemoryStream();using(var zip=new GZipStream(bytes,CompressionLevel.Fastest,true))zip.Write(Encoding.UTF8.GetBytes(Result));
var response=new HttpResponseMessage(HttpStatusCode.OK){Content=new ByteArrayContent(bytes.ToArray())};response.Content.Headers.ContentEncoding.Add("gzip");return response;
}
[Test] public async Task CompressedStationSearchReusesResultsButKeepsLocationAndPadFiltersSeparate()
{
var handler=new Handler(async(request,token)=>
{
Check.That(request.Headers.AcceptEncoding.Any(e=>e.Value=="gzip"),"Large inventories must use compression.");
using var body=JsonDocument.Parse(await request.Content!.ReadAsStringAsync(token));
Check.That(body.RootElement.GetProperty("size").GetInt32()<=5,"Nearest-place lookup must not download whole pages of module inventories.");
return CompressedResult();
});
using var http=new HttpClient(handler);var service=new GalacticPlaces(http);
var first=await service.SearchAsync(GalacticPlaceKind.MaterialTrader,"Raw",new(0,0,0));
Check.Equal("Station",first.Single().Name);Check.Equal(1,handler.Calls);
var cached=await service.SearchAsync(GalacticPlaceKind.MaterialTrader,"Raw",new(0,0,0));Check.Equal(1,handler.Calls);Check.That(ReferenceEquals(first,cached));
await service.SearchAsync(GalacticPlaceKind.MaterialTrader,"Raw",new(1,0,0));
await service.SearchAsync(GalacticPlaceKind.MaterialTrader,"Raw",new(1,0,0),largePad:true);Check.Equal(3,handler.Calls);
using var stop=new CancellationTokenSource();stop.Cancel();
try{await service.SearchAsync(GalacticPlaceKind.MaterialTrader,"Raw",new(0,0,0),token:stop.Token);throw new InvalidOperationException("Cached queries must respect cancellation.");}
catch(OperationCanceledException){Check.Equal(3,handler.Calls);}
}
[Test] public async Task TemporaryServiceFailureRetriesOnceAndPermanentFailureReturnsImmediately()
{
var calls=0;var handler=new Handler((_,_)=>Task.FromResult(++calls==1?new HttpResponseMessage(HttpStatusCode.ServiceUnavailable):CompressedResult()));
using var http=new HttpClient(handler);var service=new GalacticPlaces(http);
Check.Equal(1,(await service.SearchAsync(GalacticPlaceKind.HighTech,"",new(0,0,0))).Count);Check.Equal(2,handler.Calls);
var bad=new Handler((_,_)=>Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest)));using var badHttp=new HttpClient(bad);
try{await new GalacticPlaces(badHttp).SearchAsync(GalacticPlaceKind.HighTech,"",new(0,0,0));throw new InvalidOperationException("Expected HTTP failure.");}
catch(HttpRequestException){Check.Equal(1,bad.Calls);}
}
private sealed class WaitingBody:Stream
{
public override bool CanRead=>true;public override bool CanSeek=>false;public override bool CanWrite=>false;
public override long Length=>throw new NotSupportedException();public override long Position{get=>throw new NotSupportedException();set=>throw new NotSupportedException();}
public override async ValueTask<int> ReadAsync(Memory<byte> buffer,CancellationToken token=default){await Task.Delay(Timeout.Infinite,token);return 0;}
public override int Read(byte[] b,int o,int c)=>throw new NotSupportedException();public override void Flush(){}public override long Seek(long o,SeekOrigin s)=>throw new NotSupportedException();public override void SetLength(long l)=>throw new NotSupportedException();public override void Write(byte[] b,int o,int c)=>throw new NotSupportedException();
}
[Test] public async Task UserCancellationInterruptsTheResponseBodyAndDoesNotRetryOrPopulateTheCache()
{
var handler=new Handler((_,_)=>Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK){Content=new StreamContent(new WaitingBody())}));
using var http=new HttpClient(handler);var service=new GalacticPlaces(http);using var stop=new CancellationTokenSource(TimeSpan.FromMilliseconds(40));
var at=System.Diagnostics.Stopwatch.StartNew();
try{await service.SearchAsync(GalacticPlaceKind.MaterialTrader,"Encoded",new(0,0,0),token:stop.Token);throw new InvalidOperationException("Expected cancellation.");}
catch(OperationCanceledException){Check.That(at.Elapsed<TimeSpan.FromSeconds(1));Check.Equal(1,handler.Calls);}
}
}