using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.EntityFrameworkCore; using Moq; using Xunit; namespace JobTrackerApi.Tests; // Phase 5 Milestone 2. The point of these tests is that the checklist is ONE system: system items seed // from the same signals readiness reads, auto-complete when those signals are satisfied, and stay under // the user's control after that. public sealed class ApplicationChecklistTests { private static (JobTrackerContext db, ApplicationChecklistService svc) 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 ApplicationChecklistService(db)); } private static async Task SeedAsync(JobTrackerContext db, string owner, Action? tweak = null) { 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 = "Backend Developer", Status = "Applied", Description = "Needs .NET and SQL.", DateApplied = DateTime.UtcNow.AddDays(-3), }; tweak?.Invoke(job); db.JobApplications.Add(job); await db.SaveChangesAsync(); return job; } private static ChecklistItemDto Item(ChecklistDto checklist, string systemKey) => checklist.Items.Single(i => i.SystemKey == systemKey); [Fact] public async Task First_read_seeds_the_default_system_checklist() { var (db, svc) = New("user-1"); await using var _ = db; var job = await SeedAsync(db, "user-1"); var checklist = await svc.GetAsync("user-1", job.Id, default); Assert.NotNull(checklist); Assert.All(checklist!.Items, i => Assert.True(i.IsSystemGenerated)); Assert.Contains(checklist.Items, i => i.SystemKey == "prepare-cv"); Assert.Contains(checklist.Items, i => i.SystemKey == "confirm-submitted"); Assert.Contains(checklist.Items, i => i.SystemKey == "research-company"); } [Fact] public async Task Seeding_is_idempotent_across_reads() { var (db, svc) = New("user-1"); await using var _ = db; var job = await SeedAsync(db, "user-1"); var first = await svc.GetAsync("user-1", job.Id, default); var second = await svc.GetAsync("user-1", job.Id, default); Assert.Equal(first!.Items.Count, second!.Items.Count); Assert.Equal(first.Items.Count, await db.ApplicationChecklistItems.CountAsync()); } [Fact] public async Task System_items_auto_complete_from_the_readiness_signals() { var (db, svc) = New("user-1"); await using var _ = db; var job = await SeedAsync(db, "user-1"); var before = await svc.GetAsync("user-1", job.Id, default); Assert.Equal(ChecklistStatuses.Done, Item(before!, "review-job-details").Status); // advert present Assert.Equal(ChecklistStatuses.Pending, Item(before!, "prepare-cv").Status); job.TailoredCvText = "tailored"; await db.SaveChangesAsync(); var after = await svc.GetAsync("user-1", job.Id, default); var cv = Item(after!, "prepare-cv"); Assert.Equal(ChecklistStatuses.Done, cv.Status); Assert.True(cv.IsAutoCompleted); } [Fact] public async Task An_auto_completed_item_reopens_when_its_signal_goes_away() { var (db, svc) = New("user-1"); await using var _ = db; var job = await SeedAsync(db, "user-1", j => j.CoverLetterText = "Dear team"); Assert.Equal(ChecklistStatuses.Done, Item((await svc.GetAsync("user-1", job.Id, default))!, "create-cover-letter").Status); job.CoverLetterText = null; job.HasCoverLetter = false; await db.SaveChangesAsync(); Assert.Equal(ChecklistStatuses.Pending, Item((await svc.GetAsync("user-1", job.Id, default))!, "create-cover-letter").Status); } [Fact] public async Task A_manual_tick_survives_the_signal_sync() { var (db, svc) = New("user-1"); await using var _ = db; var job = await SeedAsync(db, "user-1"); var portfolio = Item((await svc.GetAsync("user-1", job.Id, default))!, "attach-portfolio"); await svc.UpdateAsync("user-1", job.Id, portfolio.Id, new ChecklistItemInput(null, null, null, ChecklistStatuses.Done, null), default); // The portfolio signal is still false, but the user said it is done — that must stick. var after = Item((await svc.GetAsync("user-1", job.Id, default))!, "attach-portfolio"); Assert.Equal(ChecklistStatuses.Done, after.Status); Assert.False(after.IsAutoCompleted); } [Fact] public async Task Custom_items_can_be_added_and_deleted() { var (db, svc) = New("user-1"); await using var _ = db; var job = await SeedAsync(db, "user-1"); await svc.GetAsync("user-1", job.Id, default); var created = await svc.AddAsync("user-1", job.Id, new ChecklistItemInput("Ask Sara for a referral", "She worked there until 2025.", null, null, null), default); Assert.NotNull(created); Assert.False(created!.IsSystemGenerated); Assert.Equal(ChecklistCategories.Custom, created.Category); Assert.True(await svc.DeleteAsync("user-1", job.Id, created.Id, default)); var after = await svc.GetAsync("user-1", job.Id, default); Assert.DoesNotContain(after!.Items, i => i.Id == created.Id); } [Fact] public async Task Deleting_a_system_item_dismisses_it_instead_of_resurrecting_it() { var (db, svc) = New("user-1"); await using var _ = db; var job = await SeedAsync(db, "user-1"); var portfolio = Item((await svc.GetAsync("user-1", job.Id, default))!, "attach-portfolio"); Assert.True(await svc.DeleteAsync("user-1", job.Id, portfolio.Id, default)); // A hard delete would be undone by the next seed, so removal means "dismissed" for system items. var after = await svc.GetAsync("user-1", job.Id, default); Assert.Equal(ChecklistStatuses.Dismissed, Item(after!, "attach-portfolio").Status); } [Fact] public async Task Dismissed_items_leave_the_progress_denominator() { var (db, svc) = New("user-1"); await using var _ = db; var job = await SeedAsync(db, "user-1"); var before = await svc.GetAsync("user-1", job.Id, default); var total = before!.Progress.Total; await svc.DeleteAsync("user-1", job.Id, Item(before, "attach-portfolio").Id, default); var after = await svc.GetAsync("user-1", job.Id, default); Assert.Equal(total - 1, after!.Progress.Total); Assert.Equal(1, after.Progress.Dismissed); } [Fact] public async Task Reordering_persists_the_new_order() { var (db, svc) = New("user-1"); await using var _ = db; var job = await SeedAsync(db, "user-1"); var checklist = await svc.GetAsync("user-1", job.Id, default); var preparation = checklist!.Items.Where(i => i.Category == ChecklistCategories.Preparation).ToList(); var reversed = preparation.Select(i => i.Id).Reverse().ToList(); await svc.ReorderAsync("user-1", job.Id, reversed, default); var after = await svc.GetAsync("user-1", job.Id, default); var afterPreparation = after!.Items.Where(i => i.Category == ChecklistCategories.Preparation).Select(i => i.Id).ToList(); Assert.Equal(reversed, afterPreparation); } [Fact] public async Task Next_pending_follows_the_category_priority() { var (db, svc) = New("user-1"); await using var _ = db; var job = await SeedAsync(db, "user-1"); var checklist = await svc.GetAsync("user-1", job.Id, default); var next = ApplicationChecklistService.NextPending(checklist!); // Preparation outranks submission, follow-up, interview and custom. Assert.Equal(ChecklistCategories.Preparation, next!.Category); } [Fact] public async Task A_custom_item_can_become_the_next_action_once_the_system_items_are_done() { var (db, svc) = New("user-1"); await using var _ = db; var job = await SeedAsync(db, "user-1"); var checklist = await svc.GetAsync("user-1", job.Id, default); foreach (var item in checklist!.Items.Where(i => i.Status == ChecklistStatuses.Pending)) { await svc.UpdateAsync("user-1", job.Id, item.Id, new ChecklistItemInput(null, null, null, ChecklistStatuses.Done, null), default); } var custom = await svc.AddAsync("user-1", job.Id, new ChecklistItemInput("Chase the recruiter", null, null, null, null), default); var next = ApplicationChecklistService.NextPending((await svc.GetAsync("user-1", job.Id, default))!); Assert.Equal(custom!.Id, next!.Id); } [Fact] public async Task Learning_recommendations_preserve_user_decisions_and_reopen_only_auto_completed_items() { var (db, svc) = New("user-1"); await using var _ = db; var job = await SeedAsync(db, "user-1"); var first = await svc.SyncLearningRecommendationsAsync("user-1", job.Id, ["Kubernetes", "AWS"], default); var kubernetes = first.Single(item => item.Keyword == "Kubernetes"); await svc.UpdateAsync("user-1", job.Id, kubernetes.Id, new ChecklistItemInput(null, null, null, ChecklistStatuses.Done, null), default); await svc.SyncLearningRecommendationsAsync("user-1", job.Id, [], default); var reopened = await svc.SyncLearningRecommendationsAsync("user-1", job.Id, ["Kubernetes", "AWS"], default); Assert.Equal(ChecklistStatuses.Done, reopened.Single(item => item.Keyword == "Kubernetes").Status); Assert.Equal(ChecklistStatuses.Pending, reopened.Single(item => item.Keyword == "AWS").Status); } [Fact] public async Task Interview_prep_is_only_outstanding_at_the_interview_stage() { var (db, svc) = New("user-1"); await using var _ = db; var applied = await SeedAsync(db, "user-1"); var interviewing = await SeedAsync(db, "user-1", j => j.Status = "Interview"); Assert.Equal(ChecklistStatuses.Done, Item((await svc.GetAsync("user-1", applied.Id, default))!, "prepare-interview-notes").Status); Assert.Equal(ChecklistStatuses.Pending, Item((await svc.GetAsync("user-1", interviewing.Id, default))!, "prepare-interview-notes").Status); } [Fact] public async Task Another_users_checklist_is_not_reachable() { var (db, svc) = New("user-1"); await using var _ = db; var other = await SeedAsync(db, "user-2"); Assert.Null(await svc.GetAsync("user-1", other.Id, default)); Assert.Null(await svc.AddAsync("user-1", other.Id, new ChecklistItemInput("Sneak", null, null, null, null), default)); Assert.False(await svc.DeleteAsync("user-1", other.Id, 1, default)); Assert.Empty(await svc.SyncLearningRecommendationsAsync("user-1", other.Id, ["Kubernetes"], default)); } [Fact] public async Task An_items_owner_is_checked_before_it_can_be_updated() { var (db, svc) = New("user-1"); await using var _ = db; var mine = await SeedAsync(db, "user-1"); var theirs = await SeedAsync(db, "user-2"); var theirItem = new ApplicationChecklistItem { OwnerUserId = "user-2", JobApplicationId = theirs.Id, Title = "Theirs", Category = ChecklistCategories.Custom, }; db.ApplicationChecklistItems.Add(theirItem); await db.SaveChangesAsync(); Assert.Null(await svc.UpdateAsync("user-1", mine.Id, theirItem.Id, new ChecklistItemInput(null, null, null, ChecklistStatuses.Done, null), default)); } }