// Package auth_test 验证 user-service 认证用例的登录、注册、密码、refresh 和靓号登录语义。
package auth_test

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"os"
	"strings"
	"sync"
	"testing"
	"time"

	"hyapp/pkg/xerr"
	authdomain "hyapp/services/user-service/internal/domain/auth"
	userdomain "hyapp/services/user-service/internal/domain/user"
	authservice "hyapp/services/user-service/internal/service/auth"
	userservice "hyapp/services/user-service/internal/service/user"
	"hyapp/services/user-service/internal/testutil/mysqltest"

	jwt "github.com/golang-jwt/jwt/v5"
	"github.com/redis/go-redis/v9"
)

const testAccessTokenTTLSec int64 = 604800

func accessTokenExpUnix(t *testing.T, accessToken string) int64 {
	t.Helper()
	parser := jwt.NewParser(jwt.WithValidMethods([]string{"HS256"}), jwt.WithoutClaimsValidation())
	parsed, err := parser.Parse(accessToken, func(token *jwt.Token) (any, error) {
		return []byte("test-secret"), nil
	})
	if err != nil || !parsed.Valid {
		t.Fatalf("parse access token failed: token=%q err=%v", accessToken, err)
	}
	exp, err := parsed.Claims.GetExpirationTime()
	if err != nil || exp == nil {
		t.Fatalf("access token missing exp: claims=%+v err=%v", parsed.Claims, err)
	}
	return exp.Unix()
}

func accessTokenStringClaim(t *testing.T, accessToken string, name string) string {
	t.Helper()
	parser := jwt.NewParser(jwt.WithValidMethods([]string{"HS256"}), jwt.WithoutClaimsValidation())
	parsed, err := parser.Parse(accessToken, func(token *jwt.Token) (any, error) {
		return []byte("test-secret"), nil
	})
	if err != nil || !parsed.Valid {
		t.Fatalf("parse access token claim %s failed: token=%q err=%v", name, accessToken, err)
	}
	claims, ok := parsed.Claims.(jwt.MapClaims)
	if !ok {
		t.Fatalf("access token claim set has unexpected type %T", parsed.Claims)
	}
	value, _ := claims[name].(string)
	return value
}

type memoryDecisionCache struct {
	mu      sync.Mutex
	ip      map[string]authservice.IPDecision
	revoked map[string]string
	revTTL  map[string]time.Duration
	ips     map[string][]string
	users   map[string][]int64
	userTTL map[string]time.Duration
	err     error
}

type memoryRefreshMetrics struct {
	mu     sync.Mutex
	counts map[string]int
}

type failingRotateAuthRepository struct {
	// 嵌入真实 repository 让测试只替换 rotation commit，其他查找仍走同一个 MySQL schema。
	authservice.AuthRepository
}

func (r failingRotateAuthRepository) RotateRefreshSession(context.Context, authdomain.RefreshRotationCommand) (authdomain.RefreshRotationResult, error) {
	return authdomain.RefreshRotationResult{}, errors.New("forced rotation commit failure")
}

func newMemoryRefreshMetrics() *memoryRefreshMetrics {
	return &memoryRefreshMetrics{counts: make(map[string]int)}
}

func (m *memoryRefreshMetrics) Increment(_ context.Context, metric string, appCode string) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.counts[appCode+":"+metric]++
}

func (m *memoryRefreshMetrics) count(appCode string, metric string) int {
	m.mu.Lock()
	defer m.mu.Unlock()
	return m.counts[appCode+":"+metric]
}

func newMemoryDecisionCache() *memoryDecisionCache {
	return &memoryDecisionCache{
		ip:      make(map[string]authservice.IPDecision),
		revoked: make(map[string]string),
		revTTL:  make(map[string]time.Duration),
		ips:     make(map[string][]string),
		users:   make(map[string][]int64),
		userTTL: make(map[string]time.Duration),
	}
}

func (c *memoryDecisionCache) GetIPDecision(_ context.Context, appCode string, clientIPHash string) (authservice.IPDecision, bool, error) {
	c.mu.Lock()
	defer c.mu.Unlock()
	decision, ok := c.ip[appCode+":"+clientIPHash]
	return decision, ok, nil
}

func (c *memoryDecisionCache) SetIPDecision(_ context.Context, appCode string, clientIPHash string, decision authservice.IPDecision, _ time.Duration) error {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.ip[appCode+":"+clientIPHash] = decision
	return nil
}

func (c *memoryDecisionCache) SetRevokedSession(_ context.Context, appCode string, sessionID string, reason string, ttl time.Duration) error {
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.err != nil {
		return c.err
	}
	key := appCode + ":" + sessionID
	c.revoked[key] = reason
	c.revTTL[key] = ttl
	return nil
}

func (c *memoryDecisionCache) GetIPWhitelist(_ context.Context, appCode string) ([]string, bool, error) {
	c.mu.Lock()
	defer c.mu.Unlock()
	ips, ok := c.ips[appCode]
	return append([]string(nil), ips...), ok, nil
}

func (c *memoryDecisionCache) SetIPWhitelist(_ context.Context, appCode string, ips []string, _ time.Duration) error {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.ips[appCode] = append([]string(nil), ips...)
	return nil
}

func (c *memoryDecisionCache) GetUserWhitelist(_ context.Context, appCode string) ([]int64, bool, error) {
	c.mu.Lock()
	defer c.mu.Unlock()
	userIDs, ok := c.users[appCode]
	return append([]int64(nil), userIDs...), ok, nil
}

func (c *memoryDecisionCache) SetUserWhitelist(_ context.Context, appCode string, userIDs []int64, ttl time.Duration) error {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.users[appCode] = append([]int64(nil), userIDs...)
	c.userTTL[appCode] = ttl
	return nil
}

type sequenceIDGenerator struct {
	// values 是测试预设 user_id 序列。
	values []int64
	// index 指向当前要返回的 user_id。
	index int
}

func (g *sequenceIDGenerator) NewInt64() int64 {
	// 到达末尾后保持返回最后一个值，便于测试重试耗尽路径。
	value := g.values[g.index]
	if g.index < len(g.values)-1 {
		g.index++
	}

	return value
}

type sequenceDisplayUserIDAllocator struct {
	// values 是测试预设 display_user_id 候选序列。
	values []string
}

func (a sequenceDisplayUserIDAllocator) NextDisplayUserID(_ int64, attempt int) string {
	if attempt >= len(a.values) {
		// 超出候选数量后复用最后一个值，便于构造持续冲突。
		return a.values[len(a.values)-1]
	}

	return a.values[attempt]
}

type fakeIMAccountImporter struct {
	userID   int64
	nickname string
	faceURL  string
}

func (f *fakeIMAccountImporter) ImportAccount(_ context.Context, userID int64, nickname string, faceURL string) error {
	f.userID = userID
	f.nickname = nickname
	f.faceURL = faceURL
	return nil
}

func newUserService(repository *mysqltest.Repository, options ...userservice.Option) *userservice.Service {
	base := []userservice.Option{
		userservice.WithIdentityRepository(repository),
		userservice.WithCountryRegionRepository(repository),
		userservice.WithCountryAdminRepository(repository),
		userservice.WithRegionAdminRepository(repository),
		userservice.WithRegionRebuildRepository(repository),
		userservice.WithDeviceRepository(repository),
	}
	return userservice.New(repository, append(base, options...)...)
}

func newAuthService(repository *mysqltest.Repository, now *time.Time, ids []int64, displayIDs []string, extra ...authservice.Option) *authservice.Service {
	// 测试服务固定 JWT、发号器、短号候选和时钟，保证 token/session 语义可断言。
	options := []authservice.Option{
		authservice.WithAuthRepository(repository),
		authservice.WithUserRepository(repository),
		authservice.WithIdentityRepository(repository),
		authservice.WithCountryRegionRepository(repository),
		authservice.WithIDGenerator(&sequenceIDGenerator{values: ids}),
		authservice.WithDisplayUserIDAllocator(sequenceDisplayUserIDAllocator{values: displayIDs}),
		authservice.WithClock(func() time.Time { return *now }),
		authservice.WithThirdPartyVerifier(authservice.NewStaticThirdPartyVerifier([]string{"wechat"})),
		authservice.WithIPDecisionCache(newMemoryDecisionCache()),
	}
	options = append(options, extra...)
	return authservice.New(authservice.Config{
		Issuer:             "hyapp-test",
		AccessTokenTTLSec:  testAccessTokenTTLSec,
		RefreshTokenTTLSec: 2592000,
		SigningAlg:         "HS256",
		SigningSecret:      "test-secret",
	}, options...)
}

func assertSevenDigitDisplayUserID(t *testing.T, displayUserID string) {
	t.Helper()
	if len(displayUserID) != 7 || displayUserID[0] < '1' || displayUserID[0] > '9' {
		t.Fatalf("display_user_id must be 7 digits and start with 1-9, got %q", displayUserID)
	}
	for _, digit := range displayUserID {
		if digit < '0' || digit > '9' {
			t.Fatalf("display_user_id must contain digits only, got %q", displayUserID)
		}
	}
}

func assertUserRegisteredOutboxCount(t *testing.T, repository *mysqltest.Repository, userID int64, want int) {
	t.Helper()
	var count int
	var status string
	if err := repository.RawDB().QueryRowContext(context.Background(), `
		SELECT COUNT(*), COALESCE(MAX(status), '')
		FROM user_outbox
		WHERE app_code = 'lalu' AND event_type = 'UserRegistered' AND aggregate_id = ?
	`, userID).Scan(&count, &status); err != nil {
		t.Fatalf("query UserRegistered outbox failed: %v", err)
	}
	if count != want {
		t.Fatalf("UserRegistered outbox count for user %d = %d, want %d", userID, count, want)
	}
	if want > 0 && status != "pending" {
		t.Fatalf("UserRegistered outbox status for user %d = %s, want pending", userID, status)
	}
}

func assertUserRegisteredOutboxFact(t *testing.T, repository *mysqltest.Repository, userID int64, countryID int64, regionID int64, occurredAtMS int64) {
	t.Helper()
	var raw string
	var createdAtMS int64
	if err := repository.RawDB().QueryRowContext(context.Background(), `
		SELECT payload_json, created_at_ms
		FROM user_outbox
		WHERE app_code = 'lalu' AND event_type = 'UserRegistered' AND aggregate_id = ?
	`, userID).Scan(&raw, &createdAtMS); err != nil {
		t.Fatalf("query UserRegistered payload failed: %v", err)
	}
	var payload struct {
		CountryID            int64 `json:"country_id"`
		RegionID             int64 `json:"region_id"`
		ProfileCompletedAtMS int64 `json:"profile_completed_at_ms"`
	}
	if err := json.Unmarshal([]byte(raw), &payload); err != nil {
		t.Fatalf("decode UserRegistered payload failed: %v raw=%s", err, raw)
	}
	if payload.CountryID != countryID || payload.RegionID != regionID {
		t.Fatalf("UserRegistered dimensions = country:%d region:%d, want country:%d region:%d", payload.CountryID, payload.RegionID, countryID, regionID)
	}
	if payload.ProfileCompletedAtMS != occurredAtMS || createdAtMS != occurredAtMS {
		t.Fatalf("UserRegistered time = payload:%d outbox:%d, want %d", payload.ProfileCompletedAtMS, createdAtMS, occurredAtMS)
	}
}

func thirdPartyRegistration(platform string) authdomain.ThirdPartyRegistration {
	// 部分测试需要覆盖注册快照字段，因此默认提供完整资料；完成态仍必须由 onboarding 流程推进。
	return authdomain.ThirdPartyRegistration{
		Username: "hy",
		Avatar:   "https://cdn.example/avatar.png",
		Gender:   "male",
		Country:  "SG",
		DeviceID: "dev-" + platform,
		Platform: platform,
	}
}

func seedCountry(t *testing.T, repository *mysqltest.Repository, code string) userdomain.Country {
	t.Helper()
	if country, ok, err := repository.ResolveEnabledCountryByCode(context.Background(), code); err != nil {
		t.Fatalf("resolve country %s failed: %v", code, err)
	} else if ok {
		return country
	}
	return repository.PutCountry(userdomain.Country{
		CountryName:        code + " Name",
		CountryCode:        code,
		CountryDisplayName: code + " Display",
		Enabled:            true,
	})
}

func seedRegion(t *testing.T, repository *mysqltest.Repository, code string, countries []string) userdomain.Region {
	t.Helper()
	return repository.PutRegion(userdomain.Region{
		RegionCode:      code,
		Name:            code + " Region",
		Status:          userdomain.RegionStatusActive,
		Countries:       countries,
		CreatedByUserID: 1,
		UpdatedByUserID: 1,
	})
}

func mustResolveRegionByCountry(t *testing.T, repository *mysqltest.Repository, country string) userdomain.Region {
	t.Helper()
	region, ok, err := repository.ResolveActiveRegionByCountry(context.Background(), country)
	if err != nil {
		t.Fatalf("resolve region for country %s failed: %v", country, err)
	}
	if !ok {
		t.Fatalf("country %s should have seeded region mapping", country)
	}

	return region
}

