feat: add career profile foundation with versioned history
Introduces the Career Workspace's bounded data foundation, additive and backwards-compatible: ApplicationUser.ProfileCvStructureJson stays the authoritative column every existing read path uses; the new CareerProfiles/CareerProfileVersions tables mirror it via ICareerProfileService so future Career Workspace features (variants, history UI) have real tables to build on rather than starting a second migration later. - CareerProfile: one snapshot row per user (Version, ProfileJson). - CareerProfileVersion: append-only history, one row per save (upload/rebuild/improve/reprocess/parse), so a profile edit is never silently lost the way ProfileCvStructureJson overwrites are today. - Stable item IDs assigned to jobs/education/certifications/projects on first save and preserved across later saves -- the prerequisite for CV variants to reference "this job" by identity instead of array position. - CvDateNormalizer: best-effort free-string -> "YYYY-MM" parsing for job/education/certification/project date ranges, kept alongside (never replacing) the original free-string fields. - Both SQLite (dev) and MySQL/MariaDB (prod) reconciler dialects, matching this repo's schema-via-raw-SQL-reconciler convention rather than EF migrations. Job tracking is untouched -- this is entirely within the profile/CV domain per the Career Workspace product boundary.
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user