feat(phase-2): separate /profile (identity) from /career (master profile)

Phase 2 — Career/Profile separation. The master career profile is the source of
truth; identity and career data are now saved independently so neither wipes the
other. CV Builder deliberately not built yet.

Backend — PUT /auth/profile is now a partial update:
- null/omitted field -> unchanged; "" -> cleared; value -> set (trimmed).
- Email/UserName never cleared to empty (login identifiers).
This lets /profile save identity fields and /career save the master-profile
fields through the same endpoint without one nulling the other. 4 new tests
cover the data-integrity guarantees (identity save keeps the CV, career save
keeps identity, empty clears, null leaves).

Frontend:
- ProfilePage save payload is now scoped by careerOnly: /career sends only
  { profileCvText, profileCvStructureJson }, /profile sends only identity.
- CareerWorkspacePage: removed the inert "CV Builder" tab (careerView) — Phase 2
  establishes the master profile only; the builder is Phase 4.
- Dropped the dead careerView prop.
- Updated the CV-save test to render career mode and assert identity is excluded.

Source-of-truth flip (CareerProfileService authoritative) stays deferred to F5
per the branch design; CareerProfileService keeps mirroring via its dual-write.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-17 19:59:38 +02:00
parent a28c47f515
commit 66cc6a7db4
5 changed files with 132 additions and 39 deletions
@@ -421,6 +421,87 @@ public sealed class AuthAndSystemControllerTests
Assert.Equal("Ada L.", user.DisplayName);
}
private static AuthController BuildProfileController(ApplicationUser user)
{
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
return new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb());
}
[Fact]
public async Task Identity_only_save_does_not_wipe_the_master_cv()
{
// The Phase 2 /profile save omits the CV fields. A null field must leave the master CV
// untouched -- the data-integrity guarantee behind splitting the profile/career save.
var user = new ApplicationUser
{
Email = "ada@example.com",
UserName = "ada",
ProfileCvText = "Existing master CV text",
ProfileCvStructureJson = "{\"jobs\":[]}",
};
var controller = BuildProfileController(user);
var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest(null, null, "Ada", "Lovelace", "Ada L.", null, null));
Assert.IsType<NoContentResult>(result);
Assert.Equal("Ada", user.FirstName);
Assert.Equal("Existing master CV text", user.ProfileCvText);
Assert.Equal("{\"jobs\":[]}", user.ProfileCvStructureJson);
}
[Fact]
public async Task Career_only_save_does_not_wipe_identity()
{
// The mirror: /career saves the master profile and omits identity fields.
var user = new ApplicationUser
{
Email = "ada@example.com",
UserName = "ada",
FirstName = "Ada",
LastName = "Lovelace",
DisplayName = "Ada L.",
};
var controller = BuildProfileController(user);
var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest(null, null, null, null, null, "New CV text", "{\"jobs\":[1]}"));
Assert.IsType<NoContentResult>(result);
Assert.Equal("Ada", user.FirstName);
Assert.Equal("Lovelace", user.LastName);
Assert.Equal("Ada L.", user.DisplayName);
Assert.Equal("New CV text", user.ProfileCvText);
Assert.Equal("{\"jobs\":[1]}", user.ProfileCvStructureJson);
}
[Fact]
public async Task Empty_string_clears_a_field_but_null_leaves_it()
{
var user = new ApplicationUser { Email = "ada@example.com", UserName = "ada", DisplayName = "Ada L.", FirstName = "Ada" };
var controller = BuildProfileController(user);
// DisplayName "" -> cleared; FirstName null -> unchanged.
var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest(null, null, null, null, "", null, null));
Assert.IsType<NoContentResult>(result);
Assert.Null(user.DisplayName);
Assert.Equal("Ada", user.FirstName);
}
[Fact]
public async Task Email_and_username_are_never_cleared_to_empty()
{
var user = new ApplicationUser { Email = "ada@example.com", UserName = "ada" };
var controller = BuildProfileController(user);
var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest("", "", null, null, null, null, null));
Assert.IsType<NoContentResult>(result);
Assert.Equal("ada@example.com", user.Email);
Assert.Equal("ada", user.UserName);
}
[Fact]
public async Task Request_password_reset_returns_service_unavailable_when_email_send_fails()
{