Reload places and invitations on their own stale-state gaps (241, 242, 243) #185

Merged
rob merged 1 commit from feature/task-241-stale-group-state into main 2026-08-17 06:05:15 +00:00
Owner

Tasks 241, 242 and 243 turned out not to share one root cause.

241 (first SSO sign-in hides the personal group): not a stale-state bug. Added OidcSignInJourneyTests, a real end-to-end SSO sign-in through a new StubOidcProvider Kestrel host wired into PlaceMarkAppFixture (this fixture never drove a genuine OIDC round trip before). It passes on unmodified main: CallerGroupsState loads the new personal group correctly and "Add a place" appears immediately, no navigation or reload needed. What Rob saw is PlaceAccordion.HasNothingToShow (ADR-0095, already covered by PlaceAccordionTests) — a brand-new account with no places and no shared group deliberately shows "You have no places yet." instead of a one-row accordion, identical for local and SSO sign-in. No code change; this is a UX expectation gap, not a defect. Worth a separate ticket if the personal group should be named explicitly on first sign-in.

242 (received invitation needs a hard refresh): confirmed and fixed. PendingInvitationsState.EnsureLoadedAsync's cache-once guard meant an invitation sent after NavMenu's badge first loaded the state never reappeared, even navigating to /account again — a hard refresh was genuinely the only way to see it. Added PendingInvitationsState.ReloadAsync (mirrors CallerGroupsState.ReloadAsync) and call it from Account.OnInitializedAsync instead of EnsureLoadedAsync. Scope is deliberately "re-fetch on navigation to /account", not live push — v1 has no realtime mechanism, and the comment says so. Reproduced first with ReceivedInvitationVisibilityJourneyTests (E2E, in-app link navigation only, no GotoAsync) and a bUnit test rendering Account twice against one shared state; both went red against the old EnsureLoadedAsync call and green after the fix.

243 (accepted invitation needs a hard refresh): partially already correct, one real gap fixed. Account.AcceptInvitationAsync already called CallerGroupsState.ReloadAsync(), so an empty freshly-joined group appears immediately (existing InviteAndAcceptJourneyTests case, unmodified). The gap is a group that already has places before the invitee accepts: MapPlacesState was never reloaded, so the row appeared but empty ("No places in this group yet.") until something else reloaded places. Added MapPlacesState.ReloadAsync (same shape) and call it alongside the groups reload. Reproduced with a new InviteThenAccept_GroupAlreadyHasAPlace_... E2E case (owner adds a place before inviting) and a bUnit assertion on the GET /api/places count; both went red against the old code and green after.

No ADR: both fixes apply the ReloadAsync pattern CallerGroupsState already established, to two more places that hadn't received it — not a new mechanism.

All new/modified tests were watched red against the unfixed code before being restored green. Full suite: 2016 unit/integration tests (Domain, Contracts, Architecture, WebUI, Api, Infrastructure) plus 28 E2E journeys, all green.

