using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.EntityFrameworkCore; using Moq; using Xunit; namespace JobTrackerApi.Tests; // Phase 5.5 — Interview preparation and follow-up. The properties under test: prep content belongs to // the user and nothing regenerates it, follow-ups reuse the existing FollowUpAt + checklist rather // than a second reminder system, and the timeline reads the new lifecycle events. public sealed class InterviewPrepTests { private static (JobTrackerContext db, InterviewPrepService prep, 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 InterviewPrepService(db), new ApplicationTimelineService(db)); } private static async Task SeedJobAsync(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 = "Senior Backend Developer", Status = "Interview", }; tweak?.Invoke(job); db.JobApplications.Add(job); await db.SaveChangesAsync(); return job; } // ---------- Part 1: interview preparation ---------- [Fact] public async Task Prep_items_group_by_category_in_preparation_order() { var (db, prep, _) = New("user-1"); await using var _d = db; var job = await SeedJobAsync(db, "user-1"); await prep.AddAsync("user-1", job.Id, new InterviewPrepInput(InterviewPrepCategories.Question, "What does success look like?", null, null, null), default); await prep.AddAsync("user-1", job.Id, new InterviewPrepInput(InterviewPrepCategories.CompanyResearch, "Funding history", "Series B in 2025.", null, null), default); await prep.AddAsync("user-1", job.Id, new InterviewPrepInput(InterviewPrepCategories.Technical, "System design", null, null, null), default); var result = await prep.GetAsync("user-1", job.Id, default); Assert.Equal( new[] { InterviewPrepCategories.CompanyResearch, InterviewPrepCategories.Technical, InterviewPrepCategories.Question }, result!.Groups.Select(g => g.Category)); Assert.Equal("Company research", result.Groups[0].Label); } [Fact] public async Task Marking_items_prepared_drives_the_progress_percentage() { var (db, prep, _) = New("user-1"); await using var _d = db; var job = await SeedJobAsync(db, "user-1"); var a = await prep.AddAsync("user-1", job.Id, new InterviewPrepInput(null, "One", null, null, null), default); await prep.AddAsync("user-1", job.Id, new InterviewPrepInput(null, "Two", null, null, null), default); await prep.UpdateAsync("user-1", job.Id, a!.Id, new InterviewPrepInput(null, null, null, null, true), default); var result = await prep.GetAsync("user-1", job.Id, default); Assert.Equal(2, result!.Total); Assert.Equal(1, result.Prepared); Assert.Equal(50, result.Percent); } [Fact] public async Task An_accepted_ai_suggestion_is_recorded_as_ai_but_stays_editable() { var (db, prep, _) = New("user-1"); await using var _d = db; var job = await SeedJobAsync(db, "user-1"); var item = await prep.AddAsync("user-1", job.Id, new InterviewPrepInput(InterviewPrepCategories.Behavioural, "Tell me about a conflict", "Draft answer", InterviewPrepSources.Ai, null), default); Assert.Equal(InterviewPrepSources.Ai, item!.Source); var edited = await prep.UpdateAsync("user-1", job.Id, item.Id, new InterviewPrepInput(null, null, "My own answer", null, null), default); Assert.Equal("My own answer", edited!.Content); } [Fact] public async Task Generating_ai_history_does_not_create_prep_items() { var (db, prep, _) = New("user-1"); await using var _d = db; var job = await SeedJobAsync(db, "user-1"); db.AiInteractions.Add(new AiInteraction { OwnerUserId = "user-1", JobApplicationId = job.Id, Module = "interview", Title = "Interview prep", Provider = "p", ResultJson = """{"text":"Likely questions..."}""", CreatedAtUtc = DateTimeOffset.UtcNow, }); await db.SaveChangesAsync(); var result = await prep.GetAsync("user-1", job.Id, default); // A suggestion is history until the user accepts it. Nothing appears in their prep by itself. Assert.Equal(0, result!.Total); Assert.Equal(1, result.AiSuggestionCount); } [Fact] public async Task Prep_items_are_deletable_and_scoped_to_their_owner() { var (db, prep, _) = New("user-1"); await using var _d = db; var mine = await SeedJobAsync(db, "user-1"); var theirs = await SeedJobAsync(db, "user-2"); var item = await prep.AddAsync("user-1", mine.Id, new InterviewPrepInput(null, "Mine", null, null, null), default); Assert.Null(await prep.GetAsync("user-1", theirs.Id, default)); Assert.Null(await prep.AddAsync("user-1", theirs.Id, new InterviewPrepInput(null, "Sneak", null, null, null), default)); Assert.False(await prep.DeleteAsync("user-1", theirs.Id, item!.Id, default)); Assert.True(await prep.DeleteAsync("user-1", mine.Id, item.Id, default)); Assert.Equal(0, (await prep.GetAsync("user-1", mine.Id, default))!.Total); } [Fact] public async Task Prep_never_writes_to_the_career_profile() { var (db, prep, _) = New("user-1"); await using var _d = db; var job = await SeedJobAsync(db, "user-1"); db.CareerProfiles.Add(new CareerProfile { OwnerUserId = "user-1", Experiences = { new CareerExperience { OwnerUserId = "user-1", Title = "Dev", BulletsJson = """["Original"]""" } }, }); await db.SaveChangesAsync(); var before = await db.CareerProfiles.AsNoTracking().Include(p => p.Experiences).FirstAsync(); await prep.AddAsync("user-1", job.Id, new InterviewPrepInput(InterviewPrepCategories.Star, "A time I led", "Content", InterviewPrepSources.Ai, null), default); var after = await db.CareerProfiles.AsNoTracking().Include(p => p.Experiences).FirstAsync(); Assert.Equal(before.Version, after.Version); Assert.Equal(before.Experiences[0].BulletsJson, after.Experiences[0].BulletsJson); } // ---------- Part 4: follow-up ---------- [Fact] public async Task Setting_a_follow_up_updates_the_existing_field_and_records_an_event() { var (db, prep, _) = New("user-1"); await using var _d = db; var job = await SeedJobAsync(db, "user-1"); var due = DateTime.Today.AddDays(7); var result = await prep.SetFollowUpAsync("user-1", job.Id, due, "Chase the recruiter", default); Assert.Equal(due, result!.FollowUpAt); Assert.Equal("Chase the recruiter", result.NextAction); // The same field the reminder service and RulesEngine already read — not a parallel store. Assert.Equal(due, (await db.JobApplications.AsNoTracking().FirstAsync(j => j.Id == job.Id)).FollowUpAt); Assert.Equal("FollowUpSet", (await db.JobEvents.AsNoTracking().SingleAsync()).Type); } [Fact] public async Task Follow_up_counts_open_checklist_tasks_rather_than_owning_them() { var (db, prep, _) = New("user-1"); await using var _d = db; var job = await SeedJobAsync(db, "user-1"); db.ApplicationChecklistItems.Add(new ApplicationChecklistItem { OwnerUserId = "user-1", JobApplicationId = job.Id, Title = "Chase", Category = ChecklistCategories.FollowUp, Status = ChecklistStatuses.Pending, }); db.ApplicationChecklistItems.Add(new ApplicationChecklistItem { OwnerUserId = "user-1", JobApplicationId = job.Id, Title = "Done one", Category = ChecklistCategories.FollowUp, Status = ChecklistStatuses.Done, }); await db.SaveChangesAsync(); var result = await prep.GetFollowUpAsync("user-1", job.Id, default); Assert.Equal(1, result!.OpenFollowUpTasks); // No follow-up table was created — the tasks live in the checklist. Assert.Equal(2, await db.ApplicationChecklistItems.CountAsync()); } [Fact] public async Task Clearing_a_follow_up_is_recorded_too() { var (db, prep, _) = New("user-1"); await using var _d = db; var job = await SeedJobAsync(db, "user-1", j => j.FollowUpAt = DateTime.Today.AddDays(3)); var result = await prep.SetFollowUpAsync("user-1", job.Id, null, null, default); Assert.Null(result!.FollowUpAt); Assert.Equal(1, await db.JobEvents.CountAsync()); } [Fact] public async Task Follow_up_is_not_reachable_for_another_users_application() { var (db, prep, _) = New("user-1"); await using var _d = db; var other = await SeedJobAsync(db, "user-2"); Assert.Null(await prep.GetFollowUpAsync("user-1", other.Id, default)); Assert.Null(await prep.SetFollowUpAsync("user-1", other.Id, DateTime.Today, null, default)); } // ---------- Part 5: timeline integration ---------- [Fact] public async Task Timeline_reads_the_new_lifecycle_events() { var (db, _, timeline) = New("user-1"); await using var _d = db; var job = await SeedJobAsync(db, "user-1"); db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "InterviewScheduled", NewValue = "2026-08-01", At = DateTime.Now.AddDays(-2) }); db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "InterviewCompleted", At = DateTime.Now.AddDays(-1) }); db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "OfferReceived", At = DateTime.Now }); db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "FollowUpCompleted", At = DateTime.Now }); await db.SaveChangesAsync(); 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 for 1 August 2026", summaries); Assert.Contains("Interview completed", summaries); Assert.Contains("Offer received", summaries); Assert.Contains("Follow-up completed", summaries); // Interviews and offers are milestones; a completed follow-up is routine. Assert.Equal(3, result.Milestones.Count); Assert.DoesNotContain(result.Milestones, m => m.Type == "FollowUpCompleted"); } }