package app import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "bindbox-game/internal/pkg/core" "bindbox-game/internal/pkg/logger" "bindbox-game/internal/proposal" "bindbox-game/internal/repository/mysql" ) func setupThresholdAppTestRouter(t *testing.T) (mysql.Repo, http.Handler) { t.Helper() repo, err := mysql.NewSQLiteRepoForTest() if err != nil { t.Fatal(err) } ddls := []string{ `CREATE TABLE threshold_activities ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, type TEXT NOT NULL, qualification_mode TEXT NOT NULL, spend_threshold_amount INTEGER NOT NULL DEFAULT 0, invite_threshold_count INTEGER NOT NULL DEFAULT 0, invite_effective_amount INTEGER NOT NULL DEFAULT 0, min_participants INTEGER NOT NULL DEFAULT 1, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, draw_time DATETIME NOT NULL, status TEXT NOT NULL DEFAULT 'active', description TEXT, cover_image TEXT NOT NULL DEFAULT '', draw_batch TEXT NOT NULL DEFAULT '', abort_reason TEXT NOT NULL DEFAULT '', aborted_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME )`, `CREATE TABLE threshold_activity_prizes ( id INTEGER PRIMARY KEY AUTOINCREMENT, activity_id INTEGER NOT NULL, reward_type TEXT NOT NULL, reward_ref_id INTEGER NOT NULL, reward_name_snapshot TEXT NOT NULL DEFAULT '', reward_image_snapshot TEXT NOT NULL DEFAULT '', reward_value_snapshot_cents INTEGER NOT NULL DEFAULT 0, cost_snapshot_cents INTEGER NOT NULL DEFAULT 0, quantity INTEGER NOT NULL DEFAULT 0, remaining_quantity INTEGER NOT NULL DEFAULT 0, sort INTEGER NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, `CREATE TABLE threshold_activity_participants ( id INTEGER PRIMARY KEY AUTOINCREMENT, activity_id INTEGER NOT NULL, user_id INTEGER NOT NULL, period_key TEXT NOT NULL, qualification_source TEXT NOT NULL DEFAULT '', paid_amount_snapshot INTEGER NOT NULL DEFAULT 0, effective_invite_count_snapshot INTEGER NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(activity_id, user_id, period_key) )`, `CREATE TABLE threshold_activity_winners ( id INTEGER PRIMARY KEY AUTOINCREMENT, activity_id INTEGER NOT NULL, prize_id INTEGER NOT NULL, reward_type TEXT NOT NULL, reward_ref_id INTEGER NOT NULL, prize_name_snapshot TEXT NOT NULL DEFAULT '', prize_image_snapshot TEXT NOT NULL DEFAULT '', prize_value_snapshot_cents INTEGER NOT NULL DEFAULT 0, user_id INTEGER NOT NULL, grant_record_type TEXT NOT NULL DEFAULT '', grant_record_id INTEGER NOT NULL DEFAULT 0, cost_cents INTEGER NOT NULL DEFAULT 0, draw_batch TEXT NOT NULL DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, `CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, nickname TEXT NOT NULL, avatar TEXT, invite_code TEXT NOT NULL, inviter_id INTEGER, status INTEGER NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME )`, `CREATE TABLE user_invites ( id INTEGER PRIMARY KEY AUTOINCREMENT, inviter_id INTEGER NOT NULL, invitee_id INTEGER NOT NULL, invite_code TEXT NOT NULL DEFAULT '', reward_points INTEGER NOT NULL DEFAULT 0, rewarded_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME, is_effective BOOLEAN NOT NULL DEFAULT 0, accumulated_amount INTEGER NOT NULL DEFAULT 0 )`, `CREATE TABLE orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, user_id INTEGER NOT NULL, order_no TEXT NOT NULL, source_type INTEGER NOT NULL DEFAULT 1, total_amount INTEGER NOT NULL DEFAULT 0, discount_amount INTEGER NOT NULL DEFAULT 0, points_amount INTEGER NOT NULL DEFAULT 0, actual_amount INTEGER NOT NULL DEFAULT 0, status INTEGER NOT NULL DEFAULT 1, pay_preorder_id INTEGER NOT NULL DEFAULT 0, paid_at DATETIME, cancelled_at DATETIME, user_address_id INTEGER NOT NULL DEFAULT 0, is_consumed INTEGER NOT NULL DEFAULT 0, points_ledger_id INTEGER NOT NULL DEFAULT 0, coupon_id INTEGER NOT NULL DEFAULT 0, item_card_id INTEGER NOT NULL DEFAULT 0, remark TEXT, ext_order_id TEXT NOT NULL DEFAULT '' )`, } 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) } h := New(lg, repo, nil, nil) public := mux.Group("/api/app") public.GET("/threshold-activities", h.ListThresholdActivities()) public.GET("/threshold-activities/:id", h.GetThresholdActivity()) dummyAuth := func(ctx core.Context) (proposal.SessionUserInfo, core.BusinessError) { return proposal.SessionUserInfo{Id: 88}, nil } auth := mux.Group("/api/app", core.WrapAuthHandler(dummyAuth)) auth.GET("/threshold-activities/:id/my", h.GetThresholdActivity()) auth.POST("/threshold-activities/:id/join", h.JoinThresholdActivity()) return repo, mux } func TestThresholdAppListAndGetMy(t *testing.T) { repo, mux := setupThresholdAppTestRouter(t) now := time.Now() if err := repo.GetDbW().Exec(`INSERT INTO threshold_activities (id, title, type, qualification_mode, spend_threshold_amount, invite_threshold_count, invite_effective_amount, min_participants, start_time, end_time, draw_time, status, description, cover_image, draw_batch, abort_reason, created_at, updated_at) VALUES (1, 'App活动', 'daily', 'spend_only', 1000, 0, 0, 1, ?, ?, ?, 'active', 'desc', '', '', '', ?, ?)`, now.Add(-time.Hour), now.Add(24*time.Hour), now.Add(25*time.Hour), now, now).Error; err != nil { t.Fatal(err) } if err := repo.GetDbW().Exec(`INSERT INTO orders (user_id, order_no, source_type, actual_amount, total_amount, status, paid_at, created_at, updated_at, cancelled_at) VALUES (88, 'APP_OK', 1, 1200, 1200, 2, ?, ?, ?, ?)`, now, now, now, time.Unix(0, 0)).Error; err != nil { t.Fatal(err) } rr := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodGet, "/api/app/threshold-activities?page=1&page_size=20", bytes.NewReader([]byte{})) mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("list code=%d body=%s", rr.Code, rr.Body.String()) } var listed struct { Total int64 `json:"total"` List []map[string]any `json:"list"` } if err := json.Unmarshal(rr.Body.Bytes(), &listed); err != nil { t.Fatal(err) } if listed.Total != 1 || len(listed.List) != 1 { t.Fatalf("expected 1 app activity, got total=%d len=%d", listed.Total, len(listed.List)) } rr = httptest.NewRecorder() req, _ = http.NewRequest(http.MethodGet, "/api/app/threshold-activities/1/my", bytes.NewReader([]byte{})) mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("get my code=%d body=%s", rr.Code, rr.Body.String()) } var detail map[string]any if err := json.Unmarshal(rr.Body.Bytes(), &detail); err != nil { t.Fatal(err) } if detail["can_join"] != true { t.Fatalf("expected can_join=true, got %v", detail["can_join"]) } progress := detail["qualification_progress"].(map[string]any) if int(progress["current_paid"].(float64)) != 1200 { t.Fatalf("expected current_paid=1200, got %v", progress["current_paid"]) } } func TestThresholdAppJoinSuccessAndRejectUnqualified(t *testing.T) { repo, mux := setupThresholdAppTestRouter(t) now := time.Now() if err := repo.GetDbW().Exec(`INSERT INTO threshold_activities (id, title, type, qualification_mode, spend_threshold_amount, invite_threshold_count, invite_effective_amount, min_participants, start_time, end_time, draw_time, status, description, cover_image, draw_batch, abort_reason, created_at, updated_at) VALUES (2, 'Join活动', 'daily', 'spend_only', 1000, 0, 0, 1, ?, ?, ?, 'active', 'desc', '', '', '', ?, ?)`, now.Add(-time.Hour), now.Add(24*time.Hour), now.Add(25*time.Hour), now, now).Error; err != nil { t.Fatal(err) } if err := repo.GetDbW().Exec(`INSERT INTO orders (user_id, order_no, source_type, actual_amount, total_amount, status, paid_at, created_at, updated_at, cancelled_at) VALUES (88, 'JOIN_OK', 1, 1500, 1500, 2, ?, ?, ?, ?)`, now, now, now, time.Unix(0, 0)).Error; err != nil { t.Fatal(err) } rr := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodPost, "/api/app/threshold-activities/2/join", bytes.NewReader([]byte{})) mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("join success code=%d body=%s", rr.Code, rr.Body.String()) } var count int64 if err := repo.GetDbW().Table("threshold_activity_participants").Where("activity_id = ? AND user_id = ?", 2, 88).Count(&count).Error; err != nil { t.Fatal(err) } if count != 1 { t.Fatalf("expected 1 participant after join, got %d", count) } if err := repo.GetDbW().Exec(`INSERT INTO threshold_activities (id, title, type, qualification_mode, spend_threshold_amount, invite_threshold_count, invite_effective_amount, min_participants, start_time, end_time, draw_time, status, description, cover_image, draw_batch, abort_reason, created_at, updated_at) VALUES (3, 'Join失败活动', 'daily', 'spend_only', 5000, 0, 0, 1, ?, ?, ?, 'active', 'desc', '', '', '', ?, ?)`, now.Add(-time.Hour), now.Add(24*time.Hour), now.Add(25*time.Hour), now, now).Error; err != nil { t.Fatal(err) } rr = httptest.NewRecorder() req, _ = http.NewRequest(http.MethodPost, "/api/app/threshold-activities/3/join", bytes.NewReader([]byte{})) mux.ServeHTTP(rr, req) if rr.Code != http.StatusBadRequest { t.Fatalf("expected 400 for unqualified join, got %d body=%s", rr.Code, rr.Body.String()) } }