WebUI Redesign and Component 1: category mapper fixes
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Predictalytics.Application.Interfaces;
|
||||
using Predictalytics.Domain.Entities;
|
||||
using Predictalytics.Domain.Enums;
|
||||
using Predictalytics.Infrastructure.Data;
|
||||
using Predictalytics.Infrastructure.Providers.Polymarket;
|
||||
using Predictalytics.Infrastructure.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Predictalytics.Application.Tests.Services;
|
||||
|
||||
public class BackendRedesignTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AppDbContext_ShouldThrow_WhenReadOnlyDatabaseTrue()
|
||||
{
|
||||
// Arrange
|
||||
var inMemorySettings = new Dictionary<string, string?> {
|
||||
{"ApiSettings:ReadOnlyDatabase", "true"}
|
||||
};
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(inMemorySettings)
|
||||
.Build();
|
||||
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(databaseName: "ReadOnlyTestDb")
|
||||
.Options;
|
||||
|
||||
using var db = new AppDbContext(options, configuration);
|
||||
|
||||
db.Traders.Add(new Trader
|
||||
{
|
||||
Id = 1,
|
||||
Platform = PlatformType.Polymarket,
|
||||
PlatformUserId = "0x123",
|
||||
DisplayName = "Test"
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(async () => await db.SaveChangesAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PolymarketApiClient_ShouldUseCache_ForGetMarketAsync()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddMemoryCache();
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
var cache = serviceProvider.GetRequiredService<IMemoryCache>();
|
||||
|
||||
// Set up mock HTTP handler returning a list containing a GammaMarketResponse
|
||||
var mockResponse = new List<GammaMarketResponse>
|
||||
{
|
||||
new() { ConditionId = "cond-1", Question = "Will it rain?" }
|
||||
};
|
||||
|
||||
var handlerCallCount = 0;
|
||||
var mockHandler = new MockHttpMessageHandler(req =>
|
||||
{
|
||||
handlerCallCount++;
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK);
|
||||
response.Content = JsonContent.Create(mockResponse);
|
||||
return response;
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(mockHandler);
|
||||
var httpFactoryMock = new MockHttpClientFactory(httpClient);
|
||||
|
||||
var rateLimiterMock = new MockRateLimiter();
|
||||
var egressPoolMock = new MockEgressPoolService();
|
||||
|
||||
var client = new PolymarketApiClient(
|
||||
httpFactoryMock,
|
||||
rateLimiterMock,
|
||||
egressPoolMock,
|
||||
cache,
|
||||
NullLogger<PolymarketApiClient>.Instance
|
||||
);
|
||||
|
||||
// Act
|
||||
// 1. First fetch: should trigger HTTP handler
|
||||
var m1 = await client.GetMarketAsync("cond-1");
|
||||
|
||||
// 2. Second fetch: should serve from cache
|
||||
var m2 = await client.GetMarketAsync("cond-1");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(m1);
|
||||
Assert.Equal("cond-1", m1.ConditionId);
|
||||
Assert.Equal("cond-1", m2?.ConditionId);
|
||||
Assert.Equal(1, handlerCallCount); // Only 1 HTTP call made
|
||||
}
|
||||
|
||||
private class MockHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
public MockHttpClientFactory(HttpClient client) => _client = client;
|
||||
public HttpClient CreateClient(string name) => _client;
|
||||
}
|
||||
|
||||
private class MockRateLimiter : IRateLimiter
|
||||
{
|
||||
public Task WaitAsync(PlatformType platform, CancellationToken ct = default, string endpointGroup = "Default", string? channelId = null) => Task.CompletedTask;
|
||||
public bool CanMakeRequest(PlatformType platform, string endpointGroup = "Default", string? channelId = null) => true;
|
||||
public void ReportRateLimitExceeded(PlatformType platform, TimeSpan? retryAfter = null, string endpointGroup = "Default", string? channelId = null) { }
|
||||
}
|
||||
|
||||
private class MockEgressPoolService : IEgressPoolService
|
||||
{
|
||||
public string? GetNextChannelId() => null;
|
||||
public HttpMessageInvoker? GetInvoker(string channelId) => null;
|
||||
public void ReportFailure(string channelId, Exception exception) { }
|
||||
public void ReportSuccess(string channelId) { }
|
||||
}
|
||||
|
||||
private class MockHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _sender;
|
||||
|
||||
public MockHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> sender)
|
||||
{
|
||||
_sender = sender;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(_sender(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Predictalytics.Application.Interfaces;
|
||||
using Predictalytics.Application.Services;
|
||||
using Predictalytics.Domain.Enums;
|
||||
using Predictalytics.Infrastructure.Configuration;
|
||||
using Predictalytics.Infrastructure.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Predictalytics.Application.Tests.Services;
|
||||
|
||||
public class EgressPoolTests
|
||||
{
|
||||
[Fact]
|
||||
public void EgressPoolService_ShouldRoundRobin()
|
||||
{
|
||||
// Arrange
|
||||
var options = Options.Create(new EgressOptions
|
||||
{
|
||||
Channels = new List<EgressChannelOptions>
|
||||
{
|
||||
new() { Id = "ch-1", Type = EgressChannelType.Proxy, Value = "http://127.0.0.1:8080" },
|
||||
new() { Id = "ch-2", Type = EgressChannelType.Proxy, Value = "http://127.0.0.1:8081" },
|
||||
}
|
||||
});
|
||||
|
||||
var service = new EgressPoolService(options, NullLogger<EgressPoolService>.Instance);
|
||||
|
||||
// Act & Assert
|
||||
var first = service.GetNextChannelId();
|
||||
var second = service.GetNextChannelId();
|
||||
var third = service.GetNextChannelId();
|
||||
|
||||
Assert.Equal("ch-1", first);
|
||||
Assert.Equal("ch-2", second);
|
||||
Assert.Equal("ch-1", third);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EgressPoolService_ShouldFallbackToNull_WhenNoChannelsConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var options = Options.Create(new EgressOptions());
|
||||
var service = new EgressPoolService(options, NullLogger<EgressPoolService>.Instance);
|
||||
|
||||
// Act
|
||||
var channel = service.GetNextChannelId();
|
||||
|
||||
// Assert
|
||||
Assert.Null(channel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EgressPoolService_ShouldCooldown_OnFailures()
|
||||
{
|
||||
// Arrange
|
||||
var options = Options.Create(new EgressOptions
|
||||
{
|
||||
Channels = new List<EgressChannelOptions>
|
||||
{
|
||||
new() { Id = "ch-1", Type = EgressChannelType.Proxy, Value = "http://127.0.0.1:8080" },
|
||||
new() { Id = "ch-2", Type = EgressChannelType.Proxy, Value = "http://127.0.0.1:8081" },
|
||||
}
|
||||
});
|
||||
|
||||
var service = new EgressPoolService(options, NullLogger<EgressPoolService>.Instance);
|
||||
|
||||
// Fail ch-1 three times
|
||||
service.ReportFailure("ch-1", new Exception("Fail 1"));
|
||||
service.ReportFailure("ch-1", new Exception("Fail 2"));
|
||||
service.ReportFailure("ch-1", new Exception("Fail 3"));
|
||||
|
||||
// Act
|
||||
// ch-1 is in cooldown, so it should only return ch-2
|
||||
var first = service.GetNextChannelId();
|
||||
var second = service.GetNextChannelId();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("ch-2", first);
|
||||
Assert.Equal("ch-2", second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RateLimiterService_ShouldLimitPerChannel()
|
||||
{
|
||||
// Arrange
|
||||
var limiter = new RateLimiterService();
|
||||
|
||||
// Act
|
||||
// Set a block penalty for ch-1
|
||||
limiter.ReportRateLimitExceeded(PlatformType.Polymarket, TimeSpan.FromSeconds(5), "Data", "ch-1");
|
||||
|
||||
// Assert
|
||||
// ch-1 should be limited
|
||||
var canRequestCh1 = limiter.CanMakeRequest(PlatformType.Polymarket, "Data", "ch-1");
|
||||
// ch-2 should NOT be limited
|
||||
var canRequestCh2 = limiter.CanMakeRequest(PlatformType.Polymarket, "Data", "ch-2");
|
||||
// Global/Default channel should NOT be limited
|
||||
var canRequestGlobal = limiter.CanMakeRequest(PlatformType.Polymarket, "Data");
|
||||
|
||||
Assert.False(canRequestCh1);
|
||||
Assert.True(canRequestCh2);
|
||||
Assert.True(canRequestGlobal);
|
||||
}
|
||||
}
|
||||
@@ -52,4 +52,34 @@ public class MarketCategoryMapperTests
|
||||
var (_, subcategory) = MarketCategoryMapper.Map("", "NBA, Basketball", "Lakers to win?");
|
||||
Assert.Equal("NBA", subcategory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Map_CanonicalTag_WinsOverHeuristics()
|
||||
{
|
||||
// "Politics" is a canonical tag, even though it's last in tags, it should map to Politics.
|
||||
var (category, subcategory) = MarketCategoryMapper.Map("", "Ethiopia, Elections, Politics", "Next PM of Ethiopia?");
|
||||
Assert.Equal(MarketCategory.Politics, category);
|
||||
Assert.Equal("Ethiopia", subcategory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Map_Subcategory_FiltersNoiseAndCategorySelf()
|
||||
{
|
||||
// Category is Sports.
|
||||
// "Sports" should be skipped as subcategory because it is the category itself.
|
||||
// "Hide From New" and "2025 Predictions" should be skipped as noise tags.
|
||||
// "Soccer" should be picked.
|
||||
var (category, subcategory) = MarketCategoryMapper.Map("", "Hide From New, Sports, 2025 Predictions, Soccer, FIFA World Cup", "Lakers to win?");
|
||||
Assert.Equal(MarketCategory.Sports, category);
|
||||
Assert.Equal("Soccer", subcategory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Map_Subcategory_EmptyNormalization()
|
||||
{
|
||||
// When all tags are noise or category names, subcategory should normalize to empty string.
|
||||
var (category, subcategory) = MarketCategoryMapper.Map("", "Sports, Hide From New, 2026", "Lakers to win?");
|
||||
Assert.Equal(MarketCategory.Sports, category);
|
||||
Assert.Equal(string.Empty, subcategory);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user