//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 TestMatchmakingPairsAndStartsGame(t *testing.T) { ctx := context.Background() mm := newMatchmaker(t, newRobotService(t, newGameService()), 10*time.Second) a, b := provisionAccount(t), provisionAccount(t) r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish) if err != nil { t.Fatalf("enqueue a: %v", err) } if r1.Matched { t.Fatal("first enqueue must wait") } r2, err := mm.Enqueue(ctx, b, engine.VariantEnglish) if err != nil { t.Fatalf("enqueue b: %v", err) } if !r2.Matched { t.Fatal("second enqueue must match") } seats, _, status, err := newGameService().Participants(ctx, r2.Game.ID) if err != nil { t.Fatalf("participants: %v", err) } if status != "active" || len(seats) != 2 { t.Fatalf("matched game state: status %q seats %v", status, seats) } } 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) } }