using System.Security.Claims; using JobTrackerApi.Controllers; using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using JobTrackerApi.Tests.TestSupport; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; namespace JobTrackerApi.Tests; public sealed class JobApplicationsEndpointBehaviorTests { [Fact] public async Task Save_application_drafts_updates_cover_letter_and_notes() { await using var db = CreateDb(); var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; db.Companies.Add(company); await db.SaveChangesAsync(); var job = new JobApplication { JobTitle = "Backend Dev", CompanyId = company.Id, OwnerUserId = "user-1" }; db.JobApplications.Add(job); await db.SaveChangesAsync(); var controller = CreateController(db, "user-1"); var result = await controller.SaveApplicationDrafts(job.Id, new SaveApplicationDraftsRequest(" Cover letter body ", " Notes body ", null), CancellationToken.None); Assert.IsType(result); var saved = await db.JobApplications.FirstAsync(); Assert.Equal("Cover letter body", saved.CoverLetterText); Assert.Contains("Notes body", saved.Notes); } [Fact] public async Task Generate_application_package_rejects_missing_profile_cv() { await using var db = CreateDb(); var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; db.Companies.Add(company); db.Users.Add(new ApplicationUser { Id = "user-1", UserName = "u", Email = "u@example.com" }); await db.SaveChangesAsync(); var job = new JobApplication { JobTitle = "Backend Dev", CompanyId = company.Id, OwnerUserId = "user-1", Description = "Need .NET and SQL" }; db.JobApplications.Add(job); await db.SaveChangesAsync(); var controller = CreateController(db, "user-1"); var result = await controller.GenerateApplicationPackage(job.Id, null, null, null, CancellationToken.None); var badRequest = Assert.IsType(result.Result); Assert.Contains("Profile page", badRequest.Value?.ToString()); } [Fact] public async Task Status_suggestion_from_latest_inbound_rejection() { await using var db = CreateDb(); var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; db.Companies.Add(company); await db.SaveChangesAsync(); var job = new JobApplication { JobTitle = "Dev", CompanyId = company.Id, OwnerUserId = "user-1", Status = "Applied" }; db.JobApplications.Add(job); await db.SaveChangesAsync(); db.Correspondences.Add(new Correspondence { JobApplicationId = job.Id, From = "Company", Direction = "inbound", Subject = "Update", Content = "Unfortunately, we have decided not to proceed.", Date = DateTime.Now, }); await db.SaveChangesAsync(); var controller = CreateController(db, "user-1"); var result = await controller.GetStatusSuggestion(job.Id, CancellationToken.None); var ok = Assert.IsType(result.Result); var dto = Assert.IsType(ok.Value); Assert.True(dto.HasSuggestion); Assert.Equal("Rejected", dto.SuggestedStatus); } [Fact] public async Task Status_suggestion_suppressed_when_already_in_stage() { await using var db = CreateDb(); var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; db.Companies.Add(company); await db.SaveChangesAsync(); var job = new JobApplication { JobTitle = "Dev", CompanyId = company.Id, OwnerUserId = "user-1", Status = "Rejected" }; db.JobApplications.Add(job); await db.SaveChangesAsync(); db.Correspondences.Add(new Correspondence { JobApplicationId = job.Id, From = "Company", Direction = "inbound", Content = "Unfortunately, we will not be moving forward.", Date = DateTime.Now, }); await db.SaveChangesAsync(); var controller = CreateController(db, "user-1"); var result = await controller.GetStatusSuggestion(job.Id, CancellationToken.None); var ok = Assert.IsType(result.Result); var dto = Assert.IsType(ok.Value); Assert.False(dto.HasSuggestion); } [Fact] public async Task Match_score_scores_job_against_profile_cv() { await using var db = CreateDb(); var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; db.Companies.Add(company); db.Users.Add(new ApplicationUser { Id = "user-1", UserName = "u", Email = "u@example.com", ProfileCvText = "Backend engineer skilled in C#, .NET, SQL and Docker. Built REST APIs.", }); await db.SaveChangesAsync(); var job = new JobApplication { JobTitle = "Senior C# Backend Developer", CompanyId = company.Id, OwnerUserId = "user-1", Description = "We need strong C#, .NET, SQL, Docker and REST API experience.", }; db.JobApplications.Add(job); await db.SaveChangesAsync(); var controller = CreateController(db, "user-1"); var result = await controller.GetMatchScore(job.Id, CancellationToken.None); var ok = Assert.IsType(result.Result); var dto = Assert.IsType(ok.Value); Assert.True(dto.HasEnoughSignal); Assert.True(dto.Score >= 75, $"expected strong score, got {dto.Score}"); Assert.Contains("C#", dto.MatchedKeywords); } [Fact] public async Task Match_score_requires_profile_cv() { await using var db = CreateDb(); var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; db.Companies.Add(company); db.Users.Add(new ApplicationUser { Id = "user-1", UserName = "u", Email = "u@example.com" }); await db.SaveChangesAsync(); var job = new JobApplication { JobTitle = "Dev", CompanyId = company.Id, OwnerUserId = "user-1", Description = "C# .NET" }; db.JobApplications.Add(job); await db.SaveChangesAsync(); var controller = CreateController(db, "user-1"); var result = await controller.GetMatchScore(job.Id, CancellationToken.None); Assert.IsType(result.Result); } [Fact] public async Task Create_normalizes_structured_salary() { await using var db = CreateDb(); var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; db.Companies.Add(company); await db.SaveChangesAsync(); var controller = CreateController(db, "user-1"); var request = new CreateJobApplicationRequest( JobTitle: "Backend Dev", CompanyId: company.Id, Status: null, Location: null, Salary: "60-70k", SalaryMin: 70000m, // min > max on purpose: normalization swaps them SalaryMax: 60000m, SalaryCurrency: " nok ", SalaryPeriod: "YEAR", NextAction: null, FollowUpAt: null, Notes: null, Description: null, TranslatedDescription: null, DescriptionLanguage: null, Tags: null, Deadline: null, CoverLetterText: null, JobUrl: null, DateApplied: null, FeedbackRequestedAt: null, Source: "NAV", CountryCode: "no"); var result = await controller.Create(request, CancellationToken.None); Assert.NotNull(result); var created = Assert.IsType(result.Result); Assert.IsType(created.Value); var saved = await db.JobApplications.FirstAsync(); Assert.Equal(60000m, saved.SalaryMin); Assert.Equal(70000m, saved.SalaryMax); Assert.Equal("NOK", saved.SalaryCurrency); Assert.Equal("year", saved.SalaryPeriod); Assert.Equal("60-70k", saved.Salary); var opportunity = await db.Jobs.SingleAsync(); Assert.Equal(opportunity.Id, saved.JobId); Assert.Equal("nav", opportunity.Source); Assert.Equal("NO", opportunity.CountryCode); Assert.Equal(saved.JobTitle, opportunity.JobTitle); } [Fact] public async Task Update_drops_invalid_salary_period_and_negative_values() { await using var db = CreateDb(); var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; db.Companies.Add(company); await db.SaveChangesAsync(); var job = new JobApplication { JobTitle = "Backend Dev", CompanyId = company.Id, OwnerUserId = "user-1", SalaryMin = 50000m, SalaryMax = 60000m, SalaryCurrency = "NOK", SalaryPeriod = "year", Job = new Job { CompanyId = company.Id, JobTitle = "Backend Dev", Source = "nav", CountryCode = "NO" }, }; db.JobApplications.Add(job); await db.SaveChangesAsync(); var controller = CreateController(db, "user-1"); var request = new UpdateJobApplicationRequest( JobTitle: "Backend Dev", CompanyId: company.Id, Status: "Applied", ResponseReceived: false, ResponseDate: null, Location: null, Salary: null, SalaryMin: -5m, SalaryMax: null, SalaryCurrency: "", SalaryPeriod: "fortnight", NextAction: null, FollowUpAt: null, Notes: null, Description: null, TranslatedDescription: null, DescriptionLanguage: null, Tags: null, Deadline: null, CoverLetterText: null, JobUrl: null, DateApplied: null, FeedbackRequestedAt: null, StatusChangedAt: null); var result = await controller.Update(job.Id, request, CancellationToken.None); Assert.IsType(result); var saved = await db.JobApplications.FirstAsync(); Assert.Null(saved.SalaryMin); Assert.Null(saved.SalaryMax); Assert.Null(saved.SalaryCurrency); Assert.Null(saved.SalaryPeriod); var opportunity = await db.Jobs.SingleAsync(); Assert.Equal(saved.JobTitle, opportunity.JobTitle); Assert.Null(opportunity.SalaryMin); Assert.Null(opportunity.SalaryPeriod); Assert.Equal("nav", opportunity.Source); Assert.Equal("NO", opportunity.CountryCode); } private static JobApplicationsController CreateController(JobTrackerContext db, string userId) { var summarizer = new Mock(); summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync("generated text"); var users = CreateUserManager(); var controller = new JobApplicationsController(db, summarizer.Object, users.Object); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) }, "test")) } }; return controller; } private static Mock> CreateUserManager() { return TestHostFactory.CreateUserManager(); } private static JobTrackerContext CreateDb() { return TestHostFactory.CreateInMemoryDb(); } }