using System.Security.Claims; using System.Text; using JobTrackerApi.Controllers; using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Moq; using Xunit; namespace JobTrackerApi.Tests; public sealed class CvProcessingOperationTests { [Fact] public async Task Upload_is_durable_idempotent_and_stops_at_review_gate() { await using var fixture = await Fixture.CreateAsync(); await fixture.SeedUserAsync(); CvProcessingOperationResponse first; CvProcessingOperationResponse duplicate; await using (var scope = fixture.Provider.CreateAsyncScope()) { using var owner = scope.ServiceProvider.GetRequiredService().UseBackgroundUser("user-1"); var controller = CreateController(scope.ServiceProvider); first = Response(await controller.Upload(File())); duplicate = Response(await controller.Upload(File())); var runs = Assert.IsType((await controller.GetRuns()).Result); Assert.Equal(first.Operation?.Id, Assert.Single(Assert.IsAssignableFrom>(runs.Value)).Operation?.Id); } Assert.Equal(first.ExtractionRunId, duplicate.ExtractionRunId); Assert.Equal(first.Operation?.Id, duplicate.Operation?.Id); Assert.True(first.Created); Assert.False(duplicate.Created); await using (var scope = fixture.Provider.CreateAsyncScope()) { var db = scope.ServiceProvider.GetRequiredService(); Assert.Single(await db.CvExtractionRuns.IgnoreQueryFilters().ToListAsync()); Assert.Single(await db.CvUploadArtifacts.IgnoreQueryFilters().ToListAsync()); var operation = Assert.Single(await db.UserOperations.IgnoreQueryFilters().ToListAsync()); Assert.Equal(CvProcessingQueue.TaskType, operation.TaskType); Assert.Equal(CvProcessingQueue.SubjectType, operation.SubjectType); Assert.DoesNotContain("Ada", operation.SubjectId ?? string.Empty, StringComparison.OrdinalIgnoreCase); } 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 run = await db.CvExtractionRuns.IgnoreQueryFilters().SingleAsync(); var operation = await db.UserOperations.IgnoreQueryFilters().SingleAsync(); var user = await db.Users.SingleAsync(); Assert.Equal("pending_review", run.Status); Assert.Equal(OperationStatuses.Succeeded, operation.Status); Assert.Equal($"/api/profile-cv/runs/{run.Id}/diff", operation.ResultReference); Assert.Null(user.ProfileCvStructureJson); Assert.Null(user.CurrentCvExtractionRunId); Assert.Equal("operation_succeeded", (await db.UserNotifications.IgnoreQueryFilters().SingleAsync()).Kind); } } [Fact] public async Task Retryable_provider_failure_keeps_run_queued_and_records_provenance() { await using var fixture = await Fixture.CreateAsync(); await fixture.SeedUserAsync(profileCvText: "# Ada Lovelace\n\n## Skills\nC#"); fixture.GenerationFailure = new AiGenerationException( "provider_unavailable", "The local provider is unavailable.", retryable: true, provider: "ollama", model: "qwen-test", routeReason: "local_primary"); await using (var scope = fixture.Provider.CreateAsyncScope()) { using var owner = scope.ServiceProvider.GetRequiredService().UseBackgroundUser("user-1"); Assert.IsType(await CreateController(scope.ServiceProvider).Improve()); } Assert.True(await fixture.Provider.GetRequiredService().RunOnceAsync(default)); await using var verification = fixture.Provider.CreateAsyncScope(); var db = verification.ServiceProvider.GetRequiredService(); var operation = await db.UserOperations.IgnoreQueryFilters().SingleAsync(); var run = await db.CvExtractionRuns.IgnoreQueryFilters().SingleAsync(); Assert.Equal(OperationStatuses.WaitingForRetry, operation.Status); Assert.Equal("provider_unavailable", operation.FailureCategory); Assert.Equal("ollama", operation.Provider); Assert.Equal("qwen-test", operation.Model); Assert.Equal("local_primary", operation.ProgressStage); Assert.Equal("queued", run.Status); Assert.Null(run.CompletedAtUtc); } private static ProfileCvController CreateController(IServiceProvider services) { var controller = services.GetRequiredService(); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity( new[] { new Claim(ClaimTypes.NameIdentifier, "user-1") }, "test")), }, }; return controller; } private static CvProcessingOperationResponse Response(IActionResult result) => Assert.IsType(Assert.IsType(result).Value); private static FormFile File() { const string text = "# Ada Lovelace\n\n## Professional Summary\nBuilt reliable analytical systems.\n\n## Skills\nC#\nSQL"; var bytes = Encoding.UTF8.GetBytes(text); return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "synthetic-cv.md") { Headers = new HeaderDictionary(), ContentType = "text/markdown", }; } private sealed class Fixture : IAsyncDisposable { private readonly SqliteConnection _connection; private readonly string _tempRoot; public ServiceProvider Provider { get; } public AiGenerationException? GenerationFailure { get; set; } private Fixture(SqliteConnection connection, string tempRoot, ServiceProvider provider) { _connection = connection; _tempRoot = tempRoot; Provider = provider; } public static async Task CreateAsync() { var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); var tempRoot = Path.Combine(Path.GetTempPath(), $"jobtracker-cv-operation-{Guid.NewGuid():N}"); var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Data:Root"] = tempRoot, ["Data:CvArtifactsRoot"] = Path.Combine(tempRoot, "CvArtifacts"), ["AiQueue:HeartbeatSeconds"] = "5", ["Ai:ExternalProcessingEnabled"] = "false", }).Build(); var environment = new Mock(); environment.SetupGet(item => item.ContentRootPath).Returns(tempRoot); Fixture? fixture = null; var summarizer = new Mock(); summarizer.Setup(item => item.ExtractTextAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new AiTextExtractionResult( "# Ada Lovelace\n\n## Professional Summary\nBuilt reliable analytical systems.\n\n## Skills\nC#\nSQL", false, "text/markdown", null, 94, "synthetic-cv.md")); summarizer.Setup(item => item.SummarizeSectionAsync( It.Is(instruction => instruction.Contains("structured JSON", StringComparison.Ordinal)), It.IsAny(), 3200, 900)) .ReturnsAsync(""" {"version":"1","contact":{"fullName":"Ada Lovelace"},"summary":["Built reliable analytical systems."],"jobs":[],"education":[],"skills":["C#","SQL"],"languages":[],"interests":[],"otherSections":[]} """); summarizer.Setup(item => item.GenerateSectionWithMetadataAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(() => fixture!.GenerationFailure is null ? new AiGenerationResult("# Ada Lovelace\n\n## Skills\nC#", "ollama", "qwen-test", RouteReason: "local_primary") : throw fixture.GenerationFailure); 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.AddSingleton(new AppPaths(configuration, environment.Object)); services.AddSingleton(); services.AddSingleton(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddTransient(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(summarizer.Object); var provider = services.BuildServiceProvider(); await using var scope = provider.CreateAsyncScope(); await scope.ServiceProvider.GetRequiredService().Database.EnsureCreatedAsync(); fixture = new Fixture(connection, tempRoot, provider); return fixture; } public async Task SeedUserAsync(string? profileCvText = null) { await using var scope = Provider.CreateAsyncScope(); var roles = scope.ServiceProvider.GetRequiredService>(); Assert.True((await roles.CreateAsync(new IdentityRole("Premium"))).Succeeded); var users = scope.ServiceProvider.GetRequiredService>(); var user = new ApplicationUser { Id = "user-1", UserName = "user-1@example.test", Email = "user-1@example.test", EmailConfirmed = true, AiEnabled = true, ProfileCvText = profileCvText, }; Assert.True((await users.CreateAsync(user)).Succeeded); Assert.True((await users.AddToRoleAsync(user, "Premium")).Succeeded); } public async ValueTask DisposeAsync() { await Provider.DisposeAsync(); await _connection.DisposeAsync(); try { if (Directory.Exists(_tempRoot)) Directory.Delete(_tempRoot, recursive: true); } catch { } } } }