feat(timeline): emit application lifecycle events
The timeline could interpret InterviewScheduled, InterviewCompleted, OfferReceived and FollowUpCompleted, but only StatusChanged and FollowUpSet were ever written, so those branches never rendered. Events are now derived from the status TRANSITION in one shared emitter rather than at each call site, so the two status-change boundaries in JobApplicationsController cannot drift apart and a third would get the behaviour for free. Both boundaries now call it instead of hand-writing the StatusChanged block. Deriving from the transition rather than the resulting state is what prevents duplicates: one user action produces at most one lifecycle event, re-saving an unchanged status produces none, and reaching an offer twice records it once. Moving an application backwards is treated as a correction, not a completed interview, so only a forward move out of an interview stage counts. An Interview to Offer move reports the offer, which is the thing the user cares about. Completing a follow-up checklist item emits FollowUpCompleted, guarded on the same transition rule so re-saving a done item stays silent. The task itself remains a checklist item — this only records that it happened. No new history store: every event is a JobEvent row, which stays the single source of application history. 393 backend tests pass, including timeline rendering of the emitted events. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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<JobTrackerContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;
|
||||
var currentUser = new Mock<ICurrentUserService>();
|
||||
currentUser.SetupGet(s => s.UserId).Returns(userId);
|
||||
var db = new JobTrackerContext(options, currentUser.Object);
|
||||
return (db, new ApplicationTimelineService(db));
|
||||
}
|
||||
|
||||
private static async Task<JobApplication> 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<List<string>> 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user