feat(cv)!: queue durable processing
CV upload now returns 202 with an owner-scoped operation instead of holding the request through parsing. Existing review approval remains required. BREAKING CHANGE: profile-cv upload responses use the durable operation contract.
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
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<CurrentUserService>().UseBackgroundUser("user-1");
|
||||
var controller = CreateController(scope.ServiceProvider);
|
||||
first = Response(await controller.Upload(File()));
|
||||
duplicate = Response(await controller.Upload(File()));
|
||||
var runs = Assert.IsType<OkObjectResult>((await controller.GetRuns()).Result);
|
||||
Assert.Equal(first.Operation?.Id, Assert.Single(Assert.IsAssignableFrom<IEnumerable<CvExtractionRunListItem>>(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<JobTrackerContext>();
|
||||
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<AiOperationWorker>().RunOnceAsync(default));
|
||||
Assert.False(await fixture.Provider.GetRequiredService<AiOperationWorker>().RunOnceAsync(default));
|
||||
|
||||
await using (var scope = fixture.Provider.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||||
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<CurrentUserService>().UseBackgroundUser("user-1");
|
||||
Assert.IsType<AcceptedResult>(await CreateController(scope.ServiceProvider).Improve());
|
||||
}
|
||||
|
||||
Assert.True(await fixture.Provider.GetRequiredService<AiOperationWorker>().RunOnceAsync(default));
|
||||
|
||||
await using var verification = fixture.Provider.CreateAsyncScope();
|
||||
var db = verification.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||||
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<ProfileCvController>();
|
||||
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<CvProcessingOperationResponse>(Assert.IsType<AcceptedResult>(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<Fixture> 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<string, string?>
|
||||
{
|
||||
["Data:Root"] = tempRoot,
|
||||
["Data:CvArtifactsRoot"] = Path.Combine(tempRoot, "CvArtifacts"),
|
||||
["AiQueue:HeartbeatSeconds"] = "5",
|
||||
["Ai:ExternalProcessingEnabled"] = "false",
|
||||
}).Build();
|
||||
var environment = new Mock<IHostEnvironment>();
|
||||
environment.SetupGet(item => item.ContentRootPath).Returns(tempRoot);
|
||||
Fixture? fixture = null;
|
||||
var summarizer = new Mock<ISummarizerService>();
|
||||
summarizer.Setup(item => item.ExtractTextAsync(
|
||||
It.IsAny<Stream>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.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<string>(instruction => instruction.Contains("structured JSON", StringComparison.Ordinal)),
|
||||
It.IsAny<string>(), 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<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.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<IConfiguration>(configuration);
|
||||
services.AddLogging();
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddScoped<CurrentUserService>();
|
||||
services.AddScoped<ICurrentUserService>(provider => provider.GetRequiredService<CurrentUserService>());
|
||||
services.AddDbContext<JobTrackerContext>((_, options) => options.UseSqlite(connection));
|
||||
services.AddIdentityCore<ApplicationUser>().AddRoles<IdentityRole>().AddEntityFrameworkStores<JobTrackerContext>();
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.AddSingleton(new AppPaths(configuration, environment.Object));
|
||||
services.AddSingleton<AiPrivacyPolicy>();
|
||||
services.AddSingleton<AiOperationExecutionScope>();
|
||||
services.AddScoped<UserOperationStore>();
|
||||
services.AddScoped<AiOperationAdmission>();
|
||||
services.AddScoped<ICvProcessingQueue, CvProcessingQueue>();
|
||||
services.AddTransient<ProfileCvController>();
|
||||
services.AddSingleton<IAiOperationHandler, CvProcessingOperationHandler>();
|
||||
services.AddSingleton<AiOperationWorker>();
|
||||
services.AddSingleton(summarizer.Object);
|
||||
var provider = services.BuildServiceProvider();
|
||||
await using var scope = provider.CreateAsyncScope();
|
||||
await scope.ServiceProvider.GetRequiredService<JobTrackerContext>().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<RoleManager<IdentityRole>>();
|
||||
Assert.True((await roles.CreateAsync(new IdentityRole("Premium"))).Succeeded);
|
||||
var users = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||
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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ public sealed class ProfileCvControllerTests
|
||||
var user = new ApplicationUser { Id = "user-1" };
|
||||
var userManager = CreateUserManager();
|
||||
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
ConfigureWorkerUser(userManager, user);
|
||||
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||
var aiService = new Mock<ISummarizerService>();
|
||||
aiService
|
||||
@@ -76,9 +77,9 @@ public sealed class ProfileCvControllerTests
|
||||
ContentType = "text/markdown"
|
||||
};
|
||||
|
||||
var result = await controller.Upload(file);
|
||||
var result = await UploadAndProcessAsync(controller, db, file);
|
||||
|
||||
Assert.IsType<OkObjectResult>(result);
|
||||
Assert.IsType<AcceptedResult>(result);
|
||||
var artifact = await db.CvUploadArtifacts.SingleAsync();
|
||||
var run = await db.CvExtractionRuns.SingleAsync();
|
||||
Assert.Equal("user-1", artifact.OwnerUserId);
|
||||
@@ -235,10 +236,10 @@ public sealed class ProfileCvControllerTests
|
||||
userManager.Setup(x => x.FindByIdAsync(user.Id)).ReturnsAsync(user);
|
||||
userManager.Setup(x => x.GetRolesAsync(user)).ReturnsAsync(new[] { "Premium" });
|
||||
var aiService = new Mock<ISummarizerService>();
|
||||
aiService.Setup(x => x.SummarizeSectionAsync(
|
||||
aiService.Setup(x => x.GenerateSectionWithMetadataAsync(
|
||||
It.Is<string>(instruction => instruction.StartsWith("Rewrite this CV", StringComparison.Ordinal)),
|
||||
It.IsAny<string>(), 1800, 500))
|
||||
.ReturnsAsync(user.ProfileCvText);
|
||||
It.IsAny<string>(), 1800, 500, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new AiGenerationResult(user.ProfileCvText));
|
||||
aiService.Setup(x => x.SummarizeSectionAsync(
|
||||
It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)),
|
||||
It.IsAny<string>(), 3200, 900))
|
||||
@@ -375,6 +376,7 @@ public sealed class ProfileCvControllerTests
|
||||
var user = new ApplicationUser { Id = "user-1" };
|
||||
var userManager = CreateUserManager();
|
||||
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
ConfigureWorkerUser(userManager, user);
|
||||
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||
var aiService = new Mock<ISummarizerService>();
|
||||
aiService
|
||||
@@ -398,9 +400,9 @@ public sealed class ProfileCvControllerTests
|
||||
ContentType = "application/pdf"
|
||||
};
|
||||
|
||||
var result = await controller.Upload(file);
|
||||
var result = await UploadAndProcessAsync(controller, db, file);
|
||||
|
||||
Assert.IsType<OkObjectResult>(result);
|
||||
Assert.IsType<AcceptedResult>(result);
|
||||
var savedRun = await db.CvExtractionRuns.SingleAsync();
|
||||
Assert.Equal(reconstructed, savedRun.NormalizedText);
|
||||
|
||||
@@ -425,6 +427,7 @@ public sealed class ProfileCvControllerTests
|
||||
var user = new ApplicationUser { Id = "user-1" };
|
||||
var userManager = CreateUserManager();
|
||||
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
ConfigureWorkerUser(userManager, user);
|
||||
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||
var aiService = new Mock<ISummarizerService>();
|
||||
aiService
|
||||
@@ -456,9 +459,9 @@ public sealed class ProfileCvControllerTests
|
||||
ContentType = "application/pdf"
|
||||
};
|
||||
|
||||
var result = await controller.Upload(file);
|
||||
var result = await UploadAndProcessAsync(controller, db, file);
|
||||
|
||||
Assert.IsType<OkObjectResult>(result);
|
||||
Assert.IsType<AcceptedResult>(result);
|
||||
normalizer.Verify(x => x.NormalizeAsync(It.Is<string>(text => text.Contains("Warwickshire County Council", StringComparison.Ordinal)), It.IsAny<CancellationToken>()), Times.Once);
|
||||
var savedRun = await db.CvExtractionRuns.SingleAsync();
|
||||
var structured = StructuredCvProfileJson.Deserialize(savedRun.StructuredProfileJson);
|
||||
@@ -475,6 +478,7 @@ public sealed class ProfileCvControllerTests
|
||||
var user = new ApplicationUser { Id = "user-1" };
|
||||
var userManager = CreateUserManager();
|
||||
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
ConfigureWorkerUser(userManager, user);
|
||||
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||
var aiService = new Mock<ISummarizerService>();
|
||||
aiService
|
||||
@@ -498,9 +502,9 @@ public sealed class ProfileCvControllerTests
|
||||
ContentType = "application/pdf"
|
||||
};
|
||||
|
||||
var result = await controller.Upload(file);
|
||||
var result = await UploadAndProcessAsync(controller, db, file);
|
||||
|
||||
Assert.IsType<OkObjectResult>(result);
|
||||
Assert.IsType<AcceptedResult>(result);
|
||||
var savedRun = await db.CvExtractionRuns.SingleAsync();
|
||||
var structured = StructuredCvProfileJson.Deserialize(savedRun.StructuredProfileJson);
|
||||
Assert.Equal("Connor Babbington", structured.Contact.FullName);
|
||||
@@ -1068,6 +1072,7 @@ public sealed class ProfileCvControllerTests
|
||||
var user = new ApplicationUser { Id = "user-1" };
|
||||
var userManager = CreateUserManager();
|
||||
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
ConfigureWorkerUser(userManager, user);
|
||||
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||
var aiService = new Mock<ISummarizerService>();
|
||||
aiService
|
||||
@@ -1098,9 +1103,9 @@ public sealed class ProfileCvControllerTests
|
||||
Headers = new HeaderDictionary(),
|
||||
ContentType = "text/markdown"
|
||||
};
|
||||
var result = await controller.Upload(file);
|
||||
var result = await UploadAndProcessAsync(controller, db, file);
|
||||
|
||||
Assert.IsType<OkObjectResult>(result);
|
||||
Assert.IsType<AcceptedResult>(result);
|
||||
var run = await db.CvExtractionRuns.SingleAsync();
|
||||
Assert.Contains("Built APIs", run.NormalizedText);
|
||||
Assert.Equal("Connor Babbington", StructuredCvProfileJson.Deserialize(run.StructuredProfileJson).Contact.FullName);
|
||||
@@ -1346,6 +1351,22 @@ public sealed class ProfileCvControllerTests
|
||||
return StructuredCvProfileJson.Normalize((StructuredCvProfile)result!);
|
||||
}
|
||||
|
||||
private static async Task<IActionResult> UploadAndProcessAsync(ProfileCvController controller, JobTrackerContext db, IFormFile file)
|
||||
{
|
||||
var accepted = Assert.IsType<AcceptedResult>(await controller.Upload(file));
|
||||
var run = await db.CvExtractionRuns.SingleAsync();
|
||||
var outcome = Assert.IsType<CvProcessingOutcome>(await controller.ProcessQueuedRunAsync(run.Id, CancellationToken.None));
|
||||
Assert.True(outcome.Succeeded, outcome.FailureMessage);
|
||||
return accepted;
|
||||
}
|
||||
|
||||
private static void ConfigureWorkerUser(Mock<UserManager<ApplicationUser>> userManager, ApplicationUser user)
|
||||
{
|
||||
user.AiEnabled = true;
|
||||
userManager.Setup(x => x.FindByIdAsync(user.Id)).ReturnsAsync(user);
|
||||
userManager.Setup(x => x.GetRolesAsync(user)).ReturnsAsync(new[] { "Premium" });
|
||||
}
|
||||
|
||||
private static ProfileCvController CreateController(UserManager<ApplicationUser> userManager, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ICvAiClassifier? cvAiClassifier = null, ICvAiNormalizer? cvAiNormalizer = null)
|
||||
{
|
||||
return new ProfileCvController(userManager, aiService, db, paths, null, cvAiClassifier ?? NoOpCvAiClassifier.Instance, cvAiNormalizer ?? NoOpCvAiNormalizer.Instance)
|
||||
|
||||
Reference in New Issue
Block a user