//go:build integration package inttest import ( "context" "testing" "time" "github.com/google/uuid" "scrabble/backend/internal/account" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" ) // TestRobotFriendRequestIsPerGameAndReaped checks that sending a friend request to a // disguised-robot opponent is recorded per-game in robot_friend_requests — never in the // friendships table or against the shared robot account — that a re-send is idempotent, and // that the reaper deletes the row once its game has been finished past the retention window // while keeping the row for an active game. func TestRobotFriendRequestIsPerGameAndReaped(t *testing.T) { ctx := context.Background() svc := newSocialService() accs := account.NewStore(testDB) human := provisionAccount(t) robot, err := accs.ProvisionRobot(ctx, "robot-friend-"+uuid.NewString(), "Robbie") if err != nil { t.Fatalf("provision robot: %v", err) } g, err := newGameService().Create(ctx, game.CreateParams{ Variant: engine.VariantEnglish, Seats: []uuid.UUID{human, robot.ID}, TurnTimeout: 24 * time.Hour, Seed: openingSeed(t), }) if err != nil { t.Fatalf("create game: %v", err) } if err := svc.RequestInGame(ctx, human, robot.ID, g.ID); err != nil { t.Fatalf("request robot: %v", err) } rfr, err := svc.ListRobotFriendRequests(ctx, human) if err != nil { t.Fatalf("list robot friend requests: %v", err) } if len(rfr) != 1 || rfr[0].GameID != g.ID || rfr[0].Seat != 1 { t.Fatalf("robot friend requests = %v, want one for game %s seat 1", rfr, g.ID) } // The friendships table must stay empty: no outgoing request against the shared robot account. if out, _ := svc.ListOutgoingRequests(ctx, human); len(out) != 0 { t.Errorf("friendships outgoing must be empty for a robot request, got %v", out) } // A re-send to the same game+seat is idempotent (the UNIQUE constraint collapses it). if err := svc.RequestInGame(ctx, human, robot.ID, g.ID); err != nil { t.Fatalf("re-request robot: %v", err) } if rfr, _ := svc.ListRobotFriendRequests(ctx, human); len(rfr) != 1 { t.Fatalf("re-request must not duplicate, got %v", rfr) } // While the game is active the reaper keeps the row. if n, err := svc.ReapExpiredRobotFriendRequests(ctx); err != nil || n != 0 { t.Fatalf("reap on active game = (%d, %v), want (0, nil)", n, err) } if rfr, _ := svc.ListRobotFriendRequests(ctx, human); len(rfr) != 1 { t.Fatalf("active-game request must survive the reaper, got %v", rfr) } // Finish the game well past the retention window: the reaper now deletes the row. finished := time.Now().UTC().Add(-8 * 24 * time.Hour) if _, err := testDB.ExecContext(ctx, `UPDATE backend.games SET status='finished', finished_at=$1 WHERE game_id=$2`, finished, g.ID); err != nil { t.Fatalf("finish game: %v", err) } if n, err := svc.ReapExpiredRobotFriendRequests(ctx); err != nil || n != 1 { t.Fatalf("reap on long-finished game = (%d, %v), want (1, nil)", n, err) } if rfr, _ := svc.ListRobotFriendRequests(ctx, human); len(rfr) != 0 { t.Errorf("long-finished request not reaped: %v", rfr) } }