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 {
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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,
|
||||
|
||||
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