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(), "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(), "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(() => CvImportMapping.ValidateTargets(afterRemoval, new[] { mapping })); Assert.Throws(() => 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(() => Map(current, "First title Second title", first, first)); Assert.Throws(() => 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); } }