992f89e619
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>
135 lines
5.7 KiB
C#
135 lines
5.7 KiB
C#
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;
|
|
}
|
|
}
|