Files
jobtrackingapp/JobTrackerApi.Tests/ApplicationChecklistTests.cs
T
cesnimda ce76046a29 feat: complete release readiness work
- consolidate API ownership and remove dead vendor code

- add Stripe billing, learning paths, and public CV hardening

- add migration, recovery, security, audit, and browser gates
2026-07-31 16:54:16 +02:00

295 lines
12 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 Learning_recommendations_preserve_user_decisions_and_reopen_only_auto_completed_items()
{
var (db, svc) = New("user-1");
await using var _ = db;
var job = await SeedAsync(db, "user-1");
var first = await svc.SyncLearningRecommendationsAsync("user-1", job.Id, ["Kubernetes", "AWS"], default);
var kubernetes = first.Single(item => item.Keyword == "Kubernetes");
await svc.UpdateAsync("user-1", job.Id, kubernetes.Id,
new ChecklistItemInput(null, null, null, ChecklistStatuses.Done, null), default);
await svc.SyncLearningRecommendationsAsync("user-1", job.Id, [], default);
var reopened = await svc.SyncLearningRecommendationsAsync("user-1", job.Id, ["Kubernetes", "AWS"], default);
Assert.Equal(ChecklistStatuses.Done, reopened.Single(item => item.Keyword == "Kubernetes").Status);
Assert.Equal(ChecklistStatuses.Pending, reopened.Single(item => item.Keyword == "AWS").Status);
}
[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));
Assert.Empty(await svc.SyncLearningRecommendationsAsync("user-1", other.Id, ["Kubernetes"], 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));
}
}