From 8e0dc0b7eb8524da8e19d183292a97c594989e3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Fri, 29 May 2026 17:40:37 +0200 Subject: [PATCH] Inherit mapping when merging exacts into glob MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pattern-analysis worker created the merged glob blank and re-armed mapping, discarding the org ThirdParty and description already resolved on the exacts it absorbed. That forced a full re-map (LLM/web-search) and opened a window where an in-flight exact could vanish mid-mapping. Seed the glob from the merged exacts when they unanimously agree on a single third party, carrying its description too, while still re-arming mapping so the glob derives its own catalog row. With the third party pre-set, the mapping worker skips the expensive org/disambiguation resolution. Conflicting or unresolved groups stay blank as before. The catalog link is deliberately not inherited: it is keyed on the exact pattern string, not the glob template, so the mapping worker resolves the right row itself. Signed-off-by: Émile Ré --- pkg/cookiebanner/pattern_analysis_worker.go | 54 ++++++- .../pattern_analysis_worker_process_test.go | 150 ++++++++++++++++++ 2 files changed, 203 insertions(+), 1 deletion(-) diff --git a/pkg/cookiebanner/pattern_analysis_worker.go b/pkg/cookiebanner/pattern_analysis_worker.go index bac0f6be5..be1e73695 100644 --- a/pkg/cookiebanner/pattern_analysis_worker.go +++ b/pkg/cookiebanner/pattern_analysis_worker.go @@ -161,6 +161,16 @@ func (h *patternAnalysisHandler) Process(ctx context.Context, banner coredata.Co source := bestSource(group) + // Carry over the resolved org ThirdParty (and its + // description) from the merged exacts when they agree, + // so the glob is seeded rather than re-mapped from + // scratch. Mapping is still re-armed below so the glob + // derives its own catalog row; the pre-set third party + // lets the mapping worker skip the expensive org + // resolution. Only the insert path consumes these: an + // existing glob is reloaded and keeps its own mapping. + inheritedThirdPartyID, inheritedDescription := inheritedMapping(group) + now := time.Now() globPattern := &coredata.TrackerPattern{ ID: gid.New(banner.ID.TenantID(), coredata.TrackerPatternEntityType), @@ -172,7 +182,8 @@ func (h *patternAnalysisHandler) Process(ctx context.Context, banner coredata.Co MatchType: coredata.TrackerPatternMatchTypeGlob, DisplayName: key.template, MaxAgeSeconds: maxAge, - Description: "", + ThirdPartyID: inheritedThirdPartyID, + Description: inheritedDescription, Source: source, MappingRequestedAt: &now, CreatedAt: now, @@ -712,6 +723,47 @@ func bestSource(patterns []*coredata.TrackerPattern) *coredata.CookieSource { return &src } +// inheritedMapping rolls up the resolved org ThirdParty of a group of +// exact patterns being merged into a glob, so the glob can be seeded +// instead of re-mapped from scratch. It returns a third party only when +// every resolved member agrees on a single id: a conflicting group (or +// one with no resolved member) returns nil, leaving the glob blank for a +// fresh mapping pass. When a third party is chosen, the description of +// the first member carrying that same id with non-empty text is returned +// too; the catalog link (common_tracker_pattern_id) is deliberately not +// inherited, since it is keyed on the exact pattern string rather than +// the glob template and the mapping worker derives the right row itself. +func inheritedMapping(patterns []*coredata.TrackerPattern) (*gid.GID, string) { + var thirdPartyID *gid.GID + + for _, p := range patterns { + if p.ThirdPartyID == nil { + continue + } + + if thirdPartyID == nil { + thirdPartyID = p.ThirdPartyID + continue + } + + if *thirdPartyID != *p.ThirdPartyID { + return nil, "" + } + } + + if thirdPartyID == nil { + return nil, "" + } + + for _, p := range patterns { + if p.ThirdPartyID != nil && *p.ThirdPartyID == *thirdPartyID && p.Description != "" { + return thirdPartyID, p.Description + } + } + + return thirdPartyID, "" +} + func (h *patternAnalysisHandler) adoptUncategorisedPatterns( ctx context.Context, tx pg.Tx, diff --git a/pkg/cookiebanner/pattern_analysis_worker_process_test.go b/pkg/cookiebanner/pattern_analysis_worker_process_test.go index 729eedf52..a503768bf 100644 --- a/pkg/cookiebanner/pattern_analysis_worker_process_test.go +++ b/pkg/cookiebanner/pattern_analysis_worker_process_test.go @@ -271,6 +271,40 @@ func newTestHandler(client *pg.Client) *patternAnalysisHandler { } } +// seedThirdParty inserts a minimal org ThirdParty so a tracker pattern's +// third_party_id (a FK to third_parties) can point at a real row. +func seedThirdParty(t *testing.T, ctx context.Context, client *pg.Client, fx workerFixture, name string) gid.GID { + t.Helper() + + now := time.Now().UTC().Truncate(time.Microsecond) + id := gid.New(fx.scope.GetTenantID(), coredata.ThirdPartyEntityType) + + party := coredata.ThirdParty{ + ID: id, + TenantID: fx.scope.GetTenantID(), + OrganizationID: fx.organizationID, + Name: name, + Category: coredata.ThirdPartyCategoryAnalytics, + Certifications: []string{}, + Countries: coredata.CountryCodes{}, + CreatedAt: now, + UpdatedAt: now, + } + + require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { + return party.Insert(ctx, tx, fx.scope) + })) + + t.Cleanup(func() { + _ = client.WithTx(context.Background(), func(ctx context.Context, tx pg.Tx) error { + _, err := tx.Exec(ctx, `DELETE FROM third_parties WHERE id = $1`, id) + return err + }) + }) + + return id +} + // TestPatternAnalysisWorker_PromotesSourceOnExistingGlob seeds a // banner with a PRE_EXISTING `_ga_*` glob in the analytics category, // adds three SCRIPT-source exacts that group under the same template, @@ -647,3 +681,119 @@ func TestPatternAnalysisWorker_MergeWithoutAdoptionSkipsDraftVersion(t *testing. t.Fatalf("merge-only run unexpectedly produced a banner version: state=%s", latest.State) } } + +// TestPatternAnalysisWorker_GlobInheritsUnanimousMapping seeds three +// exacts that all resolve to the same org ThirdParty (one carrying a +// description) and asserts the merged glob inherits that third party and +// description, so the mapping worker can skip the expensive org +// resolution. Mapping is still re-armed so the glob derives its own +// catalog row. +func TestPatternAnalysisWorker_GlobInheritsUnanimousMapping(t *testing.T) { + t.Parallel() + + client := newTestPgClient(t) + ctx := context.Background() + fx := seedWorkerFixture(t, ctx, client) + + thirdPartyID := seedThirdParty(t, ctx, client, fx, "Google Analytics") + + maxAge := 7 * 24 * 3600 + + exacts := []*coredata.TrackerPattern{ + newExactPattern(fx, "_ga_abc123", fx.normalCategoryID, coredata.CookieSourceScript, &maxAge), + newExactPattern(fx, "_ga_def456", fx.normalCategoryID, coredata.CookieSourceScript, &maxAge), + newExactPattern(fx, "_ga_xyz789", fx.normalCategoryID, coredata.CookieSourceScript, &maxAge), + } + for _, ep := range exacts { + ep.ThirdPartyID = &thirdPartyID + } + exacts[1].Description = "Google Analytics measurement cookie" + + require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { + for _, ep := range exacts { + if err := ep.Insert(ctx, tx, fx.scope); err != nil { + return err + } + } + + return nil + })) + + h := newTestHandler(client) + require.NoError(t, h.Process(ctx, fx.banner)) + + loaded := &coredata.TrackerPattern{} + + require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + return loaded.LoadByBannerIDTypeAndPattern( + ctx, + conn, + fx.scope, + fx.banner.ID, + coredata.TrackerTypeCookie, + "_ga_*", + &maxAge, + ) + })) + + require.NotNil(t, loaded.ThirdPartyID, "glob must inherit the unanimous org third party from the merged exacts") + assert.Equal(t, thirdPartyID, *loaded.ThirdPartyID) + assert.Equal(t, "Google Analytics measurement cookie", loaded.Description, "glob must inherit the description tied to the resolved third party") + assert.NotNil(t, loaded.MappingRequestedAt, "mapping must still be re-armed so the glob derives its own catalog row") +} + +// TestPatternAnalysisWorker_GlobSkipsConflictingMapping seeds exacts +// that resolve to two different org ThirdParties and asserts the merged +// glob is left blank rather than guessing, preserving a fresh mapping +// pass. +func TestPatternAnalysisWorker_GlobSkipsConflictingMapping(t *testing.T) { + t.Parallel() + + client := newTestPgClient(t) + ctx := context.Background() + fx := seedWorkerFixture(t, ctx, client) + + thirdPartyA := seedThirdParty(t, ctx, client, fx, "Vendor A") + thirdPartyB := seedThirdParty(t, ctx, client, fx, "Vendor B") + + maxAge := 7 * 24 * 3600 + + exacts := []*coredata.TrackerPattern{ + newExactPattern(fx, "_ga_abc123", fx.normalCategoryID, coredata.CookieSourceScript, &maxAge), + newExactPattern(fx, "_ga_def456", fx.normalCategoryID, coredata.CookieSourceScript, &maxAge), + newExactPattern(fx, "_ga_xyz789", fx.normalCategoryID, coredata.CookieSourceScript, &maxAge), + } + exacts[0].ThirdPartyID = &thirdPartyA + exacts[1].ThirdPartyID = &thirdPartyB + + require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { + for _, ep := range exacts { + if err := ep.Insert(ctx, tx, fx.scope); err != nil { + return err + } + } + + return nil + })) + + h := newTestHandler(client) + require.NoError(t, h.Process(ctx, fx.banner)) + + loaded := &coredata.TrackerPattern{} + + require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + return loaded.LoadByBannerIDTypeAndPattern( + ctx, + conn, + fx.scope, + fx.banner.ID, + coredata.TrackerTypeCookie, + "_ga_*", + &maxAge, + ) + })) + + assert.Nil(t, loaded.ThirdPartyID, "glob must not inherit a third party when the merged exacts disagree") + assert.Equal(t, "", loaded.Description, "glob must stay blank when no unanimous mapping exists") + assert.NotNil(t, loaded.MappingRequestedAt, "glob must be re-armed for a fresh mapping pass") +}