feat(lobby): keep lobby/game caches fresh from any screen + invitation delta channel
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
Builds on the cross-screen cache work: the global stream handler now keeps both caches current no matter which screen is mounted, and invitations become a live delta channel so the lobby's invitations list is fresh from any screen too. Client (boundary already started): - advanceCached now also folds opponent_joined into a not-currently-viewed game's cache via a new pure reducer applyOpponentJoined (extracted and reused by the mounted game board), so opening an open game that filled while you were elsewhere is flash-free. - patchLobbyInvitation upserts a still-pending invitation and removes a terminal one (started/declined/cancelled/expired); the global notify handler calls it on the invitation / invitation_update sub-kinds. Invitations delta channel (no wire/gateway/connector change — the notification already carries the full invitation with id/status/game_id end to end): - notify: a new in-app-only NotifyInvitationUpdate sub + NotificationInvitationUpdate constructor (shares encoding with NotificationInvitation). The Telegram connector renders no message for it, so a decline/cancel never becomes an out-of-app push. - lobby: emit the changed invitation to every participant on respond (accept/decline), on the final accept's game start, and on cancel — so each participant's lobby patches its list in place. The authoritative list holds only pending invitations, so the client's pending-vs-terminal rule matches it exactly. Tests: applyOpponentJoined + patchLobbyInvitation unit tests (TDD), the NotificationInvitationUpdate encoding unit test, and integration assertions that decline/cancel/accept publish invitation_update to every participant. Full local suite green (backend unit+integration, UI check/unit/build/bundle/e2e). Docs: ARCHITECTURE §10 (notify catalog + in-app-only note) and UI_DESIGN updated.
This commit is contained in:
@@ -13,6 +13,7 @@ import (
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/lobby"
|
||||
"scrabble/backend/internal/notify"
|
||||
)
|
||||
|
||||
// newInvitationService builds an invitation service over the shared pool, starting
|
||||
@@ -97,6 +98,8 @@ func TestMatchmakingReEnqueueReturnsOwnOpenGame(t *testing.T) {
|
||||
func TestInvitationAllAcceptStartsGame(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newInvitationService()
|
||||
pub := &capturePublisher{}
|
||||
svc.SetNotifier(pub)
|
||||
inviter := provisionAccount(t)
|
||||
invitees := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
|
||||
|
||||
@@ -118,6 +121,16 @@ func TestInvitationAllAcceptStartsGame(t *testing.T) {
|
||||
if final.Status != "started" || final.GameID == nil {
|
||||
t.Fatalf("invitation not started: %+v", final)
|
||||
}
|
||||
// Every participant is told the invitation changed so their lobby drops the (now started) card
|
||||
// from any screen, and the started-game notification lands too.
|
||||
for _, u := range append([]uuid.UUID{inviter}, invitees...) {
|
||||
if !pub.notified(u, notify.NotifyInvitationUpdate) {
|
||||
t.Errorf("participant %s missing invitation_update", u)
|
||||
}
|
||||
}
|
||||
if !pub.notified(invitees[0], notify.NotifyGameStarted) {
|
||||
t.Errorf("invitee missing game_started")
|
||||
}
|
||||
seats, _, status, err := newGameService().Participants(ctx, *final.GameID)
|
||||
if err != nil {
|
||||
t.Fatalf("participants: %v", err)
|
||||
@@ -130,6 +143,8 @@ func TestInvitationAllAcceptStartsGame(t *testing.T) {
|
||||
func TestInvitationDeclineCancels(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newInvitationService()
|
||||
pub := &capturePublisher{}
|
||||
svc.SetNotifier(pub)
|
||||
inviter := provisionAccount(t)
|
||||
invitees := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
|
||||
inv, err := svc.CreateInvitation(ctx, inviter, invitees, englishInvite())
|
||||
@@ -143,6 +158,12 @@ func TestInvitationDeclineCancels(t *testing.T) {
|
||||
if got.Status != "declined" || got.GameID != nil {
|
||||
t.Fatalf("after decline: %+v", got)
|
||||
}
|
||||
// A decline kills the whole invitation: every participant is notified to drop it.
|
||||
for _, u := range append([]uuid.UUID{inviter}, invitees...) {
|
||||
if !pub.notified(u, notify.NotifyInvitationUpdate) {
|
||||
t.Errorf("participant %s missing invitation_update after decline", u)
|
||||
}
|
||||
}
|
||||
// A further response is refused.
|
||||
if _, err := svc.RespondInvitation(ctx, inv.ID, invitees[1], true); !errors.Is(err, lobby.ErrInvitationNotPending) {
|
||||
t.Fatalf("respond after decline = %v, want ErrInvitationNotPending", err)
|
||||
@@ -184,6 +205,8 @@ func TestInvitationBlockedInvitee(t *testing.T) {
|
||||
func TestInvitationCancelByInviter(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newInvitationService()
|
||||
pub := &capturePublisher{}
|
||||
svc.SetNotifier(pub)
|
||||
inviter := provisionAccount(t)
|
||||
invitees := []uuid.UUID{provisionAccount(t)}
|
||||
inv, err := svc.CreateInvitation(ctx, inviter, invitees, englishInvite())
|
||||
@@ -197,6 +220,12 @@ func TestInvitationCancelByInviter(t *testing.T) {
|
||||
if err := svc.CancelInvitation(ctx, inv.ID, inviter); err != nil {
|
||||
t.Fatalf("inviter cancel: %v", err)
|
||||
}
|
||||
// The cancellation reaches every participant so an invitee's lobby drops the card from any screen.
|
||||
for _, u := range append([]uuid.UUID{inviter}, invitees...) {
|
||||
if !pub.notified(u, notify.NotifyInvitationUpdate) {
|
||||
t.Errorf("participant %s missing invitation_update after cancel", u)
|
||||
}
|
||||
}
|
||||
if _, err := svc.RespondInvitation(ctx, inv.ID, invitees[0], true); !errors.Is(err, lobby.ErrInvitationNotPending) {
|
||||
t.Fatalf("respond after cancel = %v, want ErrInvitationNotPending", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user