using System.Net;
using System.Text;
using EARSystem.Core;
using EARSystem.Infrastructure;
using EARSystem.Web.Controllers;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace EARSystem.Tests;
public class StubHttp(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> handler) : HttpMessageHandler
{
public List<Uri> Requests { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token) {
lock (Requests) Requests.Add(request.RequestUri!);
return handler(request, token);
}
public static HttpResponseMessage Json(string value) => new(HttpStatusCode.OK) { Content = new StringContent(value, Encoding.UTF8, "application/json") };
}
public class MapClientTests
{
private static AMapClient Map(StubHttp stub) => new(new HttpClient(stub), Options.Create(new AMapOptions { WebServiceKey = "test-key" }));
[Fact]
public async Task GcjCoordinatesNeverConvertTwice() {
var stub = new StubHttp((_,_)=>Task.FromResult(StubHttp.Json("{}")));
var coordinate = new Coordinate(120.155,30.274);
Assert.Equal(coordinate,await Map(stub).ConvertAsync(coordinate,default));
Assert.Empty(stub.Requests);
}
[Fact]
public async Task GpsCoordinatesUseConversionEndpointAndReturnGcj() {
var stub = new StubHttp((_,_)=>Task.FromResult(StubHttp.Json("""{"status":"1","locations":"120.159,30.271"}""")));
var result=await Map(stub).ConvertAsync(new(120.155,30.274,"WGS84"),default);
Assert.Equal("GCJ02",result.System);Assert.Equal(120.159,result.Longitude);
Assert.Equal("/v3/assistant/coordinate/convert",stub.Requests.Single().AbsolutePath);
Assert.Contains("coordsys=gps",stub.Requests.Single().Query);
}
[Fact]
public async Task OptionalFieldsRemainUnknownAndDuplicatePoisCollapse() {
var stub=new StubHttp((_,_)=>Task.FromResult(StubHttp.Json("""
{"status":"1","pois":[{"id":"B_TEST","name":"测试地点","location":"120.155,30.274","address":[],"business":{"rating":[],"cost":[]}},
{"id":"BAD","name":"无效坐标","location":[]}]}
""")));
var rows=await Map(stub).SearchAsync(new RecommendationQuery(),default);
Assert.Equal(6,stub.Requests.Count);var item=Assert.Single(rows);
Assert.Null(item.Price);Assert.Null(item.Rating);Assert.Null(item.IsOpen);
Assert.Null(item.Atmosphere);Assert.Null(item.CatalogId);Assert.Equal(0,item.FeedbackCount);
Assert.Equal("类型推测",item.AtmosphereSource);
}
[Fact]
public async Task KnownPriceAndRatingAreSeparateFromAtmosphere() {
var stub=new StubHttp((_,_)=>Task.FromResult(StubHttp.Json("""
{"status":"1","pois":[{"id":"B_TEST","name":"测试咖啡","type":"餐饮服务;咖啡厅","location":"120.155,30.274","business":{"rating":"4.8","cost":"28"}}]}
""")));
var rows=await Map(stub).SearchAsync(new RecommendationQuery { Categories=[PlaceCategory.Dining] },default);
var item=Assert.Single(rows);Assert.Equal(28m,item.Price);Assert.Equal(4.8,item.Rating);
Assert.Null(item.Atmosphere);
Assert.Equal(PlaceCategory.Cafe,item.Category);
}
[Theory]
[InlineData("""{"status":"0","info":"INVALID_USER_KEY"}""")]
[InlineData("""not-json""")]
public async Task FailuresAreSanitizedWithoutProviderKeys(string response) {
var stub=new StubHttp((_,_)=>Task.FromResult(StubHttp.Json(response)));
var error=await Assert.ThrowsAsync<MapServiceException>(()=>Map(stub).SearchAsync(new(),default));
Assert.DoesNotContain("test-key",error.Message);Assert.DoesNotContain("INVALID_USER_KEY",error.Message);
}
[Fact]
public async Task CancellationStopsPendingHttpRequests() {
var stub=new StubHttp(async (_,ct)=>{await Task.Delay(Timeout.Infinite,ct);return StubHttp.Json("{}");});
using var cancel=new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
await Assert.ThrowsAnyAsync<OperationCanceledException>(()=>Map(stub).SearchAsync(new(),cancel.Token));
}
private class Factory(HttpClient client) : IHttpClientFactory { public HttpClient CreateClient(string name)=>client; }
[Fact]
public async Task JsSecurityProxyIsRestrictedAndInjectsServerCredentials() {
var stub=new StubHttp((_,_)=>Task.FromResult(StubHttp.Json("{}")));
var controller=new MapProxyController(new Factory(new HttpClient(stub)),Options.Create(new AMapOptions{
JsSecurityCode="server-only-code",BrowserKey="browser-key"
})){
ControllerContext=new ControllerContext{HttpContext=new DefaultHttpContext()}
};
controller.Request.QueryString=new("?key=untrusted&jscode=untrusted");
Assert.IsType<NotFoundResult>(await controller.Proxy("https://evil.example",default));
Assert.Empty(stub.Requests);
Assert.IsType<FileContentResult>(await controller.Proxy("v4/map/styles",default));
var uri=Assert.Single(stub.Requests);
Assert.Equal("webapi.amap.com",uri.Host);
Assert.Contains("jscode=server-only-code",uri.Query);Assert.Contains("key=browser-key",uri.Query);
Assert.DoesNotContain("untrusted",uri.Query);
Assert.Equal("no-store",controller.Response.Headers.CacheControl.ToString());
}
}
using System.Net;
using System.Text;
using EARSystem.Core;
using EARSystem.Infrastructure;
using EARSystem.Web.Controllers;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace EARSystem.Tests;
public class StubHttp(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> handler) : HttpMessageHandler
{
public List<Uri> Requests { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token) {
lock (Requests) Requests.Add(request.RequestUri!);
return handler(request, token);
}
public static HttpResponseMessage Json(string value) => new(HttpStatusCode.OK) { Content = new StringContent(value, Encoding.UTF8, "application/json") };
}
public class MapClientTests
{
private static AMapClient Map(StubHttp stub) => new(new HttpClient(stub), Options.Create(new AMapOptions { WebServiceKey = "test-key" }));
[Fact]
public async Task GcjCoordinatesNeverConvertTwice() {
var stub = new StubHttp((_,_)=>Task.FromResult(StubHttp.Json("{}")));
var coordinate = new Coordinate(120.155,30.274);
Assert.Equal(coordinate,await Map(stub).ConvertAsync(coordinate,default));
Assert.Empty(stub.Requests);
}
[Fact]
public async Task GpsCoordinatesUseConversionEndpointAndReturnGcj() {
var stub = new StubHttp((_,_)=>Task.FromResult(StubHttp.Json("""{"status":"1","locations":"120.159,30.271"}""")));
var result=await Map(stub).ConvertAsync(new(120.155,30.274,"WGS84"),default);
Assert.Equal("GCJ02",result.System);Assert.Equal(120.159,result.Longitude);
Assert.Equal("/v3/assistant/coordinate/convert",stub.Requests.Single().AbsolutePath);
Assert.Contains("coordsys=gps",stub.Requests.Single().Query);
}
[Fact]
public async Task OptionalFieldsRemainUnknownAndDuplicatePoisCollapse() {
var stub=new StubHttp((_,_)=>Task.FromResult(StubHttp.Json("""
{"status":"1","pois":[{"id":"B_TEST","name":"测试地点","location":"120.155,30.274","address":[],"business":{"rating":[],"cost":[]}},
{"id":"BAD","name":"无效坐标","location":[]}]}
""")));
var rows=await Map(stub).SearchAsync(new RecommendationQuery(),default);
Assert.Equal(6,stub.Requests.Count);var item=Assert.Single(rows);
Assert.Null(item.Price);Assert.Null(item.Rating);Assert.Null(item.IsOpen);
Assert.Null(item.Atmosphere);Assert.Null(item.CatalogId);Assert.Equal(0,item.FeedbackCount);
Assert.Equal("类型推测",item.AtmosphereSource);
}
[Fact]
public async Task KnownPriceAndRatingAreSeparateFromAtmosphere() {
var stub=new StubHttp((_,_)=>Task.FromResult(StubHttp.Json("""
{"status":"1","pois":[{"id":"B_TEST","name":"测试咖啡","type":"餐饮服务;咖啡厅","location":"120.155,30.274","business":{"rating":"4.8","cost":"28"}}]}
""")));
var rows=await Map(stub).SearchAsync(new RecommendationQuery { Categories=[PlaceCategory.Dining] },default);
var item=Assert.Single(rows);Assert.Equal(28m,item.Price);Assert.Equal(4.8,item.Rating);
Assert.Null(item.Atmosphere);
Assert.Equal(PlaceCategory.Cafe,item.Category);
}
[Theory]
[InlineData("""{"status":"0","info":"INVALID_USER_KEY"}""")]
[InlineData("""not-json""")]
public async Task FailuresAreSanitizedWithoutProviderKeys(string response) {
var stub=new StubHttp((_,_)=>Task.FromResult(StubHttp.Json(response)));
var error=await Assert.ThrowsAsync<MapServiceException>(()=>Map(stub).SearchAsync(new(),default));
Assert.DoesNotContain("test-key",error.Message);Assert.DoesNotContain("INVALID_USER_KEY",error.Message);
}
[Fact]
public async Task CancellationStopsPendingHttpRequests() {
var stub=new StubHttp(async (_,ct)=>{await Task.Delay(Timeout.Infinite,ct);return StubHttp.Json("{}");});
using var cancel=new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
await Assert.ThrowsAnyAsync<OperationCanceledException>(()=>Map(stub).SearchAsync(new(),cancel.Token));
}
private class Factory(HttpClient client) : IHttpClientFactory { public HttpClient CreateClient(string name)=>client; }
[Fact]
public async Task JsSecurityProxyIsRestrictedAndInjectsServerCredentials() {
var stub=new StubHttp((_,_)=>Task.FromResult(StubHttp.Json("{}")));
var controller=new MapProxyController(new Factory(new HttpClient(stub)),Options.Create(new AMapOptions{
JsSecurityCode="server-only-code",BrowserKey="browser-key"
})){
ControllerContext=new ControllerContext{HttpContext=new DefaultHttpContext()}
};
controller.Request.QueryString=new("?key=untrusted&jscode=untrusted");
Assert.IsType<NotFoundResult>(await controller.Proxy("https://evil.example",default));
Assert.Empty(stub.Requests);
Assert.IsType<FileContentResult>(await controller.Proxy("v4/map/styles",default));
var uri=Assert.Single(stub.Requests);
Assert.Equal("webapi.amap.com",uri.Host);
Assert.Contains("jscode=server-only-code",uri.Query);Assert.Contains("key=browser-key",uri.Query);
Assert.DoesNotContain("untrusted",uri.Query);
Assert.Equal("no-store",controller.Response.Headers.CacheControl.ToString());
}
}