Files
jobtrackingapp/JobTrackerApi.Tests/InterviewAiContextTests.cs
T
2026-08-28 12:34:17 +02:00

244 lines
10 KiB
C#

using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.EntityFrameworkCore;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
// Interview AI context integration. The interview module now sees what the workspace already computed
// — requirements, matched skills, gaps, relevant experience — so its questions are about THIS
// application rather than generic. The context is deterministic and read-only, so this adds no second
// AI pipeline and cannot change the user's data.
public sealed class InterviewAiContextTests
{
private sealed class FakeAi : ISummarizerService
{
public string? Next = "## Likely questions\nSomething specific.";
public string? LastText;
public int Calls;
public Task<string?> SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40)
{
Calls++;
LastText = text;
return Task.FromResult(Next);
}
public Task<string?> SummarizeAsync(string text, int maxLength = 150, int minLength = 30) => Task.FromResult(Next);
public Task<AiTextExtractionResult?> ExtractTextAsync(Stream stream, string fileName, string? contentType = null, CancellationToken cancellationToken = default) => throw new NotImplementedException();
public Task RunProbeAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<AiServiceMetrics> GetMetricsAsync(CancellationToken cancellationToken = default) => throw new NotImplementedException();
}
private const string Advert = """
Senior Backend Developer, full-time, Oslo.
We expect:
- Strong experience with C# and .NET
- Solid SQL knowledge
- Experience with Kubernetes in production
""";
private static (JobTrackerContext db, AiWorkspaceService svc, FakeAi ai) New(string userId, bool withIntelligence = true)
{
var options = new DbContextOptionsBuilder<JobTrackerContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;
var currentUser = new Mock<ICurrentUserService>();
currentUser.SetupGet(s => s.UserId).Returns(userId);
var db = new JobTrackerContext(options, currentUser.Object);
var ai = new FakeAi();
var intelligence = withIntelligence ? new ApplicationIntelligenceService(db, new JobCvMatchService()) : null;
return (db, new AiWorkspaceService(db, ai, intelligence), ai);
}
private static async Task<JobApplication> SeedJobAsync(JobTrackerContext db, string owner)
{
var company = new Company { OwnerUserId = owner, Name = "Acme" };
db.Companies.Add(company);
await db.SaveChangesAsync();
var job = new JobApplication
{
OwnerUserId = owner,
CompanyId = company.Id,
JobTitle = "Senior Backend Developer",
Status = "Interview",
Description = Advert,
};
db.JobApplications.Add(job);
await db.SaveChangesAsync();
return job;
}
private static async Task SeedProfileAsync(JobTrackerContext db, string owner)
{
db.CareerProfiles.Add(new CareerProfile
{
OwnerUserId = owner,
Experiences =
{
new CareerExperience
{
OwnerUserId = owner, Title = "Backend Developer", Company = "Initech", Start = "2021", IsCurrent = true,
BulletsJson = """["Built services in C# and .NET","Owned the SQL migration programme"]""",
},
},
Projects =
{
new CareerProject { OwnerUserId = owner, Name = "Deploy pipeline", Role = "Author", BulletsJson = """["C# tooling"]""" },
},
});
await db.SaveChangesAsync();
}
private static async Task AttachCvAsync(JobTrackerContext db, string owner, int jobId)
{
db.CvVariants.Add(new CvVariant
{
OwnerUserId = owner,
JobApplicationId = jobId,
Name = "Backend CV",
PublicSlug = Guid.NewGuid().ToString("N"),
SettingsJson = CvVariantSettingsJson.Serialize(new CvVariantSettings()),
Version = 1,
CreatedAtUtc = DateTimeOffset.UtcNow,
UpdatedAtUtc = DateTimeOffset.UtcNow,
});
await db.SaveChangesAsync();
}
private static AiGenerateRequest Interview() => new("interview", null, null);
[Fact]
public async Task Interview_generation_includes_the_job_analysis_context()
{
var (db, svc, ai) = New("user-1");
await using var _ = db;
var job = await SeedJobAsync(db, "user-1");
await svc.GenerateAsync("user-1", job.Id, "profile text", "Ada", Interview(), "test", default);
Assert.Contains("APPLICATION INTELLIGENCE", ai.LastText);
Assert.Contains("Seniority: Senior", ai.LastText);
Assert.Contains("Key requirements", ai.LastText);
Assert.Contains("Technologies in the advert", ai.LastText);
}
[Fact]
public async Task Interview_generation_includes_the_career_match_context_and_the_gaps()
{
var (db, svc, ai) = New("user-1");
await using var _ = db;
var job = await SeedJobAsync(db, "user-1");
await SeedProfileAsync(db, "user-1");
await AttachCvAsync(db, "user-1", job.Id);
await svc.GenerateAsync("user-1", job.Id, "profile text", "Ada", Interview(), "test", default);
Assert.Contains("Match score:", ai.LastText);
Assert.Contains("Skills the candidate demonstrably has", ai.LastText);
// The gaps are the whole point — an interviewer probes what is missing.
Assert.Contains("Gaps the candidate must be ready to address", ai.LastText);
Assert.Contains("Kubernetes", ai.LastText);
Assert.Contains("Most relevant experience", ai.LastText);
Assert.Contains("Backend Developer", ai.LastText);
}
[Fact]
public async Task Modules_without_application_intelligence_are_unchanged()
{
var (db, svc, ai) = New("user-1");
await using var _ = db;
var job = await SeedJobAsync(db, "user-1");
await SeedProfileAsync(db, "user-1");
foreach (var module in new[] { "job-analysis", "career-match", "application-review" })
{
await svc.GenerateAsync("user-1", job.Id, "profile text", "Ada", new AiGenerateRequest(module, null, null), "test", default);
Assert.DoesNotContain("APPLICATION INTELLIGENCE", ai.LastText);
}
}
[Fact]
public async Task Generation_still_works_without_the_intelligence_service()
{
// The dependency is optional, so an older construction path degrades to the previous prompt
// rather than failing.
var (db, svc, ai) = New("user-1", withIntelligence: false);
await using var _ = db;
var job = await SeedJobAsync(db, "user-1");
var interaction = await svc.GenerateAsync("user-1", job.Id, "profile text", "Ada", Interview(), "test", default);
Assert.NotNull(interaction);
Assert.DoesNotContain("APPLICATION INTELLIGENCE", ai.LastText);
Assert.Contains("JOB ADVERT", ai.LastText);
}
[Fact]
public async Task Context_is_scoped_to_the_requesting_user()
{
var (db, svc, ai) = New("user-1");
await using var _ = db;
var job = await SeedJobAsync(db, "user-1");
await SeedProfileAsync(db, "user-2"); // another user's profile must never be scored in
await svc.GenerateAsync("user-1", job.Id, "profile text", "Ada", Interview(), "test", default);
Assert.DoesNotContain("Initech", ai.LastText);
Assert.DoesNotContain("Match score:", ai.LastText);
}
[Fact]
public async Task Another_users_application_cannot_be_generated_for()
{
var (db, svc, ai) = New("user-1");
await using var _ = db;
var other = await SeedJobAsync(db, "user-2");
Assert.Null(await svc.GenerateAsync("user-1", other.Id, "profile text", "Ada", Interview(), "test", default));
Assert.Equal(0, ai.Calls);
}
[Fact]
public async Task Generation_writes_history_only_and_never_the_users_content()
{
var (db, svc, _) = New("user-1");
await using var _d = db;
var job = await SeedJobAsync(db, "user-1");
await SeedProfileAsync(db, "user-1");
var profileBefore = await db.CareerProfiles.AsNoTracking().Include(p => p.Experiences).FirstAsync();
var descriptionBefore = job.Description;
await svc.GenerateAsync("user-1", job.Id, "profile text", "Ada", Interview(), "test", default);
// The only new row is the AiInteraction. No prep item is created — the user must accept one.
Assert.Equal(1, await db.AiInteractions.CountAsync());
Assert.Equal(0, await db.InterviewPrepItems.CountAsync());
var profileAfter = await db.CareerProfiles.AsNoTracking().Include(p => p.Experiences).FirstAsync();
Assert.Equal(profileBefore.Version, profileAfter.Version);
Assert.Equal(profileBefore.Experiences[0].BulletsJson, profileAfter.Experiences[0].BulletsJson);
Assert.Equal(descriptionBefore, (await db.JobApplications.AsNoTracking().FirstAsync(j => j.Id == job.Id)).Description);
}
[Fact]
public async Task Existing_prep_items_are_never_overwritten_by_generation()
{
var (db, svc, _) = New("user-1");
await using var _d = db;
var job = await SeedJobAsync(db, "user-1");
var prep = new InterviewPrepService(db);
var mine = await prep.AddAsync("user-1", job.Id,
new InterviewPrepInput(InterviewPrepCategories.Star, "My STAR example", "My own words", null, null), default);
await svc.GenerateAsync("user-1", job.Id, "profile text", "Ada", Interview(), "test", default);
var after = await prep.GetAsync("user-1", job.Id, default);
Assert.Equal(1, after!.Total);
Assert.Equal("My own words", after.Groups[0].Items[0].Content);
Assert.Equal(mine!.Id, after.Groups[0].Items[0].Id);
}
}