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)); } 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("add-job-details", o!.NextStep!.Key); } [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"); Assert.Equal("prepare-cv", (await svc.GetOverviewAsync("user-1", job.Id, default))!.NextStep!.Key); job.TailoredCvText = "tailored"; await db.SaveChangesAsync(); Assert.Equal("write-cover-letter", (await svc.GetOverviewAsync("user-1", job.Id, default))!.NextStep!.Key); job.CoverLetterText = "Dear team"; await db.SaveChangesAsync(); Assert.Equal("attach-documents", (await svc.GetOverviewAsync("user-1", job.Id, default))!.NextStep!.Key); } [Fact] public async Task Next_step_prioritises_interview_prep_at_the_interview_stage() { 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"; }); db.Attachments.Add(new Attachment { JobApplicationId = job.Id, FileName = "c.pdf", FilePath = "/tmp/c.pdf", FileType = "PDF" }); await db.SaveChangesAsync(); var o = await svc.GetOverviewAsync("user-1", job.Id, default); Assert.Equal("prepare-interview", o!.NextStep!.Key); } [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)); } }