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(() => 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()); var error = await Assert.ThrowsAsync(() => 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 CreateAsync(string responseJson, IReadOnlyList? roles = null) { var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); var currentUser = new Mock(); currentUser.SetupGet(service => service.UserId).Returns("owner"); var db = new JobTrackerContext( new DbContextOptionsBuilder().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(); 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 SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { RequestCount++; return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(responseJson, Encoding.UTF8, "application/json"), }); } } }