using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.EntityFrameworkCore; using Moq; using Xunit; namespace JobTrackerApi.Tests; public sealed class CareerProfileServiceTests { private static JobTrackerContext NewContext(string userId) { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; var currentUser = new Mock(); currentUser.SetupGet(service => service.UserId).Returns(userId); return new JobTrackerContext(options, currentUser.Object); } [Fact] public async Task SaveVersionAsync_assigns_stable_ids_to_items_missing_one() { await using var db = NewContext("user-1"); var service = new CareerProfileService(db); var profile = new StructuredCvProfile { Jobs = { new StructuredCvJob { Title = "Engineer", Company = "Acme" } }, }; var saved = await service.SaveVersionAsync("user-1", profile, "upload", default); Assert.False(string.IsNullOrWhiteSpace(saved.Jobs[0].Id)); } [Fact] public async Task SaveVersionAsync_preserves_existing_ids_across_saves() { await using var db = NewContext("user-1"); var service = new CareerProfileService(db); var profile = new StructuredCvProfile { Jobs = { new StructuredCvJob { Title = "Engineer", Company = "Acme" } }, }; await service.SaveVersionAsync("user-1", profile, "upload", default); var firstId = profile.Jobs[0].Id; await service.SaveVersionAsync("user-1", profile, "rebuild", default); Assert.Equal(firstId, profile.Jobs[0].Id); } [Theory] [InlineData("January 2020", "2020-01")] [InlineData("Mar 2019", "2019-03")] [InlineData("03/2019", "2019-03")] [InlineData("2019-03", "2019-03")] [InlineData("Present", null)] [InlineData("2020", null)] [InlineData(null, null)] public void CvDateNormalizer_parses_common_formats_without_guessing(string? input, string? expected) { Assert.Equal(expected, CvDateNormalizer.TryParseYearMonth(input)); } [Fact] public async Task SaveVersionAsync_persists_current_snapshot_and_append_only_history() { await using var db = NewContext("user-1"); var service = new CareerProfileService(db); var profile = new StructuredCvProfile { Summary = { "First version" } }; await service.SaveVersionAsync("user-1", profile, "upload", default); await service.SaveVersionAsync("user-1", profile, "improve", default); var snapshots = await db.CareerProfiles.IgnoreQueryFilters().Where(x => x.OwnerUserId == "user-1").ToListAsync(); var history = await db.CareerProfileVersions.IgnoreQueryFilters().Where(x => x.OwnerUserId == "user-1").ToListAsync(); Assert.Single(snapshots); Assert.Equal(2, snapshots[0].Version); Assert.Equal(2, history.Count); Assert.Equal(new[] { "upload", "improve" }, history.OrderBy(x => x.Version).Select(x => x.Source)); } [Fact] public async Task SaveVersionAsync_does_not_normalize_end_date_when_job_is_current() { await using var db = NewContext("user-1"); var service = new CareerProfileService(db); var profile = new StructuredCvProfile { Jobs = { new StructuredCvJob { Title = "Engineer", Start = "Jan 2020", End = "Present", IsCurrent = true } }, }; var saved = await service.SaveVersionAsync("user-1", profile, "upload", default); Assert.Equal("2020-01", saved.Jobs[0].StartDate); Assert.Null(saved.Jobs[0].EndDate); } // --- Phase 3: relational projection -------------------------------------------------------- private static StructuredCvProfile RichProfile() => new() { Contact = { FullName = "Ada Lovelace", Email = "ada@example.com", Headline = "Engineer" }, Summary = { "First", "Second" }, Interests = { "Chess" }, Jobs = { new StructuredCvJob { Title = "Senior Eng", Company = "Acme", Start = "Jan 2020", End = "Present", IsCurrent = true, Bullets = { "Built X" }, Skills = { "C#" } }, new StructuredCvJob { Title = "Eng", Company = "Beta", Start = "Jan 2018", End = "Dec 2019", Bullets = { "Built Y" } }, }, Education = { new StructuredCvEducation { Qualification = "BSc", Institution = "Uni", Details = { "Honours" } } }, Skills = { "C#", "SQL", "Azure" }, Projects = { new StructuredCvProject { Name = "Proj", Role = "Lead", Skills = { "React" } } }, Certifications = { new StructuredCvCertification { Name = "AZ-204", Issuer = "MS", Date = "Mar 2021" } }, Languages = { new StructuredCvLanguage { Name = "English", Level = "Native" } }, }; [Fact] public async Task Save_then_load_round_trips_through_the_relational_children() { await using var db = NewContext("user-1"); var service = new CareerProfileService(db); await service.SaveVersionAsync("user-1", RichProfile(), "manual", default); var loaded = await service.LoadStructuredAsync("user-1", default); Assert.Equal("Ada Lovelace", loaded.Contact.FullName); Assert.Equal(new[] { "First", "Second" }, loaded.Summary); Assert.Equal(new[] { "Chess" }, loaded.Interests); Assert.Equal(2, loaded.Jobs.Count); Assert.Equal("Senior Eng", loaded.Jobs[0].Title); // order preserved Assert.True(loaded.Jobs[0].IsCurrent); Assert.Equal(new[] { "Built X" }, loaded.Jobs[0].Bullets); Assert.Equal(new[] { "C#" }, loaded.Jobs[0].Skills); Assert.Equal("2020-01", loaded.Jobs[0].StartDate); Assert.Single(loaded.Education); Assert.Equal(new[] { "Honours" }, loaded.Education[0].Details); Assert.Equal(new[] { "C#", "SQL", "Azure" }, loaded.Skills); Assert.Equal("Proj", loaded.Projects[0].Name); Assert.Equal("AZ-204", loaded.Certifications[0].Name); Assert.Equal("English", loaded.Languages[0].Name); } [Fact] public async Task Save_preserves_item_keys_so_children_can_be_referenced_across_edits() { await using var db = NewContext("user-1"); var service = new CareerProfileService(db); var profile = RichProfile(); await service.SaveVersionAsync("user-1", profile, "manual", default); var jobKey = profile.Jobs[0].Id; var loaded = await service.LoadStructuredAsync("user-1", default); Assert.False(string.IsNullOrWhiteSpace(jobKey)); Assert.Equal(jobKey, loaded.Jobs[0].Id); // The relational row carries the same key. var expRow = await db.CareerExperiences.IgnoreQueryFilters().FirstAsync(x => x.Title == "Senior Eng"); Assert.Equal(jobKey, expRow.ItemKey); } [Fact] public async Task Save_replaces_children_wholesale_no_orphans() { await using var db = NewContext("user-1"); var service = new CareerProfileService(db); await service.SaveVersionAsync("user-1", RichProfile(), "manual", default); // 2 jobs var trimmed = new StructuredCvProfile { Jobs = { new StructuredCvJob { Title = "Only role", Company = "Solo" } } }; await service.SaveVersionAsync("user-1", trimmed, "manual", default); // 1 job var experiences = await db.CareerExperiences.IgnoreQueryFilters().Where(x => x.OwnerUserId == "user-1").ToListAsync(); Assert.Single(experiences); Assert.Equal("Only role", experiences[0].Title); // Other section children are cleared too. Assert.Empty(await db.CareerSkills.IgnoreQueryFilters().Where(x => x.OwnerUserId == "user-1").ToListAsync()); } [Fact] public async Task Load_backfills_relational_children_from_a_pre_phase3_blob() { await using var db = NewContext("user-1"); // Simulate a profile that predates Phase 3: a CareerProfile with a ProfileJson blob but no // relational children. var blob = StructuredCvProfileJson.Serialize(RichProfile()); db.CareerProfiles.Add(new CareerProfile { OwnerUserId = "user-1", ProfileJson = blob, Version = 3 }); await db.SaveChangesAsync(); Assert.Empty(await db.CareerExperiences.IgnoreQueryFilters().ToListAsync()); var service = new CareerProfileService(db); var loaded = await service.LoadStructuredAsync("user-1", default); Assert.Equal(2, loaded.Jobs.Count); // The backfill materialized the relational rows. Assert.Equal(2, await db.CareerExperiences.IgnoreQueryFilters().CountAsync(x => x.OwnerUserId == "user-1")); } [Fact] public async Task Load_returns_empty_profile_for_a_user_with_none() { await using var db = NewContext("user-1"); var service = new CareerProfileService(db); var loaded = await service.LoadStructuredAsync("user-1", default); Assert.Empty(loaded.Jobs); Assert.Empty(loaded.Skills); } [Fact] public async Task Versions_are_listed_newest_first_with_the_current_flagged() { await using var db = NewContext("user-1"); var service = new CareerProfileService(db); await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "v1" } }, "upload", default); await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "v2" } }, "improve", default); var versions = await service.ListVersionsAsync("user-1", default); Assert.Equal(new[] { 2, 1 }, versions.Select(v => v.Version)); Assert.True(versions[0].IsCurrent); Assert.False(versions[1].IsCurrent); Assert.Equal("improve", versions[0].Source); } [Fact] public async Task Restore_reapplies_an_old_snapshot_non_destructively() { await using var db = NewContext("user-1"); var service = new CareerProfileService(db); await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "original" } }, "upload", default); // v1 await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "edited" } }, "manual", default); // v2 var restored = await service.RestoreVersionAsync("user-1", 1, default); Assert.NotNull(restored); Assert.Equal(new[] { "original" }, restored!.Summary); // Restore is a new version (v3), so history is preserved and the restore is reversible. var versions = await service.ListVersionsAsync("user-1", default); Assert.Equal(new[] { 3, 2, 1 }, versions.Select(v => v.Version)); Assert.Equal("restore:v1", versions[0].Source); var loaded = await service.LoadStructuredAsync("user-1", default); Assert.Equal(new[] { "original" }, loaded.Summary); } [Fact] public async Task Restore_of_a_missing_version_returns_null() { await using var db = NewContext("user-1"); var service = new CareerProfileService(db); await service.SaveVersionAsync("user-1", new StructuredCvProfile(), "upload", default); Assert.Null(await service.RestoreVersionAsync("user-1", 99, default)); } }