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>
275 lines
11 KiB
C#
275 lines
11 KiB
C#
using JobTrackerApi.Data;
|
|
using JobTrackerApi.Models;
|
|
using JobTrackerApi.Services;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Moq;
|
|
using Xunit;
|
|
|
|
namespace JobTrackerApi.Tests;
|
|
|
|
// Phase 5 Milestone 2. The point of these tests is that the checklist is ONE system: system items seed
|
|
// from the same signals readiness reads, auto-complete when those signals are satisfied, and stay under
|
|
// the user's control after that.
|
|
public sealed class ApplicationChecklistTests
|
|
{
|
|
private static (JobTrackerContext db, ApplicationChecklistService 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 ApplicationChecklistService(db));
|
|
}
|
|
|
|
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",
|
|
Description = "Needs .NET and SQL.",
|
|
DateApplied = DateTime.UtcNow.AddDays(-3),
|
|
};
|
|
tweak?.Invoke(job);
|
|
db.JobApplications.Add(job);
|
|
await db.SaveChangesAsync();
|
|
return job;
|
|
}
|
|
|
|
private static ChecklistItemDto Item(ChecklistDto checklist, string systemKey) =>
|
|
checklist.Items.Single(i => i.SystemKey == systemKey);
|
|
|
|
[Fact]
|
|
public async Task First_read_seeds_the_default_system_checklist()
|
|
{
|
|
var (db, svc) = New("user-1");
|
|
await using var _ = db;
|
|
var job = await SeedAsync(db, "user-1");
|
|
|
|
var checklist = await svc.GetAsync("user-1", job.Id, default);
|
|
|
|
Assert.NotNull(checklist);
|
|
Assert.All(checklist!.Items, i => Assert.True(i.IsSystemGenerated));
|
|
Assert.Contains(checklist.Items, i => i.SystemKey == "prepare-cv");
|
|
Assert.Contains(checklist.Items, i => i.SystemKey == "confirm-submitted");
|
|
Assert.Contains(checklist.Items, i => i.SystemKey == "research-company");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Seeding_is_idempotent_across_reads()
|
|
{
|
|
var (db, svc) = New("user-1");
|
|
await using var _ = db;
|
|
var job = await SeedAsync(db, "user-1");
|
|
|
|
var first = await svc.GetAsync("user-1", job.Id, default);
|
|
var second = await svc.GetAsync("user-1", job.Id, default);
|
|
|
|
Assert.Equal(first!.Items.Count, second!.Items.Count);
|
|
Assert.Equal(first.Items.Count, await db.ApplicationChecklistItems.CountAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task System_items_auto_complete_from_the_readiness_signals()
|
|
{
|
|
var (db, svc) = New("user-1");
|
|
await using var _ = db;
|
|
var job = await SeedAsync(db, "user-1");
|
|
|
|
var before = await svc.GetAsync("user-1", job.Id, default);
|
|
Assert.Equal(ChecklistStatuses.Done, Item(before!, "review-job-details").Status); // advert present
|
|
Assert.Equal(ChecklistStatuses.Pending, Item(before!, "prepare-cv").Status);
|
|
|
|
job.TailoredCvText = "tailored";
|
|
await db.SaveChangesAsync();
|
|
|
|
var after = await svc.GetAsync("user-1", job.Id, default);
|
|
var cv = Item(after!, "prepare-cv");
|
|
Assert.Equal(ChecklistStatuses.Done, cv.Status);
|
|
Assert.True(cv.IsAutoCompleted);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_auto_completed_item_reopens_when_its_signal_goes_away()
|
|
{
|
|
var (db, svc) = New("user-1");
|
|
await using var _ = db;
|
|
var job = await SeedAsync(db, "user-1", j => j.CoverLetterText = "Dear team");
|
|
|
|
Assert.Equal(ChecklistStatuses.Done, Item((await svc.GetAsync("user-1", job.Id, default))!, "create-cover-letter").Status);
|
|
|
|
job.CoverLetterText = null;
|
|
job.HasCoverLetter = false;
|
|
await db.SaveChangesAsync();
|
|
|
|
Assert.Equal(ChecklistStatuses.Pending, Item((await svc.GetAsync("user-1", job.Id, default))!, "create-cover-letter").Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_manual_tick_survives_the_signal_sync()
|
|
{
|
|
var (db, svc) = New("user-1");
|
|
await using var _ = db;
|
|
var job = await SeedAsync(db, "user-1");
|
|
|
|
var portfolio = Item((await svc.GetAsync("user-1", job.Id, default))!, "attach-portfolio");
|
|
await svc.UpdateAsync("user-1", job.Id, portfolio.Id,
|
|
new ChecklistItemInput(null, null, null, ChecklistStatuses.Done, null), default);
|
|
|
|
// The portfolio signal is still false, but the user said it is done — that must stick.
|
|
var after = Item((await svc.GetAsync("user-1", job.Id, default))!, "attach-portfolio");
|
|
Assert.Equal(ChecklistStatuses.Done, after.Status);
|
|
Assert.False(after.IsAutoCompleted);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Custom_items_can_be_added_and_deleted()
|
|
{
|
|
var (db, svc) = New("user-1");
|
|
await using var _ = db;
|
|
var job = await SeedAsync(db, "user-1");
|
|
await svc.GetAsync("user-1", job.Id, default);
|
|
|
|
var created = await svc.AddAsync("user-1", job.Id,
|
|
new ChecklistItemInput("Ask Sara for a referral", "She worked there until 2025.", null, null, null), default);
|
|
|
|
Assert.NotNull(created);
|
|
Assert.False(created!.IsSystemGenerated);
|
|
Assert.Equal(ChecklistCategories.Custom, created.Category);
|
|
|
|
Assert.True(await svc.DeleteAsync("user-1", job.Id, created.Id, default));
|
|
var after = await svc.GetAsync("user-1", job.Id, default);
|
|
Assert.DoesNotContain(after!.Items, i => i.Id == created.Id);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Deleting_a_system_item_dismisses_it_instead_of_resurrecting_it()
|
|
{
|
|
var (db, svc) = New("user-1");
|
|
await using var _ = db;
|
|
var job = await SeedAsync(db, "user-1");
|
|
var portfolio = Item((await svc.GetAsync("user-1", job.Id, default))!, "attach-portfolio");
|
|
|
|
Assert.True(await svc.DeleteAsync("user-1", job.Id, portfolio.Id, default));
|
|
|
|
// A hard delete would be undone by the next seed, so removal means "dismissed" for system items.
|
|
var after = await svc.GetAsync("user-1", job.Id, default);
|
|
Assert.Equal(ChecklistStatuses.Dismissed, Item(after!, "attach-portfolio").Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Dismissed_items_leave_the_progress_denominator()
|
|
{
|
|
var (db, svc) = New("user-1");
|
|
await using var _ = db;
|
|
var job = await SeedAsync(db, "user-1");
|
|
var before = await svc.GetAsync("user-1", job.Id, default);
|
|
var total = before!.Progress.Total;
|
|
|
|
await svc.DeleteAsync("user-1", job.Id, Item(before, "attach-portfolio").Id, default);
|
|
|
|
var after = await svc.GetAsync("user-1", job.Id, default);
|
|
Assert.Equal(total - 1, after!.Progress.Total);
|
|
Assert.Equal(1, after.Progress.Dismissed);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Reordering_persists_the_new_order()
|
|
{
|
|
var (db, svc) = New("user-1");
|
|
await using var _ = db;
|
|
var job = await SeedAsync(db, "user-1");
|
|
var checklist = await svc.GetAsync("user-1", job.Id, default);
|
|
var preparation = checklist!.Items.Where(i => i.Category == ChecklistCategories.Preparation).ToList();
|
|
var reversed = preparation.Select(i => i.Id).Reverse().ToList();
|
|
|
|
await svc.ReorderAsync("user-1", job.Id, reversed, default);
|
|
|
|
var after = await svc.GetAsync("user-1", job.Id, default);
|
|
var afterPreparation = after!.Items.Where(i => i.Category == ChecklistCategories.Preparation).Select(i => i.Id).ToList();
|
|
Assert.Equal(reversed, afterPreparation);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Next_pending_follows_the_category_priority()
|
|
{
|
|
var (db, svc) = New("user-1");
|
|
await using var _ = db;
|
|
var job = await SeedAsync(db, "user-1");
|
|
var checklist = await svc.GetAsync("user-1", job.Id, default);
|
|
|
|
var next = ApplicationChecklistService.NextPending(checklist!);
|
|
|
|
// Preparation outranks submission, follow-up, interview and custom.
|
|
Assert.Equal(ChecklistCategories.Preparation, next!.Category);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_custom_item_can_become_the_next_action_once_the_system_items_are_done()
|
|
{
|
|
var (db, svc) = New("user-1");
|
|
await using var _ = db;
|
|
var job = await SeedAsync(db, "user-1");
|
|
var checklist = await svc.GetAsync("user-1", job.Id, default);
|
|
foreach (var item in checklist!.Items.Where(i => i.Status == ChecklistStatuses.Pending))
|
|
{
|
|
await svc.UpdateAsync("user-1", job.Id, item.Id, new ChecklistItemInput(null, null, null, ChecklistStatuses.Done, null), default);
|
|
}
|
|
var custom = await svc.AddAsync("user-1", job.Id, new ChecklistItemInput("Chase the recruiter", null, null, null, null), default);
|
|
|
|
var next = ApplicationChecklistService.NextPending((await svc.GetAsync("user-1", job.Id, default))!);
|
|
|
|
Assert.Equal(custom!.Id, next!.Id);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Interview_prep_is_only_outstanding_at_the_interview_stage()
|
|
{
|
|
var (db, svc) = New("user-1");
|
|
await using var _ = db;
|
|
var applied = await SeedAsync(db, "user-1");
|
|
var interviewing = await SeedAsync(db, "user-1", j => j.Status = "Interview");
|
|
|
|
Assert.Equal(ChecklistStatuses.Done,
|
|
Item((await svc.GetAsync("user-1", applied.Id, default))!, "prepare-interview-notes").Status);
|
|
Assert.Equal(ChecklistStatuses.Pending,
|
|
Item((await svc.GetAsync("user-1", interviewing.Id, default))!, "prepare-interview-notes").Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Another_users_checklist_is_not_reachable()
|
|
{
|
|
var (db, svc) = New("user-1");
|
|
await using var _ = db;
|
|
var other = await SeedAsync(db, "user-2");
|
|
|
|
Assert.Null(await svc.GetAsync("user-1", other.Id, default));
|
|
Assert.Null(await svc.AddAsync("user-1", other.Id, new ChecklistItemInput("Sneak", null, null, null, null), default));
|
|
Assert.False(await svc.DeleteAsync("user-1", other.Id, 1, default));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_items_owner_is_checked_before_it_can_be_updated()
|
|
{
|
|
var (db, svc) = New("user-1");
|
|
await using var _ = db;
|
|
var mine = await SeedAsync(db, "user-1");
|
|
var theirs = await SeedAsync(db, "user-2");
|
|
var theirItem = new ApplicationChecklistItem
|
|
{
|
|
OwnerUserId = "user-2", JobApplicationId = theirs.Id, Title = "Theirs", Category = ChecklistCategories.Custom,
|
|
};
|
|
db.ApplicationChecklistItems.Add(theirItem);
|
|
await db.SaveChangesAsync();
|
|
|
|
Assert.Null(await svc.UpdateAsync("user-1", mine.Id, theirItem.Id,
|
|
new ChecklistItemInput(null, null, null, ChecklistStatuses.Done, null), default));
|
|
}
|
|
}
|