Add canonical CV artifact pipeline
This commit is contained in:
@@ -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>>();
|
||||
|
||||
Reference in New Issue
Block a user