func TestThirdPartyUserSetsPasswordThenLogsIn(t *testing.T) {
	// 覆盖三方注册、设置密码、密码登录、refresh 轮换和 logout 的完整认证生命周期。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	cache := newMemoryDecisionCache()
	svc := newAuthService(repository, &now, []int64{900001}, []string{"100001"}, authservice.WithIPDecisionCache(cache))

	thirdToken, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-1", thirdPartyRegistration("ios"), authservice.Meta{RequestID: "req-third"})
	if err != nil {
		t.Fatalf("LoginThirdParty failed: %v", err)
	}
	displayUserID := thirdToken.DisplayUserID
	assertSevenDigitDisplayUserID(t, displayUserID)
	if !isNewUser || thirdToken.UserID != 900001 || displayUserID == "" || thirdToken.RefreshToken == "" || thirdToken.AccessToken == "" {
		t.Fatalf("unexpected third-party token: token=%+v isNew=%v", thirdToken, isNewUser)
	}
	if thirdToken.ExpiresInSec != testAccessTokenTTLSec {
		t.Fatalf("third-party access token TTL mismatch: %+v", thirdToken)
	}
	if got, want := accessTokenExpUnix(t, thirdToken.AccessToken), now.Unix()+testAccessTokenTTLSec; got != want {
		t.Fatalf("third-party access token exp mismatch: got=%d want=%d", got, want)
	}
	if got := accessTokenStringClaim(t, thirdToken.AccessToken, "device_id"); got != "dev-ios" {
		// 登录签发必须使用 auth_session 的设备绑定，不能让下游拿 sid 代替设备风控 scope。
		t.Fatalf("third-party access token device_id=%q, want dev-ios", got)
	}
	if thirdToken.ProfileCompleted || thirdToken.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) {
		t.Fatalf("new third-party user must require onboarding: %+v", thirdToken)
	}
	assertUserRegisteredOutboxCount(t, repository, thirdToken.UserID, 0)

	if _, err := svc.LoginPassword(ctx, displayUserID, "secret-pass", "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.AuthFailed) {
		// 未设置密码前不能通过短号密码登录，且错误必须统一 AUTH_FAILED。
		t.Fatalf("expected AUTH_FAILED before password is set, got %v", err)
	}

	if err := svc.SetPassword(ctx, thirdToken.UserID, "secret-pass", authservice.Meta{RequestID: "req-set-password"}); err != nil {
		t.Fatalf("SetPassword failed: %v", err)
	}
	if err := svc.SetPassword(ctx, thirdToken.UserID, "another-pass", authservice.Meta{}); !xerr.IsCode(err, xerr.PasswordAlreadySet) {
		// v1 只允许首次设置密码，暂不提供重置密码流程。
		t.Fatalf("expected PASSWORD_ALREADY_SET, got %v", err)
	}

	loginToken, err := svc.LoginPassword(ctx, displayUserID, "secret-pass", "ios", authservice.Meta{
		RequestID:   "req-login",
		AppVersion:  " 2.4.0 ",
		BuildNumber: "24001",
	})
	if err != nil {
		t.Fatalf("LoginPassword failed: %v", err)
	}
	if loginToken.UserID != thirdToken.UserID || loginToken.DisplayUserID != displayUserID || loginToken.RefreshToken == thirdToken.RefreshToken {
		t.Fatalf("unexpected login token: %+v", loginToken)
	}
	if loginToken.ExpiresInSec != testAccessTokenTTLSec {
		t.Fatalf("password login access token TTL mismatch: %+v", loginToken)
	}
	if got, want := accessTokenExpUnix(t, loginToken.AccessToken), now.Unix()+testAccessTokenTTLSec; got != want {
		t.Fatalf("password login access token exp mismatch: got=%d want=%d", got, want)
	}
	if got := accessTokenStringClaim(t, loginToken.AccessToken, "device_id"); got != "ios" {
		t.Fatalf("password access token device_id=%q, want ios", got)
	}
	var auditedAppVersion string
	var auditedBuildNumber string
	if err := repository.RawDB().QueryRowContext(ctx, `
		SELECT app_version, build_number
		FROM login_audit
		WHERE app_code = 'lalu' AND request_id = 'req-login' AND user_id = ?
		  AND login_type = 'password' AND result = 'success' AND blocked = 0
	`, loginToken.UserID).Scan(&auditedAppVersion, &auditedBuildNumber); err != nil {
		t.Fatalf("query password login client version audit failed: %v", err)
	}
	if auditedAppVersion != "2.4.0" || auditedBuildNumber != "24001" {
		t.Fatalf("password login client version audit mismatch: app_version=%q build_number=%q", auditedAppVersion, auditedBuildNumber)
	}

	if _, err := svc.LoginPassword(ctx, displayUserID, "wrong", "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.AuthFailed) {
		t.Fatalf("expected AUTH_FAILED for wrong password, got %v", err)
	}

	now = now.Add(time.Second)
	// refresh 成功后必须返回新的 refresh token，并吊销旧 token。
	refreshToken, err := svc.RefreshToken(ctx, loginToken.RefreshToken, "ios", authservice.Meta{
		RequestID:   "req-refresh",
		AppVersion:  "2.5.0",
		BuildNumber: "25001",
	})
	if err != nil {
		t.Fatalf("RefreshToken failed: %v", err)
	}
	if refreshToken.RefreshToken == loginToken.RefreshToken || refreshToken.DisplayUserID != displayUserID {
		t.Fatalf("refresh token must rotate")
	}
	if refreshToken.ExpiresInSec != testAccessTokenTTLSec {
		t.Fatalf("refresh access token TTL mismatch: %+v", refreshToken)
	}
	if got, want := accessTokenExpUnix(t, refreshToken.AccessToken), now.Unix()+testAccessTokenTTLSec; got != want {
		t.Fatalf("refresh access token exp mismatch: got=%d want=%d", got, want)
	}
	if got := accessTokenStringClaim(t, refreshToken.AccessToken, "device_id"); got != "ios" {
		t.Fatalf("refreshed access token device_id=%q, want ios", got)
	}
	if got := cache.revoked["lalu:"+loginToken.SessionID]; got != "REFRESH_ROTATED" {
		t.Fatalf("refresh must denylist rotated access session, got %q", got)
	}
	if got, want := cache.revTTL["lalu:"+loginToken.SessionID], time.Duration(testAccessTokenTTLSec+60)*time.Second; got != want {
		t.Fatalf("refresh denylist TTL mismatch: got=%s want=%s", got, want)
	}
	if err := repository.RawDB().QueryRowContext(ctx, `
		SELECT app_version, build_number
		FROM login_audit
		WHERE app_code = 'lalu' AND request_id = 'req-refresh' AND user_id = ?
		  AND login_type = 'refresh' AND result = 'success' AND blocked = 0
	`, loginToken.UserID).Scan(&auditedAppVersion, &auditedBuildNumber); err != nil {
		t.Fatalf("query refresh client version audit failed: %v", err)
	}
	if auditedAppVersion != "2.5.0" || auditedBuildNumber != "25001" {
		t.Fatalf("refresh client version audit mismatch: app_version=%q build_number=%q", auditedAppVersion, auditedBuildNumber)
	}

	graceToken, err := svc.RefreshToken(ctx, loginToken.RefreshToken, "ios", authservice.Meta{})
	if err != nil || graceToken.AccessToken != refreshToken.AccessToken || graceToken.RefreshToken != refreshToken.RefreshToken || graceToken.SessionID != refreshToken.SessionID {
		t.Fatalf("direct parent retry must return the exact first token pair: got=%+v err=%v want=%+v", graceToken, err, refreshToken)
	}

	revoked, err := svc.Logout(ctx, refreshToken.SessionID, refreshToken.RefreshToken, authservice.Meta{RequestID: "req-logout"})
	if err != nil || !revoked {
		t.Fatalf("Logout failed: revoked=%v err=%v", revoked, err)
	}
	if _, err := svc.RefreshToken(ctx, refreshToken.RefreshToken, "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.SessionRevoked) {
		t.Fatalf("expected logout refresh token revoked, got %v", err)
	}
	if got := cache.revoked["lalu:"+refreshToken.SessionID]; got != "USER_LOGOUT" {
		t.Fatalf("logout must denylist access session, got %q", got)
	}
	if got, want := cache.revTTL["lalu:"+refreshToken.SessionID], time.Duration(testAccessTokenTTLSec+60)*time.Second; got != want {
		t.Fatalf("logout denylist TTL mismatch: got=%s want=%s", got, want)
	}
}

func TestRefreshTokenDenylistFailureRecoversPersistedRotationOnRetry(t *testing.T) {
	// MySQL 先原子提交 rotation/outcome/job；Redis 短暂失败时首个请求返回 unavailable，
	// 同一个旧 refresh token 的重试必须从 grace outcome 恢复同一 token pair，不能再建 child 或分叉。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	cache := newMemoryDecisionCache()
	svc := newAuthService(repository, &now, []int64{900012}, []string{"100012"}, authservice.WithIPDecisionCache(cache))

	token, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-denylist-failure", thirdPartyRegistration("ios"), authservice.Meta{AppCode: "lalu", RequestID: "req-login"})
	if err != nil {
		t.Fatalf("LoginThirdParty failed: %v", err)
	}

	cache.err = errors.New("redis unavailable")
	refreshMeta := authservice.Meta{AppCode: "lalu", RequestID: "req-refresh-fail", RefreshRequestID: "refresh-after-redis-outage"}
	if _, err := svc.RefreshToken(ctx, token.RefreshToken, "dev-ios", refreshMeta); !xerr.IsCode(err, xerr.Unavailable) {
		t.Fatalf("RefreshToken should surface unavailable when access denylist write fails: %v", err)
	}
	var revokedReason string
	var childCount int
	var outcomeCount int
	var pendingJobs int
	if err := repository.RawDB().QueryRowContext(ctx, `SELECT revoked_reason FROM auth_sessions WHERE session_id = ?`, token.SessionID).Scan(&revokedReason); err != nil {
		t.Fatalf("query committed parent rotation failed: %v", err)
	}
	if err := repository.RawDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM auth_sessions WHERE parent_session_id = ?`, token.SessionID).Scan(&childCount); err != nil {
		t.Fatalf("query committed child failed: %v", err)
	}
	if err := repository.RawDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM auth_refresh_outcomes WHERE parent_session_id = ?`, token.SessionID).Scan(&outcomeCount); err != nil {
		t.Fatalf("query committed refresh outcome failed: %v", err)
	}
	if err := repository.RawDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM auth_session_denylist_jobs WHERE session_id = ? AND status = 'pending'`, token.SessionID).Scan(&pendingJobs); err != nil {
		t.Fatalf("query committed denylist job failed: %v", err)
	}
	if revokedReason != "REFRESH_ROTATED" || childCount != 1 || outcomeCount != 1 || pendingJobs != 1 {
		t.Fatalf("rotation transaction mismatch: reason=%q children=%d outcomes=%d pending_jobs=%d", revokedReason, childCount, outcomeCount, pendingJobs)
	}
	cache.err = nil

	refreshed, err := svc.RefreshToken(ctx, token.RefreshToken, "dev-ios", refreshMeta)
	if err != nil {
		t.Fatalf("RefreshToken retry must recover persisted outcome: %v", err)
	}
	if refreshed.SessionID == token.SessionID || refreshed.RefreshToken == token.RefreshToken {
		t.Fatalf("recovered outcome must contain rotated session and refresh token: old=%+v new=%+v", token, refreshed)
	}
	again, err := svc.RefreshToken(ctx, token.RefreshToken, "dev-ios", refreshMeta)
	if err != nil || again.AccessToken != refreshed.AccessToken || again.RefreshToken != refreshed.RefreshToken || again.SessionID != refreshed.SessionID {
		t.Fatalf("subsequent grace retry must return exact persisted pair: first=%+v again=%+v err=%v", refreshed, again, err)
	}
	if got := cache.revoked["lalu:"+token.SessionID]; got != "REFRESH_ROTATED" {
		t.Fatalf("retry must denylist original session, got %q", got)
	}
	var deliveredJobs int
	if err := repository.RawDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM auth_session_denylist_jobs WHERE session_id = ? AND status = 'delivered'`, token.SessionID).Scan(&deliveredJobs); err != nil {
		t.Fatalf("query delivered denylist job failed: %v", err)
	}
	if deliveredJobs != 1 {
		t.Fatalf("successful retry must close the durable denylist job, got %d", deliveredJobs)
	}
}

func TestRefreshTokenDatabaseFailureDoesNotDenyActiveSession(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	cache := newMemoryDecisionCache()
	normal := newAuthService(repository, &now, []int64{900044}, []string{"100044"}, authservice.WithIPDecisionCache(cache))

	token, _, err := normal.LoginThirdParty(ctx, "wechat", "openid-refresh-db-failure", thirdPartyRegistration("ios"), authservice.Meta{AppCode: "lalu"})
	if err != nil {
		t.Fatalf("login failed: %v", err)
	}
	failing := newAuthService(repository, &now, []int64{900045}, []string{"100045"},
		authservice.WithIPDecisionCache(cache),
		authservice.WithAuthRepository(failingRotateAuthRepository{AuthRepository: repository}),
	)
	if _, err := failing.RefreshToken(ctx, token.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RefreshRequestID: "db-failure"}); err == nil {
		t.Fatal("refresh must surface the forced MySQL failure")
	}
	if got, ok := cache.revoked["lalu:"+token.SessionID]; ok {
		t.Fatalf("failed DB rotation must not deny the still-active sid, got %q", got)
	}
	var revokedAt *int64
	var childCount int
	var jobCount int
	if err := repository.RawDB().QueryRowContext(ctx, `SELECT revoked_at_ms FROM auth_sessions WHERE session_id = ?`, token.SessionID).Scan(&revokedAt); err != nil {
		t.Fatalf("query source session failed: %v", err)
	}
	if err := repository.RawDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM auth_sessions WHERE parent_session_id = ?`, token.SessionID).Scan(&childCount); err != nil {
		t.Fatalf("query child sessions failed: %v", err)
	}
	if err := repository.RawDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM auth_session_denylist_jobs WHERE session_id = ?`, token.SessionID).Scan(&jobCount); err != nil {
		t.Fatalf("query denylist jobs failed: %v", err)
	}
	if revokedAt != nil || childCount != 0 || jobCount != 0 {
		t.Fatalf("failed DB rotation changed durable state: revoked_at=%v children=%d jobs=%d", revokedAt, childCount, jobCount)
	}
}

func TestRefreshDenylistDeadlineKeepsHistoricalSevenDayFloor(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	cache := newMemoryDecisionCache()
	loginSvc := newAuthService(repository, &now, []int64{900046}, []string{"100046"}, authservice.WithIPDecisionCache(cache))
	token, _, err := loginSvc.LoginThirdParty(ctx, "wechat", "openid-short-access-ttl", thirdPartyRegistration("ios"), authservice.Meta{AppCode: "lalu"})
	if err != nil {
		t.Fatalf("login failed: %v", err)
	}

	// 模拟 access TTL 从历史 7 天降到 1 小时；撤销 deadline 仍须覆盖已签发的历史 token。
	shortTTLSvc := authservice.New(authservice.Config{
		Issuer:             "hyapp-test",
		AccessTokenTTLSec:  3600,
		RefreshTokenTTLSec: 2592000,
		SigningAlg:         "HS256",
		SigningSecret:      "test-secret",
	},
		authservice.WithAuthRepository(repository),
		authservice.WithUserRepository(repository),
		authservice.WithIdentityRepository(repository),
		authservice.WithCountryRegionRepository(repository),
		authservice.WithClock(func() time.Time { return now }),
		authservice.WithIPDecisionCache(cache),
	)
	if _, err := shortTTLSvc.RefreshToken(ctx, token.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RefreshRequestID: "short-ttl-refresh"}); err != nil {
		t.Fatalf("refresh with shorter current TTL failed: %v", err)
	}
	if got, want := cache.revTTL["lalu:"+token.SessionID], 7*24*time.Hour+time.Minute; got != want {
		t.Fatalf("historical access TTL floor was shortened: got=%s want=%s", got, want)
	}
}

func TestSessionDenylistHydrationPaginatesAllStatesAndFailsOnRedisError(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := time.UnixMilli(1000)
	const jobCount = 1001
	tx, err := repository.RawDB().BeginTx(ctx, nil)
	if err != nil {
		t.Fatalf("begin denylist seed transaction failed: %v", err)
	}
	statement, err := tx.PrepareContext(ctx, `
		INSERT INTO auth_session_denylist_jobs
		  (app_code, session_id, reason, status, attempts, next_retry_at_ms, locked_by, locked_until_ms, last_error, deny_until_ms, created_at_ms, updated_at_ms)
		VALUES ('lalu', ?, 'REFRESH_TOKEN_REUSE', ?, 0, ?, ?, ?, '', ?, ?, ?)
	`)
	if err != nil {
		_ = tx.Rollback()
		t.Fatalf("prepare denylist seed failed: %v", err)
	}
	statuses := []string{"delivered", "processing", "retryable"}
	for index := 0; index < jobCount; index++ {
		status := statuses[index%len(statuses)]
		lockedBy := ""
		lockedUntilMs := int64(0)
		if status == "processing" {
			lockedBy = "old-pod"
			lockedUntilMs = now.Add(time.Hour).UnixMilli()
		}
		// retryable 的 next_retry 在未来，证明 hydration 不受 worker 调度状态约束。
		if _, err := statement.ExecContext(ctx, fmt.Sprintf("hydrate-%04d", index), status, now.Add(time.Hour).UnixMilli(), lockedBy, lockedUntilMs, now.Add(8*24*time.Hour).UnixMilli(), now.UnixMilli(), now.UnixMilli()); err != nil {
			_ = statement.Close()
			_ = tx.Rollback()
			t.Fatalf("seed denylist job %d failed: %v", index, err)
		}
	}
	if err := statement.Close(); err != nil {
		_ = tx.Rollback()
		t.Fatalf("close denylist seed statement failed: %v", err)
	}
	if err := tx.Commit(); err != nil {
		t.Fatalf("commit denylist seeds failed: %v", err)
	}

	cache := newMemoryDecisionCache()
	svc := newAuthService(repository, &now, []int64{900047}, []string{"100047"}, authservice.WithIPDecisionCache(cache))
	processed, err := svc.RehydrateAllSessionDenylistJobs(ctx, 100)
	if err != nil || processed != jobCount {
		t.Fatalf("hydrate all denylist jobs failed: processed=%d err=%v", processed, err)
	}
	if got := len(cache.revoked); got != jobCount {
		t.Fatalf("hydration cursor skipped jobs: got=%d want=%d", got, jobCount)
	}
	for _, index := range []int{0, 500, 1000} {
		if got := cache.revoked[fmt.Sprintf("lalu:hydrate-%04d", index)]; got != "REFRESH_TOKEN_REUSE" {
			t.Fatalf("hydrated sid %d missing: %q", index, got)
		}
	}

	failingCache := newMemoryDecisionCache()
	failingCache.err = errors.New("redis unavailable")
	failingSvc := newAuthService(repository, &now, []int64{900048}, []string{"100048"}, authservice.WithIPDecisionCache(failingCache))
	processed, err = failingSvc.RehydrateAllSessionDenylistJobs(ctx, 100)
	if err == nil || processed != 0 {
		t.Fatalf("hydration must fail closed on Redis error: processed=%d err=%v", processed, err)
	}
}

