Promote glob source and trigger draft on adoption

The pattern-analysis worker dropped two signals on every run. When
InsertIfNotExists hit a pre-existing glob, the computed bestSource
was discarded by the LoadByBannerIDTypeAndPattern fallback, so the
SCRIPT > EXTENSION > PRE_EXISTING precedence advertised on
bestSource was only ever enforced at first insert. Subsequent
batches with stronger sources could not promote the glob, even
though the page-script-wins rule already lives in detected_trackers
at the row level.

Separately, adoptUncategorisedPatterns returned an adopted bool
that the worker discarded; the function moves detected trackers
from uncategorised exact patterns into categorised globs, which is
a real consent transition, but no draft banner version was created
on adoption-only runs.

Add a focused TrackerPattern.PromoteSource that only updates the
source and updated_at columns. Express the precedence as a pure-Go
shouldPromoteSource helper alongside bestSource so the rule is
unit-testable without a database. The worker now calls
InsertIfNotExists, then on conflict loads, skips when the slot is
held by an exact pattern or a user-recategorised glob, and only
calls PromoteSource when the candidate source ranks above the
existing one.

The skip branch is now documented: adoptUncategorisedPatterns is
the safety net that re-homes uncategorised exacts into the existing
glob via globMatch. Capture its adopted return value and use it
(instead of the previous over-eager consentChanged flag) to gate
ensureDraftVersionForBanner. Merging exacts into a glob in their
own category never changes visitor consent, so the prior flag
produced redundant draft versions on every non-uncategorised merge.

Cover the new pieces with three test layers: pure-unit cases for
shouldPromoteSource (precedence matrix including HTTP/nil collapse
and equal-rank no-write), DB-backed tests for PromoteSource (touch
only source + updated_at, ErrResourceNotFound for missing rows),
and end-to-end worker tests for source promotion on an existing
glob, draft-on-adoption, and the merge-only no-draft case.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-05-26 14:50:39 +02:00
parent bd5c527548
commit 05cbab7258
5 changed files with 957 additions and 28 deletions

View File

