fix(cv-qa): preserve reviewed mapping order and verify merge journeys
This commit is contained in:
@@ -15,6 +15,36 @@ namespace JobTrackerApi.Tests;
|
||||
|
||||
public class CvImportMappingControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Mapping_order_is_part_of_replay_identity_while_exact_legacy_receipts_still_work()
|
||||
{
|
||||
await using var db = TestHostFactory.CreateInMemoryDb("user-1");
|
||||
var user = new ApplicationUser { Id = "user-1", UserName = "mapping-order" };
|
||||
db.Users.Add(user);
|
||||
var blocks = new[] { new CvImportBlock("b1", "Engineer First", "paragraph", 1), new CvImportBlock("b2", "Lead Second", "paragraph", 1) };
|
||||
var document = new CvImportDocument("Engineer First Lead Second", false, "application/pdf", 1, 26, "synthetic.pdf", blocks, Array.Empty<string>(), "synthetic");
|
||||
var run = new CvExtractionRun { OwnerUserId = user.Id, ParserVersion = ProfileCvController.DocumentParserVersion, Status = "pending_review", DocumentJson = JsonSerializer.Serialize(document), StructuredProfileJson = StructuredCvProfileJson.SerializePersisted(new()) };
|
||||
db.CvExtractionRuns.Add(run);
|
||||
await db.SaveChangesAsync();
|
||||
var users = TestHostFactory.CreateUserManager();
|
||||
users.Setup(u => u.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
users.Setup(u => u.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||
var controller = new ProfileCvController(users.Object, Mock.Of<ISummarizerService>(), db, null!) { ControllerContext = new() { HttpContext = new DefaultHttpContext() } };
|
||||
var mappings = new[]
|
||||
{
|
||||
new CvImportRecordMapping("z-first", "jobs", null, new[] { new CvImportMappedField("title", "Engineer", "b1"), new CvImportMappedField("company", "First", "b1") }),
|
||||
new CvImportRecordMapping("a-second", "jobs", null, new[] { new CvImportMappedField("title", "Lead", "b2"), new CvImportMappedField("company", "Second", "b2") }),
|
||||
};
|
||||
var request = new ProfileCvController.ApplyReviewedRequest(0, Array.Empty<CvImportDecision>(), mappings);
|
||||
Assert.IsType<OkObjectResult>(await controller.ApplyReviewed(run.Id, request));
|
||||
var receipt = System.Text.Json.Nodes.JsonNode.Parse(run.ReviewJson!)!;
|
||||
receipt["Hash"] = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(new { request.ExpectedVersion, Decisions = request.Decisions!.OrderBy(d => d.Id, StringComparer.Ordinal), Mappings = mappings.OrderBy(m => m.ClientId, StringComparer.Ordinal) })));
|
||||
run.ReviewJson = receipt.ToJsonString();
|
||||
await db.SaveChangesAsync();
|
||||
Assert.IsType<OkObjectResult>(await controller.ApplyReviewed(run.Id, request));
|
||||
Assert.IsType<ConflictObjectResult>(await controller.ApplyReviewed(run.Id, request with { Mappings = mappings.Reverse().ToArray() }));
|
||||
Assert.Single(await db.CareerProfileVersions.ToListAsync());
|
||||
}
|
||||
[Fact]
|
||||
public async Task Mapping_only_apply_is_revision_bound_audited_and_idempotent()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public class CvImportMergeScenarioTests
|
||||
{
|
||||
[Fact]
|
||||
public void Explicit_new_record_order_is_not_replaced_by_random_client_identifier_order()
|
||||
{
|
||||
var blocks = new[] { new CvImportBlock("b1", "Engineer First AS", "paragraph", 1), new CvImportBlock("b2", "Lead Second AS", "paragraph", 1) };
|
||||
var document = new CvImportDocument("Engineer First AS Lead Second AS", false, "application/pdf", 1, 35, "synthetic.pdf", blocks, Array.Empty<string>(), "synthetic");
|
||||
var mappings = new[]
|
||||
{
|
||||
new CvImportRecordMapping("z-first", "jobs", null, new[] { new CvImportMappedField("title", "Engineer", "b1"), new CvImportMappedField("company", "First AS", "b1") }),
|
||||
new CvImportRecordMapping("a-second", "jobs", null, new[] { new CvImportMappedField("title", "Lead", "b2"), new CvImportMappedField("company", "Second AS", "b2") }),
|
||||
};
|
||||
var result = CvImportMapping.Apply(new(), mappings, document, 1, 2, new string('a', 64));
|
||||
Assert.Equal(new[] { "First AS", "Second AS" }, result.Jobs.Select(job => job.Company));
|
||||
}
|
||||
private static StructuredCvJob Job(string company = "Example Labs", string? start = "2020", string? end = "2022") => new()
|
||||
{
|
||||
Title = "Engineer", Company = company, Start = start, End = end,
|
||||
};
|
||||
private static StructuredCvProfile Profile(params StructuredCvJob[] jobs) => new() { Jobs = jobs.ToList() };
|
||||
private static CvImportDecision[] Decide(StructuredCvProfile current, StructuredCvProfile incoming, string action) =>
|
||||
CvDocumentReview.Build(current, incoming).SelectMany(g => g.Fields).Select(f => new CvImportDecision(f.Id, action)).ToArray();
|
||||
private static CvImportDocument Source(string text) => new(text, false, "application/pdf", 1, text.Length, "scenario.pdf",
|
||||
new[] { new CvImportBlock("source", text, "paragraph", 1) }, Array.Empty<string>(), "docling");
|
||||
private static CvImportMappedField Field(string name, string value) => new(name, value, "source");
|
||||
private static StructuredCvProfile Map(StructuredCvProfile current, string text, params CvImportRecordMapping[] mappings) =>
|
||||
CvImportMapping.Apply(current, mappings, Source(text), 10, 20, "scenario-hash");
|
||||
|
||||
[Fact]
|
||||
public void Reimporting_the_same_complete_extracted_record_twice_reuses_its_existing_identity()
|
||||
{
|
||||
var saved = Job();
|
||||
saved.Id = "stable-job";
|
||||
saved.Location = "Saved location";
|
||||
saved.Bullets.Add("User-only achievement");
|
||||
var current = Profile(saved);
|
||||
var incomingJob = Job();
|
||||
incomingJob.Bullets.Add("Imported achievement");
|
||||
var incoming = Profile(incomingJob);
|
||||
incoming.Metadata.Fields["jobs.0.bullets.0"] = new() { SourceBlockId = "original-occurrence", SourceExtractionRunId = 10 };
|
||||
var first = CvDocumentReview.Apply(current, incoming, Decide(current, incoming, "use"));
|
||||
var second = CvDocumentReview.Apply(first, incoming, Decide(first, incoming, "use"));
|
||||
var retained = Assert.Single(second.Jobs);
|
||||
Assert.Equal("stable-job", retained.Id);
|
||||
Assert.Equal("Saved location", retained.Location);
|
||||
Assert.Equal(new[] { "User-only achievement", "Imported achievement" }, retained.Bullets);
|
||||
Assert.Equal("original-occurrence", second.Metadata.Fields["jobs.0.bullets.1"].SourceBlockId);
|
||||
Assert.Single(current.Jobs[0].Bullets);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("2020", "2023")]
|
||||
[InlineData("2020", "2021")]
|
||||
[InlineData("2021", "2024")]
|
||||
[InlineData("2020-01", "2022")]
|
||||
public void Revised_older_overlapping_or_differently_precise_dates_remain_distinct_until_reviewed(string start, string end)
|
||||
{
|
||||
var current = Profile(Job());
|
||||
var incoming = Profile(Job(start: start, end: end));
|
||||
Assert.Equal("New record", Assert.Single(CvDocumentReview.Build(current, incoming)).MatchDescription);
|
||||
var rejected = CvDocumentReview.Apply(current, incoming, Decide(current, incoming, "keep"));
|
||||
Assert.Single(rejected.Jobs);
|
||||
Assert.Equal("2022", rejected.Jobs[0].End);
|
||||
var approved = CvDocumentReview.Apply(current, incoming, Decide(current, incoming, "use"));
|
||||
Assert.Equal(2, approved.Jobs.Count);
|
||||
Assert.Equal("2020", approved.Jobs[0].Start);
|
||||
Assert.Equal("2022", approved.Jobs[0].End);
|
||||
Assert.Equal(start, approved.Jobs[1].Start);
|
||||
Assert.Equal(end, approved.Jobs[1].End);
|
||||
Assert.Null(approved.Jobs[1].StartDate);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Example Lab")]
|
||||
[InlineData("Example Labs AS")]
|
||||
[InlineData("Example Laboratories")]
|
||||
public void Similar_but_distinct_employers_are_never_fuzzy_merged(string company)
|
||||
{
|
||||
var current = Profile(Job());
|
||||
var incoming = Profile(Job(company));
|
||||
Assert.Equal("New record", Assert.Single(CvDocumentReview.Build(current, incoming)).MatchDescription);
|
||||
var approved = CvDocumentReview.Apply(current, incoming, Decide(current, incoming, "use"));
|
||||
Assert.Equal(new[] { "Example Labs", company }, approved.Jobs.Select(j => j.Company));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Incomplete_identity_is_new_until_the_user_explicitly_targets_a_current_record()
|
||||
{
|
||||
var current = Profile(Job());
|
||||
current.Jobs[0].Id = "saved";
|
||||
current.Jobs[0].Bullets.Add("Saved achievement");
|
||||
var partial = Profile(Job(end: null));
|
||||
partial.Jobs[0].Bullets.Add("New achievement");
|
||||
var group = Assert.Single(CvDocumentReview.Build(current, partial));
|
||||
Assert.Equal("New record", group.MatchDescription);
|
||||
Assert.All(group.Fields, f => Assert.Null(f.CurrentValue));
|
||||
var rejected = CvDocumentReview.Apply(current, partial, Decide(current, partial, "keep"));
|
||||
var updated = Map(rejected, "New achievement", new CvImportRecordMapping("explicit", "jobs", 0, new[] { Field("bullets", "New achievement") }));
|
||||
var retained = Assert.Single(updated.Jobs);
|
||||
Assert.Equal("saved", retained.Id);
|
||||
Assert.Equal("2022", retained.End);
|
||||
Assert.Equal(new[] { "Saved achievement", "New achievement" }, retained.Bullets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Correcting_an_unmatched_extracted_identity_does_not_silently_retarget_a_saved_record()
|
||||
{
|
||||
var current = Profile(Job());
|
||||
current.Jobs[0].Id = "saved";
|
||||
var incoming = Profile(Job("Example Typo"));
|
||||
var decisions = Decide(current, incoming, "use").Select(d => d.Id == "jobs.0.company" ? d with { Action = "correct", Value = "Example Labs" } : d).ToArray();
|
||||
var result = CvDocumentReview.Apply(current, incoming, decisions);
|
||||
Assert.Equal(2, result.Jobs.Count);
|
||||
Assert.Equal("saved", result.Jobs[0].Id);
|
||||
Assert.Null(result.Jobs[1].Id);
|
||||
Assert.All(result.Jobs, j => Assert.Equal("Example Labs", j.Company));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Correcting_a_matched_identity_keeps_the_original_target_even_when_another_record_has_the_corrected_name()
|
||||
{
|
||||
var current = Profile(Job("First Labs"), Job("Second Labs"));
|
||||
current.Jobs[0].Id = "first";
|
||||
current.Jobs[1].Id = "second";
|
||||
var incoming = Profile(Job("First Labs"));
|
||||
var result = CvDocumentReview.Apply(current, incoming, new[] { new CvImportDecision("jobs.0.company", "correct", "Second Labs") });
|
||||
Assert.Equal(2, result.Jobs.Count);
|
||||
Assert.Equal("first", result.Jobs[0].Id);
|
||||
Assert.Equal("Second Labs", result.Jobs[0].Company);
|
||||
Assert.Equal("second", result.Jobs[1].Id);
|
||||
Assert.Equal("Second Labs", result.Jobs[1].Company);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Explicit_target_adds_education_details_without_replacing_absent_identity_dates_or_evidence()
|
||||
{
|
||||
var current = new StructuredCvProfile { Education = { new() { Id = "degree", Qualification = "BSc", Institution = "Saved University", Location = "Oslo", Start = "2018", End = "2021", Details = { "Saved detail" } } } };
|
||||
current.Metadata.Fields["education.0.details.0"] = new() { SourceBlockId = "saved-source", SourceExtractionRunId = 3 };
|
||||
var result = Map(current, "Database specialisation", new CvImportRecordMapping("detail", "education", 0, new[] { Field("details", "Database specialisation") }));
|
||||
var education = Assert.Single(result.Education);
|
||||
Assert.Equal("degree", education.Id);
|
||||
Assert.Equal("BSc", education.Qualification);
|
||||
Assert.Equal("Saved University", education.Institution);
|
||||
Assert.Equal("Oslo", education.Location);
|
||||
Assert.Equal("2018", education.Start);
|
||||
Assert.Equal("2021", education.End);
|
||||
Assert.Equal(new[] { "Saved detail", "Database specialisation" }, education.Details);
|
||||
Assert.Equal("saved-source", result.Metadata.Fields["education.0.details.0"].SourceBlockId);
|
||||
Assert.Equal(20, result.Metadata.Fields["education.0.details.1"].SourceExtractionRunId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejected_extracted_choices_are_not_saved_as_a_side_effect_of_authoring_a_missing_record()
|
||||
{
|
||||
var current = new StructuredCvProfile { Contact = new() { FullName = "Saved name", GitHub = "https://github.com/saved" }, Skills = { "Saved skill" } };
|
||||
current.Metadata.Fields["contact.fullName"] = new() { SourceExtractionRunId = 2 };
|
||||
var incoming = Profile(Job("Rejected employer"));
|
||||
incoming.Contact.FullName = "Rejected name";
|
||||
incoming.Skills.Add("Rejected skill");
|
||||
var kept = CvDocumentReview.Apply(current, incoming, Decide(current, incoming, "keep"));
|
||||
var result = Map(kept, "Mapped Engineer | Reviewed employer", new CvImportRecordMapping("only-approved", "jobs", null,
|
||||
new[] { Field("title", "Mapped Engineer"), Field("company", "Reviewed employer") }));
|
||||
var job = Assert.Single(result.Jobs);
|
||||
Assert.Equal("Reviewed employer", job.Company);
|
||||
Assert.Equal("Saved name", result.Contact.FullName);
|
||||
Assert.Equal("https://github.com/saved", result.Contact.GitHub);
|
||||
Assert.Equal(new[] { "Saved skill" }, result.Skills);
|
||||
Assert.Equal(2, result.Metadata.Fields["contact.fullName"].SourceExtractionRunId);
|
||||
Assert.DoesNotContain(result.Metadata.Fields.Values, m => m.SourceSnippet is "Rejected name" or "Rejected skill");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_target_that_disappeared_from_the_current_snapshot_is_rejected_without_mutation()
|
||||
{
|
||||
var current = Profile(Job("First Labs"), Job("Second Labs"));
|
||||
var mapping = new CvImportRecordMapping("explicit", "jobs", 1, new[] { Field("bullets", "New achievement") });
|
||||
CvImportMapping.ValidateTargets(current, new[] { mapping });
|
||||
var afterRemoval = Profile(Job("First Labs"));
|
||||
Assert.Throws<ArgumentException>(() => CvImportMapping.ValidateTargets(afterRemoval, new[] { mapping }));
|
||||
Assert.Throws<ArgumentException>(() => Map(afterRemoval, "New achievement", mapping));
|
||||
Assert.Empty(afterRemoval.Jobs[0].Bullets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Repeated_explicit_target_list_approval_is_additive_and_keeps_the_first_evidence()
|
||||
{
|
||||
var current = Profile(Job());
|
||||
var mapping = new CvImportRecordMapping("explicit", "jobs", 0, new[] { Field("bullets", "New achievement") });
|
||||
var first = Map(current, "New achievement", mapping);
|
||||
var second = CvImportMapping.Apply(first, new[] { mapping }, Source("New achievement"), 99, 99, "later-hash");
|
||||
Assert.Single(second.Jobs);
|
||||
Assert.Equal(new[] { "New achievement" }, second.Jobs[0].Bullets);
|
||||
Assert.Equal(20, second.Metadata.Fields["jobs.0.bullets.0"].SourceExtractionRunId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Duplicate_client_ids_and_conflicting_scalar_mappings_fail_without_changing_the_input()
|
||||
{
|
||||
var current = Profile(Job());
|
||||
var first = new CvImportRecordMapping("same", "jobs", 0, new[] { Field("title", "First title") });
|
||||
var second = new CvImportRecordMapping("other", "jobs", 0, new[] { Field("title", "Second title") });
|
||||
Assert.Throws<ArgumentException>(() => Map(current, "First title Second title", first, first));
|
||||
Assert.Throws<ArgumentException>(() => Map(current, "First title Second title", first, second));
|
||||
Assert.Equal("Engineer", current.Jobs[0].Title);
|
||||
Assert.Empty(current.Metadata.Fields);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_mapping_target_is_a_deliberate_new_record_even_when_its_identity_already_exists()
|
||||
{
|
||||
var current = Profile(Job());
|
||||
current.Jobs[0].Id = "saved";
|
||||
var result = Map(current, "Engineer Example Labs", new CvImportRecordMapping("deliberately-new", "jobs", null,
|
||||
new[] { Field("title", "Engineer"), Field("company", "Example Labs") }));
|
||||
Assert.Equal(2, result.Jobs.Count);
|
||||
Assert.Equal("saved", result.Jobs[0].Id);
|
||||
Assert.Null(result.Jobs[1].Id);
|
||||
Assert.Null(result.Jobs[1].Start);
|
||||
Assert.Null(result.Jobs[1].End);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user