feat(workspace): interview and follow-up workflow
CI and Deploy / test (push) Failing after 3m54s
CI and Deploy / deploy (push) Has been skipped

Phase 5.5. Completes the lifecycle after submission: prepare, communicate, chase.

Interview preparation gets a durable, user-owned store. There were already two
per-application AI stores, InterviewPrepNote and AiWorkspaceNote, but both are
caches that regenerate when their context signature changes — anything a user
typed into them would eventually be overwritten. InterviewPrepItem is the side
nothing regenerates, covering company research, technical notes, behavioural
answers, STAR examples and the user's own questions in one table, because those
categories differ only by label and adding one must not need a migration. Each
item records whether the user wrote it or accepted a suggestion, and an
IsPrepared flag makes the section double as the preparation checklist.

Generation stays in the existing AiWorkspaceService "interview" module, appended
to AiInteraction as before. A suggestion is history until the user adds it as a
prep item; opening the section generates nothing.

Follow-up reuses what exists rather than adding a tracker. The date is
JobApplication.FollowUpAt, the same field RulesEngine and the reminder hosted
service already act on, so reminders keep working with no new wiring. The task
stays an ApplicationChecklistItem in the follow-up category — the section counts
open tasks without owning them. The record is a FollowUpSet JobEvent, the same
type the rest of the app emits.

Communication is untouched: Correspondence already owns recruiter contacts,
history and notes, and the workspace already mounted it.

The timeline interpreter learned five more types — InterviewScheduled,
InterviewCompleted and OfferReceived as milestones, FollowUpCreated and
FollowUpCompleted as routine, deliberately outside the milestone spine so it
stays a summary of what actually happened. JobEvent remains the history source.

InterviewPrepItems is reconciler-owned with a no-op migration, guarded on
JobApplications, and verified on a fresh MariaDB 11: int AUTO_INCREMENT primary
key, varchar owner and title, tinyint flag, datetime(6), composite index inside
the key limit.

371 backend tests, 128 frontend tests, Release build and the production build all
pass locally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-19 17:01:33 +02:00
parent 4759f1f610
commit 3d74baef78
17 changed files with 3788 additions and 3 deletions
+252
View File
@@ -0,0 +1,252 @@
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);
var result = await prep.GetAsync("user-1", job.Id, default);
Assert.Equal(
new[] { InterviewPrepCategories.CompanyResearch, InterviewPrepCategories.Technical, InterviewPrepCategories.Question },
result!.Groups.Select(g => g.Category));
Assert.Equal("Company research", result.Groups[0].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");
}
}