func TestSessionDenylistDeliveredCASDoesNotHideExtendedDeadline(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	nowMs := int64(1000)
	oldDenyUntilMs := nowMs + int64(time.Hour/time.Millisecond)
	newDenyUntilMs := nowMs + int64((2*time.Hour)/time.Millisecond)
	result, err := repository.RawDB().ExecContext(ctx, `
		INSERT INTO auth_session_denylist_jobs
		  (app_code, session_id, reason, status, attempts, next_retry_at_ms, locked_by, locked_until_ms, last_error, deny_until_ms, created_at_ms, updated_at_ms)
		VALUES ('lalu', 'cas-extended-sid', 'REFRESH_ROTATED', 'processing', 1, 0, 'old-worker', 5000, '', ?, ?, ?)
	`, oldDenyUntilMs, nowMs, nowMs)
	if err != nil {
		t.Fatalf("seed claimed denylist job failed: %v", err)
	}
	jobID, err := result.LastInsertId()
	if err != nil {
		t.Fatalf("read denylist job id failed: %v", err)
	}
	// 模拟 worker 写 Redis 旧 TTL 的同时，另一事务把同 sid 延长并重新置 pending。
	if _, err := repository.RawDB().ExecContext(ctx, `
		UPDATE auth_session_denylist_jobs
		SET deny_until_ms = ?, reason = 'REFRESH_TOKEN_REUSE', status = 'pending', locked_by = '', locked_until_ms = 0
		WHERE job_id = ?
	`, newDenyUntilMs, jobID); err != nil {
		t.Fatalf("extend denylist deadline failed: %v", err)
	}
	if err := repository.MarkSessionDenylistJobDelivered(ctx, jobID, "lalu", "cas-extended-sid", "REFRESH_ROTATED", oldDenyUntilMs, nowMs, nowMs+1000); err != nil {
		t.Fatalf("stale delivered CAS failed: %v", err)
	}
	var status string
	var denyUntilMs int64
	if err := repository.RawDB().QueryRowContext(ctx, `SELECT status, deny_until_ms FROM auth_session_denylist_jobs WHERE job_id = ?`, jobID).Scan(&status, &denyUntilMs); err != nil {
		t.Fatalf("query extended denylist job failed: %v", err)
	}
	if status != "pending" || denyUntilMs != newDenyUntilMs {
		t.Fatalf("stale worker hid extended deadline: status=%s deny_until=%d want=%d", status, denyUntilMs, newDenyUntilMs)
	}
}

func TestRedisSessionDenylistTTLOnlyExtends(t *testing.T) {
	redisAddr := strings.TrimSpace(os.Getenv("HYAPP_TEST_REDIS_ADDR"))
	if redisAddr == "" {
		t.Skip("HYAPP_TEST_REDIS_ADDR is not configured")
	}
	ctx := context.Background()
	client := redis.NewClient(&redis.Options{Addr: redisAddr})
	t.Cleanup(func() { _ = client.Close() })
	if err := client.Ping(ctx).Err(); err != nil {
		t.Fatalf("connect test Redis failed: %v", err)
	}
	const key = "auth:revoked_session:lalu:ttl-only-extends"
	t.Cleanup(func() { _ = client.Del(context.Background(), key).Err() })
	if err := client.Del(ctx, key).Err(); err != nil {
		t.Fatalf("clear test denylist key failed: %v", err)
	}
	cache := authservice.NewRedisIPDecisionCache(client)
	if err := cache.SetRevokedSession(ctx, "lalu", "ttl-only-extends", "LONG", 10*time.Second); err != nil {
		t.Fatalf("write initial denylist TTL failed: %v", err)
	}
	if err := cache.SetRevokedSession(ctx, "lalu", "ttl-only-extends", "SHORT", time.Second); err != nil {
		t.Fatalf("write shorter denylist TTL failed: %v", err)
	}
	shortAttemptTTL, err := client.PTTL(ctx, key).Result()
	if err != nil {
		t.Fatalf("read denylist TTL after shorter attempt failed: %v", err)
	}
	value, err := client.Get(ctx, key).Result()
	if err != nil {
		t.Fatalf("read denylist reason after shorter attempt failed: %v", err)
	}
	if shortAttemptTTL < 9*time.Second || value != "LONG" {
		t.Fatalf("shorter stale write reduced protection: ttl=%s value=%q", shortAttemptTTL, value)
	}
	if err := cache.SetRevokedSession(ctx, "lalu", "ttl-only-extends", "EXTENDED", 20*time.Second); err != nil {
		t.Fatalf("extend denylist TTL failed: %v", err)
	}
	extendedTTL, err := client.PTTL(ctx, key).Result()
	if err != nil {
		t.Fatalf("read extended denylist TTL failed: %v", err)
	}
	value, err = client.Get(ctx, key).Result()
	if err != nil {
		t.Fatalf("read extended denylist reason failed: %v", err)
	}
	if extendedTTL < 19*time.Second || value != "EXTENDED" {
		t.Fatalf("longer write did not extend protection: ttl=%s value=%q", extendedTTL, value)
	}
	if epoch, err := cache.SessionDenylistEpoch(ctx); err != nil || strings.TrimSpace(epoch) == "" {
		t.Fatalf("Redis epoch must expose run_id for restart detection: epoch=%q err=%v", epoch, err)
	}
}

func TestRefreshRotationGraceReplayRevokesFamilyAndDurablyRetriesDenylist(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	cache := newMemoryDecisionCache()
	metrics := newMemoryRefreshMetrics()
	svc := newAuthService(repository, &now, []int64{900016}, []string{"100016"},
		authservice.WithIPDecisionCache(cache),
		authservice.WithRefreshMetricRecorder(metrics),
	)

	root, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-refresh-family", thirdPartyRegistration("ios"), authservice.Meta{AppCode: "lalu", RequestID: "req-login"})
	if err != nil {
		t.Fatalf("LoginThirdParty failed: %v", err)
	}
	now = now.Add(time.Second)
	first, err := svc.RefreshToken(ctx, root.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "trace-1", RefreshRequestID: "refresh-attempt-1"})
	if err != nil {
		t.Fatalf("first refresh failed: %v", err)
	}
	now = now.Add(10 * time.Second)
	grace, err := svc.RefreshToken(ctx, root.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "trace-2", RefreshRequestID: "refresh-attempt-network-retry"})
	if err != nil || grace.AccessToken != first.AccessToken || grace.RefreshToken != first.RefreshToken || grace.SessionID != first.SessionID {
		t.Fatalf("direct predecessor grace must reuse exact token pair: grace=%+v first=%+v err=%v", grace, first, err)
	}
	now = now.Add(time.Second)
	second, err := svc.RefreshToken(ctx, first.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "trace-3", RefreshRequestID: "refresh-attempt-2"})
	if err != nil {
		t.Fatalf("second generation refresh failed: %v", err)
	}

	var rootFamily string
	var rootGeneration int64
	var firstFamily string
	var firstGeneration int64
	var secondFamily string
	var secondGeneration int64
	if err := repository.RawDB().QueryRowContext(ctx, `SELECT token_family_id, generation FROM auth_sessions WHERE session_id = ?`, root.SessionID).Scan(&rootFamily, &rootGeneration); err != nil {
		t.Fatalf("query root lineage failed: %v", err)
	}
	if err := repository.RawDB().QueryRowContext(ctx, `SELECT token_family_id, generation FROM auth_sessions WHERE session_id = ?`, first.SessionID).Scan(&firstFamily, &firstGeneration); err != nil {
		t.Fatalf("query first lineage failed: %v", err)
	}
	if err := repository.RawDB().QueryRowContext(ctx, `SELECT token_family_id, generation FROM auth_sessions WHERE session_id = ?`, second.SessionID).Scan(&secondFamily, &secondGeneration); err != nil {
		t.Fatalf("query second lineage failed: %v", err)
	}
	if rootFamily != root.SessionID || firstFamily != rootFamily || secondFamily != rootFamily || rootGeneration != 0 || firstGeneration != 1 || secondGeneration != 2 {
		t.Fatalf("token family lineage mismatch: root=%s/%d first=%s/%d second=%s/%d", rootFamily, rootGeneration, firstFamily, firstGeneration, secondFamily, secondGeneration)
	}

	cache.err = errors.New("redis unavailable")
	now = now.Add(time.Second)
	if _, err := svc.RefreshToken(ctx, root.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "trace-reuse", RefreshRequestID: "refresh-attempt-reuse"}); !xerr.IsCode(err, xerr.Unavailable) {
		t.Fatalf("second-to-last token must revoke family and surface denylist outage: %v", err)
	}
	var activeReason string
	if err := repository.RawDB().QueryRowContext(ctx, `SELECT revoked_reason FROM auth_sessions WHERE session_id = ?`, second.SessionID).Scan(&activeReason); err != nil {
		t.Fatalf("query active generation after reuse failed: %v", err)
	}
	if activeReason != "REFRESH_TOKEN_REUSE" {
		t.Fatalf("active generation must be revoked on reuse, got %q", activeReason)
	}
	var pendingJobs int
	if err := repository.RawDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM auth_session_denylist_jobs WHERE app_code = 'lalu' AND reason = 'REFRESH_TOKEN_REUSE' AND status = 'pending'`).Scan(&pendingJobs); err != nil {
		t.Fatalf("query durable denylist jobs failed: %v", err)
	}
	if pendingJobs != 3 {
		t.Fatalf("family revoke must persist one denylist job per sid, got %d", pendingJobs)
	}
	processed, err := svc.ProcessSessionDenylistBatch(ctx, "denylist-test", 10)
	if err == nil || processed != 3 {
		t.Fatalf("denylist worker must move failed Redis writes to retryable: processed=%d err=%v", processed, err)
	}
	var retryableJobs int
	if err := repository.RawDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM auth_session_denylist_jobs WHERE app_code = 'lalu' AND reason = 'REFRESH_TOKEN_REUSE' AND status = 'retryable'`).Scan(&retryableJobs); err != nil {
		t.Fatalf("query retryable denylist jobs failed: %v", err)
	}
	if retryableJobs != 3 {
		t.Fatalf("failed compensation must leave all jobs retryable, got %d", retryableJobs)
	}
	cache.err = nil
	now = now.Add(3 * time.Second)
	processed, err = svc.ProcessSessionDenylistBatch(ctx, "denylist-test", 10)
	if err != nil || processed != 3 {
		t.Fatalf("denylist retry compensation failed: processed=%d err=%v", processed, err)
	}
	for _, sessionID := range []string{root.SessionID, first.SessionID, second.SessionID} {
		if got := cache.revoked["lalu:"+sessionID]; got != "REFRESH_TOKEN_REUSE" {
			t.Fatalf("family sid %s missing replay denylist, got %q", sessionID, got)
		}
	}
	var deliveredJobs int
	if err := repository.RawDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM auth_session_denylist_jobs WHERE app_code = 'lalu' AND reason = 'REFRESH_TOKEN_REUSE' AND status = 'delivered'`).Scan(&deliveredJobs); err != nil {
		t.Fatalf("query delivered denylist jobs failed: %v", err)
	}
	if deliveredJobs != 3 {
		t.Fatalf("all denylist jobs must be delivered, got %d", deliveredJobs)
	}
	var outcomeCount int
	if err := repository.RawDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM auth_refresh_outcomes WHERE app_code = 'lalu'`).Scan(&outcomeCount); err != nil {
		t.Fatalf("query refresh outcomes failed: %v", err)
	}
	if outcomeCount != 0 {
		t.Fatalf("family revoke must delete encrypted token outcomes, got %d", outcomeCount)
	}
	if metrics.count("lalu", "refresh_success") != 2 || metrics.count("lalu", "grace_hit") != 1 || metrics.count("lalu", "token_reuse") != 1 || metrics.count("lalu", "family_revoked") != 1 {
		t.Fatalf("refresh metrics mismatch: %+v", metrics.counts)
	}
}

func TestConcurrentRefreshRetriesReturnOnePersistedTokenPair(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	cache := newMemoryDecisionCache()
	metrics := newMemoryRefreshMetrics()
	svc := newAuthService(repository, &now, []int64{900019}, []string{"100019"},
		authservice.WithIPDecisionCache(cache),
		authservice.WithRefreshMetricRecorder(metrics),
	)

	root, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-concurrent-refresh", thirdPartyRegistration("ios"), authservice.Meta{AppCode: "lalu", RequestID: "req-login"})
	if err != nil {
		t.Fatalf("login failed: %v", err)
	}
	now = now.Add(time.Second)

	const parallel = 20
	start := make(chan struct{})
	results := make(chan authdomain.Token, parallel)
	errorsCh := make(chan error, parallel)
	var wg sync.WaitGroup
	for i := 0; i < parallel; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			<-start
			token, refreshErr := svc.RefreshToken(ctx, root.RefreshToken, "dev-ios", authservice.Meta{
				AppCode:          "lalu",
				RequestID:        "trace-concurrent-refresh",
				RefreshRequestID: "refresh-request-single-flight",
			})
			if refreshErr != nil {
				errorsCh <- refreshErr
				return
			}
			results <- token
		}()
	}
	close(start)
	wg.Wait()
	close(results)
	close(errorsCh)
	for refreshErr := range errorsCh {
		t.Fatalf("concurrent refresh failed: %v", refreshErr)
	}

	var canonical authdomain.Token
	count := 0
	for token := range results {
		if count == 0 {
			canonical = token
		} else if token.AccessToken != canonical.AccessToken || token.RefreshToken != canonical.RefreshToken || token.SessionID != canonical.SessionID {
			t.Fatalf("concurrent retry forked token pair: canonical=%+v got=%+v", canonical, token)
		}
		count++
	}
	if count != parallel {
		t.Fatalf("concurrent result count mismatch: got=%d want=%d", count, parallel)
	}

	var childCount int
	if err := repository.RawDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM auth_sessions WHERE app_code = 'lalu' AND parent_session_id = ?`, root.SessionID).Scan(&childCount); err != nil {
		t.Fatalf("query refresh child count failed: %v", err)
	}
	if childCount != 1 {
		t.Fatalf("concurrent refresh must create exactly one child, got %d", childCount)
	}
	var outcomeCount int
	if err := repository.RawDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM auth_refresh_outcomes WHERE app_code = 'lalu' AND parent_session_id = ?`, root.SessionID).Scan(&outcomeCount); err != nil {
		t.Fatalf("query refresh outcome count failed: %v", err)
	}
	if outcomeCount != 1 {
		t.Fatalf("concurrent refresh must persist exactly one canonical outcome, got %d", outcomeCount)
	}
	if metrics.count("lalu", "refresh_success") != 1 || metrics.count("lalu", "grace_hit") != parallel-1 {
		t.Fatalf("concurrent refresh metrics mismatch: %+v", metrics.counts)
	}
}

func TestRefreshGraceUsesPersistedWinnerDeadlineAcrossConfigRollout(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	cache := newMemoryDecisionCache()
	winner := newAuthService(repository, &now, []int64{900043}, []string{"100043"}, authservice.WithIPDecisionCache(cache))

	root, _, err := winner.LoginThirdParty(ctx, "wechat", "openid-refresh-grace-config-rollout", thirdPartyRegistration("ios"), authservice.Meta{AppCode: "lalu"})
	if err != nil {
		t.Fatalf("login failed: %v", err)
	}
	now = now.Add(time.Second)
	first, err := winner.RefreshToken(ctx, root.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RefreshRequestID: "winner-30s-grace"})
	if err != nil {
		t.Fatalf("winner refresh failed: %v", err)
	}

	// retry 实例模拟滚动配置从 30s 降到 10s；首次事务固化的 30s deadline 才是这次 token pair 的唯一窗口事实。
	retry := authservice.New(authservice.Config{
		Issuer:                  "hyapp-test",
		AccessTokenTTLSec:       testAccessTokenTTLSec,
		RefreshTokenTTLSec:      2592000,
		RefreshRotationGraceSec: 10,
		SigningAlg:              "HS256",
		SigningSecret:           "test-secret",
	},
		authservice.WithAuthRepository(repository),
		authservice.WithClock(func() time.Time { return now }),
		authservice.WithIPDecisionCache(cache),
	)
	now = now.Add(5 * time.Second)
	grace, err := retry.RefreshToken(ctx, root.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RefreshRequestID: "retry-on-10s-instance"})
	if err != nil || grace.AccessToken != first.AccessToken || grace.RefreshToken != first.RefreshToken || grace.SessionID != first.SessionID {
		t.Fatalf("retry must trust persisted winner deadline and return exact pair: grace=%+v first=%+v err=%v", grace, first, err)
	}
}

