feat: persist candidate fit and focus plan, stop re-running on every open
Extends the interview-prep persistence pattern (previous commit) to the other two AI-generated per-job outputs that were re-running their full AI call chain on every tab open: candidate-fit (4 AI calls) and focus-plan (4 AI calls). Across all three tabs that's 9 AI calls fired every single time a user revisits a job's AI workspace tabs. Generalized into AiWorkspaceNote (OwnerUserId, JobApplicationId, NoteType, ResultJson) rather than duplicating InterviewPrepNote's per-field-column shape: CandidateFitDto and FocusPlanDto are irregular and nested (up to 13 fields including a nested guidance object), where per-field columns would be unreasonable. One table, keyed by note type, serving both. Same rules as interview prep: reuse across calls, regenerate when the attachment selection changes, regenerate on explicit refresh. Frontend gets the same "Regenerate" button on both tabs. 3 new tests (persist+reuse for both, refresh for candidate-fit). Verified against the real dev DB.
This commit is contained in:
@@ -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 JobApplicationsController.CandidateFitDto GetDto(ActionResult<JobApplicationsController.CandidateFitDto> result)
|
||||
=> (JobApplicationsController.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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user