//go:build integration package inttest import ( "context" "errors" "testing" "time" "github.com/google/uuid" "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 // games through a real game service and reading blocks through a social service. func newInvitationService() *lobby.InvitationService { return lobby.NewInvitationService(lobby.NewStore(testDB), newGameService(), account.NewStore(testDB), newSocialService()) } func englishInvite() lobby.InvitationSettings { return lobby.InvitationSettings{ Variant: engine.VariantEnglish, TurnTimeout: 24 * time.Hour, HintsAllowed: true, HintsPerPlayer: 1, } } func TestMatchmakingOpensThenJoins(t *testing.T) { ctx := context.Background() clearOpenGames(t) mm := newMatchmaker(t, newRobotService(t, newGameService()), 90*time.Second, 90*time.Second) a, b := provisionAccount(t), provisionAccount(t) // The first player opens a game and enters it immediately, still awaiting an opponent. r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true) if err != nil { t.Fatalf("enqueue a: %v", err) } if r1.Matched { t.Fatal("first enqueue must open a game awaiting an opponent, not match") } if _, _, status, err := newGameService().Participants(ctx, r1.Game.ID); err != nil || status != "open" { t.Fatalf("opened game status = %q err %v, want open", status, err) } // A second player for the same variant and rule joins that open game, which starts. r2, err := mm.Enqueue(ctx, b, engine.VariantEnglish, true) if err != nil { t.Fatalf("enqueue b: %v", err) } if !r2.Matched || r2.Game.ID != r1.Game.ID { t.Fatalf("second enqueue = (matched %v, game %s), want it to join the open game %s", r2.Matched, r2.Game.ID, r1.Game.ID) } seats, _, status, err := newGameService().Participants(ctx, r2.Game.ID) if err != nil { t.Fatalf("participants: %v", err) } has := func(id uuid.UUID) bool { for _, s := range seats { if s == id { return true } } return false } if status != "active" || len(seats) != 2 || !has(a) || !has(b) { t.Fatalf("joined game: status %q seats %v (want active with a=%s and b=%s)", status, seats, a, b) } } // TestMatchmakingReEnqueueOpensNewGame checks a re-enqueue opens a fresh open game // rather than returning the caller's existing one: tapping "random opponent" again while // still waiting starts a new search instead of bouncing the player back into the pending // game. Both games stay open until an opponent (a human or the reaper's robot) joins each. func TestMatchmakingReEnqueueOpensNewGame(t *testing.T) { ctx := context.Background() clearOpenGames(t) mm := newMatchmaker(t, newRobotService(t, newGameService()), 90*time.Second, 90*time.Second) a := provisionAccount(t) r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true) if err != nil { t.Fatalf("enqueue: %v", err) } r2, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true) if err != nil { t.Fatalf("re-enqueue: %v", err) } if r2.Matched || r2.Game.ID == r1.Game.ID { t.Fatalf("re-enqueue = (game %s, matched %v), want a new open game distinct from %s, unmatched", r2.Game.ID, r2.Matched, r1.Game.ID) } for _, g := range []uuid.UUID{r1.Game.ID, r2.Game.ID} { if _, _, status, err := newGameService().Participants(ctx, g); err != nil || status != "open" { t.Fatalf("game %s status = %q err %v, want open", g, status, err) } } } 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)} inv, err := svc.CreateInvitation(ctx, inviter, invitees, englishInvite()) if err != nil { t.Fatalf("create: %v", err) } if inv.Status != "pending" || len(inv.Invitees) != 2 { t.Fatalf("unexpected invitation: %+v", inv) } if got, err := svc.RespondInvitation(ctx, inv.ID, invitees[0], true); err != nil || got.Status != "pending" { t.Fatalf("first accept: status %q err %v", got.Status, err) } final, err := svc.RespondInvitation(ctx, inv.ID, invitees[1], true) if err != nil { t.Fatalf("second accept: %v", err) } 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) } if status != "active" || len(seats) != 3 || seats[0] != inviter { t.Fatalf("started game: status %q seats %v (inviter %s)", status, seats, inviter) } } 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()) if err != nil { t.Fatalf("create: %v", err) } got, err := svc.RespondInvitation(ctx, inv.ID, invitees[0], false) if err != nil { t.Fatalf("decline: %v", err) } 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) } } func TestInvitationLazyExpiry(t *testing.T) { ctx := context.Background() svc := newInvitationService() inviter := provisionAccount(t) invitees := []uuid.UUID{provisionAccount(t)} inv, err := svc.CreateInvitation(ctx, inviter, invitees, englishInvite()) if err != nil { t.Fatalf("create: %v", err) } if _, err := testDB.ExecContext(ctx, `UPDATE backend.game_invitations SET expires_at = now() - interval '1 minute' WHERE invitation_id = $1`, inv.ID); err != nil { t.Fatalf("backdate expiry: %v", err) } if _, err := svc.RespondInvitation(ctx, inv.ID, invitees[0], true); !errors.Is(err, lobby.ErrInvitationExpired) { t.Fatalf("respond expired = %v, want ErrInvitationExpired", err) } } func TestInvitationBlockedInvitee(t *testing.T) { ctx := context.Background() svc := newInvitationService() pub := &capturePublisher{} svc.SetNotifier(pub) soc := newSocialService() // The inviter has blocked an invitee: the invitation is refused outright (the inviter is aware). inviter := provisionAccount(t) blockedInvitee := provisionAccount(t) if err := soc.Block(ctx, inviter, blockedInvitee); err != nil { t.Fatalf("block: %v", err) } if _, err := svc.CreateInvitation(ctx, inviter, []uuid.UUID{blockedInvitee}, englishInvite()); !errors.Is(err, lobby.ErrInvitationBlocked) { t.Fatalf("create with a blocked invitee = %v, want ErrInvitationBlocked", err) } // An invitee who has blocked the inviter is instead suppressed: the invitation is created // (no error — the inviter notices nothing), but that invitee is never notified and never // sees it in their list. inviter2 := provisionAccount(t) suppressed := provisionAccount(t) if err := soc.Block(ctx, suppressed, inviter2); err != nil { t.Fatalf("block: %v", err) } if _, err := svc.CreateInvitation(ctx, inviter2, []uuid.UUID{suppressed}, englishInvite()); err != nil { t.Fatalf("create with a suppressing invitee = %v, want nil", err) } if pub.notified(suppressed, notify.NotifyInvitation) { t.Error("an invitee who blocked the inviter must not be notified of the invitation") } if list, _ := svc.ListInvitations(ctx, suppressed); len(list) != 0 { t.Errorf("suppressed invitee's invitation list = %v, want empty", list) } } 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()) if err != nil { t.Fatalf("create: %v", err) } // A non-inviter cannot cancel. if err := svc.CancelInvitation(ctx, inv.ID, invitees[0]); !errors.Is(err, lobby.ErrNotInviter) { t.Fatalf("stranger cancel = %v, want ErrNotInviter", err) } 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) } } func TestListInvitations(t *testing.T) { ctx := context.Background() svc := newInvitationService() inviter := provisionAccount(t) invitee := provisionAccount(t) inv, err := svc.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite()) if err != nil { t.Fatalf("create: %v", err) } // An open invitation appears for both the inviter and the invitee. for _, who := range []uuid.UUID{inviter, invitee} { list, err := svc.ListInvitations(ctx, who) if err != nil { t.Fatalf("list for %s: %v", who, err) } if len(list) != 1 || list[0].ID != inv.ID { t.Fatalf("invitations for %s = %+v, want [%s]", who, list, inv.ID) } } // Once accepted (the game starts), it is no longer an open invitation. if _, err := svc.RespondInvitation(ctx, inv.ID, invitee, true); err != nil { t.Fatalf("accept: %v", err) } if list, _ := svc.ListInvitations(ctx, inviter); len(list) != 0 { t.Fatalf("started invitation still listed: %+v", list) } }