using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.EntityFrameworkCore; using Moq; using Xunit; namespace JobTrackerApi.Tests; // Phase 5.3 — Application Intelligence. These tests pin the two properties that matter most: the // results are OWNERSHIP-SCOPED, and nothing here writes to the user's data. Career matching in // particular reads the master CareerProfile and must leave it byte-identical. public sealed class ApplicationIntelligenceTests { private const string Advert = """ We are hiring a Senior Backend Developer (full-time) to join our platform team in Oslo. You will: - Build and operate REST APIs used by every product surface - Own services end to end, from design through production support - Work closely with product and design on new features We expect: - Strong experience with C# and .NET - Solid SQL and Docker knowledge - Experience with unit tests and CI/CD """; private static (JobTrackerContext db, ApplicationIntelligenceService intelligence, ApplicationTimelineService timeline) New(string userId) { 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); return (db, new ApplicationIntelligenceService(db, new JobCvMatchService()), new ApplicationTimelineService(db)); } private static async Task SeedJobAsync(JobTrackerContext db, string owner, Action? tweak = null) { 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 = "Applied", Location = "Oslo", Description = Advert, }; tweak?.Invoke(job); db.JobApplications.Add(job); await db.SaveChangesAsync(); return job; } private static async Task SeedProfileAsync(JobTrackerContext db, string owner) { var profile = new CareerProfile { OwnerUserId = owner, Experiences = { new CareerExperience { OwnerUserId = owner, Title = "Backend Developer", Company = "Initech", Start = "2021", IsCurrent = true, BulletsJson = """["Built REST APIs in C# and .NET","Ran the SQL migration programme"]""", }, new CareerExperience { OwnerUserId = owner, Title = "Barista", Company = "Coffee Co", Start = "2018", End = "2020", BulletsJson = """["Served customers"]""", }, }, Projects = { new CareerProject { OwnerUserId = owner, Name = "Deploy pipeline", Role = "Author", BulletsJson = """["Docker based CI/CD for six services"]""", }, }, Skills = { new CareerSkill { OwnerUserId = owner, Name = "C#", Category = "Languages" } }, }; db.CareerProfiles.Add(profile); await db.SaveChangesAsync(); return profile; } // ---------- Milestone 1: timeline ---------- [Fact] public async Task Timeline_turns_raw_events_into_readable_summaries() { var (db, _, timeline) = New("user-1"); await using var _d = db; var job = await SeedJobAsync(db, "user-1"); db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "Created", At = DateTime.Now.AddDays(-5) }); db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "StatusChanged", OldValue = "Applied", NewValue = "Interview", At = DateTime.Now.AddDays(-1) }); await db.SaveChangesAsync(); var result = await timeline.GetAsync("user-1", job.Id, null, false, default); Assert.NotNull(result); var summaries = result!.Days.SelectMany(d => d.Events).Select(e => e.Summary).ToList(); Assert.Contains("Moved from Applied to Interview", summaries); Assert.Contains("Application created", summaries); } [Fact] public async Task Timeline_groups_by_day_newest_first() { var (db, _, timeline) = New("user-1"); await using var _d = db; var job = await SeedJobAsync(db, "user-1"); db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "Created", At = DateTime.Now.AddDays(-3) }); db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "AiRefreshed", At = DateTime.Now.AddDays(-3).AddHours(2) }); db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "ReplyReceived", At = DateTime.Now }); await db.SaveChangesAsync(); var result = await timeline.GetAsync("user-1", job.Id, null, false, default); Assert.Equal(2, result!.Days.Count); Assert.True(result.Days[0].Date > result.Days[1].Date); Assert.Equal(2, result.Days[1].Events.Count); Assert.Equal("Today", result.Days[0].Label); } [Fact] public async Task Timeline_detects_milestones_and_ignores_routine_events() { var (db, _, timeline) = New("user-1"); await using var _d = db; var job = await SeedJobAsync(db, "user-1"); db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "StatusChanged", NewValue = "Interview", At = DateTime.Now }); db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "StatusChanged", NewValue = "Waiting", At = DateTime.Now.AddDays(-1) }); db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "AiRefreshed", At = DateTime.Now.AddDays(-2) }); await db.SaveChangesAsync(); var result = await timeline.GetAsync("user-1", job.Id, null, false, default); Assert.Single(result!.Milestones); Assert.Equal("Moved to Interview", result.Milestones[0].Summary); } [Fact] public async Task Timeline_filters_by_category_but_keeps_the_milestone_spine() { var (db, _, timeline) = New("user-1"); await using var _d = db; var job = await SeedJobAsync(db, "user-1"); db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "StatusChanged", NewValue = "Offer", At = DateTime.Now }); db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "AiRefreshed", At = DateTime.Now }); await db.SaveChangesAsync(); var result = await timeline.GetAsync("user-1", job.Id, "ai", false, default); Assert.Single(result!.Days.SelectMany(d => d.Events)); Assert.Equal("ai", result.Days[0].Events[0].Category); // Filtering the detail must not hide what actually happened. Assert.Single(result.Milestones); Assert.Equal(2, result.TotalEvents); } [Fact] public async Task Timeline_is_not_readable_for_another_users_application() { var (db, _, timeline) = New("user-1"); await using var _d = db; var other = await SeedJobAsync(db, "user-2"); Assert.Null(await timeline.GetAsync("user-1", other.Id, null, false, default)); } // ---------- Milestone 2: job analysis ---------- [Fact] public async Task Analysis_extracts_structure_from_the_advert() { var (db, intelligence, _) = New("user-1"); await using var _d = db; var job = await SeedJobAsync(db, "user-1"); var a = await intelligence.AnalyzeAsync("user-1", job.Id, default); Assert.NotNull(a); Assert.Equal("Senior Backend Developer", a!.Role); Assert.Equal("Acme", a.Company); Assert.Equal("Oslo", a.Location); Assert.Equal("Full-time", a.EmploymentType); Assert.Equal("Senior", a.Seniority); Assert.Contains("C#", a.Technologies); Assert.Contains(".NET", a.Technologies); Assert.Contains("Docker", a.Technologies); Assert.NotEmpty(a.ImportantRequirements); Assert.NotEmpty(a.Responsibilities); Assert.True(a.HasJobDescription); } [Fact] public async Task Analysis_reports_what_the_advert_does_not_say() { var (db, intelligence, _) = New("user-1"); await using var _d = db; var job = await SeedJobAsync(db, "user-1", j => { j.Salary = null; j.JobUrl = null; }); var a = await intelligence.AnalyzeAsync("user-1", job.Id, default); Assert.Contains(a!.MissingInformation, m => m.Contains("Salary", StringComparison.OrdinalIgnoreCase)); Assert.Contains(a.MissingInformation, m => m.Contains("link", StringComparison.OrdinalIgnoreCase)); } [Fact] public async Task Analysis_degrades_gracefully_without_an_advert() { var (db, intelligence, _) = New("user-1"); await using var _d = db; var job = await SeedJobAsync(db, "user-1", j => j.Description = null); var a = await intelligence.AnalyzeAsync("user-1", job.Id, default); Assert.False(a!.HasJobDescription); Assert.Empty(a.Responsibilities); Assert.Contains("advert", a.Summary, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task Analysis_is_deterministic() { var (db, intelligence, _) = New("user-1"); await using var _d = db; var job = await SeedJobAsync(db, "user-1"); var first = await intelligence.AnalyzeAsync("user-1", job.Id, default); var second = await intelligence.AnalyzeAsync("user-1", job.Id, default); Assert.Equal(first!.Keywords, second!.Keywords); Assert.Equal(first.Summary, second.Summary); } [Fact] public async Task Analysis_is_not_readable_for_another_users_application() { var (db, intelligence, _) = New("user-1"); await using var _d = db; var other = await SeedJobAsync(db, "user-2"); Assert.Null(await intelligence.AnalyzeAsync("user-1", other.Id, default)); } // ---------- Milestone 3: career matching ---------- [Fact] public async Task Match_scores_the_profile_against_the_advert_with_evidence() { var (db, intelligence, _) = New("user-1"); await using var _d = db; var job = await SeedJobAsync(db, "user-1"); await SeedProfileAsync(db, "user-1"); var m = await intelligence.MatchAsync("user-1", job.Id, default); Assert.NotNull(m); Assert.True(m!.HasCareerProfile); Assert.True(m.Score > 0); Assert.Contains("C#", m.MatchedSkills); // The relevant-experience list is evidence drawn FROM the profile, not a copy of it. Assert.Contains(m.RelevantExperience, e => e.Title == "Backend Developer"); Assert.DoesNotContain(m.RelevantExperience, e => e.Title == "Barista"); Assert.NotEmpty(m.Suggestions); } [Fact] public async Task Match_never_writes_to_the_career_profile() { var (db, intelligence, _) = New("user-1"); await using var _d = db; var job = await SeedJobAsync(db, "user-1"); var profile = await SeedProfileAsync(db, "user-1"); var bulletsBefore = profile.Experiences[0].BulletsJson; var experienceCountBefore = profile.Experiences.Count; var versionBefore = profile.Version; await intelligence.MatchAsync("user-1", job.Id, default); var after = await db.CareerProfiles.Include(p => p.Experiences).FirstAsync(p => p.OwnerUserId == "user-1"); Assert.Equal(experienceCountBefore, after.Experiences.Count); Assert.Equal(bulletsBefore, after.Experiences.First(e => e.Title == "Backend Developer").BulletsJson); Assert.Equal(versionBefore, after.Version); } [Fact] public async Task Match_asks_for_a_profile_before_scoring_anything() { var (db, intelligence, _) = New("user-1"); await using var _d = db; var job = await SeedJobAsync(db, "user-1"); var m = await intelligence.MatchAsync("user-1", job.Id, default); Assert.False(m!.HasCareerProfile); Assert.Equal(0, m.Score); Assert.Contains(m.Suggestions, s => s.Contains("career profile", StringComparison.OrdinalIgnoreCase)); } [Fact] public async Task Match_only_reads_the_requesting_users_profile() { var (db, intelligence, _) = New("user-1"); await using var _d = db; var job = await SeedJobAsync(db, "user-1"); await SeedProfileAsync(db, "user-2"); // someone else's profile must not be scored var m = await intelligence.MatchAsync("user-1", job.Id, default); Assert.False(m!.HasCareerProfile); } [Fact] public async Task Match_is_not_readable_for_another_users_application() { var (db, intelligence, _) = New("user-1"); await using var _d = db; var other = await SeedJobAsync(db, "user-2"); Assert.Null(await intelligence.MatchAsync("user-1", other.Id, default)); } [Fact] public async Task Match_counts_ai_suggestions_without_generating_any() { var (db, intelligence, _) = New("user-1"); await using var _d = db; var job = await SeedJobAsync(db, "user-1"); await SeedProfileAsync(db, "user-1"); db.AiInteractions.Add(new AiInteraction { OwnerUserId = "user-1", JobApplicationId = job.Id, Module = "career-match", Title = "Career match", Provider = "p", ResultJson = "{}", CreatedAtUtc = DateTimeOffset.UtcNow, }); await db.SaveChangesAsync(); var interactionsBefore = await db.AiInteractions.CountAsync(); var m = await intelligence.MatchAsync("user-1", job.Id, default); Assert.Equal(1, m!.AiSuggestionCount); // Reading the match must not itself call the AI or append history. Assert.Equal(interactionsBefore, await db.AiInteractions.CountAsync()); } }