using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.AspNetCore.Identity; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Xunit; namespace JobTrackerApi.Tests; public sealed class AiOperationQueueTests { [Fact] public async Task Admission_is_pro_only_idempotent_bounded_and_contains_no_raw_payload() { await using var fixture = await Fixture.CreateAsync(perUserCapacity: 1); await fixture.SeedUserAsync("pro-1", pro: true); await using var scope = fixture.Provider.CreateAsyncScope(); using var owner = scope.ServiceProvider.GetRequiredService().UseBackgroundUser("pro-1"); var admission = scope.ServiceProvider.GetRequiredService(); var first = await admission.EnqueueAsync("synthetic.ai", "same", "job", "42", AiOperationPriorities.Interactive, default); var duplicate = await admission.EnqueueAsync("synthetic.ai", "same", "job", "42", AiOperationPriorities.Interactive, default); var full = await Assert.ThrowsAsync(() => admission.EnqueueAsync("synthetic.ai", "other", "job", "43", AiOperationPriorities.Scheduled, default)); Assert.True(first.Created); Assert.False(duplicate.Created); Assert.Equal(first.Operation.Id, duplicate.Operation.Id); Assert.Equal("/api/operations/" + first.Operation.Id.ToString("D"), first.StatusUrl); Assert.Equal("pro", first.Operation.EntitlementDecision); Assert.Equal("local_only", first.Operation.PrivacyPolicy); Assert.Equal("ai_queue_full", full.Code); Assert.Equal(15, full.RetryAfterSeconds); } [Fact] public async Task Free_and_ai_disabled_users_are_rejected_before_an_operation_is_created() { await using var fixture = await Fixture.CreateAsync(); await fixture.SeedUserAsync("free-1", pro: false); await fixture.SeedUserAsync("disabled-1", pro: true, aiEnabled: false); Assert.Equal(ProEntitlement.RequiredCode, (await fixture.RejectedAsync("free-1")).Code); Assert.Equal(ProEntitlement.DisabledCode, (await fixture.RejectedAsync("disabled-1")).Code); await using var scope = fixture.Provider.CreateAsyncScope(); Assert.Empty(await scope.ServiceProvider.GetRequiredService().UserOperations.IgnoreQueryFilters().ToListAsync()); } [Fact] public async Task Worker_resolves_owner_completes_once_and_creates_persistent_notification() { await using var fixture = await Fixture.CreateAsync(); await fixture.SeedUserAsync("pro-1", pro: true); await fixture.EnqueueAsync("pro-1", "run"); Assert.True(await fixture.Provider.GetRequiredService().RunOnceAsync(default)); Assert.False(await fixture.Provider.GetRequiredService().RunOnceAsync(default)); await using var scope = fixture.Provider.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); var operation = await db.UserOperations.IgnoreQueryFilters().AsNoTracking().SingleAsync(); Assert.Equal(OperationStatuses.Succeeded, operation.Status); Assert.Equal("synthetic-result", operation.ResultReference); Assert.Equal("ollama", operation.Provider); Assert.Equal("qwen-test", operation.Model); Assert.Equal("local_primary", operation.ProgressStage); Assert.Equal("pro-1", fixture.Handler.OwnerUserId); Assert.Equal("local_only", fixture.Handler.PrivacyPolicy); Assert.Equal("operation_succeeded", (await db.UserNotifications.IgnoreQueryFilters().AsNoTracking().SingleAsync()).Kind); } [Fact] public async Task Worker_classifies_retryable_failures_and_rechecks_live_entitlement() { await using var retryFixture = await Fixture.CreateAsync(); await retryFixture.SeedUserAsync("pro-1", pro: true); await retryFixture.EnqueueAsync("pro-1", "retry"); retryFixture.Handler.Failure = new AiOperationFailure("provider_unavailable", "Provider is unavailable.", retryable: true); Assert.True(await retryFixture.Provider.GetRequiredService().RunOnceAsync(default)); await using (var scope = retryFixture.Provider.CreateAsyncScope()) { var row = await scope.ServiceProvider.GetRequiredService().UserOperations.IgnoreQueryFilters().AsNoTracking().SingleAsync(); Assert.Equal(OperationStatuses.WaitingForRetry, row.Status); Assert.Equal("provider_unavailable", row.FailureCategory); } await using var downgradeFixture = await Fixture.CreateAsync(); await downgradeFixture.SeedUserAsync("pro-2", pro: true); await downgradeFixture.EnqueueAsync("pro-2", "downgrade"); await downgradeFixture.SetAiEnabledAsync("pro-2", false); Assert.True(await downgradeFixture.Provider.GetRequiredService().RunOnceAsync(default)); await using var verification = downgradeFixture.Provider.CreateAsyncScope(); var failed = await verification.ServiceProvider.GetRequiredService().UserOperations.IgnoreQueryFilters().AsNoTracking().SingleAsync(); Assert.Equal(OperationStatuses.Failed, failed.Status); Assert.Equal("entitlement_changed", failed.FailureCategory); } [Fact] public async Task Worker_records_provider_metadata_for_generation_failures() { await using var fixture = await Fixture.CreateAsync(); await fixture.SeedUserAsync("pro-1", pro: true); await fixture.EnqueueAsync("pro-1", "provider-failure"); fixture.Handler.GenerationFailure = new AiGenerationException( "provider_unavailable", "AI provider unavailable.", retryable: true, provider: "gemini", model: "gemini-test", routeReason: "external_fallback"); Assert.True(await fixture.Provider.GetRequiredService().RunOnceAsync(default)); await using var scope = fixture.Provider.CreateAsyncScope(); var row = await scope.ServiceProvider.GetRequiredService() .UserOperations.IgnoreQueryFilters().AsNoTracking().SingleAsync(); Assert.Equal(OperationStatuses.WaitingForRetry, row.Status); Assert.Equal("gemini", row.Provider); Assert.Equal("gemini-test", row.Model); Assert.Equal("external_fallback", row.ProgressStage); } private sealed class SyntheticHandler : IAiOperationHandler { public string TaskType => "synthetic.ai"; public string? OwnerUserId { get; private set; } public string? PrivacyPolicy { get; private set; } public AiOperationFailure? Failure { get; set; } public AiGenerationException? GenerationFailure { get; set; } public Task ExecuteAsync(AiOperationExecutionContext context, IServiceProvider services, CancellationToken cancellationToken) { if (Failure is not null) throw Failure; if (GenerationFailure is not null) throw GenerationFailure; OwnerUserId = services.GetRequiredService().UserId; PrivacyPolicy = context.EffectivePrivacyPolicy; return Task.FromResult(new AiOperationExecutionResult("synthetic-result", "ollama", "qwen-test", "local_primary")); } } private sealed class Fixture : IAsyncDisposable { private readonly SqliteConnection _connection; public ServiceProvider Provider { get; } public SyntheticHandler Handler { get; } private Fixture(SqliteConnection connection, ServiceProvider provider, SyntheticHandler handler) { _connection = connection; Provider = provider; Handler = handler; } public static async Task CreateAsync(int perUserCapacity = 10) { var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["AiQueue:PerUserCapacity"] = perUserCapacity.ToString(), ["AiQueue:GlobalCapacity"] = "100", ["AiQueue:HeartbeatSeconds"] = "5", ["Ai:ExternalProcessingEnabled"] = "false", }).Build(); var handler = new SyntheticHandler(); var services = new ServiceCollection(); services.AddSingleton(configuration); services.AddLogging(); services.AddHttpContextAccessor(); services.AddScoped(); services.AddScoped(provider => provider.GetRequiredService()); services.AddDbContext((_, options) => options.UseSqlite(connection)); services.AddIdentityCore().AddRoles().AddEntityFrameworkStores(); services.AddSingleton(TimeProvider.System); services.AddScoped(); services.AddSingleton(); services.AddSingleton(); services.AddScoped(); services.AddSingleton(handler); services.AddSingleton(); var provider = services.BuildServiceProvider(); await using var scope = provider.CreateAsyncScope(); await scope.ServiceProvider.GetRequiredService().Database.EnsureCreatedAsync(); return new Fixture(connection, provider, handler); } public async Task SeedUserAsync(string userId, bool pro, bool aiEnabled = true) { await using var scope = Provider.CreateAsyncScope(); var roles = scope.ServiceProvider.GetRequiredService>(); if (pro && !await roles.RoleExistsAsync("Premium")) await roles.CreateAsync(new IdentityRole("Premium")); var users = scope.ServiceProvider.GetRequiredService>(); var user = new ApplicationUser { Id = userId, UserName = $"{userId}@example.test", Email = $"{userId}@example.test", AiEnabled = aiEnabled }; Assert.True((await users.CreateAsync(user)).Succeeded); if (pro) Assert.True((await users.AddToRoleAsync(user, "Premium")).Succeeded); } public async Task RejectedAsync(string userId) { await using var scope = Provider.CreateAsyncScope(); using var owner = scope.ServiceProvider.GetRequiredService().UseBackgroundUser(userId); return await Assert.ThrowsAsync(() => scope.ServiceProvider.GetRequiredService() .EnqueueAsync("synthetic.ai", userId, "job", "42", AiOperationPriorities.Interactive, default)); } public async Task EnqueueAsync(string userId, string key) { await using var scope = Provider.CreateAsyncScope(); using var owner = scope.ServiceProvider.GetRequiredService().UseBackgroundUser(userId); await scope.ServiceProvider.GetRequiredService() .EnqueueAsync("synthetic.ai", key, "job", "42", AiOperationPriorities.Interactive, default); } public async Task SetAiEnabledAsync(string userId, bool enabled) { await using var scope = Provider.CreateAsyncScope(); var users = scope.ServiceProvider.GetRequiredService>(); var user = Assert.IsType(await users.FindByIdAsync(userId)); user.AiEnabled = enabled; Assert.True((await users.UpdateAsync(user)).Succeeded); } public async ValueTask DisposeAsync() { await Provider.DisposeAsync(); await _connection.DisposeAsync(); } } }