feat: integrate Career Workspace foundation from feature/career-workspace

Recover the F1 Career Profile foundation + AI-workspace persistence from the
unmerged feature/career-workspace branch, so Phase 2 builds on the documented,
tested target state instead of re-deriving it. Foundation only — CV Builder
commits (variants, ATS badge, rewrite diff) stay deferred per "do not build CV
Builder yet". See docs/career-workspace-branch-assessment.md.

Squashed from 3 branch commits (235e291, 5916f09, 00a035e), resolved against
main + Phase 0:

- CareerProfile + CareerProfileVersion (append-only history), dual-written from
  every profile save path via CareerProfileService. ApplicationUser.
  ProfileCvStructureJson stays authoritative; the tables mirror it. Stable item
  IDs assigned to jobs/education/certifications/projects (the prerequisite for
  future variant lineage). CvDateNormalizer for free-text -> YYYY-MM.
- InterviewPrepNote + AiWorkspaceNote: cache AI interview prep / candidate fit /
  focus plan keyed by an attachment-context signature, so they stop regenerating
  (and re-spending the provider) on every open.

Conflict resolutions (union, favouring current code + Phase 0):
- JobTrackerContext / StartupInitializationExtensions: kept Phase 0's tables and
  reconciler blocks, added the career/interview/ai-note tables (both SQLite and
  MySQL dialects).
- ProfileCvController: dropped the branch's in-file DTO records (main defines them
  in ProfileCvDtos.cs) and the LayoutFamily/AtsRating template fields (deferred
  ATS-badge work), keeping main's 7-arg CvTemplateDescriptor.
- JobApplicationsController: kept the branch's cache-check, restored main's
  AsNoTracking on the read-only user load.

