Files
jobtrackingapp/JobTrackerApi.Tests/ApplicationWorkspaceTests.cs
T
cesnimda 109745edb0
CI and Deploy / test (pull_request) Failing after 2m51s
CI and Deploy / deploy (pull_request) Has been skipped
feat(jobs): add dedicated workspace page
Make /jobs/:id the canonical application workspace while preserving list state and compatibility links. Replace popup and expandable-row navigation with accessible whole-row routing and richer job details.
2026-08-15 13:33:00 +02:00

228 lines
8.7 KiB
C#

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<JobTrackerContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;
var currentUser = new Mock<ICurrentUserService>();
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<JobApplication> SeedAsync(JobTrackerContext db, string owner, Action<JobApplication>? 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.Equal("Needs .NET and SQL.", o.Description);
Assert.Equal(job.SavedAt, o.SavedAt);
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));
}
}