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:
@@ -137,18 +137,6 @@ func (h *patternAnalysisHandler) Process(ctx context.Context, banner coredata.Co
|
|||||||
func(ctx context.Context, tx pg.Tx) error {
|
func(ctx context.Context, tx pg.Tx) error {
|
||||||
scope := coredata.NewScopeFromObjectID(banner.ID)
|
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
|
var exactPatterns coredata.TrackerPatterns
|
||||||
if err := exactPatterns.LoadAllByCookieBannerID(
|
if err := exactPatterns.LoadAllByCookieBannerID(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -163,8 +151,6 @@ func (h *patternAnalysisHandler) Process(ctx context.Context, banner coredata.Co
|
|||||||
|
|
||||||
mergeGroups := findMergeGroups(exactPatterns, patternMergeThreshold)
|
mergeGroups := findMergeGroups(exactPatterns, patternMergeThreshold)
|
||||||
|
|
||||||
consentChanged := false
|
|
||||||
|
|
||||||
for key, group := range mergeGroups {
|
for key, group := range mergeGroups {
|
||||||
var maxAge *int
|
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)
|
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
|
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 {
|
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(
|
h.logger.InfoCtx(
|
||||||
ctx,
|
ctx,
|
||||||
"merged exact patterns into glob pattern",
|
"merged exact patterns into glob pattern",
|
||||||
log.String("template", key.template),
|
log.String("template", key.template),
|
||||||
log.Int("count", len(group)),
|
log.Int("count", len(group)),
|
||||||
|
log.Bool("inserted", inserted),
|
||||||
log.String("banner_id", banner.ID.String()),
|
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)
|
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)
|
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 {
|
if _, err := h.svc.ensureDraftVersionForBanner(ctx, tx, scope, banner.ID); err != nil {
|
||||||
return fmt.Errorf("cannot ensure draft version: %w", err)
|
return fmt.Errorf("cannot ensure draft version: %w", err)
|
||||||
}
|
}
|
||||||
@@ -633,15 +637,45 @@ func globMatch(pattern, name string) bool {
|
|||||||
return true
|
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
|
// bestSource rolls up the source values of a group of exact patterns
|
||||||
// being merged into a single glob. Precedence is SCRIPT > EXTENSION
|
// being merged into a single glob. Precedence is SCRIPT > EXTENSION
|
||||||
// > PRE_EXISTING, mirroring both the upsert SQL's "page-script wins"
|
// > PRE_EXISTING, mirroring both the page-script-wins rule in
|
||||||
// rule and the asymmetric signal strength of each bucket: SCRIPT is
|
// detected_trackers and the asymmetric signal strength of each
|
||||||
// high-confidence page evidence (a real page tracker), EXTENSION is
|
// bucket: SCRIPT is high-confidence page evidence (a real page
|
||||||
// high-confidence extension evidence, and PRE_EXISTING is the
|
// tracker), EXTENSION is high-confidence extension evidence, and
|
||||||
// catch-all that may include extension state injected before SDK
|
// PRE_EXISTING is the catch-all that may include extension state
|
||||||
// load. HTTP and nil collapse into PRE_EXISTING here, preserving
|
// injected before SDK load. HTTP and nil collapse into PRE_EXISTING
|
||||||
// the original two-value rollup behaviour for non-script values.
|
// here, preserving the original two-value rollup behaviour for
|
||||||
|
// non-script values.
|
||||||
func bestSource(patterns []*coredata.TrackerPattern) *coredata.CookieSource {
|
func bestSource(patterns []*coredata.TrackerPattern) *coredata.CookieSource {
|
||||||
var hasExtension bool
|
var hasExtension bool
|
||||||
|
|
||||||
|
|||||||
492
pkg/cookiebanner/pattern_analysis_worker_process_test.go
Normal file
492
pkg/cookiebanner/pattern_analysis_worker_process_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -491,6 +491,52 @@ ON CONFLICT (cookie_banner_id, tracker_type, pattern, COALESCE(max_age_seconds,
|
|||||||
return result.RowsAffected() > 0, nil
|
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(
|
func (tp *TrackerPattern) Update(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
tx pg.Tx,
|
tx pg.Tx,
|
||||||
|
|||||||
257
pkg/coredata/tracker_pattern_promote_test.go
Normal file
257
pkg/coredata/tracker_pattern_promote_test.go
Normal 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user