Files
jobtrackingapp/JobTrackerApi.Tests/JobLifecycleEventTests.cs
T
cesnimda 432e1fd667
CI and Deploy / test (push) Failing after 1m3s
CI and Deploy / deploy (push) Has been skipped
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>
2026-07-19 17:30:40 +02:00

182 lines
6.8 KiB
C#

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");
}
}