2026-07-22 13:55:55 +08:00

532 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

package appconfig
import (
"os"
"strings"
"testing"
"time"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/model"
activityv1 "hyapp.local/api/proto/activity/v1"
)
func TestH5AppScopeSchemaMigrationCreatesScopedKeys(t *testing.T) {
body, err := os.ReadFile("../../../migrations/086_app_config_app_scope.sql")
if err != nil {
t.Fatalf("read h5 app scope migration failed: %v", err)
}
sqlText := string(body)
for _, snippet := range []string{
"app_code VARCHAR(32) NOT NULL DEFAULT ''",
"is_deleted BOOLEAN NOT NULL DEFAULT FALSE",
"INDEX_NAME = 'uk_admin_app_configs_group_key'",
"INDEX_NAME = 'idx_admin_app_config_group_key'",
"uk_admin_app_config_app_group_key (app_code, `group`, `key`)",
} {
if !strings.Contains(sqlText, snippet) {
t.Fatalf("h5 app scope migration missing %q", snippet)
}
}
// 086 只负责补齐通用 scoped 存储结构091 再按 H5 的强隔离边界迁走历史空作用域数据。
for _, appCode := range []string{"huwaa", "fami", "yumi", "aslan"} {
if strings.Contains(sqlText, "'"+appCode+"'") {
t.Fatalf("migration must not hard-code app_code %q", appCode)
}
}
}
func TestH5StrictAppScopeMigrationMovesLegacyLinksToDefaultApp(t *testing.T) {
body, err := os.ReadFile("../../../migrations/091_h5_config_strict_app_scope.sql")
if err != nil {
t.Fatalf("read strict h5 app scope migration failed: %v", err)
}
sqlText := string(body)
for _, snippet := range []string{
"scoped.app_code = 'lalu'",
"legacy.app_code = ''",
"legacy.`group` = 'h5-links'",
"SET app_code = 'lalu'",
"`group` = 'h5-links'",
} {
if !strings.Contains(sqlText, snippet) {
t.Fatalf("strict h5 app scope migration missing %q", snippet)
}
}
}
func TestFloatingScreenConfigMapsActivityOwnerContract(t *testing.T) {
item := floatingScreenConfigFromProto(" FAMI ", &activityv1.GameWinBroadcastConfig{
Enabled: true, MinWinCoins: 9000, UpdatedByAdminId: 7, CreatedAtMs: 100, UpdatedAtMs: 200,
})
if item.AppCode != "fami" || !item.Enabled || item.GameWinMinCoins != 9000 || item.UpdatedByAdminID != 7 || item.UpdatedAtMS != 200 {
t.Fatalf("floating screen config mapping mismatch: %+v", item)
}
}
func TestFloatingScreenNavigationUsesAppConfigChildMenu(t *testing.T) {
body, err := os.ReadFile("../../../migrations/114_game_win_floating_screen_navigation.sql")
if err != nil {
t.Fatalf("read floating screen navigation migration: %v", err)
}
sqlText := string(body)
for _, snippet := range []string{"'飘屏配置'", "'app-config-floating-screen'", "'/app-config/floating-screen'", "parent.code = 'app-config'", "'app-config:view'"} {
if !strings.Contains(sqlText, snippet) {
t.Fatalf("floating screen navigation migration missing %q", snippet)
}
}
}
func TestRoomDiscoveryNavigationMigratesLegacyScopeWithoutRuntimeBranch(t *testing.T) {
body, err := os.ReadFile("../../../migrations/117_room_discovery_scope_config.sql")
if err != nil {
t.Fatalf("read room discovery navigation migration: %v", err)
}
sqlText := string(body)
for _, snippet := range []string{"'房间发现范围'", "'app-config-room-discovery'", "'/app-config/room-discovery'", "parent.code = 'app-config'", "'app-config:view'"} {
if !strings.Contains(sqlText, snippet) {
t.Fatalf("room discovery navigation migration missing %q", snippet)
}
}
// 旧 Huwaa 分支必须一次性落成配置数据,否则发布新代码后会错误退回 viewer_region运行时仍只读 App 配置。
for _, snippet := range []string{"'huwaa'", "'room-discovery'", "'scope'", "'all_active_regions'"} {
if !strings.Contains(strings.ToLower(sqlText), snippet) {
t.Fatalf("room discovery legacy capability migration missing %q", snippet)
}
}
for _, appCode := range []string{"fami", "lalu", "yumi", "aslan"} {
if strings.Contains(strings.ToLower(sqlText), "'"+appCode+"'") {
t.Fatalf("room discovery migration must not seed unrelated app_code %q", appCode)
}
}
}
func TestRoomDiscoveryConfigModelAcceptsOnlySupportedScopes(t *testing.T) {
item, err := roomDiscoveryConfigModel(" FUTURE-APP ", roomDiscoveryConfigRequest{Scope: " ALL_ACTIVE_REGIONS "})
if err != nil {
t.Fatalf("valid room discovery scope failed: %v", err)
}
if item.AppCode != "future-app" || item.Group != roomDiscoveryGroup || item.Key != roomDiscoveryScopeKey || item.Value != roomDiscoveryScopeAllActiveRegions {
t.Fatalf("room discovery model mismatch: %+v", item)
}
if _, err := roomDiscoveryConfigModel("future-app", roomDiscoveryConfigRequest{Scope: "global"}); err == nil {
t.Fatal("unsupported room discovery scope must fail")
}
legacy := roomDiscoveryConfigFromModel(model.AppConfig{AppCode: "future-app", Value: "global", UpdatedAtMS: 99})
if legacy.Scope != roomDiscoveryScopeViewerRegion || legacy.UpdatedAtMS != 99 {
t.Fatalf("invalid stored scope must fail closed: %+v", legacy)
}
}
func TestRuntimeCapabilitiesConfigUsesGenericDefaultsAndValidatesEnums(t *testing.T) {
defaults, err := runtimeCapabilitiesConfigFromModels(" FUTURE-APP ", nil)
if err != nil {
t.Fatalf("runtime capability defaults failed: %v", err)
}
if defaults.AppCode != "future-app" || defaults.ActivityLeaderboardScope != activityLeaderboardScopeAppGlobal || defaults.ResourceEquipCommandIDPolicy != resourceEquipCommandIDPolicyLegacyOptional {
t.Fatalf("runtime capability defaults mismatch: %+v", defaults)
}
items, err := runtimeCapabilitiesConfigModels(" FUTURE-APP ", runtimeCapabilitiesConfigRequest{
ActivityLeaderboardScope: " VIEWER_REGION ",
ResourceEquipCommandIDPolicy: " REQUIRED ",
})
if err != nil {
t.Fatalf("valid runtime capabilities failed: %v", err)
}
if len(items) != 2 || items[0].AppCode != "future-app" || items[0].Value != activityLeaderboardScopeViewerRegion || items[1].Value != resourceEquipCommandIDPolicyRequired {
t.Fatalf("runtime capability models mismatch: %+v", items)
}
if _, err := runtimeCapabilitiesConfigModels("future-app", runtimeCapabilitiesConfigRequest{ActivityLeaderboardScope: "all", ResourceEquipCommandIDPolicy: "required"}); err == nil {
t.Fatal("unsupported leaderboard scope must fail")
}
if _, err := runtimeCapabilitiesConfigModels("future-app", runtimeCapabilitiesConfigRequest{ActivityLeaderboardScope: "app_global", ResourceEquipCommandIDPolicy: "optional"}); err == nil {
t.Fatal("unsupported equip policy must fail")
}
}
func TestRuntimeCapabilitiesMigrationSeedsOnlyCompatibilityRows(t *testing.T) {
body, err := os.ReadFile("../../../migrations/118_runtime_capabilities_config.sql")
if err != nil {
t.Fatalf("read runtime capabilities migration: %v", err)
}
sqlText := strings.ToLower(string(body))
for _, snippet := range []string{
"'lalu', 'runtime-capabilities', 'activity_leaderboard_scope', 'viewer_region'",
"'fami', 'runtime-capabilities', 'activity_leaderboard_scope', 'viewer_region'",
"'fami', 'runtime-capabilities', 'resource_equip_command_id_policy', 'required'",
"'app-config-runtime-capabilities'",
"'/app-config/runtime-capabilities'",
} {
if !strings.Contains(sqlText, snippet) {
t.Fatalf("runtime capabilities migration missing %q", snippet)
}
}
if strings.Contains(sqlText, "'lalu', 'runtime-capabilities', 'resource_equip_command_id_policy'") {
t.Fatal("lalu must retain the missing-key legacy_optional default")
}
}
func TestRoleScopePoliciesConfigMapsOnlyValidatedOwnerSnapshot(t *testing.T) {
policies, err := roleScopePoliciesFromRequest(roleScopePoliciesConfigRequest{
OrganizationScope: " GLOBAL ",
ManagerBaseScope: "country",
ManagerExpandedScope: "region",
ManagerRegionExpansionConfigurable: true,
})
if err != nil {
t.Fatalf("valid role scope config failed: %v", err)
}
policies[0].UpdatedAtMS = 100
policies[1].UpdatedAtMS = 200
config, err := roleScopePoliciesConfigFromPolicies(" TENANT-A ", policies)
if err != nil {
t.Fatalf("map role scope owner response: %v", err)
}
if config.AppCode != "tenant-a" || config.OrganizationScope != roleScopeGlobal || config.ManagerBaseScope != roleScopeCountry || config.ManagerExpandedScope != roleScopeRegion || !config.ManagerRegionExpansionConfigurable || config.UpdatedAtMS != 200 {
t.Fatalf("role scope config mismatch: %+v", config)
}
if _, err := roleScopePoliciesFromRequest(roleScopePoliciesConfigRequest{
OrganizationScope: roleScopeRegion,
ManagerBaseScope: roleScopeCountry,
ManagerExpandedScope: roleScopeGlobal,
}); err == nil {
t.Fatal("disabled manager expansion must keep one scope")
}
if _, err := roleScopePoliciesConfigFromPolicies("tenant-a", []userclient.RoleScopePolicy{policies[0]}); err == nil {
t.Fatal("partial owner response must fail closed")
}
}
func TestH5LinkModelFromPayloadValidatesDynamicConfig(t *testing.T) {
item, err := h5LinkModelFromPayload(" YUMI ", h5LinkPayload{
Key: "host-center.v2",
Label: "Host Center",
URL: "https://h5.example.com/host",
})
if err != nil {
t.Fatalf("h5 config should be valid: %v", err)
}
if item.AppCode != "yumi" || item.Group != h5LinkGroup || item.Key != "host-center.v2" || item.Description != "Host Center" || item.Value != "https://h5.example.com/host" {
t.Fatalf("h5 config model mismatch: %+v", item)
}
}
func TestH5LinkModelFromPayloadRejectsInvalidKey(t *testing.T) {
_, err := h5LinkModelFromPayload("lalu", h5LinkPayload{
Key: "host center",
Label: "Host Center",
URL: "https://h5.example.com/host",
})
if err == nil {
t.Fatal("expected invalid key to fail")
}
}
func TestBannerModelFromRequestRequiresRoomSmallImageForRoomScope(t *testing.T) {
_, err := bannerModelFromRequest("lalu", bannerRequest{
CoverURL: "https://cdn.example.com/banner.png",
BannerType: "h5",
DisplayScope: bannerDisplayScopeRoom,
Param: "https://h5.example.com/activity",
Status: bannerStatusActive,
Platform: "android",
EndsAtMs: time.Now().UTC().Add(time.Hour).UnixMilli(),
})
if err == nil {
t.Fatal("expected room scope without room small image to fail")
}
}
func TestBannerModelFromRequestKeepsRoomScopeScheduleFields(t *testing.T) {
startsAtMs := time.Now().UTC().Add(time.Hour).UnixMilli()
endsAtMs := startsAtMs + int64(time.Hour/time.Millisecond)
item, err := bannerModelFromRequest("lalu", bannerRequest{
CoverURL: "https://cdn.example.com/banner.png",
RoomSmallImageURL: "https://cdn.example.com/banner-small.png",
BannerType: "h5",
DisplayScopes: []string{bannerDisplayScopeHome, bannerDisplayScopeRoom},
Param: "https://h5.example.com/activity",
Status: bannerStatusActive,
Platform: "android",
StartsAtMs: startsAtMs,
EndsAtMs: endsAtMs,
})
if err != nil {
t.Fatalf("banner model should be valid: %v", err)
}
if item.DisplayScope != "home,room" || item.RoomSmallImageURL == "" {
t.Fatalf("room display fields mismatch: %+v", item)
}
if item.StartsAtMS != startsAtMs || item.EndsAtMS != endsAtMs || item.Status != bannerStatusActive {
t.Fatalf("schedule fields mismatch: %+v", item)
}
}
func TestBannerModelFromRequestExpiresEndedActiveBanner(t *testing.T) {
item, err := bannerModelFromRequest("lalu", bannerRequest{
CoverURL: "https://cdn.example.com/banner.png",
RoomSmallImageURL: "https://cdn.example.com/ignored.png",
BannerType: "app",
DisplayScope: bannerDisplayScopeHome,
Status: bannerStatusActive,
Platform: "ios",
EndsAtMs: 1,
})
if err != nil {
t.Fatalf("banner model should accept ended active banner: %v", err)
}
if item.Status != bannerStatusExpired {
t.Fatalf("ended active banner must become expired: %+v", item)
}
if item.RoomSmallImageURL != "" {
t.Fatalf("non-room banner must not retain room small image: %+v", item)
}
}
func TestAppBannerFromModelReturnsDisplayScopes(t *testing.T) {
item := appBannerFromModel(bannerModel("home,room,recharge,me"))
if item.DisplayScope != "home,room,recharge,me" {
t.Fatalf("display scope should keep stored value: %+v", item)
}
if len(item.DisplayScopes) != 4 || item.DisplayScopes[0] != bannerDisplayScopeHome || item.DisplayScopes[1] != bannerDisplayScopeRoom || item.DisplayScopes[2] != bannerDisplayScopeRecharge || item.DisplayScopes[3] != bannerDisplayScopeMe {
t.Fatalf("display scopes mismatch: %+v", item.DisplayScopes)
}
}
func TestSplashScreenModelFromRequestValidatesH5Config(t *testing.T) {
startsAtMs := time.Now().UTC().Add(time.Hour).UnixMilli()
endsAtMs := startsAtMs + int64(time.Hour/time.Millisecond)
item, err := splashScreenModelFromRequest("lalu", splashScreenRequest{
CoverURL: "https://cdn.example.com/splash.png",
SplashType: "h5",
Param: "https://h5.example.com/splash",
Status: bannerStatusActive,
Platform: "android",
SortOrder: 7,
RegionID: 1001,
CountryCode: "us",
Description: "launch campaign",
DisplayDurationMs: 4200,
StartsAtMs: startsAtMs,
EndsAtMs: endsAtMs,
})
if err != nil {
t.Fatalf("splash screen model should be valid: %v", err)
}
if item.AppCode != "lalu" || item.SplashType != "h5" || item.CountryCode != "US" || item.DisplayDurationMS != 4200 || item.StartsAtMS != startsAtMs || item.EndsAtMS != endsAtMs {
t.Fatalf("splash screen model mismatch: %+v", item)
}
}
func TestSplashScreenModelFromRequestDefaultsDisplayDuration(t *testing.T) {
item, err := splashScreenModelFromRequest("lalu", splashScreenRequest{
CoverURL: "https://cdn.example.com/splash.png",
SplashType: "app",
Status: bannerStatusActive,
Platform: "android",
})
if err != nil {
t.Fatalf("splash screen model should accept missing display duration: %v", err)
}
if item.DisplayDurationMS != splashDefaultDisplayDurationMS {
t.Fatalf("display duration should default to %d, got %+v", splashDefaultDisplayDurationMS, item)
}
}
func TestSplashScreenModelFromRequestRejectsNegativeDisplayDuration(t *testing.T) {
_, err := splashScreenModelFromRequest("lalu", splashScreenRequest{
CoverURL: "https://cdn.example.com/splash.png",
SplashType: "app",
Status: bannerStatusActive,
Platform: "android",
DisplayDurationMs: -1,
})
if err == nil {
t.Fatal("expected negative display duration to fail")
}
}
func TestSplashScreenModelFromRequestRequiresH5Link(t *testing.T) {
_, err := splashScreenModelFromRequest("lalu", splashScreenRequest{
CoverURL: "https://cdn.example.com/splash.png",
SplashType: "h5",
Status: bannerStatusActive,
Platform: "android",
})
if err == nil {
t.Fatal("expected h5 splash screen without link to fail")
}
}
func TestSplashScreenModelFromRequestExpiresEndedActiveConfig(t *testing.T) {
item, err := splashScreenModelFromRequest("lalu", splashScreenRequest{
CoverURL: "https://cdn.example.com/splash.png",
SplashType: "app",
Status: bannerStatusActive,
Platform: "ios",
EndsAtMs: 1,
})
if err != nil {
t.Fatalf("ended app splash screen should be accepted: %v", err)
}
if item.Status != bannerStatusExpired {
t.Fatalf("ended active splash screen must become expired: %+v", item)
}
}
func TestAppSplashScreenFromModelMapsDTOFields(t *testing.T) {
item := appSplashScreenFromModel(model.AppSplashScreen{
ID: 9,
AppCode: "lalu",
CoverURL: "https://cdn.example.com/splash.png",
SplashType: "app",
Param: "profile",
Status: bannerStatusDisabled,
Platform: "ios",
SortOrder: 3,
RegionID: 2002,
CountryCode: "BR",
Description: "disabled campaign",
DisplayDurationMS: 5000,
StartsAtMS: 1700000000000,
EndsAtMS: 1800000000000,
CreatedAtMS: 1690000000000,
UpdatedAtMS: 1700000002000,
})
if item.ID != 9 || item.SplashType != "app" || item.Param != "profile" || item.RegionID != 2002 || item.DisplayDurationMs != 5000 || item.UpdatedAtMs != 1700000002000 {
t.Fatalf("splash screen dto mismatch: %+v", item)
}
}
func TestPopupModelFromRequestValidatesOptionalImageAndPeriod(t *testing.T) {
startsAtMs := time.Now().UTC().Add(time.Hour).UnixMilli()
endsAtMs := startsAtMs + int64(time.Hour/time.Millisecond)
item, err := popupModelFromRequest("lalu", popupRequest{
Code: "new-user-popup",
Name: "新人活动",
ImageURL: "",
JumpType: "h5",
JumpURL: "https://h5.example.com/new-user",
DisplayPeriodDays: 2,
Status: bannerStatusActive,
SortOrder: 4,
StartsAtMs: startsAtMs,
EndsAtMs: endsAtMs,
})
if err != nil {
t.Fatalf("popup model should be valid: %v", err)
}
if item.AppCode != "lalu" || item.Code != "new-user-popup" || item.Name != "新人活动" || item.ImageURL != "" || item.JumpType != "h5" || item.DisplayPeriodDays != 2 || item.StartsAtMS != startsAtMs || item.EndsAtMS != endsAtMs {
t.Fatalf("popup model mismatch: %+v", item)
}
}
func TestPopupModelFromRequestRejectsInvalidJumpURL(t *testing.T) {
_, err := popupModelFromRequest("lalu", popupRequest{
Code: "activity-popup",
Name: "活动弹窗",
JumpType: "h5",
JumpURL: "https://h5.example.com/new user",
Status: bannerStatusActive,
})
if err == nil {
t.Fatal("expected popup h5 jump url with whitespace to fail")
}
}
func TestPopupModelFromRequestRejectsInvalidCode(t *testing.T) {
_, err := popupModelFromRequest("lalu", popupRequest{
Code: "activity popup",
Name: "活动弹窗",
JumpType: "app",
JumpURL: "activity/detail",
Status: bannerStatusActive,
})
if err == nil {
t.Fatal("expected popup code with whitespace to fail")
}
}
func TestPopupModelFromRequestExpiresEndedActivePopup(t *testing.T) {
item, err := popupModelFromRequest("lalu", popupRequest{
Code: "history-popup",
Name: "历史活动",
JumpType: "app",
JumpURL: "activity/detail",
Status: bannerStatusActive,
EndsAtMs: 1,
})
if err != nil {
t.Fatalf("ended popup should be accepted: %v", err)
}
if item.Status != bannerStatusExpired {
t.Fatalf("ended active popup must become expired: %+v", item)
}
}
func TestAppPopupFromModelMapsDTOFields(t *testing.T) {
item := appPopupFromModel(model.AppPopup{
ID: 10,
AppCode: "lalu",
Code: "recharge-popup",
Name: "充值活动",
ImageURL: "https://cdn.example.com/popup.png",
JumpType: "app",
JumpURL: "recharge",
DisplayPeriodDays: 0,
Status: bannerStatusDisabled,
SortOrder: 8,
StartsAtMS: 1700000000000,
EndsAtMS: 1800000000000,
CreatedAtMS: 1690000000000,
UpdatedAtMS: 1700000002000,
})
if item.ID != 10 || item.Code != "recharge-popup" || item.Name != "充值活动" || item.JumpType != "app" || item.JumpURL != "recharge" || item.DisplayPeriodDays != 0 || item.UpdatedAtMs != 1700000002000 {
t.Fatalf("popup dto mismatch: %+v", item)
}
}
func bannerModel(displayScope string) model.AppBanner {
return model.AppBanner{
ID: 1,
AppCode: "lalu",
CoverURL: "https://cdn.example.com/banner.png",
BannerType: "h5",
DisplayScope: displayScope,
Param: "https://h5.example.com/activity",
Status: bannerStatusActive,
Platform: "android",
}
}
func TestExploreTabModelFromRequestValidatesRequiredFields(t *testing.T) {
item, err := exploreTabModelFromRequest("lalu", exploreTabRequest{
Enabled: true,
H5URL: "https://h5.example.com/explore/live",
SortOrder: 3,
Tab: "Live",
})
if err != nil {
t.Fatalf("explore tab should be valid: %v", err)
}
if item.AppCode != "lalu" || item.Tab != "Live" || item.H5URL != "https://h5.example.com/explore/live" || !item.Enabled || item.SortOrder != 3 {
t.Fatalf("explore tab model mismatch: %+v", item)
}
}
func TestExploreTabModelFromRequestRejectsWhitespaceURL(t *testing.T) {
_, err := exploreTabModelFromRequest("lalu", exploreTabRequest{
Enabled: true,
H5URL: "https://h5.example.com/explore live",
Tab: "Live",
})
if err == nil {
t.Fatal("expected whitespace h5 url to fail")
}
}