Tables ship empty (verified dev); nothing to migrate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-17 19:09:42 +02:00
parent aedd6e32ad
commit 992f89e619
15 changed files with 1063 additions and 9 deletions
@@ -0,0 +1,144 @@
using System.Security.Claims;
using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
// Candidate fit and focus plan share AiWorkspaceNote persistence with the same rules as
// InterviewPrepNote: reuse across calls, regenerate on refresh, regenerate when the attachment
// selection changes (career-workspace-implementation-roadmap.md Phase F5).
public sealed class AiWorkspaceNotePersistenceTests
{
[Fact]
public async Task GetCandidateFit_persists_and_reuses_the_generated_note()
{
await using var db = TestHostFactory.CreateInMemoryDb();
var job = await SeedJobWithCvAsync(db);
var summarizer = new Mock<ISummarizerService>();
var callCount = 0;
summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()))
.ReturnsAsync(() => { callCount++; return $"Summary call {callCount}"; });
var controller = CreateController(db, summarizer.Object, "user-1");
var first = await controller.GetCandidateFit(job.Id, null, false, CancellationToken.None);
var callsAfterFirst = callCount;
var second = await controller.GetCandidateFit(job.Id, null, false, CancellationToken.None);
Assert.Equal(GetDto(first).MatchSummary, GetDto(second).MatchSummary);
Assert.Equal(callsAfterFirst, callCount);
var stored = Assert.Single(db.AiWorkspaceNotes.IgnoreQueryFilters().Where(x => x.JobApplicationId == job.Id && x.NoteType == "candidate-fit"));
Assert.NotEmpty(stored.ResultJson);
}
[Fact]
public async Task GetCandidateFit_regenerates_when_refresh_is_requested()
{
await using var db = TestHostFactory.CreateInMemoryDb();
var job = await SeedJobWithCvAsync(db);
var summarizer = new Mock<ISummarizerService>();
var callCount = 0;
summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()))
.ReturnsAsync(() => { callCount++; return $"Summary call {callCount}"; });
var controller = CreateController(db, summarizer.Object, "user-1");
await controller.GetCandidateFit(job.Id, null, false, CancellationToken.None);
var callsAfterFirst = callCount;
var refreshed = await controller.GetCandidateFit(job.Id, null, true, CancellationToken.None);
Assert.True(callCount > callsAfterFirst);
Assert.NotNull(GetDto(refreshed).MatchSummary);
}
[Fact]
public async Task GetFocusPlan_persists_and_reuses_the_generated_note()
{
await using var db = TestHostFactory.CreateInMemoryDb();
var job = await SeedJobWithCvAsync(db);
var summarizer = new Mock<ISummarizerService>();
var callCount = 0;
summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()))
.ReturnsAsync(() => { callCount++; return $"Text {callCount}"; });
var controller = CreateController(db, summarizer.Object, "user-1");
await controller.GetFocusPlan(job.Id, null, false, CancellationToken.None);
var callsAfterFirst = callCount;
await controller.GetFocusPlan(job.Id, null, false, CancellationToken.None);
Assert.Equal(callsAfterFirst, callCount);
var stored = Assert.Single(db.AiWorkspaceNotes.IgnoreQueryFilters().Where(x => x.JobApplicationId == job.Id && x.NoteType == "focus-plan"));
Assert.NotEmpty(stored.ResultJson);
}
private static CandidateFitDto GetDto(ActionResult<CandidateFitDto> result)
=> (CandidateFitDto)Assert.IsType<OkObjectResult>(result.Result).Value!;
private static async Task<JobApplication> SeedJobWithCvAsync(JobTrackerContext db)
{
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
db.Companies.Add(company);
db.Users.Add(new ApplicationUser
{
Id = "user-1",
UserName = "user@example.test",
Email = "user@example.test",
ProfileCvText = "Built .NET APIs and led backend delivery with SQL and Docker.",
ProfileCvStructureJson = "[]",
});
await db.SaveChangesAsync();
var job = new JobApplication
{
JobTitle = "Backend Developer",
CompanyId = company.Id,
OwnerUserId = "user-1",
Description = "Needs .NET, SQL, and Docker experience.",
};
db.JobApplications.Add(job);
await db.SaveChangesAsync();
return job;
}
private static JobApplicationsController CreateController(JobTrackerContext db, ISummarizerService summarizer, string userId)
{
var user = db.Users.AsNoTracking().FirstOrDefault(x => x.Id == userId);
var controller = new JobApplicationsController(
db,
summarizer,
Mock.Of<IAppEmailSender>(),
TestHostFactory.CreateUserManager(user).Object,
NullLogger<JobApplicationsController>.Instance,
Mock.Of<ICvTemplateRenderer>(),
Mock.Of<ICvPdfExporter>());
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, userId)
}, "test"))
}
};
return controller;
}
}
@@ -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);
}
}
@@ -0,0 +1,134 @@
using System.Security.Claims;
using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
// Interview prep persistence (career-workspace-implementation-roadmap.md Phase F5): the AI call
// used to re-run on every tab open. These tests lock in that a note is generated once, reused on
// subsequent reads, and only regenerated when the attachment context changes or a refresh is
// explicitly requested.
public sealed class InterviewPrepPersistenceTests
{
[Fact]
public async Task GetInterviewPrep_persists_and_reuses_the_generated_note()
{
await using var db = TestHostFactory.CreateInMemoryDb();
var job = await SeedJobAsync(db);
var summarizer = new Mock<ISummarizerService>();
summarizer.SetupSequence(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()))
.ReturnsAsync("First generated brief.")
.ReturnsAsync("Second generated brief.");
var controller = CreateController(db, summarizer.Object, "user-1");
var first = await controller.GetInterviewPrep(job.Id, null, false, CancellationToken.None);
Assert.Equal("First generated brief.", GetDto(first).Summary);
var second = await controller.GetInterviewPrep(job.Id, null, false, CancellationToken.None);
Assert.Equal("First generated brief.", GetDto(second).Summary);
summarizer.Verify(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()), Times.Once);
var stored = Assert.Single(db.InterviewPrepNotes.IgnoreQueryFilters().Where(x => x.JobApplicationId == job.Id));
Assert.Equal("First generated brief.", stored.Summary);
}
[Fact]
public async Task GetInterviewPrep_regenerates_when_refresh_is_requested()
{
await using var db = TestHostFactory.CreateInMemoryDb();
var job = await SeedJobAsync(db);
var summarizer = new Mock<ISummarizerService>();
summarizer.SetupSequence(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()))
.ReturnsAsync("First generated brief.")
.ReturnsAsync("Refreshed brief.");
var controller = CreateController(db, summarizer.Object, "user-1");
await controller.GetInterviewPrep(job.Id, null, false, CancellationToken.None);
var refreshed = await controller.GetInterviewPrep(job.Id, null, true, CancellationToken.None);
Assert.Equal("Refreshed brief.", GetDto(refreshed).Summary);
summarizer.Verify(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()), Times.Exactly(2));
var stored = Assert.Single(db.InterviewPrepNotes.IgnoreQueryFilters().Where(x => x.JobApplicationId == job.Id));
Assert.Equal("Refreshed brief.", stored.Summary);
}
[Fact]
public async Task GetInterviewPrep_regenerates_when_attachment_selection_changes()
{
await using var db = TestHostFactory.CreateInMemoryDb();
var job = await SeedJobAsync(db);
var summarizer = new Mock<ISummarizerService>();
summarizer.SetupSequence(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()))
.ReturnsAsync("No-attachment brief.")
.ReturnsAsync("With-attachment brief.");
var controller = CreateController(db, summarizer.Object, "user-1");
await controller.GetInterviewPrep(job.Id, null, false, CancellationToken.None);
var withAttachment = await controller.GetInterviewPrep(job.Id, "7", false, CancellationToken.None);
Assert.Equal("With-attachment brief.", GetDto(withAttachment).Summary);
summarizer.Verify(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()), Times.Exactly(2));
}
private static InterviewPrepDto GetDto(ActionResult<InterviewPrepDto> result)
=> (InterviewPrepDto)Assert.IsType<OkObjectResult>(result.Result).Value!;
private static async Task<JobApplication> SeedJobAsync(JobTrackerContext db)
{
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
db.Companies.Add(company);
await db.SaveChangesAsync();
var job = new JobApplication
{
JobTitle = "Backend Developer",
CompanyId = company.Id,
OwnerUserId = "user-1",
Description = "Needs .NET and SQL experience.",
};
db.JobApplications.Add(job);
await db.SaveChangesAsync();
return job;
}
private static JobApplicationsController CreateController(JobTrackerContext db, ISummarizerService summarizer, string userId)
{
var controller = new JobApplicationsController(
db,
summarizer,
Mock.Of<IAppEmailSender>(),
TestHostFactory.CreateUserManager(null).Object,
NullLogger<JobApplicationsController>.Instance,
Mock.Of<ICvTemplateRenderer>(),
Mock.Of<ICvPdfExporter>());
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, userId)
}, "test"))
}
};
return controller;
}
}