feat: persist interview prep instead of regenerating on every open
Interview prep re-ran its AI call every time the tab opened -- flagged in the product teardown as work evaporating on every re-open (cost, latency, and non-determinism for no reason). GetInterviewPrep now persists one note per job application and reuses it on subsequent reads, only regenerating when the selected attachment context changes or a refresh is explicitly requested. - InterviewPrepNote: one row per (owner, job), keyed additionally by an attachment-selection fingerprint so picking different attachments correctly triggers a fresh brief without needing an explicit flag. - GetInterviewPrep gained a `refresh` query param; the frontend adds a small "Regenerate" button as the explicit escape hatch for when the underlying job/notes have changed since the note was written. - Both SQLite (dev) and MySQL/MariaDB (prod) reconciler dialects. - 3 new tests: reuse across calls, refresh regenerates, attachment context change regenerates. Verified against the real dev DB.
This commit is contained in:
@@ -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 JobApplicationsController.InterviewPrepDto GetDto(ActionResult<JobApplicationsController.InterviewPrepDto> result)
|
||||
=> (JobApplicationsController.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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user