244 lines
10 KiB
C#
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);
|
|
}
|
|
}
|