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 SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40) { Calls++; LastText = text; return Task.FromResult(Next); } public Task SummarizeAsync(string text, int maxLength = 150, int minLength = 30) => Task.FromResult(Next); public Task ExtractTextAsync(Stream stream, string fileName, string? contentType = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); public Task RunProbeAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; public Task 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() .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; var currentUser = new Mock(); 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 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 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 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 Other_modules_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", "cover-letter", "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); } }