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:
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user