@@ -137,18 +137,6 @@ func (h *patternAnalysisHandler) Process(ctx context.Context, banner coredata.Co
func(ctx context.Context, tx pg.Tx) error {
scope := coredata.NewScopeFromObjectID(banner.ID)
var uncategorised coredata.CookieCategory
hasUncategorised := true
if err := uncategorised.LoadUncategorisedByCookieBannerID(ctx, tx, scope, banner.ID); err != nil {
if !errors.Is(err, coredata.ErrResourceNotFound) {
return fmt.Errorf("cannot load uncategorised category: %w", err)
}
hasUncategorised = false
}
var exactPatterns coredata.TrackerPatterns
if err := exactPatterns.LoadAllByCookieBannerID(
ctx,
@@ -163,8 +151,6 @@ func (h *patternAnalysisHandler) Process(ctx context.Context, banner coredata.Co
mergeGroups := findMergeGroups(exactPatterns, patternMergeThreshold)
consentChanged := false
for key, group := range mergeGroups {
var maxAge *int
@@ -203,9 +189,23 @@ func (h *patternAnalysisHandler) Process(ctx context.Context, banner coredata.Co
return fmt.Errorf("cannot load existing glob pattern %q: %w", key.template, err)
}
if globPattern.CookieCategoryID != key.categoryID || globPattern.MatchType != coredata.TrackerPatternMatchTypeGlob {
if globPattern.MatchType != coredata.TrackerPatternMatchTypeGlob || globPattern.CookieCategoryID != key.categoryID {
// The slot is occupied by an exact pattern or
// a user-recategorised glob. Skip the relink
// here so we don't overwrite the user's
// categorisation; the exact patterns in
// `group` will be picked up below by
// adoptUncategorisedPatterns if they live in
// the uncategorised category and globMatch
// the existing glob.
continue
}
if shouldPromoteSource(globPattern.Source, source) {
if err := globPattern.PromoteSource(ctx, tx, scope, *source, now); err != nil {
return fmt.Errorf("cannot promote source on glob pattern %q: %w", key.template, err)
}
}
}
for _, exactPattern := range group {
@@ -219,20 +219,18 @@ func (h *patternAnalysisHandler) Process(ctx context.Context, banner coredata.Co
}
}
if !hasUncategorised || key.categoryID != uncategorised.ID {
consentChanged = true
}
h.logger.InfoCtx(
ctx,
"merged exact patterns into glob pattern",
log.String("template", key.template),
log.Int("count", len(group)),
log.Bool("inserted", inserted),
log.String("banner_id", banner.ID.String()),
)
}
if _, err := h.adoptUncategorisedPatterns(ctx, tx, scope, banner); err != nil {
adopted, err := h.adoptUncategorisedPatterns(ctx, tx, scope, banner)
if err != nil {
return fmt.Errorf("cannot adopt uncategorised patterns: %w", err)
}
@@ -241,7 +239,13 @@ func (h *patternAnalysisHandler) Process(ctx context.Context, banner coredata.Co
return fmt.Errorf("cannot refresh last_matched_at: %w", err)
}
if consentChanged {
// Merging exact patterns into a glob in the same category
// does not change visitor consent for those identifiers
// (findMergeGroups keys on category, so every member of a
// group is already under key.categoryID). Adoption is the
// only operation in this worker that moves trackers
// between categories and therefore changes consent.
if adopted {
if _, err := h.svc.ensureDraftVersionForBanner(ctx, tx, scope, banner.ID); err != nil {
return fmt.Errorf("cannot ensure draft version: %w", err)
}
@@ -633,15 +637,45 @@ func globMatch(pattern, name string) bool {
return true
}
// sourceRank converts a CookieSource into a comparable rank that
// reflects signal strength: SCRIPT > EXTENSION > PRE_EXISTING. HTTP
// and nil collapse into the PRE_EXISTING rank because bestSource
// already normalises them; if a future caller hands us either, the
// ranking still produces a sane "no promotion" outcome against
// PRE_EXISTING/EXTENSION/SCRIPT existing values.
func sourceRank(s *coredata.CookieSource) int {
if s == nil {
return 0
}
switch *s {
case coredata.CookieSourceScript:
return 2
case coredata.CookieSourceExtension:
return 1
default:
return 0
}
}
// shouldPromoteSource reports whether candidate represents a stronger
// signal than existing under the SCRIPT > EXTENSION > PRE_EXISTING
// precedence used across the cookie-banner pipeline. Equal ranks do
// not promote so we avoid pointless writes.
func shouldPromoteSource(existing, candidate *coredata.CookieSource) bool {
return sourceRank(candidate) > sourceRank(existing)
}
// bestSource rolls up the source values of a group of exact patterns
// being merged into a single glob. Precedence is SCRIPT > EXTENSION
// > PRE_EXISTING, mirroring both the upsert SQL's "page-script wins"
// rule and the asymmetric signal strength of each bucket: SCRIPT is
// high-confidence page evidence (a real page tracker), EXTENSION is
// high-confidence extension evidence, and PRE_EXISTING is the
// catch-all that may include extension state injected before SDK
// load. HTTP and nil collapse into PRE_EXISTING here, preserving
// the original two-value rollup behaviour for non-script values.
// > PRE_EXISTING, mirroring both the page-script-wins rule in
// detected_trackers and the asymmetric signal strength of each
// bucket: SCRIPT is high-confidence page evidence (a real page
// tracker), EXTENSION is high-confidence extension evidence, and
// PRE_EXISTING is the catch-all that may include extension state
// injected before SDK load. HTTP and nil collapse into PRE_EXISTING
// here, preserving the original two-value rollup behaviour for
// non-script values.
func bestSource(patterns []*coredata.TrackerPattern) *coredata.CookieSource {
var hasExtension bool

View File

@@ -0,0 +1,492 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package cookiebanner
import (
"context"
"errors"
"io"
"net"
"net/url"
"os"
"testing"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
const testPgDSNEnvVar = "PROBO_TEST_PG_URL"
func newTestPgClient(t *testing.T) *pg.Client {
t.Helper()
dsn := os.Getenv(testPgDSNEnvVar)
if dsn == "" {
t.Skipf("skipping: %s not set (requires a migrated test database)", testPgDSNEnvVar)
}
u, err := url.Parse(dsn)
require.NoError(t, err, "invalid %s value", testPgDSNEnvVar)
opts := []pg.Option{pg.WithRegisterer(prometheus.NewRegistry())}
if u.Host != "" {
host := u.Host
if u.Port() == "" {
host = net.JoinHostPort(u.Hostname(), "5432")
}
opts = append(opts, pg.WithAddr(host))
}
if u.User != nil {
opts = append(opts, pg.WithUser(u.User.Username()))
if password, ok := u.User.Password(); ok {
opts = append(opts, pg.WithPassword(password))
}
}
if len(u.Path) > 1 {
opts = append(opts, pg.WithDatabase(u.Path[1:]))
}
client, err := pg.NewClient(opts...)
require.NoError(t, err)
t.Cleanup(func() {
client.Close()
})
return client
}
// workerFixture bootstraps the parent rows the worker's transaction
// needs: an organization, a cookie banner, an uncategorised category,
// and a normal category. Patterns/detected trackers are seeded
// per-test.
type workerFixture struct {
scope *coredata.Scope
organizationID gid.GID
banner coredata.CookieBanner
uncategorisedID gid.GID
normalCategoryID gid.GID
}
func seedWorkerFixture(t *testing.T, ctx context.Context, client *pg.Client) workerFixture {
t.Helper()
tenantID := gid.NewTenantID()
scope := coredata.NewScope(tenantID)
organizationID := gid.New(tenantID, coredata.OrganizationEntityType)
bannerID := gid.New(tenantID, coredata.CookieBannerEntityType)
uncategorisedID := gid.New(tenantID, coredata.CookieCategoryEntityType)
normalCategoryID := gid.New(tenantID, coredata.CookieCategoryEntityType)
now := time.Now().UTC().Truncate(time.Microsecond)
banner := coredata.CookieBanner{
ID: bannerID,
OrganizationID: organizationID,
Name: "Worker Test Banner",
Origin: "https://worker-test-" + bannerID.String() + ".example.com",
State: coredata.CookieBannerStateActive,
CookiePolicyURL: "https://worker-test.example.com/cookies",
ConsentExpiryDays: 180,
ShowBranding: false,
DefaultLanguage: "en",
CreatedAt: now,
UpdatedAt: now,
}
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
org := &coredata.Organization{
ID: organizationID,
TenantID: tenantID,
Name: "Worker Test Org",
CreatedAt: now,
UpdatedAt: now,
}
if err := org.Insert(ctx, tx); err != nil {
return err
}
if err := banner.Insert(ctx, tx, scope); err != nil {
return err
}
uncategorised := &coredata.CookieCategory{
ID: uncategorisedID,
OrganizationID: organizationID,
CookieBannerID: bannerID,
Name: "Uncategorised",
Slug: "uncategorised",
Description: "",
Kind: coredata.CookieCategoryKindUncategorised,
Rank: 0,
GCMConsentTypes: []string{},
PostHogConsent: false,
CreatedAt: now,
UpdatedAt: now,
}
if err := uncategorised.Insert(ctx, tx, scope); err != nil {
return err
}
normal := &coredata.CookieCategory{
ID: normalCategoryID,
OrganizationID: organizationID,
CookieBannerID: bannerID,
Name: "Analytics",
Slug: "analytics",
Description: "",
Kind: coredata.CookieCategoryKindNormal,
Rank: 1,
GCMConsentTypes: []string{},
PostHogConsent: false,
CreatedAt: now,
UpdatedAt: now,
}
if err := normal.Insert(ctx, tx, scope); err != nil {
return err
}
return nil
}))
t.Cleanup(func() {
_ = client.WithTx(context.Background(), func(ctx context.Context, tx pg.Tx) error {
if _, err := tx.Exec(ctx, `DELETE FROM detected_trackers WHERE cookie_banner_id = $1`, bannerID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `DELETE FROM tracker_patterns WHERE cookie_banner_id = $1`, bannerID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `DELETE FROM cookie_banner_versions WHERE cookie_banner_id = $1`, bannerID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `DELETE FROM cookie_categories WHERE cookie_banner_id = $1`, bannerID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `DELETE FROM cookie_banners WHERE id = $1`, bannerID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `DELETE FROM organizations WHERE id = $1`, organizationID); err != nil {
return err
}
return nil
})
})
return workerFixture{
scope: scope,
organizationID: organizationID,
banner: banner,
uncategorisedID: uncategorisedID,
normalCategoryID: normalCategoryID,
}
}
func newExactPattern(
fx workerFixture,
pattern string,
categoryID gid.GID,
source coredata.CookieSource,
maxAge *int,
) *coredata.TrackerPattern {
now := time.Now().UTC().Truncate(time.Microsecond)
return &coredata.TrackerPattern{
ID: gid.New(fx.scope.GetTenantID(), coredata.TrackerPatternEntityType),
OrganizationID: fx.organizationID,
CookieBannerID: fx.banner.ID,
CookieCategoryID: categoryID,
TrackerType: coredata.TrackerTypeCookie,
Pattern: pattern,
MatchType: coredata.TrackerPatternMatchTypeExact,
DisplayName: pattern,
Description: "",
MaxAgeSeconds: maxAge,
Source: &source,
MappingRequestedAt: &now,
CreatedAt: now,
UpdatedAt: now,
}
}
func newGlobInCategory(
fx workerFixture,
pattern string,
categoryID gid.GID,
source coredata.CookieSource,
maxAge *int,
) *coredata.TrackerPattern {
now := time.Now().UTC().Truncate(time.Microsecond)
return &coredata.TrackerPattern{
ID: gid.New(fx.scope.GetTenantID(), coredata.TrackerPatternEntityType),
OrganizationID: fx.organizationID,
CookieBannerID: fx.banner.ID,
CookieCategoryID: categoryID,
TrackerType: coredata.TrackerTypeCookie,
Pattern: pattern,
MatchType: coredata.TrackerPatternMatchTypeGlob,
DisplayName: pattern,
Description: "",
MaxAgeSeconds: maxAge,
Source: &source,
MappingRequestedAt: &now,
CreatedAt: now,
UpdatedAt: now,
}
}
func newTestHandler(client *pg.Client) *patternAnalysisHandler {
return &patternAnalysisHandler{
svc: NewService(client, false),
pg: client,
logger: log.NewLogger(log.WithOutput(io.Discard)),
}
}
// 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,
// and asserts that running the worker promotes the glob's source to
// SCRIPT. This guards against the original regression where
// bestSource was only honoured on first insert and subsequent batches
// could never promote.
func TestPatternAnalysisWorker_PromotesSourceOnExistingGlob(t *testing.T) {
t.Parallel()
client := newTestPgClient(t)
ctx := context.Background()
fx := seedWorkerFixture(t, ctx, client)
maxAge := 7 * 24 * 3600
existingGlob := newGlobInCategory(
fx,
"_ga_*",
fx.normalCategoryID,
coredata.CookieSourcePreExisting,
&maxAge,
)
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),
}
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
if err := existingGlob.Insert(ctx, tx, fx.scope); err != nil {
return err
}
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.Source)
assert.Equal(t, coredata.CookieSourceScript, *loaded.Source, "glob source must be promoted to SCRIPT")
assert.Equal(t, existingGlob.ID, loaded.ID, "the existing glob row must be reused, not replaced")
assert.Equal(t, fx.normalCategoryID, loaded.CookieCategoryID, "category must not be touched")
var remainingExacts coredata.TrackerPatterns
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
return remainingExacts.LoadAllByCookieBannerID(
ctx,
conn,
fx.scope,
fx.banner.ID,
coredata.NewTrackerPatternFilter(new(coredata.TrackerPatternMatchTypeExact), nil, new(false)),
nil,
)
}))
assert.Empty(t, remainingExacts, "all three exacts must be relinked and deleted")
}
// TestPatternAnalysisWorker_AdoptionTriggersDraftVersion seeds a
// banner with a categorised `_ga_*` glob and uncategorised exacts
// that match it. The merge loop must skip the relink (different
// category) but adoptUncategorisedPatterns must re-home the exacts;
// the worker must then create a draft banner version reflecting the
// consent state change. This guards against the original bug where
// the adopted bool was discarded.
func TestPatternAnalysisWorker_AdoptionTriggersDraftVersion(t *testing.T) {
t.Parallel()
client := newTestPgClient(t)
ctx := context.Background()
fx := seedWorkerFixture(t, ctx, client)
maxAge := 7 * 24 * 3600
existingGlob := newGlobInCategory(
fx,
"_ga_*",
fx.normalCategoryID,
coredata.CookieSourceScript,
&maxAge,
)
exacts := []*coredata.TrackerPattern{
newExactPattern(fx, "_ga_abc123", fx.uncategorisedID, coredata.CookieSourcePreExisting, &maxAge),
newExactPattern(fx, "_ga_def456", fx.uncategorisedID, coredata.CookieSourcePreExisting, &maxAge),
newExactPattern(fx, "_ga_xyz789", fx.uncategorisedID, coredata.CookieSourcePreExisting, &maxAge),
}
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
if err := existingGlob.Insert(ctx, tx, fx.scope); err != nil {
return err
}
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.Equal(t, fx.normalCategoryID, loaded.CookieCategoryID, "user-set category must not be overwritten by the worker")
assert.Equal(t, existingGlob.ID, loaded.ID, "existing glob row must be reused")
var remainingExacts coredata.TrackerPatterns
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
return remainingExacts.LoadAllByCookieBannerID(
ctx,
conn,
fx.scope,
fx.banner.ID,
coredata.NewTrackerPatternFilter(new(coredata.TrackerPatternMatchTypeExact), nil, new(false)),
nil,
)
}))
assert.Empty(t, remainingExacts, "adoptUncategorisedPatterns must absorb the uncategorised exacts into the existing glob")
latest := &coredata.CookieBannerVersion{}
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
return latest.LoadLatestByCookieBannerID(ctx, conn, fx.scope, fx.banner.ID)
}))
assert.Equal(t, coredata.CookieBannerVersionStateDraft, latest.State, "adoption must trigger a draft version")
}
// TestPatternAnalysisWorker_MergeWithoutAdoptionSkipsDraftVersion
// asserts the inverse: when the worker only consolidates exacts into
// a glob in their own category (no consent transition), no draft
// version is created. This guards against the prior over-eager
// consentChanged flag, which produced redundant draft versions on
// every merge into a non-uncategorised category.
func TestPatternAnalysisWorker_MergeWithoutAdoptionSkipsDraftVersion(t *testing.T) {
t.Parallel()
client := newTestPgClient(t)
ctx := context.Background()
fx := seedWorkerFixture(t, ctx, client)
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),
}
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))
var globs coredata.TrackerPatterns
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
return globs.LoadAllByCookieBannerID(
ctx,
conn,
fx.scope,
fx.banner.ID,
coredata.NewTrackerPatternFilter(new(coredata.TrackerPatternMatchTypeGlob), nil, new(false)),
nil,
)
}))
require.Len(t, globs, 1, "the three exacts must consolidate into a single glob")
assert.Equal(t, "_ga_*", globs[0].Pattern)
assert.Equal(t, fx.normalCategoryID, globs[0].CookieCategoryID)
latest := &coredata.CookieBannerVersion{}
err := client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
return latest.LoadLatestByCookieBannerID(ctx, conn, fx.scope, fx.banner.ID)
})
assert.ErrorIs(t, err, coredata.ErrResourceNotFound, "merge alone must not create a draft version")
// Sanity-check the negative assertion: if the lookup unexpectedly
// succeeds, fail with a clearer message than the bare error mismatch.
if !errors.Is(err, coredata.ErrResourceNotFound) {
t.Fatalf("merge-only run unexpectedly produced a banner version: state=%s", latest.State)
}
}

