WebUI Redesign and Component 1: category mapper fixes

This commit is contained in:
Richard
2026-07-19 10:51:07 +02:00
parent c3e66d6cca
commit 7045002ca3
40 changed files with 7037 additions and 971 deletions
@@ -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);
}
}