// Package main runs a local end-to-end CP flow against real Docker services. package main import ( "bytes" "context" "database/sql" "encoding/json" "errors" "fmt" "io" "net/http" "os" "strconv" "strings" "time" _ "github.com/go-sql-driver/mysql" "github.com/golang-jwt/jwt/v5" ) const ( appCode = "lalu" jwtSecret = "dev-shared-secret" userA = int64(910001) userB = int64(910002) userC = int64(910003) giftCP = "local_cp_gift_cp" giftBrother = "local_cp_gift_brother" giftSister = "local_cp_gift_sister" ) type smoke struct { db *sql.DB http *http.Client gateway string startMS int64 roomID string region int64 } type envelope struct { Code string `json:"code"` Message string `json:"message"` RequestID string `json:"request_id"` Data json.RawMessage `json:"data"` } type cpApplication struct { ApplicationID string `json:"application_id"` RelationType string `json:"relation_type"` Status string `json:"status"` RoomID string `json:"room_id"` ExpiresAtMS int64 `json:"expires_at_ms"` } type cpRelationship struct { RelationshipID string `json:"relationship_id"` RelationType string `json:"relation_type"` Status string `json:"status"` IntimacyValue int64 `json:"intimacy_value"` Level int32 `json:"level"` CurrentLevelThreshold int64 `json:"current_level_threshold"` NextLevelThreshold int64 `json:"next_level_threshold"` NeededForNextLevel int64 `json:"needed_for_next_level"` LevelProgressPercent int32 `json:"level_progress_percent"` MaxLevel bool `json:"max_level"` } type cpRelationshipListData struct { Relationships []cpRelationship `json:"relationships"` Total int64 `json:"total"` } type cpLevelRuleSnapshot struct { LevelNo int32 IntimacyThreshold int64 RewardResourceGroupID int64 LevelIconURL string Status string CreatedAtMS int64 UpdatedAtMS int64 } func main() { ctx, cancel := context.WithTimeout(context.Background(), envDuration("CP_SMOKE_TIMEOUT", 4*time.Minute)) defer cancel() db, err := sql.Open("mysql", env("CP_SMOKE_MYSQL_DSN", "hyapp:hyapp@tcp(127.0.0.1:23306)/?parseTime=true&charset=utf8mb4&loc=UTC&multiStatements=true")) if err != nil { fatal(err) } defer db.Close() if err := db.PingContext(ctx); err != nil { fatal(fmt.Errorf("mysql ping failed: %w", err)) } s := &smoke{ db: db, http: &http.Client{Timeout: 8 * time.Second}, gateway: strings.TrimRight(env("CP_SMOKE_GATEWAY", "http://127.0.0.1:13000"), "/"), startMS: time.Now().UTC().UnixMilli(), } if env("CP_SMOKE_FOCUS", "") == "relationships_progress" { fmt.Println("==> focused relationship progress HTTP chain") if err := s.runRelationshipProgressFocus(ctx); err != nil { fatal(err) } fmt.Println("ok focused relationship progress HTTP chain") fmt.Println("CP relationship progress smoke passed") return } steps := []struct { name string fn func(context.Context) error }{ {"seed users, wallet, gifts", s.seed}, {"import A/B/C Tencent IM accounts", s.importIMAccounts}, {"create room and join A/B/C", s.createRoomAndJoin}, {"T01 create CP application from gift", s.testCreateCPApplication}, {"T02 accept application and deliver C2C/room/region IM", s.testAcceptApplication}, {"T03 existing relationship gift increments intimacy", s.testIntimacyIncrement}, {"T04 same pair can accept only one relation type", s.testPairSingleRelation}, {"T05 one user can have only one active relation", s.testUserSingleRelation}, {"T06 expired application cannot be accepted", s.testExpiredApplication}, {"T07 rejected application delivers C2C and room IM", s.testRejectApplication}, } for _, step := range steps { fmt.Printf("==> %s\n", step.name) if err := step.fn(ctx); err != nil { fatal(fmt.Errorf("%s: %w", step.name, err)) } fmt.Printf("ok %s\n", step.name) } fmt.Println("CP smoke flow passed") } func (s *smoke) seed(ctx context.Context) error { regionID, err := s.lookupUSRegion(ctx) if err != nil { return err } s.region = regionID if err := s.cleanupCPFacts(ctx); err != nil { return err } if err := s.cleanupRoomFacts(ctx); err != nil { return err } if err := s.seedUsers(ctx, regionID); err != nil { return err } if err := s.seedWallet(ctx); err != nil { return err } return s.seedGifts(ctx) } func (s *smoke) runRelationshipProgressFocus(ctx context.Context) (err error) { originalRules, err := s.snapshotCPLevelRules(ctx, "cp") if err != nil { return err } // 聚焦烟测会临时写入确定性的等级规则;退出前恢复原规则,避免影响本地后台正在调的 CP 配置。 defer func() { if restoreErr := s.restoreCPLevelRules(ctx, "cp", originalRules); err == nil && restoreErr != nil { err = restoreErr } }() // 关系和申请只使用固定 smoke 用户,退出时清掉这些事实,避免下一次烟测被旧 active 关系挡住。 defer func() { if cleanupErr := s.cleanupCPFacts(ctx); err == nil && cleanupErr != nil { err = cleanupErr } }() if err := s.seed(ctx); err != nil { return err } if err := s.seedProgressLevelRules(ctx, "cp"); err != nil { return err } if err := s.seedProgressRelationship(ctx, 180, 2); err != nil { return err } if err := s.assertRelationshipProgressHTTP(ctx, cpRelationship{ RelationshipID: relationshipID(userA, userB), RelationType: "cp", Status: "active", IntimacyValue: 180, Level: 2, CurrentLevelThreshold: 100, NextLevelThreshold: 300, NeededForNextLevel: 120, LevelProgressPercent: 40, MaxLevel: false, }); err != nil { return err } if err := s.seedProgressRelationship(ctx, 1200, 5); err != nil { return err } return s.assertRelationshipProgressHTTP(ctx, cpRelationship{ RelationshipID: relationshipID(userA, userB), RelationType: "cp", Status: "active", IntimacyValue: 1200, Level: 5, CurrentLevelThreshold: 1000, NextLevelThreshold: 0, NeededForNextLevel: 0, LevelProgressPercent: 100, MaxLevel: true, }) } func (s *smoke) snapshotCPLevelRules(ctx context.Context, relationType string) ([]cpLevelRuleSnapshot, error) { rows, err := s.db.QueryContext(ctx, ` SELECT level_no, intimacy_threshold, reward_resource_group_id, level_icon_url, status, created_at_ms, updated_at_ms FROM hyapp_user.user_cp_level_rules WHERE app_code = ? AND relation_type = ? ORDER BY level_no ASC`, appCode, relationType) if err != nil { return nil, err } defer rows.Close() rules := make([]cpLevelRuleSnapshot, 0) for rows.Next() { var rule cpLevelRuleSnapshot if err := rows.Scan(&rule.LevelNo, &rule.IntimacyThreshold, &rule.RewardResourceGroupID, &rule.LevelIconURL, &rule.Status, &rule.CreatedAtMS, &rule.UpdatedAtMS); err != nil { return nil, err } rules = append(rules, rule) } return rules, rows.Err() } func (s *smoke) restoreCPLevelRules(ctx context.Context, relationType string, rules []cpLevelRuleSnapshot) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer func() { _ = tx.Rollback() }() // 先删除当前 relation_type 的临时规则,再按快照恢复;这样新增、删除和禁用状态都能回到烟测前。 if _, err := tx.ExecContext(ctx, ` DELETE FROM hyapp_user.user_cp_level_rules WHERE app_code = ? AND relation_type = ?`, appCode, relationType); err != nil { return err } for _, rule := range rules { if _, err := tx.ExecContext(ctx, ` INSERT INTO hyapp_user.user_cp_level_rules ( app_code, relation_type, level_no, intimacy_threshold, reward_resource_group_id, level_icon_url, status, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, appCode, relationType, rule.LevelNo, rule.IntimacyThreshold, rule.RewardResourceGroupID, rule.LevelIconURL, rule.Status, rule.CreatedAtMS, rule.UpdatedAtMS, ); err != nil { return err } } return tx.Commit() } func (s *smoke) seedProgressLevelRules(ctx context.Context, relationType string) error { nowMS := time.Now().UTC().UnixMilli() rules := []struct { level int32 threshold int64 }{ {1, 0}, {2, 100}, {3, 300}, {4, 600}, {5, 1000}, } for _, rule := range rules { if _, err := s.db.ExecContext(ctx, ` INSERT INTO hyapp_user.user_cp_level_rules ( app_code, relation_type, level_no, intimacy_threshold, reward_resource_group_id, level_icon_url, status, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, 0, '', 'active', ?, ?) ON DUPLICATE KEY UPDATE intimacy_threshold = VALUES(intimacy_threshold), reward_resource_group_id = 0, level_icon_url = '', status = 'active', updated_at_ms = VALUES(updated_at_ms)`, appCode, relationType, rule.level, rule.threshold, nowMS, nowMS, ); err != nil { return err } } return nil } func (s *smoke) seedProgressRelationship(ctx context.Context, intimacyValue int64, level int32) error { nowMS := time.Now().UTC().UnixMilli() a, b := orderedPair(userA, userB) // 直接写真实关系表,只绕过“送礼/申请/同意”前置链路;本用例验证的是已形成关系的服务端读模型和 gateway 输出。 _, err := s.db.ExecContext(ctx, ` INSERT INTO hyapp_user.user_cp_relationships ( app_code, relationship_id, user_a_id, user_b_id, relation_type, status, intimacy_value, level_no, source_application_id, formed_at_ms, ended_at_ms, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, 'cp', 'active', ?, ?, 'cp-smoke-progress', ?, 0, ?, ?) ON DUPLICATE KEY UPDATE relation_type = 'cp', status = 'active', intimacy_value = VALUES(intimacy_value), level_no = VALUES(level_no), updated_at_ms = VALUES(updated_at_ms)`, appCode, relationshipID(userA, userB), a, b, intimacyValue, level, nowMS, nowMS, nowMS, ) return err } func (s *smoke) assertRelationshipProgressHTTP(ctx context.Context, want cpRelationship) error { got, err := s.getHTTPRelationship(ctx, userA, want.RelationType) if err != nil { return err } // 新增字段必须由 HTTP 响应直接给出;这里逐项比对,防止客户端继续用等级和亲密值自行推算进度。 checks := []struct { name string got any want any }{ {"relationship_id", got.RelationshipID, want.RelationshipID}, {"relation_type", got.RelationType, want.RelationType}, {"status", got.Status, want.Status}, {"intimacy_value", got.IntimacyValue, want.IntimacyValue}, {"level", got.Level, want.Level}, {"current_level_threshold", got.CurrentLevelThreshold, want.CurrentLevelThreshold}, {"next_level_threshold", got.NextLevelThreshold, want.NextLevelThreshold}, {"needed_for_next_level", got.NeededForNextLevel, want.NeededForNextLevel}, {"level_progress_percent", got.LevelProgressPercent, want.LevelProgressPercent}, {"max_level", got.MaxLevel, want.MaxLevel}, } for _, check := range checks { if check.got != check.want { return fmt.Errorf("relationship %s mismatch: got=%v want=%v full=%+v", check.name, check.got, check.want, got) } } return nil } func (s *smoke) getHTTPRelationship(ctx context.Context, userID int64, relationType string) (cpRelationship, error) { resp, err := s.request(ctx, http.MethodGet, "/api/v1/cp/relationships?relation_type="+relationType+"&page=1&page_size=20", userID, nil) if err != nil { return cpRelationship{}, err } var data cpRelationshipListData if err := json.Unmarshal(resp.Data, &data); err != nil { return cpRelationship{}, err } if data.Total != 1 || len(data.Relationships) != 1 { return cpRelationship{}, fmt.Errorf("relationship list size mismatch total=%d len=%d data=%s", data.Total, len(data.Relationships), string(resp.Data)) } return data.Relationships[0], nil } func (s *smoke) lookupUSRegion(ctx context.Context) (int64, error) { var regionID int64 err := s.db.QueryRowContext(ctx, ` SELECT r.region_id FROM hyapp_user.regions r JOIN hyapp_user.region_countries rc ON rc.app_code = r.app_code AND rc.region_id = r.region_id WHERE r.app_code = ? AND rc.country_code = 'US' AND r.status = 'active' AND rc.status = 'active' ORDER BY r.region_id LIMIT 1`, appCode).Scan(®ionID) if err != nil { return 0, fmt.Errorf("lookup US region: %w", err) } return regionID, nil } func (s *smoke) cleanupCPFacts(ctx context.Context) error { ids := []int64{userA, userB, userC} relationshipIDAB := relationshipID(userA, userB) relationshipIDAC := relationshipID(userA, userC) statements := []string{ `DELETE FROM hyapp_user.user_cp_level_reward_grants WHERE app_code = ? AND relationship_id IN (?, ?)`, `DELETE FROM hyapp_user.user_cp_relationships WHERE app_code = ? AND (user_a_id IN (?, ?, ?) OR user_b_id IN (?, ?, ?))`, `DELETE FROM hyapp_user.user_cp_applications WHERE app_code = ? AND (requester_user_id IN (?, ?, ?) OR target_user_id IN (?, ?, ?))`, `DELETE FROM hyapp_user.user_outbox WHERE app_code = ? AND event_type LIKE 'UserCP%' AND aggregate_id IN (?, ?, ?)`, `DELETE FROM hyapp_notice.notice_delivery_events WHERE source_name = 'user_outbox' AND app_code = ? AND notice_type LIKE 'cp_%' AND (JSON_SEARCH(payload_json, 'one', ?) IS NOT NULL OR JSON_SEARCH(payload_json, 'one', ?) IS NOT NULL OR JSON_SEARCH(payload_json, 'one', ?) IS NOT NULL)`, } if _, err := s.db.ExecContext(ctx, statements[0], appCode, relationshipIDAB, relationshipIDAC); err != nil && !isMissingTable(err) { return err } if _, err := s.db.ExecContext(ctx, statements[1], appCode, ids[0], ids[1], ids[2], ids[0], ids[1], ids[2]); err != nil { return err } if _, err := s.db.ExecContext(ctx, statements[2], appCode, ids[0], ids[1], ids[2], ids[0], ids[1], ids[2]); err != nil { return err } if _, err := s.db.ExecContext(ctx, statements[3], appCode, ids[0], ids[1], ids[2]); err != nil { return err } _, err := s.db.ExecContext(ctx, statements[4], appCode, fmt.Sprint(userA), fmt.Sprint(userB), fmt.Sprint(userC)) return err } func (s *smoke) cleanupRoomFacts(ctx context.Context) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer func() { _ = tx.Rollback() }() // smoke 使用固定用户反复造房;room-service 对 owner_user_id 有唯一约束,所以每次运行前必须清掉这些测试用户拥有的旧房间事实。 // 所有删除都先通过 rooms.owner_user_id 限定范围,最后才删 rooms 本表,避免误清理其他本地调试房间。 roomSubquery := `SELECT room_id FROM hyapp_room.rooms WHERE app_code = ? AND owner_user_id IN (?, ?, ?)` deleteByRoomStatements := []string{ `DELETE FROM hyapp_room.room_background_images WHERE app_code = ? AND room_id IN (` + roomSubquery + `)`, `DELETE FROM hyapp_room.room_snapshots WHERE app_code = ? AND room_id IN (` + roomSubquery + `)`, `DELETE FROM hyapp_room.room_command_log WHERE app_code = ? AND room_id IN (` + roomSubquery + `)`, `DELETE FROM hyapp_room.room_outbox WHERE app_code = ? AND room_id IN (` + roomSubquery + `)`, `DELETE FROM hyapp_room.room_user_gift_stats WHERE app_code = ? AND room_id IN (` + roomSubquery + `)`, `DELETE FROM hyapp_room.room_user_feed_entries WHERE app_code = ? AND room_id IN (` + roomSubquery + `)`, `DELETE FROM hyapp_room.room_follows WHERE app_code = ? AND room_id IN (` + roomSubquery + `)`, `DELETE FROM hyapp_room.room_region_pins WHERE app_code = ? AND room_id IN (` + roomSubquery + `)`, `DELETE FROM hyapp_room.room_list_entries WHERE app_code = ? AND room_id IN (` + roomSubquery + `)`, } for _, statement := range deleteByRoomStatements { if _, err := tx.ExecContext(ctx, statement, appCode, appCode, userA, userB, userC); err != nil { return err } } if _, err := tx.ExecContext(ctx, ` DELETE FROM hyapp_room.room_user_presence WHERE app_code = ? AND (user_id IN (?, ?, ?) OR room_id IN (`+roomSubquery+`))`, appCode, userA, userB, userC, appCode, userA, userB, userC, ); err != nil { return err } if _, err := tx.ExecContext(ctx, ` DELETE FROM hyapp_room.rooms WHERE app_code = ? AND owner_user_id IN (?, ?, ?)`, appCode, userA, userB, userC, ); err != nil { return err } return tx.Commit() } func (s *smoke) seedUsers(ctx context.Context, regionID int64) error { if _, err := s.db.ExecContext(ctx, ` DELETE FROM hyapp_user.user_display_user_ids WHERE app_code = ? AND (user_id IN (?, ?, ?) OR display_user_id IN ('910001', '910002', '910003'))`, appCode, userA, userB, userC, ); err != nil { return err } users := []struct { id int64 name string avatar string }{ {userA, "CP Smoke Alice", "https://cdn.example.com/cp-smoke-a.png"}, {userB, "CP Smoke Bob", "https://cdn.example.com/cp-smoke-b.png"}, {userC, "CP Smoke Cara", "https://cdn.example.com/cp-smoke-c.png"}, } nowMS := time.Now().UTC().UnixMilli() for _, user := range users { displayID := strconv.FormatInt(user.id, 10) if _, err := s.db.ExecContext(ctx, ` INSERT INTO hyapp_user.users ( app_code, user_id, default_display_user_id, current_display_user_id, current_display_user_id_kind, username, gender, country, region_id, avatar, profile_completed, profile_completed_at_ms, onboarding_status, status, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, 'default', ?, 'unknown', 'US', ?, ?, 1, ?, 'completed', 'active', ?, ?) ON DUPLICATE KEY UPDATE current_display_user_id = VALUES(current_display_user_id), current_display_user_id_kind = VALUES(current_display_user_id_kind), username = VALUES(username), country = VALUES(country), region_id = VALUES(region_id), avatar = VALUES(avatar), profile_completed = VALUES(profile_completed), profile_completed_at_ms = VALUES(profile_completed_at_ms), onboarding_status = VALUES(onboarding_status), status = VALUES(status), updated_at_ms = VALUES(updated_at_ms)`, appCode, user.id, displayID, displayID, user.name, regionID, user.avatar, nowMS, nowMS, nowMS, ); err != nil { return err } if _, err := s.db.ExecContext(ctx, ` INSERT INTO hyapp_user.user_display_user_ids ( app_code, display_user_id, user_id, display_user_id_kind, status, assigned_at_ms, activated_at_ms, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, 'default', 'active', ?, ?, ?, ?)`, appCode, displayID, user.id, nowMS, nowMS, nowMS, nowMS, ); err != nil { return err } } return nil } func (s *smoke) seedWallet(ctx context.Context) error { nowMS := time.Now().UTC().UnixMilli() for _, userID := range []int64{userA, userC} { if _, err := s.db.ExecContext(ctx, ` INSERT INTO hyapp_wallet.wallet_accounts ( app_code, user_id, asset_type, available_amount, frozen_amount, version, created_at_ms, updated_at_ms ) VALUES (?, ?, 'COIN', 1000000, 0, 1, ?, ?) ON DUPLICATE KEY UPDATE available_amount = 1000000, frozen_amount = 0, version = version + 1, updated_at_ms = VALUES(updated_at_ms)`, appCode, userID, nowMS, nowMS, ); err != nil { return err } } return nil } func (s *smoke) seedGifts(ctx context.Context) error { gifts := []struct { id string resourceCode string name string relationType string icon string }{ {giftCP, "local_cp_gift_cp_resource", "Local CP Gift", "cp", "https://cdn.example.com/local-cp.png"}, {giftBrother, "local_cp_gift_brother_resource", "Local Brother Gift", "brother", "https://cdn.example.com/local-brother.png"}, {giftSister, "local_cp_gift_sister_resource", "Local Sister Gift", "sister", "https://cdn.example.com/local-sister.png"}, } nowMS := time.Now().UTC().UnixMilli() for index, gift := range gifts { resourceID, err := s.upsertGiftResource(ctx, gift.resourceCode, gift.name, gift.icon, int32(index+1)*10, nowMS) if err != nil { return err } presentation := fmt.Sprintf(`{"cp_relation_type":"%s","cp_relation_label":"%s"}`, gift.relationType, gift.name) if _, err := s.db.ExecContext(ctx, ` INSERT INTO hyapp_wallet.gift_configs ( app_code, gift_id, resource_id, status, name, sort_order, presentation_json, gift_type_code, effective_from_ms, effective_to_ms, effect_types_json, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, 'active', ?, ?, CAST(? AS JSON), 'cp', 0, 0, JSON_ARRAY(), 0, 0, ?, ?) ON DUPLICATE KEY UPDATE resource_id = VALUES(resource_id), status = 'active', name = VALUES(name), sort_order = VALUES(sort_order), presentation_json = VALUES(presentation_json), gift_type_code = 'cp', effective_from_ms = 0, effective_to_ms = 0, effect_types_json = JSON_ARRAY(), updated_at_ms = VALUES(updated_at_ms)`, appCode, gift.id, resourceID, gift.name, int32(index+1)*10, presentation, nowMS, nowMS, ); err != nil { return err } if _, err := s.db.ExecContext(ctx, ` INSERT INTO hyapp_wallet.wallet_gift_prices ( app_code, gift_id, price_version, status, charge_asset_type, coin_price, gift_point_amount, heat_value, effective_at_ms, created_at_ms, updated_at_ms ) VALUES (?, ?, 'smoke-v1', 'active', 'COIN', 10, 0, 10, 0, ?, ?) ON DUPLICATE KEY UPDATE status = 'active', charge_asset_type = 'COIN', coin_price = 10, gift_point_amount = 0, heat_value = 10, effective_at_ms = 0, updated_at_ms = VALUES(updated_at_ms)`, appCode, gift.id, nowMS, nowMS, ); err != nil { return err } if _, err := s.db.ExecContext(ctx, ` INSERT INTO hyapp_wallet.gift_config_regions (app_code, gift_id, region_id, created_at_ms, updated_at_ms) VALUES (?, ?, 0, ?, ?) ON DUPLICATE KEY UPDATE updated_at_ms = VALUES(updated_at_ms)`, appCode, gift.id, nowMS, nowMS, ); err != nil { return err } } return nil } func (s *smoke) upsertGiftResource(ctx context.Context, code string, name string, icon string, sortOrder int32, nowMS int64) (int64, error) { if _, err := s.db.ExecContext(ctx, ` INSERT INTO hyapp_wallet.resources ( app_code, resource_code, resource_type, name, status, grantable, manager_grant_enabled, grant_strategy, wallet_asset_type, wallet_asset_amount, price_type, coin_price, gift_point_amount, usage_scope_json, asset_url, preview_url, animation_url, metadata_json, sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms ) VALUES (?, ?, 'gift', ?, 'active', TRUE, TRUE, 'permanent', '', 0, 'coin', 10, 0, JSON_ARRAY('gift_panel'), ?, ?, '', JSON_OBJECT(), ?, 0, 0, ?, ?) ON DUPLICATE KEY UPDATE resource_type = 'gift', name = VALUES(name), status = 'active', asset_url = VALUES(asset_url), preview_url = VALUES(preview_url), sort_order = VALUES(sort_order), updated_at_ms = VALUES(updated_at_ms)`, appCode, code, name, icon, icon, sortOrder, nowMS, nowMS, ); err != nil { return 0, err } var resourceID int64 err := s.db.QueryRowContext(ctx, `SELECT resource_id FROM hyapp_wallet.resources WHERE app_code = ? AND resource_code = ?`, appCode, code).Scan(&resourceID) return resourceID, err } func (s *smoke) importIMAccounts(ctx context.Context) error { for _, userID := range []int64{userA, userB, userC} { if _, err := s.request(ctx, http.MethodGet, "/api/v1/im/usersig", userID, nil); err != nil { return fmt.Errorf("import im account %d: %w", userID, err) } } return nil } func (s *smoke) createRoomAndJoin(ctx context.Context) error { // CreateRoom 走真实 gateway->room-service 校验,seat_count 必须使用后台允许的麦位数, // 这里固定 10 麦避免测试数据绕过当前房间配置边界。 resp, err := s.request(ctx, http.MethodPost, "/api/v1/rooms/create", userA, map[string]any{ "command_id": commandID("create_room"), "seat_count": 10, "mode": "voice", "room_name": "CP Smoke Room", "room_avatar": "https://cdn.example.com/cp-smoke-room.png", "room_description": "cp smoke", }) if err != nil { return err } roomID := jsonString(resp.Data, "room", "room_id") if roomID == "" { return fmt.Errorf("create room response missing room.room_id: %s", string(resp.Data)) } s.roomID = roomID for _, userID := range []int64{userA, userB, userC} { if _, err := s.request(ctx, http.MethodPost, "/api/v1/rooms/join", userID, map[string]any{ "room_id": roomID, "command_id": commandID(fmt.Sprintf("join_%d", userID)), "role": "audience", }); err != nil { return fmt.Errorf("join user %d: %w", userID, err) } } panel, err := s.request(ctx, http.MethodGet, "/api/v1/rooms/"+roomID+"/gift-panel", userA, nil) if err != nil { return err } if !jsonArrayContainsGift(panel.Data, giftCP) { return fmt.Errorf("gift panel does not contain %s", giftCP) } return nil } func (s *smoke) testCreateCPApplication(ctx context.Context) error { if err := s.cleanupCPFacts(ctx); err != nil { return err } if err := s.sendGift(ctx, userA, userB, giftCP, "t01_cp"); err != nil { return err } app, err := s.waitApplication(ctx, userA, userB, "cp", "pending") if err != nil { return err } if app.ExpiresAtMS <= time.Now().UTC().Add(23*time.Hour).UnixMilli() { return fmt.Errorf("application expires_at_ms is not about 24h later: %+v", app) } if err := s.waitNotice(ctx, "UserCPApplicationCreated", "tencent_im_c2c", "cp_application_created", userB, "delivered"); err != nil { return err } return s.waitNotice(ctx, "UserCPApplicationCreated", "tencent_im_room", "cp_application_created", userB, "delivered") } func (s *smoke) testAcceptApplication(ctx context.Context) error { app, err := s.waitApplication(ctx, userA, userB, "cp", "pending") if err != nil { return err } resp, err := s.request(ctx, http.MethodPost, "/api/v1/cp/applications/"+urlPath(app.ApplicationID)+"/accept", userB, nil) if err != nil { return err } if jsonString(resp.Data, "relationship", "relationship_id") != relationshipID(userA, userB) { return fmt.Errorf("accept response relationship mismatch: %s", string(resp.Data)) } if _, err := s.waitRelationship(ctx, userA, userB, "cp", 0); err != nil { return err } if err := s.waitApplicationStatus(ctx, app.ApplicationID, "accepted"); err != nil { return err } if err := s.waitNotice(ctx, "UserCPApplicationAccepted", "tencent_im_c2c", "cp_application_accepted", userA, "delivered"); err != nil { return err } if err := s.waitNotice(ctx, "UserCPApplicationAccepted", "tencent_im_c2c_acceptor", "cp_application_accepted", userB, "delivered"); err != nil { return err } if err := s.waitNotice(ctx, "UserCPApplicationAccepted", "tencent_im_room", "cp_application_accepted", userA, "delivered"); err != nil { return err } return s.waitNotice(ctx, "UserCPRelationshipCreated", "tencent_im_region", "cp_relationship_created", 0, "delivered") } func (s *smoke) testIntimacyIncrement(ctx context.Context) error { before, err := s.waitRelationship(ctx, userA, userB, "cp", 0) if err != nil { return err } if err := s.sendGift(ctx, userA, userB, giftCP, "t03_cp_again"); err != nil { return err } after, err := s.waitRelationship(ctx, userA, userB, "cp", before.IntimacyValue+10) if err != nil { return err } if after.IntimacyValue != before.IntimacyValue+10 { return fmt.Errorf("intimacy mismatch before=%d after=%d", before.IntimacyValue, after.IntimacyValue) } return s.waitUserOutbox(ctx, "UserCPRelationshipIntimacyChanged") } func (s *smoke) testPairSingleRelation(ctx context.Context) error { if err := s.cleanupCPFacts(ctx); err != nil { return err } if err := s.sendGift(ctx, userA, userB, giftBrother, "t04_brother"); err != nil { return err } if err := s.sendGift(ctx, userA, userB, giftSister, "t04_sister"); err != nil { return err } brother, err := s.waitApplication(ctx, userA, userB, "brother", "pending") if err != nil { return err } sister, err := s.waitApplication(ctx, userA, userB, "sister", "pending") if err != nil { return err } if _, err := s.request(ctx, http.MethodPost, "/api/v1/cp/applications/"+urlPath(brother.ApplicationID)+"/accept", userB, nil); err != nil { return err } if err := s.waitApplicationStatus(ctx, sister.ApplicationID, "blocked"); err != nil { return err } err = s.expectCode(ctx, http.MethodPost, "/api/v1/cp/applications/"+urlPath(sister.ApplicationID)+"/accept", userB, nil, "CONFLICT") if err != nil { return err } _, err = s.waitRelationship(ctx, userA, userB, "brother", 0) return err } func (s *smoke) testUserSingleRelation(ctx context.Context) error { if err := s.sendGift(ctx, userC, userA, giftCP, "t05_c_to_a"); err != nil { return err } app, err := s.waitApplication(ctx, userC, userA, "cp", "pending") if err != nil { return err } if err := s.expectCode(ctx, http.MethodPost, "/api/v1/cp/applications/"+urlPath(app.ApplicationID)+"/accept", userA, nil, "CONFLICT"); err != nil { return err } if _, err := s.waitRelationship(ctx, userA, userB, "brother", 0); err != nil { return err } return s.assertNoRelationship(ctx, userA, userC) } func (s *smoke) testExpiredApplication(ctx context.Context) error { if err := s.cleanupCPFacts(ctx); err != nil { return err } if err := s.sendGift(ctx, userA, userB, giftCP, "t06_expire"); err != nil { return err } app, err := s.waitApplication(ctx, userA, userB, "cp", "pending") if err != nil { return err } if _, err := s.db.ExecContext(ctx, ` UPDATE hyapp_user.user_cp_applications SET expires_at_ms = ?, updated_at_ms = ? WHERE app_code = ? AND application_id = ?`, time.Now().UTC().Add(-time.Minute).UnixMilli(), time.Now().UTC().UnixMilli(), appCode, app.ApplicationID, ); err != nil { return err } if err := s.expectCode(ctx, http.MethodPost, "/api/v1/cp/applications/"+urlPath(app.ApplicationID)+"/accept", userB, nil, "CONFLICT"); err != nil { return err } return s.waitApplicationStatus(ctx, app.ApplicationID, "expired") } func (s *smoke) testRejectApplication(ctx context.Context) error { if err := s.cleanupCPFacts(ctx); err != nil { return err } if err := s.sendGift(ctx, userA, userB, giftCP, "t07_reject"); err != nil { return err } app, err := s.waitApplication(ctx, userA, userB, "cp", "pending") if err != nil { return err } if _, err := s.request(ctx, http.MethodPost, "/api/v1/cp/applications/"+urlPath(app.ApplicationID)+"/reject", userB, map[string]any{"reason": "smoke"}); err != nil { return err } if err := s.waitApplicationStatus(ctx, app.ApplicationID, "rejected"); err != nil { return err } if err := s.assertNoRelationship(ctx, userA, userB); err != nil { return err } if err := s.waitNotice(ctx, "UserCPApplicationRejected", "tencent_im_c2c", "cp_application_rejected", userA, "delivered"); err != nil { return err } return s.waitNotice(ctx, "UserCPApplicationRejected", "tencent_im_room", "cp_application_rejected", userA, "delivered") } func (s *smoke) sendGift(ctx context.Context, senderID int64, targetID int64, giftID string, key string) error { _, err := s.request(ctx, http.MethodPost, "/api/v1/rooms/gift/send", senderID, map[string]any{ "room_id": s.roomID, "command_id": commandID(key), "target_type": "user", "target_user_ids": []int64{targetID}, "gift_id": giftID, "gift_count": 1, }) return err } func (s *smoke) request(ctx context.Context, method string, path string, userID int64, body any) (envelope, error) { var reader io.Reader if body != nil { raw, err := json.Marshal(body) if err != nil { return envelope{}, err } reader = bytes.NewReader(raw) } req, err := http.NewRequestWithContext(ctx, method, s.gateway+path, reader) if err != nil { return envelope{}, err } req.Header.Set("Authorization", "Bearer "+tokenForUser(userID)) req.Header.Set("X-App-Code", appCode) req.Header.Set("Content-Type", "application/json") resp, err := s.http.Do(req) if err != nil { return envelope{}, err } defer resp.Body.Close() raw, _ := io.ReadAll(resp.Body) var out envelope if err := json.Unmarshal(raw, &out); err != nil { return envelope{}, fmt.Errorf("%s %s returned non-envelope status=%d body=%s", method, path, resp.StatusCode, string(raw)) } if resp.StatusCode < 200 || resp.StatusCode >= 300 || out.Code != "OK" { return out, fmt.Errorf("%s %s failed status=%d code=%s message=%s body=%s", method, path, resp.StatusCode, out.Code, out.Message, string(raw)) } return out, nil } func (s *smoke) expectCode(ctx context.Context, method string, path string, userID int64, body any, expected string) error { out, err := s.request(ctx, method, path, userID, body) if err == nil { return fmt.Errorf("expected code %s, got OK data=%s", expected, string(out.Data)) } if out.Code != expected { return fmt.Errorf("expected code %s, got %s: %v", expected, out.Code, err) } return nil } func (s *smoke) waitApplication(ctx context.Context, requester int64, target int64, relationType string, status string) (cpApplication, error) { var app cpApplication err := wait(ctx, 25*time.Second, func() error { row := s.db.QueryRowContext(ctx, ` SELECT application_id, relation_type, status, room_id, expires_at_ms FROM hyapp_user.user_cp_applications WHERE app_code = ? AND requester_user_id = ? AND target_user_id = ? AND relation_type = ? AND status = ? ORDER BY updated_at_ms DESC LIMIT 1`, appCode, requester, target, relationType, status, ) return row.Scan(&app.ApplicationID, &app.RelationType, &app.Status, &app.RoomID, &app.ExpiresAtMS) }) return app, err } func (s *smoke) waitApplicationStatus(ctx context.Context, applicationID string, status string) error { return wait(ctx, 20*time.Second, func() error { var got string err := s.db.QueryRowContext(ctx, ` SELECT status FROM hyapp_user.user_cp_applications WHERE app_code = ? AND application_id = ?`, appCode, applicationID, ).Scan(&got) if err != nil { return err } if got != status { return fmt.Errorf("application %s status=%s, want %s", applicationID, got, status) } return nil }) } func (s *smoke) waitRelationship(ctx context.Context, user1 int64, user2 int64, relationType string, minIntimacy int64) (cpRelationship, error) { var rel cpRelationship a, b := orderedPair(user1, user2) err := wait(ctx, 25*time.Second, func() error { row := s.db.QueryRowContext(ctx, ` SELECT relationship_id, relation_type, status, intimacy_value, level_no FROM hyapp_user.user_cp_relationships WHERE app_code = ? AND user_a_id = ? AND user_b_id = ? AND relation_type = ? AND status = 'active' LIMIT 1`, appCode, a, b, relationType, ) if err := row.Scan(&rel.RelationshipID, &rel.RelationType, &rel.Status, &rel.IntimacyValue, &rel.Level); err != nil { return err } if rel.IntimacyValue < minIntimacy { return fmt.Errorf("relationship intimacy=%d, want >=%d", rel.IntimacyValue, minIntimacy) } return nil }) return rel, err } func (s *smoke) assertNoRelationship(ctx context.Context, user1 int64, user2 int64) error { a, b := orderedPair(user1, user2) var count int64 if err := s.db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM hyapp_user.user_cp_relationships WHERE app_code = ? AND user_a_id = ? AND user_b_id = ? AND status = 'active'`, appCode, a, b, ).Scan(&count); err != nil { return err } if count != 0 { return fmt.Errorf("unexpected active relationship for %d/%d", user1, user2) } return nil } func (s *smoke) waitUserOutbox(ctx context.Context, eventType string) error { return wait(ctx, 20*time.Second, func() error { var count int64 err := s.db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM hyapp_user.user_outbox WHERE app_code = ? AND event_type = ? AND created_at_ms >= ?`, appCode, eventType, s.startMS, ).Scan(&count) if err != nil { return err } if count == 0 { return fmt.Errorf("missing user_outbox %s", eventType) } return nil }) } func (s *smoke) waitNotice(ctx context.Context, sourceEventType string, channel string, noticeType string, targetUserID int64, wantStatus string) error { return wait(ctx, 35*time.Second, func() error { query := ` SELECT nde.status, COALESCE(nde.last_error, '') FROM hyapp_notice.notice_delivery_events nde JOIN hyapp_user.user_outbox uo ON uo.app_code = nde.app_code AND uo.event_id = nde.source_event_id WHERE nde.source_name = 'user_outbox' AND nde.app_code = ? AND uo.event_type = ? AND nde.channel = ? AND nde.notice_type = ? AND nde.created_at_ms >= ?` args := []any{appCode, sourceEventType, channel, noticeType, s.startMS} if targetUserID > 0 { query += ` AND nde.target_user_id = ?` args = append(args, targetUserID) } query += ` ORDER BY nde.updated_at_ms DESC LIMIT 1` var status, lastErr string err := s.db.QueryRowContext(ctx, query, args...).Scan(&status, &lastErr) if err != nil { return err } if status != wantStatus { return fmt.Errorf("notice %s/%s/%s status=%s last_error=%s", sourceEventType, channel, noticeType, status, lastErr) } return nil }) } func wait(ctx context.Context, timeout time.Duration, fn func() error) error { deadline := time.Now().Add(timeout) var last error for time.Now().Before(deadline) { if err := fn(); err != nil { last = err select { case <-ctx.Done(): return ctx.Err() case <-time.After(500 * time.Millisecond): continue } } return nil } if last == nil { last = errors.New("condition not satisfied") } return last } func tokenForUser(userID int64) string { token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "user_id": strconv.FormatInt(userID, 10), "app_code": appCode, "sid": "cp-smoke-" + strconv.FormatInt(userID, 10), "profile_completed": true, "onboarding_status": "completed", "exp": time.Now().Add(2 * time.Hour).Unix(), "iat": time.Now().Unix(), }) signed, err := token.SignedString([]byte(jwtSecret)) if err != nil { panic(err) } return signed } func commandID(key string) string { return fmt.Sprintf("cp-smoke:%s:%d", key, time.Now().UTC().UnixNano()) } func jsonString(raw json.RawMessage, path ...string) string { var cur any if err := json.Unmarshal(raw, &cur); err != nil { return "" } for _, key := range path { obj, ok := cur.(map[string]any) if !ok { return "" } cur = obj[key] } switch value := cur.(type) { case string: return value case float64: if value == float64(int64(value)) { return strconv.FormatInt(int64(value), 10) } } return "" } func jsonArrayContainsGift(raw json.RawMessage, giftID string) bool { var obj map[string]any if err := json.Unmarshal(raw, &obj); err != nil { return false } items, _ := obj["gifts"].([]any) for _, item := range items { gift, _ := item.(map[string]any) if strings.TrimSpace(fmt.Sprint(gift["gift_id"])) == giftID { return true } } return false } func orderedPair(a int64, b int64) (int64, int64) { if a < b { return a, b } return b, a } func relationshipID(a int64, b int64) string { x, y := orderedPair(a, b) return fmt.Sprintf("cp_rel:%d:%d", x, y) } func urlPath(value string) string { return strings.ReplaceAll(value, "/", "%2F") } func env(key string, fallback string) string { if value := strings.TrimSpace(os.Getenv(key)); value != "" { return value } return fallback } func envDuration(key string, fallback time.Duration) time.Duration { if value := strings.TrimSpace(os.Getenv(key)); value != "" { if parsed, err := time.ParseDuration(value); err == nil { return parsed } } return fallback } func isMissingTable(err error) bool { return strings.Contains(err.Error(), "doesn't exist") } func fatal(err error) { fmt.Fprintln(os.Stderr, "cp smoke failed:", err) os.Exit(1) }