View File

@@ -1051,3 +1051,103 @@ func TestDurationBucket(t *testing.T) {
)
}
}
func TestShouldPromoteSource(t *testing.T) {
t.Parallel()
script := coredata.CookieSourceScript
extension := coredata.CookieSourceExtension
preExisting := coredata.CookieSourcePreExisting
http := coredata.CookieSourceHTTP
tests := []struct {
name string
existing *coredata.CookieSource
candidate *coredata.CookieSource
want bool
}{
{
name: "nil existing promotes to SCRIPT",
existing: nil,
candidate: &script,
want: true,
},
{
name: "nil existing promotes to EXTENSION",
existing: nil,
candidate: &extension,
want: true,
},
{
name: "nil existing does not promote to PRE_EXISTING (equal rank)",
existing: nil,
candidate: &preExisting,
want: false,
},
{
name: "PRE_EXISTING promotes to SCRIPT",
existing: &preExisting,
candidate: &script,
want: true,
},
{
name: "PRE_EXISTING promotes to EXTENSION",
existing: &preExisting,
candidate: &extension,
want: true,
},
{
name: "EXTENSION promotes to SCRIPT",
existing: &extension,
candidate: &script,
want: true,
},
{
name: "SCRIPT does not promote to PRE_EXISTING",
existing: &script,
candidate: &preExisting,
want: false,
},
{
name: "SCRIPT does not promote to EXTENSION",
existing: &script,
candidate: &extension,
want: false,
},
{
name: "EXTENSION does not promote to PRE_EXISTING",
existing: &extension,
candidate: &preExisting,
want: false,
},
{
name: "SCRIPT does not promote to SCRIPT (equal rank, no write)",
existing: &script,
candidate: &script,
want: false,
},
{
name: "HTTP collapses to PRE_EXISTING rank: does not promote SCRIPT",
existing: &script,
candidate: &http,
want: false,
},
{
name: "HTTP collapses to PRE_EXISTING rank: equal to nil existing",
existing: nil,
candidate: &http,
want: false,
},
}
for _, tt := range tests {
t.Run(
tt.name,
func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, shouldPromoteSource(tt.existing, tt.candidate))
},
)
}
}

