package roomadmin import ( "context" "database/sql" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" "github.com/DATA-DOG/go-sqlmock" "github.com/gin-gonic/gin" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/integration/robotclient" "hyapp-admin-server/internal/integration/roomclient" ) func TestNormalizeRoomListSortKeepsOnlyContributionSort(t *testing.T) { tests := []struct { name string query listQuery wantOrder string }{ { name: "default", query: listQuery{}, wantOrder: "rle.created_at_ms DESC, rle.room_id DESC", }, { name: "contribution_desc", query: listQuery{SortBy: "room_contribution", SortDirection: "desc"}, wantOrder: "rle.heat DESC, rle.created_at_ms DESC, rle.room_id DESC", }, { name: "contribution_asc", query: listQuery{SortBy: "heat", SortDirection: "asc"}, wantOrder: "rle.heat ASC, rle.created_at_ms DESC, rle.room_id DESC", }, { name: "unknown_falls_back", query: listQuery{SortBy: "owner_user_id", SortDirection: "asc"}, wantOrder: "rle.created_at_ms DESC, rle.room_id DESC", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { query := normalizeListQuery(test.query) if got := roomListOrderBy(query); got != test.wantOrder { t.Fatalf("order by mismatch: got %q want %q", got, test.wantOrder) } }) } } func TestListRoomsResolvesOwnerUserKeywordBeforeRoomClient(t *testing.T) { db, sqlMock, err := sqlmock.New() if err != nil { t.Fatalf("create sql mock failed: %v", err) } defer db.Close() const keyword = "VIP2026" const ownerUserID = int64(327702592329093120) expectRoomOwnerIdentityLookup(sqlMock, keyword, ownerUserID) roomClient := &recordingRoomClient{} service := NewService(db, nil, roomClient, nil) _, _, err = service.ListRooms(appctx.WithContext(context.Background(), "lalu"), listQuery{ OwnerUserKeyword: keyword, Page: 1, PageSize: 20, }) if err != nil { t.Fatalf("list rooms failed: %v", err) } if len(roomClient.requests) != 1 { t.Fatalf("expected one room-service request, got %+v", roomClient.requests) } if got := roomClient.requests[0].OwnerUserID; got != ownerUserID { t.Fatalf("owner user id should be resolved before room-service call: got %d want %d", got, ownerUserID) } if roomClient.requests[0].Keyword != "" { t.Fatalf("owner keyword must not be forwarded as room keyword: %+v", roomClient.requests[0]) } if err := sqlMock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations mismatch: %v", err) } } func TestListRoomsResolvesRoomKeywordOwnerPrettyIDBeforeRoomClient(t *testing.T) { db, sqlMock, err := sqlmock.New() if err != nil { t.Fatalf("create sql mock failed: %v", err) } defer db.Close() const keyword = "VIP2026" const ownerUserID = int64(327702592329093120) expectRoomOwnerNumberLookup(sqlMock, keyword, ownerUserID) roomClient := &recordingRoomClient{} service := NewService(db, nil, roomClient, nil) _, _, err = service.ListRooms(appctx.WithContext(context.Background(), "lalu"), listQuery{ Keyword: keyword, Page: 1, PageSize: 20, }) if err != nil { t.Fatalf("list rooms failed: %v", err) } if len(roomClient.requests) != 1 { t.Fatalf("expected one room-service request, got %+v", roomClient.requests) } if got := roomClient.requests[0].OwnerUserID; got != ownerUserID { t.Fatalf("room keyword should resolve owner user id before room-service call: got %d want %d", got, ownerUserID) } if roomClient.requests[0].Keyword != "" { t.Fatalf("owner pretty id must not be forwarded as room keyword: %+v", roomClient.requests[0]) } if err := sqlMock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations mismatch: %v", err) } } func TestListRoomsReturnsEmptyWhenOwnerUserKeywordHasNoMatch(t *testing.T) { db, sqlMock, err := sqlmock.New() if err != nil { t.Fatalf("create sql mock failed: %v", err) } defer db.Close() const keyword = "VIP404" expectRoomOwnerIdentityMiss(sqlMock, keyword) roomClient := &recordingRoomClient{} service := NewService(db, nil, roomClient, nil) items, total, err := service.ListRooms(appctx.WithContext(context.Background(), "lalu"), listQuery{ OwnerUserKeyword: keyword, Page: 1, PageSize: 20, }) if err != nil { t.Fatalf("list rooms failed: %v", err) } if len(items) != 0 || total != 0 { t.Fatalf("unmatched owner keyword should return empty page, got items=%+v total=%d", items, total) } if len(roomClient.requests) != 0 { t.Fatalf("room-service must not be called when owner keyword is known unmatched: %+v", roomClient.requests) } if err := sqlMock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations mismatch: %v", err) } } func TestRobotRoomProfileFromOwnerUsesOwnerNameAndAvatar(t *testing.T) { name, avatar, err := robotRoomProfileFromOwner(RoomOwner{ Username: " Robot Host ", Avatar: " https://cdn.example.com/robot.png ", }) if err != nil { t.Fatalf("robot room owner profile should be valid: %v", err) } if name != "Robot Host" || avatar != "https://cdn.example.com/robot.png" { t.Fatalf("owner profile mismatch: name=%q avatar=%q", name, avatar) } } func expectRoomOwnerIdentityLookup(sqlMock sqlmock.Sqlmock, keyword string, userID int64) { expectRoomOwnerIdentityQuery(sqlMock, keyword). WillReturnRows(sqlmock.NewRows([]string{"user_id"}).AddRow(userID)) } func expectRoomOwnerIdentityMiss(sqlMock sqlmock.Sqlmock, keyword string) { expectRoomOwnerIdentityQuery(sqlMock, keyword). WillReturnError(sql.ErrNoRows) } func expectRoomOwnerNumberLookup(sqlMock sqlmock.Sqlmock, keyword string, userID int64) { expectRoomOwnerNumberQuery(sqlMock, keyword). WillReturnRows(sqlmock.NewRows([]string{"user_id"}).AddRow(userID)) } func expectRoomOwnerIdentityQuery(sqlMock sqlmock.Sqlmock, keyword string) *sqlmock.ExpectedQuery { return sqlMock.ExpectQuery(`(?s)SELECT u\.user_id.*FROM users u.*LIMIT 1`). WithArgs( "lalu", keyword, keyword, keyword, sqlmock.AnyArg(), keyword, "%"+keyword+"%", keyword, keyword, keyword, sqlmock.AnyArg(), keyword, ) } func expectRoomOwnerNumberQuery(sqlMock sqlmock.Sqlmock, keyword string) *sqlmock.ExpectedQuery { return sqlMock.ExpectQuery(`(?s)SELECT u\.user_id.*FROM users u.*CAST\(u\.user_id AS CHAR\) = \?.*LIMIT 1`). WithArgs( "lalu", keyword, keyword, keyword, sqlmock.AnyArg(), keyword, keyword, keyword, keyword, sqlmock.AnyArg(), keyword, ) } type recordingRoomClient struct { roomclient.Client requests []roomclient.ListRoomsRequest result *roomclient.ListRoomsResult } func (c *recordingRoomClient) ListRooms(_ context.Context, req roomclient.ListRoomsRequest) (*roomclient.ListRoomsResult, error) { c.requests = append(c.requests, req) if c.result != nil { return c.result, nil } return &roomclient.ListRoomsResult{}, nil } func TestRobotRoomProfileFromOwnerRequiresNameAndAvatar(t *testing.T) { tests := []struct { name string owner RoomOwner }{ {name: "missing_name", owner: RoomOwner{Avatar: "https://cdn.example.com/robot.png"}}, {name: "missing_avatar", owner: RoomOwner{Username: "Robot Host"}}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { _, _, err := robotRoomProfileFromOwner(test.owner) if !errors.Is(err, ErrInvalidArgument) { t.Fatalf("expected ErrInvalidArgument, got %v", err) } }) } } func TestApplyOwnerProfileToRobotRoomRequestFollowsOwnerRegionAndCountry(t *testing.T) { req := createRobotRoomRequest{ RoomName: "manual name", RoomAvatar: "https://cdn.example.com/manual.png", VisibleRegionID: 999, OwnerCountryCode: "XX", } err := applyOwnerProfileToRobotRoomRequest(&req, RoomOwner{ Username: " Robot Owner ", Avatar: " https://cdn.example.com/owner.png ", CountryCode: " ae ", VisibleRegionID: 686, }) if err != nil { t.Fatalf("apply owner profile failed: %v", err) } if req.RoomName != "Robot Owner" || req.RoomAvatar != "https://cdn.example.com/owner.png" { t.Fatalf("owner display profile mismatch: name=%q avatar=%q", req.RoomName, req.RoomAvatar) } if req.VisibleRegionID != 686 || req.OwnerCountryCode != "AE" { t.Fatalf("owner region or country mismatch: region=%d country=%q", req.VisibleRegionID, req.OwnerCountryCode) } } func TestCreateRobotRoomRequestAcceptsStringInt64IDs(t *testing.T) { var req createRobotRoomRequest if err := json.Unmarshal([]byte(`{ "ownerRobotUserId":"325379237278126080", "candidateRobotUserIds":["325379237278126080","325455441691676672","325455441691676672"], "minRobotCount":2, "maxRobotCount":8, "giftIds":["84"], "luckyGiftIds":["28"], "normalGiftIntervalMs":10000, "luckyComboMin":100, "luckyComboMax":10000, "luckyPauseMinMs":5000, "luckyPauseMaxMs":20000 }`), &req); err != nil { t.Fatalf("robot room request should accept string int64 ids: %v", err) } normalized, err := normalizeCreateRobotRoomRequest(req) if err != nil { t.Fatalf("robot room request should normalize: %v", err) } if got := int64(normalized.OwnerRobotUserID); got != 325379237278126080 { t.Fatalf("owner id mismatch: got %d", got) } if got := []int64(normalized.CandidateRobotUserIDs); len(got) != 1 || got[0] != 325455441691676672 { t.Fatalf("candidate ids should drop owner and duplicates, got %+v", got) } } func TestCreateRobotRoomRequestBindsStringInt64IDsFromHTTP(t *testing.T) { gin.SetMode(gin.TestMode) body := `{ "ownerRobotUserId":"325379237278126080", "candidateRobotUserIds":["325455441691676672"], "minRobotCount":2, "maxRobotCount":8, "giftIds":["84"], "luckyGiftIds":["28"], "normalGiftIntervalMs":10000, "luckyComboMin":100, "luckyComboMax":10000, "luckyPauseMinMs":5000, "luckyPauseMaxMs":20000 }` req := httptest.NewRequest(http.MethodPost, "/admin/rooms/robot-rooms", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(rec) ctx.Request = req var payload createRobotRoomRequest if err := ctx.ShouldBindJSON(&payload); err != nil { t.Fatalf("gin binding should accept string int64 ids: %v", err) } if got := int64(payload.OwnerRobotUserID); got != 325379237278126080 { t.Fatalf("owner id mismatch: got %d", got) } if got := []int64(payload.CandidateRobotUserIDs); len(got) != 1 || got[0] != 325455441691676672 { t.Fatalf("candidate ids mismatch: %+v", got) } } func TestNormalizeRoomWhitelistUserIDsKeepsStringInt64Order(t *testing.T) { got, err := normalizeRoomWhitelistUserIDs([]string{"325379237278126080", "1001", "1001"}) if err != nil { t.Fatalf("normalize room whitelist failed: %v", err) } want := []string{"1001", "325379237278126080"} if len(got) != len(want) { t.Fatalf("whitelist length mismatch: got %+v want %+v", got, want) } for index := range want { if got[index] != want[index] { t.Fatalf("whitelist id %d mismatch: got %+v want %+v", index, got, want) } } } func TestNormalizeRoomWhitelistUserIDsRejectsInvalidID(t *testing.T) { if _, err := normalizeRoomWhitelistUserIDs([]string{"1.2"}); !errors.Is(err, ErrInvalidArgument) { t.Fatalf("invalid id should return ErrInvalidArgument, got %v", err) } } func TestAvailableRoomRobotsFromGamePoolKeepsOnlyRoomAvailableUsers(t *testing.T) { robots := []robotclient.Robot{ {UserID: 101, Status: "active", LastUsedAtMS: 1000, UsedCount: 2}, {UserID: 102, Status: "active", LastUsedAtMS: 2000, UsedCount: 3}, {UserID: 103, Status: "active", LastUsedAtMS: 3000, UsedCount: 4}, } owners := map[int64]RoomOwner{ 101: {UserID: "101", Username: "Robot A"}, 103: {UserID: "103", Username: "Robot C"}, } items := availableRoomRobotsFromGamePool(robots, []int64{103, 101}, owners) if len(items) != 2 { t.Fatalf("available robots length mismatch: got %d want 2", len(items)) } if items[0].UserID != "101" || items[0].User.Username != "Robot A" || items[0].UsedCount != 2 { t.Fatalf("first available robot mismatch: %+v", items[0]) } if items[1].UserID != "103" || items[1].User.Username != "Robot C" || items[1].LastUsedAtMS != 3000 { t.Fatalf("second available robot mismatch: %+v", items[1]) } } func TestListAllActiveGameRobotsFollowsCursor(t *testing.T) { client := &pagedRobotClient{ pages: map[string]robotclient.ListGameRobotsResult{ "": { Robots: []robotclient.Robot{{UserID: 101}, {UserID: 102}}, NextCursor: "102", }, "102": { Robots: []robotclient.Robot{{UserID: 201}}, }, }, } robots, err := listAllActiveGameRobots(context.Background(), client) if err != nil { t.Fatalf("list all active game robots failed: %v", err) } if len(robots) != 3 || robots[0].UserID != 101 || robots[1].UserID != 102 || robots[2].UserID != 201 { t.Fatalf("robots should include every cursor page, got %+v", robots) } if len(client.requests) != 2 { t.Fatalf("expected two page requests, got %+v", client.requests) } if client.requests[0].Cursor != "" || client.requests[1].Cursor != "102" { t.Fatalf("cursor chain mismatch: %+v", client.requests) } for _, req := range client.requests { if req.Status != "active" || req.PageSize != availableRoomRobotPageSize { t.Fatalf("request should use active status and page size %d, got %+v", availableRoomRobotPageSize, req) } } } type pagedRobotClient struct { pages map[string]robotclient.ListGameRobotsResult requests []robotclient.ListGameRobotsRequest } func (c *pagedRobotClient) ListGameRobots(_ context.Context, req robotclient.ListGameRobotsRequest) (robotclient.ListGameRobotsResult, error) { c.requests = append(c.requests, req) return c.pages[req.Cursor], nil } func (c *pagedRobotClient) ListRoomRobots(context.Context, robotclient.ListRoomRobotsRequest) (robotclient.ListRoomRobotsResult, error) { return robotclient.ListRoomRobotsResult{}, errors.New("not used") }