feat(career): master career profile API
CI and Deploy / test (push) Failing after 1m56s
CI and Deploy / deploy (push) Has been skipped

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:
cesnimda
2026-07-18 00:43:26 +02:00
parent 46ff9454a8
commit b203120ab4
4 changed files with 282 additions and 0 deletions
@@ -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);
}
}