Add canonical CV artifact pipeline

This commit is contained in:
2026-03-28 23:32:54 +01:00
parent d8ab312f59
commit 107c181506
10 changed files with 619 additions and 82 deletions
+174 -28
View File
@@ -2,11 +2,15 @@ using System.Security.Claims;
using System.Text;
using System.Text.Json;
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.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
@@ -19,15 +23,14 @@ public sealed class ProfileCvControllerTests
[Fact]
public async Task Upload_rejects_unsupported_extension()
{
var user = new ApplicationUser();
var user = new ApplicationUser { Id = "user-1" };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
var aiService = new Mock<ISummarizerService>();
var controller = new ProfileCvController(userManager.Object, aiService.Object)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
await using var db = CreateDb();
var paths = CreatePaths();
var controller = CreateController(userManager.Object, aiService.Object, db, paths);
var file = new FormFile(new MemoryStream(Encoding.UTF8.GetBytes("hello")), 0, 5, "file", "resume.exe");
var result = await controller.Upload(file);
@@ -36,6 +39,116 @@ public sealed class ProfileCvControllerTests
Assert.True((badRequest.Value?.ToString() ?? string.Empty).Contains("supported", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task Upload_stores_cv_artifact_and_extraction_run_metadata()
{
var user = new ApplicationUser { Id = "user-1" };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var aiService = new Mock<ISummarizerService>();
aiService
.Setup(x => x.ExtractTextAsync(It.IsAny<Stream>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new AiTextExtractionResult("# Connor Babbington\n\n## Professional Summary\nBuilt APIs and UIs", false, "text/markdown", null, 62, "resume.md"));
aiService
.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny<string>(), 3200, 900))
.ReturnsAsync("""
{
"version":"1",
"contact":{"fullName":"Connor Babbington"},
"summary":["Built APIs and UIs"],
"jobs":[],
"education":[],
"skills":[],
"languages":[],
"interests":[],
"otherSections":[]
}
""");
await using var db = CreateDb();
var paths = CreatePaths();
var controller = CreateController(userManager.Object, aiService.Object, db, paths);
var file = new FormFile(new MemoryStream(Encoding.UTF8.GetBytes("# Connor Babbington\n\n## Professional Summary\nBuilt APIs and UIs")), 0, 62, "file", "resume.md")
{
Headers = new HeaderDictionary(),
ContentType = "text/markdown"
};
var result = await controller.Upload(file);
Assert.IsType<OkObjectResult>(result);
var artifact = await db.CvUploadArtifacts.SingleAsync();
var run = await db.CvExtractionRuns.SingleAsync();
Assert.Equal("user-1", artifact.OwnerUserId);
Assert.Equal("resume.md", artifact.OriginalFileName);
Assert.True(System.IO.File.Exists(artifact.StoragePath));
Assert.Equal("applied", run.Status);
Assert.Equal("upload", run.Trigger);
Assert.Equal(artifact.Id, run.ArtifactId);
Assert.Equal(run.Id, user.CurrentCvExtractionRunId);
Assert.Equal(artifact.Id, user.CurrentCvUploadArtifactId);
Assert.Equal(1, user.CurrentCvProfileVersion);
}
[Fact]
public async Task Reprocess_uses_latest_stored_artifact_and_creates_a_new_run()
{
var user = new ApplicationUser { Id = "user-1", CurrentCvProfileVersion = 1 };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var aiService = new Mock<ISummarizerService>();
aiService
.Setup(x => x.ExtractTextAsync(It.IsAny<Stream>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new AiTextExtractionResult("# Connor Babbington\n\n## Professional Summary\nRefined profile", false, "text/markdown", null, 49, "resume.md"));
aiService
.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny<string>(), 3200, 900))
.ReturnsAsync("""
{
"version":"1",
"contact":{"fullName":"Connor Babbington"},
"summary":["Refined profile"],
"jobs":[],
"education":[],
"skills":[],
"languages":[],
"interests":[],
"otherSections":[]
}
""");
await using var db = CreateDb();
var paths = CreatePaths();
var artifactPath = Path.Combine(paths.CvArtifactsRoot, "user-1", "resume.md");
Directory.CreateDirectory(Path.GetDirectoryName(artifactPath)!);
await System.IO.File.WriteAllTextAsync(artifactPath, "# Connor Babbington\n\n## Professional Summary\nLegacy profile");
db.CvUploadArtifacts.Add(new CvUploadArtifact
{
OwnerUserId = "user-1",
OriginalFileName = "resume.md",
StoredFileName = "resume.md",
MimeType = "text/markdown",
ByteSize = 58,
Sha256 = "ABC",
StoragePath = artifactPath,
UploadedAtUtc = DateTimeOffset.UtcNow,
});
await db.SaveChangesAsync();
var controller = CreateController(userManager.Object, aiService.Object, db, paths);
var result = await controller.Reprocess();
Assert.IsType<OkObjectResult>(result);
var run = await db.CvExtractionRuns.SingleAsync();
Assert.Equal("reprocess", run.Trigger);
Assert.Equal("applied", run.Status);
Assert.Equal(2, user.CurrentCvProfileVersion);
Assert.Equal(run.Id, user.CurrentCvExtractionRunId);
Assert.Equal("# Connor Babbington\n\n## Professional Summary\nRefined profile", user.ProfileCvText);
}
[Fact]
public async Task Upload_reconstructs_flattened_pdf_cv_before_save()
{
@@ -83,7 +196,7 @@ public sealed class ProfileCvControllerTests
}
""";
var user = new ApplicationUser();
var user = new ApplicationUser { Id = "user-1" };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
@@ -98,10 +211,9 @@ public sealed class ProfileCvControllerTests
.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), reconstructed, 3200, 900))
.ReturnsAsync(structuredJson);
var controller = new ProfileCvController(userManager.Object, aiService.Object)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
await using var db = CreateDb();
var paths = CreatePaths();
var controller = CreateController(userManager.Object, aiService.Object, db, paths);
var bytes = Encoding.UTF8.GetBytes("fake pdf bytes");
var file = new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "Resume.en.pdf")
@@ -133,7 +245,7 @@ public sealed class ProfileCvControllerTests
{
var rawExtraction = "connor.babbington@cesnimda.co.uk cesnimda.co.uk +47 41 33 44 70 E D U C A T I O N E X T E N D E D D I P L O M A N V Q L E V E L 3 I N I C T 2012 - 2015 F O L L O W A B O U T M E Mid-level system developer with eight years of experience in UK local government, with expertise in full-stack development, backend, frontend and server administration. I N T E R E S T S I am interested in PC and board games, as well as cooking and learning new skills. E X P E R I E N C E S Y S T E M D E V E L O P E R 2015 - 2023 Developed and maintained multiple full-stack applications using C#, Python, Ruby on Rails, SQL, and JavaScript. + Warwickshire County Council, UK C O N T A C T Native English speaker, Norwegian level A2/B1.";
var user = new ApplicationUser();
var user = new ApplicationUser { Id = "user-1" };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
@@ -148,10 +260,9 @@ public sealed class ProfileCvControllerTests
.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny<string>(), 3200, 900))
.ReturnsAsync("not-json");
var controller = new ProfileCvController(userManager.Object, aiService.Object)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
await using var db = CreateDb();
var paths = CreatePaths();
var controller = CreateController(userManager.Object, aiService.Object, db, paths);
var bytes = Encoding.UTF8.GetBytes("fake pdf bytes");
var file = new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "Resume.en.pdf")
@@ -180,6 +291,7 @@ public sealed class ProfileCvControllerTests
{
var user = new ApplicationUser
{
Id = "user-1",
ProfileCvText = "# Connor Babbington\n\n## Contact\nconnor@example.com\n+47 41 33 44 70\n\n## Professional Summary\nBuilt backend systems.\n\n## Work Experience\n### System Developer\nWarwickshire County Council\n2015 - 2023\n- Built APIs\n\n## Education\n### Warwickshire College\n2012 - 2015"
};
var structuredJson = """
@@ -225,10 +337,9 @@ public sealed class ProfileCvControllerTests
.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), user.ProfileCvText, 3200, 900))
.ReturnsAsync(structuredJson);
var controller = new ProfileCvController(userManager.Object, aiService.Object)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
await using var db = CreateDb();
var paths = CreatePaths();
var controller = CreateController(userManager.Object, aiService.Object, db, paths);
var result = await controller.Parse(new ProfileCvController.ParseCvRequest(user.ProfileCvText));
@@ -249,6 +360,7 @@ public sealed class ProfileCvControllerTests
{
var user = new ApplicationUser
{
Id = "user-1",
ProfileCvText = "# Connor Babbington\n\n## Professional Summary\nBuilt backend systems.\n\n## Skills\n.NET\nSQL\nAzure"
};
var userManager = CreateUserManager();
@@ -259,10 +371,9 @@ public sealed class ProfileCvControllerTests
.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), user.ProfileCvText, 3200, 900))
.ReturnsAsync("not-json");
var controller = new ProfileCvController(userManager.Object, aiService.Object)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
await using var db = CreateDb();
var paths = CreatePaths();
var controller = CreateController(userManager.Object, aiService.Object, db, paths);
var result = await controller.Parse(new ProfileCvController.ParseCvRequest(user.ProfileCvText));
@@ -280,7 +391,7 @@ public sealed class ProfileCvControllerTests
[Fact]
public async Task Upload_accepts_markdown_cv_and_saves_text()
{
var user = new ApplicationUser();
var user = new ApplicationUser { Id = "user-1" };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
@@ -304,10 +415,9 @@ public sealed class ProfileCvControllerTests
}
""");
var controller = new ProfileCvController(userManager.Object, aiService.Object)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
await using var db = CreateDb();
var paths = CreatePaths();
var controller = CreateController(userManager.Object, aiService.Object, db, paths);
var file = new FormFile(new MemoryStream(Encoding.UTF8.GetBytes("# Connor Babbington\n\n## Professional Summary\nBuilt APIs and UIs")), 0, 62, "file", "resume.md")
{
@@ -321,6 +431,42 @@ public sealed class ProfileCvControllerTests
Assert.Equal("Connor Babbington", StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson).Contact.FullName);
}
private static ProfileCvController CreateController(UserManager<ApplicationUser> userManager, ISummarizerService aiService, JobTrackerContext db, AppPaths paths)
{
return new ProfileCvController(userManager, aiService, db, paths)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
}
private static JobTrackerContext CreateDb(string userId = "user-1")
{
var options = new DbContextOptionsBuilder<JobTrackerContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var currentUser = new Mock<ICurrentUserService>();
currentUser.SetupGet(x => x.UserId).Returns(userId);
return new JobTrackerContext(options, currentUser.Object);
}
private static AppPaths CreatePaths()
{
var tempRoot = Path.Combine(Path.GetTempPath(), $"jobtracker-cv-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempRoot);
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Data:Root"] = tempRoot,
["Data:CvArtifactsRoot"] = Path.Combine(tempRoot, "CvArtifacts")
})
.Build();
var env = new Mock<IHostEnvironment>();
env.SetupGet(x => x.ContentRootPath).Returns(tempRoot);
return new AppPaths(config, env.Object);
}
private static Mock<UserManager<ApplicationUser>> CreateUserManager()
{
var store = new Mock<IUserStore<ApplicationUser>>();