diff --git a/JobTrackerApi.Tests/JobLifecycleEventTests.cs b/JobTrackerApi.Tests/JobLifecycleEventTests.cs new file mode 100644 index 0000000..eb2d5b4 --- /dev/null +++ b/JobTrackerApi.Tests/JobLifecycleEventTests.cs @@ -0,0 +1,181 @@ +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"); + } +} diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 4d898f5..d9c1737 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -851,17 +851,8 @@ Canonical profile: At = DateTime.Now }); } - if (!string.Equals(oldStatus, job.Status, StringComparison.OrdinalIgnoreCase)) - { - _db.JobEvents.Add(new JobEvent - { - JobApplicationId = job.Id, - Type = "StatusChanged", - OldValue = oldStatus, - NewValue = job.Status, - At = request.StatusChangedAt ?? DateTime.Now - }); - } + // Records StatusChanged plus any lifecycle event the transition implies. + JobLifecycleEvents.RecordStatusChange(_db, job, oldStatus, request.StatusChangedAt ?? DateTime.Now); await _db.SaveChangesAsync(cancellationToken); return NoContent(); @@ -884,17 +875,7 @@ Canonical profile: // Stamps DateApplied when the job leaves the pre-application stages (e.g. the user // drags Preparing -> Applied), and clears it if they move back. SyncAppliedDateWithHistory(job); - if (!string.Equals(old, job.Status, StringComparison.OrdinalIgnoreCase)) - { - _db.JobEvents.Add(new JobEvent - { - JobApplicationId = job.Id, - Type = "StatusChanged", - OldValue = old, - NewValue = job.Status, - At = DateTime.Now - }); - } + JobLifecycleEvents.RecordStatusChange(_db, job, old, DateTime.Now); await _db.SaveChangesAsync(cancellationToken); return NoContent(); diff --git a/JobTrackerApi/Services/ApplicationChecklistService.cs b/JobTrackerApi/Services/ApplicationChecklistService.cs index fbc9ad7..c7a1352 100644 --- a/JobTrackerApi/Services/ApplicationChecklistService.cs +++ b/JobTrackerApi/Services/ApplicationChecklistService.cs @@ -193,9 +193,18 @@ public sealed class ApplicationChecklistService : IApplicationChecklistService if (ChecklistCategories.IsValid(input.Category)) item.Category = input.Category!; } + var wasDone = item.Status == ChecklistStatuses.Done; + if (ChecklistStatuses.IsValid(input.Status)) Stamp(item, input.Status!); else Stamp(item); + // Ticking off a follow-up task is a real lifecycle moment, so the timeline records it. Guarded + // on the transition, so re-saving an already-done item does not emit a second event. + if (!wasDone && item.Status == ChecklistStatuses.Done && item.Category == ChecklistCategories.FollowUp) + { + JobLifecycleEvents.RecordFollowUpCompleted(_db, jobApplicationId, item.Title); + } + await _db.SaveChangesAsync(ct); return Project(item); } diff --git a/JobTrackerApi/Services/JobLifecycleEvents.cs b/JobTrackerApi/Services/JobLifecycleEvents.cs new file mode 100644 index 0000000..b6d5a63 --- /dev/null +++ b/JobTrackerApi/Services/JobLifecycleEvents.cs @@ -0,0 +1,86 @@ +using JobTrackerApi.Data; +using JobTrackerApi.Models; + +namespace JobTrackerApi.Services; + +// Phase 5.6 — emitting the lifecycle events the timeline already knows how to read. +// +// JobEvent stays the single history source: this adds rows to it, it does not add a second store. +// Every emitter lives here rather than at each call site, so the two status-change boundaries in +// JobApplicationsController cannot drift apart, and a third boundary gets the behaviour for free. +// +// Events are derived from the TRANSITION, not from the resulting state, so one user action produces +// at most one lifecycle event and re-saving an unchanged status produces none. +// docs/architecture/application-workspace.md. +public static class JobLifecycleEvents +{ + // Records the status change itself plus, when the transition warrants it, one lifecycle event. + // Replaces the hand-written StatusChanged block at each call site. + public static void RecordStatusChange(JobTrackerContext db, JobApplication job, string? oldStatus, DateTime at) + { + var from = oldStatus ?? string.Empty; + var to = job.Status ?? string.Empty; + if (string.Equals(from, to, StringComparison.OrdinalIgnoreCase)) return; + + db.JobEvents.Add(new JobEvent + { + JobApplicationId = job.Id, + Type = "StatusChanged", + OldValue = oldStatus, + NewValue = job.Status, + At = at, + }); + + var lifecycle = LifecycleFor(from, to); + if (lifecycle is null) return; + + db.JobEvents.Add(new JobEvent + { + JobApplicationId = job.Id, + Type = lifecycle, + // Useful metadata: which transition produced it, so the timeline can say more than the type. + OldValue = oldStatus, + NewValue = job.Status, + At = at, + }); + } + + // At most one lifecycle event per transition. Ordered so an Interview -> Offer move reports the + // offer, which is the thing the user actually cares about. + private static string? LifecycleFor(string from, string to) + { + var wasInterview = IsInterviewStage(from); + var isInterview = IsInterviewStage(to); + + if (IsOfferStage(to) && !IsOfferStage(from)) return "OfferReceived"; + if (isInterview && !wasInterview) return "InterviewScheduled"; + + // Only counts as completed when the application moved FORWARD out of the interview stage. + // Dragging a card back to an earlier stage is a correction, not a completed interview. + if (wasInterview && !isInterview && JobPipeline.OrderOf(to) > JobPipeline.OrderOf(from)) + { + return "InterviewCompleted"; + } + + return null; + } + + // Emitted when a follow-up checklist item is ticked off. The task itself stays a checklist item — + // this only records that it happened. + public static void RecordFollowUpCompleted(JobTrackerContext db, int jobApplicationId, string title) + { + db.JobEvents.Add(new JobEvent + { + JobApplicationId = jobApplicationId, + Type = "FollowUpCompleted", + NewValue = title, + At = DateTime.Now, + }); + } + + private static bool IsInterviewStage(string? status) => + (status ?? string.Empty).Contains("interview", StringComparison.OrdinalIgnoreCase); + + private static bool IsOfferStage(string? status) => + (status ?? string.Empty).Contains("offer", StringComparison.OrdinalIgnoreCase); +}