Tasks 241, 242 and 243 turned out not to share one root cause. **241 (first SSO sign-in hides the personal group): not a stale-state bug.** Added `OidcSignInJourneyTests`, a real end-to-end SSO sign-in through a new `StubOidcProvider` Kestrel host wired into `PlaceMarkAppFixture` (this fixture never drove a genuine OIDC round trip before). It passes on unmodified `main`: `CallerGroupsState` loads the new personal group correctly and "Add a place" appears immediately, no navigation or reload needed. What Rob saw is `PlaceAccordion.HasNothingToShow` (ADR-0095, already covered by `PlaceAccordionTests`) — a brand-new account with no places and no shared group deliberately shows "You have no places yet." instead of a one-row accordion, identical for local and SSO sign-in. No code change; this is a UX expectation gap, not a defect. Worth a separate ticket if the personal group should be named explicitly on first sign-in. **242 (received invitation needs a hard refresh): confirmed and fixed.** `PendingInvitationsState.EnsureLoadedAsync`'s cache-once guard meant an invitation sent after `NavMenu`'s badge first loaded the state never reappeared, even navigating to `/account` again — a hard refresh was genuinely the only way to see it. Added `PendingInvitationsState.ReloadAsync` (mirrors `CallerGroupsState.ReloadAsync`) and call it from `Account.OnInitializedAsync` instead of `EnsureLoadedAsync`. Scope is deliberately "re-fetch on navigation to `/account`", not live push — v1 has no realtime mechanism, and the comment says so. Reproduced first with `ReceivedInvitationVisibilityJourneyTests` (E2E, in-app link navigation only, no `GotoAsync`) and a bUnit test rendering `Account` twice against one shared state; both went red against the old `EnsureLoadedAsync` call and green after the fix. **243 (accepted invitation needs a hard refresh): partially already correct, one real gap fixed.** `Account.AcceptInvitationAsync` already called `CallerGroupsState.ReloadAsync()`, so an empty freshly-joined group appears immediately (existing `InviteAndAcceptJourneyTests` case, unmodified). The gap is a group that already has places before the invitee accepts: `MapPlacesState` was never reloaded, so the row appeared but empty ("No places in this group yet.") until something else reloaded places. Added `MapPlacesState.ReloadAsync` (same shape) and call it alongside the groups reload. Reproduced with a new `InviteThenAccept_GroupAlreadyHasAPlace_...` E2E case (owner adds a place before inviting) and a bUnit assertion on the `GET /api/places` count; both went red against the old code and green after. No ADR: both fixes apply the `ReloadAsync` pattern `CallerGroupsState` already established, to two more places that hadn't received it — not a new mechanism. All new/modified tests were watched red against the unfixed code before being restored green. Full suite: 2016 unit/integration tests (Domain, Contracts, Architecture, WebUI, Api, Infrastructure) plus 28 E2E journeys, all green.
Reload places and invitations on their own stale-state gaps (242, 243)
All checks were successful
CI / build (pull_request) Successful in 4m39s
CI / container-images (pull_request) Successful in 5s
CI / e2e (pull_request) Successful in 3m23s
d0172925ff
rob left a comment

Verdict: mergeable

242 and 243 are genuine, correctly-scoped fixes: ReloadAsync placement is right, the bUnit tests plausibly discriminate the old EnsureLoadedAsync call (traced through by hand — reverting to EnsureLoadedAsync would leave the second /api/users/me/invitations GET and the /api/places GET un-made, failing both new assertions), the 243 E2E case asserts the place row with no navigation/reload between accept and check as task 243 requires, and 242's live-push limitation is stated in a durable code comment, not just the PR body.

241's "not a bug" conclusion holds up. HasNothingToShow (PlacesState.Groups.Count == 0 && !CallerGroups.Groups.Any(g => !g.IsPersonal)) is pre-existing and already has dedicated coverage — PlaceAccordion_AccountHasOnlyItsPersonalGroupAndNoPlaces_ShowsTheFlatEmptyMessageNotAnAccordionOfOneEmptyRow predates this PR and proves a personal-group-only account renders zero accordion headers, by design (ADR-0095). Nothing in that gate or in CallerGroupsState/MapPlacesState branches on how the caller signed in, so the same UX applies to local registration too. OidcSignInJourneyTests doesn't merely pass vacuously: CallerGroupsState has never loaded before this first sign-in in a fresh browser context, AuthStateProvider.SignInAsync is awaited fully before the client-side NavigateTo, and PKCE/redirect mechanics are exercised for real, so a genuine stale-cache or broken-redirect regression would plausibly turn this red. Not proven, but well-supported, and no ADR is needed for either the 241 conclusion (already documented in ADR-0095) or the 242/243 fixes (both apply the existing ReloadAsync pattern).

One gap worth closing, not blocking: the new test asserts "Add a place" is visible, but never asserts the flat "You have no places yet." message actually renders for this SSO account (or that zero accordion headers appear). That's the specific claim the PR body makes about what Rob saw, and right now it's inferred by reading HasNothingToShow's pre-existing coverage rather than demonstrated by this test itself. Adding that assertion would make the test's connection to the 241 narrative airtight instead of merely consistent with it.

