9edcbfc5de
Make job/CV comparisons language-aware and filter recruitment noise. Improve responsive career navigation, shared spacing, dashboard priorities, settings, localized workspace controls, and portable browser tests.
424 lines
17 KiB
C#
424 lines
17 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());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Match_uses_saved_English_translation_when_linked_cv_is_English()
|
|
{
|
|
var (db, intelligence, _) = New("user-1");
|
|
await using var _d = db;
|
|
var job = await SeedJobAsync(db, "user-1", j =>
|
|
{
|
|
j.Description = "Vi ser etter en utvikler med erfaring fra C# og skalerbare systemer.";
|
|
j.DescriptionLanguage = "nb-NO";
|
|
j.TranslatedDescription = "We need a developer with C# and scalable systems experience.";
|
|
});
|
|
await SeedProfileAsync(db, "user-1");
|
|
await AttachCvAsync(db, "user-1", job.Id, new CvVariantSettings { Language = "en" });
|
|
|
|
var match = await intelligence.MatchAsync("user-1", job.Id, default);
|
|
|
|
Assert.NotNull(match);
|
|
Assert.Equal("nb", match!.JobLanguage);
|
|
Assert.Equal("en", match.CvLanguage);
|
|
Assert.True(match.LanguageMismatch);
|
|
Assert.True(match.UsedTranslatedJobDescription);
|
|
Assert.Contains("C#", match.MatchedSkills);
|
|
Assert.DoesNotContain(match.MissingSkills, value => value.Equals("utvikler", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
}
|