Exclude translations from cookie banner version snapshots

Translation changes are cosmetic, not consent-contract changes,
so they should not trigger a version bump. Translations are now
loaded live from the database at serve time instead of being
frozen in the snapshot.

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-05-02 18:10:18 +04:00
parent 685ff91d74
commit 887453fee2
5 changed files with 58 additions and 64 deletions

View File

@@ -472,7 +472,7 @@ func TestCookieBannerVersioning_ExcludedPattern(t *testing.T) {
}) })
} }
func TestCookieBannerVersioning_NoOpTranslation(t *testing.T) { func TestCookieBannerVersioning_TranslationChangesNeverBump(t *testing.T) {
t.Parallel() t.Parallel()
t.Run("re-upserting identical JSON does not bump version", func(t *testing.T) { t.Run("re-upserting identical JSON does not bump version", func(t *testing.T) {
@@ -481,13 +481,11 @@ func TestCookieBannerVersioning_NoOpTranslation(t *testing.T) {
bannerID := factory.CreateCookieBanner(owner) bannerID := factory.CreateCookieBanner(owner)
// Insert a custom translation, then publish.
const customJSON = `{"banner_title":"Cookie Bar","button_accept_all":"Accept"}` const customJSON = `{"banner_title":"Cookie Bar","button_accept_all":"Accept"}`
upsertTranslation(t, owner, bannerID, "it", customJSON) upsertTranslation(t, owner, bannerID, "it", customJSON)
published := publishBanner(t, owner, bannerID) published := publishBanner(t, owner, bannerID)
baseline := published.Version baseline := published.Version
// Re-upsert the same JSON.
upsertTranslation(t, owner, bannerID, "it", customJSON) upsertTranslation(t, owner, bannerID, "it", customJSON)
got := latestVersion(t, owner, bannerID) got := latestVersion(t, owner, bannerID)
@@ -495,25 +493,35 @@ func TestCookieBannerVersioning_NoOpTranslation(t *testing.T) {
assert.Equal(t, "PUBLISHED", got.State) assert.Equal(t, "PUBLISHED", got.State)
}) })
t.Run("whitespace-only and key-order differences do not bump version", func(t *testing.T) { t.Run("changing translation content does not bump version", func(t *testing.T) {
t.Parallel() t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner) owner := testutil.NewClient(t, testutil.RoleOwner)
bannerID := factory.CreateCookieBanner(owner) bannerID := factory.CreateCookieBanner(owner)
const compact = `{"banner_title":"Cookie Bar","button_accept_all":"Accept"}` upsertTranslation(t, owner, bannerID, "it", `{"banner_title":"Cookie Bar"}`)
const reformatted = `{
"button_accept_all": "Accept",
"banner_title": "Cookie Bar"
}`
upsertTranslation(t, owner, bannerID, "it", compact)
published := publishBanner(t, owner, bannerID) published := publishBanner(t, owner, bannerID)
baseline := published.Version baseline := published.Version
upsertTranslation(t, owner, bannerID, "it", reformatted) upsertTranslation(t, owner, bannerID, "it", `{"banner_title":"Barra dei Cookie"}`)
got := latestVersion(t, owner, bannerID) got := latestVersion(t, owner, bannerID)
assert.Equal(t, baseline, got.Version, "JSON formatting differences should be canonicalised") assert.Equal(t, baseline, got.Version, "translation content changes should not bump the version")
assert.Equal(t, "PUBLISHED", got.State)
})
t.Run("adding a new language does not bump version", func(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
bannerID := factory.CreateCookieBanner(owner)
published := publishBanner(t, owner, bannerID)
baseline := published.Version
upsertTranslation(t, owner, bannerID, "fr", `{"banner_title":"Bandeau cookies"}`)
got := latestVersion(t, owner, bannerID)
assert.Equal(t, baseline, got.Version, "adding a new language should not bump the version")
assert.Equal(t, "PUBLISHED", got.State) assert.Equal(t, "PUBLISHED", got.State)
}) })
} }

View File

@@ -358,9 +358,8 @@ func (s *Service) ensureDraftVersion(
banner *coredata.CookieBanner, banner *coredata.CookieBanner,
categories coredata.CookieCategories, categories coredata.CookieCategories,
allPatterns coredata.CookiePatterns, allPatterns coredata.CookiePatterns,
translations coredata.CookieBannerTranslations,
) (*coredata.CookieBannerVersion, error) { ) (*coredata.CookieBannerVersion, error) {
snapshot := buildSnapshot(banner, categories, allPatterns, translations) snapshot := buildSnapshot(banner, categories, allPatterns)
var latest coredata.CookieBannerVersion var latest coredata.CookieBannerVersion
err := latest.LoadLatestByCookieBannerID(ctx, tx, scope, banner.ID) err := latest.LoadLatestByCookieBannerID(ctx, tx, scope, banner.ID)
@@ -440,12 +439,7 @@ func (s *Service) ensureDraftVersionForBanner(
return nil, fmt.Errorf("cannot load cookie patterns: %w", err) return nil, fmt.Errorf("cannot load cookie patterns: %w", err)
} }
var translations coredata.CookieBannerTranslations return s.ensureDraftVersion(ctx, tx, scope, &banner, categories, allPatterns)
if err := translations.LoadAllByCookieBannerID(ctx, tx, scope, bannerID); err != nil {
return nil, fmt.Errorf("cannot load cookie banner translations: %w", err)
}
return s.ensureDraftVersion(ctx, tx, scope, &banner, categories, allPatterns, translations)
} }
func (s *Service) CreateCookieBanner( func (s *Service) CreateCookieBanner(
@@ -1888,7 +1882,18 @@ func (s *Service) GetActiveBannerConfig(
return fmt.Errorf("cannot get version snapshot: %w", err) return fmt.Errorf("cannot get version snapshot: %w", err)
} }
config = buildBannerConfig(&banner, &version, &snapshot, lang) var categories coredata.CookieCategories
if err := categories.LoadAllByCookieBannerID(ctx, conn, scope, banner.ID); err != nil {
return fmt.Errorf("cannot load cookie categories: %w", err)
}
var translations coredata.CookieBannerTranslations
if err := translations.LoadAllByCookieBannerID(ctx, conn, scope, banner.ID); err != nil {
return fmt.Errorf("cannot load cookie banner translations: %w", err)
}
resolved := resolveTranslations(translations, categories)
config = buildBannerConfig(&banner, &version, &snapshot, resolved, lang)
return nil return nil
}, },
@@ -1904,6 +1909,7 @@ func buildBannerConfig(
banner *coredata.CookieBanner, banner *coredata.CookieBanner,
version *coredata.CookieBannerVersion, version *coredata.CookieBannerVersion,
snapshot *coredata.CookieBannerVersionSnapshot, snapshot *coredata.CookieBannerVersionSnapshot,
translations map[string]coredata.CookieBannerVersionSnapshotTranslation,
lang string, lang string,
) *BannerConfig { ) *BannerConfig {
defaultLang := snapshot.DefaultLanguage defaultLang := snapshot.DefaultLanguage
@@ -1913,7 +1919,7 @@ func buildBannerConfig(
resolvedLang := defaultLang resolvedLang := defaultLang
if lang != "" { if lang != "" {
if _, ok := snapshot.Translations[lang]; ok { if _, ok := translations[lang]; ok {
resolvedLang = lang resolvedLang = lang
} }
} }
@@ -1921,7 +1927,7 @@ func buildBannerConfig(
categories := snapshot.Categories categories := snapshot.Categories
texts := make(map[string]string) texts := make(map[string]string)
if t, ok := snapshot.Translations[resolvedLang]; ok { if t, ok := translations[resolvedLang]; ok {
maps.Copy(texts, t.UI) maps.Copy(texts, t.UI)
if len(t.Categories) == len(categories) { if len(t.Categories) == len(categories) {
@@ -2038,10 +2044,6 @@ func (s *Service) UpsertCookieBannerTranslation(
return fmt.Errorf("cannot load cookie banner translation: %w", err) return fmt.Errorf("cannot load cookie banner translation: %w", err)
} }
if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, req.CookieBannerID); err != nil {
return fmt.Errorf("cannot ensure draft version: %w", err)
}
return nil return nil
}, },
) )

View File

@@ -49,14 +49,6 @@ func TestSnapshotsEqual(t *testing.T) {
PostHogConsent: false, PostHogConsent: false,
}, },
}, },
Translations: map[string]coredata.CookieBannerVersionSnapshotTranslation{
"fr": {
UI: map[string]string{"title": "Cookies"},
Categories: []coredata.CookieBannerVersionSnapshotCategoryTranslation{
{Name: "Analyse", Description: "Cookies d'analyse"},
},
},
},
} }
} }
@@ -103,19 +95,6 @@ func TestSnapshotsEqual(t *testing.T) {
assert.False(t, snapshotsEqual(a, b)) assert.False(t, snapshotsEqual(a, b))
}) })
t.Run("differing translation UI is not equal", func(t *testing.T) {
t.Parallel()
a := baseSnapshot()
b := baseSnapshot()
b.Translations["fr"] = coredata.CookieBannerVersionSnapshotTranslation{
UI: map[string]string{"title": "Cookies updated"},
Categories: a.Translations["fr"].Categories,
}
assert.False(t, snapshotsEqual(a, b))
})
t.Run("differing GCMConsentTypes order is not equal", func(t *testing.T) { t.Run("differing GCMConsentTypes order is not equal", func(t *testing.T) {
t.Parallel() t.Parallel()
@@ -214,8 +193,8 @@ func TestBuildSnapshot_RankInvariant(t *testing.T) {
t.Run("snapshot is identical regardless of rank values", func(t *testing.T) { t.Run("snapshot is identical regardless of rank values", func(t *testing.T) {
t.Parallel() t.Parallel()
original := buildSnapshot(banner, mkCategories(0, 1, 2, 3), nil, nil) original := buildSnapshot(banner, mkCategories(0, 1, 2, 3), nil)
shuffled := buildSnapshot(banner, mkCategories(99, 50, 25, 10), nil, nil) shuffled := buildSnapshot(banner, mkCategories(99, 50, 25, 10), nil)
assert.True(t, snapshotsEqual(original, shuffled), "rank changes must not affect the snapshot") assert.True(t, snapshotsEqual(original, shuffled), "rank changes must not affect the snapshot")
}) })
@@ -226,8 +205,8 @@ func TestBuildSnapshot_RankInvariant(t *testing.T) {
ordered := mkCategories(0, 1, 2, 3) ordered := mkCategories(0, 1, 2, 3)
reversed := coredata.CookieCategories{ordered[3], ordered[2], ordered[1], ordered[0]} reversed := coredata.CookieCategories{ordered[3], ordered[2], ordered[1], ordered[0]}
a := buildSnapshot(banner, ordered, nil, nil) a := buildSnapshot(banner, ordered, nil)
b := buildSnapshot(banner, reversed, nil, nil) b := buildSnapshot(banner, reversed, nil)
assert.True(t, snapshotsEqual(a, b)) assert.True(t, snapshotsEqual(a, b))
}) })
@@ -235,7 +214,7 @@ func TestBuildSnapshot_RankInvariant(t *testing.T) {
t.Run("Necessary comes first and Uncategorised comes last", func(t *testing.T) { t.Run("Necessary comes first and Uncategorised comes last", func(t *testing.T) {
t.Parallel() t.Parallel()
snap := buildSnapshot(banner, mkCategories(0, 1, 2, 3), nil, nil) snap := buildSnapshot(banner, mkCategories(0, 1, 2, 3), nil)
require.Len(t, snap.Categories, 4) require.Len(t, snap.Categories, 4)
assert.Equal(t, coredata.CookieCategoryKindNecessary, snap.Categories[0].Kind) assert.Equal(t, coredata.CookieCategoryKindNecessary, snap.Categories[0].Kind)

View File

@@ -24,6 +24,16 @@ import (
"go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/gid"
) )
// resolveTranslations converts raw DB translations into the resolved map
// used by buildBannerConfig at serve time. Categories must be sorted in
// snapshot order so the positional category translations align.
func resolveTranslations(
translations coredata.CookieBannerTranslations,
categories coredata.CookieCategories,
) map[string]coredata.CookieBannerVersionSnapshotTranslation {
return buildSnapshotTranslations(translations, sortCategoriesForSnapshot(categories))
}
// snapshotsEqual reports whether two version snapshots are visitor-identical. // snapshotsEqual reports whether two version snapshots are visitor-identical.
// buildSnapshot already normalises empty slices and nil maps, so reflect.DeepEqual // buildSnapshot already normalises empty slices and nil maps, so reflect.DeepEqual
// is sufficient and is the single chokepoint we'd extend if we ever wanted to // is sufficient and is the single chokepoint we'd extend if we ever wanted to
@@ -67,7 +77,6 @@ func buildSnapshot(
banner *coredata.CookieBanner, banner *coredata.CookieBanner,
categories coredata.CookieCategories, categories coredata.CookieCategories,
allPatterns coredata.CookiePatterns, allPatterns coredata.CookiePatterns,
translations coredata.CookieBannerTranslations,
) coredata.CookieBannerVersionSnapshot { ) coredata.CookieBannerVersionSnapshot {
categories = sortCategoriesForSnapshot(categories) categories = sortCategoriesForSnapshot(categories)
@@ -104,8 +113,6 @@ func buildSnapshot(
} }
} }
snapshotTranslations := buildSnapshotTranslations(translations, categories)
return coredata.CookieBannerVersionSnapshot{ return coredata.CookieBannerVersionSnapshot{
PrivacyPolicyURL: banner.PrivacyPolicyURL, PrivacyPolicyURL: banner.PrivacyPolicyURL,
CookiePolicyURL: banner.CookiePolicyURL, CookiePolicyURL: banner.CookiePolicyURL,
@@ -113,7 +120,6 @@ func buildSnapshot(
ConsentMode: string(banner.ConsentMode), ConsentMode: string(banner.ConsentMode),
DefaultLanguage: banner.DefaultLanguage, DefaultLanguage: banner.DefaultLanguage,
Categories: snapshotCategories, Categories: snapshotCategories,
Translations: snapshotTranslations,
} }
} }

View File

@@ -30,13 +30,12 @@ import (
type ( type (
CookieBannerVersionSnapshot struct { CookieBannerVersionSnapshot struct {
PrivacyPolicyURL *string `json:"privacy_policy_url,omitempty"` PrivacyPolicyURL *string `json:"privacy_policy_url,omitempty"`
CookiePolicyURL string `json:"cookie_policy_url"` CookiePolicyURL string `json:"cookie_policy_url"`
ConsentExpiryDays int `json:"consent_expiry_days"` ConsentExpiryDays int `json:"consent_expiry_days"`
ConsentMode string `json:"consent_mode"` ConsentMode string `json:"consent_mode"`
DefaultLanguage string `json:"default_language"` DefaultLanguage string `json:"default_language"`
Categories []CookieBannerVersionSnapshotCategory `json:"categories"` Categories []CookieBannerVersionSnapshotCategory `json:"categories"`
Translations map[string]CookieBannerVersionSnapshotTranslation `json:"translations,omitempty"`
} }
CookieBannerVersionSnapshotTranslation struct { CookieBannerVersionSnapshotTranslation struct {