using System; using JobTrackerApi.Models; using JobTrackerApi.Services; using Xunit; namespace JobTrackerApi.Tests; public sealed class JobPipelineTests { [Theory] [InlineData("applied", "Applied")] [InlineData("APPLIED", "Applied")] [InlineData(" Offer ", "Offer")] [InlineData("Interviewing", "Interview")] [InlineData("interviews", "Interview")] [InlineData("declined", "Rejected")] [InlineData("no response", "Ghosted")] public void Normalize_canonicalizes_casing_and_synonyms(string input, string expected) => Assert.Equal(expected, JobPipeline.Normalize(input)); [Theory] [InlineData("")] [InlineData(" ")] [InlineData(null)] public void Normalize_empty_becomes_default(string? input) => Assert.Equal("Applied", JobPipeline.Normalize(input)); [Fact] public void Normalize_preserves_unknown_custom_status() => Assert.Equal("Take-home assignment", JobPipeline.Normalize(" Take-home assignment ")); [Fact] public void Stages_are_ordered_and_unique() { var orders = JobPipeline.Stages.Select(s => s.Order).ToList(); Assert.Equal(orders.OrderBy(x => x), orders); Assert.Equal(orders.Count, orders.Distinct().Count()); } [Fact] public void OrderOf_sorts_canonical_before_custom() { Assert.True(JobPipeline.OrderOf("Applied") < JobPipeline.OrderOf("Offer")); Assert.True(JobPipeline.OrderOf("Offer") < JobPipeline.OrderOf("Custom stage")); Assert.Equal(JobPipeline.OrderOf("Interview"), JobPipeline.OrderOf("Interviewing")); } [Fact] public void IsCanonical_only_true_for_known_stages() { Assert.True(JobPipeline.IsCanonical("Offer")); Assert.True(JobPipeline.IsCanonical("offer")); Assert.False(JobPipeline.IsCanonical("Interviewing")); // synonym, not canonical Assert.False(JobPipeline.IsCanonical("Whatever")); } // --- Pre-application (Prospect) stages ------------------------------------------------ [Theory] [InlineData("bookmarked", "Saved")] [InlineData("to apply", "Saved")] [InlineData("shortlisted", "Interested")] [InlineData("drafting", "Preparing")] [InlineData("in preparation", "Preparing")] public void Normalize_canonicalizes_prospect_synonyms(string input, string expected) => Assert.Equal(expected, JobPipeline.Normalize(input)); [Theory] [InlineData("Saved")] [InlineData("Interested")] [InlineData("Preparing")] [InlineData("bookmarked")] public void IsProspect_true_for_pre_application_stages(string status) => Assert.True(JobPipeline.IsProspect(status)); [Theory] [InlineData("Applied")] [InlineData("Waiting")] [InlineData("Interview")] [InlineData("Offer")] [InlineData("Rejected")] [InlineData("Ghosted")] public void IsProspect_false_for_post_application_stages(string status) => Assert.False(JobPipeline.IsProspect(status)); [Fact] public void IsProspect_false_for_custom_status() // Custom statuses predate the split and have always counted as applied. Treating them as // prospects would silently drop them out of existing users' analytics. => Assert.False(JobPipeline.IsProspect("Take-home assignment")); [Fact] public void Prospect_stages_sort_before_applied() { Assert.True(JobPipeline.OrderOf("Saved") < JobPipeline.OrderOf("Interested")); Assert.True(JobPipeline.OrderOf("Interested") < JobPipeline.OrderOf("Preparing")); Assert.True(JobPipeline.OrderOf("Preparing") < JobPipeline.OrderOf("Applied")); } // --- SyncAppliedDate: DateApplied set <=> the job has left the Prospect stages ----------- [Fact] public void SyncAppliedDate_clears_the_applied_date_for_a_prospect() { var now = new DateTime(2026, 7, 17, 12, 0, 0, DateTimeKind.Utc); var job = new JobApplication { Status = "Saved", DateApplied = now.AddDays(-5) }; JobPipeline.SyncAppliedDate(job, now); Assert.Null(job.DateApplied); } [Fact] public void SyncAppliedDate_stamps_when_a_prospect_becomes_applied() { var now = new DateTime(2026, 7, 17, 12, 0, 0, DateTimeKind.Utc); var job = new JobApplication { Status = "Preparing", DateApplied = null }; job.Status = "Applied"; JobPipeline.SyncAppliedDate(job, now); Assert.Equal(now, job.DateApplied); } [Fact] public void SyncAppliedDate_preserves_an_existing_applied_date() { var now = new DateTime(2026, 7, 17, 12, 0, 0, DateTimeKind.Utc); var applied = now.AddDays(-30); var job = new JobApplication { Status = "Interview", DateApplied = applied }; JobPipeline.SyncAppliedDate(job, now); Assert.Equal(applied, job.DateApplied); } [Fact] public void DaysSince_is_null_without_an_applied_date() => Assert.Null(new JobApplication { Status = "Saved", DateApplied = null }.DaysSince); [Fact] public void SyncAppliedDate_returns_the_cleared_date_so_callers_can_record_it() { var now = new DateTime(2026, 7, 17, 12, 0, 0, DateTimeKind.Utc); var applied = now.AddDays(-5); var job = new JobApplication { Status = "Saved", DateApplied = applied }; var cleared = JobPipeline.SyncAppliedDate(job, now); // The controller persists this as an AppliedDateCleared JobEvent, so the application // activity survives a backwards move. Assert.Equal(applied, cleared); Assert.Null(job.DateApplied); } [Fact] public void SyncAppliedDate_returns_null_when_nothing_was_cleared() { var now = new DateTime(2026, 7, 17, 12, 0, 0, DateTimeKind.Utc); Assert.Null(JobPipeline.SyncAppliedDate(new JobApplication { Status = "Applied", DateApplied = now.AddDays(-1) }, now)); Assert.Null(JobPipeline.SyncAppliedDate(new JobApplication { Status = "Saved", DateApplied = null }, now)); } // --- Withdrawn + board grouping --------------------------------------------------------- [Theory] [InlineData("withdrew", "Withdrawn")] [InlineData("cancelled", "Withdrawn")] [InlineData("canceled", "Withdrawn")] public void Normalize_canonicalizes_withdrawn_synonyms(string input, string expected) => Assert.Equal(expected, JobPipeline.Normalize(input)); [Fact] public void Declined_stays_rejected_and_does_not_become_withdrawn() // Opposite directions: the employer declined you vs you pulled out. => Assert.Equal("Rejected", JobPipeline.Normalize("declined")); [Fact] public void Withdrawn_is_closed_and_not_a_prospect() { Assert.False(JobPipeline.IsProspect("Withdrawn")); Assert.True(JobPipeline.IsCanonical("Withdrawn")); } [Fact] public void Ghosted_and_waiting_are_still_canonical_stages() { // Retained deliberately: the rules engine parks unanswered jobs in Ghosted, and Waiting // carries its own follow-up rule. Removing either would strand that behaviour. Assert.True(JobPipeline.IsCanonical("Ghosted")); Assert.True(JobPipeline.IsCanonical("Waiting")); } [Fact] public void Board_groups_match_the_agreed_layout() { Assert.Equal(new[] { "Saved", "Interested", "Preparing" }, JobPipeline.StagesInGroup(PipelineGroup.NotApplied)); Assert.Equal(new[] { "Applied", "Waiting", "Interview", "Offer" }, JobPipeline.StagesInGroup(PipelineGroup.Active)); Assert.Equal(new[] { "Rejected", "Ghosted", "Withdrawn" }, JobPipeline.StagesInGroup(PipelineGroup.Closed)); } [Fact] public void Every_stage_belongs_to_exactly_one_group() { var grouped = JobPipeline.StagesInGroup(PipelineGroup.NotApplied) .Concat(JobPipeline.StagesInGroup(PipelineGroup.Active)) .Concat(JobPipeline.StagesInGroup(PipelineGroup.Closed)) .ToList(); Assert.Equal(JobPipeline.Stages.Count, grouped.Count); Assert.Equal(grouped.Count, grouped.Distinct().Count()); } [Fact] public void Offer_is_success_for_analytics_but_groups_under_active_for_the_board() { // The two axes deliberately disagree: StageAnalytics excludes Success from time-in-stage, // but the user is still actively working an Offer. var offer = JobPipeline.Stages.Single(s => s.Key == "Offer"); Assert.Equal(PipelineCategory.Success, offer.Category); Assert.Equal(PipelineGroup.Active, offer.Group); } }