feat(cv)!: queue durable processing
CI and Deploy / test (pull_request) Failing after 1m39s
CI and Deploy / deploy (pull_request) Has been skipped

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:
cesnimda
2026-08-09 15:11:03 +02:00
parent 39e98049ea
commit c3c5af8329
9 changed files with 614 additions and 141 deletions
+34 -13
View File
@@ -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)