func TestLegacyActiveSessionFirstRotationUsesItselfAsFamilyRoot(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	cache := newMemoryDecisionCache()
	svc := newAuthService(repository, &now, []int64{900040}, []string{"100040"}, authservice.WithIPDecisionCache(cache))

	legacy, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-legacy-refresh-family", thirdPartyRegistration("ios"), authservice.Meta{AppCode: "lalu"})
	if err != nil {
		t.Fatalf("login failed: %v", err)
	}
	// 模拟 018 上线前已存在的 active 行：新增列默认空值，首次新版轮换必须原地升级 lineage，不能另起错误 family。
	if _, err := repository.RawDB().ExecContext(ctx, `
		UPDATE auth_sessions
		SET token_family_id = '', generation = 0, parent_session_id = '', rotated_to_session_id = '', rotation_at_ms = 0, rotation_request_id = ''
		WHERE app_code = 'lalu' AND session_id = ?
	`, legacy.SessionID); err != nil {
		t.Fatalf("downgrade session to legacy lineage failed: %v", err)
	}

	now = now.Add(time.Second)
	child, err := svc.RefreshToken(ctx, legacy.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RefreshRequestID: "legacy-first-refresh"})
	if err != nil {
		t.Fatalf("legacy first rotation failed: %v", err)
	}
	var parentFamily string
	var childFamily string
	var childGeneration int64
	var childParent string
	if err := repository.RawDB().QueryRowContext(ctx, `SELECT token_family_id FROM auth_sessions WHERE session_id = ?`, legacy.SessionID).Scan(&parentFamily); err != nil {
		t.Fatalf("query upgraded legacy parent failed: %v", err)
	}
	if err := repository.RawDB().QueryRowContext(ctx, `SELECT token_family_id, generation, parent_session_id FROM auth_sessions WHERE session_id = ?`, child.SessionID).Scan(&childFamily, &childGeneration, &childParent); err != nil {
		t.Fatalf("query legacy child lineage failed: %v", err)
	}
	if parentFamily != legacy.SessionID || childFamily != legacy.SessionID || childGeneration != 1 || childParent != legacy.SessionID {
		t.Fatalf("legacy lineage upgrade mismatch: parent_family=%q child_family=%q generation=%d parent=%q root=%q", parentFamily, childFamily, childGeneration, childParent, legacy.SessionID)
	}
}

func TestLegacyBinaryRotationReplayDoesNotRevokeUnprovenNewSessions(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	cache := newMemoryDecisionCache()
	metrics := newMemoryRefreshMetrics()
	svc := newAuthService(repository, &now, []int64{900042}, []string{"100042"},
		authservice.WithIPDecisionCache(cache),
		authservice.WithRefreshMetricRecorder(metrics),
	)

	root, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-legacy-binary-rotation", thirdPartyRegistration("ios"), authservice.Meta{AppCode: "lalu"})
	if err != nil {
		t.Fatalf("ios login failed: %v", err)
	}
	android, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-legacy-binary-rotation", thirdPartyRegistration("android"), authservice.Meta{AppCode: "lalu"})
	if err != nil {
		t.Fatalf("android login failed: %v", err)
	}

	const legacyChildID = "sess_legacy_binary_child"
	const legacyRotationAtMs = int64(2000)
	// 精确模拟迁移后仍在滚动运行的旧 ReplaceSession：parent 只写 REFRESH_ROTATED，child 的六个 lineage 列全走 DDL 默认值。
	if _, err := repository.RawDB().ExecContext(ctx, `
		UPDATE auth_sessions
		SET token_family_id = '', revoked_at_ms = ?, revoked_reason = 'REFRESH_ROTATED', revoked_by = 'refresh_token',
		    rotated_to_session_id = '', rotation_at_ms = 0, rotation_request_id = '', updated_at_ms = ?
		WHERE app_code = 'lalu' AND session_id = ?
	`, legacyRotationAtMs, legacyRotationAtMs, root.SessionID); err != nil {
		t.Fatalf("simulate legacy parent rotation failed: %v", err)
	}
	if _, err := repository.RawDB().ExecContext(ctx, `
		INSERT INTO auth_sessions
		  (app_code, session_id, user_id, refresh_token_hash, token_family_id, generation, parent_session_id,
		   rotated_to_session_id, rotation_at_ms, rotation_request_id, device_id, expires_at_ms,
		   last_heartbeat_at_ms, last_heartbeat_request_id, revoked_at_ms, revoked_reason, revoked_request_id,
		   revoked_by, created_at_ms, updated_at_ms)
		SELECT app_code, ?, user_id, 'legacy_binary_child_refresh_hash', '', 0, '', '', 0, '', device_id, expires_at_ms,
		       0, '', NULL, '', '', '', ?, ?
		FROM auth_sessions WHERE app_code = 'lalu' AND session_id = ?
	`, legacyChildID, legacyRotationAtMs, legacyRotationAtMs, root.SessionID); err != nil {
		t.Fatalf("simulate legacy child insert failed: %v", err)
	}

	now = time.UnixMilli(3000)
	if _, err := svc.RefreshToken(ctx, root.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RefreshRequestID: "legacy-parent-replay"}); !xerr.IsCode(err, xerr.SessionRevoked) {
		t.Fatalf("legacy parent replay must reject the old token, got %v", err)
	}
	var childRevokedAt *int64
	var childReason string
	if err := repository.RawDB().QueryRowContext(ctx, `SELECT revoked_at_ms, revoked_reason FROM auth_sessions WHERE session_id = ?`, legacyChildID).Scan(&childRevokedAt, &childReason); err != nil {
		t.Fatalf("query unproven legacy child failed: %v", err)
	}
	if childRevokedAt != nil || childReason != "" {
		t.Fatalf("unproven successor must remain active: revoked_at=%v reason=%q", childRevokedAt, childReason)
	}
	for _, sessionID := range []string{root.SessionID, legacyChildID} {
		if got, ok := cache.revoked["lalu:"+sessionID]; ok {
			t.Fatalf("legacy replay without provable lineage must not create denylist for sid %s, got %q", sessionID, got)
		}
	}
	if metrics.count("lalu", "token_reuse") != 0 || metrics.count("lalu", "family_revoked") != 0 {
		t.Fatalf("unproven legacy replay must not emit family-reuse metrics: %+v", metrics.counts)
	}
	// 显式 logout 也不能从 legacy root 按 user/device 猜 successor；它只撤销已锁定 source。
	if _, err := svc.Logout(ctx, root.SessionID, "", authservice.Meta{AppCode: "lalu", RequestID: "legacy-root-logout"}); err != nil {
		t.Fatalf("legacy root logout failed: %v", err)
	}
	childRevokedAt = nil
	childReason = ""
	if err := repository.RawDB().QueryRowContext(ctx, `SELECT revoked_at_ms, revoked_reason FROM auth_sessions WHERE session_id = ?`, legacyChildID).Scan(&childRevokedAt, &childReason); err != nil {
		t.Fatalf("query unproven child after logout failed: %v", err)
	}
	if childRevokedAt != nil || childReason != "" {
		t.Fatalf("legacy logout must not revoke unproven successor: revoked_at=%v reason=%q", childRevokedAt, childReason)
	}
	if _, ok := cache.revoked["lalu:"+legacyChildID]; ok {
		t.Fatal("legacy logout must not denylist unproven successor")
	}
	if _, ok := cache.revoked["lalu:"+android.SessionID]; ok {
		t.Fatal("legacy replay must not revoke another device")
	}
	if _, err := svc.RefreshToken(ctx, android.RefreshToken, "dev-android", authservice.Meta{AppCode: "lalu", RefreshRequestID: "android-after-legacy-replay"}); err != nil {
		t.Fatalf("other device must remain refreshable after legacy replay: %v", err)
	}
}

func TestCorrectRefreshTokenOnWrongDeviceRevokesItsFamilyAsReuse(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	cache := newMemoryDecisionCache()
	metrics := newMemoryRefreshMetrics()
	svc := newAuthService(repository, &now, []int64{900041}, []string{"100041"},
		authservice.WithIPDecisionCache(cache),
		authservice.WithRefreshMetricRecorder(metrics),
	)

	root, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-refresh-wrong-device", thirdPartyRegistration("ios"), authservice.Meta{AppCode: "lalu"})
	if err != nil {
		t.Fatalf("login failed: %v", err)
	}
	now = now.Add(time.Second)
	if _, err := svc.RefreshToken(ctx, root.RefreshToken, "dev-android", authservice.Meta{AppCode: "lalu", RefreshRequestID: "wrong-device-replay"}); !xerr.IsCode(err, xerr.SessionRevoked) {
		t.Fatalf("correct refresh token on another device must be treated as reuse, got %v", err)
	}
	var revokedReason string
	if err := repository.RawDB().QueryRowContext(ctx, `SELECT revoked_reason FROM auth_sessions WHERE session_id = ?`, root.SessionID).Scan(&revokedReason); err != nil {
		t.Fatalf("query wrong-device family revoke failed: %v", err)
	}
	if revokedReason != "REFRESH_TOKEN_REUSE" {
		t.Fatalf("wrong-device refresh must revoke token family as reuse, got %q", revokedReason)
	}
	if got := cache.revoked["lalu:"+root.SessionID]; got != "REFRESH_TOKEN_REUSE" {
		t.Fatalf("wrong-device reuse must denylist family access sid, got %q", got)
	}
	if metrics.count("lalu", "token_reuse") != 1 || metrics.count("lalu", "family_revoked") != 1 {
		t.Fatalf("wrong-device reuse metrics mismatch: %+v", metrics.counts)
	}
}

func TestLogoutUsingRotatedParentRevokesOnlyThatDeviceFamily(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	cache := newMemoryDecisionCache()
	svc := newAuthService(repository, &now, []int64{900017}, []string{"100017"}, authservice.WithIPDecisionCache(cache))

	iosRoot, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-logout-family", thirdPartyRegistration("ios"), authservice.Meta{AppCode: "lalu", RequestID: "req-ios-login"})
	if err != nil {
		t.Fatalf("ios login failed: %v", err)
	}
	androidRegistration := thirdPartyRegistration("android")
	androidRoot, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-logout-family", androidRegistration, authservice.Meta{AppCode: "lalu", RequestID: "req-android-login"})
	if err != nil {
		t.Fatalf("android login failed: %v", err)
	}
	now = now.Add(time.Second)
	iosChild, err := svc.RefreshToken(ctx, iosRoot.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RefreshRequestID: "ios-refresh"})
	if err != nil {
		t.Fatalf("ios refresh failed: %v", err)
	}

	revoked, err := svc.Logout(ctx, iosRoot.SessionID, iosRoot.RefreshToken, authservice.Meta{AppCode: "lalu", RequestID: "logout-parent"})
	if err != nil || !revoked {
		t.Fatalf("logout by rotated parent failed: revoked=%v err=%v", revoked, err)
	}
	if _, err := svc.RefreshToken(ctx, iosChild.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RefreshRequestID: "ios-after-logout"}); !xerr.IsCode(err, xerr.SessionRevoked) {
		t.Fatalf("rotated child must be revoked by parent logout, got %v", err)
	}
	for _, sessionID := range []string{iosRoot.SessionID, iosChild.SessionID} {
		if got := cache.revoked["lalu:"+sessionID]; got != "USER_LOGOUT" {
			t.Fatalf("ios family sid %s missing logout denylist, got %q", sessionID, got)
		}
	}
	if _, ok := cache.revoked["lalu:"+androidRoot.SessionID]; ok {
		t.Fatal("logout must not revoke a different device token family")
	}
	now = now.Add(time.Second)
	if _, err := svc.RefreshToken(ctx, androidRoot.RefreshToken, "dev-android", authservice.Meta{AppCode: "lalu", RefreshRequestID: "android-refresh"}); err != nil {
		t.Fatalf("different device family must remain refreshable: %v", err)
	}
}

func TestDirectParentRefreshAfterGraceRevokesFamily(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	cache := newMemoryDecisionCache()
	svc := newAuthService(repository, &now, []int64{900018}, []string{"100018"}, authservice.WithIPDecisionCache(cache))
	root, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-refresh-grace-expired", thirdPartyRegistration("ios"), authservice.Meta{AppCode: "lalu"})
	if err != nil {
		t.Fatalf("login failed: %v", err)
	}
	now = now.Add(time.Second)
	child, err := svc.RefreshToken(ctx, root.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RefreshRequestID: "first"})
	if err != nil {
		t.Fatalf("first refresh failed: %v", err)
	}
	now = now.Add(31 * time.Second)
	if _, err := svc.RefreshToken(ctx, root.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RefreshRequestID: "late-retry"}); !xerr.IsCode(err, xerr.SessionRevoked) {
		t.Fatalf("direct parent after grace must be treated as reuse, got %v", err)
	}
	var reason string
	if err := repository.RawDB().QueryRowContext(ctx, `SELECT revoked_reason FROM auth_sessions WHERE session_id = ?`, child.SessionID).Scan(&reason); err != nil {
		t.Fatalf("query child revoke reason failed: %v", err)
	}
	if reason != "REFRESH_TOKEN_REUSE" {
		t.Fatalf("late predecessor retry must revoke active child, got %q", reason)
	}
}

func TestRefreshAndLogoutRequireSessionDenylistCache(t *testing.T) {
	// 168 小时 access token 下，缺少 gateway denylist 写入能力时不能假装 refresh/logout 成功。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	svc := newAuthService(repository, &now, []int64{900014}, []string{"100014"}, authservice.WithIPDecisionCache(nil))

	token, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-missing-denylist", thirdPartyRegistration("ios"), authservice.Meta{AppCode: "lalu", RequestID: "req-login"})
	if err != nil {
		t.Fatalf("LoginThirdParty failed: %v", err)
	}
	if _, err := svc.RefreshToken(ctx, token.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "req-refresh"}); !xerr.IsCode(err, xerr.Unavailable) {
		t.Fatalf("RefreshToken must fail without session denylist cache, got %v", err)
	}
	if revoked, err := svc.Logout(ctx, token.SessionID, token.RefreshToken, authservice.Meta{AppCode: "lalu", RequestID: "req-logout"}); err == nil || !xerr.IsCode(err, xerr.Unavailable) || revoked {
		t.Fatalf("Logout must fail without session denylist cache, revoked=%v err=%v", revoked, err)
	}
}

func TestLogoutByRefreshTokenUsesSessionAppCodeForDenylist(t *testing.T) {
	// logout 是公开接口，旧客户端可能只带 refresh_token；denylist 分区必须来自持久化 session，而不是依赖请求头。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	cache := newMemoryDecisionCache()
	svc := newAuthService(repository, &now, []int64{900015}, []string{"100015"}, authservice.WithIPDecisionCache(cache))

	token, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-refresh-only-logout", thirdPartyRegistration("ios"), authservice.Meta{AppCode: "lalu", RequestID: "req-login"})
	if err != nil {
		t.Fatalf("LoginThirdParty failed: %v", err)
	}

	revoked, err := svc.Logout(ctx, "", token.RefreshToken, authservice.Meta{RequestID: "req-logout-refresh-only"})
	if err != nil || !revoked {
		t.Fatalf("Logout by refresh token failed: revoked=%v err=%v", revoked, err)
	}
	if got := cache.revoked["lalu:"+token.SessionID]; got != "USER_LOGOUT" {
		t.Fatalf("logout must use session app_code for denylist, got %q", got)
	}
	if _, ok := cache.revoked[":"+token.SessionID]; ok {
		t.Fatal("logout must not write denylist under empty app_code")
	}
}

func TestCompleteOnboardingEmitsUserRegisteredOutboxWithSelectedCountry(t *testing.T) {
	// UserRegistered 是“注册页资料完成”事实，不是三方登录占位账号创建事实；统计国家必须来自最终提交的国家。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	country := seedCountry(t, repository, "AE")
	region := mustResolveRegionByCountry(t, repository, "AE")
	loginAt := time.UnixMilli(1000)
	authSvc := newAuthService(repository, &loginAt, []int64{900031}, []string{"100031"})

	token, isNewUser, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-onboarding-register", authdomain.ThirdPartyRegistration{
		DeviceID: "dev-ios",
		Platform: "ios",
	}, authservice.Meta{RequestID: "req-third-create"})
	if err != nil {
		t.Fatalf("LoginThirdParty failed: %v", err)
	}
	if !isNewUser || token.ProfileCompleted {
		t.Fatalf("third-party login should create profile-required user: token=%+v isNew=%v", token, isNewUser)
	}
	assertUserRegisteredOutboxCount(t, repository, token.UserID, 0)

	completedAtMS := int64(2500)
	userSvc := newUserService(repository, userservice.WithClock(func() time.Time { return time.UnixMilli(completedAtMS).UTC() }))
	user, err := userSvc.CompleteOnboarding(ctx, token.UserID, "Amina", "https://cdn.example/amina.png", "female", "ae", "", "req-complete-onboarding")
	if err != nil {
		t.Fatalf("CompleteOnboarding failed: %v", err)
	}
	if !user.ProfileCompleted || user.Country != "AE" || user.RegionID != region.RegionID || user.ProfileCompletedAtMs != completedAtMS {
		t.Fatalf("completed user snapshot mismatch: %+v", user)
	}
	assertUserRegisteredOutboxCount(t, repository, token.UserID, 1)
	assertUserRegisteredOutboxFact(t, repository, token.UserID, country.CountryID, region.RegionID, completedAtMS)
}

