255 lines
11 KiB
C#
255 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.5 — Interview preparation and follow-up. The properties under test: prep content belongs to
|
|
// the user and nothing regenerates it, follow-ups reuse the existing FollowUpAt + checklist rather
|
|
// than a second reminder system, and the timeline reads the new lifecycle events.
|
|
public sealed class InterviewPrepTests
|
|
{
|
|
private static (JobTrackerContext db, InterviewPrepService prep, ApplicationTimelineService timeline) 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 InterviewPrepService(db), new ApplicationTimelineService(db));
|
|
}
|
|
|
|
private static async Task<JobApplication> SeedJobAsync(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 = "Senior Backend Developer",
|
|
Status = "Interview",
|
|
};
|
|
tweak?.Invoke(job);
|
|
db.JobApplications.Add(job);
|
|
await db.SaveChangesAsync();
|
|
return job;
|
|
}
|
|
|
|
// ---------- Part 1: interview preparation ----------
|
|
|
|
[Fact]
|
|
public async Task Prep_items_group_by_category_in_preparation_order()
|
|
{
|
|
var (db, prep, _) = New("user-1");
|
|
await using var _d = db;
|
|
var job = await SeedJobAsync(db, "user-1");
|
|
|
|
await prep.AddAsync("user-1", job.Id, new InterviewPrepInput(InterviewPrepCategories.Question, "What does success look like?", null, null, null), default);
|
|
await prep.AddAsync("user-1", job.Id, new InterviewPrepInput(InterviewPrepCategories.CompanyResearch, "Funding history", "Series B in 2025.", null, null), default);
|
|
await prep.AddAsync("user-1", job.Id, new InterviewPrepInput(InterviewPrepCategories.Technical, "System design", null, null, null), default);
|
|
await prep.AddAsync("user-1", job.Id, new InterviewPrepInput(InterviewPrepCategories.Debrief, "Interview debrief", "Questions asked", null, null), default);
|
|
|
|
var result = await prep.GetAsync("user-1", job.Id, default);
|
|
|
|
Assert.Equal(
|
|
new[] { InterviewPrepCategories.CompanyResearch, InterviewPrepCategories.Technical, InterviewPrepCategories.Question, InterviewPrepCategories.Debrief },
|
|
result!.Groups.Select(g => g.Category));
|
|
Assert.Equal("Company research", result.Groups[0].Label);
|
|
Assert.Equal("Interview debrief", result.Groups[^1].Label);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Marking_items_prepared_drives_the_progress_percentage()
|
|
{
|
|
var (db, prep, _) = New("user-1");
|
|
await using var _d = db;
|
|
var job = await SeedJobAsync(db, "user-1");
|
|
var a = await prep.AddAsync("user-1", job.Id, new InterviewPrepInput(null, "One", null, null, null), default);
|
|
await prep.AddAsync("user-1", job.Id, new InterviewPrepInput(null, "Two", null, null, null), default);
|
|
|
|
await prep.UpdateAsync("user-1", job.Id, a!.Id, new InterviewPrepInput(null, null, null, null, true), default);
|
|
var result = await prep.GetAsync("user-1", job.Id, default);
|
|
|
|
Assert.Equal(2, result!.Total);
|
|
Assert.Equal(1, result.Prepared);
|
|
Assert.Equal(50, result.Percent);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_accepted_ai_suggestion_is_recorded_as_ai_but_stays_editable()
|
|
{
|
|
var (db, prep, _) = New("user-1");
|
|
await using var _d = db;
|
|
var job = await SeedJobAsync(db, "user-1");
|
|
|
|
var item = await prep.AddAsync("user-1", job.Id,
|
|
new InterviewPrepInput(InterviewPrepCategories.Behavioural, "Tell me about a conflict", "Draft answer", InterviewPrepSources.Ai, null), default);
|
|
|
|
Assert.Equal(InterviewPrepSources.Ai, item!.Source);
|
|
|
|
var edited = await prep.UpdateAsync("user-1", job.Id, item.Id,
|
|
new InterviewPrepInput(null, null, "My own answer", null, null), default);
|
|
|
|
Assert.Equal("My own answer", edited!.Content);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Generating_ai_history_does_not_create_prep_items()
|
|
{
|
|
var (db, prep, _) = New("user-1");
|
|
await using var _d = db;
|
|
var job = await SeedJobAsync(db, "user-1");
|
|
db.AiInteractions.Add(new AiInteraction
|
|
{
|
|
OwnerUserId = "user-1", JobApplicationId = job.Id, Module = "interview",
|
|
Title = "Interview prep", Provider = "p", ResultJson = """{"text":"Likely questions..."}""",
|
|
CreatedAtUtc = DateTimeOffset.UtcNow,
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var result = await prep.GetAsync("user-1", job.Id, default);
|
|
|
|
// A suggestion is history until the user accepts it. Nothing appears in their prep by itself.
|
|
Assert.Equal(0, result!.Total);
|
|
Assert.Equal(1, result.AiSuggestionCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Prep_items_are_deletable_and_scoped_to_their_owner()
|
|
{
|
|
var (db, prep, _) = New("user-1");
|
|
await using var _d = db;
|
|
var mine = await SeedJobAsync(db, "user-1");
|
|
var theirs = await SeedJobAsync(db, "user-2");
|
|
var item = await prep.AddAsync("user-1", mine.Id, new InterviewPrepInput(null, "Mine", null, null, null), default);
|
|
|
|
Assert.Null(await prep.GetAsync("user-1", theirs.Id, default));
|
|
Assert.Null(await prep.AddAsync("user-1", theirs.Id, new InterviewPrepInput(null, "Sneak", null, null, null), default));
|
|
Assert.False(await prep.DeleteAsync("user-1", theirs.Id, item!.Id, default));
|
|
|
|
Assert.True(await prep.DeleteAsync("user-1", mine.Id, item.Id, default));
|
|
Assert.Equal(0, (await prep.GetAsync("user-1", mine.Id, default))!.Total);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Prep_never_writes_to_the_career_profile()
|
|
{
|
|
var (db, prep, _) = New("user-1");
|
|
await using var _d = db;
|
|
var job = await SeedJobAsync(db, "user-1");
|
|
db.CareerProfiles.Add(new CareerProfile
|
|
{
|
|
OwnerUserId = "user-1",
|
|
Experiences = { new CareerExperience { OwnerUserId = "user-1", Title = "Dev", BulletsJson = """["Original"]""" } },
|
|
});
|
|
await db.SaveChangesAsync();
|
|
var before = await db.CareerProfiles.AsNoTracking().Include(p => p.Experiences).FirstAsync();
|
|
|
|
await prep.AddAsync("user-1", job.Id, new InterviewPrepInput(InterviewPrepCategories.Star, "A time I led", "Content", InterviewPrepSources.Ai, null), default);
|
|
|
|
var after = await db.CareerProfiles.AsNoTracking().Include(p => p.Experiences).FirstAsync();
|
|
Assert.Equal(before.Version, after.Version);
|
|
Assert.Equal(before.Experiences[0].BulletsJson, after.Experiences[0].BulletsJson);
|
|
}
|
|
|
|
// ---------- Part 4: follow-up ----------
|
|
|
|
[Fact]
|
|
public async Task Setting_a_follow_up_updates_the_existing_field_and_records_an_event()
|
|
{
|
|
var (db, prep, _) = New("user-1");
|
|
await using var _d = db;
|
|
var job = await SeedJobAsync(db, "user-1");
|
|
var due = DateTime.Today.AddDays(7);
|
|
|
|
var result = await prep.SetFollowUpAsync("user-1", job.Id, due, "Chase the recruiter", default);
|
|
|
|
Assert.Equal(due, result!.FollowUpAt);
|
|
Assert.Equal("Chase the recruiter", result.NextAction);
|
|
// The same field the reminder service and RulesEngine already read — not a parallel store.
|
|
Assert.Equal(due, (await db.JobApplications.AsNoTracking().FirstAsync(j => j.Id == job.Id)).FollowUpAt);
|
|
Assert.Equal("FollowUpSet", (await db.JobEvents.AsNoTracking().SingleAsync()).Type);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Follow_up_counts_open_checklist_tasks_rather_than_owning_them()
|
|
{
|
|
var (db, prep, _) = New("user-1");
|
|
await using var _d = db;
|
|
var job = await SeedJobAsync(db, "user-1");
|
|
db.ApplicationChecklistItems.Add(new ApplicationChecklistItem
|
|
{
|
|
OwnerUserId = "user-1", JobApplicationId = job.Id, Title = "Chase",
|
|
Category = ChecklistCategories.FollowUp, Status = ChecklistStatuses.Pending,
|
|
});
|
|
db.ApplicationChecklistItems.Add(new ApplicationChecklistItem
|
|
{
|
|
OwnerUserId = "user-1", JobApplicationId = job.Id, Title = "Done one",
|
|
Category = ChecklistCategories.FollowUp, Status = ChecklistStatuses.Done,
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var result = await prep.GetFollowUpAsync("user-1", job.Id, default);
|
|
|
|
Assert.Equal(1, result!.OpenFollowUpTasks);
|
|
// No follow-up table was created — the tasks live in the checklist.
|
|
Assert.Equal(2, await db.ApplicationChecklistItems.CountAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Clearing_a_follow_up_is_recorded_too()
|
|
{
|
|
var (db, prep, _) = New("user-1");
|
|
await using var _d = db;
|
|
var job = await SeedJobAsync(db, "user-1", j => j.FollowUpAt = DateTime.Today.AddDays(3));
|
|
|
|
var result = await prep.SetFollowUpAsync("user-1", job.Id, null, null, default);
|
|
|
|
Assert.Null(result!.FollowUpAt);
|
|
Assert.Equal(1, await db.JobEvents.CountAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Follow_up_is_not_reachable_for_another_users_application()
|
|
{
|
|
var (db, prep, _) = New("user-1");
|
|
await using var _d = db;
|
|
var other = await SeedJobAsync(db, "user-2");
|
|
|
|
Assert.Null(await prep.GetFollowUpAsync("user-1", other.Id, default));
|
|
Assert.Null(await prep.SetFollowUpAsync("user-1", other.Id, DateTime.Today, null, default));
|
|
}
|
|
|
|
// ---------- Part 5: timeline integration ----------
|
|
|
|
[Fact]
|
|
public async Task Timeline_reads_the_new_lifecycle_events()
|
|
{
|
|
var (db, _, timeline) = New("user-1");
|
|
await using var _d = db;
|
|
var job = await SeedJobAsync(db, "user-1");
|
|
db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "InterviewScheduled", NewValue = "2026-08-01", At = DateTime.Now.AddDays(-2) });
|
|
db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "InterviewCompleted", At = DateTime.Now.AddDays(-1) });
|
|
db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "OfferReceived", At = DateTime.Now });
|
|
db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "FollowUpCompleted", At = DateTime.Now });
|
|
await db.SaveChangesAsync();
|
|
|
|
var result = await timeline.GetAsync("user-1", job.Id, null, false, default);
|
|
|
|
var summaries = result!.Days.SelectMany(d => d.Events).Select(e => e.Summary).ToList();
|
|
Assert.Contains("Interview scheduled for 1 August 2026", summaries);
|
|
Assert.Contains("Interview completed", summaries);
|
|
Assert.Contains("Offer received", summaries);
|
|
Assert.Contains("Follow-up completed", summaries);
|
|
|
|
// Interviews and offers are milestones; a completed follow-up is routine.
|
|
Assert.Equal(3, result.Milestones.Count);
|
|
Assert.DoesNotContain(result.Milestones, m => m.Type == "FollowUpCompleted");
|
|
}
|
|
}
|