Files
jobtrackingapp/JobTrackerApi.Tests/CareerProfileServiceTests.cs
T
cesnimda 46ff9454a8
CI and Deploy / test (push) Failing after 1m53s
CI and Deploy / deploy (push) Has been skipped
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>
2026-07-18 00:39:53 +02:00

213 lines
9.0 KiB
C#

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<JobTrackerContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var currentUser = new Mock<ICurrentUserService>();
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);
}
}