func TestQuickCreateAccountCreatesCompletedPasswordLogin(t *testing.T) {
	// 快捷建号必须一次性得到可登录短号、已完成资料快照和密码身份，服务游戏联调直接取 uid/token。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(2000)
	imImporter := &fakeIMAccountImporter{}
	svc := newAuthService(repository, &now, []int64{900011}, []string{"100011"}, authservice.WithIMAccountImporter(imImporter))

	token, err := svc.QuickCreateAccount(ctx, "secret-pass", authdomain.ThirdPartyRegistration{
		Username:   "Lingxian Test",
		Avatar:     "https://cdn.example/avatar.png",
		Gender:     "unknown",
		Country:    "SG",
		DeviceID:   "quick-dev",
		Platform:   "ios",
		Language:   "en-US",
		Timezone:   "Asia/Shanghai",
		AppVersion: "1.0.0",
	}, authservice.Meta{RequestID: "req-quick-create", AppCode: "lalu", ClientIP: "203.0.113.10", UserAgent: "quick-test/1.0"})
	if err != nil {
		t.Fatalf("QuickCreateAccount failed: %v", err)
	}
	assertSevenDigitDisplayUserID(t, token.DisplayUserID)
	if token.UserID != 900011 || token.DisplayUserID == "" || token.AccessToken == "" || token.RefreshToken == "" {
		t.Fatalf("unexpected quick token: %+v", token)
	}
	if token.ExpiresInSec != testAccessTokenTTLSec {
		t.Fatalf("quick account access token TTL mismatch: %+v", token)
	}
	if !token.ProfileCompleted || token.OnboardingStatus != string(userdomain.OnboardingStatusCompleted) {
		t.Fatalf("quick account must be completed: %+v", token)
	}
	user, err := repository.GetUser(ctx, token.UserID)
	if err != nil {
		t.Fatalf("GetUser failed: %v", err)
	}
	if user.Username != "Lingxian Test" || user.Country != "SG" || !user.ProfileCompleted {
		t.Fatalf("quick user profile mismatch: %+v", user)
	}
	if imImporter.userID != 900011 || imImporter.nickname != "Lingxian Test" || imImporter.faceURL != "https://cdn.example/avatar.png" {
		t.Fatalf("quick account must import IM account: %+v", imImporter)
	}
	assertUserRegisteredOutboxCount(t, repository, token.UserID, 0)

	loginToken, err := svc.LoginPassword(ctx, token.DisplayUserID, "secret-pass", "quick-dev", authservice.Meta{RequestID: "req-quick-login", AppCode: "lalu"})
	if err != nil {
		t.Fatalf("LoginPassword for quick account failed: %v", err)
	}
	if loginToken.UserID != token.UserID || loginToken.DisplayUserID != token.DisplayUserID || !loginToken.ProfileCompleted {
		t.Fatalf("quick account login token mismatch: %+v", loginToken)
	}
}

func TestQuickCreateGameRobotCannotLoginOrCreateSession(t *testing.T) {
	// 全站机器人复用用户资料和短号展示，但不是 App 登录主体；创建路径不能返回可用 token，也不能落 refresh session。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "SA")
	now := time.UnixMilli(3000)
	imImporter := &fakeIMAccountImporter{}
	svc := newAuthService(repository, &now, []int64{900012}, []string{"100012"}, authservice.WithIMAccountImporter(imImporter))

	token, err := svc.QuickCreateAccount(ctx, "secret-pass", authdomain.ThirdPartyRegistration{
		Username:   "Robot Account",
		Avatar:     "https://cdn.example/robot.png",
		Gender:     "unknown",
		Country:    "SA",
		DeviceID:   "robot-dev",
		Source:     userdomain.RegisterSourceGameRobot,
		Platform:   "android",
		Language:   "ar",
		Timezone:   "UTC",
		AppVersion: "1.0.0",
	}, authservice.Meta{RequestID: "req-robot-create", AppCode: "lalu", ClientIP: "203.0.113.20", UserAgent: "robot-test/1.0"})
	if err != nil {
		t.Fatalf("QuickCreateAccount robot failed: %v", err)
	}
	assertSevenDigitDisplayUserID(t, token.DisplayUserID)
	if token.UserID != 900012 || token.DisplayUserID == "" || token.AccessToken != "" || token.RefreshToken != "" || token.SessionID != "" {
		t.Fatalf("robot token must expose identity without login credentials: %+v", token)
	}
	user, err := repository.GetUser(ctx, token.UserID)
	if err != nil {
		t.Fatalf("GetUser robot failed: %v", err)
	}
	if user.RegisterSource != userdomain.RegisterSourceGameRobot || user.CanLogin() {
		t.Fatalf("robot user source/login boundary mismatch: %+v", user)
	}
	if imImporter.userID != 0 {
		t.Fatalf("robot account must not import IM login account: %+v", imImporter)
	}
	var sessionCount int
	if err := repository.RawDB().QueryRowContext(ctx, `
		SELECT COUNT(*)
		FROM auth_sessions
		WHERE app_code = 'lalu' AND user_id = ?
	`, token.UserID).Scan(&sessionCount); err != nil {
		t.Fatalf("query robot sessions failed: %v", err)
	}
	if sessionCount != 0 {
		t.Fatalf("robot account must not create auth session, got %d", sessionCount)
	}
	assertUserRegisteredOutboxCount(t, repository, token.UserID, 0)
	if _, err := svc.LoginPassword(ctx, token.DisplayUserID, "secret-pass", "robot-dev", authservice.Meta{RequestID: "req-robot-login", AppCode: "lalu"}); !xerr.IsCode(err, xerr.UserDisabled) {
		t.Fatalf("robot password login must be blocked, got %v", err)
	}
}

func TestGameRobotSourceDoesNotEmitUserRegisteredOutbox(t *testing.T) {
	// UserRegistered 是新增用户和留存 cohort 的事实来源；source=game_robot 即使走通用用户创建事务也不能写入统计 outbox。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := time.UnixMilli(4000)
	robot := userdomain.User{
		AppCode:                  "lalu",
		UserID:                   900101,
		DefaultDisplayUserID:     "190101",
		CurrentDisplayUserID:     "190101",
		CurrentDisplayUserIDKind: userdomain.DisplayUserIDKindDefault,
		RegisterSource:           userdomain.RegisterSourceGameRobot,
		Status:                   userdomain.StatusActive,
		CreatedAtMs:              now.UnixMilli(),
		UpdatedAtMs:              now.UnixMilli(),
	}
	normal := userdomain.User{
		AppCode:                  "lalu",
		UserID:                   900102,
		DefaultDisplayUserID:     "190102",
		CurrentDisplayUserID:     "190102",
		CurrentDisplayUserIDKind: userdomain.DisplayUserIDKindDefault,
		Status:                   userdomain.StatusActive,
		CreatedAtMs:              now.Add(time.Millisecond).UnixMilli(),
		UpdatedAtMs:              now.Add(time.Millisecond).UnixMilli(),
	}
	if err := repository.CreateUserWithIdentity(ctx, robot, userdomain.Identity{
		AppCode:              "lalu",
		UserID:               robot.UserID,
		DisplayUserID:        robot.DefaultDisplayUserID,
		DefaultDisplayUserID: robot.DefaultDisplayUserID,
		DisplayUserIDKind:    userdomain.DisplayUserIDKindDefault,
		Status:               userdomain.DisplayUserIDStatusActive,
	}); err != nil {
		t.Fatalf("create robot user failed: %v", err)
	}
	if err := repository.CreateUserWithIdentity(ctx, normal, userdomain.Identity{
		AppCode:              "lalu",
		UserID:               normal.UserID,
		DisplayUserID:        normal.DefaultDisplayUserID,
		DefaultDisplayUserID: normal.DefaultDisplayUserID,
		DisplayUserIDKind:    userdomain.DisplayUserIDKindDefault,
		Status:               userdomain.DisplayUserIDStatusActive,
	}); err != nil {
		t.Fatalf("create normal user failed: %v", err)
	}
	var outboxCount int
	var aggregateID int64
	if err := repository.RawDB().QueryRowContext(ctx, `
		SELECT COUNT(*), COALESCE(MAX(aggregate_id), 0)
		FROM user_outbox
		WHERE app_code = 'lalu' AND event_type = 'UserRegistered'
	`).Scan(&outboxCount, &aggregateID); err != nil {
		t.Fatalf("query user registered outbox failed: %v", err)
	}
	if outboxCount != 1 || aggregateID != normal.UserID {
		t.Fatalf("robot source must be excluded from UserRegistered outbox, count=%d aggregate=%d", outboxCount, aggregateID)
	}
}

func TestRefreshTokenCarriesProfileRequiredOnboardingStatus(t *testing.T) {
	// gateway profile gate 依赖 access token 快照；未完成 onboarding 的新用户刷新后仍必须保持资料待补态。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	authSvc := newAuthService(repository, &now, []int64{900009}, []string{"100009"})

	token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-onboarding", thirdPartyRegistration("ios"), authservice.Meta{RequestID: "req-login"})
	if err != nil {
		t.Fatalf("LoginThirdParty failed: %v", err)
	}
	if token.ProfileCompleted || token.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) {
		t.Fatalf("new token should require onboarding: %+v", token)
	}

	now = time.UnixMilli(3000)
	refreshed, err := authSvc.RefreshToken(ctx, token.RefreshToken, "dev-ios", authservice.Meta{RequestID: "req-refresh"})
	if err != nil {
		t.Fatalf("RefreshToken failed: %v", err)
	}
	if refreshed.ProfileCompleted || refreshed.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) {
		t.Fatalf("refreshed token must carry profile-required onboarding: %+v", refreshed)
	}
}

func TestLoginIPRiskWorkerRevokesBlockedCountrySession(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "CN")
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	cache := newMemoryDecisionCache()
	authSvc := newAuthService(repository, &now, []int64{900020}, []string{"100020"},
		authservice.WithLoginRiskPolicy(authservice.LoginRiskPolicy{
			Enabled:              true,
			UnsupportedCountries: []string{"CN"},
			BlockedTTL:           time.Hour,
			AllowedTTL:           time.Hour,
			UnknownTTL:           time.Minute,
			ProviderTimeout:      50 * time.Millisecond,
			ProviderNames:        []string{"edge_country"},
			DenylistTTL:          time.Hour,
		}),
		authservice.WithIPDecisionCache(cache),
		authservice.WithIPGeoProviders(authservice.BuildIPGeoProviders([]string{"edge_country"})),
	)

	token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-risk-blocked", thirdPartyRegistration("ios"), authservice.Meta{
		AppCode:     "lalu",
		RequestID:   "req-risk-blocked",
		ClientIP:    "203.0.113.77",
		CountryByIP: "CN",
		Platform:    "ios",
		Language:    "en-US",
		Timezone:    "America/Los_Angeles",
	})
	if err != nil {
		t.Fatalf("LoginThirdParty failed: %v", err)
	}

	now = time.UnixMilli(2000)
	processed, err := authSvc.ProcessLoginIPRiskBatch(ctx, authservice.LoginIPRiskWorkerOptions{
		WorkerID:  "risk-test",
		LockTTL:   time.Second,
		BatchSize: 10,
	})
	if err != nil || processed != 1 {
		t.Fatalf("risk worker mismatch: processed=%d err=%v", processed, err)
	}
	if _, err := authSvc.RefreshToken(ctx, token.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "req-refresh-revoked"}); !xerr.IsCode(err, xerr.SessionRevoked) {
		t.Fatalf("expected revoked refresh session, got %v", err)
	}
	if got := cache.revoked["lalu:"+token.SessionID]; got != "GEO_POLICY_BLOCKED" {
		t.Fatalf("revoked denylist mismatch: %q", got)
	}
	decision, ok := cache.ip["lalu:"+authservice.HashLoginRiskIP("203.0.113.77")]
	if !ok || decision.Decision != "blocked" || decision.Country != "CN" {
		t.Fatalf("blocked IP cache mismatch: ok=%v decision=%+v", ok, decision)
	}

	var result string
	var blocked bool
	var blockReason string
	err = repository.RawDB().QueryRowContext(ctx, `
		SELECT result, blocked, block_reason
		FROM login_audit
		WHERE app_code = 'lalu' AND request_id = 'req-risk-blocked' AND user_id = ?
	`, token.UserID).Scan(&result, &blocked, &blockReason)
	if err != nil {
		t.Fatalf("query login audit failed: %v", err)
	}
	if result != "revoked" || !blocked || blockReason != "ip_async" {
		t.Fatalf("login audit risk mark mismatch: result=%s blocked=%v reason=%s", result, blocked, blockReason)
	}
}

func TestLoginIPRiskIPWhitelistBypassesBlockedCountry(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "CN")
	now := time.UnixMilli(1000)
	if _, err := repository.RawDB().ExecContext(ctx, `
		INSERT INTO login_risk_ip_whitelist (
			app_code, ip_address, enabled, created_at_ms, updated_at_ms
		)
		VALUES ('lalu', '203.0.113.80', 1, 1000, 1000)
	`); err != nil {
		t.Fatalf("seed ip whitelist failed: %v", err)
	}
	cache := newMemoryDecisionCache()
	authSvc := newAuthService(repository, &now, []int64{900024}, []string{"100024"},
		authservice.WithLoginRiskPolicy(authservice.LoginRiskPolicy{
			Enabled:              true,
			UnsupportedCountries: []string{"CN"},
			BlockedTTL:           time.Hour,
			AllowedTTL:           time.Hour,
			UnknownTTL:           time.Minute,
			ProviderTimeout:      50 * time.Millisecond,
			ProviderNames:        []string{"edge_country"},
			DenylistTTL:          time.Hour,
		}),
		authservice.WithIPDecisionCache(cache),
		authservice.WithIPGeoProviders(authservice.BuildIPGeoProviders([]string{"edge_country"})),
	)

	token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-risk-whitelist", thirdPartyRegistration("ios"), authservice.Meta{
		AppCode:     "lalu",
		RequestID:   "req-risk-whitelist",
		ClientIP:    "203.0.113.80",
		CountryByIP: "CN",
		Platform:    "ios",
	})
	if err != nil {
		t.Fatalf("LoginThirdParty failed: %v", err)
	}

	now = time.UnixMilli(2000)
	processed, err := authSvc.ProcessLoginIPRiskBatch(ctx, authservice.LoginIPRiskWorkerOptions{WorkerID: "risk-whitelist-test", LockTTL: time.Second, BatchSize: 10})
	if err != nil || processed != 1 {
		t.Fatalf("risk worker mismatch: processed=%d err=%v", processed, err)
	}
	if _, err := authSvc.RefreshToken(ctx, token.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "req-refresh-whitelist"}); err != nil {
		t.Fatalf("whitelisted IP must keep refresh session active: %v", err)
	}
	if _, ok := cache.ip["lalu:"+authservice.HashLoginRiskIP("203.0.113.80")]; ok {
		t.Fatalf("whitelisted IP must not write ordinary IP decision cache")
	}
	if got := cache.ips["lalu"]; len(got) != 1 || got[0] != "203.0.113.80" {
		t.Fatalf("ip whitelist cache mismatch: %+v", got)
	}
	var decision string
	if err := repository.RawDB().QueryRowContext(ctx, `
		SELECT decision
		FROM login_ip_risk_jobs
		WHERE app_code = 'lalu' AND request_id = 'req-risk-whitelist'
	`).Scan(&decision); err != nil {
		t.Fatalf("query risk job failed: %v", err)
	}
	if decision != "allowed" {
		t.Fatalf("whitelisted risk job decision mismatch: %s", decision)
	}
}

