Files
probo/pkg/coredata/tracker_pattern_test.go
Émile Ré dc92fd238f Fold PromoteSource into Update
Every PromoteSource caller already loaded the tracker pattern under
the same transaction, so a dedicated single-column UPDATE only
duplicated machinery and forced callers to learn a second mutation
verb. Add `source = @source` to Update's SET clause, mutate
Source/UpdatedAt on the receiver, and call Update at the three
promotion sites (worker merge loop, worker adoption loop, and
reportDetectedTracker). The shouldPromoteSource gate still ranks the
candidate against the loaded value; Update is now the single write
path that can advance source, with a doc comment spelling out the
load-first contract.

Re-cast the coredata tests around Update: WritesSource pins the
round-trip from receiver to DB, NotFoundForMissingRow preserves the
ErrResourceNotFound contract callers rely on. The old
OnlyTouchesSourceAndUpdatedAt test was a property of the narrow
PromoteSource UPDATE and no longer applies — Update intentionally
rewrites the full editable column set from the receiver.

Signed-off-by: Émile Ré <emile@probo.com>
2026-05-26 18:06:55 +02:00

252 lines
7.7 KiB
Go

// 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 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 Test Banner",
Origin: "https://tracker-pattern-test.example.com",
State: coredata.CookieBannerStateActive,
CookiePolicyURL: "https://tracker-pattern-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
}
// TestTrackerPattern_Update_WritesSource pins the source-promotion
// path now folded into Update: load the row, bump Source, call
// Update, and verify the new value lands in the DB. This is the
// invariant the pattern-analysis worker and reportDetectedTracker
// rely on when promoting PRE_EXISTING → SCRIPT/EXTENSION; if Update
// stops writing `source`, every "ratchet the signal" call site
// silently regresses without the wrapping `shouldPromoteSource`
// gate noticing.
func TestTrackerPattern_Update_WritesSource(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)
newSource := coredata.CookieSourceScript
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
var loaded coredata.TrackerPattern
if err := loaded.LoadByID(ctx, tx, fx.scope, tp.ID); err != nil {
return err
}
loaded.Source = &newSource
loaded.UpdatedAt = bumpedAt
return loaded.Update(ctx, tx, fx.scope)
}))
reloaded := &coredata.TrackerPattern{}
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
return reloaded.LoadByID(ctx, conn, fx.scope, tp.ID)
}))
require.NotNil(t, reloaded.Source)
assert.Equal(t, coredata.CookieSourceScript, *reloaded.Source, "DB row must reflect the new source")
assert.True(t, reloaded.UpdatedAt.Equal(bumpedAt), "DB row must reflect the new updated_at")
}
// TestTrackerPattern_Update_NotFoundForMissingRow pins the
// ErrResourceNotFound contract: callers like the worker and
// reportDetectedTracker assume an unmatched UPDATE surfaces as
// ErrResourceNotFound so they can distinguish "row vanished mid-txn"
// from arbitrary pg errors.
func TestTrackerPattern_Update_NotFoundForMissingRow(t *testing.T) {
t.Parallel()
client := newTestPgClient(t)
ctx := context.Background()
fx := seedTrackerPatternFixture(t, ctx, client)
maxAge := 3600
source := coredata.CookieSourceScript
now := time.Now().UTC().Truncate(time.Microsecond)
tp := &coredata.TrackerPattern{
ID: gid.New(fx.scope.GetTenantID(), coredata.TrackerPatternEntityType),
OrganizationID: fx.organizationID,
CookieBannerID: fx.cookieBannerID,
CookieCategoryID: fx.cookieCategoryID,
TrackerType: coredata.TrackerTypeCookie,
Pattern: "*_ghost",
MatchType: coredata.TrackerPatternMatchTypeGlob,
DisplayName: "*_ghost",
MaxAgeSeconds: &maxAge,
Source: &source,
CreatedAt: now,
UpdatedAt: now,
}
err := client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
return tp.Update(ctx, tx, fx.scope)
})
assert.ErrorIs(t, err, coredata.ErrResourceNotFound)
}