feat(career): relational projection and backfill for the master profile
CI and Deploy / test (push) Failing after 1m53s
CI and Deploy / deploy (push) Has been skipped

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:
cesnimda
2026-07-18 00:39:53 +02:00
parent 3a4c8fbc10
commit 46ff9454a8
3 changed files with 332 additions and 1 deletions
@@ -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);
}
}