func TestLoginIPRiskUserWhitelistBypassesBlockedCountryWithFiveMinuteCache(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "CN")
	now := time.UnixMilli(1000)
	if _, err := repository.RawDB().ExecContext(ctx, `
		INSERT INTO login_risk_user_whitelist (
			app_code, user_id, enabled, created_at_ms, updated_at_ms
		)
		VALUES ('lalu', 900025, 1, 1000, 1000)
	`); err != nil {
		t.Fatalf("seed user whitelist failed: %v", err)
	}
	cache := newMemoryDecisionCache()
	authSvc := newAuthService(repository, &now, []int64{900025}, []string{"100025"},
		authservice.WithLoginRiskPolicy(authservice.LoginRiskPolicy{
			Enabled:              true,
			UnsupportedCountries: []string{"CN"},
			BlockedTTL:           time.Hour,
			AllowedTTL:           time.Hour,
			UnknownTTL:           time.Minute,
			ProviderTimeout:      50 * time.Millisecond,
			ProviderNames:        []string{"edge_country"},
			DenylistTTL:          time.Hour,
		}),
		authservice.WithIPDecisionCache(cache),
		authservice.WithIPGeoProviders(authservice.BuildIPGeoProviders([]string{"edge_country"})),
	)

	snapshot, err := authSvc.RefreshLoginRiskWhitelistCache(ctx)
	if err != nil {
		t.Fatalf("refresh whitelist cache failed: %v", err)
	}
	if !snapshot.Refreshed || snapshot.UserWhitelistCount != 1 || snapshot.UserCacheExpiresAtMs != now.Add(5*time.Minute).UnixMilli() {
		t.Fatalf("user whitelist refresh snapshot mismatch: %+v", snapshot)
	}
	if got := cache.userTTL["lalu"]; got != 5*time.Minute {
		t.Fatalf("user whitelist cache ttl = %v, want 5m", got)
	}

	token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-risk-user-whitelist", thirdPartyRegistration("ios"), authservice.Meta{
		AppCode:     "lalu",
		RequestID:   "req-risk-user-whitelist",
		ClientIP:    "203.0.113.81",
		CountryByIP: "CN",
		Platform:    "ios",
	})
	if err != nil {
		t.Fatalf("LoginThirdParty failed: %v", err)
	}

	now = time.UnixMilli(2000)
	processed, err := authSvc.ProcessLoginIPRiskBatch(ctx, authservice.LoginIPRiskWorkerOptions{WorkerID: "risk-user-whitelist-test", LockTTL: time.Second, BatchSize: 10})
	if err != nil || processed != 1 {
		t.Fatalf("risk worker mismatch: processed=%d err=%v", processed, err)
	}
	if _, err := authSvc.RefreshToken(ctx, token.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "req-refresh-user-whitelist"}); err != nil {
		t.Fatalf("whitelisted user must keep refresh session active: %v", err)
	}
	if _, ok := cache.ip["lalu:"+authservice.HashLoginRiskIP("203.0.113.81")]; ok {
		t.Fatalf("user whitelist must not write ordinary IP decision cache")
	}
	var decision string
	if err := repository.RawDB().QueryRowContext(ctx, `
		SELECT decision
		FROM login_ip_risk_jobs
		WHERE app_code = 'lalu' AND request_id = 'req-risk-user-whitelist'
	`).Scan(&decision); err != nil {
		t.Fatalf("query risk job failed: %v", err)
	}
	if decision != "allowed" {
		t.Fatalf("whitelisted user risk job decision mismatch: %s", decision)
	}
}

func TestLoginIPRiskWorkerRevokesRotatedSameDeviceSessions(t *testing.T) {
	// 复现线上竞态：登录后风控任务尚未完成，客户端连续 refresh 轮换 session。
	// blocked 决策落地时必须沿源 session 的同设备登录链路清掉后续 session，否则最新 access/refresh 仍能继续使用。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "CN")
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	cache := newMemoryDecisionCache()
	authSvc := newAuthService(repository, &now, []int64{900023}, []string{"100023"},
		authservice.WithLoginRiskPolicy(authservice.LoginRiskPolicy{
			Enabled:              true,
			UnsupportedCountries: []string{"CN"},
			BlockedTTL:           time.Hour,
			AllowedTTL:           time.Hour,
			UnknownTTL:           time.Minute,
			ProviderTimeout:      50 * time.Millisecond,
			ProviderNames:        []string{"edge_country"},
			DenylistTTL:          time.Hour,
		}),
		authservice.WithIPDecisionCache(cache),
		authservice.WithIPGeoProviders(authservice.BuildIPGeoProviders([]string{"edge_country"})),
	)

	token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-risk-rotated", thirdPartyRegistration("ios"), authservice.Meta{
		AppCode:     "lalu",
		RequestID:   "req-risk-rotated",
		ClientIP:    "203.0.113.79",
		CountryByIP: "CN",
		Platform:    "ios",
	})
	if err != nil {
		t.Fatalf("LoginThirdParty failed: %v", err)
	}

	now = time.UnixMilli(1200)
	refreshedOnce, err := authSvc.RefreshToken(ctx, token.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "req-refresh-before-risk-1"})
	if err != nil {
		t.Fatalf("first RefreshToken before risk failed: %v", err)
	}
	now = time.UnixMilli(1400)
	refreshedTwice, err := authSvc.RefreshToken(ctx, refreshedOnce.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "req-refresh-before-risk-2"})
	if err != nil {
		t.Fatalf("second RefreshToken before risk failed: %v", err)
	}

	now = time.UnixMilli(2000)
	processed, err := authSvc.ProcessLoginIPRiskBatch(ctx, authservice.LoginIPRiskWorkerOptions{WorkerID: "risk-rotated-test", LockTTL: time.Second, BatchSize: 10})
	if err != nil || processed != 1 {
		t.Fatalf("risk worker mismatch: processed=%d err=%v", processed, err)
	}
	if _, err := authSvc.RefreshToken(ctx, refreshedTwice.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "req-refresh-after-risk"}); !xerr.IsCode(err, xerr.SessionRevoked) {
		t.Fatalf("latest rotated refresh session must be revoked by risk, got %v", err)
	}
	if _, err := authSvc.IssueAccessTokenForSession(ctx, refreshedTwice.UserID, refreshedTwice.SessionID, authservice.Meta{AppCode: "lalu", RequestID: "req-resign-after-risk"}); !xerr.IsCode(err, xerr.SessionRevoked) {
		t.Fatalf("latest rotated access session must be revoked by risk, got %v", err)
	}

	for _, sessionID := range []string{token.SessionID, refreshedOnce.SessionID, refreshedTwice.SessionID} {
		if got := cache.revoked["lalu:"+sessionID]; got != "GEO_POLICY_BLOCKED" {
			t.Fatalf("session %s denylist mismatch: %q", sessionID, got)
		}
	}
	var latestReason string
	if err := repository.RawDB().QueryRowContext(ctx, `
		SELECT revoked_reason
		FROM auth_sessions
		WHERE app_code = 'lalu' AND session_id = ?
	`, refreshedTwice.SessionID).Scan(&latestReason); err != nil {
		t.Fatalf("query latest session failed: %v", err)
	}
	if latestReason != "GEO_POLICY_BLOCKED" {
		t.Fatalf("latest session revoke reason mismatch: %s", latestReason)
	}
}

func TestLoginIPRiskWorkerUsesHTTPGeoProviderFallback(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "CN")
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	cache := newMemoryDecisionCache()
	server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
		if got := request.URL.Query().Get("ip"); got != "203.0.113.78" {
			t.Errorf("http geo provider received wrong ip query: %q", got)
		}
		if got := request.Header.Get("X-Source-IP"); got != "203.0.113.78" {
			t.Errorf("http geo provider received wrong ip header: %q", got)
		}
		writer.Header().Set("Content-Type", "application/json")
		_, _ = writer.Write([]byte(`{"data":{"country_code":"CN"}}`))
	}))
	defer server.Close()
	authSvc := newAuthService(repository, &now, []int64{900022}, []string{"100022"},
		authservice.WithLoginRiskPolicy(authservice.LoginRiskPolicy{
			Enabled:              true,
			UnsupportedCountries: []string{"CN"},
			BlockedTTL:           time.Hour,
			AllowedTTL:           time.Hour,
			UnknownTTL:           time.Minute,
			ProviderTimeout:      time.Second,
			ProviderNames:        []string{"edge_country", "primary_geo"},
			DenylistTTL:          time.Hour,
		}),
		authservice.WithIPDecisionCache(cache),
		authservice.WithIPGeoProviders(authservice.BuildIPGeoProvidersWithHTTP([]string{"edge_country", "primary_geo"}, map[string]authservice.HTTPIPGeoProviderConfig{
			"primary_geo": {
				URL:             server.URL + "/lookup?ip={ip}",
				Method:          http.MethodGet,
				CountryJSONPath: "data.country_code",
				Headers:         map[string]string{"X-Source-IP": "{ip}"},
				HTTPClient:      server.Client(),
			},
		})),
	)

	token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-risk-http-provider", thirdPartyRegistration("ios"), authservice.Meta{
		AppCode:   "lalu",
		RequestID: "req-risk-http-provider",
		ClientIP:  "203.0.113.78",
		Platform:  "ios",
	})
	if err != nil {
		t.Fatalf("LoginThirdParty failed: %v", err)
	}

	now = time.UnixMilli(2000)
	processed, err := authSvc.ProcessLoginIPRiskBatch(ctx, authservice.LoginIPRiskWorkerOptions{WorkerID: "risk-http-test", LockTTL: time.Second, BatchSize: 10})
	if err != nil || processed != 1 {
		t.Fatalf("risk worker mismatch: processed=%d err=%v", processed, err)
	}
	if _, err := authSvc.RefreshToken(ctx, token.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "req-refresh-http-provider"}); !xerr.IsCode(err, xerr.SessionRevoked) {
		t.Fatalf("expected revoked refresh session, got %v", err)
	}
	decision, ok := cache.ip["lalu:"+authservice.HashLoginRiskIP("203.0.113.78")]
	if !ok || decision.Decision != "blocked" || decision.Country != "CN" {
		t.Fatalf("blocked IP cache mismatch: ok=%v decision=%+v", ok, decision)
	}

	var countryCode string
	var decisionValue string
	if err := repository.RawDB().QueryRowContext(ctx, `
		SELECT country_code, decision
		FROM login_ip_risk_jobs
		WHERE app_code = 'lalu' AND request_id = 'req-risk-http-provider'
	`).Scan(&countryCode, &decisionValue); err != nil {
		t.Fatalf("query risk job failed: %v", err)
	}
	if countryCode != "CN" || decisionValue != "blocked" {
		t.Fatalf("risk job decision mismatch: country=%s decision=%s", countryCode, decisionValue)
	}
}

func TestLoginIPRiskWorkerUsesDynamicCountryBlocks(t *testing.T) {
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "MY")
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	if _, err := repository.RawDB().ExecContext(ctx, `
		INSERT INTO login_risk_country_blocks (
			app_code, keyword, country_code, enabled, created_at_ms, updated_at_ms
		)
		VALUES ('lalu', 'Malaysia', 'MY', 1, 1000, 1000)
	`); err != nil {
		t.Fatalf("seed dynamic country block failed: %v", err)
	}
	authSvc := newAuthService(repository, &now, []int64{900021}, []string{"100021"},
		authservice.WithLoginRiskPolicy(authservice.LoginRiskPolicy{
			Enabled:              true,
			UnsupportedCountries: nil,
			BlockedTTL:           time.Hour,
			AllowedTTL:           time.Hour,
			UnknownTTL:           time.Minute,
			ProviderTimeout:      50 * time.Millisecond,
			ProviderNames:        []string{"edge_country"},
			DenylistTTL:          time.Hour,
		}),
		authservice.WithIPGeoProviders(authservice.BuildIPGeoProviders([]string{"edge_country"})),
	)

	token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-risk-dynamic", thirdPartyRegistration("ios"), authservice.Meta{
		AppCode:     "lalu",
		RequestID:   "req-risk-dynamic",
		ClientIP:    "203.0.113.88",
		CountryByIP: "MY",
		Platform:    "ios",
	})
	if err != nil {
		t.Fatalf("LoginThirdParty failed: %v", err)
	}

	now = time.UnixMilli(2000)
	processed, err := authSvc.ProcessLoginIPRiskBatch(ctx, authservice.LoginIPRiskWorkerOptions{WorkerID: "risk-test", LockTTL: time.Second, BatchSize: 10})
	if err != nil || processed != 1 {
		t.Fatalf("risk worker mismatch: processed=%d err=%v", processed, err)
	}
	if _, err := authSvc.RefreshToken(ctx, token.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "req-refresh-dynamic"}); !xerr.IsCode(err, xerr.SessionRevoked) {
		t.Fatalf("expected revoked refresh session, got %v", err)
	}
	blocks, err := authSvc.ListLoginRiskBlockedCountries(ctx)
	if err != nil {
		t.Fatalf("ListLoginRiskBlockedCountries failed: %v", err)
	}
	if len(blocks) != 1 || blocks[0].CountryCode != "MY" || blocks[0].Keyword != "Malaysia" {
		t.Fatalf("dynamic block list mismatch: %+v", blocks)
	}
}

func TestIssueAccessTokenForSessionCarriesProfileRequiredOnboardingStatusWithoutRotatingRefresh(t *testing.T) {
	// 重新签发 access token 只能复用已有 session；refresh token 原文不可从数据库反查，也不应被静默轮换。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	authSvc := newAuthService(repository, &now, []int64{900010}, []string{"100010"})

	token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-onboarding-resign", thirdPartyRegistration("ios"), authservice.Meta{RequestID: "req-login"})
	if err != nil {
		t.Fatalf("LoginThirdParty failed: %v", err)
	}

	now = time.UnixMilli(3000)
	reissued, err := authSvc.IssueAccessTokenForSession(ctx, token.UserID, token.SessionID, authservice.Meta{RequestID: "req-resign"})
	if err != nil {
		t.Fatalf("IssueAccessTokenForSession failed: %v", err)
	}
	if reissued.ProfileCompleted || reissued.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) || reissued.AccessToken == "" {
		t.Fatalf("reissued token must carry profile-required onboarding: %+v", reissued)
	}
	if reissued.ExpiresInSec != testAccessTokenTTLSec {
		t.Fatalf("reissued access token TTL mismatch: %+v", reissued)
	}
	if reissued.SessionID != token.SessionID || reissued.RefreshToken != "" {
		t.Fatalf("access resign must reuse session and not mint refresh token: %+v", reissued)
	}
	if got := accessTokenStringClaim(t, reissued.AccessToken, "device_id"); got != "dev-ios" {
		// access resign 不接受客户端 device_id，只能从已校验的 active session 回读原绑定值。
		t.Fatalf("reissued access token device_id=%q, want dev-ios", got)
	}
}

func TestIssueAccessTokenForSessionCapsTTLAtSessionExpiry(t *testing.T) {
	// access token 不能因为心跳或资料完成重签而越过 refresh session 的服务端硬过期时间。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	authSvc := newAuthService(repository, &now, []int64{900013}, []string{"100013"})

	token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-session-expiry-cap", thirdPartyRegistration("ios"), authservice.Meta{AppCode: "lalu", RequestID: "req-login"})
	if err != nil {
		t.Fatalf("LoginThirdParty failed: %v", err)
	}

	now = time.UnixMilli(3000)
	sessionExpiresAt := now.Add(2 * time.Hour)
	if _, err := repository.RawDB().ExecContext(ctx, `
		UPDATE auth_sessions
		SET expires_at_ms = ?
		WHERE app_code = 'lalu' AND session_id = ?
	`, sessionExpiresAt.UnixMilli(), token.SessionID); err != nil {
		t.Fatalf("shorten session expiry failed: %v", err)
	}

	reissued, err := authSvc.IssueAccessTokenForSession(ctx, token.UserID, token.SessionID, authservice.Meta{AppCode: "lalu", RequestID: "req-resign"})
	if err != nil {
		t.Fatalf("IssueAccessTokenForSession failed: %v", err)
	}
	if reissued.ExpiresInSec != int64((2 * time.Hour).Seconds()) {
		t.Fatalf("reissued token must be capped by session expiry: %+v", reissued)
	}
	if got, want := accessTokenExpUnix(t, reissued.AccessToken), sessionExpiresAt.Unix(); got != want {
		t.Fatalf("reissued access token exp mismatch: got=%d want=%d", got, want)
	}
}

func TestAppHeartbeatRefreshesCurrentSessionOnly(t *testing.T) {
	// APP 心跳是登录会话在线状态，不应该创建房间 presence，也不能续住其他用户或其他 session。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	authSvc := newAuthService(repository, &now, []int64{900011}, []string{"100011"})

	token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-app-heartbeat", thirdPartyRegistration("ios"), authservice.Meta{AppCode: "lalu", RequestID: "req-login"})
	if err != nil {
		t.Fatalf("LoginThirdParty failed: %v", err)
	}

	now = time.UnixMilli(4000)
	session, err := authSvc.AppHeartbeat(ctx, token.UserID, token.SessionID, authservice.Meta{AppCode: "lalu", RequestID: "req-app-heartbeat"})
	if err != nil {
		t.Fatalf("AppHeartbeat failed: %v", err)
	}
	if session.LastHeartbeatAtMs != 4000 || session.LastHeartbeatRequestID != "req-app-heartbeat" {
		t.Fatalf("session heartbeat mismatch: %+v", session)
	}

	var heartbeatAtMS int64
	var requestID string
	if err := repository.RawDB().QueryRowContext(ctx, `
		SELECT last_heartbeat_at_ms, last_heartbeat_request_id
		FROM auth_sessions
		WHERE app_code = 'lalu' AND session_id = ?
	`, token.SessionID).Scan(&heartbeatAtMS, &requestID); err != nil {
		t.Fatalf("query heartbeat failed: %v", err)
	}
	if heartbeatAtMS != 4000 || requestID != "req-app-heartbeat" {
		t.Fatalf("stored heartbeat mismatch: at=%d request=%s", heartbeatAtMS, requestID)
	}

	now = time.UnixMilli(5000)
	if _, err := authSvc.AppHeartbeat(ctx, token.UserID+1, token.SessionID, authservice.Meta{AppCode: "lalu", RequestID: "req-wrong-user"}); !xerr.IsCode(err, xerr.SessionRevoked) {
		t.Fatalf("wrong user must not refresh session heartbeat, got %v", err)
	}
}

