ce76046a29
- 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
295 lines
12 KiB
C#
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));
|
|
}
|
|
}
|