package admin import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "net/url" "testing" "bindbox-game/internal/pkg/core" "bindbox-game/internal/pkg/logger" "bindbox-game/internal/proposal" "bindbox-game/internal/repository/mysql" // dao/model not required in sqlite DDL ) func Test_ListAppUsers_FilterByID(t *testing.T) { repo, err := mysql.NewSQLiteRepoForTest() if err != nil { t.Fatal(err) } if err := repo.GetDbW().Exec(`CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME, nickname TEXT NOT NULL, avatar TEXT, mobile TEXT, openid TEXT, unionid TEXT, invite_code TEXT NOT NULL, inviter_id INTEGER, status INTEGER NOT NULL, douyin_id TEXT, channel_id INTEGER, douyin_user_id TEXT, remark TEXT NOT NULL DEFAULT '' )`).Error; err != nil { t.Fatal(err) } if err := repo.GetDbW().Exec(`CREATE TABLE channels (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, code TEXT)`).Error; err != nil { t.Fatal(err) } if err := repo.GetDbW().Exec(`INSERT INTO users (nickname,invite_code,status) VALUES ('u1','c1',1)`).Error; err != nil { t.Fatal(err) } if err := repo.GetDbW().Exec(`INSERT INTO users (nickname,invite_code,status) VALUES ('u2','c2',1)`).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)) adminGroup.GET("/users", New(lg, repo, nil).ListAppUsers()) // request with id filter = 1 rr := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/admin/users?id=1&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 map[string]interface{} if err := json.Unmarshal([]byte(rr.Body.String()), &rsp); err != nil { t.Fatal(err) } if int(rsp["total"].(float64)) != 1 { t.Fatalf("expect total=1 got %v", rsp["total"]) } } func Test_ListUserInvites_WithDateFilterAndHasConsumeOnly(t *testing.T) { repo, mux := setupListInvitesTestRouter(t) seedListInvitesTestData(t, repo) type listInvitesResp struct { Total int64 `json:"total"` List []struct { ID int64 `json:"id"` TotalConsume int64 `json:"total_consume"` TotalAssetValue int64 `json:"total_asset_value"` } `json:"list"` Summary struct { TotalConsume int64 `json:"total_consume"` TotalAsset int64 `json:"total_asset"` TotalProfit int64 `json:"total_profit"` } `json:"summary"` } // 1) 2026-01 区间,仅看有消费:仅返回 user2 { rr := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/admin/users/1/invites?page=1&page_size=20&startDate=2026-01-01&endDate=2026-01-31&hasConsumeOnly=true", bytes.NewBufferString("")) mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("jan code=%d body=%s", rr.Code, rr.Body.String()) } var rsp listInvitesResp if err := json.Unmarshal(rr.Body.Bytes(), &rsp); err != nil { t.Fatal(err) } if rsp.Total != 1 { t.Fatalf("jan expect total=1 got %d", rsp.Total) } if len(rsp.List) != 1 || rsp.List[0].ID != 2 { t.Fatalf("jan expect only user2 got %+v", rsp.List) } if rsp.List[0].TotalConsume != 100 { t.Fatalf("jan expect user2 consume=100 got %d", rsp.List[0].TotalConsume) } if rsp.List[0].TotalAssetValue != 300 { t.Fatalf("jan expect user2 asset=300 got %d", rsp.List[0].TotalAssetValue) } if rsp.Summary.TotalConsume != 100 || rsp.Summary.TotalAsset != 300 || rsp.Summary.TotalProfit != 200 { t.Fatalf("jan summary mismatch %+v", rsp.Summary) } } // 2) 同区间,包含全部下线:返回 user2 + user3(user3消费为0) { rr := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/admin/users/1/invites?page=1&page_size=20&startDate=2026-01-01&endDate=2026-01-31&hasConsumeOnly=false", bytes.NewBufferString("")) mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("jan all code=%d body=%s", rr.Code, rr.Body.String()) } var rsp listInvitesResp if err := json.Unmarshal(rr.Body.Bytes(), &rsp); err != nil { t.Fatal(err) } if rsp.Total != 2 { t.Fatalf("jan all expect total=2 got %d", rsp.Total) } byID := map[int64]struct { consume int64 asset int64 }{} for _, row := range rsp.List { byID[row.ID] = struct { consume int64 asset int64 }{consume: row.TotalConsume, asset: row.TotalAssetValue} } if byID[2].consume != 100 || byID[2].asset != 300 { t.Fatalf("jan all user2 mismatch %+v", byID[2]) } if byID[3].consume != 0 || byID[3].asset != 0 { t.Fatalf("jan all user3 mismatch %+v", byID[3]) } } // 3) 2026-02 区间,仅看有消费:验证 paid_at 为空回落 created_at(user3) { rr := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/admin/users/1/invites?page=1&page_size=20&startDate=2026-02-01&endDate=2026-02-28&hasConsumeOnly=true", bytes.NewBufferString("")) mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("feb code=%d body=%s", rr.Code, rr.Body.String()) } var rsp listInvitesResp if err := json.Unmarshal(rr.Body.Bytes(), &rsp); err != nil { t.Fatal(err) } if rsp.Total != 1 { t.Fatalf("feb expect total=1 got %d", rsp.Total) } if len(rsp.List) != 1 || rsp.List[0].ID != 3 { t.Fatalf("feb expect only user3 got %+v", rsp.List) } if rsp.List[0].TotalConsume != 200 { t.Fatalf("feb expect user3 consume=200 got %d", rsp.List[0].TotalConsume) } if rsp.List[0].TotalAssetValue != 400 { t.Fatalf("feb expect user3 asset=400 got %d", rsp.List[0].TotalAssetValue) } } } func Test_ListUserInvites_InvalidDateParams(t *testing.T) { repo, mux := setupListInvitesTestRouter(t) seedListInvitesTestData(t, repo) cases := []url.Values{ {"page": {"1"}, "page_size": {"20"}, "startDate": {"2026-01-01"}}, {"page": {"1"}, "page_size": {"20"}, "startDate": {"2026/01/01"}, "endDate": {"2026-01-31"}}, {"page": {"1"}, "page_size": {"20"}, "startDate": {"2026-02-01"}, "endDate": {"2026-01-31"}}, {"page": {"1"}, "page_size": {"20"}, "startDate": {"2024-01-01"}, "endDate": {"2025-02-01"}}, } for _, query := range cases { rr := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/admin/users/1/invites?"+query.Encode(), bytes.NewBufferString("")) mux.ServeHTTP(rr, req) if rr.Code != http.StatusBadRequest { t.Fatalf("query=%s expect 400 got %d body=%s", query.Encode(), rr.Code, rr.Body.String()) } } } func setupListInvitesTestRouter(t *testing.T) (mysql.Repo, http.Handler) { t.Helper() repo, err := mysql.NewSQLiteRepoForTest() if err != nil { t.Fatal(err) } ddls := []string{ `CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME, nickname TEXT NOT NULL, avatar TEXT, mobile TEXT, openid TEXT, unionid TEXT, invite_code TEXT NOT NULL, inviter_id INTEGER, status INTEGER NOT NULL, douyin_id TEXT, channel_id INTEGER, 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, paid_at DATETIME, created_at DATETIME NOT NULL )`, `CREATE TABLE user_inventory ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, product_id INTEGER, value_cents INTEGER NOT NULL DEFAULT 0, status INTEGER NOT NULL, created_at DATETIME NOT NULL )`, `CREATE TABLE products ( id INTEGER PRIMARY KEY AUTOINCREMENT, price INTEGER NOT NULL DEFAULT 0 )`, } 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)) adminGroup.GET("/users/:user_id/invites", New(lg, repo, nil).ListUserInvites()) return repo, mux } func seedListInvitesTestData(t *testing.T, repo mysql.Repo) { t.Helper() sqls := []string{ `INSERT INTO users (id, nickname, invite_code, inviter_id, status, created_at, updated_at, remark) VALUES (1, 'inviter', 'i1', 0, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', '')`, `INSERT INTO users (id, nickname, invite_code, inviter_id, status, created_at, updated_at, remark) VALUES (2, 'invitee-a', 'a1', 1, 1, '2026-01-02 00:00:00', '2026-01-02 00:00:00', '')`, `INSERT INTO users (id, nickname, invite_code, inviter_id, status, created_at, updated_at, remark) VALUES (3, 'invitee-b', 'b1', 1, 1, '2026-01-03 00:00:00', '2026-01-03 00:00:00', '')`, `INSERT INTO products (id, price) VALUES (1, 1000), (2, 400)`, `INSERT INTO orders (user_id, status, actual_amount, paid_at, created_at) VALUES (2, 2, 100, '2026-01-06 10:00:00', '2026-01-05 09:00:00')`, `INSERT INTO orders (user_id, status, actual_amount, paid_at, created_at) VALUES (3, 2, 200, NULL, '2026-02-05 11:00:00')`, `INSERT INTO orders (user_id, status, actual_amount, paid_at, created_at) VALUES (3, 1, 999, '2026-01-10 10:00:00', '2026-01-10 10:00:00')`, `INSERT INTO user_inventory (user_id, product_id, value_cents, status, created_at) VALUES (2, 1, 300, 1, '2026-01-06 12:00:00')`, `INSERT INTO user_inventory (user_id, product_id, value_cents, status, created_at) VALUES (3, 2, 0, 1, '2026-02-06 12:00:00')`, } for _, sql := range sqls { if err := repo.GetDbW().Exec(sql).Error; err != nil { t.Fatal(err) } } }