func TestDisabledUserCannotLogin(t *testing.T) {
	// 用户状态被禁用后，即使密码正确也不能继续签发 token。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "CN")
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	svc := newAuthService(repository, &now, []int64{900001}, []string{"100001"})

	token, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-1", thirdPartyRegistration("ios"), authservice.Meta{})
	if err != nil {
		t.Fatalf("LoginThirdParty failed: %v", err)
	}
	if err := svc.SetPassword(ctx, token.UserID, "secret-pass", authservice.Meta{}); err != nil {
		t.Fatalf("SetPassword failed: %v", err)
	}
	user, err := repository.GetUser(ctx, token.UserID)
	if err != nil {
		t.Fatalf("GetUser failed: %v", err)
	}
	user.Status = userdomain.StatusDisabled
	// 直接改 MySQL 测试库中的用户状态，模拟运营禁用用户。
	repository.PutUser(user)

	_, err = svc.LoginPassword(ctx, token.DisplayUserID, "secret-pass", "ios", authservice.Meta{})
	if !xerr.IsCode(err, xerr.UserDisabled) {
		t.Fatalf("expected USER_DISABLED, got %v", err)
	}
}

func TestThirdPartyLoginOrRegister(t *testing.T) {
	// 首次三方登录创建用户，再次同 provider subject 登录应复用原用户。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	svc := newAuthService(repository, &now, []int64{900002}, []string{"100002"})

	firstToken, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-1", thirdPartyRegistration("ios"), authservice.Meta{RequestID: "req-third-first"})
	if err != nil {
		t.Fatalf("first LoginThirdParty failed: %v", err)
	}
	assertSevenDigitDisplayUserID(t, firstToken.DisplayUserID)
	if !isNewUser || firstToken.UserID != 900002 || firstToken.DisplayUserID == "" {
		t.Fatalf("unexpected first third-party result: token=%+v isNew=%v", firstToken, isNewUser)
	}
	if firstToken.ProfileCompleted || firstToken.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) {
		t.Fatalf("first third-party registration must require onboarding: %+v", firstToken)
	}

	secondToken, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-1", authdomain.ThirdPartyRegistration{DeviceID: "dev-ios", Platform: "ios"}, authservice.Meta{RequestID: "req-third-second"})
	if err != nil {
		t.Fatalf("second LoginThirdParty failed: %v", err)
	}
	if isNewUser || secondToken.UserID != firstToken.UserID {
		t.Fatalf("expected existing third-party user, token=%+v isNew=%v", secondToken, isNewUser)
	}

	if _, _, err := svc.LoginThirdParty(ctx, "unknown", "openid-1", thirdPartyRegistration("ios"), authservice.Meta{}); !xerr.IsCode(err, xerr.AuthFailed) {
		// 未允许的 provider 统一认证失败。
		t.Fatalf("expected AUTH_FAILED for unknown provider, got %v", err)
	}
}

func TestRegisterThirdPartyCreatesCompletedUserAndSession(t *testing.T) {
	// 新注册接口必须一次性完成资料、三方绑定、session 和注册事实，客户端不再拿 profile_required token。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	country := seedCountry(t, repository, "SG")
	region := mustResolveRegionByCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	imImporter := &fakeIMAccountImporter{}
	svc := newAuthService(repository, &now, []int64{900030}, []string{"100030"}, authservice.WithIMAccountImporter(imImporter))

	token, isNewUser, err := svc.RegisterThirdParty(ctx, "wechat", "openid-register-complete", thirdPartyRegistration("ios"), authservice.Meta{RequestID: "req-third-register", ClientIP: "203.0.113.10", UserAgent: "hyapp-test/1.0"})
	if err != nil {
		t.Fatalf("RegisterThirdParty failed: %v", err)
	}
	if !isNewUser || token.UserID != 900030 || token.ProfileCompleted != true || token.OnboardingStatus != string(userdomain.OnboardingStatusCompleted) || token.AccessToken == "" || token.RefreshToken == "" {
		t.Fatalf("register must return completed token for new user: token=%+v isNew=%v", token, isNewUser)
	}
	user, err := repository.GetUser(ctx, token.UserID)
	if err != nil {
		t.Fatalf("GetUser failed: %v", err)
	}
	if !user.ProfileCompleted || user.ProfileCompletedAtMs != now.UnixMilli() || user.OnboardingStatus != userdomain.OnboardingStatusCompleted {
		t.Fatalf("user must be completed immediately: %+v", user)
	}
	if user.Country != country.CountryCode || user.RegionID != region.RegionID || user.Username != "hy" || user.Avatar != "https://cdn.example/avatar.png" {
		t.Fatalf("registration profile mismatch: %+v", user)
	}
	if imImporter.userID != token.UserID || imImporter.nickname != "hy" || imImporter.faceURL != "https://cdn.example/avatar.png" {
		t.Fatalf("completed registration must import final IM profile: %+v", imImporter)
	}
	assertUserRegisteredOutboxCount(t, repository, token.UserID, 1)
	assertUserRegisteredOutboxFact(t, repository, token.UserID, country.CountryID, region.RegionID, now.UnixMilli())
}

func TestRegisterThirdPartyCompletesExistingPendingUser(t *testing.T) {
	// 兼容旧流程：同一 provider subject 已有 pending 用户时，新接口补齐资料并签新 session，不创建第二个账号。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	country := seedCountry(t, repository, "SG")
	region := mustResolveRegionByCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	svc := newAuthService(repository, &now, []int64{900031, 900032}, []string{"100031", "100032"})

	pendingToken, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-pending-register", authdomain.ThirdPartyRegistration{DeviceID: "dev-ios", Platform: "ios"}, authservice.Meta{RequestID: "req-third-pending"})
	if err != nil {
		t.Fatalf("LoginThirdParty pending seed failed: %v", err)
	}
	if !isNewUser || pendingToken.ProfileCompleted {
		t.Fatalf("seed login must create pending user: token=%+v isNew=%v", pendingToken, isNewUser)
	}
	assertUserRegisteredOutboxCount(t, repository, pendingToken.UserID, 0)

	now = time.UnixMilli(2000)
	register := thirdPartyRegistration("ios")
	register.Username = "finished"
	register.DeviceID = "dev-ios-finished"
	completedToken, isNewUser, err := svc.RegisterThirdParty(ctx, "wechat", "openid-pending-register", register, authservice.Meta{RequestID: "req-third-complete"})
	if err != nil {
		t.Fatalf("RegisterThirdParty completion failed: %v", err)
	}
	if isNewUser || completedToken.UserID != pendingToken.UserID || !completedToken.ProfileCompleted || completedToken.OnboardingStatus != string(userdomain.OnboardingStatusCompleted) {
		t.Fatalf("pending subject must complete existing user: token=%+v isNew=%v pending=%+v", completedToken, isNewUser, pendingToken)
	}
	user, err := repository.GetUser(ctx, pendingToken.UserID)
	if err != nil {
		t.Fatalf("GetUser failed: %v", err)
	}
	if user.Username != "finished" || user.Country != country.CountryCode || user.RegionID != region.RegionID || user.ProfileCompletedAtMs != now.UnixMilli() {
		t.Fatalf("pending user completion mismatch: %+v", user)
	}
	assertUserRegisteredOutboxCount(t, repository, pendingToken.UserID, 1)
	assertUserRegisteredOutboxFact(t, repository, pendingToken.UserID, country.CountryID, region.RegionID, now.UnixMilli())
}

func TestCheckThirdPartyRegisteredReturnsOnlyCompletedRegistration(t *testing.T) {
	// 检查接口只服务前端路由判断，不创建用户、不创建 session；pending 用户仍然要进资料页。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	svc := newAuthService(repository, &now, []int64{900033}, []string{"100033"})

	registered, err := svc.CheckThirdPartyRegistered(ctx, "wechat", "openid-check", authservice.Meta{RequestID: "req-check-empty"})
	if err != nil {
		t.Fatalf("CheckThirdPartyRegistered empty failed: %v", err)
	}
	if registered {
		t.Fatalf("unbound third-party subject must not be registered")
	}

	pendingToken, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-check", authdomain.ThirdPartyRegistration{DeviceID: "dev-ios", Platform: "ios"}, authservice.Meta{RequestID: "req-check-pending"})
	if err != nil {
		t.Fatalf("seed pending LoginThirdParty failed: %v", err)
	}
	if !isNewUser || pendingToken.ProfileCompleted {
		t.Fatalf("seed login must create pending user: token=%+v isNew=%v", pendingToken, isNewUser)
	}
	registered, err = svc.CheckThirdPartyRegistered(ctx, "wechat", "openid-check", authservice.Meta{RequestID: "req-check-pending"})
	if err != nil {
		t.Fatalf("CheckThirdPartyRegistered pending failed: %v", err)
	}
	if registered {
		t.Fatalf("pending third-party subject must not be treated as registered")
	}

	register := thirdPartyRegistration("ios")
	register.DeviceID = "dev-ios-register"
	if _, _, err := svc.RegisterThirdParty(ctx, "wechat", "openid-check", register, authservice.Meta{RequestID: "req-check-register"}); err != nil {
		t.Fatalf("RegisterThirdParty completion failed: %v", err)
	}
	registered, err = svc.CheckThirdPartyRegistered(ctx, "wechat", "openid-check", authservice.Meta{RequestID: "req-check-completed"})
	if err != nil {
		t.Fatalf("CheckThirdPartyRegistered completed failed: %v", err)
	}
	if !registered {
		t.Fatalf("completed third-party subject must be registered")
	}
}

func TestThirdPartyRegisterRejectsReusedDeviceID(t *testing.T) {
	// 设备号只限制新账号注册；同一个 provider subject 的再次登录仍然可以创建新 session。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	svc := newAuthService(repository, &now, []int64{900020, 900021}, []string{"100020", "100021"})
	registration := thirdPartyRegistration("ios")
	registration.DeviceID = "device-one-account"

	firstToken, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-device-first", registration, authservice.Meta{RequestID: "req-device-first"})
	if err != nil {
		t.Fatalf("first LoginThirdParty failed: %v", err)
	}
	if !isNewUser || firstToken.UserID != 900020 {
		t.Fatalf("unexpected first third-party result: token=%+v isNew=%v", firstToken, isNewUser)
	}

	existingToken, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-device-first", registration, authservice.Meta{RequestID: "req-device-existing"})
	if err != nil {
		t.Fatalf("existing LoginThirdParty should not be blocked by device limit: %v", err)
	}
	if isNewUser || existingToken.UserID != firstToken.UserID {
		t.Fatalf("expected existing third-party login, token=%+v isNew=%v", existingToken, isNewUser)
	}

	_, _, err = svc.LoginThirdParty(ctx, "wechat", "openid-device-second", registration, authservice.Meta{RequestID: "req-device-second"})
	if !xerr.IsCode(err, xerr.DeviceAlreadyRegistered) {
		// 第二个 provider subject 会创建新账号，必须被注册设备唯一性拦截。
		t.Fatalf("expected DEVICE_ALREADY_REGISTERED for reused device, got %v", err)
	}
}

func TestThirdPartyRegisterAllowsMinimalProfileFields(t *testing.T) {
	// 三方首次登录只需要认证材料、设备和平台；公开资料由后续 onboarding 原子提交。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := time.UnixMilli(1000)
	svc := newAuthService(repository, &now, []int64{900012}, []string{"100012"})

	token, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-minimal", authdomain.ThirdPartyRegistration{
		DeviceID: "dev-ios",
		Platform: "ios",
	}, authservice.Meta{})
	if err != nil {
		t.Fatalf("LoginThirdParty with minimal registration failed: %v", err)
	}
	if !isNewUser || token.ProfileCompleted || token.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) {
		t.Fatalf("minimal third-party registration must create profile-required user: token=%+v isNew=%v", token, isNewUser)
	}
}

func TestThirdPartyRegisterStoresRegistrationProfile(t *testing.T) {
	// 三方首次注册要把资料字段和注册来源快照随 users 主记录同事务写入。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "CN")
	now := time.UnixMilli(1000)
	imImporter := &fakeIMAccountImporter{}
	svc := newAuthService(repository, &now, []int64{900003}, []string{"100003"}, authservice.WithIMAccountImporter(imImporter))
	if err := repository.CreateUserWithIdentity(ctx, userdomain.User{
		AppCode:                  "lalu",
		UserID:                   800001,
		DefaultDisplayUserID:     "188001",
		CurrentDisplayUserID:     "188001",
		CurrentDisplayUserIDKind: userdomain.DisplayUserIDKindDefault,
		Status:                   userdomain.StatusActive,
		OnboardingStatus:         userdomain.OnboardingStatusProfileRequired,
		CreatedAtMs:              now.UnixMilli(),
		UpdatedAtMs:              now.UnixMilli(),
	}, userdomain.Identity{
		AppCode:              "lalu",
		UserID:               800001,
		DisplayUserID:        "188001",
		DefaultDisplayUserID: "188001",
		DisplayUserIDKind:    userdomain.DisplayUserIDKindDefault,
		Status:               userdomain.DisplayUserIDStatusActive,
	}); err != nil {
		t.Fatalf("seed inviter failed: %v", err)
	}
	inviter, err := repository.GetUser(ctx, 800001)
	if err != nil {
		t.Fatalf("get inviter failed: %v", err)
	}
	registration := authdomain.ThirdPartyRegistration{
		Username:       " hy ",
		Gender:         "male",
		Country:        "CN",
		InviteCode:     inviter.InviteOverview.MyInviteCode,
		IP:             "198.51.100.9",
		DeviceID:       " dev-1 ",
		Device:         "iPhone 15",
		OSVersion:      " iOS 18.1 ",
		Avatar:         "https://cdn.example/avatar.png",
		BirthDate:      "2000-01-02",
		AppVersion:     "1.2.3",
		BuildNumber:    " 123 ",
		Source:         "campaign-a",
		InstallChannel: " app_store ",
		Campaign:       " spring_launch ",
		Platform:       "IOS",
		Language:       "zh-CN",
		Timezone:       " Asia/Shanghai ",
	}

	token, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-profile", registration, authservice.Meta{ClientIP: "203.0.113.10", UserAgent: "hyapp-test/1.0", CountryByIP: "cn"})
	if err != nil {
		t.Fatalf("LoginThirdParty failed: %v", err)
	}
	if !isNewUser {
		t.Fatalf("first third-party login must create user")
	}

	user, err := repository.GetUser(ctx, token.UserID)
	if err != nil {
		t.Fatalf("GetUser failed: %v", err)
	}
	if user.Username != "hy" || user.Gender != "male" || user.Country != "CN" || user.InviteCode != inviter.InviteOverview.MyInviteCode {
		t.Fatalf("profile fields mismatch: %+v", user)
	}
	if user.RegisterIP != "203.0.113.10" || user.RegisterUserAgent != "hyapp-test/1.0" || user.CountryByIP != "CN" {
		t.Fatalf("gateway observed source fields mismatch: %+v", user)
	}
	if user.RegisterDeviceID != "dev-1" || user.RegisterDevice != "iPhone 15" || user.RegisterOSVersion != "iOS 18.1" || user.RegisterAppVersion != "1.2.3" || user.RegisterBuildNumber != "123" {
		t.Fatalf("registration source fields mismatch: %+v", user)
	}
	if user.Avatar == "" || user.BirthDate != "2000-01-02" || user.RegisterSource != "campaign-a" || user.RegisterInstallChannel != "app_store" || user.RegisterCampaign != "spring_launch" || user.RegisterPlatform != "ios" || user.Language != "zh-CN" || user.Timezone != "Asia/Shanghai" {
		t.Fatalf("registration profile fields mismatch: %+v", user)
	}
	if imImporter.userID != token.UserID || imImporter.nickname != "hy" || imImporter.faceURL != "https://cdn.example/avatar.png" {
		t.Fatalf("third-party registration must import IM account: %+v", imImporter)
	}
}

