1128 lines
39 KiB
Go
1128 lines
39 KiB
Go
// 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)
|
||
}
|