View File

@@ -491,6 +491,52 @@ ON CONFLICT (cookie_banner_id, tracker_type, pattern, COALESCE(max_age_seconds,
return result.RowsAffected() > 0, nil
}
// PromoteSource overwrites the row's source column with newSource and
// refreshes updated_at. The caller is responsible for ranking
// newSource against the existing value before invoking this method —
// see shouldPromoteSource in pkg/cookiebanner. Returns ErrResourceNotFound
// if no row with the receiver's ID exists in scope.
func (tp *TrackerPattern) PromoteSource(
ctx context.Context,
tx pg.Tx,
scope Scoper,
newSource CookieSource,
updatedAt time.Time,
) error {
q := `
UPDATE tracker_patterns
SET
source = @source,
updated_at = @updated_at
WHERE
%s
AND id = @id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"id": tp.ID,
"source": newSource,
"updated_at": updatedAt,
}
maps.Copy(args, scope.SQLArguments())
result, err := tx.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot promote tracker pattern source: %w", err)
}
if result.RowsAffected() == 0 {
return ErrResourceNotFound
}
tp.Source = &newSource
tp.UpdatedAt = updatedAt
return nil
}
func (tp *TrackerPattern) Update(
ctx context.Context,
tx pg.Tx,

View File

@@ -0,0 +1,257 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata_test
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
// trackerPatternFixture bootstraps the parent rows that a tracker
// pattern's FKs require: organization, cookie banner, and a normal
// cookie category.
type trackerPatternFixture struct {
scope *coredata.Scope
organizationID gid.GID
cookieBannerID gid.GID
cookieCategoryID gid.GID
}
func seedTrackerPatternFixture(t *testing.T, ctx context.Context, client *pg.Client) trackerPatternFixture {
t.Helper()
tenantID := gid.NewTenantID()
scope := coredata.NewScope(tenantID)
organizationID := gid.New(tenantID, coredata.OrganizationEntityType)
cookieBannerID := gid.New(tenantID, coredata.CookieBannerEntityType)
cookieCategoryID := gid.New(tenantID, coredata.CookieCategoryEntityType)
now := time.Now().UTC()
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
org := &coredata.Organization{
ID: organizationID,
TenantID: tenantID,
Name: "TrackerPattern Promote Test Org",
CreatedAt: now,
UpdatedAt: now,
}
if err := org.Insert(ctx, tx); err != nil {
return err
}
banner := &coredata.CookieBanner{
ID: cookieBannerID,
OrganizationID: organizationID,
Name: "TrackerPattern Promote Test Banner",
Origin: "https://promote-test.example.com",
State: coredata.CookieBannerStateActive,
CookiePolicyURL: "https://promote-test.example.com/cookies",
ConsentExpiryDays: 180,
ShowBranding: false,
DefaultLanguage: "en",
CreatedAt: now,
UpdatedAt: now,
}
if err := banner.Insert(ctx, tx, scope); err != nil {
return err
}
category := &coredata.CookieCategory{
ID: cookieCategoryID,
OrganizationID: organizationID,
CookieBannerID: cookieBannerID,
Name: "Analytics",
Slug: "analytics",
Description: "",
Kind: coredata.CookieCategoryKindNormal,
Rank: 1,
GCMConsentTypes: []string{},
PostHogConsent: false,
CreatedAt: now,
UpdatedAt: now,
}
if err := category.Insert(ctx, tx, scope); err != nil {
return err
}
return nil
}))
t.Cleanup(func() {
_ = client.WithTx(context.Background(), func(ctx context.Context, tx pg.Tx) error {
if _, err := tx.Exec(ctx, `DELETE FROM tracker_patterns WHERE cookie_banner_id = $1`, cookieBannerID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `DELETE FROM cookie_categories WHERE cookie_banner_id = $1`, cookieBannerID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `DELETE FROM cookie_banners WHERE id = $1`, cookieBannerID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `DELETE FROM organizations WHERE id = $1`, organizationID); err != nil {
return err
}
return nil
})
})
return trackerPatternFixture{
scope: scope,
organizationID: organizationID,
cookieBannerID: cookieBannerID,
cookieCategoryID: cookieCategoryID,
}
}
func seedTrackerPattern(
t *testing.T,
ctx context.Context,
client *pg.Client,
fx trackerPatternFixture,
pattern string,
matchType coredata.TrackerPatternMatchType,
source coredata.CookieSource,
) *coredata.TrackerPattern {
t.Helper()
now := time.Now().UTC().Truncate(time.Microsecond)
maxAge := 3600
tp := &coredata.TrackerPattern{
ID: gid.New(fx.scope.GetTenantID(), coredata.TrackerPatternEntityType),
OrganizationID: fx.organizationID,
CookieBannerID: fx.cookieBannerID,
CookieCategoryID: fx.cookieCategoryID,
TrackerType: coredata.TrackerTypeCookie,
Pattern: pattern,
MatchType: matchType,
DisplayName: pattern,
Description: "",
MaxAgeSeconds: &maxAge,
Source: &source,
CreatedAt: now,
UpdatedAt: now,
}
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
return tp.Insert(ctx, tx, fx.scope)
}))
return tp
}
func TestTrackerPattern_PromoteSource_OverwritesSource(t *testing.T) {
t.Parallel()
client := newTestPgClient(t)
ctx := context.Background()
fx := seedTrackerPatternFixture(t, ctx, client)
tp := seedTrackerPattern(
t,
ctx,
client,
fx,
"*_session",
coredata.TrackerPatternMatchTypeGlob,
coredata.CookieSourcePreExisting,
)
bumpedAt := time.Now().UTC().Add(time.Hour).Truncate(time.Microsecond)
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
return tp.PromoteSource(ctx, tx, fx.scope, coredata.CookieSourceScript, bumpedAt)
}))
require.NotNil(t, tp.Source)
assert.Equal(t, coredata.CookieSourceScript, *tp.Source, "receiver must reflect the new source")
assert.True(t, tp.UpdatedAt.Equal(bumpedAt), "receiver must reflect the new updated_at")
loaded := &coredata.TrackerPattern{}
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
return loaded.LoadByID(ctx, conn, fx.scope, tp.ID)
}))
require.NotNil(t, loaded.Source)
assert.Equal(t, coredata.CookieSourceScript, *loaded.Source, "DB row must reflect the promoted source")
assert.True(t, loaded.UpdatedAt.Equal(bumpedAt), "DB row must reflect the new updated_at")
}
func TestTrackerPattern_PromoteSource_OnlyTouchesSourceAndUpdatedAt(t *testing.T) {
t.Parallel()
client := newTestPgClient(t)
ctx := context.Background()
fx := seedTrackerPatternFixture(t, ctx, client)
tp := seedTrackerPattern(
t,
ctx,
client,
fx,
"*_token",
coredata.TrackerPatternMatchTypeGlob,
coredata.CookieSourcePreExisting,
)
originalCategory := tp.CookieCategoryID
originalDisplay := tp.DisplayName
originalMaxAge := tp.MaxAgeSeconds
originalExcluded := tp.Excluded
originalDescription := tp.Description
bumpedAt := time.Now().UTC().Add(2 * time.Hour).Truncate(time.Microsecond)
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
return tp.PromoteSource(ctx, tx, fx.scope, coredata.CookieSourceExtension, bumpedAt)
}))
loaded := &coredata.TrackerPattern{}
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
return loaded.LoadByID(ctx, conn, fx.scope, tp.ID)
}))
assert.Equal(t, originalCategory, loaded.CookieCategoryID, "category must be untouched")
assert.Equal(t, originalDisplay, loaded.DisplayName, "display_name must be untouched")
assert.Equal(t, originalMaxAge, loaded.MaxAgeSeconds, "max_age_seconds must be untouched")
assert.Equal(t, originalExcluded, loaded.Excluded, "excluded must be untouched")
assert.Equal(t, originalDescription, loaded.Description, "description must be untouched")
}
func TestTrackerPattern_PromoteSource_NotFoundForMissingRow(t *testing.T) {
t.Parallel()
client := newTestPgClient(t)
ctx := context.Background()
fx := seedTrackerPatternFixture(t, ctx, client)
tp := &coredata.TrackerPattern{ID: gid.New(fx.scope.GetTenantID(), coredata.TrackerPatternEntityType)}
err := client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
return tp.PromoteSource(ctx, tx, fx.scope, coredata.CookieSourceScript, time.Now().UTC())
})
assert.ErrorIs(t, err, coredata.ErrResourceNotFound)
}