146 lines
6.2 KiB
C#
146 lines
6.2 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.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Moq;
|
|
using Xunit;
|
|
|
|
namespace JobTrackerApi.Tests;
|
|
|
|
// Candidate fit and Strategy Snapshot share AiWorkspaceNote persistence. Strategy generation is
|
|
// durable now, so this file verifies its service-level result cache rather than invoking a model
|
|
// from the GET endpoint.
|
|
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 StrategySnapshot_generation_persists_a_stable_cached_result()
|
|
{
|
|
await using var db = TestHostFactory.CreateInMemoryDb();
|
|
var job = await SeedJobWithCvAsync(db);
|
|
|
|
var summarizer = new Mock<ISummarizerService>();
|
|
summarizer.Setup(x => x.GenerateSectionWithMetadataAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new AiGenerationResult("""{"strategicSummary":"Lead with backend delivery.","cvBulletIdeas":["Built APIs"],"proofPointsToLeadWith":["Led delivery"],"coverLetterAngles":["Relevant platform work"]}""", "ollama", "test-model"));
|
|
|
|
var service = new StrategySnapshotService(db, summarizer.Object);
|
|
var generated = await service.GenerateAsync(job.Id, [], CancellationToken.None);
|
|
var cached = await service.GetCachedAsync(job.Id, string.Empty, CancellationToken.None);
|
|
|
|
Assert.NotNull(cached);
|
|
Assert.Equal(generated.Result.StrategicSummary, cached.StrategicSummary);
|
|
Assert.Equal(generated.Result.CvBulletIdeas, cached.CvBulletIdeas);
|
|
Assert.Equal(generated.Result.ProofPointsToLeadWith, cached.ProofPointsToLeadWith);
|
|
Assert.Equal(generated.Result.CoverLetterAngles, cached.CoverLetterAngles);
|
|
Assert.Equal("ollama", generated.Provider);
|
|
|
|
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;
|
|
}
|
|
}
|