f1bf92a4e0
Phase 3, version history (list + restore). CareerProfileVersions was already
populated on every save; this makes it usable.
- ICareerProfileService.ListVersionsAsync — the append-only history, newest first,
with the current version flagged.
- RestoreVersionAsync — reapplies a past snapshot NON-DESTRUCTIVELY: it is re-saved
as a new version, so the current state stays in history and the restore is itself
reversible. Syncs the relational children + blob projection like any save.
- Endpoints: GET /career/profile/versions, POST /career/profile/versions/{v}/restore.
Tests (+4): versions listed newest-first with current flagged; restore reapplies
an old snapshot as a new version (history preserved, reversible); restore of a
missing version returns null.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
259 lines
11 KiB
C#
259 lines
11 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);
|
|
}
|
|
|
|
[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));
|
|
}
|
|
}
|