192 lines
7.9 KiB
C#
192 lines
7.9 KiB
C#
using System.Net;
|
|
using System.Text;
|
|
using JobTrackerApi.Data;
|
|
using JobTrackerApi.Models;
|
|
using JobTrackerApi.Services;
|
|
using JobTrackerApi.Tests.TestSupport;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.Data.Sqlite;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
using Moq;
|
|
using Xunit;
|
|
|
|
namespace JobTrackerApi.Tests;
|
|
|
|
public sealed class MeteredSummarizerServiceTests
|
|
{
|
|
[Fact]
|
|
public async Task Usage_limit_handler_returns_stable_429_problem()
|
|
{
|
|
var context = new DefaultHttpContext { TraceIdentifier = "trace-ai-limit" };
|
|
context.Response.Body = new MemoryStream();
|
|
|
|
Assert.True(await new AiUsageLimitExceptionHandler().TryHandleAsync(
|
|
context,
|
|
new AiUsageLimitException("monthly_ai_calls_exhausted", "Monthly AI limit reached."),
|
|
default));
|
|
|
|
Assert.Equal(StatusCodes.Status429TooManyRequests, context.Response.StatusCode);
|
|
context.Response.Body.Position = 0;
|
|
using var reader = new StreamReader(context.Response.Body);
|
|
var body = await reader.ReadToEndAsync();
|
|
Assert.Contains("monthly_ai_calls_exhausted", body, StringComparison.Ordinal);
|
|
Assert.Contains("trace-ai-limit", body, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Successful_synchronous_generation_is_admitted_and_finalized()
|
|
{
|
|
await using var fixture = await Fixture.CreateAsync("{\"summary\":\"measured result\"}");
|
|
|
|
var result = await fixture.Service.SummarizeAsync("measured input", 150, 30);
|
|
|
|
Assert.Equal("measured result", result);
|
|
var usage = Assert.Single(await fixture.Db.AiUsageRecords.AsNoTracking().ToListAsync());
|
|
Assert.Equal("synchronous", usage.SourceType);
|
|
Assert.Equal("synchronous.summary", usage.TaskType);
|
|
Assert.Equal("measured input".Length, usage.InputCharacterCount);
|
|
Assert.Equal("measured result".Length, usage.OutputCharacterCount);
|
|
Assert.Equal((usage.InputCharacterCount + usage.OutputCharacterCount + 3) / 4, usage.EstimatedTokenCount);
|
|
Assert.Equal(1, fixture.Handler.RequestCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Exhausted_limit_rejects_before_provider_call()
|
|
{
|
|
await using var fixture = await Fixture.CreateAsync("{\"summary\":\"must not run\"}");
|
|
fixture.Db.AiUsageRecords.Add(new AiUsageRecord
|
|
{
|
|
OwnerUserId = "owner",
|
|
SourceType = "synthetic",
|
|
SourceId = "monthly-limit",
|
|
TaskType = "synthetic",
|
|
CallCount = 250,
|
|
EstimatedTokenCount = 1,
|
|
CreatedAtUtc = DateTimeOffset.UtcNow,
|
|
});
|
|
await fixture.Db.SaveChangesAsync();
|
|
|
|
var error = await Assert.ThrowsAsync<AiUsageLimitException>(() => fixture.Service.SummarizeAsync("blocked"));
|
|
|
|
Assert.Equal("monthly_ai_calls_exhausted", error.Code);
|
|
Assert.Equal(0, fixture.Handler.RequestCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Existing_metered_scope_bypasses_the_synchronous_decorator()
|
|
{
|
|
await using var fixture = await Fixture.CreateAsync("{\"summary\":\"already metered\"}");
|
|
|
|
using (fixture.UsageScope.Suppress())
|
|
Assert.Equal("already metered", await fixture.Service.SummarizeAsync("workspace input"));
|
|
|
|
Assert.Empty(await fixture.Db.AiUsageRecords.AsNoTracking().ToListAsync());
|
|
Assert.Equal(1, fixture.Handler.RequestCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Durable_operation_scope_does_not_create_a_second_usage_record()
|
|
{
|
|
await using var fixture = await Fixture.CreateAsync("{\"summary\":\"operation result\"}");
|
|
var lease = new UserOperationLease(
|
|
Guid.NewGuid(), "owner", "lease", "strategy.snapshot", "local_only", "job", "1", 1, null);
|
|
|
|
using (fixture.OperationScope.Use(new AiOperationExecutionContext(lease, "local_only")))
|
|
Assert.Equal("operation result", await fixture.Service.SummarizeAsync("operation input"));
|
|
|
|
Assert.Empty(await fixture.Db.AiUsageRecords.AsNoTracking().ToListAsync());
|
|
Assert.Equal(1, fixture.Handler.RequestCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Free_owner_is_rejected_at_the_shared_provider_boundary()
|
|
{
|
|
await using var fixture = await Fixture.CreateAsync("{\"summary\":\"must not run\"}", Array.Empty<string>());
|
|
|
|
var error = await Assert.ThrowsAsync<AiUsageLimitException>(() => fixture.Service.SummarizeAsync("blocked"));
|
|
|
|
Assert.Equal("ai_not_available", error.Code);
|
|
Assert.Empty(await fixture.Db.AiUsageRecords.AsNoTracking().ToListAsync());
|
|
Assert.Equal(0, fixture.Handler.RequestCount);
|
|
}
|
|
|
|
private sealed class Fixture : IAsyncDisposable
|
|
{
|
|
private readonly SqliteConnection _connection;
|
|
public JobTrackerContext Db { get; }
|
|
public MeteredSummarizerService Service { get; }
|
|
public CountingHandler Handler { get; }
|
|
public AiUsageExecutionScope UsageScope { get; }
|
|
public AiOperationExecutionScope OperationScope { get; }
|
|
|
|
private Fixture(
|
|
SqliteConnection connection,
|
|
JobTrackerContext db,
|
|
MeteredSummarizerService service,
|
|
CountingHandler handler,
|
|
AiUsageExecutionScope usageScope,
|
|
AiOperationExecutionScope operationScope)
|
|
{
|
|
_connection = connection;
|
|
Db = db;
|
|
Service = service;
|
|
Handler = handler;
|
|
UsageScope = usageScope;
|
|
OperationScope = operationScope;
|
|
}
|
|
|
|
public static async Task<Fixture> CreateAsync(string responseJson, IReadOnlyList<string>? roles = null)
|
|
{
|
|
var connection = new SqliteConnection("Data Source=:memory:");
|
|
await connection.OpenAsync();
|
|
var currentUser = new Mock<ICurrentUserService>();
|
|
currentUser.SetupGet(service => service.UserId).Returns("owner");
|
|
var db = new JobTrackerContext(
|
|
new DbContextOptionsBuilder<JobTrackerContext>().UseSqlite(connection).Options,
|
|
currentUser.Object);
|
|
await db.Database.EnsureCreatedAsync();
|
|
|
|
var handler = new CountingHandler(responseJson);
|
|
var client = new HttpClient(handler) { BaseAddress = new Uri("http://ai.test") };
|
|
var factory = new Mock<IHttpClientFactory>();
|
|
factory.Setup(item => item.CreateClient("ai-service")).Returns(client);
|
|
var inner = new SummarizerService(factory.Object, new MemoryCache(new MemoryCacheOptions()));
|
|
var user = new ApplicationUser { Id = "owner", AiEnabled = true };
|
|
var users = TestHostFactory.CreateUserManager(user);
|
|
users.Setup(item => item.GetRolesAsync(user)).ReturnsAsync((roles ?? new[] { "Premium" }).ToList());
|
|
var usageScope = new AiUsageExecutionScope();
|
|
var operationScope = new AiOperationExecutionScope();
|
|
var service = new MeteredSummarizerService(
|
|
inner,
|
|
db,
|
|
users.Object,
|
|
new AiUsageMeter(db, TimeProvider.System),
|
|
operationScope,
|
|
usageScope);
|
|
return new Fixture(connection, db, service, handler, usageScope, operationScope);
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await Db.DisposeAsync();
|
|
await _connection.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
public sealed class CountingHandler(string responseJson) : HttpMessageHandler
|
|
{
|
|
public int RequestCount { get; private set; }
|
|
|
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
|
{
|
|
RequestCount++;
|
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new StringContent(responseJson, Encoding.UTF8, "application/json"),
|
|
});
|
|
}
|
|
}
|
|
}
|