//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" ) // 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) } } // TestMatchmakingReEnqueueReturnsOwnOpenGame checks a re-enqueue is idempotent: the // caller gets their existing open game rather than a second one. func TestMatchmakingReEnqueueReturnsOwnOpenGame(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.Game.ID != r1.Game.ID || r2.Matched { t.Fatalf("re-enqueue = (game %s, matched %v), want the same open game %s unmatched", r2.Game.ID, r2.Matched, r1.Game.ID) } } func TestInvitationAllAcceptStartsGame(t *testing.T) { ctx := context.Background() svc := newInvitationService() 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) } 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() 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 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() social := newSocialService() inviter := provisionAccount(t) invitee := provisionAccount(t) if err := social.Block(ctx, invitee, inviter); err != nil { t.Fatalf("block: %v", err) } if _, err := svc.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite()); !errors.Is(err, lobby.ErrInvitationBlocked) { t.Fatalf("create blocked = %v, want ErrInvitationBlocked", err) } } func TestInvitationCancelByInviter(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) } // 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) } 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) } }