feat(career): relational projection and backfill for the master profile
Phase 3, service layer. CareerProfileService now maintains the relational children as the source of truth for structured career data, with the StructuredCvProfile blob kept as a derived projection. - SaveVersionAsync additionally syncs the relational children (replace-all, preserving ItemKeys from the blob item ids; SortOrder = array position) and the LongTailJson (contact, summary, interests, other sections, metadata). - New LoadStructuredAsync reads the master profile from the relational children, lazily backfilling from the ProfileJson blob for profiles that predate Phase 3. - CareerProfileMapper: the two-way projection between relational rows and StructuredCvProfile. Tests (+5): round-trip through relational, item-key preservation, wholesale child replacement (no orphans), backfill from a pre-Phase-3 blob, empty profile. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -98,4 +98,115 @@ public sealed class CareerProfileServiceTests
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user