2026-06-08 19:00:23 +08:00

1128 lines
39 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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(&regionID)
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)
}