using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.EntityFrameworkCore; using Moq; using Xunit; namespace JobTrackerApi.Tests; public sealed class ApplicationWorkspaceTests { private static (JobTrackerContext db, ApplicationWorkspaceService 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 ApplicationWorkspaceService(db, new ApplicationChecklistService(db))); } // Milestone 2: the next action comes from the checklist, and every preparation item outranks the // later categories. Seeding the parts a test is not asserting on keeps the assertion about one rule. private static async Task CompletePreparationAsync(JobTrackerContext db, string owner, int jobId, params string[] except) { db.CareerProfiles.Add(new CareerProfile { OwnerUserId = owner, Experiences = { new CareerExperience { OwnerUserId = owner, Title = "Dev", Company = "Acme" } }, }); await db.SaveChangesAsync(); var svc = new ApplicationChecklistService(db); var checklist = await svc.GetAsync(owner, jobId, default); foreach (var item in checklist!.Items.Where(i => i.Category == ChecklistCategories.Preparation && i.Status == ChecklistStatuses.Pending && !except.Contains(i.SystemKey))) { await svc.UpdateAsync(owner, jobId, item.Id, new ChecklistItemInput(null, null, null, ChecklistStatuses.Done, null), default); } } 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", Location = "Oslo", Description = "Needs .NET and SQL.", DateApplied = DateTime.UtcNow.AddDays(-3), }; tweak?.Invoke(job); db.JobApplications.Add(job); await db.SaveChangesAsync(); return job; } [Fact] public async Task Overview_aggregates_the_application_without_copying_career_data() { var (db, svc) = New("user-1"); await using var _ = db; var job = await SeedAsync(db, "user-1"); var o = await svc.GetOverviewAsync("user-1", job.Id, default); Assert.NotNull(o); Assert.Equal("Backend Developer", o!.JobTitle); Assert.Equal("Acme", o.Company); Assert.Equal("Oslo", o.Location); Assert.True(o.HasJobDescription); Assert.Null(o.Cv.VariantId); // no variant attached yet Assert.False(o.HasCoverLetter); Assert.Equal(0, o.DocumentCount); } [Fact] public async Task Overview_surfaces_the_attached_cv_variant() { var (db, svc) = New("user-1"); await using var _ = db; var job = await SeedAsync(db, "user-1"); db.CvVariants.Add(new CvVariant { OwnerUserId = "user-1", JobApplicationId = job.Id, PublicSlug = Guid.NewGuid().ToString("N"), Name = "Backend CV", SettingsJson = CvVariantSettingsJson.Serialize(new CvVariantSettings { ThemeId = "nordic" }), UpdatedAtUtc = DateTimeOffset.UtcNow, }); await db.SaveChangesAsync(); var o = await svc.GetOverviewAsync("user-1", job.Id, default); Assert.Equal("Backend CV", o!.Cv.VariantName); Assert.Equal("nordic", o.Cv.ThemeId); } [Fact] public async Task Overview_counts_documents_and_ai_history() { var (db, svc) = New("user-1"); await using var _ = db; var job = await SeedAsync(db, "user-1"); db.Attachments.Add(new Attachment { JobApplicationId = job.Id, FileName = "cert.pdf", FilePath = "/tmp/a.pdf", FileType = "PDF" }); db.AiInteractions.Add(new AiInteraction { OwnerUserId = "user-1", JobApplicationId = job.Id, Module = "job-analysis", Title = "Job analysis", Provider = "p", ResultJson = "{}", CreatedAtUtc = DateTimeOffset.UtcNow, }); await db.SaveChangesAsync(); var o = await svc.GetOverviewAsync("user-1", job.Id, default); Assert.Equal(1, o!.DocumentCount); Assert.Equal(1, o.AiInteractionCount); Assert.NotNull(o.LastAiAtUtc); } [Fact] public async Task Overview_returns_recent_activity_newest_first() { var (db, svc) = New("user-1"); await using var _ = db; var job = await SeedAsync(db, "user-1"); db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "Created", At = DateTime.Now.AddDays(-2) }); db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "StatusChanged", NewValue = "Applied", At = DateTime.Now }); await db.SaveChangesAsync(); var o = await svc.GetOverviewAsync("user-1", job.Id, default); Assert.Equal(new[] { "StatusChanged", "Created" }, o!.RecentActivity.Select(a => a.Type)); } // --- next recommended action: "the user should never ask what to do next" --- [Fact] public async Task Next_step_asks_for_the_advert_when_there_is_no_description() { var (db, svc) = New("user-1"); await using var _ = db; var job = await SeedAsync(db, "user-1", j => j.Description = null); var o = await svc.GetOverviewAsync("user-1", job.Id, default); Assert.Equal("review-job-details", o!.NextStep!.Key); Assert.Equal("job-details", o.NextStep.Section); } [Fact] public async Task Next_step_walks_cv_then_cover_letter_then_documents() { var (db, svc) = New("user-1"); await using var _ = db; var job = await SeedAsync(db, "user-1"); await CompletePreparationAsync(db, "user-1", job.Id, "prepare-cv", "create-cover-letter", "attach-supporting-documents"); Assert.Equal("prepare-cv", (await svc.GetOverviewAsync("user-1", job.Id, default))!.NextStep!.Key); job.TailoredCvText = "tailored"; await db.SaveChangesAsync(); Assert.Equal("create-cover-letter", (await svc.GetOverviewAsync("user-1", job.Id, default))!.NextStep!.Key); job.CoverLetterText = "Dear team"; await db.SaveChangesAsync(); Assert.Equal("attach-supporting-documents", (await svc.GetOverviewAsync("user-1", job.Id, default))!.NextStep!.Key); } [Fact] public async Task Next_step_reaches_interview_prep_once_preparation_is_done() { var (db, svc) = New("user-1"); await using var _ = db; var job = await SeedAsync(db, "user-1", j => { j.Status = "Interview"; j.TailoredCvText = "tailored"; j.CoverLetterText = "letter"; j.FollowUpAt = DateTime.UtcNow.AddDays(3); j.NextAction = "Confirm the interview slot"; }); db.Attachments.Add(new Attachment { JobApplicationId = job.Id, FileName = "c.pdf", FilePath = "/tmp/c.pdf", FileType = "PDF" }); await db.SaveChangesAsync(); await CompletePreparationAsync(db, "user-1", job.Id); var o = await svc.GetOverviewAsync("user-1", job.Id, default); Assert.Equal("prepare-interview-notes", o!.NextStep!.Key); Assert.Equal("interview", o.NextStep.Section); } [Fact] public async Task Overview_reports_checklist_progress() { var (db, svc) = New("user-1"); await using var _ = db; var job = await SeedAsync(db, "user-1"); var o = await svc.GetOverviewAsync("user-1", job.Id, default); Assert.NotNull(o!.ChecklistProgress); Assert.True(o.ChecklistProgress!.Total > 0); // The advert is present, so that item auto-completed from the same signal readiness uses. Assert.True(o.ChecklistProgress.Completed > 0); } [Fact] public async Task Another_users_application_is_not_visible() { var (db, svc) = New("user-1"); await using var _ = db; var other = await SeedAsync(db, "user-2"); Assert.Null(await svc.GetOverviewAsync("user-1", other.Id, default)); } }