3a906b881e
Evolve the existing readiness workflow into one persisted, user-controlled checklist rather than adding a second tracker. ApplicationChecklistItem records only completion state and user intent. Each default system item carries a stable SystemKey and an AutoSignal — the same signal /readiness already computed — and re-syncs on every read: a satisfied signal auto-completes the item, a reverted signal reopens it, and a manual tick always wins. Users can add, reorder, dismiss and delete. Readiness is refactored into a projection of the checklist (score = completion percentage, completed/missing = live items by status). Its DTO shape and the workflowSignal/reminders health view are unchanged, so no API contract breaks. The workspace's next recommended action now comes from the first pending checklist item in category priority order (preparation, submission, follow-up, interview, custom), replacing the parallel ruleset — so the overview can never recommend something already ticked off, and a user's own task can be next. The table follows the established MariaDB-safe path: the scaffolded migration is a no-op and the idempotent reconciler owns the DDL for both providers. Verified on MariaDB 11 — auto_increment PK, varchar/datetime(6)/tinyint(1) columns, both indexes inside the key limit, cascade delete, unique system key per application, and NULL system keys not colliding for custom items. 329 backend tests, 94 frontend tests, type check, production build and both Docker builds pass locally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
226 lines
8.6 KiB
C#
226 lines
8.6 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.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));
|
|
}
|
|
}
|