feat(career): master career profile API
Phase 3, API layer. GET/PUT /api/career/profile — the endpoint the /career editor uses to read and write the master profile. - GET: returns the structured profile (assembled from the relational children, backfilled from the blob if needed) plus a completeness scorecard. - PUT: validates limits, persists via CareerProfileService (relational children + append-only version), then serializes the result into ApplicationUser.ProfileCvStructureJson so the legacy read paths stay in sync. Identity fields are untouched (they belong to /profile). - GET /completeness: just the scorecard, for the overview. - CareerCompleteness: weighted percent + missing sections. - CareerProfileValidator: item-count/length limits (abuse guard, NOT completeness — a work-in-progress profile always saves). Tests (+4): put/get round-trip + projection sync, completeness, over-limit rejection, empty WIP profile accepted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
|||||||
|
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 Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Tests;
|
||||||
|
|
||||||
|
public sealed class CareerProfileControllerTests
|
||||||
|
{
|
||||||
|
private static (CareerProfileController controller, JobTrackerContext db, ApplicationUser user) Build()
|
||||||
|
{
|
||||||
|
var db = TestHostFactory.CreateInMemoryDb();
|
||||||
|
var user = new ApplicationUser { Id = "user-1", Email = "ada@example.com", UserName = "ada" };
|
||||||
|
var users = TestHostFactory.CreateUserManager();
|
||||||
|
users.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||||
|
users.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||||
|
|
||||||
|
var controller = new CareerProfileController(users.Object, new CareerProfileService(db), db)
|
||||||
|
{
|
||||||
|
ControllerContext = new ControllerContext
|
||||||
|
{
|
||||||
|
HttpContext = new DefaultHttpContext
|
||||||
|
{
|
||||||
|
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "user-1") }, "test")),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return (controller, db, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StructuredCvProfile Sample() => new()
|
||||||
|
{
|
||||||
|
Contact = { FullName = "Ada Lovelace", Email = "ada@example.com" },
|
||||||
|
Jobs = { new StructuredCvJob { Title = "Engineer", Company = "Acme" } },
|
||||||
|
Skills = { "C#", "SQL" },
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Put_then_get_round_trips_the_profile_and_updates_the_projection()
|
||||||
|
{
|
||||||
|
var (controller, _, user) = Build();
|
||||||
|
|
||||||
|
var put = await controller.Put(Sample(), CancellationToken.None);
|
||||||
|
Assert.IsType<OkObjectResult>(put.Result);
|
||||||
|
|
||||||
|
// The derived blob projection is kept in sync for legacy readers.
|
||||||
|
Assert.Contains("Ada Lovelace", user.ProfileCvStructureJson);
|
||||||
|
|
||||||
|
var get = await controller.Get(CancellationToken.None);
|
||||||
|
var dto = Assert.IsType<CareerProfileDto>(Assert.IsType<OkObjectResult>(get.Result).Value);
|
||||||
|
Assert.Equal("Ada Lovelace", dto.Profile.Contact.FullName);
|
||||||
|
Assert.Single(dto.Profile.Jobs);
|
||||||
|
Assert.Equal(new[] { "C#", "SQL" }, dto.Profile.Skills);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Get_reports_completeness_with_missing_sections()
|
||||||
|
{
|
||||||
|
var (controller, _, _) = Build();
|
||||||
|
await controller.Put(Sample(), CancellationToken.None); // has personal + experience + skills
|
||||||
|
|
||||||
|
var get = await controller.Get(CancellationToken.None);
|
||||||
|
var dto = Assert.IsType<CareerProfileDto>(Assert.IsType<OkObjectResult>(get.Result).Value);
|
||||||
|
|
||||||
|
Assert.InRange(dto.Completeness.Percent, 1, 99);
|
||||||
|
Assert.Contains("Education", dto.Completeness.Missing); // not provided
|
||||||
|
Assert.DoesNotContain("Experience", dto.Completeness.Missing);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Put_rejects_an_over_limit_profile()
|
||||||
|
{
|
||||||
|
var (controller, _, _) = Build();
|
||||||
|
var huge = new StructuredCvProfile();
|
||||||
|
for (var i = 0; i < 500; i++) huge.Skills.Add($"skill-{i}");
|
||||||
|
|
||||||
|
var put = await controller.Put(huge, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<BadRequestObjectResult>(put.Result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Put_accepts_an_empty_work_in_progress_profile()
|
||||||
|
{
|
||||||
|
var (controller, _, _) = Build();
|
||||||
|
|
||||||
|
var put = await controller.Put(new StructuredCvProfile(), CancellationToken.None);
|
||||||
|
|
||||||
|
// Completeness, not validation, is what flags an incomplete profile — an empty profile saves.
|
||||||
|
Assert.IsType<OkObjectResult>(put.Result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using JobTrackerApi.Data;
|
||||||
|
using JobTrackerApi.Models;
|
||||||
|
using JobTrackerApi.Services;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Controllers;
|
||||||
|
|
||||||
|
// Phase 3: the master career profile API. /career reads and writes the structured profile through
|
||||||
|
// here. The relational children (via CareerProfileService) are the source of truth; the
|
||||||
|
// ApplicationUser.ProfileCvStructureJson blob is kept in sync as a derived projection so the legacy
|
||||||
|
// read paths (CV rendering, tailoring, match-score) keep working. See
|
||||||
|
// docs/architecture/career-profile-model.md.
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/career/profile")]
|
||||||
|
[Authorize]
|
||||||
|
public sealed class CareerProfileController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly UserManager<ApplicationUser> _users;
|
||||||
|
private readonly ICareerProfileService _career;
|
||||||
|
private readonly JobTrackerContext _db;
|
||||||
|
|
||||||
|
public CareerProfileController(UserManager<ApplicationUser> users, ICareerProfileService career, JobTrackerContext db)
|
||||||
|
{
|
||||||
|
_users = users;
|
||||||
|
_career = career;
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The master career profile, assembled from the relational children.</summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<CareerProfileDto>> Get(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var user = await _users.GetUserAsync(User);
|
||||||
|
if (user is null) return Unauthorized();
|
||||||
|
|
||||||
|
var profile = await _career.LoadStructuredAsync(user.Id, cancellationToken);
|
||||||
|
return Ok(new CareerProfileDto(profile, CareerCompleteness.Evaluate(profile)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replaces the master career profile. Persists the relational children + an append-only
|
||||||
|
/// version, then serializes the result back into the ProfileCvStructureJson projection so the
|
||||||
|
/// legacy readers stay consistent. Identity fields are untouched (they belong to /profile).
|
||||||
|
/// </summary>
|
||||||
|
[HttpPut]
|
||||||
|
[Authorize(AuthenticationSchemes = "local")]
|
||||||
|
public async Task<ActionResult<CareerProfileDto>> Put([FromBody] StructuredCvProfile? request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var user = await _users.GetUserAsync(User);
|
||||||
|
if (user is null) return StatusCode(501, "The career profile can only be edited on local accounts.");
|
||||||
|
|
||||||
|
var profile = StructuredCvProfileJson.Normalize(request);
|
||||||
|
var error = CareerProfileValidator.Validate(profile);
|
||||||
|
if (error is not null) return BadRequest(error);
|
||||||
|
|
||||||
|
var saved = await _career.SaveVersionAsync(user.Id, profile, "manual", cancellationToken);
|
||||||
|
|
||||||
|
// Keep the derived projection in sync for legacy readers.
|
||||||
|
user.ProfileCvStructureJson = StructuredCvProfileJson.Serialize(saved);
|
||||||
|
var res = await _users.UpdateAsync(user);
|
||||||
|
if (!res.Succeeded) return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
|
||||||
|
|
||||||
|
return Ok(new CareerProfileDto(saved, CareerCompleteness.Evaluate(saved)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Just the completeness scorecard, for the /career overview.</summary>
|
||||||
|
[HttpGet("completeness")]
|
||||||
|
public async Task<ActionResult<CareerCompletenessDto>> Completeness(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var user = await _users.GetUserAsync(User);
|
||||||
|
if (user is null) return Unauthorized();
|
||||||
|
var profile = await _career.LoadStructuredAsync(user.Id, cancellationToken);
|
||||||
|
return Ok(CareerCompleteness.Evaluate(profile));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record CareerProfileDto(StructuredCvProfile Profile, CareerCompletenessDto Completeness);
|
||||||
|
|
||||||
|
public sealed record CareerCompletenessDto(int Percent, List<string> Missing, List<CareerSectionStatusDto> Sections);
|
||||||
|
|
||||||
|
public sealed record CareerSectionStatusDto(string Key, string Label, bool Complete, int Count);
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using JobTrackerApi.Controllers;
|
||||||
|
using JobTrackerApi.Models;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Services;
|
||||||
|
|
||||||
|
// Phase 3: the /career overview scorecard — "Profile completeness: 70%, missing: skills, projects".
|
||||||
|
// Weighted so the sections that most affect a CV (identity, experience) count for more.
|
||||||
|
public static class CareerCompleteness
|
||||||
|
{
|
||||||
|
private sealed record Section(string Key, string Label, int Weight, Func<StructuredCvProfile, int> Count);
|
||||||
|
|
||||||
|
private static readonly Section[] Sections =
|
||||||
|
{
|
||||||
|
new("personal", "Personal details", 20, p =>
|
||||||
|
(!string.IsNullOrWhiteSpace(p.Contact.FullName) ? 1 : 0)
|
||||||
|
+ (!string.IsNullOrWhiteSpace(p.Contact.Email) ? 1 : 0)),
|
||||||
|
new("summary", "Professional summary", 10, p => p.Summary.Count(s => !string.IsNullOrWhiteSpace(s))),
|
||||||
|
new("experience", "Experience", 25, p => p.Jobs.Count),
|
||||||
|
new("education", "Education", 15, p => p.Education.Count),
|
||||||
|
new("skills", "Skills", 15, p => p.Skills.Count(s => !string.IsNullOrWhiteSpace(s))),
|
||||||
|
new("projects", "Projects", 10, p => p.Projects.Count),
|
||||||
|
new("languages", "Languages", 5, p => p.Languages.Count),
|
||||||
|
};
|
||||||
|
|
||||||
|
public static CareerCompletenessDto Evaluate(StructuredCvProfile profile)
|
||||||
|
{
|
||||||
|
var statuses = new List<CareerSectionStatusDto>();
|
||||||
|
var missing = new List<string>();
|
||||||
|
var earned = 0;
|
||||||
|
var total = 0;
|
||||||
|
|
||||||
|
foreach (var s in Sections)
|
||||||
|
{
|
||||||
|
total += s.Weight;
|
||||||
|
var count = s.Count(profile);
|
||||||
|
// "personal" needs both name + email (count 2); the rest need at least one item.
|
||||||
|
var complete = s.Key == "personal" ? count >= 2 : count >= 1;
|
||||||
|
if (complete) earned += s.Weight;
|
||||||
|
else missing.Add(s.Label);
|
||||||
|
statuses.Add(new CareerSectionStatusDto(s.Key, s.Label, complete, count));
|
||||||
|
}
|
||||||
|
|
||||||
|
var percent = total == 0 ? 0 : (int)Math.Round(100.0 * earned / total);
|
||||||
|
return new CareerCompletenessDto(percent, missing, statuses);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using JobTrackerApi.Models;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Services;
|
||||||
|
|
||||||
|
// Phase 3 (validation): guards against abuse/DoS on the career-profile write path, NOT content
|
||||||
|
// completeness (that is CareerCompleteness's job — a WIP profile must always be savable). Enforces
|
||||||
|
// item counts and string lengths so a single PUT cannot store an unbounded blob. Returns an error
|
||||||
|
// string to reject, or null to accept.
|
||||||
|
public static class CareerProfileValidator
|
||||||
|
{
|
||||||
|
private const int MaxItemsPerSection = 200; // generous; a real CV has < 50
|
||||||
|
private const int MaxListEntries = 200; // bullets/skills/details per item
|
||||||
|
private const int MaxShortField = 500; // titles, names, single-line fields
|
||||||
|
private const int MaxLongField = 5000; // a single bullet/summary line
|
||||||
|
|
||||||
|
public static string? Validate(StructuredCvProfile p)
|
||||||
|
{
|
||||||
|
if (p.Jobs.Count > MaxItemsPerSection) return $"Too many experience entries (max {MaxItemsPerSection}).";
|
||||||
|
if (p.Education.Count > MaxItemsPerSection) return $"Too many education entries (max {MaxItemsPerSection}).";
|
||||||
|
if (p.Skills.Count > MaxItemsPerSection) return $"Too many skills (max {MaxItemsPerSection}).";
|
||||||
|
if (p.Projects.Count > MaxItemsPerSection) return $"Too many projects (max {MaxItemsPerSection}).";
|
||||||
|
if (p.Certifications.Count > MaxItemsPerSection) return $"Too many certifications (max {MaxItemsPerSection}).";
|
||||||
|
if (p.Languages.Count > MaxItemsPerSection) return $"Too many languages (max {MaxItemsPerSection}).";
|
||||||
|
if (p.Summary.Count > MaxListEntries) return $"Summary is too long (max {MaxListEntries} lines).";
|
||||||
|
|
||||||
|
foreach (var j in p.Jobs)
|
||||||
|
{
|
||||||
|
if (Over(j.Title, MaxShortField) || Over(j.Company, MaxShortField) || Over(j.Location, MaxShortField))
|
||||||
|
return "An experience field exceeds the allowed length.";
|
||||||
|
if (j.Bullets.Count > MaxListEntries || j.Skills.Count > MaxListEntries) return "An experience has too many bullets/skills.";
|
||||||
|
if (j.Bullets.Any(b => Over(b, MaxLongField))) return "An experience bullet is too long.";
|
||||||
|
}
|
||||||
|
foreach (var e in p.Education)
|
||||||
|
{
|
||||||
|
if (Over(e.Qualification, MaxShortField) || Over(e.Institution, MaxShortField)) return "An education field exceeds the allowed length.";
|
||||||
|
if (e.Details.Count > MaxListEntries) return "An education entry has too many details.";
|
||||||
|
}
|
||||||
|
foreach (var pr in p.Projects)
|
||||||
|
{
|
||||||
|
if (Over(pr.Name, MaxShortField) || Over(pr.Role, MaxShortField)) return "A project field exceeds the allowed length.";
|
||||||
|
if (pr.Bullets.Count > MaxListEntries || pr.Skills.Count > MaxListEntries) return "A project has too many bullets/skills.";
|
||||||
|
}
|
||||||
|
foreach (var s in p.Skills)
|
||||||
|
if (Over(s, MaxShortField)) return "A skill entry is too long.";
|
||||||
|
|
||||||
|
if (Over(p.Contact.FullName, MaxShortField) || Over(p.Contact.Email, MaxShortField)
|
||||||
|
|| Over(p.Contact.Headline, MaxShortField) || Over(p.Contact.Location, MaxShortField))
|
||||||
|
return "A contact field exceeds the allowed length.";
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Over(string? value, int max) => value is not null && value.Length > max;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user