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

399 lines
16 KiB
C#

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<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);
return (db, new ApplicationIntelligenceService(db, new JobCvMatchService()), new ApplicationTimelineService(db));
}
private static async Task<JobApplication> SeedJobAsync(JobTrackerContext db, string owner, Action<JobApplication>? 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<CareerProfile> 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;
}
private static async Task<CvVariant> AttachCvAsync(JobTrackerContext db, string owner, int jobId, CvVariantSettings? settings = null)
{
var variant = new CvVariant
{
OwnerUserId = owner,
JobApplicationId = jobId,
Name = "Backend CV",
PublicSlug = Guid.NewGuid().ToString("N"),
SettingsJson = CvVariantSettingsJson.Serialize(settings),
Version = 1,
CreatedAtUtc = DateTimeOffset.UtcNow,
UpdatedAtUtc = DateTimeOffset.UtcNow,
};
db.CvVariants.Add(variant);
await db.SaveChangesAsync();
return variant;
}
// ---------- 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");
// Anchor the two older events to a fixed time-of-day so they always land on the same
// calendar day. Using DateTime.Now.AddDays(-3).AddHours(2) straddled midnight whenever the
// wall clock was within two hours of it, splitting one day into two and failing the test.
db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "Created", At = DateTime.Today.AddDays(-3).AddHours(9) });
db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "AiRefreshed", At = DateTime.Today.AddDays(-3).AddHours(11) });
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");
await AttachCvAsync(db, "user-1", job.Id);
var m = await intelligence.MatchAsync("user-1", job.Id, default);
Assert.NotNull(m);
Assert.True(m!.HasSelectedCv);
Assert.Equal("Backend CV", m.SelectedCvName);
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");
await AttachCvAsync(db, "user-1", job.Id);
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_requires_an_explicitly_linked_cv_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.False(m.HasSelectedCv);
Assert.Equal(0, m.Score);
Assert.Contains(m.Suggestions, s => s.Contains("Select the CV", 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
await AttachCvAsync(db, "user-1", job.Id);
var m = await intelligence.MatchAsync("user-1", job.Id, default);
Assert.False(m!.HasCareerProfile);
Assert.True(m.HasSelectedCv);
}
[Fact]
public async Task Match_respects_sections_hidden_in_the_linked_cv()
{
var (db, intelligence, _) = New("user-1");
await using var _d = db;
var job = await SeedJobAsync(db, "user-1");
await SeedProfileAsync(db, "user-1");
await AttachCvAsync(db, "user-1", job.Id, new CvVariantSettings
{
Sections = { new CvSectionSetting { Key = "experience", Hidden = true } },
});
var match = await intelligence.MatchAsync("user-1", job.Id, default);
Assert.True(match!.HasSelectedCv);
Assert.Empty(match.RelevantExperience);
}
[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());
}
}