Verdict: mergeable 242 and 243 are genuine, correctly-scoped fixes: `ReloadAsync` placement is right, the bUnit tests plausibly discriminate the old `EnsureLoadedAsync` call (traced through by hand — reverting to `EnsureLoadedAsync` would leave the second `/api/users/me/invitations` GET and the `/api/places` GET un-made, failing both new assertions), the 243 E2E case asserts the place row with no navigation/reload between accept and check as task 243 requires, and 242's live-push limitation is stated in a durable code comment, not just the PR body. 241's "not a bug" conclusion holds up. `HasNothingToShow` (`PlacesState.Groups.Count == 0 && !CallerGroups.Groups.Any(g => !g.IsPersonal)`) is pre-existing and already has dedicated coverage — `PlaceAccordion_AccountHasOnlyItsPersonalGroupAndNoPlaces_ShowsTheFlatEmptyMessageNotAnAccordionOfOneEmptyRow` predates this PR and proves a personal-group-only account renders zero accordion headers, by design (ADR-0095). Nothing in that gate or in `CallerGroupsState`/`MapPlacesState` branches on how the caller signed in, so the same UX applies to local registration too. `OidcSignInJourneyTests` doesn't merely pass vacuously: `CallerGroupsState` has never loaded before this first sign-in in a fresh browser context, `AuthStateProvider.SignInAsync` is awaited fully before the client-side `NavigateTo`, and PKCE/redirect mechanics are exercised for real, so a genuine stale-cache or broken-redirect regression would plausibly turn this red. Not proven, but well-supported, and no ADR is needed for either the 241 conclusion (already documented in ADR-0095) or the 242/243 fixes (both apply the existing `ReloadAsync` pattern). One gap worth closing, not blocking: the new test asserts "Add a place" is visible, but never asserts the flat "You have no places yet." message actually renders for this SSO account (or that zero accordion headers appear). That's the specific claim the PR body makes about what Rob saw, and right now it's inferred by reading `HasNothingToShow`'s pre-existing coverage rather than demonstrated by this test itself. Adding that assertion would make the test's connection to the 241 narrative airtight instead of merely consistent with it.
@ -146,0 +158,4 @@
/// itself, and for the same reason: nothing about the mutation that makes this stale is a signal
/// this class watches on its own.
/// </summary>
public async Task ReloadAsync(CancellationToken cancellationToken = default) => await LoadAsync(cancellationToken);
Author
Owner

ReloadAsync bypasses the _isLoading guard EnsureLoadedAsync has, by design — but that means a ReloadAsync call that lands while an EnsureLoadedAsync-triggered load is still in flight runs two concurrent LoadAsync invocations against the same _places list (Clear/AddRange interleaved across await points on the UI thread), which isn't safe. Narrow window in practice, and CallerGroupsState.ReloadAsync already has the same shape, so this isn't new — flagging for awareness rather than blocking on it.

ReloadAsync bypasses the `_isLoading` guard EnsureLoadedAsync has, by design — but that means a ReloadAsync call that lands while an EnsureLoadedAsync-triggered load is still in flight runs two concurrent LoadAsync invocations against the same `_places` list (Clear/AddRange interleaved across await points on the UI thread), which isn't safe. Narrow window in practice, and CallerGroupsState.ReloadAsync already has the same shape, so this isn't new — flagging for awareness rather than blocking on it.
@ -0,0 +41,4 @@
// CallerGroupsState were still showing whatever it held before sign-in (nothing, for a caller
// who was anonymous a moment ago) rather than the freshly signed-in caller's own groups, this
// button would not appear without a further navigation or reload.
await Expect(page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "Add a place", Exact = true })).ToBeVisibleAsync();
Author
Owner

This proves CallerGroupsState loaded the personal group (via the "Add a place" gate, which is independent of HasNothingToShow), but it doesn't assert the actual claim the PR body makes about this scenario: that the accordion shows the flat "You have no places yet." message with no group row, matching HasNothingToShow. Worth adding await Expect(page.GetByText("You have no places yet.")).ToBeVisibleAsync(); (or asserting zero button.accordion-header) here so this test demonstrates the 241 explanation directly rather than relying on a reader to cross-reference PlaceAccordionTests.

This proves CallerGroupsState loaded the personal group (via the "Add a place" gate, which is independent of HasNothingToShow), but it doesn't assert the actual claim the PR body makes about this scenario: that the accordion shows the flat "You have no places yet." message with no group row, matching HasNothingToShow. Worth adding `await Expect(page.GetByText("You have no places yet.")).ToBeVisibleAsync();` (or asserting zero `button.accordion-header`) here so this test demonstrates the 241 explanation directly rather than relying on a reader to cross-reference PlaceAccordionTests.
rob merged commit 7291de1073 into main 2026-08-17 06:05:15 +00:00
rob deleted branch feature/task-241-stale-group-state 2026-08-17 06:05:15 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rob/PlaceMark!185
No description provided.