package admin import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "testing" "bindbox-game/internal/pkg/core" "bindbox-game/internal/pkg/logger" "bindbox-game/internal/proposal" "bindbox-game/internal/repository/mysql" ) func Test_BindChannelUsers_SuccessWithPartialFailure(t *testing.T) { repo, mux := setupChannelsTestRouter(t) sqls := []string{ `INSERT INTO channels (id, name, code, type, remarks) VALUES (1, '渠道A', 'CH_A', 'other', ''), (2, '渠道B', 'CH_B', 'other', '')`, `INSERT INTO users (id, nickname, mobile, invite_code, status, channel_id) VALUES (1, 'u1', '13800000001', 'I1', 1, 1)`, `INSERT INTO users (id, nickname, mobile, invite_code, status, channel_id) VALUES (2, 'u2', '13800000002', 'I2', 1, 0)`, `INSERT INTO users (id, nickname, mobile, invite_code, status, channel_id) VALUES (3, 'u3', '13800000003', 'I3', 1, 2)`, } for _, sql := range sqls { if err := repo.GetDbW().Exec(sql).Error; err != nil { t.Fatal(err) } } body := []byte(`{"user_ids":[1,2,3,999,2]}`) rr := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodPost, "/api/admin/channels/2/users/bind", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("code=%d body=%s", rr.Code, rr.Body.String()) } var rsp struct { SuccessCount int `json:"success_count"` FailedCount int `json:"failed_count"` SkippedCount int `json:"skipped_count"` } if err := json.Unmarshal(rr.Body.Bytes(), &rsp); err != nil { t.Fatal(err) } if rsp.SuccessCount != 2 || rsp.FailedCount != 1 || rsp.SkippedCount != 1 { t.Fatalf("unexpected result: %+v", rsp) } type row struct { ID int64 ChannelID int64 } var rows []row if err := repo.GetDbR().Raw(`SELECT id, channel_id FROM users WHERE id IN (1,2,3) ORDER BY id`).Scan(&rows).Error; err != nil { t.Fatal(err) } if len(rows) != 3 { t.Fatalf("expect 3 rows, got %d", len(rows)) } if rows[0].ChannelID != 2 || rows[1].ChannelID != 2 || rows[2].ChannelID != 2 { t.Fatalf("bind verify failed, rows=%+v", rows) } } func Test_BindChannelUsers_TooManyUsers(t *testing.T) { repo, mux := setupChannelsTestRouter(t) if err := repo.GetDbW().Exec(`INSERT INTO channels (id, name, code, type, remarks) VALUES (1, '渠道A', 'CH_A', 'other', '')`).Error; err != nil { t.Fatal(err) } userIDs := make([]int, 0, 201) for i := 1; i <= 201; i++ { userIDs = append(userIDs, i) } reqBody, _ := json.Marshal(map[string]any{"user_ids": userIDs}) rr := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodPost, "/api/admin/channels/1/users/bind", bytes.NewBuffer(reqBody)) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rr, req) if rr.Code != http.StatusBadRequest { t.Fatalf("expect 400, got code=%d body=%s", rr.Code, rr.Body.String()) } } func Test_ListChannels_FilterByCodeAndPaidStats(t *testing.T) { repo, mux := setupChannelsTestRouter(t) sqls := []string{ `INSERT INTO channels (id, name, code, type, remarks) VALUES (1, '渠道A', 'CH_A', 'other', ''), (2, '渠道B', 'CH_B', 'other', '')`, `INSERT INTO users (id, nickname, mobile, invite_code, status, channel_id) VALUES (1, 'u1', '13800000001', 'I1', 1, 1)`, `INSERT INTO users (id, nickname, mobile, invite_code, status, channel_id) VALUES (2, 'u2', '13800000002', 'I2', 1, 1)`, `INSERT INTO users (id, nickname, mobile, invite_code, status, channel_id) VALUES (3, 'u3', '13800000003', 'I3', 1, 2)`, `INSERT INTO orders (user_id, status, actual_amount, source_type, ext_order_id, created_at) VALUES (1, 2, 100, 1, '', '2026-03-01 10:00:00')`, `INSERT INTO orders (user_id, status, actual_amount, source_type, ext_order_id, created_at) VALUES (2, 2, 300, 3, '', '2026-03-01 11:00:00')`, `INSERT INTO orders (user_id, status, actual_amount, source_type, ext_order_id, created_at) VALUES (3, 2, 500, 2, '', '2026-03-01 12:00:00')`, `INSERT INTO orders (user_id, status, actual_amount, source_type, ext_order_id, created_at) VALUES (1, 1, 700, 1, '', '2026-03-01 13:00:00')`, `INSERT INTO orders (user_id, status, actual_amount, source_type, ext_order_id, created_at) VALUES (1, 2, 0, 1, '', '2026-03-01 14:00:00')`, `INSERT INTO orders (user_id, status, actual_amount, source_type, ext_order_id, created_at) VALUES (1, 2, 900, 1, 'EXTERNAL-1', '2026-03-01 15:00:00')`, } for _, sql := range sqls { if err := repo.GetDbW().Exec(sql).Error; err != nil { t.Fatal(err) } } rr := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodGet, "/api/admin/channels?name=CH_A&page=1&page_size=20", bytes.NewBufferString("")) mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("code=%d body=%s", rr.Code, rr.Body.String()) } var rsp struct { Total int64 `json:"total"` List []struct { ID int64 `json:"id"` UserCount int64 `json:"user_count"` PaidAmountCents int64 `json:"paid_amount_cents"` PaidAmount int64 `json:"paid_amount"` } `json:"list"` } if err := json.Unmarshal(rr.Body.Bytes(), &rsp); err != nil { t.Fatal(err) } if rsp.Total != 1 || len(rsp.List) != 1 { t.Fatalf("unexpected list result: %+v", rsp) } if rsp.List[0].ID != 1 || rsp.List[0].UserCount != 2 { t.Fatalf("unexpected channel row: %+v", rsp.List[0]) } if rsp.List[0].PaidAmountCents != 400 || rsp.List[0].PaidAmount != 4 { t.Fatalf("unexpected paid stats: %+v", rsp.List[0]) } } func Test_SearchChannelUsers_ByIDAndFuzzy(t *testing.T) { repo, mux := setupChannelsTestRouter(t) sqls := []string{ `INSERT INTO channels (id, name, code, type, remarks) VALUES (1, '渠道A', 'CH_A', 'other', '')`, `INSERT INTO users (id, nickname, mobile, invite_code, status, channel_id) VALUES (11, 'Alice', '13800138000', 'I11', 1, 1)`, `INSERT INTO users (id, nickname, mobile, invite_code, status, channel_id) VALUES (12, 'Bob', '13900139000', 'I12', 1, 0)`, } for _, sql := range sqls { if err := repo.GetDbW().Exec(sql).Error; err != nil { t.Fatal(err) } } assertSearch := func(url string, expectTotal int64, expectIDs ...int64) { rr := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodGet, url, bytes.NewBufferString("")) mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("url=%s code=%d body=%s", url, rr.Code, rr.Body.String()) } var rsp struct { Total int64 `json:"total"` List []struct { ID int64 `json:"id"` } `json:"list"` } if err := json.Unmarshal(rr.Body.Bytes(), &rsp); err != nil { t.Fatal(err) } if rsp.Total != expectTotal { t.Fatalf("url=%s expect total=%d got %d", url, expectTotal, rsp.Total) } if len(rsp.List) != len(expectIDs) { t.Fatalf("url=%s expect %d rows got %d", url, len(expectIDs), len(rsp.List)) } for i, id := range expectIDs { if rsp.List[i].ID != id { t.Fatalf("url=%s expect id=%d got %d", url, id, rsp.List[i].ID) } } } assertSearch("/api/admin/channels/users/search?keyword=11&page=1&page_size=10", 1, 11) assertSearch("/api/admin/channels/users/search?keyword=1380013&page=1&page_size=10", 1, 11) assertSearch("/api/admin/channels/users/search?keyword=Bob&page=1&page_size=10", 1, 12) } func Test_SearchChannelUsers_ByChannelOnly(t *testing.T) { repo, mux := setupChannelsTestRouter(t) sqls := []string{ `INSERT INTO channels (id, name, code, type, remarks) VALUES (1, '渠道A', 'CH_A', 'other', ''), (2, '渠道B', 'CH_B', 'other', '')`, `INSERT INTO users (id, nickname, mobile, invite_code, status, channel_id) VALUES (11, 'Alice', '13800138000', 'I11', 1, 1)`, `INSERT INTO users (id, nickname, mobile, invite_code, status, channel_id) VALUES (12, 'Bob', '13900139000', 'I12', 1, 2)`, `INSERT INTO users (id, nickname, mobile, invite_code, status, channel_id) VALUES (13, 'Cathy', '13700137000', 'I13', 1, 1)`, } for _, sql := range sqls { if err := repo.GetDbW().Exec(sql).Error; err != nil { t.Fatal(err) } } rr := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodGet, "/api/admin/channels/users/search?channel_id=1&page=1&page_size=10", bytes.NewBufferString("")) mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("code=%d body=%s", rr.Code, rr.Body.String()) } var rsp struct { Total int64 `json:"total"` List []struct { ID int64 `json:"id"` ChannelID int64 `json:"channel_id"` } `json:"list"` } if err := json.Unmarshal(rr.Body.Bytes(), &rsp); err != nil { t.Fatal(err) } if rsp.Total != 2 || len(rsp.List) != 2 { t.Fatalf("unexpected result: %+v", rsp) } if rsp.List[0].ID != 13 || rsp.List[0].ChannelID != 1 { t.Fatalf("unexpected first row: %+v", rsp.List[0]) } if rsp.List[1].ID != 11 || rsp.List[1].ChannelID != 1 { t.Fatalf("unexpected second row: %+v", rsp.List[1]) } } func Test_SearchChannelUsers_RequireKeywordOrChannelID(t *testing.T) { _, mux := setupChannelsTestRouter(t) rr := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodGet, "/api/admin/channels/users/search?page=1&page_size=10", bytes.NewBufferString("")) mux.ServeHTTP(rr, req) if rr.Code != http.StatusBadRequest { t.Fatalf("expect 400, got code=%d body=%s", rr.Code, rr.Body.String()) } } func setupChannelsTestRouter(t *testing.T) (mysql.Repo, http.Handler) { t.Helper() repo, err := mysql.NewSQLiteRepoForTest() if err != nil { t.Fatal(err) } ddls := []string{ `CREATE TABLE channels ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, code TEXT NOT NULL, type TEXT NOT NULL DEFAULT 'other', remarks TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME )`, `CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME, nickname TEXT NOT NULL, avatar TEXT, mobile TEXT, openid TEXT, unionid TEXT, invite_code TEXT NOT NULL, inviter_id INTEGER DEFAULT 0, status INTEGER NOT NULL DEFAULT 1, douyin_id TEXT, channel_id INTEGER DEFAULT 0, douyin_user_id TEXT, remark TEXT NOT NULL DEFAULT '' )`, `CREATE TABLE orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, status INTEGER NOT NULL, actual_amount INTEGER NOT NULL DEFAULT 0, source_type INTEGER NOT NULL DEFAULT 1, ext_order_id TEXT NOT NULL DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, } for _, ddl := range ddls { if err := repo.GetDbW().Exec(ddl).Error; err != nil { t.Fatal(err) } } lg, err := logger.NewCustomLogger(logger.WithOutputInConsole()) if err != nil { t.Fatal(err) } mux, err := core.New(lg) if err != nil { t.Fatal(err) } dummyAuth := func(ctx core.Context) (proposal.SessionUserInfo, core.BusinessError) { return proposal.SessionUserInfo{Id: 1, IsSuper: 1}, nil } adminGroup := mux.Group("/api/admin", core.WrapAuthHandler(dummyAuth)) h := New(lg, repo, nil) adminGroup.GET("/channels", h.ListChannels()) adminGroup.GET("/channels/users/search", h.SearchChannelUsers()) adminGroup.POST("/channels/:channel_id/users/bind", h.BindChannelUsers()) return repo, mux }