using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.EntityFrameworkCore; using Moq; using Xunit; namespace JobTrackerApi.Tests; // Lifecycle events are derived from the TRANSITION, so one user action produces at most one event and // an unchanged save produces none. JobEvent stays the only history store — these tests assert rows are // added there and nowhere else. public sealed class JobLifecycleEventTests { private static (JobTrackerContext db, ApplicationTimelineService timeline) New(string userId) { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; var currentUser = new Mock(); currentUser.SetupGet(s => s.UserId).Returns(userId); var db = new JobTrackerContext(options, currentUser.Object); return (db, new ApplicationTimelineService(db)); } private static async Task SeedAsync(JobTrackerContext db, string owner, string status) { 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 = "Dev", Status = status }; db.JobApplications.Add(job); await db.SaveChangesAsync(); return job; } private static async Task> MoveAsync(JobTrackerContext db, JobApplication job, string to) { var old = job.Status; job.Status = to; JobLifecycleEvents.RecordStatusChange(db, job, old, DateTime.Now); await db.SaveChangesAsync(); return await db.JobEvents.AsNoTracking().Select(e => e.Type).ToListAsync(); } [Fact] public async Task Entering_an_interview_stage_records_interview_scheduled() { var (db, _) = New("user-1"); await using var _d = db; var job = await SeedAsync(db, "user-1", "Applied"); var types = await MoveAsync(db, job, "Interview"); Assert.Contains("StatusChanged", types); Assert.Contains("InterviewScheduled", types); Assert.Equal(2, types.Count); } [Fact] public async Task Moving_forward_out_of_an_interview_records_interview_completed() { var (db, _) = New("user-1"); await using var _d = db; var job = await SeedAsync(db, "user-1", "Interview"); var types = await MoveAsync(db, job, "Offer"); // An offer outranks the completed interview: one lifecycle event per action, the one that matters. Assert.Contains("OfferReceived", types); Assert.DoesNotContain("InterviewCompleted", types); } [Fact] public async Task Leaving_an_interview_for_a_later_non_offer_stage_records_interview_completed() { var (db, _) = New("user-1"); await using var _d = db; var job = await SeedAsync(db, "user-1", "Interview"); var types = await MoveAsync(db, job, "Rejected"); Assert.Contains("InterviewCompleted", types); } [Fact] public async Task Dragging_an_application_backwards_is_a_correction_not_a_completed_interview() { var (db, _) = New("user-1"); await using var _d = db; var job = await SeedAsync(db, "user-1", "Interview"); var types = await MoveAsync(db, job, "Applied"); Assert.Contains("StatusChanged", types); Assert.DoesNotContain("InterviewCompleted", types); } [Fact] public async Task Reaching_an_offer_records_offer_received_once() { var (db, _) = New("user-1"); await using var _d = db; var job = await SeedAsync(db, "user-1", "Applied"); await MoveAsync(db, job, "Offer"); // Saving the same status again is not a new action, so nothing more is recorded. var types = await MoveAsync(db, job, "Offer"); Assert.Single(types, t => t == "OfferReceived"); Assert.Single(types, t => t == "StatusChanged"); } [Fact] public async Task An_unchanged_status_records_nothing_at_all() { var (db, _) = New("user-1"); await using var _d = db; var job = await SeedAsync(db, "user-1", "Applied"); var types = await MoveAsync(db, job, "Applied"); Assert.Empty(types); } [Fact] public async Task Completing_a_follow_up_checklist_item_records_it_once() { var (db, _) = New("user-1"); await using var _d = db; var job = await SeedAsync(db, "user-1", "Applied"); var svc = new ApplicationChecklistService(db); var checklist = await svc.GetAsync("user-1", job.Id, default); var followUp = checklist!.Items.First(i => i.Category == ChecklistCategories.FollowUp && i.Status == ChecklistStatuses.Pending); await svc.UpdateAsync("user-1", job.Id, followUp.Id, new ChecklistItemInput(null, null, null, ChecklistStatuses.Done, null), default); // Re-saving an already-done item must not emit a second event. await svc.UpdateAsync("user-1", job.Id, followUp.Id, new ChecklistItemInput(null, null, null, ChecklistStatuses.Done, null), default); Assert.Equal(1, await db.JobEvents.CountAsync(e => e.Type == "FollowUpCompleted")); } [Fact] public async Task Completing_a_non_follow_up_item_records_nothing() { var (db, _) = New("user-1"); await using var _d = db; var job = await SeedAsync(db, "user-1", "Applied"); var svc = new ApplicationChecklistService(db); var checklist = await svc.GetAsync("user-1", job.Id, default); var preparation = checklist!.Items.First(i => i.Category == ChecklistCategories.Preparation && i.Status == ChecklistStatuses.Pending); await svc.UpdateAsync("user-1", job.Id, preparation.Id, new ChecklistItemInput(null, null, null, ChecklistStatuses.Done, null), default); Assert.Equal(0, await db.JobEvents.CountAsync(e => e.Type == "FollowUpCompleted")); } [Fact] public async Task The_timeline_renders_the_emitted_events() { var (db, timeline) = New("user-1"); await using var _d = db; var job = await SeedAsync(db, "user-1", "Applied"); await MoveAsync(db, job, "Interview"); await MoveAsync(db, job, "Offer"); var result = await timeline.GetAsync("user-1", job.Id, null, false, default); var summaries = result!.Days.SelectMany(d => d.Events).Select(e => e.Summary).ToList(); Assert.Contains("Interview scheduled — Interview", summaries); Assert.Contains("Offer received", summaries); // Interviews and offers belong on the milestone spine. Assert.Contains(result.Milestones, m => m.Type == "InterviewScheduled"); Assert.Contains(result.Milestones, m => m.Type == "OfferReceived"); } }