diff --git a/JobTrackerApi.Tests/CareerProfileControllerTests.cs b/JobTrackerApi.Tests/CareerProfileControllerTests.cs new file mode 100644 index 0000000..6b2eeee --- /dev/null +++ b/JobTrackerApi.Tests/CareerProfileControllerTests.cs @@ -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())).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(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(Assert.IsType(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(Assert.IsType(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(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(put.Result); + } +} diff --git a/JobTrackerApi/Controllers/CareerProfileController.cs b/JobTrackerApi/Controllers/CareerProfileController.cs new file mode 100644 index 0000000..348406c --- /dev/null +++ b/JobTrackerApi/Controllers/CareerProfileController.cs @@ -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 _users; + private readonly ICareerProfileService _career; + private readonly JobTrackerContext _db; + + public CareerProfileController(UserManager users, ICareerProfileService career, JobTrackerContext db) + { + _users = users; + _career = career; + _db = db; + } + + /// The master career profile, assembled from the relational children. + [HttpGet] + public async Task> 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))); + } + + /// + /// 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). + /// + [HttpPut] + [Authorize(AuthenticationSchemes = "local")] + public async Task> 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))); + } + + /// Just the completeness scorecard, for the /career overview. + [HttpGet("completeness")] + public async Task> 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 Missing, List Sections); + +public sealed record CareerSectionStatusDto(string Key, string Label, bool Complete, int Count); diff --git a/JobTrackerApi/Services/CareerCompleteness.cs b/JobTrackerApi/Services/CareerCompleteness.cs new file mode 100644 index 0000000..c2dd200 --- /dev/null +++ b/JobTrackerApi/Services/CareerCompleteness.cs @@ -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 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(); + var missing = new List(); + 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); + } +} diff --git a/JobTrackerApi/Services/CareerProfileValidator.cs b/JobTrackerApi/Services/CareerProfileValidator.cs new file mode 100644 index 0000000..cce03de --- /dev/null +++ b/JobTrackerApi/Services/CareerProfileValidator.cs @@ -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; +}