ae3505b877
New EmailStatusClassifier scans a message subject/body for outcome
signals (interview invite, offer, rejection) and suggests a canonical
pipeline status. Priority-ordered so a rejection that mentions the prior
interview still classifies as Rejected. Deterministic - no AI - so it is
instant, reproducible, and safe.
- GET /api/jobapplications/{id}/status-suggestion reads the job's latest
inbound correspondence (incl. Gmail imports) and suggests a forward
status move, suppressed when already in/past that stage
- always human-confirmed via the existing PATCH .../status
- 7 classifier unit tests + 2 endpoint integration tests; backend green (133)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
314 lines
12 KiB
C#
314 lines
12 KiB
C#
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 JobApplicationsController.SaveApplicationDraftsRequest(" Cover letter body ", " Notes body ", null), CancellationToken.None);
|
|
|
|
Assert.IsType<NoContentResult>(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<BadRequestObjectResult>(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<OkObjectResult>(result.Result);
|
|
var dto = Assert.IsType<JobApplicationsController.StatusSuggestionDto>(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<OkObjectResult>(result.Result);
|
|
var dto = Assert.IsType<JobApplicationsController.StatusSuggestionDto>(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<OkObjectResult>(result.Result);
|
|
var dto = Assert.IsType<JobApplicationsController.MatchScoreDto>(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<BadRequestObjectResult>(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 JobApplicationsController.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,
|
|
HasResume: null,
|
|
HasCoverLetter: null,
|
|
HasPortfolio: null,
|
|
HasOtherAttachment: null);
|
|
|
|
var result = await controller.Create(request, CancellationToken.None);
|
|
|
|
Assert.NotNull(result);
|
|
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);
|
|
}
|
|
|
|
[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",
|
|
};
|
|
db.JobApplications.Add(job);
|
|
await db.SaveChangesAsync();
|
|
|
|
var controller = CreateController(db, "user-1");
|
|
var request = new JobApplicationsController.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,
|
|
HasResume: null,
|
|
HasCoverLetter: null,
|
|
HasPortfolio: null,
|
|
HasOtherAttachment: 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<NoContentResult>(result);
|
|
var saved = await db.JobApplications.FirstAsync();
|
|
Assert.Null(saved.SalaryMin);
|
|
Assert.Null(saved.SalaryMax);
|
|
Assert.Null(saved.SalaryCurrency);
|
|
Assert.Null(saved.SalaryPeriod);
|
|
}
|
|
|
|
private static JobApplicationsController CreateController(JobTrackerContext db, string userId)
|
|
{
|
|
var summarizer = new Mock<ISummarizerService>();
|
|
summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>())).ReturnsAsync("generated text");
|
|
var users = CreateUserManager();
|
|
|
|
var controller = new JobApplicationsController(db, summarizer.Object, Mock.Of<IAppEmailSender>(), users.Object, NullLogger<JobApplicationsController>.Instance);
|
|
controller.ControllerContext = new ControllerContext
|
|
{
|
|
HttpContext = new DefaultHttpContext
|
|
{
|
|
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
|
{
|
|
new Claim(ClaimTypes.NameIdentifier, userId)
|
|
}, "test"))
|
|
}
|
|
};
|
|
return controller;
|
|
}
|
|
|
|
private static Mock<Microsoft.AspNetCore.Identity.UserManager<ApplicationUser>> CreateUserManager()
|
|
{
|
|
return TestHostFactory.CreateUserManager();
|
|
}
|
|
|
|
private static JobTrackerContext CreateDb()
|
|
{
|
|
return TestHostFactory.CreateInMemoryDb();
|
|
}
|
|
}
|