From ed3e7096e35a272e31d561455110fdb5958ff339 Mon Sep 17 00:00:00 2001 From: zhx Date: Mon, 13 Jul 2026 15:23:59 +0800 Subject: [PATCH] =?UTF-8?q?app=E5=8C=BA=E5=9F=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/modules/appconfig/service.go | 19 ++-- .../modules/appconfig/service_test.go | 23 ++++- .../repository/app_config_repository.go | 29 +++++++ .../repository/app_config_repository_test.go | 49 ++++++++--- .../091_h5_config_strict_app_scope.sql | 18 ++++ .../internal/appconfig/mysql.go | 87 +++---------------- .../internal/appconfig/mysql_test.go | 27 ++---- 7 files changed, 133 insertions(+), 119 deletions(-) create mode 100644 server/admin/migrations/091_h5_config_strict_app_scope.sql diff --git a/server/admin/internal/modules/appconfig/service.go b/server/admin/internal/modules/appconfig/service.go index 02639a95..9d2c3b67 100644 --- a/server/admin/internal/modules/appconfig/service.go +++ b/server/admin/internal/modules/appconfig/service.go @@ -131,7 +131,8 @@ func NewService(store *repository.Store) *AppConfigService { func (s *AppConfigService) ListH5Links(appCode string) ([]H5Link, error) { appCode = appctx.Normalize(appCode) - configs, err := s.store.ListScopedAppConfigs(appCode, h5LinkGroup) + // H5 是 App 品牌和运营内容的一部分,未配置时必须返回空列表,不能继承其他 App 的历史链接。 + configs, err := s.store.ListOwnedAppConfigs(appCode, h5LinkGroup) if err != nil { return nil, err } @@ -176,16 +177,16 @@ func (s *AppConfigService) CreateH5Link(appCode string, req h5LinkPayload) (H5Li if err != nil { return H5Link{}, err } - if _, err := s.store.GetScopedAppConfig(appCode, h5LinkGroup, item.Key); err == nil { + if _, err := s.store.GetOwnedAppConfig(appCode, h5LinkGroup, item.Key); err == nil { return H5Link{}, fmt.Errorf("h5 link key already exists: %s", item.Key) } else if !errors.Is(err, gorm.ErrRecordNotFound) { return H5Link{}, err } - // 创建同样使用 upsert,以便显式恢复当前 App 曾删除过的 inherited key,而不触碰 legacy 基线。 + // upsert 只以三元唯一键写当前 App,重试不会触碰其他 App 的同名 H5 key。 if err := s.store.UpsertScopedAppConfigs(appCode, []model.AppConfig{item}); err != nil { return H5Link{}, err } - stored, err := s.store.GetScopedAppConfig(appCode, h5LinkGroup, item.Key) + stored, err := s.store.GetOwnedAppConfig(appCode, h5LinkGroup, item.Key) if err != nil { return H5Link{}, err } @@ -194,7 +195,7 @@ func (s *AppConfigService) CreateH5Link(appCode string, req h5LinkPayload) (H5Li func (s *AppConfigService) UpdateH5Link(appCode string, key string, req h5LinkPayload) (H5Link, error) { appCode = appctx.Normalize(appCode) - item, err := s.store.GetScopedAppConfig(appCode, h5LinkGroup, key) + item, err := s.store.GetOwnedAppConfig(appCode, h5LinkGroup, key) if err != nil { return H5Link{}, err } @@ -203,11 +204,11 @@ func (s *AppConfigService) UpdateH5Link(appCode string, key string, req h5LinkPa if err != nil { return H5Link{}, err } - // inherited 基线永远不原地更新;upsert 会为当前 App 创建或覆盖 scoped 行。 + // 请求作用域决定 app_code;请求体只能更新当前 App 已存在的 key。 if err := s.store.UpsertScopedAppConfigs(appCode, []model.AppConfig{updated}); err != nil { return H5Link{}, err } - stored, err := s.store.GetScopedAppConfig(appCode, h5LinkGroup, updated.Key) + stored, err := s.store.GetOwnedAppConfig(appCode, h5LinkGroup, updated.Key) if err != nil { return H5Link{}, err } @@ -216,10 +217,10 @@ func (s *AppConfigService) UpdateH5Link(appCode string, key string, req h5LinkPa func (s *AppConfigService) DeleteH5Link(appCode string, key string) error { appCode = appctx.Normalize(appCode) - if _, err := s.store.GetScopedAppConfig(appCode, h5LinkGroup, key); err != nil { + if _, err := s.store.GetOwnedAppConfig(appCode, h5LinkGroup, key); err != nil { return err } - return s.store.TombstoneScopedAppConfig(appCode, h5LinkGroup, key) + return s.store.DeleteOwnedAppConfig(appCode, h5LinkGroup, key) } func (s *AppConfigService) ListBanners(appCode string, options repository.AppBannerListOptions) ([]AppBanner, error) { diff --git a/server/admin/internal/modules/appconfig/service_test.go b/server/admin/internal/modules/appconfig/service_test.go index 11c3f2b3..d4aa23e7 100644 --- a/server/admin/internal/modules/appconfig/service_test.go +++ b/server/admin/internal/modules/appconfig/service_test.go @@ -9,7 +9,7 @@ import ( "hyapp-admin-server/internal/model" ) -func TestH5AppScopeMigrationKeepsAppAgnosticLegacyBaseline(t *testing.T) { +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) @@ -25,7 +25,7 @@ func TestH5AppScopeMigrationKeepsAppAgnosticLegacyBaseline(t *testing.T) { t.Fatalf("h5 app scope migration missing %q", snippet) } } - // 迁移不能枚举当前 App;空基线 + runtime override 才能让未来 app_code 自动继承旧配置。 + // 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) @@ -33,6 +33,25 @@ func TestH5AppScopeMigrationKeepsAppAgnosticLegacyBaseline(t *testing.T) { } } +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 TestH5LinkModelFromPayloadValidatesDynamicConfig(t *testing.T) { item, err := h5LinkModelFromPayload(" YUMI ", h5LinkPayload{ Key: "host-center.v2", diff --git a/server/admin/internal/repository/app_config_repository.go b/server/admin/internal/repository/app_config_repository.go index 4e2edc26..b3476b29 100644 --- a/server/admin/internal/repository/app_config_repository.go +++ b/server/admin/internal/repository/app_config_repository.go @@ -108,6 +108,35 @@ func (s *Store) GetScopedAppConfig(appCode string, group string, key string) (mo return items[0], nil } +// ListOwnedAppConfigs 只返回当前 App 自己持有的配置行。H5 等产品入口不能继承空 app_code +// 基线,否则新增 App 在尚未配置时会直接暴露默认 App 的运营链接,形成跨 App 内容串用。 +func (s *Store) ListOwnedAppConfigs(appCode string, group string) ([]model.AppConfig, error) { + var items []model.AppConfig + err := s.db. + Where("app_code = ? AND `group` = ? AND is_deleted = FALSE", normalizeScopedAppCode(appCode), strings.TrimSpace(group)). + Order("`key` ASC"). + Find(&items).Error + return items, err +} + +// GetOwnedAppConfig 和列表保持同一强隔离语义;更新与删除不能通过 legacy 基线找到其他 +// App 可见的同名 key,也不能把墓碑当成可编辑配置。 +func (s *Store) GetOwnedAppConfig(appCode string, group string, key string) (model.AppConfig, error) { + var item model.AppConfig + err := s.db. + Where("app_code = ? AND `group` = ? AND `key` = ? AND is_deleted = FALSE", normalizeScopedAppCode(appCode), strings.TrimSpace(group), strings.TrimSpace(key)). + First(&item).Error + return item, err +} + +// DeleteOwnedAppConfig 物理删除当前 App 的配置。强隔离配置不存在跨 App 基线回退,因此无需保留 +// 墓碑;删除后当前 App 应立即回到“未配置”状态。 +func (s *Store) DeleteOwnedAppConfig(appCode string, group string, key string) error { + return s.db. + Where("app_code = ? AND `group` = ? AND `key` = ?", normalizeScopedAppCode(appCode), strings.TrimSpace(group), strings.TrimSpace(key)). + Delete(&model.AppConfig{}).Error +} + // UpsertScopedAppConfigs 只写当前 app_code;调用方无法通过请求体覆盖租户作用域。 func (s *Store) UpsertScopedAppConfigs(appCode string, items []model.AppConfig) error { if len(items) == 0 { diff --git a/server/admin/internal/repository/app_config_repository_test.go b/server/admin/internal/repository/app_config_repository_test.go index ce63a2eb..9c58fa26 100644 --- a/server/admin/internal/repository/app_config_repository_test.go +++ b/server/admin/internal/repository/app_config_repository_test.go @@ -10,11 +10,11 @@ import ( func TestMergeScopedAppConfigsDoesNotLeakOverridesOrTombstonesAcrossApps(t *testing.T) { rows := []model.AppConfig{ - {AppCode: "", Group: "h5-links", Key: "admin", Description: "Legacy Admin", Value: "https://legacy.example.com/admin"}, - {AppCode: "", Group: "h5-links", Key: "host", Description: "Legacy Host", Value: "https://legacy.example.com/host"}, - {AppCode: "yumi", Group: "h5-links", Key: "admin", Description: "Yumi Admin", Value: "https://yumi.example.com/admin"}, - {AppCode: "yumi", Group: "h5-links", Key: "host", IsDeleted: true}, - {AppCode: "fami", Group: "h5-links", Key: "admin", Description: "Fami Admin", Value: "https://fami.example.com/admin"}, + {AppCode: "", Group: "runtime-config", Key: "admin", Description: "Legacy Admin", Value: "https://legacy.example.com/admin"}, + {AppCode: "", Group: "runtime-config", Key: "host", Description: "Legacy Host", Value: "https://legacy.example.com/host"}, + {AppCode: "yumi", Group: "runtime-config", Key: "admin", Description: "Yumi Admin", Value: "https://yumi.example.com/admin"}, + {AppCode: "yumi", Group: "runtime-config", Key: "host", IsDeleted: true}, + {AppCode: "fami", Group: "runtime-config", Key: "admin", Description: "Fami Admin", Value: "https://fami.example.com/admin"}, } yumi := mergeScopedAppConfigs(rows, "yumi") @@ -37,11 +37,11 @@ func TestTombstoneScopedAppConfigWritesOnlyCurrentApp(t *testing.T) { mock.ExpectBegin() mock.ExpectExec("INSERT INTO `admin_app_configs`"). - WithArgs("yumi", "h5-links", "host", "", "", true, sqlmock.AnyArg(), sqlmock.AnyArg(), true, sqlmock.AnyArg()). + WithArgs("yumi", "runtime-config", "host", "", "", true, sqlmock.AnyArg(), sqlmock.AnyArg(), true, sqlmock.AnyArg()). WillReturnResult(sqlmock.NewResult(9, 1)) mock.ExpectCommit() - if err := store.TombstoneScopedAppConfig(" YUMI ", "h5-links", "host"); err != nil { + if err := store.TombstoneScopedAppConfig(" YUMI ", "runtime-config", "host"); err != nil { t.Fatalf("write scoped tombstone failed: %v", err) } if err := mock.ExpectationsWereMet(); err != nil { @@ -54,15 +54,15 @@ func TestListScopedAppConfigsOverlaysBaselineAndHonorsTombstone(t *testing.T) { defer closeStore() mock.ExpectQuery("SELECT \\* FROM `admin_app_configs` WHERE `group` = \\? AND app_code IN \\(\\?,\\?\\) ORDER BY `key` ASC, app_code ASC"). - WithArgs("h5-links", "", "yumi"). + WithArgs("runtime-config", "", "yumi"). WillReturnRows(sqlmock.NewRows([]string{"id", "app_code", "group", "key", "value", "description", "is_deleted", "created_at_ms", "updated_at_ms"}). - AddRow(1, "", "h5-links", "admin", "https://legacy.example.com/admin", "Admin", false, int64(100), int64(100)). - AddRow(2, "yumi", "h5-links", "admin", "https://yumi.example.com/admin", "Yumi Admin", false, int64(200), int64(200)). - AddRow(3, "", "h5-links", "host", "https://legacy.example.com/host", "Host", false, int64(100), int64(100)). - AddRow(4, "yumi", "h5-links", "host", "", "", true, int64(300), int64(300)). - AddRow(5, "", "h5-links", "public", "https://legacy.example.com/public", "Public", false, int64(100), int64(100))) + AddRow(1, "", "runtime-config", "admin", "https://legacy.example.com/admin", "Admin", false, int64(100), int64(100)). + AddRow(2, "yumi", "runtime-config", "admin", "https://yumi.example.com/admin", "Yumi Admin", false, int64(200), int64(200)). + AddRow(3, "", "runtime-config", "host", "https://legacy.example.com/host", "Host", false, int64(100), int64(100)). + AddRow(4, "yumi", "runtime-config", "host", "", "", true, int64(300), int64(300)). + AddRow(5, "", "runtime-config", "public", "https://legacy.example.com/public", "Public", false, int64(100), int64(100))) - items, err := store.ListScopedAppConfigs(" YUMI ", "h5-links") + items, err := store.ListScopedAppConfigs(" YUMI ", "runtime-config") if err != nil { t.Fatalf("list scoped app configs failed: %v", err) } @@ -79,3 +79,24 @@ func TestListScopedAppConfigsOverlaysBaselineAndHonorsTombstone(t *testing.T) { t.Fatalf("sql expectations mismatch: %v", err) } } + +func TestListOwnedAppConfigsDoesNotReadLegacyOrOtherAppRows(t *testing.T) { + store, mock, closeStore := newRepositorySQLMock(t) + defer closeStore() + + mock.ExpectQuery("SELECT \\* FROM `admin_app_configs` WHERE app_code = \\? AND `group` = \\? AND is_deleted = FALSE ORDER BY `key` ASC"). + WithArgs("yumi", "h5-links"). + WillReturnRows(sqlmock.NewRows([]string{"id", "app_code", "group", "key", "value", "description", "is_deleted", "created_at_ms", "updated_at_ms"}). + AddRow(2, "yumi", "h5-links", "admin", "https://yumi.example.com/admin", "Yumi Admin", false, int64(200), int64(200))) + + items, err := store.ListOwnedAppConfigs(" YUMI ", "h5-links") + if err != nil { + t.Fatalf("list owned app configs failed: %v", err) + } + if len(items) != 1 || items[0].AppCode != "yumi" || items[0].Key != "admin" { + t.Fatalf("owned config scope mismatch: %+v", items) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("sql expectations mismatch: %v", err) + } +} diff --git a/server/admin/migrations/091_h5_config_strict_app_scope.sql b/server/admin/migrations/091_h5_config_strict_app_scope.sql new file mode 100644 index 00000000..b40055ea --- /dev/null +++ b/server/admin/migrations/091_h5_config_strict_app_scope.sql @@ -0,0 +1,18 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- 086 曾把迁移前的 H5 行保留为 app_code='' 全局基线,导致所有未配置 App 都继承同一批链接。 +-- 历史后台默认 App 是 lalu:若 lalu 已有同名 override,保留 override 并删除旧基线;其余基线归属 lalu。 +DELETE legacy +FROM admin_app_configs legacy +JOIN admin_app_configs scoped + ON scoped.app_code = 'lalu' + AND scoped.`group` = legacy.`group` + AND scoped.`key` = legacy.`key` +WHERE legacy.app_code = '' + AND legacy.`group` = 'h5-links'; + +UPDATE admin_app_configs +SET app_code = 'lalu', + is_deleted = FALSE +WHERE app_code = '' + AND `group` = 'h5-links'; diff --git a/services/gateway-service/internal/appconfig/mysql.go b/services/gateway-service/internal/appconfig/mysql.go index 3bd9adac..b398e7c6 100644 --- a/services/gateway-service/internal/appconfig/mysql.go +++ b/services/gateway-service/internal/appconfig/mysql.go @@ -6,7 +6,6 @@ import ( "database/sql" "encoding/json" "errors" - "sort" "strconv" "strings" "time" @@ -18,7 +17,7 @@ import ( const h5LinkGroup = "h5-links" const roomRegionWhitelistGroup = "room-region-whitelist" -const listH5LinksSQL = "SELECT app_code, `key`, COALESCE(description, ''), COALESCE(value, ''), is_deleted, updated_at_ms FROM admin_app_configs WHERE `group` = ? AND app_code IN ('', ?) ORDER BY `key` ASC, app_code ASC" +const listH5LinksSQL = "SELECT `key`, COALESCE(description, ''), COALESCE(value, ''), updated_at_ms FROM admin_app_configs WHERE app_code = ? AND `group` = ? AND is_deleted = FALSE ORDER BY `key` ASC" const getRoomRegionWhitelistSQL = "SELECT COALESCE(value, '') FROM admin_app_configs WHERE app_code = '' AND `group` = ? AND `key` = ? LIMIT 1" const bdLeaderPositionAliasSQL = ` SELECT position_alias @@ -72,16 +71,6 @@ type H5Link struct { UpdatedAtMs int64 `json:"updated_at_ms"` } -// storedH5Link 保留数据库作用域和墓碑字段;对 App 的公开响应只返回合并后的 H5Link。 -type storedH5Link struct { - AppCode string - Key string - Label string - URL string - IsDeleted bool - UpdatedAtMs int64 -} - // H5LinkQuery 是 H5 入口配置的可选用户态筛选条件。 type H5LinkQuery struct { AppCode string @@ -274,24 +263,30 @@ func (r *MySQLReader) ListH5Links(ctx context.Context, query H5LinkQuery) ([]H5L } appCode := normalizeAppCode(query.AppCode) - rows, err := r.db.QueryContext(ctx, listH5LinksSQL, h5LinkGroup, appCode) + // H5 链接是 App 专属运营内容;未配置的 App 返回空列表,不能回退到空 app_code 历史基线。 + rows, err := r.db.QueryContext(ctx, listH5LinksSQL, appCode, h5LinkGroup) if err != nil { return nil, err } defer rows.Close() - stored := make([]storedH5Link, 0) + items := make([]H5Link, 0) for rows.Next() { - var item storedH5Link - if err := rows.Scan(&item.AppCode, &item.Key, &item.Label, &item.URL, &item.IsDeleted, &item.UpdatedAtMs); err != nil { + var item H5Link + if err := rows.Scan(&item.Key, &item.Label, &item.URL, &item.UpdatedAtMs); err != nil { return nil, err } - stored = append(stored, item) + item.Key = strings.TrimSpace(item.Key) + item.Label = strings.TrimSpace(item.Label) + if item.Label == "" { + item.Label = item.Key + } + item.URL = strings.TrimSpace(item.URL) + items = append(items, item) } if err := rows.Err(); err != nil { return nil, err } - items := mergeStoredH5Links(stored, appCode) if alias, err := r.bdLeaderPositionAlias(ctx, query); err != nil { return nil, err } else if alias != "" { @@ -300,62 +295,6 @@ func (r *MySQLReader) ListH5Links(ctx context.Context, query H5LinkQuery) ([]H5L return items, nil } -// mergeStoredH5Links 与 admin-server 使用同一规则:App override 覆盖 legacy 基线,墓碑阻止已删除 key 回退重现。 -func mergeStoredH5Links(rows []storedH5Link, appCode string) []H5Link { - appCode = normalizeAppCode(appCode) - baseline := make(map[string]storedH5Link) - overrides := make(map[string]storedH5Link) - for _, item := range rows { - item.AppCode = strings.ToLower(strings.TrimSpace(item.AppCode)) - item.Key = strings.TrimSpace(item.Key) - item.Label = strings.TrimSpace(item.Label) - item.URL = strings.TrimSpace(item.URL) - if item.Key == "" { - continue - } - switch item.AppCode { - case "": - baseline[item.Key] = item - case appCode: - overrides[item.Key] = item - } - } - - keys := make(map[string]struct{}, len(baseline)+len(overrides)) - for key := range baseline { - keys[key] = struct{}{} - } - for key := range overrides { - keys[key] = struct{}{} - } - sortedKeys := make([]string, 0, len(keys)) - for key := range keys { - sortedKeys = append(sortedKeys, key) - } - sort.Strings(sortedKeys) - - items := make([]H5Link, 0, len(sortedKeys)) - for _, key := range sortedKeys { - stored, overridden := overrides[key] - if !overridden { - stored = baseline[key] - } - if stored.IsDeleted { - continue - } - if stored.Label == "" { - stored.Label = stored.Key - } - items = append(items, H5Link{ - Key: stored.Key, - Label: stored.Label, - URL: stored.URL, - UpdatedAtMs: stored.UpdatedAtMs, - }) - } - return items -} - func (r *MySQLReader) bdLeaderPositionAlias(ctx context.Context, query H5LinkQuery) (string, error) { if query.UserID <= 0 { return "", nil diff --git a/services/gateway-service/internal/appconfig/mysql_test.go b/services/gateway-service/internal/appconfig/mysql_test.go index 9aff7951..23b6c26f 100644 --- a/services/gateway-service/internal/appconfig/mysql_test.go +++ b/services/gateway-service/internal/appconfig/mysql_test.go @@ -8,7 +8,7 @@ import ( "github.com/DATA-DOG/go-sqlmock" ) -func TestListH5LinksScopesQueryAndMergesLegacyBaseline(t *testing.T) { +func TestListH5LinksReadsOnlyRequestedApp(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("new sql mock failed: %v", err) @@ -16,13 +16,10 @@ func TestListH5LinksScopesQueryAndMergesLegacyBaseline(t *testing.T) { defer db.Close() mock.ExpectQuery(regexp.QuoteMeta(listH5LinksSQL)). - WithArgs(h5LinkGroup, "yumi"). - WillReturnRows(sqlmock.NewRows([]string{"app_code", "key", "description", "value", "is_deleted", "updated_at_ms"}). - AddRow("", "admin", "Admin", "https://legacy.example.com/admin", false, int64(100)). - AddRow("yumi", "admin", "Yumi Admin", "https://yumi.example.com/admin", false, int64(200)). - AddRow("", "host", "Host", "https://legacy.example.com/host", false, int64(100)). - AddRow("yumi", "host", "", "", true, int64(300)). - AddRow("", "public", "", "https://legacy.example.com/public", false, int64(100))) + WithArgs("yumi", h5LinkGroup). + WillReturnRows(sqlmock.NewRows([]string{"key", "description", "value", "updated_at_ms"}). + AddRow("admin", "Yumi Admin", "https://yumi.example.com/admin", int64(200)). + AddRow("public", "", "https://yumi.example.com/public", int64(300))) reader := &MySQLReader{db: db} items, err := reader.ListH5Links(context.Background(), H5LinkQuery{AppCode: " YUMI "}) @@ -35,24 +32,14 @@ func TestListH5LinksScopesQueryAndMergesLegacyBaseline(t *testing.T) { if items[0].Key != "admin" || items[0].Label != "Yumi Admin" || items[0].URL != "https://yumi.example.com/admin" { t.Fatalf("scoped override did not replace baseline: %+v", items[0]) } - if items[1].Key != "public" || items[1].Label != "public" || items[1].URL != "https://legacy.example.com/public" { - t.Fatalf("legacy baseline was not retained: %+v", items[1]) + if items[1].Key != "public" || items[1].Label != "public" || items[1].URL != "https://yumi.example.com/public" { + t.Fatalf("requested app config mismatch: %+v", items[1]) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations mismatch: %v", err) } } -func TestMergeStoredH5LinksKeepsBaselineForUnknownFutureApp(t *testing.T) { - items := mergeStoredH5Links([]storedH5Link{ - {AppCode: "", Key: "host", Label: "Host", URL: "https://legacy.example.com/host"}, - {AppCode: "another-app", Key: "host", Label: "Other Host", URL: "https://other.example.com/host"}, - }, "future-app") - if len(items) != 1 || items[0].Label != "Host" || items[0].URL != "https://legacy.example.com/host" { - t.Fatalf("future app must inherit only the legacy baseline: %+v", items) - } -} - func TestNormalizeBannerDisplayScopeAcceptsChineseLabels(t *testing.T) { cases := map[string]string{ "": "home",