func TestThirdPartyRegisterAssignsRegionAndExistingLoginKeepsOriginalCountry(t *testing.T) {
	// 首次注册按国家映射写 users.region_id；已有三方登录不因请求体国家变化改用户资料。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "SG")
	region := mustResolveRegionByCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	svc := newAuthService(repository, &now, []int64{900006}, []string{"100006"})

	firstToken, isNew, err := svc.LoginThirdParty(ctx, "wechat", "openid-region", authdomain.ThirdPartyRegistration{
		Username: "hy",
		Avatar:   "https://cdn.example/avatar.png",
		Gender:   "male",
		DeviceID: "ios",
		Platform: "ios",
		Country:  " sg ",
	}, authservice.Meta{RequestID: "req-region-first"})
	if err != nil {
		t.Fatalf("LoginThirdParty failed: %v", err)
	}
	if !isNew {
		t.Fatalf("first third-party login must create user")
	}
	user, err := repository.GetUser(ctx, firstToken.UserID)
	if err != nil {
		t.Fatalf("GetUser failed: %v", err)
	}
	if user.Country != "SG" || user.RegionID != region.RegionID || user.RegionCode != region.RegionCode {
		t.Fatalf("region assignment mismatch: %+v", user)
	}

	_, isNew, err = svc.LoginThirdParty(ctx, "wechat", "openid-region", authdomain.ThirdPartyRegistration{
		DeviceID: "ios",
		Platform: "ios",
		Country:  "ZZ",
	}, authservice.Meta{RequestID: "req-region-second"})
	if err != nil {
		t.Fatalf("existing LoginThirdParty failed: %v", err)
	}
	if isNew {
		t.Fatalf("existing third-party login must not create user")
	}
	user, err = repository.GetUser(ctx, firstToken.UserID)
	if err != nil {
		t.Fatalf("GetUser failed: %v", err)
	}
	if user.Country != "SG" || user.RegionID != region.RegionID {
		t.Fatalf("existing login must not rewrite country or region: %+v", user)
	}
}

func TestThirdPartyRegisterRejectsUnknownOrDisabledCountry(t *testing.T) {
	// 国家格式合法但未命中 active countries 时，也必须返回稳定 INVALID_ARGUMENT。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	country := seedCountry(t, repository, "MY")
	repository.SetCountryEnabled(country.CountryID, false)
	now := time.UnixMilli(1000)
	svc := newAuthService(repository, &now, []int64{900007, 900008}, []string{"100007", "100008"})

	for _, tc := range []struct {
		name    string
		country string
	}{
		{name: "unknown", country: "ZZ"},
		{name: "disabled", country: "MY"},
	} {
		_, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-"+tc.name, authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Country: tc.country}, authservice.Meta{})
		if !xerr.IsCode(err, xerr.InvalidArgument) {
			t.Fatalf("expected INVALID_ARGUMENT for %s country, got %v", tc.name, err)
		}
	}
}

func TestThirdPartyRegisterValidatesRegistrationProfile(t *testing.T) {
	// 格式边界必须在 service 层返回稳定 INVALID_ARGUMENT，不能让 MySQL 报错泄漏为 upstream error。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := time.UnixMilli(1000)
	svc := newAuthService(repository, &now, []int64{900004}, []string{"100004"})

	cases := []struct {
		name         string
		registration authdomain.ThirdPartyRegistration
	}{
		{name: "birth_format", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", BirthDate: "2000/01/02"}},
		{name: "birth_mysql_range", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", BirthDate: "0001-01-01"}},
		{name: "platform_missing", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios"}},
		{name: "platform_invalid", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "web"}},
		{name: "country", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Country: "U1"}},
		{name: "avatar", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Avatar: "ftp://cdn.example/avatar.png"}},
		{name: "language", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Language: "en_US"}},
		{name: "timezone", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Timezone: "Mars/Phobos"}},
	}
	for _, tc := range cases {
		_, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-"+tc.name, tc.registration, authservice.Meta{})
		if !xerr.IsCode(err, xerr.InvalidArgument) {
			t.Fatalf("expected INVALID_ARGUMENT for %s, got %v", tc.name, err)
		}
	}
}

func TestThirdPartyRegisterValidatesSQLBackedLengths(t *testing.T) {
	// 每个写入 users VARCHAR 的注册字段都要在 service 层按 schema 长度拒绝，避免数据库错误变成 UPSTREAM_ERROR。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	now := time.UnixMilli(1000)
	svc := newAuthService(repository, &now, []int64{900005}, []string{"100005"})
	longURL := "https://cdn.example/" + strings.Repeat("a", 513)
	cases := []struct {
		name         string
		registration authdomain.ThirdPartyRegistration
		meta         authservice.Meta
	}{
		{name: "username", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Username: strings.Repeat("a", 65)}},
		{name: "gender", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Gender: strings.Repeat("a", 33)}},
		{name: "country", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Country: strings.Repeat("a", 65)}},
		{name: "invite_code", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", InviteCode: strings.Repeat("a", 65)}},
		{name: "register_ip", registration: thirdPartyRegistration("ios"), meta: authservice.Meta{ClientIP: strings.Repeat("1", 65)}},
		{name: "register_user_agent", registration: thirdPartyRegistration("ios"), meta: authservice.Meta{UserAgent: strings.Repeat("a", 513)}},
		{name: "device_id", registration: authdomain.ThirdPartyRegistration{DeviceID: strings.Repeat("a", 129), Platform: "ios"}},
		{name: "device", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Device: strings.Repeat("a", 129)}},
		{name: "os_version", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", OSVersion: strings.Repeat("a", 65)}},
		{name: "avatar", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Avatar: longURL}},
		{name: "app_version", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", AppVersion: strings.Repeat("a", 65)}},
		{name: "build_number", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", BuildNumber: strings.Repeat("a", 65)}},
		{name: "source", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Source: strings.Repeat("a", 65)}},
		{name: "install_channel", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", InstallChannel: strings.Repeat("a", 65)}},
		{name: "campaign", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Campaign: strings.Repeat("a", 129)}},
		{name: "platform", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: strings.Repeat("a", 17)}},
		{name: "language", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Language: strings.Repeat("a", 33)}},
		{name: "timezone", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Timezone: strings.Repeat("a", 65)}},
	}
	for _, tc := range cases {
		_, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-"+tc.name, tc.registration, tc.meta)
		if !xerr.IsCode(err, xerr.InvalidArgument) {
			t.Fatalf("expected INVALID_ARGUMENT for overlong %s, got %v", tc.name, err)
		}
	}
}

func TestThirdPartyRegisterAvoidsOccupiedDisplayUserID(t *testing.T) {
	// 默认短号随机生成前先查历史占用；创建事务仍用唯一索引处理并发撞号。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "SG")
	repository.PutUser(userdomain.User{UserID: 1, CurrentDisplayUserID: "163000", Status: userdomain.StatusActive})
	now := time.UnixMilli(1000)
	svc := newAuthService(repository, &now, []int64{900001, 900002}, []string{"100001", "100002"})

	token, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-1", thirdPartyRegistration("ios"), authservice.Meta{})
	if err != nil {
		t.Fatalf("LoginThirdParty should retry display_user_id conflict: %v", err)
	}

	userSvc := newUserService(repository)
	identity, err := userSvc.GetUserIdentity(ctx, token.UserID)
	if err != nil {
		t.Fatalf("GetUserIdentity failed: %v", err)
	}
	assertSevenDigitDisplayUserID(t, identity.DisplayUserID)
	if identity.DisplayUserID == "163000" {
		t.Fatalf("display_user_id must not reuse occupied value: %+v", identity)
	}
}

func TestPasswordLoginUsesCurrentDisplayUserIDWithPrettyLease(t *testing.T) {
	// 密码登录允许 held 默认短号作为登录别名；签发 token 必须仍使用当前 active 靓号。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	authSvc := newAuthService(repository, &now, []int64{900001}, []string{"100001"})
	userSvc := newUserService(repository, userservice.WithClock(func() time.Time { return now }))

	token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-pretty", thirdPartyRegistration("ios"), authservice.Meta{})
	if err != nil {
		t.Fatalf("LoginThirdParty failed: %v", err)
	}
	if err := authSvc.SetPassword(ctx, token.UserID, "secret-pass", authservice.Meta{}); err != nil {
		t.Fatalf("SetPassword failed: %v", err)
	}
	if _, _, err := userSvc.ApplyPrettyDisplayUserID(ctx, token.UserID, "888888", 1000, "receipt-1", "req-pretty"); err != nil {
		t.Fatalf("ApplyPrettyDisplayUserID failed: %v", err)
	}

	defaultAliasToken, err := authSvc.LoginPassword(ctx, token.DefaultDisplayUserID, "secret-pass", "ios", authservice.Meta{})
	if err != nil {
		t.Fatalf("default display_user_id alias login failed while pretty is active: %v", err)
	}
	if defaultAliasToken.DisplayUserID != "888888" || defaultAliasToken.DefaultDisplayUserID != token.DefaultDisplayUserID || defaultAliasToken.DisplayUserIDKind != string(userdomain.DisplayUserIDKindPretty) {
		// 默认短号只是登录入口别名，不能把用户当前展示号回退成默认短号。
		t.Fatalf("unexpected default alias login token: %+v", defaultAliasToken)
	}
	prettyToken, err := authSvc.LoginPassword(ctx, "888888", "secret-pass", "ios", authservice.Meta{})
	if err != nil {
		t.Fatalf("pretty display_user_id login failed: %v", err)
	}
	if prettyToken.DisplayUserID != "888888" || prettyToken.DefaultDisplayUserID != token.DefaultDisplayUserID || prettyToken.DisplayUserIDKind != string(userdomain.DisplayUserIDKindPretty) {
		t.Fatalf("unexpected pretty login token: %+v", prettyToken)
	}

	now = time.UnixMilli(2500)
	// 靓号过期后，密码登录默认号会触发懒恢复。
	defaultToken, err := authSvc.LoginPassword(ctx, token.DefaultDisplayUserID, "secret-pass", "ios", authservice.Meta{})
	if err != nil {
		t.Fatalf("default display_user_id login should recover after pretty expires: %v", err)
	}
	if defaultToken.DisplayUserID != token.DefaultDisplayUserID || defaultToken.DisplayUserIDKind != string(userdomain.DisplayUserIDKindDefault) {
		t.Fatalf("unexpected recovered login token: %+v", defaultToken)
	}
	if _, err := authSvc.LoginPassword(ctx, "888888", "secret-pass", "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.AuthFailed) {
		t.Fatalf("expired pretty display_user_id must not login, got %v", err)
	}
}

func TestPasswordLoginAllowsAlphanumericPrettyDisplayID(t *testing.T) {
	// 后台发放靓号可以包含字母；密码登录既允许 active 靓号，也允许 held 默认短号作为别名。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	authSvc := newAuthService(repository, &now, []int64{900001}, []string{"100001"})
	userSvc := newUserService(repository, userservice.WithClock(func() time.Time { return now }))

	token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-pretty-alpha", thirdPartyRegistration("ios"), authservice.Meta{})
	if err != nil {
		t.Fatalf("LoginThirdParty failed: %v", err)
	}
	if err := authSvc.SetPassword(ctx, token.UserID, "secret-pass", authservice.Meta{}); err != nil {
		t.Fatalf("SetPassword failed: %v", err)
	}
	if _, _, err := userSvc.AdminGrantPrettyDisplayID(ctx, userdomain.AdminGrantPrettyDisplayIDCommand{
		TargetUserID:    token.UserID,
		DisplayUserID:   "1111cc1111",
		OperatorAdminID: 1,
		RequestID:       "req-admin-pretty-alpha",
	}); err != nil {
		t.Fatalf("AdminGrantPrettyDisplayID failed: %v", err)
	}
	if _, err := repository.RawDB().ExecContext(ctx, `
		UPDATE users
		SET current_display_user_id = default_display_user_id,
		    current_display_user_id_kind = ?,
		    current_display_user_id_expires_at_ms = NULL
		WHERE app_code = ? AND user_id = ?
	`, string(userdomain.DisplayUserIDKindDefault), "lalu", token.UserID); err != nil {
		t.Fatalf("make users display snapshot stale failed: %v", err)
	}

	defaultAliasToken, err := authSvc.LoginPassword(ctx, token.DefaultDisplayUserID, "secret-pass", "ios", authservice.Meta{})
	if err != nil {
		t.Fatalf("default display_user_id alias login failed while admin pretty is active: %v", err)
	}
	if defaultAliasToken.UserID != token.UserID || defaultAliasToken.DisplayUserID != "1111cc1111" || defaultAliasToken.DisplayUserIDKind != string(userdomain.DisplayUserIDKindPretty) {
		// users 快照即使因为历史脚本滞后停在默认号，登录成功后也必须按事实表修回 active 靓号。
		t.Fatalf("unexpected default alias login token: %+v", defaultAliasToken)
	}
	userAfterAliasLogin, err := repository.GetUser(ctx, token.UserID)
	if err != nil {
		t.Fatalf("GetUser after default alias login failed: %v", err)
	}
	if userAfterAliasLogin.CurrentDisplayUserID != "1111cc1111" || userAfterAliasLogin.CurrentDisplayUserIDKind != userdomain.DisplayUserIDKindPretty {
		t.Fatalf("default alias login must repair users current display snapshot: %+v", userAfterAliasLogin)
	}
	prettyToken, err := authSvc.LoginPassword(ctx, "1111cc1111", "secret-pass", "ios", authservice.Meta{})
	if err != nil {
		t.Fatalf("alphanumeric pretty display_user_id login failed: %v", err)
	}
	if prettyToken.UserID != token.UserID || prettyToken.DisplayUserID != "1111cc1111" || prettyToken.DisplayUserIDKind != string(userdomain.DisplayUserIDKindPretty) {
		t.Fatalf("unexpected alphanumeric pretty login token: %+v", prettyToken)
	}
}

func TestRecycleAdminGrantedPrettyDisplayIDRestoresDefaultDisplayID(t *testing.T) {
	// 后台回收必须能处理 duration_ms=0 的长期靓号；这类靓号不能复用到期恢复接口，否则会被未到期校验拒绝。
	ctx := context.Background()
	repository := mysqltest.NewRepository(t)
	seedCountry(t, repository, "SG")
	now := time.UnixMilli(1000)
	authSvc := newAuthService(repository, &now, []int64{900001}, []string{"100001"})
	userSvc := newUserService(repository, userservice.WithClock(func() time.Time { return now }))

	token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-pretty-recycle", thirdPartyRegistration("ios"), authservice.Meta{})
	if err != nil {
		t.Fatalf("LoginThirdParty failed: %v", err)
	}
	if err := authSvc.SetPassword(ctx, token.UserID, "secret-pass", authservice.Meta{}); err != nil {
		t.Fatalf("SetPassword failed: %v", err)
	}
	identity, _, err := userSvc.AdminGrantPrettyDisplayID(ctx, userdomain.AdminGrantPrettyDisplayIDCommand{
		TargetUserID:    token.UserID,
		DisplayUserID:   "RECYCLE2026",
		OperatorAdminID: 1,
		RequestID:       "req-admin-pretty-recycle-grant",
	})
	if err != nil {
		t.Fatalf("AdminGrantPrettyDisplayID failed: %v", err)
	}
	if identity.PrettyID == "" || identity.DisplayUserID != "RECYCLE2026" {
		t.Fatalf("unexpected grant identity: %+v", identity)
	}

	now = time.UnixMilli(2000)
	recycled, err := userSvc.RecyclePrettyDisplayID(ctx, userdomain.PrettyDisplayIDRecycleCommand{
		PrettyID:        identity.PrettyID,
		Reason:          "manual test recycle",
		OperatorAdminID: 2,
		RequestID:       "req-admin-pretty-recycle",
	})
	if err != nil {
		t.Fatalf("RecyclePrettyDisplayID failed: %v", err)
	}
	if recycled.Status != userdomain.PrettyDisplayIDStatusReleased || recycled.AssignedUserID != 0 || recycled.AssignedLeaseID != "" {
		// 后台自由发放号回收后保留 released 历史态，不回到池内可申请。
		t.Fatalf("unexpected recycled pretty item: %+v", recycled)
	}

	userAfterRecycle, err := repository.GetUser(ctx, token.UserID)
	if err != nil {
		t.Fatalf("GetUser after recycle failed: %v", err)
	}
	if userAfterRecycle.CurrentDisplayUserID != token.DefaultDisplayUserID || userAfterRecycle.CurrentDisplayUserIDKind != userdomain.DisplayUserIDKindDefault {
		t.Fatalf("user must recover default display id after recycle: %+v", userAfterRecycle)
	}
	defaultToken, err := authSvc.LoginPassword(ctx, token.DefaultDisplayUserID, "secret-pass", "ios", authservice.Meta{})
	if err != nil {
		t.Fatalf("default display_user_id login after recycle failed: %v", err)
	}
	if defaultToken.DisplayUserID != token.DefaultDisplayUserID || defaultToken.DisplayUserIDKind != string(userdomain.DisplayUserIDKindDefault) {
		t.Fatalf("unexpected default token after recycle: %+v", defaultToken)
	}
	if _, err := authSvc.LoginPassword(ctx, "RECYCLE2026", "secret-pass", "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.AuthFailed) {
		t.Fatalf("recycled pretty display_user_id must not login, got %v", err)
	}
}
