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(new CareerProfileSaveRequest(Sample(), null), 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 Put_then_get_preserves_reviewed_free_form_fields() { var (controller, _, user) = Build(); var profile = Sample(); profile.Contact.Location = "Oslo, Norway and Remote across Europe"; profile.Contact.Website = "https://example.test/portfolio/ada?view=full"; profile.Contact.LinkedIn = "https://www.linkedin.com/in/ada-lovelace"; profile.Jobs[0].Location = "Oslo, Norway and Remote across Europe"; profile.Jobs[0].Start = "Spring 2020"; profile.Jobs.Add(new StructuredCvJob { Location = "Remote across Europe", Start = "Before 2020" }); profile.Languages.Add(new StructuredCvLanguage { Name = "Norwegian Sign Language", Level = "Professional working proficiency", Notes = "Used with distributed teams", }); var put = await controller.Put(new CareerProfileSaveRequest(profile, null), CancellationToken.None); var saved = Assert.IsType(Assert.IsType(put.Result).Value).Profile; var get = await controller.Get(CancellationToken.None); var reloaded = Assert.IsType(Assert.IsType(get.Result).Value).Profile; foreach (var actual in new[] { saved, reloaded }) { Assert.Equal("Oslo, Norway and Remote across Europe", actual.Contact.Location); Assert.Equal("https://example.test/portfolio/ada?view=full", actual.Contact.Website); Assert.Equal("https://www.linkedin.com/in/ada-lovelace", actual.Contact.LinkedIn); Assert.Equal("Oslo, Norway and Remote across Europe", actual.Jobs[0].Location); Assert.Equal("Spring 2020", actual.Jobs[0].Start); Assert.Equal("Remote across Europe", actual.Jobs[1].Location); Assert.Equal("Before 2020", actual.Jobs[1].Start); Assert.Equal("Norwegian Sign Language", actual.Languages[0].Name); Assert.Equal("Professional working proficiency", actual.Languages[0].Level); } var projected = StructuredCvProfileJson.DeserializePersisted(user.ProfileCvStructureJson); Assert.Equal("https://example.test/portfolio/ada?view=full", projected.Contact.Website); } [Fact] public async Task Get_reports_completeness_with_missing_sections() { var (controller, _, _) = Build(); await controller.Put(new CareerProfileSaveRequest(Sample(), null), 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(new CareerProfileSaveRequest(huge, null), CancellationToken.None); Assert.IsType(put.Result); } [Fact] public async Task Put_rejects_an_over_limit_reviewed_contact_value_instead_of_discarding_it() { var (controller, _, _) = Build(); var profile = Sample(); profile.Contact.Website = $"https://example.test/{new string('a', 600)}"; var put = await controller.Put(new CareerProfileSaveRequest(profile, null), CancellationToken.None); var badRequest = Assert.IsType(put.Result); Assert.Equal("A contact field exceeds the allowed length.", badRequest.Value); } [Fact] public async Task Put_accepts_an_empty_work_in_progress_profile() { var (controller, _, _) = Build(); var put = await controller.Put(new CareerProfileSaveRequest(new StructuredCvProfile(), null), CancellationToken.None); // Completeness, not validation, is what flags an incomplete profile — an empty profile saves. Assert.IsType(put.Result); } }