feat: Phase 0 foundation — Job entity, expanded pipeline, AI service lockdown, DateApplied history
Unblocks the documented core workflow and closes the AI-service exposure, without changing existing behaviour. Job/JobApplication split (additive; see ADR-002): - New Job entity (the opportunity) with owner-scoped query filter; nullable JobApplication.JobId FK. Nothing reads Job yet. - Migration AddJobEntityAndProspectStages, hand-edited to drop reconciler-owned tables the scaffolder re-emitted; verified against the real dev DB. Pipeline: 10 internal stages across three concerns kept separate — PipelineStage (workflow) / PipelineGroup (UI: NotApplied/Active/Closed) / PipelineCategory (analytics). Adds Saved/Interested/Preparing/Withdrawn; keeps Waiting and Ghosted. Kanban shows 3 grouped columns; cards keep a stage chip and full transitions; drag applies only safe transitions (never infers Ghosted/Withdrawn). DateApplied nullable + SavedAt. Cleared when leaving Applied so analytics stay accurate; the discarded date is preserved as an AppliedDateCleared JobEvent. AI service lockdown: no host port; private ai_internal network (backend is the only other member); X-Ai-Service-Token required on all non-/health endpoints; AI_SERVICE_TOKEN mandatory via compose. Verified backend-only against the live stack. Also carries two pre-existing working-tree files (views/ProfilePage.tsx, views/CareerWorkspacePage.tsx) so the tree is clean for the branch integration. Tests: +40 backend (247 total), +5 sidecar (16), +15 frontend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
using System.Security.Claims;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Tests.TestSupport;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Moving a job backwards into a pre-application stage clears DateApplied so analytics stay
|
||||
/// accurate. These tests guard the other half of that bargain: the application activity must be
|
||||
/// preserved as history rather than destroyed.
|
||||
/// </summary>
|
||||
public sealed class JobApplicationsAppliedDateHistoryTests
|
||||
{
|
||||
private static readonly DateTime AppliedOn = new(2026, 3, 1, 9, 30, 0, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public async Task Moving_back_to_a_prospect_stage_clears_the_date_but_records_it_as_history()
|
||||
{
|
||||
await using var db = CreateDb();
|
||||
var job = await SeedAppliedJob(db);
|
||||
var controller = CreateController(db, "user-1");
|
||||
|
||||
await controller.UpdateStatus(job.Id, new UpdateStatusRequest("Saved"), CancellationToken.None);
|
||||
|
||||
var saved = await db.JobApplications.SingleAsync(j => j.Id == job.Id);
|
||||
Assert.Equal("Saved", saved.Status);
|
||||
Assert.Null(saved.DateApplied);
|
||||
|
||||
var cleared = await db.JobEvents.SingleAsync(e => e.Type == JobPipeline.AppliedDateClearedEvent);
|
||||
// Round-tripped, so the original date is recoverable exactly — not just described in prose.
|
||||
Assert.Equal(AppliedOn, DateTime.Parse(cleared.OldValue!, null, System.Globalization.DateTimeStyles.RoundtripKind));
|
||||
|
||||
// The status transition itself is still recorded separately.
|
||||
Assert.Contains(await db.JobEvents.ToListAsync(), e => e.Type == "StatusChanged" && e.NewValue == "Saved");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Re_applying_after_a_backwards_move_stamps_a_fresh_date_and_leaves_history_intact()
|
||||
{
|
||||
await using var db = CreateDb();
|
||||
var job = await SeedAppliedJob(db);
|
||||
var controller = CreateController(db, "user-1");
|
||||
|
||||
await controller.UpdateStatus(job.Id, new UpdateStatusRequest("Saved"), CancellationToken.None);
|
||||
await controller.UpdateStatus(job.Id, new UpdateStatusRequest("Applied"), CancellationToken.None);
|
||||
|
||||
var saved = await db.JobApplications.SingleAsync(j => j.Id == job.Id);
|
||||
Assert.Equal("Applied", saved.Status);
|
||||
Assert.NotNull(saved.DateApplied);
|
||||
Assert.NotEqual(AppliedOn, saved.DateApplied);
|
||||
|
||||
// The first application's date survives the round trip in history.
|
||||
var cleared = await db.JobEvents.SingleAsync(e => e.Type == JobPipeline.AppliedDateClearedEvent);
|
||||
Assert.Equal(AppliedOn, DateTime.Parse(cleared.OldValue!, null, System.Globalization.DateTimeStyles.RoundtripKind));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Moving_between_post_application_stages_records_no_cleared_date_event()
|
||||
{
|
||||
await using var db = CreateDb();
|
||||
var job = await SeedAppliedJob(db);
|
||||
var controller = CreateController(db, "user-1");
|
||||
|
||||
await controller.UpdateStatus(job.Id, new UpdateStatusRequest("Interview"), CancellationToken.None);
|
||||
|
||||
var saved = await db.JobApplications.SingleAsync(j => j.Id == job.Id);
|
||||
Assert.Equal(AppliedOn, saved.DateApplied);
|
||||
Assert.DoesNotContain(await db.JobEvents.ToListAsync(), e => e.Type == JobPipeline.AppliedDateClearedEvent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Withdrawing_keeps_the_applied_date()
|
||||
{
|
||||
await using var db = CreateDb();
|
||||
var job = await SeedAppliedJob(db);
|
||||
var controller = CreateController(db, "user-1");
|
||||
|
||||
await controller.UpdateStatus(job.Id, new UpdateStatusRequest("Withdrawn"), CancellationToken.None);
|
||||
|
||||
// Withdrawn is a closed stage, not a pre-application one: you did apply, then pulled out.
|
||||
var saved = await db.JobApplications.SingleAsync(j => j.Id == job.Id);
|
||||
Assert.Equal("Withdrawn", saved.Status);
|
||||
Assert.Equal(AppliedOn, saved.DateApplied);
|
||||
}
|
||||
|
||||
private static async Task<JobApplication> SeedAppliedJob(JobTrackerContext db)
|
||||
{
|
||||
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
|
||||
db.Companies.Add(company);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var job = new JobApplication
|
||||
{
|
||||
JobTitle = "Backend Developer",
|
||||
CompanyId = company.Id,
|
||||
OwnerUserId = "user-1",
|
||||
Status = "Applied",
|
||||
DateApplied = AppliedOn,
|
||||
SavedAt = AppliedOn.AddDays(-2),
|
||||
};
|
||||
db.JobApplications.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
return job;
|
||||
}
|
||||
|
||||
private static JobApplicationsController CreateController(JobTrackerContext db, string userId)
|
||||
{
|
||||
var controller = new JobApplicationsController(db, Mock.Of<ISummarizerService>(), Mock.Of<IAppEmailSender>(), TestHostFactory.CreateUserManager().Object, NullLogger<JobApplicationsController>.Instance);
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, userId)
|
||||
}, "test"))
|
||||
}
|
||||
};
|
||||
return controller;
|
||||
}
|
||||
|
||||
private static JobTrackerContext CreateDb() => TestHostFactory.CreateInMemoryDb();
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using System;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Xunit;
|
||||
|
||||
@@ -51,4 +53,172 @@ public sealed class JobPipelineTests
|
||||
Assert.False(JobPipeline.IsCanonical("Interviewing")); // synonym, not canonical
|
||||
Assert.False(JobPipeline.IsCanonical("Whatever"));
|
||||
}
|
||||
|
||||
// --- Pre-application (Prospect) stages ------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("bookmarked", "Saved")]
|
||||
[InlineData("to apply", "Saved")]
|
||||
[InlineData("shortlisted", "Interested")]
|
||||
[InlineData("drafting", "Preparing")]
|
||||
[InlineData("in preparation", "Preparing")]
|
||||
public void Normalize_canonicalizes_prospect_synonyms(string input, string expected)
|
||||
=> Assert.Equal(expected, JobPipeline.Normalize(input));
|
||||
|
||||
[Theory]
|
||||
[InlineData("Saved")]
|
||||
[InlineData("Interested")]
|
||||
[InlineData("Preparing")]
|
||||
[InlineData("bookmarked")]
|
||||
public void IsProspect_true_for_pre_application_stages(string status)
|
||||
=> Assert.True(JobPipeline.IsProspect(status));
|
||||
|
||||
[Theory]
|
||||
[InlineData("Applied")]
|
||||
[InlineData("Waiting")]
|
||||
[InlineData("Interview")]
|
||||
[InlineData("Offer")]
|
||||
[InlineData("Rejected")]
|
||||
[InlineData("Ghosted")]
|
||||
public void IsProspect_false_for_post_application_stages(string status)
|
||||
=> Assert.False(JobPipeline.IsProspect(status));
|
||||
|
||||
[Fact]
|
||||
public void IsProspect_false_for_custom_status()
|
||||
// Custom statuses predate the split and have always counted as applied. Treating them as
|
||||
// prospects would silently drop them out of existing users' analytics.
|
||||
=> Assert.False(JobPipeline.IsProspect("Take-home assignment"));
|
||||
|
||||
[Fact]
|
||||
public void Prospect_stages_sort_before_applied()
|
||||
{
|
||||
Assert.True(JobPipeline.OrderOf("Saved") < JobPipeline.OrderOf("Interested"));
|
||||
Assert.True(JobPipeline.OrderOf("Interested") < JobPipeline.OrderOf("Preparing"));
|
||||
Assert.True(JobPipeline.OrderOf("Preparing") < JobPipeline.OrderOf("Applied"));
|
||||
}
|
||||
|
||||
// --- SyncAppliedDate: DateApplied set <=> the job has left the Prospect stages -----------
|
||||
|
||||
[Fact]
|
||||
public void SyncAppliedDate_clears_the_applied_date_for_a_prospect()
|
||||
{
|
||||
var now = new DateTime(2026, 7, 17, 12, 0, 0, DateTimeKind.Utc);
|
||||
var job = new JobApplication { Status = "Saved", DateApplied = now.AddDays(-5) };
|
||||
|
||||
JobPipeline.SyncAppliedDate(job, now);
|
||||
|
||||
Assert.Null(job.DateApplied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SyncAppliedDate_stamps_when_a_prospect_becomes_applied()
|
||||
{
|
||||
var now = new DateTime(2026, 7, 17, 12, 0, 0, DateTimeKind.Utc);
|
||||
var job = new JobApplication { Status = "Preparing", DateApplied = null };
|
||||
|
||||
job.Status = "Applied";
|
||||
JobPipeline.SyncAppliedDate(job, now);
|
||||
|
||||
Assert.Equal(now, job.DateApplied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SyncAppliedDate_preserves_an_existing_applied_date()
|
||||
{
|
||||
var now = new DateTime(2026, 7, 17, 12, 0, 0, DateTimeKind.Utc);
|
||||
var applied = now.AddDays(-30);
|
||||
var job = new JobApplication { Status = "Interview", DateApplied = applied };
|
||||
|
||||
JobPipeline.SyncAppliedDate(job, now);
|
||||
|
||||
Assert.Equal(applied, job.DateApplied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DaysSince_is_null_without_an_applied_date()
|
||||
=> Assert.Null(new JobApplication { Status = "Saved", DateApplied = null }.DaysSince);
|
||||
|
||||
[Fact]
|
||||
public void SyncAppliedDate_returns_the_cleared_date_so_callers_can_record_it()
|
||||
{
|
||||
var now = new DateTime(2026, 7, 17, 12, 0, 0, DateTimeKind.Utc);
|
||||
var applied = now.AddDays(-5);
|
||||
var job = new JobApplication { Status = "Saved", DateApplied = applied };
|
||||
|
||||
var cleared = JobPipeline.SyncAppliedDate(job, now);
|
||||
|
||||
// The controller persists this as an AppliedDateCleared JobEvent, so the application
|
||||
// activity survives a backwards move.
|
||||
Assert.Equal(applied, cleared);
|
||||
Assert.Null(job.DateApplied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SyncAppliedDate_returns_null_when_nothing_was_cleared()
|
||||
{
|
||||
var now = new DateTime(2026, 7, 17, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
Assert.Null(JobPipeline.SyncAppliedDate(new JobApplication { Status = "Applied", DateApplied = now.AddDays(-1) }, now));
|
||||
Assert.Null(JobPipeline.SyncAppliedDate(new JobApplication { Status = "Saved", DateApplied = null }, now));
|
||||
}
|
||||
|
||||
// --- Withdrawn + board grouping ---------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("withdrew", "Withdrawn")]
|
||||
[InlineData("cancelled", "Withdrawn")]
|
||||
[InlineData("canceled", "Withdrawn")]
|
||||
public void Normalize_canonicalizes_withdrawn_synonyms(string input, string expected)
|
||||
=> Assert.Equal(expected, JobPipeline.Normalize(input));
|
||||
|
||||
[Fact]
|
||||
public void Declined_stays_rejected_and_does_not_become_withdrawn()
|
||||
// Opposite directions: the employer declined you vs you pulled out.
|
||||
=> Assert.Equal("Rejected", JobPipeline.Normalize("declined"));
|
||||
|
||||
[Fact]
|
||||
public void Withdrawn_is_closed_and_not_a_prospect()
|
||||
{
|
||||
Assert.False(JobPipeline.IsProspect("Withdrawn"));
|
||||
Assert.True(JobPipeline.IsCanonical("Withdrawn"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ghosted_and_waiting_are_still_canonical_stages()
|
||||
{
|
||||
// Retained deliberately: the rules engine parks unanswered jobs in Ghosted, and Waiting
|
||||
// carries its own follow-up rule. Removing either would strand that behaviour.
|
||||
Assert.True(JobPipeline.IsCanonical("Ghosted"));
|
||||
Assert.True(JobPipeline.IsCanonical("Waiting"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Board_groups_match_the_agreed_layout()
|
||||
{
|
||||
Assert.Equal(new[] { "Saved", "Interested", "Preparing" }, JobPipeline.StagesInGroup(PipelineGroup.NotApplied));
|
||||
Assert.Equal(new[] { "Applied", "Waiting", "Interview", "Offer" }, JobPipeline.StagesInGroup(PipelineGroup.Active));
|
||||
Assert.Equal(new[] { "Rejected", "Ghosted", "Withdrawn" }, JobPipeline.StagesInGroup(PipelineGroup.Closed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_stage_belongs_to_exactly_one_group()
|
||||
{
|
||||
var grouped = JobPipeline.StagesInGroup(PipelineGroup.NotApplied)
|
||||
.Concat(JobPipeline.StagesInGroup(PipelineGroup.Active))
|
||||
.Concat(JobPipeline.StagesInGroup(PipelineGroup.Closed))
|
||||
.ToList();
|
||||
|
||||
Assert.Equal(JobPipeline.Stages.Count, grouped.Count);
|
||||
Assert.Equal(grouped.Count, grouped.Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Offer_is_success_for_analytics_but_groups_under_active_for_the_board()
|
||||
{
|
||||
// The two axes deliberately disagree: StageAnalytics excludes Success from time-in-stage,
|
||||
// but the user is still actively working an Offer.
|
||||
var offer = JobPipeline.Stages.Single(s => s.Key == "Offer");
|
||||
Assert.Equal(PipelineCategory.Success, offer.Category);
|
||||
Assert.Equal(PipelineGroup.Active, offer.Group);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Guards the rule that matters most after Phase 0 added pre-application stages: a job the user
|
||||
/// has not applied to must never be chased for a reply or auto-ghosted. Nothing was submitted,
|
||||
/// so there is nobody to follow up with and nobody to be ghosted by.
|
||||
/// </summary>
|
||||
public sealed class RulesEngineProspectTests
|
||||
{
|
||||
private static readonly RuleSettings AggressiveSettings = new()
|
||||
{
|
||||
Id = 1,
|
||||
AppliedFollowUpDays = 1,
|
||||
AppliedGhostDays = 2,
|
||||
OfferFollowUpDays = 1,
|
||||
OfferGhostDays = 2,
|
||||
FeedbackFollowUpDays = 1,
|
||||
FeedbackGhostDays = 2,
|
||||
};
|
||||
|
||||
private static readonly DateTime Now = new(2026, 7, 17, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
[Theory]
|
||||
[InlineData("Saved")]
|
||||
[InlineData("Interested")]
|
||||
[InlineData("Preparing")]
|
||||
public void A_prospect_is_never_followed_up_or_ghosted_however_old(string status)
|
||||
{
|
||||
// Saved a year ago with thresholds of 1-2 days: an unguarded rule would ghost this.
|
||||
var job = new JobApplication
|
||||
{
|
||||
Status = status,
|
||||
DateApplied = null,
|
||||
SavedAt = Now.AddDays(-365),
|
||||
};
|
||||
|
||||
var decision = RulesEngine.Evaluate(AggressiveSettings, job, Now, lastMessageAt: null);
|
||||
|
||||
Assert.False(decision.NeedsFollowUp);
|
||||
Assert.False(decision.ShouldGhost);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_applied_job_with_no_applied_date_is_not_ghosted()
|
||||
{
|
||||
// Shouldn't happen (SyncAppliedDate stamps on the way into Applied), but a null must fail
|
||||
// safe rather than be read as "infinitely old" and silently ghost the job.
|
||||
var job = new JobApplication
|
||||
{
|
||||
Status = "Applied",
|
||||
DateApplied = null,
|
||||
SavedAt = Now.AddDays(-365),
|
||||
};
|
||||
|
||||
var decision = RulesEngine.Evaluate(AggressiveSettings, job, Now, lastMessageAt: null);
|
||||
|
||||
Assert.False(decision.NeedsFollowUp);
|
||||
Assert.False(decision.ShouldGhost);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_applied_job_past_the_threshold_still_ghosts()
|
||||
{
|
||||
// The guards above must not have disabled the actual rule.
|
||||
var job = new JobApplication
|
||||
{
|
||||
Status = "Applied",
|
||||
DateApplied = Now.AddDays(-30),
|
||||
SavedAt = Now.AddDays(-31),
|
||||
};
|
||||
|
||||
var decision = RulesEngine.Evaluate(AggressiveSettings, job, Now, lastMessageAt: null);
|
||||
|
||||
Assert.True(decision.NeedsFollowUp);
|
||||
Assert.True(decision.ShouldGhost);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user