bindbox-game/internal/api/admin/channels_test.go
2026-03-05 12:50:06 +08:00

319 lines
11 KiB
Go

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
}