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:
cesnimda
2026-07-17 17:05:25 +02:00
parent b176a44627
commit eac34705e3
36 changed files with 3060 additions and 96 deletions
+170
View File
@@ -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);
}
}