Skip to content

refactor: migrate UserInvitationResult to AndreGoepel.Core.Result<T> - #152

Merged
andregoepel merged 1 commit into
mainfrom
feature/migrate-to-andregoepel-core
Jul 26, 2026
Merged

refactor: migrate UserInvitationResult to AndreGoepel.Core.Result<T>#152
andregoepel merged 1 commit into
mainfrom
feature/migrate-to-andregoepel-core

Conversation

@andregoepel

Copy link
Copy Markdown
Owner

Summary

Adds AndreGoepel.Core v1.0.1 (AndreGoepel.Core.Result<T>) as a dependency of AndreGoepel.Marten.Identity and uses it for the one real ad-hoc result type in this repo. Per #148's guardrail, Microsoft.AspNetCore.Identity.IdentityResult usage in UserStore/RoleStore is untouched — that stays the idiomatic contract the framework expects.

1. UserInvitationResultResult<UserInvitationDetails>

UserInvitationService.InviteAsync/ResendAsync now return Result<UserInvitationDetails> (UserInvitationDetails = (User User, string Token)) instead of the bespoke UserInvitationResult record.

Errors → Error reconciliation: the old type wrapped a full IdentityResult and exposed a computed ErrorMessage that already joined Result.Errors.Select(e => e.Description) for display — so the "collection vs single string" question was largely already answered by the existing code. I audited every failure path in InviteAsync/ResendAsync:

  • NotAuthorized, DuplicateEmail, InvitationAlreadyAccepted — each constructs exactly one IdentityError.
  • userManager.CreateAsync(user) and userStore.AddToRoleAsync(...) return real framework IdentityResults that could in principle carry more than one error.

Rather than assume single-error-only, Result<UserInvitationDetails>.Error is built by joining IdentityResult.Errors.Select(e => e.Description) with ", " — identical to the old ErrorMessage behavior, so no information is dropped even in the multi-error case.

What is genuinely lost: IdentityError.Code (e.g. NotAuthorized, DuplicateEmail, InvitationAlreadyAccepted) is no longer carried at this boundary, since Result<T>.Error is a plain string. I checked every consumer before accepting this:

  • UserInvitationMailer (the only direct consumer) never branched on .Code, only joined .Errors.Select(e => e.Description).
  • InviteUser.razor.cs and Users.razor (the Blazor UI) consume UserInvitationMailer's return value, which is IdentityResult — untouched by this PR — and also only ever display the joined description text, never branch on .Code.
  • Only the integration tests (UserInvitationServiceTests) asserted on .Code; these are updated to assert on the message content instead (e.g. Assert.Contains("administrator authority", result.Error)).

So the code-branching contract IdentityErrorCodes documents ("consuming apps branch on them") is preserved everywhere it actually applies — it was never exercised at the UserInvitationResult boundary to begin with.

UserInvitationMailer keeps its own public Task<IdentityResult> signature unchanged (that's the Blazor-facing contract InviteUser.razor.cs/Users.razor use, out of scope here) and internally reconstructs an IdentityResult.Failed from Result<T>.Error with a generic code, since nothing reads it.

2. Passkey ?? throw new InvalidOperationException("User not found") (UserStore.cs ~728/764/790) — left as documented follow-up

All three throw sites live inside UserStore<TUser> methods (AddOrUpdatePasskeyAsync, GetPasskeysAsync, FindPasskeyAsync) that implement Microsoft.AspNetCore.Identity.IUserPasskeyStore<TUser>. Unlike #143's AddToRoleAsync/RemoveFromRoleAsync (which had a repo-owned overload the store's own callers used, separate from the interface-mandated one), these three are called directly through UserManager<TUser> in real app code (IdentityComponentsEndpointRouteBuilderExtensions.cs:171). Their return types — Task, Task<IList<UserPasskeyInfo>>, Task<UserPasskeyInfo?> — are dictated by the ASP.NET Core Identity framework, not this repo, so changing them to Result<T> isn't a "many call sites" tradeoff, it's a hard interface-compliance blocker: UserManager's own wrapper methods have fixed signatures we cannot change.

I also considered a narrower change — silently returning []/null instead of throwing for the two methods whose existing signatures could already encode "not found" (GetPasskeysAsync returning empty, FindPasskeyAsync already returning UserPasskeyInfo?) — but decided against it: these methods are re-querying for a TUser that was already loaded by the caller, so "not found" here means the user record vanished between load and this call (e.g. deleted concurrently) or a stale/detached user was passed in. Silently treating that as "no passkeys" would mask a real data-consistency bug rather than surface it. Leaving this as a fail-fast throw stays consistent with how the rest of the store treats unexpected state.

Leaving this as documented follow-up rather than forcing a Result<T>/nullable shape that would either not compile against the framework interface or quietly change failure semantics.

Verification

  • dotnet csharpier format . — clean, only the files this PR touches were reformatted.
  • dotnet build — 0 errors, 0 warnings (TreatWarningsAsErrors is active).
  • dotnet test --filter "FullyQualifiedName!~E2ETests" — all green: 191 unit tests, 130 Blazor bUnit tests, 92 integration tests (Testcontainers/PostgreSQL, Docker was available), including all UserInvitationServiceTests.
  • dotnet list package --vulnerable --include-transitive — clean across all projects.

Closes #148

🤖 Generated with Claude Code

Replaces the repo's ad-hoc UserInvitationResult record with the canonical
AndreGoepel.Core.Result<UserInvitationDetails>, now that AndreGoepel.Core
sits below marten-identity in the dependency graph. IdentityResult usage
in UserStore/RoleStore is untouched, per #148's guardrail.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Consider Result<T> for UserInvitationResult and the remaining typed-exception gap

1 participant