Files
jobtrackingapp/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs
cesnimda b4fd5e2f96
CI and Deploy / test (pull_request) Successful in 2m4s
CI and Deploy / deploy (pull_request) Has been skipped
fix(jobs): derive attachment checklist flags from actual Attachments
Backlog item 4 (Wave 3, first sub-item). HasResume/HasCoverLetter/HasPortfolio/
HasOtherAttachment were manually-editable checkboxes in EditJobDialog,
completely independent of whether a file was actually attached -- classic
drift: mark 'resume ready' by hand, later delete the resume attachment, flag
stays stuck true forever. User confirmed (asked directly, since removing the
manual-override capability is a product decision, not purely technical):
make them fully computed from Attachments, no manual override.

- AttachmentsController.RecomputeAttachmentFlagsAsync: the single place these
  four fields get written now, called after every attachment mutation
  (upload, delete, Purpose change) that could affect them. Deliberately kept
  as persisted columns (not [NotMapped] computed properties reading the
  Attachments navigation collection) -- ~15 query sites build JobApplication
  DTOs without .Include(Attachments), so a live-computed property would
  silently return false everywhere instead of throwing, the worst kind of
  bug. Recomputing at the one write funnel avoids touching any read path.
- Removed HasResume/etc from CreateJobApplicationRequest/
  UpdateJobApplicationRequest -- no longer client-settable.
- EditJobDialog: removed the manual checkboxes, kept the (now genuinely
  accurate) read-only status chips.
- AddJobModal: stopped sending has*-flags at job-creation time; the
  follow-up attachment upload call now sets them correctly via the same
  recompute path.

Caught a real bug while testing this: the Purpose-change path recomputed
before saving the Purpose change, so a fresh query missed the pending edit
and the flags never updated. Fixed by committing the mutation before
recomputing.

3 new backend tests (purpose-change sets flag, delete clears flag,
non-primary purpose counts as "other"). 172/172 backend, 25/25 frontend
suites (57 tests) green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 21:10:39 +02:00

306 lines
11 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);
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,
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();
}
}