Files
jobtrackingapp/JobTrackerApi.MariaDb.Tests/CvImportReviewedPersistenceTests.cs
T

228 lines
15 KiB
C#

using System.Security.Claims;
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.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Moq;
using Xunit;
namespace JobTrackerApi.MariaDb.Tests;
public class CvImportReviewedPersistenceTests
{
private readonly Xunit.Abstractions.ITestOutputHelper _output;
public CvImportReviewedPersistenceTests(Xunit.Abstractions.ITestOutputHelper output) => _output = output;
[Fact]
public async Task Existing_profile_fields_survive_and_import_metadata_uses_the_canonical_revision()
{
await using var database = await MariaDbDatabase.CreateAsync();
await using var db = database.Context();
var run = await Seed(db);
await new CareerProfileService(db).SaveVersionAsync(Owner, new() { Summary = { "Preserve my summary" }, Contact = new() { GitHub = "https://github.com/existing" }, OtherSections = { new() { Title = "Private", Items = { "Preserve my custom fact" } } } }, "manual", 0, default);
(await db.Users.SingleAsync()).CurrentCvProfileVersion = 99;
await db.SaveChangesAsync();
var controller = Controller(db);
var clock = System.Diagnostics.Stopwatch.StartNew();
Assert.IsType<OkObjectResult>(await controller.ApplyReviewed(run.Id, new(1, new[] { new CvImportDecision("skills.0", "use") })));
clock.Stop();
_output.WriteLine("ReviewedImportApplyMs={0:F3}; provider=MariaDB12.2.2; includes=validation,merge,transaction,Identity_projection", clock.Elapsed.TotalMilliseconds);
await using var verify = database.Context();
var snapshot = await new CareerProfileService(verify).LoadSnapshotAsync(Owner, default);
Assert.Equal(2, snapshot.Revision);
Assert.Equal(2, snapshot.Profile.Metadata.ProfileVersion);
Assert.Equal("Preserve my summary", Assert.Single(snapshot.Profile.Summary));
Assert.Equal("https://github.com/existing", snapshot.Profile.Contact.GitHub);
Assert.Equal("Preserve my custom fact", Assert.Single(Assert.Single(snapshot.Profile.OtherSections).Items));
}
private const string Owner = "rd21-owner";
private static ProfileCvController Controller(JobTrackerContext db, bool identitySucceeds = true)
{
var user = db.Users.Single(u => u.Id == Owner);
var users = TestHostFactory.CreateUserManager();
users.Setup(u => u.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
users.Setup(u => u.UpdateAsync(user)).Returns(async () =>
{
if (!identitySucceeds) return IdentityResult.Failed(new IdentityError { Description = "Synthetic rollback" });
await db.SaveChangesAsync();
return IdentityResult.Success;
});
return new(users.Object, Mock.Of<ISummarizerService>(), db, null!)
{ ControllerContext = new() { HttpContext = new DefaultHttpContext() } };
}
private static async Task<CvExtractionRun> Seed(JobTrackerContext db)
{
db.Users.Add(new() { Id = Owner, UserName = "import-synthetic" });
var run = new CvExtractionRun
{
OwnerUserId = Owner, Trigger = "document_import", ParserVersion = ProfileCvController.DocumentParserVersion,
Status = "pending_review", RawExtractedText = "Skills\nC#\nSQL", DocumentJson = "{\"synthetic\":true}",
StructuredProfileJson = StructuredCvProfileJson.Serialize(new() { Skills = { "C#", "SQL" } }),
};
db.CvExtractionRuns.Add(run);
await db.SaveChangesAsync();
return run;
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Source_mapped_new_records_fill_empty_profile_or_roll_back_without_rejected_proposals(bool succeeds)
{
await using var database = await MariaDbDatabase.CreateAsync();
await using var db = database.Context();
var run = await Seed(db);
var blocks = new List<CvImportBlock>();
var sequence = 0;
CvImportRecordMapping Map(string category, params (string Name, string Value)[] values)
{
var fields = values.Select(value =>
{
var block = new CvImportBlock("b" + ++sequence, value.Value, "paragraph", 1);
blocks.Add(block);
return new CvImportMappedField(value.Name, value.Value, block.Id);
}).ToArray();
return new("record-" + sequence, category, null, fields);
}
var mappings = new[]
{
Map("contact", ("fullName", "Åse Synthetic"), ("email", "ase@example.test"), ("phone", "+47 400 00 001"), ("headline", "Engineer"), ("location", "Oslo"), ("website", "https://example.test"), ("linkedIn", "https://linkedin.com/in/synthetic"), ("gitHub", "https://github.com/synthetic"), ("linkLabel", "Portfolio"), ("linkUrl", "https://portfolio.example.test")),
Map("jobs", ("title", "Engineer"), ("company", "First AS"), ("location", "Oslo"), ("start", "2018"), ("end", "2020"), ("bullets", "Built tools."), ("skills", "Rust")),
Map("jobs", ("title", "Lead"), ("company", "Second AS"), ("start", "2021"), ("end", "Present"), ("isCurrent", "true"), ("bullets", "Led delivery.")),
Map("education", ("qualification", "Bachelor"), ("qualificationLevel", "Bachelor"), ("institution", "First University"), ("location", "Bergen"), ("start", "2010"), ("end", "2013"), ("details", "Databases")),
Map("education", ("qualification", "Master"), ("institution", "Second University"), ("start", "2013"), ("end", "2015"), ("details", "Distributed systems")),
Map("certifications", ("name", "Synthetic certificate"), ("issuer", "Example Institute"), ("date", "2022"), ("location", "Online"), ("details", "Recorded assessment")),
Map("projects", ("name", "Atlas"), ("role", "Developer"), ("location", "Remote"), ("start", "2020"), ("end", "2021"), ("bullets", "Built search."), ("skills", "React")),
Map("languages", ("name", "Norsk"), ("level", "Morsmål"), ("notes", "Stated level")), Map("languages", ("name", "English")),
Map("skills", ("value", "Rust"), ("value", "Python")), Map("summary", ("value", "Source-backed professional summary.")),
Map("otherSections", ("title", "Courses"), ("items", "Systems course")), Map("awards", ("value", "Synthetic award")),
Map("publications", ("value", "Synthetic publication")), Map("organisations", ("value", "Synthetic organisation")),
Map("interests", ("value", "Hiking")), Map("references", ("value", "Available on request")),
};
run.RawExtractedText = string.Join('\n', blocks.Select(b => b.Text));
run.DocumentJson = JsonSerializer.Serialize(new CvImportDocument(run.RawExtractedText, false, "application/pdf", 1, run.RawExtractedText.Length, "synthetic.pdf", blocks, Array.Empty<string>(), "synthetic-test-document"));
run.Artifact = new() { OwnerUserId = Owner, OriginalFileName = "synthetic.pdf", StoredFileName = "synthetic.pdf", MimeType = "application/pdf", Sha256 = new string('a', 64) };
await db.SaveChangesAsync();
var artifactId = run.ArtifactId;
var request = new ProfileCvController.ApplyReviewedRequest(0, new[] { new CvImportDecision("skills.0", "keep"), new CvImportDecision("skills.1", "keep") }, mappings);
var result = await Controller(db, succeeds).ApplyReviewed(run.Id, request);
if (succeeds)
{
Assert.IsType<OkObjectResult>(result);
Assert.IsType<OkObjectResult>(await Controller(db).ApplyReviewed(run.Id, request));
}
else Assert.IsType<BadRequestObjectResult>(result);
await db.SaveChangesAsync();
await using var verify = database.Context();
var snapshot = await new CareerProfileService(verify).LoadSnapshotAsync(Owner, default);
Assert.Equal(succeeds ? 1 : 0, snapshot.Revision);
Assert.Equal(succeeds ? 2 : 0, await verify.CareerExperiences.CountAsync());
Assert.Equal(succeeds ? 2 : 0, await verify.CareerEducations.CountAsync());
Assert.Equal(succeeds ? 2 : 0, await verify.CareerLanguages.CountAsync());
Assert.Equal(succeeds ? 1 : 0, await verify.CareerProjects.CountAsync());
Assert.Equal(succeeds ? 1 : 0, await verify.CareerCertifications.CountAsync());
Assert.Equal(succeeds ? new[] { "Python", "Rust" } : Array.Empty<string>(), await verify.CareerSkills.OrderBy(s => s.Name).Select(s => s.Name!).ToArrayAsync());
Assert.Equal(succeeds ? 1 : 0, await verify.CareerProfileVersions.CountAsync());
Assert.Equal(succeeds ? "applied" : "pending_review", (await verify.CvExtractionRuns.SingleAsync()).Status);
if (!succeeds) return;
var profile = snapshot.Profile;
Assert.Equal("Åse Synthetic", profile.Contact.FullName);
Assert.Equal("https://portfolio.example.test", Assert.Single(profile.Contact.Links).Url);
Assert.Equal("2018", profile.Jobs[0].Start);
Assert.Equal("Oslo", profile.Jobs[0].Location);
Assert.True(profile.Jobs[1].IsCurrent);
Assert.Null(profile.Languages.Single(l => l.Name == "English").Level);
Assert.Equal("Databases", Assert.Single(profile.Education[0].Details));
Assert.Equal("React", Assert.Single(profile.Projects[0].Skills));
Assert.Equal("Courses", Assert.Single(profile.OtherSections).Title);
Assert.Single(profile.Awards); Assert.Single(profile.Publications); Assert.Single(profile.Organisations); Assert.Single(profile.Interests); Assert.Single(profile.References); Assert.Single(profile.Summary);
Assert.DoesNotContain("SQL", StructuredCvProfileJson.SerializePersisted(profile));
Assert.Equal(artifactId, profile.Metadata.Fields["jobs.0.title"].SourceArtifactId);
Assert.Equal(run.Id, profile.Metadata.Fields["jobs.0.title"].SourceExtractionRunId);
Assert.Equal("user-source-mapping", profile.Metadata.Fields["jobs.0.title"].Method);
Assert.Contains("Mappings", (await verify.CvExtractionRuns.SingleAsync()).ReviewJson);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Reviewed_selection_commits_once_or_rolls_back_entire_projection(bool succeeds)
{
await using var database = await MariaDbDatabase.CreateAsync();
await using var db = database.Context();
var run = await Seed(db);
var request = new ProfileCvController.ApplyReviewedRequest(0, new[] { new CvImportDecision("skills.0", "use"), new CvImportDecision("skills.1", "keep") });
var result = await Controller(db, succeeds).ApplyReviewed(run.Id, request);
if (succeeds)
{
Assert.IsType<OkObjectResult>(result);
Assert.IsType<OkObjectResult>(await Controller(db).ApplyReviewed(run.Id, request));
Assert.IsType<ConflictObjectResult>(await Controller(db).ApplyReviewed(run.Id, new(0, new[] { new CvImportDecision("skills.1", "use") })));
}
else Assert.IsType<BadRequestObjectResult>(result);
await db.SaveChangesAsync();
await using var verify = database.Context();
var saved = await verify.CvExtractionRuns.SingleAsync();
Assert.Equal(succeeds ? "applied" : "pending_review", saved.Status);
Assert.Equal(succeeds, saved.ReviewJson is not null);
Assert.Equal(succeeds ? 1 : 0, await verify.CareerProfileVersions.CountAsync());
Assert.Equal(succeeds ? new[] { "C#" } : Array.Empty<string>(), await verify.CareerSkills.Select(s => s.Name).ToArrayAsync());
Assert.Equal(succeeds, (await verify.Users.SingleAsync()).CurrentCvExtractionRunId.HasValue);
}
[Fact]
public async Task Stale_review_does_not_overwrite_acknowledged_edit_or_mark_run_applied()
{
await using var database = await MariaDbDatabase.CreateAsync();
await using var db = database.Context();
var run = await Seed(db);
await new CareerProfileService(db).SaveVersionAsync(Owner, new() { Skills = { "Saved elsewhere" } }, "manual", 0, default);
Assert.IsType<ConflictObjectResult>(await Controller(db).ApplyReviewed(run.Id, new(0, new[] { new CvImportDecision("skills.0", "use") })));
await db.SaveChangesAsync();
await using var verify = database.Context();
Assert.Equal("Saved elsewhere", (await new CareerProfileService(verify).LoadStructuredAsync(Owner, default)).Skills.Single());
Assert.Equal("pending_review", (await verify.CvExtractionRuns.SingleAsync()).Status);
Assert.Single(await verify.CareerProfileVersions.ToListAsync());
}
[Fact]
public async Task Review_owner_boundary_and_unknown_fields_fail_closed()
{
await using var database = await MariaDbDatabase.CreateAsync();
await using var db = database.Context();
var run = await Seed(db);
Assert.IsType<BadRequestObjectResult>(await Controller(db).ApplyReviewed(run.Id, new(0, new[] { new CvImportDecision("metadata.owner", "correct", "other") })));
(await db.CvExtractionRuns.SingleAsync()).OwnerUserId = "another-owner";
await db.SaveChangesAsync();
Assert.IsType<NotFoundResult>(await Controller(db).GetImportReview(run.Id));
Assert.IsType<NotFoundResult>(await Controller(db).ApplyReviewed(run.Id, new(0, Array.Empty<CvImportDecision>())));
Assert.Empty(await db.CareerProfiles.ToListAsync());
}
[Fact]
public async Task Additive_import_schema_down_up_preserves_canonical_and_historical_facts()
{
await using var database = await MariaDbDatabase.CreateAsync();
await using var db = database.Context();
var run = await Seed(db);
await new CareerProfileService(db).SaveVersionAsync(Owner, new() { Skills = { "Existing" }, Contact = new() { GitHub = "https://github.com/example" } }, "manual", 0, default);
var before = (await db.CareerProfiles.SingleAsync()).ProfileJson;
await db.GetService<IMigrator>().MigrateAsync("20261025090000_AddCoverLetterDocumentDesign");
db.ChangeTracker.Clear();
await db.GetService<IMigrator>().MigrateAsync();
Assert.Equal(before, (await db.CareerProfiles.AsNoTracking().SingleAsync()).ProfileJson);
Assert.Equal(before, (await db.CareerProfileVersions.AsNoTracking().SingleAsync()).ProfileJson);
Assert.Null((await db.CvExtractionRuns.AsNoTracking().SingleAsync()).ReviewJson);
Assert.Equal("pending_review", (await db.CvExtractionRuns.AsNoTracking().SingleAsync()).Status);
Assert.Empty(await db.Database.GetPendingMigrationsAsync());
}
}