Files
jobtrackingapp/JobTrackerApi.Tests/InterviewPrepTests.cs
T
2026-08-31 17:20:58 +02:00

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");
}
}