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.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; namespace JobTrackerApi.Tests; /// /// Moving a job backwards into a pre-application stage clears DateApplied so analytics stay /// accurate. These tests guard the other half of that bargain: the application activity must be /// preserved as history rather than destroyed. /// public sealed class JobApplicationsAppliedDateHistoryTests { private static readonly DateTime AppliedOn = new(2026, 3, 1, 9, 30, 0, DateTimeKind.Utc); [Fact] public async Task Moving_back_to_a_prospect_stage_clears_the_date_but_records_it_as_history() { await using var db = CreateDb(); var job = await SeedAppliedJob(db); var controller = CreateController(db, "user-1"); await controller.UpdateStatus(job.Id, new UpdateStatusRequest("Saved"), CancellationToken.None); var saved = await db.JobApplications.SingleAsync(j => j.Id == job.Id); Assert.Equal("Saved", saved.Status); Assert.Null(saved.DateApplied); var cleared = await db.JobEvents.SingleAsync(e => e.Type == JobPipeline.AppliedDateClearedEvent); // Round-tripped, so the original date is recoverable exactly — not just described in prose. Assert.Equal(AppliedOn, DateTime.Parse(cleared.OldValue!, null, System.Globalization.DateTimeStyles.RoundtripKind)); // The status transition itself is still recorded separately. Assert.Contains(await db.JobEvents.ToListAsync(), e => e.Type == "StatusChanged" && e.NewValue == "Saved"); } [Fact] public async Task Re_applying_after_a_backwards_move_stamps_a_fresh_date_and_leaves_history_intact() { await using var db = CreateDb(); var job = await SeedAppliedJob(db); var controller = CreateController(db, "user-1"); await controller.UpdateStatus(job.Id, new UpdateStatusRequest("Saved"), CancellationToken.None); await controller.UpdateStatus(job.Id, new UpdateStatusRequest("Applied"), CancellationToken.None); var saved = await db.JobApplications.SingleAsync(j => j.Id == job.Id); Assert.Equal("Applied", saved.Status); Assert.NotNull(saved.DateApplied); Assert.NotEqual(AppliedOn, saved.DateApplied); // The first application's date survives the round trip in history. var cleared = await db.JobEvents.SingleAsync(e => e.Type == JobPipeline.AppliedDateClearedEvent); Assert.Equal(AppliedOn, DateTime.Parse(cleared.OldValue!, null, System.Globalization.DateTimeStyles.RoundtripKind)); } [Fact] public async Task Moving_between_post_application_stages_records_no_cleared_date_event() { await using var db = CreateDb(); var job = await SeedAppliedJob(db); var controller = CreateController(db, "user-1"); await controller.UpdateStatus(job.Id, new UpdateStatusRequest("Interview"), CancellationToken.None); var saved = await db.JobApplications.SingleAsync(j => j.Id == job.Id); Assert.Equal(AppliedOn, saved.DateApplied); Assert.DoesNotContain(await db.JobEvents.ToListAsync(), e => e.Type == JobPipeline.AppliedDateClearedEvent); } [Fact] public async Task Withdrawing_keeps_the_applied_date() { await using var db = CreateDb(); var job = await SeedAppliedJob(db); var controller = CreateController(db, "user-1"); await controller.UpdateStatus(job.Id, new UpdateStatusRequest("Withdrawn"), CancellationToken.None); // Withdrawn is a closed stage, not a pre-application one: you did apply, then pulled out. var saved = await db.JobApplications.SingleAsync(j => j.Id == job.Id); Assert.Equal("Withdrawn", saved.Status); Assert.Equal(AppliedOn, saved.DateApplied); } private static async Task SeedAppliedJob(JobTrackerContext db) { var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; db.Companies.Add(company); await db.SaveChangesAsync(); var job = new JobApplication { JobTitle = "Backend Developer", CompanyId = company.Id, OwnerUserId = "user-1", Status = "Applied", DateApplied = AppliedOn, SavedAt = AppliedOn.AddDays(-2), }; db.JobApplications.Add(job); await db.SaveChangesAsync(); return job; } private static JobApplicationsController CreateController(JobTrackerContext db, string userId) { var controller = new JobApplicationsController(db, Mock.Of(), TestHostFactory.CreateUserManager().Object); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) }, "test")) } }; return controller; } private static JobTrackerContext CreateDb() => TestHostFactory.CreateInMemoryDb(); }